spring interceptor JWT Authentication 리팩터링

wellbeing-dough·2024년 5월 29일
0

1. 문제상황

  1. 우리는 jwt를 사용해서 인가를 하고 interceptor에서 한다 그래서 WebConfig.class에서
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(tokenInterceptor)
                .order(1)
                .addPathPatterns("/v1/registration-step")
                .addPathPatterns("/v1/male/career-image")
                .addPathPatterns("/v1/optional-certification")
                .addPathPatterns("/v1/payment")
                .addPathPatterns("/v1/female/career-image")
                .addPathPatterns("/v1/univ-image")
                .addPathPatterns("/v1/income-image")
                .addPathPatterns("/v1/complete/optional-certification")
                .addPathPatterns("/v1/user-image");

        registry.addInterceptor(apiThrottlingInterceptor)
                .order(2)
                .addPathPatterns("/**");
    }

이렇게 하나하나 인가가 필요한 url을 달아서 InterceptorRegistry를 사용하여 인터셉터를 추가한다

이렇게 하니까 각 패키지에 흩어져있는 controller 메서드 하나하나 WebConfig를 통해서 인가를 관리해야 한다 -> 귀찮고 유지보수 않좋고 휴먼 에러 나기 참 쉽다

@Slf4j
@RequiredArgsConstructor
@Component
public class TokenInterceptor implements HandlerInterceptor {

    private final JwtProvider jwtTokenProvider;

    @Override
    // 컨트롤러 호출전에 호출
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        if (request.getMethod().equals(HttpMethod.OPTIONS.name())) {  //preflight 통과하도록 설정
            return true;
        }
        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
        parseTokenAndTransferUserId(request, authorizationHeader);
        return true;
    }

기존에는 이렇게 구성했다

  1. jwt에서 유저아이디 꺼내오는것도

    private void parseTokenAndTransferUserId(HttpServletRequest request, String authorizationHeader) {
        HashMap<String, Object> parseJwtTokenMap = jwtTokenProvider.parseJwtToken(request ,authorizationHeader);
        Long userId = getUserIdFromToken(parseJwtTokenMap);
        request.setAttribute("userId", userId);
    }

이렇게 하고 controller에서 @RequestAttribute Long userId 를 매개변수로 받아서 사용했는데 이것도 직관적이지 않다

2. 문제 해결

  1. 모든 인터셉터를 열어두고
  2. Authenticated.class를 커스텀 어노테이션으로 만들고
  3. handlerMethod.hasMethodAnnotation(Authenticated.class)를 사용해서 컨트롤러 위에 @Authenticated 가 있으면 인가를 처리하도록 해보자
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Value("${cors.allowed.origins:}")
    private String[] ALLOWED_CORS_URLS;
    private final TokenInterceptor tokenInterceptor;
    private final AdminTokenInterceptor adminTokenInterceptor;
    private final ApiThrottlingInterceptor apiThrottlingInterceptor;
    private final UserIdentifierArgumentResolver userIdentifierArgumentResolver;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(tokenInterceptor)
                .order(1);

        registry.addInterceptor(adminTokenInterceptor)
                .order(2);

        registry.addInterceptor(apiThrottlingInterceptor)
                .order(3);
    }

이런식으로 일단 모든 인터셉터를 열어두고

@RequiredArgsConstructor
@Component
public class TokenInterceptor implements HandlerInterceptor {

    private final AuthService authService;

    @Override
    public boolean preHandle(final HttpServletRequest request,
                             final HttpServletResponse response,
                             final Object handler) {
        if (request.getMethod().equals(HttpMethod.OPTIONS.name())) {  //preflight 통과하도록 설정
            return true;
        }

        if (!(handler instanceof final HandlerMethod handlerMethod)) { // handler가 컨트롤러인지 확인, mvc에서 정적 파일 요청도 가능 -> 컨트롤러가 아니면 뒤의 로직 스킵
            return true;
        }

        if (handlerMethod.hasMethodAnnotation(Authenticated.class)) { // 컨트롤러 메서드가 인증처리가 필요한 메서드인 경우
            String token = authService.getAuthorizationToken(request.getHeader("Authorization"));
            if (!authService.isValidToken(token)) { // 토큰 유효성 검사
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 상태 코드 설정
                return false;
            }
        }

        return true;
    }

}

이런식으로 인가를 했다 토큰에서 페이로드 꺼내오는 로직은 알규먼트 리졸버에서 처리하도록 하고 일단 검증은 이렇게 했다

@Component
@RequiredArgsConstructor
public class UserIdentifierArgumentResolver implements HandlerMethodArgumentResolver {

    private final AuthService authService;

    @Override
    public boolean supportsParameter(final MethodParameter parameter) {
        if (!parameter.hasMethodAnnotation(Authenticated.class)) {
            throw new AuthenticationException(ErrorCode.INVALID_AUTHENITCATED_METHOD, ErrorCode.INVALID_AUTHENITCATED_METHOD.getStatusMessage());
        }

        return parameter.getParameterType().equals(Long.class) &&
                parameter.hasParameterAnnotation(UserIdentifier.class);
    }

    @Override
    public Long resolveArgument(final MethodParameter parameter,
                                final ModelAndViewContainer mavContainer,
                                final NativeWebRequest webRequest,
                                final WebDataBinderFactory binderFactory) {
        final String authorizationHeader = webRequest.getHeader("Authorization");
        String authorizationToken = authService.getAuthorizationToken(authorizationHeader);
        return extractUserIdFromJwtToken(authorizationToken);
    }

알규먼트 리졸버도 Authenticated.class 커스텀 어노테이션 만들어서 작성했다

@Component
@RequiredArgsConstructor
public class UserIdentifierArgumentResolver implements HandlerMethodArgumentResolver {

    private final AuthService authService;

    @Override
    public boolean supportsParameter(final MethodParameter parameter) {
        if (!parameter.hasMethodAnnotation(Authenticated.class)) {	//
            throw new AuthenticationException(ErrorCode.INVALID_AUTHENITCATED_METHOD, ErrorCode.INVALID_AUTHENITCATED_METHOD.getStatusMessage());
        }

        return parameter.getParameterType().equals(Long.class) &&
                parameter.hasParameterAnnotation(UserIdentifier.class);
    }

    @Override
    public Long resolveArgument(final MethodParameter parameter,
                                final ModelAndViewContainer mavContainer,
                                final NativeWebRequest webRequest,
                                final WebDataBinderFactory binderFactory) {
        final String authorizationHeader = webRequest.getHeader("Authorization");
        String authorizationToken = authService.getAuthorizationToken(authorizationHeader);
        return extractUserIdFromJwtToken(authorizationToken);
    }

간단하게 코드 설명하면
Spring MVC는 MethodParameter 객체를 사용하여 컨트롤러 메서드의 각 매개변수에 대한 메타데이터를 제공한다
메서드에 @Authentication 있는지 확인하고 매개변수에 @UserIdentifier가 있는지 확인한다
그렇다면 supportsParameter가 true를 return

resolveArgument에서는 이제 토큰에서 페이로드 까서 반환한다 우리는 userId를 사용한다

한가지 방법이 더있다

@Getter
@AllArgsConstructor
public final class UserId {

    @ApiModelProperty(hidden = true)
    private final Long id;

}

리졸버에다가 인가가 끝나고 꺼내온 userId를 저기다가 담아서 controller에 던져주는거다
걍 다 똑같다 resolveArgument메서드에서 반환할때 Long이 아니라 UserId라는 객체 생성에서 반환해주면 된다 사용해보니가 controller에서 userId.getId()해줘야 되기도 하고 한다해도 아직까지는 크게 효과가 좋은 것 같지 않아서 패스

3. 결과

    @Operation(summary = "어쩌고 저쩌고")
    @PostMapping(value = "/v1/test")
    @Authenticated
    public ResponseEntity<HttpStatus> uploadUserImages(@UserIdentifier Long userId) {
        webRegisterService.test(userId);
        return ResponseEntity.ok().build();
    }

운영중인 코드는 좀 그래서 간단하게 만들어봤다
이렇게 하나만 보면 큰 의미가 없어보이는데 70개 api넘어가니까 확실히 관리하기 편리하다는 생각이 들었다
그리고 이제부터 인가가 필요한 api를 짤 때 WebConfig들어가서 url매핑해주는 일을 하지 않고 그냥 컨트롤러에 어노테이션 하나 박으면 된다

물론 모든 api가 인터셉터를 한번은 타서 성능상 약간은 손해인거 같긴 하지만 우리 서비스는 80퍼센트 이상이 인가를 필요로 하는 서비스라서 어쩌피 타야된다 아주 조금의 성능과 가독성, 유지보수성을 트레이드 오프 했다

0개의 댓글

관련 채용 정보

Powered by GraphCDN, the GraphQL CDN