Spring Security Exception Handling - Filter 단 예외 처리하기
오늘은 Spring Security 를 적용했지만 JWT 가 만료되거나, 잘못된 토큰일 경우 401 코드 뿐만아니라 에러 메세지까지 핸들링 해줄 수 있도록 설정해 주고자 합니다.
1. Spring Security 와 @ControllerAdvice
먼저 상황은 아래와 같습니다. (깔끔하져?)
사이드 프로젝트 진행중인데, 바빠서 이부분까지 신경을 못 써주고 당연히 메세지까지 전달해주겠지~ 했지만 아니였습니다.
일단 저는 Spring Boot 에서 에러 핸들링을 @ControllerAdvice 를 이용한 AOP 방식으로 처리해주었는데요
📌Spring Security 는 Spring Context 의 바깥 쪽, 즉 Filter 단에서 Servlet 에 전달되기 전에 처리됩니다.
@ControllerAdvice 가 핸들링하는 에러는 Context 내부에서 던져진 Exception이 Controller까지 타고와야 핸들링이 가능해집니다. (https://thalals.tistory.com/272)
하지만 Security는 Context 외부 filter 에서 인증가 인가를 판단해주니, filter 내부에서의 Exception 처리를 해주는 환경을 별도로 구성해 주어야합니다.
2. Filter Exception Handling 과정
그렇다면 filter 에서 핸들링하는 과정자체는 스프링 컨텍스트 내부에서 핸들링해주는거와 유사하게 동작하게 하면됩니다.
- A필터에서 에러(Exception)를 던진다.
- A필터보다 앞에있는 B 필터에서 Exception이 체크해주고 핸들링 해준다.
- @ControllerAdvice 에서 Response Body 에 핸들링한 에러 메세지를 담는거처럼 Exception 내용을 Response 에 추가해준다.
- Response 는 필터들을 거쳐 클라이언트에게 전달된다.
코드로 보시죠!
SecurityConfiguration
- 기존의 Spring Security 설정 코드 입니다.
- 자세한 코드는 해당 포스팅을 확인해주세요. 거의 유사합니다. → Spring Security 가이드 (with. Spring boot 3.0)
- 간단하게 설명하자면, 그냥 Custom 하게 만들어준 JWT Filter 내부에 유효성을 검사하고 Exception 을 throw 해주는 로직입니다.
- 📌 이렇게 되면 던져진 Exception 이 던져지기 때문에 해당 Filter 를 벗어나게 됩니다. 그렇기 때문에 401 코드만 반환해주고 에러 메세지는 비게 되는거죠
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtFilter jwtFilter;
private static final String[] PERMIT_URL_ARRAY = {
/* 허용해줄 url */
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf().disable();
return httpSecurity
.authorizeHttpRequests(
authorize -> authorize
.requestMatchers(PERMIT_URL_ARRAY).permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
)
.httpBasic(Customizer.withDefaults())
.build();
}
}
//JwtFilter 내부 Token 유효성 검사 로직
public 핉터 {
..생략
public boolean validateToken(final String token) {
try {
Jwts.parserBuilder().setSigningKey(jwtSecretKey).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
LogWriteUtils.logInfo(String.format("exception : %s, message : 잘못된 JWT 서명입니다.", e.getClass().getName()));
throw new TokenNotValidateException("잘못된 JWT 서명입니다.", e);
} catch (ExpiredJwtException e) {
LogWriteUtils.logInfo(String.format("exception : %s, message : 만료된 JWT 토큰입니다.", e.getClass().getName()));
throw new TokenNotValidateException("만료된 JWT 토큰입니다.", e);
} catch (UnsupportedJwtException e) {
LogWriteUtils.logInfo(String.format("exception : %s, message : 지원되지 않는 JWT 토큰입니다.", e.getClass().getName()));
throw new TokenNotValidateException("지원되지 않는 JWT 토큰입니다.", e);
} catch (IllegalArgumentException e) {
LogWriteUtils.logInfo(String.format("exception : %s, message : JWT 토큰이 잘못되었습니다.", e.getClass().getName()));
throw new TokenNotValidateException("JWT 토큰이 잘못되었습니다.", e);
}
}
}
public class TokenNotValidateException extends JwtException {
public TokenNotValidateException(String message) {
super(message);
}
public TokenNotValidateException(String message, Throwable cause) {
super(message, cause);
}
}
↓
✔️ SecurityConfiguration 리팩토링
- JWT Filter 앞에 Response 를 반환해주기 전 Exception Handler Filter를 위치시켜줍니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtFilter jwtFilter;
private final ExceptionHandlerFilter exceptionHandlerFilter;
private static final String[] PERMIT_URL_ARRAY = {
..생략
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf().disable();
return httpSecurity
.authorizeHttpRequests(
authorize -> authorize
.requestMatchers(PERMIT_URL_ARRAY).permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(exceptionHandlerFilter, JwtFilter.class)
)
.httpBasic(Customizer.withDefaults())
.build();
}
}
✔️ ExceptionHandlerFilter
- 기존의 Filter 에서 만약 핸들링하고자 하는 Exception이 던져졌다면 try-catch 로 핸들링 해줍니다.
- 스프링 컨텍스트 내부 처리처럼 단순하게 생각해서, 응답값에 넣고싶은 메세지를 넣어줄 겁니다.
- getWirter() 함수는 문자 텍스트를 응답값에 담을 수 있는 PrintWriter 객체를 반환하고, PrintWriter 에는 write 메소드로 문자열 내용을 담을 수 있습니다.
- 즉, Class 객체를 String 으로 변환시켜야합니다. Json 형식이면 좋으므로 ObjectMapper를 사용해 주었습니다.
@Component
public class ExceptionHandlerFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (TokenNotValidateException ex) {
setErrorResponse(HttpStatus.UNAUTHORIZED, request, response, ex);
}
}
public void setErrorResponse(HttpStatus status, HttpServletRequest request,
HttpServletResponse response, Throwable ex) throws IOException {
response.setStatus(status.value());
response.setContentType("application/json; charset=UTF-8");
response.getWriter().write(
ErrorResponse.of(
HttpStatus.UNAUTHORIZED,
ex.getMessage(),
request
)
.convertToJson()
);
}
}
@Getter
@ToString
public class ErrorResponse {
private static final ObjectMapper objectMapper = new ObjectMapper();
private final String timestamp;
private final int status;
private final String error;
private final String message;
private final String path;
//생성자 및 정적 메소드 생략
public String convertToJson() throws JsonProcessingException {
return objectMapper.writeValueAsString(this);
}
}
짜잔~ 그럼 이렇게 이쁘게 Json 타입으로 변환된 에러 응답값이 바디에 담겨서 반환됩니다~~~
ExceptionHandlerFitler 가 일종의 @RestControllerAdvce + @ExceptionHandler 의 역할을 유사하게 구현하게 되는거죠
마음에 드네요.
뭐든 명확한게 좋다고 생각합니다.
이상 끝!
====================== 🫡 Spring Security 시리즈======================
- ✔️ Spring Security 가이드 (with. Spring boot 3.0) - 스프링 시큐리티란, 동작 과정, 사용 방법, JWT 발급
- 👉 Spring Security Exception Handling - Filter 단 예외 처리하기
- ✔️ [Spring Security] 존재하지 않는 API 호출 시 404 대신 401 or 403 을 반환할 때
- ✔️ @AuthenticationPrincipal 유닛 테스트 - Custom Mock User 삽입하기