현재 개발 중인 프로젝트에서 Spring Security와 JWT를 이용해 로그인 로직을 구현하고 있다. 구현 중 인증(Authentication) 예외와 JWT 예외 처리를 따로 분리하기 위한 과정을 정리해 보았다.
🔙 이전 진행사항
[Spring Security] SecurityConfig permitAll() 적용 안 되는 이유
Spring Security를 도입하면 SecurityConfig 클래스 파일에서 웹 애플리케이션의 인증(Authentication) 및 권한 부여(Authorization) 메커니즘을 설정하고 관리하게 된다. 🌱 SecurityConfig.class@RequiredArgsConstructor@Con
chaewsscode.tistory.com
[Spring Security] OncePerRequestFilter.shouldNotFilter 잘 사용하기
현재 개발 중인 프로젝트에서 Spring Security와 JWT를 이용해 로그인 로직을 구현하고 있다. 구현 중 SecurityConfig의 permitAll() 메서드와 JwtAuthenticationFilter의 shoudNotFilter() 메서드의 효율적인 관리를 위
chaewsscode.tistory.com
📌 수정 전
🌱 SecurityConfig.class
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final RequestMatcherHolder requestMatcherHolder;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF 비활성화 등 코드 생략
// authorizeHttpRequests 등 권한 코드 생략
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler));
return http.build();
}
}
🌱 JwtAuthenticationEntryPoint.class
@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())));
}
}
이 코드는 Spring Security 필터에서 Exception이 발생하면 Authentication(인증) 예외인지, Access(인가) 예외인지 원인을 확인해 처리하는 메서드이다. 인증 예외일 경우 SecurityConfig에서 등록해 두었던 JwtAuthenticationEntryPoint로 보내져 예외 응답을 처리하게 된다.
이 코드에서 내가 생각했던 문제점은 아래와 같이 응답의 data가 어느 경우에서든 다 똑같은 것이었다.
접근 권한이 없을 경우, JWT 예외인 경우 모두 통틀어서 InsufficientAuthenticationException이 발생해 해당 예외가 어떤 이유로 발생했는지 정확한 이유를 알 수가 없었다. 그리고 JWT 예외의 경우 JWT 필터에서 유효성을 검증하는 것에서 끝나지만, 이 경우 JWT 토큰의 문제점이 발견된 후에도 doFilter 메서드를 통해 다음 필터를 수행해 인증 로직에 더 깊게 들어가게 된다는 문제점도 가지게 된다.
이러한 문제점으로 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 (JwtException ex) {
setResponse(response, ex);
}
}
public void setResponse(HttpServletResponse response, JwtException ex) throws IOException {
response.setStatus(SC_UNAUTHORIZED);
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(
ResponseData.of(INVALID_TOKEN, ex.getMessage())));
}
}
🌲 SecurityConfig.class
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF 비활성화 등 코드 생략
// authorizeHttpRequests 등 권한 코드 생략
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionFilter, jwtAuthenticationFilter.getClass()) // JWT Exception 처리 필터 추가
.exceptionHandling(exceptionHandling -> exceptionHandling
.accessDeniedHandler(jwtAccessDeniedHandler));
return http.build();
}
JwtExceptionFilter를 생성한 후 SecurityConfig에 등록해 주고 addFilterBefore 메서드를 통해 아래 순서대로 필터가 실행되도록 설정한다.
1. JwtExceptionFilter
2. JwtAuthenticationFilter
3. UsernamePasswordAuthenticationFilter
유효기간이 만료된 토큰으로 API 호출을 할 경우 JwtExceptionFilter의 doFilter 메서드를 통해 JwtAuthenticationFilter 필터로 처리가 넘어간다.
🌲 JwtAuthenticationFilter.class
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String accessToken = getAccessTokenFromHeader(request);
Claims claims = tokenProvider.validateToken(accessToken); // 토큰 검증 부분
AccessTokenPayload accessTokenPayload = tokenProvider.createAccessTokenPayload(claims);
Authentication authentication = tokenProvider.getAuthentication(accessTokenPayload.email(),
accessTokenPayload.role().name());
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
private String getAccessTokenFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(TOKEN_TYPE)) {
return bearerToken.substring(7);
}
throw new JwtException("토큰이 없습니다");
}
해당 필터의 doFilterInternal 메서드의 validateToken에서 JWT에 대한 추출과 검증이 이루어지게 된다.
🌲 TokenProvider.class
/**
* JWT Claims 추출 및 검증
*
* @param token
* @return JWT Claims
*/
public Claims validateToken(String token) {
try {
return Jwts.parser()
.verifyWith(secretKey).build()
.parseSignedClaims(token)
.getPayload();
} catch (SecurityException e) {
throw new JwtException("잘못된 JWT 시그니처입니다");
} catch (MalformedJwtException e) {
throw new JwtException("유효하지 않은 JWT 토큰입니다");
} catch (ExpiredJwtException e) {
throw new JwtException("만료된 JWT 토큰입니다");
} catch (UnsupportedJwtException e) {
throw new JwtException("지원되지 않는 JWT 토큰입니다");
} catch (IllegalArgumentException e) {
throw new JwtException("JWT 토큰이 잘못되었습니다");
}
}
토큰을 추출할 때 JWTException을 throw 하고 각 예외에 해당하는 메세지를 적으면 JWTExceptionFilter에서 JWTException을 catch 해 setResponse 메서드를 통해 응답을 반환해 줄 때 해당하는 Exception에 맞는 정보가 반환되는 것을 확인할 수 있다.
📚 참고
'Back-end > TroubldShooting' 카테고리의 다른 글
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 |