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(jwtProvider, redisTemplate), UsernamePasswordAuthenticationFilter.class)
.build();
}
addFilterBefore(Filter, beforeFilter)
beforeFilter가 실행되기 이전에 Filter을 먼저 실행시키도록 설정하는 메소드이다.
.addFilterBefore(new JwtTokenFilter(jwtProvider, redisTemplate), UsernamePasswordAuthenticationFilter.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);
}
JwtTokenFilter의 전체 코드를 추가겠습니다
@RequiredArgsConstructor
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
/**
* request 에서 전달받은 Jwt 토큰을 확인
*/
private final String BEARER = "Bearer ";
private final JwtProvider jwtProvider;
private final RedisTemplate redisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader(HttpHeaders.AUTHORIZATION);
log.info("First Authorization = {}", token);
request.setAttribute("existsToken", true); // 토큰 존재 여부 초기화
if (isEmptyToken(token)) request.setAttribute("existsToken", false); // 토큰이 없는 경우 false로 변경
//쿠키 값 셋팅
if (token == null) {
Cookie[] cookies = request.getCookies();
if (cookies != null) for (Cookie cookie : cookies) if(cookie.getName().equals("jwt")) token = cookie.getValue().replace("+", " ");
else {
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute("Authorization") == null) {
} else {
token = session.getAttribute("Authorization").toString();
}
}
}
// 쿠키 조회했는데도 null이거나 'Bearer ' 로 시작하지 않으면 에러
if (token == null || !token.startsWith(BEARER)) {
log.info("Error At nullCheck Authorization = {}", token);
filterChain.doFilter(request, response);
return;
}
token = parseBearer(token);
log.info("After remove Bearer Authorization = {}", token);
if (jwtProvider.validateToken(token)) {
// Redis 에 해당 accessToken logout 여부 확인
String isLogout = (String)redisTemplate.opsForValue().get(token);
log.info("isLogout?:{}",isLogout);
if (ObjectUtils.isEmpty(isLogout)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
} else if(isLogout.equals("logout")) {
MakeError(response,ErrorCode.INVALID_PERMISSION);
filterChain.doFilter(request, response);
return;
}
}
log.info("finish add Authorization to Security ContextHolder= {}", token);
filterChain.doFilter(request, response);
}
private boolean isEmptyToken(String token) {
return token == null || "".equals(token);
}
private String parseBearer(String token) {
return token.substring(BEARER.length());
}
/**
* 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> resultResponse = Response.error(errorResponse);
// 한글 출력을 위해 getWriter()
response.getWriter().write(objectMapper.writeValueAsString(resultResponse));
}
}
필터 클래스를 직접 만들어 사용할 때 만약 filterChain.doFilter() 메소드를 호출해주지 않으면 다음 필터로 넘어가지도 않을 뿐더러 서블릿으로 요청이 가지도 못한다는 것에 주의해야 한다.
JwtTokenFilter
는 JWT토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스.
- HttpServletRequest에서 토큰 추출
- 토큰에 대한 유효성을 검사>
유효하다면 Authentication 객체를 생성해서 SecurityContextHolder에 추가
필터 수행 순서 : JwtTokenFilter
> UsernamePasswordAuthenticationFilter
=> Dispatcher Servlet
시큐리티를 처음 설정할 땐 낯설게 느껴지지만 핵심적인 클래스와 메서드를 짚어보면 큰 그림이 그려진다.
어려운 내용을 만났을 때 잘 모르고 다음으로 넘어가는 것보다 이렇게 하나씩 정리해두면
두고두고 이용해먹을 수 있겠다.
Security Filter에서 발생하는 Exception 처리하기
[Spring Boot] JWT 토큰 만료에 대한 예외처리
스프링부트핵심가이드(397~403)
[Spring] 스프링 Filter, DoFilter
스프링 필터와 스프링 시큐리티(Spring Security)의 동작 구조
좋은 글 잘 읽었습니다. 좋은 정보 감사합니다.
혹시 setErrorResponse() 메소드는 JwtTokenFilter 클래스에 있는 걸까요? ExceptionHandlerFilter에 있는 걸까요? 글에서는 JwtTokenFilter에서 선언했다고 되어있는데 그렇다면 ExceptionHandlerFilter에서 호출한 setErrorResponse()는 어떻게 호출된건지 알 수 있을까요?
디렉토리 구조를 몰라서 헤매고 있습니다.