Spring Security에서 토큰 기반 인증 중 예외가 발생한다면 어떤 일이 일어나는지, 어떻게 핸들링 해야하는지에 대해 알아보자.
토큰 인증 방식
인증받은 사용자에게 토큰을 발급해주고,
서버에 요청을 할 때 HTTP 헤더에 토큰을 함께 보내 인증받은 사용자(유효성 검사)인지 확인한다.
Spring boot 예외처리 방식
@ControllerAdvice
와@RestControllerAdvice
를 이용해서 컴포넌트를 생성하고 예외처리 메서드를 작성해놓으면 모든 클래스에 전역적으로 적용이 가능하다.
@ExceptionHandler
을 통해 특정 컨트롤러의 예외를 처리한다.
A. 가능하다면 좋겠지만 불가능하다!
spring security와 spring boot 예외 처리구간이 다르다고 생각해보면 간단하다.
Filter
는Dispatcher Servlet
보다 앞단에 존재하며Handler Intercepter
는 뒷단에 존재하기 때문에Filter
에서 보낸 예외는Exception Handler
로 처리를 못한다.
따라서, 토큰 예외처리를 위해선 새로운 Filter를 정의해서 Filter Chain에 추가해줘야 한다.
public class SecurityConfig {
private final UserService userService;
@Value("${jwt.token.secret}")
private String secretKey;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic().disable()
.csrf().disable()
.cors().and()
.authorizeRequests()
.antMatchers("/api/v1/users/join", "/api/v1/users/login").permitAll()
.antMatchers( "/api/v1/users/list","/api/v1/users/{userId}/role/change").hasAnyRole("ADMIN")
.antMatchers(HttpMethod.GET,"/api/v1/posts/my", "/api/v1/alarms").authenticated()
.antMatchers(HttpMethod.POST, "/api/v1/**").authenticated()
.antMatchers(HttpMethod.PUT, "/api/v1/**").authenticated()
.antMatchers(HttpMethod.DELETE, "/api/v1/**").authenticated()
.and()
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPointHandler())
.accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new ExceptionHandlerFilter(), JwtTokenFilter.class)
.build();
}
addFilterBefore(Filter, beforeFilter)
beforeFilter가 실행되기 이전에 Filter을 먼저 실행시키도록 설정하는 메소드이다.
.addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new ExceptionHandlerFilter(), JwtTokenFilter.class)
그 외 추가한 메소드 설명
.exceptionHandling() // 인증 과정에서 예외가 발생할 경우 예외를 전달한다. .authenticationEntryPoint(new CustomAuthenticationEntryPointHandler()) // 권한을 확인하는 과정에서 통과하지 못하는 예외가 발생하는 경우 예외를 전달한다. .accessDeniedHandler(new CustomAccessDeniedHandler())
메소드를 살펴보면 인가 과정의 예외 상황
에서 CustomAccessDeniedHandler와 CustomAuthenticationEntryPointHandler 로 예외를 전달하고 있었다.
다음은 이러한 클래스를 작성하는 방법이다.
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 4. 토큰 인증 후 권한 거부
ErrorCode errorCode = ErrorCode.FORBIDDEN_REQUEST;
JwtTokenFilter.setErrorResponse(response, errorCode);
}
}
AccessDeniedHandler
액세스 권한이 없는 리소스에 접근할 경우 발생하는 예외
handle() 메소드를 오버라이딩한다.
@Slf4j
@Component
public class CustomAuthenticationEntryPointHandler implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
// 1. 토큰 없음 2. 시그니처 불일치
if (authorization == null || !authorization.startsWith("Bearer ")) {
log.error("토큰이 존재하지 않거나 Bearer로 시작하지 않는 경우");
ErrorCode errorCode = ErrorCode.INVALID_TOKEN;
JwtTokenFilter.setErrorResponse(response, errorCode);
} else if (authorization.equals(ErrorCode.EXPIRED_TOKEN)) {
log.error("토큰이 만료된 경우");
// 3. 토큰 만료
ErrorCode errorCode = ErrorCode.EXPIRED_TOKEN;
JwtTokenFilter.setErrorResponse(response,errorCode);
}
}
}
AuthenticationEntryPoint
인증이 실패한 상황을 처리한다.
commence() 메서드를 오버라이딩해서 코드를 구현한다.
에러코드는 enum으로 관리한다
@AllArgsConstructor
@Getter
public enum ErrorCode {
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 토큰입니다."),
EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."),
INVALID_PERMISSION(HttpStatus.UNAUTHORIZED, "사용자가 권한이 없습니다."),
FORBIDDEN_REQUEST(HttpStatus.FORBIDDEN, "ADMIN 회원만 접근할 수 있습니다.");
private final HttpStatus httpStatus;
private final String message;
}
JwtTokenFilter에 메소드를 추가로 작성해서 가독성을 높였다.
/**
* Security Chain 에서 발생하는 에러 응답 구성
*/
public static void setErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(errorCode.getHttpStatus().value());
ObjectMapper objectMapper = new ObjectMapper();
ErrorResponse errorResponse = new ErrorResponse
(errorCode, errorCode.getMessage());
Response<ErrorResponse> error = Response.error(errorResponse);
String s = objectMapper.writeValueAsString(error);
/**
* 한글 출력을 위해 getWriter() 사용
*/
response.getWriter().write(s);
}
@Slf4j
public class ExceptionHandlerFilter extends OncePerRequestFilter {
/**
* 토큰 관련 에러 핸들링
* JwtTokenFilter 에서 발생하는 에러를 핸들링해준다.
* <토큰의 유효성 검사>
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
// 다음 filter Chain에 대한 실행 (filter-chain의 마지막에는 Dispatcher Servlet이 실행된다.)
filterChain.doFilter(request, response);
} catch (ExpiredJwtException e) {
//토큰의 유효기간 만료
log.error("만료된 토큰입니다");
setErrorResponse(response, ErrorCode.EXPIRED_TOKEN);
} catch (JwtException | IllegalArgumentException e) {
//유효하지 않은 토큰
log.error("유효하지 않은 토큰이 입력되었습니다.");
setErrorResponse(response, ErrorCode.INVALID_TOKEN);
} catch (NoSuchElementException e) {
//사용자 찾을 수 없음
log.error("사용자를 찾을 수 없습니다.");
setErrorResponse(response, ErrorCode.USERNAME_NOT_FOUND);
} catch (ArrayIndexOutOfBoundsException e) {
log.error("토큰을 추출할 수 없습니다.");
setErrorResponse(response, ErrorCode.INVALID_TOKEN);
} catch (NullPointerException e) {
filterChain.doFilter(request, response);
}
}
}
필터 클래스를 직접 만들어 사용할 때 만약 filterChain.doFilter() 메소드를 호출해주지 않으면 다음 필터로 넘어가지도 않을 뿐더러 서블릿으로 요청이 가지도 못한다는 것에 주의해야 한다.
공통점 : OncePerRequestFilter을 상속받아 필터를 구현했고, 매 요청마다 각각의 필터가 한번씩만 실행된다.
Spring Security의 필터 설명
필터는 스프링 컨텍스트 외부에서 request와 response의 해당하는 작업을 가로채어 공통 로직을 수행한다.
차이점 :
JwtTokenFilter
는 JWT토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스.
- HttpServletRequest에서 토큰 추출
- 토큰에 대한 유효성을 검사>
유효하다면 Authentication 객체를 생성해서 SecurityContextHolder에 추가
ExcptionHandlerFilter
은 filter에서 터지는 exception들을 효율적으로 관리하기 위해 만든 클래스
JwtTokenFilter실행 전에 호출하여, Security 필터에서 오류가 발생시 처리하는 예외처리 로직을 작성한다..addFilterBefore(new ExceptionHandlerFilter(), JwtTokenFilter.class)
인증/인가 수준에서 나올 수 있는 거의 모든 exception들을 처리하는 handler들이 security속에 다 구현돼 있어서 custom만 알맞게 한다면 직접 exceptionHandlerFilter를 만들 필요는 없는거 같다 (스프링부트 핵심가이드에서도 따로 ExceptionHandlerFilter을 구현하지 않고 에러처리를 했다.)
필터 수행 순서 : ExceptionHandlerFilter
> JwtTokenFilter
> UsernamePasswordAuthenticationFilter
=> Dispatcher Servlet
시큐리티를 처음 설정할 땐 낯설게 느껴지지만 핵심적인 클래스와 메서드를 짚어보면 큰 그림이 그려진다.
어려운 내용을 만났을 때 잘 모르고 다음으로 넘어가는 것보다 이렇게 하나씩 정리해두면
두고두고 이용해먹을 수 있겠다.
Security Filter에서 발생하는 Exception 처리하기
[Spring Boot] JWT 토큰 만료에 대한 예외처리
스프링부트핵심가이드(397~403)
[Spring] 스프링 Filter, DoFilter
스프링 필터와 스프링 시큐리티(Spring Security)의 동작 구조