현재 개발 중인 프로젝트에서 Spring Security와 JWT를 이용해 로그인 로직을 구현하고 있다. 구현 중 인증(Authentication) 예외와 JWT 예외 처리를 따로 분리하기 위한 과정을 정리해 보았다.
🔙 이전 진행사항
📌 문제 상황
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final RequestMatcherHolder requestMatcherHolder;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF 비활성화 등 코드 생략
.authorizeHttpRequests(request -> request
.requestMatchers(requestMatcherHolder.getRequestMatchersByMinRole(null)).permitAll()
.requestMatchers(requestMatcherHolder.getRequestMatchersByMinRole(ASSOCIATE))
.hasAnyAuthority(ASSOCIATE.name(), ADMIN.name())
.requestMatchers(requestMatcherHolder.getRequestMatchersByMinRole(ADMIN))
.hasAnyAuthority(ADMIN.name())
.anyRequest().authenticated())
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint));
return http.build();
}
}
SecurityConfig 클래스에 따로 지정해 놓은 API가 아닌 경우 로그인을 한 사용자만 API 호출이 가능하도록 정의해 놓았다.
AuthenticationEntryPoint 클래스에서 응답을 커스터마이징 하여, 로그인이 필요한 API를 비로그인 사용자가 호출할 경우 위와 같은 응답이 반환된다.
@RequiredArgsConstructor
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(SC_UNAUTHORIZED);
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(
ResponseData.of(INVALID_TOKEN, authException.getMessage())));
}
}
AuthenticationEntryPoint를 구현해 오버라이딩한 commence 메서드의 파라미터를 보면, AuthenticationException을 받아오는 것을 확인할 수 있다.
🫧 AuthenticationException이 발생했을 때 처리 과정
- 클라이언트 요청이 필터 체인을 통과한다.
- 인증 중 AuthenticationException이 발생한다.
- ExceptionTranslationFilter가 예외를 잡아낸다.
- ExceptionTranslationFilter는 등록된 AuthenticationEntryPoint인 JwtAuthenticationEntryPoint를 호출한다.
- JwtAuthenticationEntryPoint는 사용자가 커스텀한대로 응답을 반환한다.
ExceptionTranslationFilter는 Spring Security 필터에서 Exception이 발생하면 Authentication(인증) 예외인지, Access(인가) 예외인지 원인을 확인해 처리한다. 인증 예외(AuthenticationException)일 경우 SecurityConfig에서 커스텀 필터로 등록해 두었던 JwtAuthenticationEntryPoint로 보내져 예외 응답을 처리하는 것이다.
이 코드에서 내가 생각했던 문제점은 응답의 data가 어느 경우에서든 다 똑같은 것이었다.
헤더에 임의로 수정된 토큰으로 API를 호출하면, JWT 토큰 추출 과정에서 토큰의 형식이 잘못되었음을 나타내는 MalformedJwtException이 발생한다.
하지만 이 예외가 Spring Security의 인증 과정에서 InsufficientAuthenticationException으로 변환되어 처리되는 것을 확인할 수 있다.
정리하자면, 인증 예외의 경우와 JWT 예외인 경우 모두 통틀어서 InsufficientAuthenticationException이 발생해 해당 예외가 어떤 이유로 발생했는지 정확한 이유를 알 수가 없었다.
그리고 두 번째 문제점은 JWT 토큰의 유효성을 검사하는 로직은 JWT 필터에서 유효성을 검증하는 것만으로 검증을 끝낼 수 있지만, 현재의 구현에서는 doFilter를 통해 Spring Security의 필터 체인의 다음 필터를 실행하게 된다는 것이다.
이러한 문제점으로 JWT Exception을 담당으로 처리하는 필터를 JWT 인증 필터 앞에 붙이게 되었다.
📌 수정 후
전체 로직 요약
1. JWTExceptionFilter 클래스 doFilterInternal의 doFilter 메서드를 통해 JWTAuthenticationFilter 호출
2. JWTAuthenticationFilter 내의 JWT 유효성 검사 로직에서 예외 던짐
3. JWTExceptionFilter에서 JWTException 예외 발생 시 setResponse를 호출해 커스텀 응답 반환
🌲 JwtExceptionFilter.class
@RequiredArgsConstructor
@Component
public class JwtExceptionFilter extends OncePerRequestFilter implements {
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (JwtErrorException ex) {
response.setStatus(ex.getErrorCode().getStatusCode().getCode());
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter()
.write(objectMapper.writeValueAsString(ErrorResponse.from(ex.getErrorCode())));
}
}
}
🌲 SecurityConfig.class
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF 비활성화 등 코드 생략
// authorizeHttpRequests 등 권한 코드 생략
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class) // JWT Exception 처리 필터 추가
.exceptionHandling(exceptionHandling -> exceptionHandling
.accessDeniedHandler(jwtAccessDeniedHandler));
return http.build();
}
JwtExceptionFilter를 생성한 후 SecurityConfig에 등록해 주고 addFilterBefore 메서드를 통해 아래 순서대로 필터가 실행되도록 설정한다.
1. JwtExceptionFilter
2. JwtAuthenticationFilter
3. UsernamePasswordAuthenticationFilter
🌲 JwtAuthenticationFilter.class
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String accessToken = resolveAccessToken(request);
authenticateUser(accessToken);
filterChain.doFilter(request, response);
}
private String resolveAccessToken(HttpServletRequest request) {
String authHeader = request.getHeader(AUTHORIZATION_HEADER);
return tokenProvider.extractToken(authHeader);
}
private void authenticateUser(String accessToken) {
Authentication authentication = tokenProvider.getAuthentication(accessToken); // 토큰 검증 부분
SecurityContextHolder.getContext().setAuthentication(authentication);
}
🌲 TokenProvider.class
// 헤더로부터 토큰 추출
public String extractToken(String authHeader) {
if (StringUtils.hasText(authHeader) && authHeader.startsWith(BEARER_TYPE)) {
return authHeader.substring(7);
}
throw new JwtErrorException(EMPTY_ACCESS_TOKEN);
}
// 토큰을 기반으로 사용자의 인증 정보 추출
public Authentication getAuthentication(String token) {
Claims claims = getClaimsFromToken(token);
String email = claims.getSubject();
GrantedAuthority authority = new SimpleGrantedAuthority(claims.get(ROLE_KEY).toString());
return new UsernamePasswordAuthenticationToken(email, null, List.of(authority));
}
// 토큰에서 클레임들을 추출하여 Claims 객체로 반환
public Claims getClaimsFromToken(String token) {
try {
return Jwts.parser()
.verifyWith(secretKey).build()
.parseSignedClaims(token)
.getPayload();
} catch (SignatureException e) {
throw new JwtErrorException(INVALID_SIGNATURE);
} catch (MalformedJwtException e) {
throw new JwtErrorException(MALFORMED_TOKEN);
} catch (ExpiredJwtException e) {
throw new JwtErrorException(EXPIRED_TOKEN);
} catch (UnsupportedJwtException e) {
throw new JwtErrorException(UNSUPPORTED_TOKEN);
} catch (IllegalArgumentException e) {
throw new JwtErrorException(ILLEGAL_TOKEN);
}
}
만약 잘못된 JWT 토큰을 헤더에 포함하여 API를 호출하면, JwtExceptionFilter는 이 요청을 받아 doFilter 메서드를 통해 JwtAuthenticationFilter로 전달한다. JwtAuthenticationFilter는 TokenProvider를 통해 토큰의 유효성을 검사한다. 이 과정에서 예외가 발생하면 JwtExceptionFilter는 해당 예외를 잡아 커스텀 된 응답 형식으로 에러를 반환하게 된다.
임의로 수정한 토큰으로 테스트했기 때문에 MALFORMED_TOKEN 에러가 발생하는 것을 확인할 수 있다.
📌 JwtExceptionFilter 전체 로직
- JwtExceptionFilter의 doFilterInternal에서 filterChain.doFilter()를 통해 JwtAuthenticationFilter 호출
- JwtAuthenticationFilter에서 TokenProvider를 통해 JWT 토큰 유효성 검사
- TokenProvider에서 JWT 예외 발생
- JwtAuthenticationFilter의 filterChain.doFilter()를 실행하지 않고 바로 예외 반환
- JwtExceptionFilter에서 JwtErrorException을 예외로 받아 커스텀 응답 반환
📚 참고
'Back-end > TroubleShooting' 카테고리의 다른 글
ConflictingBeanDefinitionException 그치만 중복된 빈이 없을때.. (0) | 2024.05.24 |
---|---|
[Spring] 로그인 서비스 테스트 AuthenticationManagerBuilder NPE (0) | 2024.05.14 |
[Spring Security] OncePerRequestFilter.shouldNotFilter 잘 사용하기 (0) | 2024.05.10 |
[Spring Security] SecurityConfig permitAll() 적용 안 되는 이유 (0) | 2024.05.10 |
[Spring Boot] JPA delete 후 insert 시 duplicate entry 에러 (1) | 2023.05.06 |