ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Firebase 에서 OAuth2 , JWT 전환기(4) : 예외처리
    Project/TravelFeelDog 2023. 10. 14. 18:55

    지금까지 FireBase 를 사용한 시나리오에서 OAuth2 , JWT를 사용한 시나리오로 변경한 것은  다음과 같습니다.

     

    1. 사용자는 구글 로그인 버튼이 있는 홈페이지에 접속합니다.

    - 테스트를 위한 홈페이지로 RESTful API 를 지원하는 Spring 서버 어플리케이션을 만드는 것을 목표로 합니다.

     

    2. 사용자는 구글 로그인을 통한 인증을 진행 합니다.

    - 인증이 완료가 되면 리소스 서버(Spring-MySQl)에 GUEST 로 저장이 됩니다.

     

    3. 구글 로그인을 통한 인증이 완료된 사용자는 추가적인 API 를 호출하여 회원가입을 진행 합니다.

    - 회원 가입이 완료가 되면에 USER 로 저장이 됩니다.

     

     

    다음 필요한 작업입니다.

     

     1.  구글 로그인을 하는 유저에게 토큰을 갱신 해주는 기능이 필요합니다.

     

       - 지금은 리소스 서버의 API 를 호출한 회원가입시에만 토큰을 갱신합니다.

     

    2. 예외 처리를 해줍니다.

     

     

    1. 구글 로그인을 하는 유저에게 토큰을 갱신 해주는 기능을 추가합니다.

     

    AuthenticationSuccessHandler 인터페이스를 상속한 클라스를 제작하고 컴포넌트로 등록을 하는 방법이 있습니다.

    public interface AuthenticationSuccessHandler {
    
    	/**
    	 * Called when a user has been successfully authenticated.
    	 * @param request the request which caused the successful authentication
    	 * @param response the response
    	 * @param chain the {@link FilterChain} which can be used to proceed other filters in
    	 * the chain
    	 * @param authentication the <tt>Authentication</tt> object which was created during
    	 * the authentication process.
    	 * @since 5.2.0
    	 */
    	default void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
    			Authentication authentication) throws IOException, ServletException {
    		onAuthenticationSuccess(request, response, authentication);
    		chain.doFilter(request, response);
    	}

     

     

    위의 인터페이스의 경우 성공적인 인증(Authentication)의 관한 시나리오를 따로 처리가 가능하며 ,  유저가 Guest 인 경우 회원가입 페이지로  리다이렉션을 하는경우 같은 케이스일때 사용합니다.

     

    웹과 앱을 동시에 지원을 해야하는 프로젝트 목표상 회원가입 페이지를 리다이렉션을 하지 않기 때문에 사용하지는 않겠습니다.

     

     tokenUpdateCheck 이라는 메서드를 추가하여 로그인시 토큰을 업데이트 해주는 기능을 추가합니다.

    public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    
        private final MemberRepository memberRepository;
        private final JwtService jwtService;
    
        @Override
        public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
            OAuth2UserService delegate = new DefaultOAuth2UserService();
            OAuth2User oAuth2User = delegate.loadUser(userRequest);
    
            String registrationId = userRequest.getClientRegistration().getRegistrationId();
            String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                    .getUserInfoEndpoint().getUserNameAttributeName();
    
            OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName,
                    oAuth2User.getAttributes());
            Member member = getByEmail(attributes);
            tokenUpdateCheck(member);
            saveOrUpdate(member);
            return new DefaultOAuth2User(
                    Collections.singleton(new SimpleGrantedAuthority(member.getRoleKey())),
                    attributes.getAttributes(),
                    attributes.getNameAttributeKey());
        }
    
        private void tokenUpdateCheck(Member member) {
            if (member.getRole() != Role.GUEST) {
                TokenResponse token = new TokenResponse(member.getAccessToken(),
                        member.getRefreshToken());
                token = jwtService.updateToken(token, member.getEmail());
                member.updateToken(token.getAccessToken(), token.getRefreshToken());
            }
        }

     

    jwtService 코드는 JWT Provider 를 호출합니다.

    public TokenResponse updateToken(TokenResponse token,String email) {
            TokenResponse newToken = jwtProvider.updateToken(token,email);
            return new TokenResponse(newToken);
        }

     

     

    JWT Provider 는 토큰의 유효성을 확인하는 메서드와 업데이트 메서드를 추가합니다.

    public boolean isTokenExpire(String token) {
            try {
                Claims claims = Jwts.parserBuilder()
                        .setSigningKey(jwtSecretKey.getKey())
                        .build()
                        .parseClaimsJws(token)
                        .getBody();
                Date expiration = claims.getExpiration();
                return expiration.before(new Date());
            } catch (ExpiredJwtException e) {
                return true;
            } catch (JwtException e) {
                throw new InvalidTokenException("Failed to check expiration status of the token", e);
            }
        }
    
        public TokenResponse updateToken(TokenResponse token, String email) {
            String atk = token.getAccessToken();
            String rtk = token.getAccessToken();
            if (isTokenExpire(atk)) {
                atk = createAccessToken(email).get(ACCESS_TOKEN_KEY);
            }
            if (isTokenExpire(rtk)) {
                rtk = createRefreshToken(email).get(ACCESS_TOKEN_KEY);
            }
            return new TokenResponse(atk, rtk);
        }

     

     

     

     

    2. 예외 처리를 해줍니다.

     

    기존의 ExceptionClass 를 만들어 @ControllerAdvice 와 @ExceptionHandler 를 사용하여 발생하는 Exception에 맞게 Response 를 반환하였습니다.

     

    ex)

    @RestControllerAdvice
    public class ExceptionController {
    
        @ExceptionHandler(Exception.class)
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        public ApiResponse ServerException2(Exception e) {
            e.printStackTrace();
            return ApiResponse.error("서버 로직 에러",e.getMessage()); // 500
        }
    
        @ExceptionHandler(MissingRequestHeaderException.class)
        @ResponseStatus(HttpStatus.BAD_REQUEST)
        public ApiResponse MissingRequestHeaderException(Exception e) {
            e.printStackTrace();
            return ApiResponse.error("MissingRequestHeaderException", e.getMessage()); // 400
        }
        @ExceptionHandler(MissingPathVariableException.class)
        @ResponseStatus(HttpStatus.NOT_FOUND)
        public ApiResponse MissingPathVariableException(Exception e) {
            e.printStackTrace();
            return ApiResponse.error("잘못된 경로 입니다.", e.getMessage()); // 404
        }

     

    그러나 JWTFilter 의 경우  DispatcherServlet 이전에서 예외처리가 되어 나가기 때문에 만들어둔 ExceptionClass 클라스를 사용할지에 선택사항이 있습니다.

     

    1. Filter 에서 예외가 발생하여 응답값을 컨트롤러 단을 거치지 않고 반환하는 기능을 구현합니다.

     

    2.Filter 에서 예외가 발생하여 응답값을 만들어둔 ControllerAdvice를 거쳐 반환하는 기능을 구현합니다.

     

    2 번의 방식이 1번의 방식보다 예외 발생시 응답값을 반환하는 것이 구조상 느리겠지만,

    예외처리와 관련된 코드들을 응집하는 장점이 있습니다. 

     

    JWT 를 검증하는 필터에 다음과 같이 try catch 를 추가하고  HttpServletRequest로 받은 request 에 Attribute 를 추가하겠습니다.

     

    기존 :

    @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                FilterChain filterChain) throws ServletException, IOException {
            if (request.getRequestURI().contains("/api/v1")) {
                final String token = request.getHeader(AUTHORIZATION_HEADER);
                jwtService.validateToken(token);
                Member member = jwtService.findMemberByToken(token);
                saveAuthentication(member);
            }
            filterChain.doFilter(request, response);
        }

     

    변경 후 :

    @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                FilterChain filterChain) throws ServletException, IOException {
            if (request.getRequestURI().contains("/api/v1")) {
                final String token = request.getHeader(AUTHORIZATION_HEADER);
                try{
                    jwtService.validateToken(token);
                    Member member = jwtService.findMemberByToken(token);
                    saveAuthentication(member);
                }catch (InvalidTokenException e){
                    log.error("[ERROR] : "+e);
                    request.setAttribute("exception", e);
                }
            }
            filterChain.doFilter(request, response);
        }

     

     

    예외가 발생한 경우 처리해줄 CustomAuthenticationEntryPoint

    @Component
    public class CustomAuthenticationEntryPoint  implements AuthenticationEntryPoint {
        private final HandlerExceptionResolver resolver;
        public CustomAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
            this.resolver = resolver;
        }
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {
            resolver.resolveException(request, response, null, (Exception) request.getAttribute("exception"));
        }
    }

     

     

     

    JWT 의 exceptionHandling 추가는 다음과 같습니다.

     

    Spring 6.2 이상 버전입니다.

    @Configuration
    @EnableMethodSecurity
    @EnableWebSecurity
    @RequiredArgsConstructor
    public class SecurityConfig {
    
        private final CustomOAuth2UserService customOAuth2UserService;
        private final JwtService jwtService;
        private final AuthenticationEntryPoint authenticationEntryPoint;
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
            httpSecurity
                    .csrf(
                            AbstractHttpConfigurer::disable
                    ).cors(
                            AbstractHttpConfigurer::disable
                    )
                    .logout(withDefaults());
            httpSecurity.oauth2Login(request -> request.userInfoEndpoint(
                            userInfoEndpointConfig -> userInfoEndpointConfig.userService(
                                    customOAuth2UserService)));
            httpSecurity.addFilterAfter(new JwtFilter(jwtService), LogoutFilter.class);
            httpSecurity.exceptionHandling((exception)-> exception.authenticationEntryPoint(authenticationEntryPoint));
    
            return httpSecurity.build();
        }
    }

     

    Filter 가 아닌 컨트롤러단 즉 디스패터 서블릿 이후에서 발생할 에러를 출력할 메시지를 추가해줍니다.

     

    @RestControllerAdvice
    public class ExceptionController {
    
    	@ExceptionHandler(InvalidTokenException.class)
        public ApiResponse JwtInvalidException(Exception e) {
            e.printStackTrace();
            return ApiResponse.error("JWT 에러 터짐",e.getMessage());
        }
        ..
        @ExceptionHandler(Exception.class)
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        public ApiResponse ServerException2(Exception e) {
            e.printStackTrace();
            return ApiResponse.error("서버 로직 에러",e.getMessage()); // 500
        }
    
        @ExceptionHandler(MissingRequestHeaderException.class)
        @ResponseStatus(HttpStatus.BAD_REQUEST)
        public ApiResponse MissingRequestHeaderException(Exception e) {
            e.printStackTrace();
            return ApiResponse.error("MissingRequestHeaderException", e.getMessage()); // 400
        }
        @ExceptionHandler(MissingPathVariableException.class)
        @ResponseStatus(HttpStatus.NOT_FOUND)
        public ApiResponse MissingPathVariableException(Exception e) {
            e.printStackTrace();
            return ApiResponse.error("잘못된 경로 입니다.", e.getMessage()); // 404
        }
        ..

     

    잘못된 서명으로 다음과 같은 컨트롤러의 코드를 호출합니다.

    @GetMapping(value="/user/{testNumber}")
        @ResponseStatus(HttpStatus.OK)
        public ResponseEntity<Long> getConnectUserTestNumber(@PathVariable Long testNumber , 
        													@LoginUser String email){
            log.info("email : {}",email);
            return ResponseEntity.ok(testNumber);
        }

     

    결과

    .

    @ControllerAdvice 에 추가한 메시지가 보입니다.

     

     

     

     로그의 출력 결과는 다음과 같습니다.

     

    t.global.auth.jwt.service.JwtProvider    : 잘못된 JWT 서명입니다.
    t.global.auth.jwt.filter.JwtFilter       : [ERROR] : travelfeeldog.global.auth.jwt.exception.InvalidTokenException: Invalid JWT signature.
    ocalVariableTableParameterNameDiscoverer : Using deprecated '-debug' fallback for parameter name resolution. Compile the affected code with '-parameters' instead or avoid its introspection: travelfeeldog.infra.api.ConnectionCheckApi
    t.g.auth.secure.AuthorizationExtractor   : Header Value: '!!eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJrcmlzY2hvMTIwNEBnbWFpbC5jb20iLCJpYXQiOjE2OTcyNzI1NTIsImV4cCIY5NzMwODU1Mn0.FJmVO123I8gk3l8xoX68B6ZqVqKgEiZYJIB-c8zNMlQ'
    t.g.a.secure.LoginUserArgumentResolver   : token from Login User : !!eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJrcmlzY2hvMTIwNEBnbWFpbC5jb20iLCJpYXQiOjE2OTcyNzI1NTIsImV4cCIY5NzMwODU1Mn0.FJmVO123I8gk3l8xoX68B6ZqVqKgEiZYJIB-c8zNMlQ 
    t.global.auth.jwt.service.JwtService     : [ERROR] io.jsonwebtoken.security.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

     

    1. 잘못된 서명의 JWT Token 을 Header 에 담아 Http API 를 호출합니다 :  http://localhost:8080/api/v1/test/user/:testNumber

     

    2. JWT Filter 에서 JWT service -> Jwt Provider 로 전달한 토큰의 유효성을 확인 합니다.

    -> 에러가 처음으로 발생하는 지점이고 로그에 "JwtProvider    : 잘못된 JWT 서명입니다." 로 나옵니다.

     

    3.  JWT Filter 의 Try-Catch 를 통해 Provider 에서 발생한 예외를 감지하면  HttpServletRequest로 받은 request 에 

    "exception" 키와 InvalidExeption 클라스의 객체를 Attribute 의 저장하고 다음 필터로 전달합니다.

     

    로그는 다음과 같이 나옵니다.

     

    "JwtFilter       : [ERROR] : travelfeeldog.global.auth.jwt.exception.InvalidTokenException: Invalid JWT signature."

     

     

    4. CustomAuthenticationEntryPoint 의 commence 메서드를 거쳐 호출한 API에 맞는 getConnectUserTestNumber 메서드를 호출합니다.

     

    5. 호출한 메서드의 아규먼트인  @LoginUser  의 맞는 Resolver 를 호출합니다.

    @Override
        public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
            HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
    
            String token = AuthorizationExtractor.extract(Objects.requireNonNull(request));
            log.info("token from Login User : {} ",token);
            if (token == null || token.trim().isEmpty()) {
                return null;
            }
            return jwtService.findEmailByToken(token);
        }

    이와 관련한 로그는 다음과 같습니다.

     

    t.g.auth.secure.AuthorizationExtractor   : Header Value: '!!ey ....


    t.g.a.secure.LoginUserArgumentResolver   : token from Login User : !!eyJhb.......

     

     

    6. Resolver 에서 호출한 jwtService.findEmailByToken() 에서 Jwt 의 잘못된 서명에 맞는 에러를 발생시키고 @Controller Advice 로 예외를 전달하여 출력합니다. 

     

     

    관련 로그는 다음과 같습니다.

     

    service.JwtService     : [ERROR] io.jsonwebtoken.security.SignatureException:

     

     

    잘못된 토큰을 헤더에 넣었을때 Jwt Filter 에서 예외를 발생 시키지만 무시하고 Controller 단에 전달하여  Service code 에서 토큰의 검증에서 발생한 예외를 @ControllerAdvice 를 통해 출력하는 기능을 완성했습니다.

     

     

    일련의 흐름을 보았을때 예외의 발생이 있는 부분에서 가급적이면 바로 처리하는 것이 좋아 보입니다.

     

     

     

     

     

     

     

     

     

    참고한 블로그 )

     

    https://colabear754.tistory.com/172

     

    [Spring Security] Spring Security 예외를 @ControllerAdvice와 @ExceptionHandler를 사용하여 전역으로 처리해보자

    이 글은 이전의 [Spring Security] Spring Security와 JWT를 사용하여 사용자 인증 구현하기(Spring Boot 3.0.0 이상)에서 진행했던 예제 프로젝트에서 이어집니다. [수정사항] 2023-05-25 : 스프링 시큐리티 설정이

    colabear754.tistory.com

     

Designed by Tistory.