[회고] Interceptor 적용기 - JWT에 담긴 사용자 정보 추출

DY_DEV·2023년 12월 1일
0

TIL

목록 보기
16/17

<<학습하는 과정이라 완벽하지 않습니다. 오류 관련 피드백 부탁드립니다!>>

게시판을 구현하는 팀프로젝트를 진행하면서 우리는 몇 가지 치명적인 실수를 범했다. 그중 하나는 인증을 위한 사용자의 정보를 PathVariable로 받은 것이다. 당시 다들 분담한 역할을 수행하는 것에 급급해 JWT 적용을 후순위로 뒀다고는 해도, 이처럼 표현계층(Controller)에 직접 전달하는 방식은 매우 번거롭고 복잡한 문제를 야기했다.

문제

  • 구현한 API들은 @PathVariable를 사용해 URI에서 변수를 Controller로 전달하거나, @RequestBody를 사용해 HTTP 요청 본문에서 사용자 정보(memberId)를 추출해 Controller로 전달하고 있다.
  • JWT를 적용해 인증, 인가를 구현하면서 1번의 문제는 모두 해결할 수 있었다.
  • 하지만 기존에 전달 받던 사용자의 정보(memberId)는 JWT에 담겨있었고, 해당 내용을 디코딩 할 때 추출해 컨트롤러에 전달해주는 로직을 작성해야 했다.

대안

  1. 필터를 통해 사용자 정보 컨트롤러로 전달하는 방법
  2. 인터셉터를 사용해 전달하는 방법

해결과정

난 당시 2번 방법을 선택했다. 우선 필터와 인터셉터의 차이를 알아보자.

필터는 특정 요청과 컨트롤러에 상관없이 전역적으로 처리해야하는 작업이나 웹 어플리케이션에 전반적으로 사용되는 기능을 구현할 때 사용되고, 인터셉터는 클라이언트의 요청과 관련된 작업에 대해 추가적인 요구사항을 만족해야할 때 적용한다. 필터는 웹 컨테이너에 의해서, 인터셉터는 스프링 컨테이너에 의해서 관리된다.
JWT에서 claims에 담긴 사용자 정보(memberId)를 추출해 controller로 전달해준다는 문제를 해결하기 위해선 필터나 인터셉터 둘 다 크게 상관없는 듯 보였다. 그래서 구현 방법을 둘 다 알아봤다. 당시 내가 찾은 구현 방법은 아래와 같다.

[1번] Filter를 이용한 사용자 정보 추출

JwtVerificationFilter: JWT를 검증하는 custom filter다.

@Slf4j
@RequiredArgsConstructor
public class JwtVerificationFilter extends OncePerRequestFilter {
    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    
    try {
            Map<String, Object> claims = verifyJws(request);
            setAuthenticationToContext(claims);
        } catch (SignatureException se) {
            request.setAttribute("exception", se);
        } catch (ExpiredJwtException ee) {
            request.setAttribute("exception", ee);
        } catch (Exception e) {
            request.setAttribute("exception", e);
        }

        filterChain.doFilter(request, response);// security filter 호출
    }
    
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        String authorization = request.getHeader("Authorization");  // Authorization header의 값을 얻은 후에

        return authorization == null || !authorization.startsWith("Bearer");  // Authorization header의 값이 null이거나 Authorization header의 값이 “Bearer”로 시작하지 않는다면 해당 Filter의 동작을 수행하지 않도록 정의
    }

    private Map<String, Object> verifyJws(HttpServletRequest request) {
        String jws = request.getHeader("Authorization").replace("Bearer ", ""); // request의 header에서 JWT를 얻고 있습니다.
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()); //JWT 서명(Signature)을 검증하기 위한 Secret Key를 얻습니다.
        Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody();   //JWT에서 Claims를 파싱 합니다. > 내부적으로 서명검증에 성공했음을 의미.

        return claims;
    }


    // claims map에서 사용자 정보 추출 후 Spring Security의 Authentication객체 생성, 이를 SecurityContextHolder에 설정
    private void setAuthenticationToContext(Map<String, Object> claims) {
        String username = (String) claims.get("username");
        List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));
        Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        log.info("MemberId in SecurityContextHolder: " + SecurityContextHolder.getContext().getAuthentication().getDetails());
    
    }

}

주목할 부분은 setAuthenticationToContext 메소드다. 필터를 통해 JWT에 담긴 Claims 값을 추출하려면 해당 위치에서 미리 생성한 DTO에 memberId를 담아 두고, Controller에서 HttpServletRequest를 매개변수로 전달받아 DTO에 담긴 memberId를 사용할 수 있다. 아래는 구현 예시 코드다.

Controller에서 MemberId 사용을 위한 DTO 생성

@Data
public class MemberIdDto {
    private long memberId;
}

JwtVerificationFilter 수정

  • JwtVerificationFilter에서 memberId를 추출하고, DTO를 추가하는 부분을 구현
// JwtVerificationFilter 클래스의 수정된 부분
private void setAuthenticationToContext(Map<String, Object> claims, HttpServletRequest request) {
    String username = (String) claims.get("username");
    List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List) claims.get("roles"));
    Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);

    // memberId 추출
    long memberId = (long) claims.get("memberId");

    // MemberIdDto 생성
    MemberIdDto memberIdDto = new MemberIdDto(memberId);

    // HttpServletRequest에 MemberIdDto를 attribute로 추가
    request.setAttribute("memberIdDto", memberIdDto);

    SecurityContextHolder.getContext().setAuthentication(authentication);
}

Controller에서 MemberIdDto 활용

@RestController
@RequestMapping("/api")
public class Controller {

    @GetMapping("/Endpoint")
    public ResponseEntity<String> endPoint(HttpServletRequest request) {
        // HttpServletRequest에서 MemberIdDto를 추출
        MemberIdDto memberIdDto = (MemberIdDto) request.getAttribute("memberIdDto");

        // memberIdDto에 담긴 사용자 정보(memberId) 가져오기 
        long memberId = memberIdDto.getMemberId();

        //  memberId를 활용한 로직 구현 부분 
       

        return ResponseEntity.ok("Your response");
    }
}

하지만 이렇게 Controller에서 HttpServletRequest를 전달 받는 것은 바람직하지 않다. HttpServletRequest는 서블릿에서 관리하기 때문에 추후 mock객체를 사용한 단위테스트가 복잡해지거나 의존성에 문제가 생길 수 있다. 그래서 인터셉터를 사용하는 방법을 선택했다.

[2번] Interceptor를 이용한 사용자 정보 추출

인터셉터는 위 그림처럼 DispatcherServlet과 Controller 사이에 위치한다. 연쇄적으로 작동하는 필터와는 달리, 인터셉터는 DispatcherServlet이 여러 인터셉터들을 관리하며 preHandle()로 핸들러 실행되기 전에 실행되는 로직, postHandle()로 핸들러가 실행된 이후에 실행되는 로직, afterCompletion()으로 핸들러 이후에 비즈니스 로직의 예외나 리소스 정리 같은 작업을 구현할 수 있다.

HandlerInterceptor를 구현한 JwtInterceptor 생성

@Slf4j
@Component
//@RequiredArgsConstructor
public class JwtInterceptor implements HandlerInterceptor {
    private final JwtTokenizer jwtTokenizer;
    private static final ThreadLocal<Long> authenicatedMemberId = new ThreadLocal<>();


    public JwtInterceptor(JwtTokenizer jwtTokenizer){
        this.jwtTokenizer = jwtTokenizer;
    }

    // request의 memberId 반환 > controller에서 사용
    public static long requestMemberId(){
        Long memberId = authenicatedMemberId.get();
        if (memberId == null) {
            throw new IllegalStateException("인증된 memberId를 찾을 수 없습니다.");
        }
        return memberId;
    }

    // 핸들러가 실행되기 전에 실행. 공통적으로 적용할 사항
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception{

        try {
            String jws = request.getHeader("Authorization").replace("Bearer", "");
            String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()); // JWT 서명(Signature)을 검증하기 위한 Secret Key를 얻습니다.
            Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody(); // JWT에서 Claims를 파싱합니다.

            Long memberId = Long.parseLong(claims.get("memberId").toString()); // NumberFormatException
            if(memberId != null) {
                authenicatedMemberId.set(memberId);
                return true;
            }
            else {
                ErrorResponder.sendErrorResponse(response, HttpStatus.UNAUTHORIZED);
                return false;
            }

        }catch (NumberFormatException | JwtException ex){ //디코딩 과정 시 문제발생하면 JwtException 발생
            authenicatedMemberId.set(null); // 로직 실현 불가능
            return false;

        }
    }

    // 핸들러가 실행된 이후에 실행되는 메서드
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception{
        authenicatedMemberId.remove(); // 검증 후 스레드 리소스 정리
    }

    // 핸들러 이후에 실행 . 비즈니스 로직의 예외, 리소스 정리에 사용
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception{

    }

}

우선 인터셉터에서는 TreadLocal을 이용해 인증된 MemberId를 저장한다. TreadLocal을 사용하면 각 스레드에서 자체적으로 데이터를 보유할 수 있기 때문에 스레드 간의 충돌을 방지하고 각 스레드에서 현재 사용자의 ID를 추적하고 유지할 수 있다.
이렇게 추출한 memberId는 requestMemberId()를 통해 각 도메인의 Controller에 import 후 사용이 가능하다.
ThreadPool 사용하여 Thread 재활용 시 이전에 저장된 ThreadLocal을 호출하게 되므로 모든 ThreadLocal 사용 후 반드시 remove로 제거해야한다.

WebMvcConfig

  • WebMvcConfigurer의 구현클래스다. addInterceptors를 구현해 인터셉터가 모든 경로에 적용될 수 있도록 설정했다. 현재는 모든 경로로 했지만 특정 경로에만 인터셉터를 적용할 수 있다.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    private final JwtInterceptor jwtInterceptor;

    public WebMvcConfig(JwtInterceptor jwtInterceptor) {
        this.jwtInterceptor = jwtInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry){
        registry.addInterceptor(jwtInterceptor)
                .addPathPatterns("/**"); // 모든 경로에 interceptor 적용
//                .excludePathPatterns("/")  // 경로 설정 (이 경로는 제외)
    }
}

결과

사용자의 정보를 전달받아 비즈니스 계층에서 원하는 로직을 구현할 수 있었다. 하지만 권한 관련해서 추가적인 수정사항이 남아있다. 추후 Spring Security 관련 내용을 복습하며 해당 문제에 대해 복기할 예정이다.


참고)
https://mangkyu.tistory.com/173
https://dev-coco.tistory.com/173
https://creampuffy.tistory.com/172
https://docs.spring.io/spring-framework/docs/4.0.x/spring-framework-reference/html/overview.html
https://maivve.tistory.com/331

0개의 댓글