티스토리 뷰

Spring Security에서 인증 메커니즘을 구현하기 위해 JWT 필터를 사용했습니다.

이 필터는 Exception Handler로 처리를 못하기 때문에 별도의 메커니즘이 필요합니다 ! 

왜 그럴까?
JWT 필터는 OncePerRequestFilter를 상속받아 매 요청마다 한 번씩 실행됩니다. 이 필터는 HTTP 요청 헤더에 포함된 JWT를 해석하고, 유효한 토큰인 경우 Security Context에 인증 정보를 설정합니다. 또한 이 필터는 Dispatcher Servlet 보다 앞단에 위치하여, Handler Interceptor는 뒷단에 존재하기 때문에, Filter에서 보낸 예외는 Exception Handler로 처리를 못합니다. 따라서, JWT 필터에서 예외가 발생할 경우 이를 적절히 처리하기 위한 별도의 메커니즘이 필요합니다.

 

이를 위해, JwtAuthenticationEntryPoint 클래스를 사용하여 인증되지 않은 사용자가 보호된 리소스에 액세스할 때 발생하는 예외를 처리합니다. 요청 객체의 속성(request.getAttribute("exception"))을 이용하여 발생한 예외를 구분하고, 적절한 응답을 클라이언트에 전달합니다.

예를 들어, 토큰이 만료되었거나, 잘못된 타입의 토큰인 경우, 추가 정보가 필요한 토큰인 경우 등 다양한 예외 상황을 구분하여 처리할 수 있습니다.

 

1. SecurityConfig.java

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/resource/**", "/css/**", "/js/**", "/img/**", "/lib/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()   //CSRF 보호 비활성화
                .formLogin().disable()  //폼 로그인 비활성화
                .httpBasic().disable()  // HTTP 기본 인증 비활성화
                .exceptionHandling()    //예외 처리 설정
                .authenticationEntryPoint(jwtAuthenticationEntryPoint) //인증되지 않은 사용자가 보호된 리소스에 액세스 할 때 호출되는 JwtAuthenticationEntryPoint 설정
                .accessDeniedHandler(jwtAccessDeniedHandler)    //권한이 없는 사용자가 보호된 리소스에 액세스 할 때 호출되는 JwtAccessDeniedHandler 설정
                .and()
                .headers().frameOptions().sameOrigin()
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //Spring Security에서 세션을 사용하지 않도록 설정
                .and()
                .authorizeRequests()
                .antMatchers("/swagger-resources/**").permitAll()
                .antMatchers("/swagger-ui/**").permitAll()
                .antMatchers("/webjars/**").permitAll()
                .antMatchers("/v3/api-docs").permitAll()
                .antMatchers("/api/v1/users/**").permitAll()
                .antMatchers("/oauth/kakao/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .apply(new JwtSecurityConfig(tokenProvider));
    }
}

 

Spring Security의 설정을 담당하는 클래스로, WebSecurityConfigureAdapter를 상속받아 웹 보안 설정을 재정의합니다. 
.authenticationEntryPoint(jwtAuthenticationEntryPoint): 인증되지 않은 사용자가 보호된 리소스(authenticated())에 액세스 할 때 호출되는JwtAuthenticationEntryPoint 를 설정합니다.  

.accessDeniedHandler(jwtAccessDeniedHandler) : 권한이 없는 사용자가 보호된 리소스(authenticated())에 액세스 할 때 호출되는 JwtAccessDeniedHandler 를 설정합니다.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) : Jwt 토큰 기반의 인증에서는 세션을 필요로 하지 않기 때문에 세션을 사용하지 않도록 설정합니다. 
apply(new JwtSecurityConfig(tokenProvider)): JWT 보안 설정을 적용합니다.

이러한 설정을 통해 웹 애플리케이션은 JWT 토큰 기반의 인증을 사용하여 보안을 유지하게 됩니다. 사용자의 요청에 따라 적절한 인증 과정을 거치고, 인증되지 않은 사용자나 권한이 없는 사용자는 접근이 차단되도록 설정됩니다. 이는 사용자의 데이터를 보호하고, 악의적인 요청으로부터 시스템을 보호하는 데 중요한 역할을 합니다.

 

2. JwtFilter

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    private final TokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException, JwtException {
        String jwt = resolveToken(request);
        String requestURI = request.getRequestURI();
        try {
            if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
                boolean isAdditionalInfoProvided = tokenProvider.getAdditionalInfoProvided(jwt);
                if (isAdditionalInfoProvided) {
                    Authentication authentication = tokenProvider.getAuthentication(jwt);
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                    log.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
                } else {
                    throw new AdditionalInfoException();
                }
            }
        }
        catch(AdditionalInfoException e){
            request.setAttribute("exception", JwtConstants.JWTExceptionList.ADDITIONAL_REQUIRED_TOKEN.getErrorCode());
        } catch (SecurityException | MalformedException e) {
            request.setAttribute("exception", JwtConstants.JWTExceptionList.MAL_FORMED_TOKEN.getErrorCode());
        } catch (ExpiredException e) {
            request.setAttribute("exception", JwtConstants.JWTExceptionList.EXPIRED_TOKEN.getErrorCode());
        } catch (UnsupportedException e) {
            request.setAttribute("exception", JwtConstants.JWTExceptionList.UNSUPPORTED_TOKEN.getErrorCode());
        } catch (IllegalException e) {
            request.setAttribute("exception", JwtConstants.JWTExceptionList.ILLEGAL_TOKEN.getErrorCode());
        } catch (Exception e) {
            log.error("================================================");
            log.error("JwtFilter - doFilterInternal() 오류발생");
            log.error("token : {}", jwt);
            log.error("Exception Message : {}", e.getMessage());
            log.error("Exception StackTrace : {");
            e.printStackTrace();
            log.error("}");
            log.error("================================================");
            request.setAttribute("exception", JwtConstants.JWTExceptionList.UNKNOWN_ERROR.getErrorCode());
        }
        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

JWT 필터는 OncePerRequestFilter를 상속받아 매 요청마다 실행됩니다. 이 필터의 주 역할은 HTTP 요청 헤더에서 JWT를 추출하고 이를 검증하는 것입니다.

doFilterInternal 메소드에서는 먼저 HTTP 요청 헤더에서 JWT를 추출하고, 이를 검증합니다. JWT가 유효하고 추가 정보가 제공된 경우, 인증 정보를 Security Context에 설정합니다.

만약 JWT 검증 과정에서 예외가 발생하면, 해당 예외의 유형에 따라 요청 객체의 속성(request.setAttribute("exception", ...))에 에러 코드를 설정합니다. 이후 이 에러 코드를 이용해 JwtAuthenticationEntryPoint에서 적절한 응답을 만들어내게 됩니다.

 

3. 인증 실패 시 예외 처리

@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        String exception = String.valueOf(request.getAttribute("exception"));

        if(exception.equals(JWTExceptionList.ADDITIONAL_REQUIRED_TOKEN.getErrorCode()))
            setResponse(response, JWTExceptionList.ADDITIONAL_REQUIRED_TOKEN);
        //잘못된 타입의 토큰인 경우
        else if(exception.equals(JWTExceptionList.UNKNOWN_ERROR.getErrorCode()))
            setResponse(response, JWTExceptionList.UNKNOWN_ERROR);

        else if(exception.equals(JWTExceptionList.MAL_FORMED_TOKEN.getErrorCode()))
            setResponse(response, JWTExceptionList.MAL_FORMED_TOKEN);

        else if(exception.equals(JWTExceptionList.ILLEGAL_TOKEN.getErrorCode()))
            setResponse(response, JWTExceptionList.ILLEGAL_TOKEN);

        //토큰 만료된 경우
        else if(exception.equals(JWTExceptionList.EXPIRED_TOKEN.getErrorCode()))
            setResponse(response, JWTExceptionList.EXPIRED_TOKEN);
        //지원되지 않는 토큰인 경우
        else if(exception.equals(JWTExceptionList.UNSUPPORTED_TOKEN.getErrorCode()))
            setResponse(response, JWTExceptionList.UNSUPPORTED_TOKEN);

        else setResponse(response, JWTExceptionList.ACCESS_DENIED);

    }

    private void setResponse(HttpServletResponse response, JWTExceptionList exceptionCode) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        JSONObject responseJson = new JSONObject();
        responseJson.put("timestamp", LocalDateTime.now().withNano(0).toString());
        responseJson.put("message", exceptionCode.getMessage());
        responseJson.put("errorCode", exceptionCode.getErrorCode());

        response.getWriter().print(responseJson);
    }
}

JwtAuthenticationEntryPoint 클래스인증되지 않은 사용자가 보호된 리소스에 액세스할 때 호출됩니다. 

여기서 commence 메소드에서는 요청 객체의 속성에서 에러 코드를 추출하고, 이를 이용해 적절한 응답을 만들어냅니다.

각각의 예외 유형에 따라 다른 응답을 만들어내는데, 예를 들어 토큰이 만료되었거나, 잘못된 타입의 토큰인 경우 등에 대한 처리가 이루어집니다. 

 

4. 접근 거부 시 예외 처리

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        ErrorResponse errorResponse = new ErrorResponse("403", "접근이 금지되었습니다. 권한이 없는 사용자가 접근하려고 했습니다.");
        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(response.getWriter(), errorResponse);
    }
}

JwtAccessDeniedHandler 클래스권한이 없는 사용자가 보호된 리소스에 액세스할 때 호출됩니다.

이 클래스는 AccessDeniedHandler 인터페이스를 구현하며, handle 메소드에서는 접근 거부에 대한 응답을 만들어냅니다.

이 세 가지 클래스를 통해 JWT 필터에서 발생할 수 있는 다양한 예외 상황을 적절히 처리할 수 있으며 클라이언트는 더 정확한 에러 정보를 받을 수 있게 됩니다!

 

5. JwtSecurityConfig

@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private final TokenProvider tokenProvider;


    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        //UsernamePasswordAuthenticationFilter 앞에 필터로 JwtFilter 추가
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

마지막으로 JwtSecurityConfig에서 UesrnamePasswordAuthenticationFilter앞에 우리의 JwtFilter를  추가해줍니다.  

 

==== 0806 수정 =====

 

🧨 인증이 필요하지 않은 경로 설정 주의

Spring Security 설정에서는 HttpSecurity 객체를 사용하여 보호되어야 하는 경로를 지정하고, 인증되지 않은 사용자가 이러한 경로에 접근하려고 할 때 JwtAuthenticationEntryPoint 를 호출하도록 설정했습니다.

 

그러나 /oauth/kakao/ 경로는 인증이 필요하지 않도록 설정되어 있기 때문에, 이러한 경로로의 요청은 JwtAuthenticationEntryPoint.commence() 호출 없이 진행됩니다.

Spring Security에서는 HttpSecurity 객체를 통해 인증이 필요한 경로.anyRequest().authenticated()와 인증이 필요하지 않은 경로.antMatchers("/oauth/kakao/****").permitAll()를 구분하여 설정할 수 있습니다.

이렇게 설정된 규칙에 따라, 인증이 필요하지 않은 경로로 들어오는 요청에 대해서는 JwtAuthenticationEntryPoint가 호출되지 않습니다. 이는 Spring Security가 인증이 필요한 요청과 그렇지 않은 요청을 구분하여 처리하기 때문입니다.

 

너무나도 당연한 사실이지만, 이 점을 놓친다면 토큰이 만료되어서 토큰 만료 관련 에러가 안나오고 단순히 서버에러만 출력이 됩니다.