우리는 SpringSecurity를 이용해 Rest Api를 구현할 때 비즈니스 로직에서 발생하는 예외를 처리해 주기 위해 ControllerAdvice를 사용합니다.
데이터베이스에서 값을 조회하는 데에 실패했다던가, 유저의 아이디, 패스워드가 다르다던가 하는 정상적인 요청의 처리에 벗어나는 상황(예외)에 대해 유연하게 처리하기 위해 사용합니다.
예)
@ControllerAdvice
public class CustomControllerAdvice {
//javax.persistence.EntityNotFoundException
@ExceptionHandler(value = {EntityNotFoundException.class})
public ResponseEntity<ExceptionPayload> handleEntityNotFoundException(EntityNotFoundException e) {
ResultEmptyException resultEmptyException = new ResultEmptyException();
final ExceptionPayload payload = ExceptionPayload
.create()
.status(HttpStatus.NOT_FOUND.value())
.code(resultEmptyException.getExceptionCode().getCode())
.message(e.getMessage());
return new ResponseEntity<>(payload, HttpStatus.NOT_FOUND);
}
// UserNotExistException(Custom Exception)
@ExceptionHandler(value = {UserNotExistException.class})
public ResponseEntity<ExceptionPayload> handleUserNotExistException(UserNotExistException e) {
final ExceptionPayload payload = ExceptionPayload
.create()
.status(HttpStatus.NOT_FOUND.value())
.code(e.getExceptionCode().getCode())
.message(e.getMessage());
return new ResponseEntity<>(payload, HttpStatus.NOT_FOUND);
}
//JWT Filter에서 발생시키는 경우 ControllerAdvice에서 처리를 하지 못한다.
//PermissionDeniedException(Custom Exception)
@ExceptionHandler(value = {PermissionDeniedException.class})
public ResponseEntity<ExceptionPayload> handlePermissionDeniedException(PermissionDeniedException e) {
final ExceptionPayload payload = ExceptionPayload
.create()
.status(HttpStatus.FORBIDDEN.value())
.code(e.getExceptionCode().getCode())
.message(e.getMessage());
return new ResponseEntity<>(payload, HttpStatus.FORBIDDEN);
}
}
우리가 JWT를 사용하는 가장 큰 장점은, DB에 접근하지 않고 인메모상의 키값을 이용해 사용자의 권한을 체크하기 위함입니다. 때문에 tokenProvider를 만들어 토큰 검증을 하고, 이를 "Filter"에 등록합니다.
하지만 ControllerAdvice는 Filter, Interceptor 단에서 발생하는 Exception은 처리를 해주지 못합니다.
//CustomAuthenticationEntryPoint.java
@Slf4j
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
String exception = (String)request.getAttribute("exception");
if(exception == null) {
setResponse(response, ExceptionCode.UNKNOWN_ERROR);
}
//잘못된 타입의 토큰인 경우
else if(exception.equals(ExceptionCode.WRONG_TYPE_TOKEN.getCode())) {
setResponse(response, ExceptionCode.WRONG_TYPE_TOKEN);
}
//토큰 만료된 경우
else if(exception.equals(ExceptionCode.EXPIRED_TOKEN.getCode())) {
setResponse(response, ExceptionCode.EXPIRED_TOKEN);
}
//지원되지 않는 토큰인 경우
else if(exception.equals(ExceptionCode.UNSUPPORTED_TOKEN.getCode())) {
setResponse(response, ExceptionCode.UNSUPPORTED_TOKEN);
}
else {
setResponse(response, ExceptionCode.ACCESS_DENIED);
}
}
//한글 출력을 위해 getWriter() 사용
private void setResponse(HttpServletResponse response, ExceptionCode exceptionCode) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
JSONObject responseJson = new JSONObject();
responseJson.put("message", exceptionCode.getMessage());
responseJson.put("code", exceptionCode.getCode());
response.getWriter().print(responseJson);
}
}
//JwtFilter
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "token";
public static final String BEARER_PREFIX = "Bearer ";
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = getToken(request);
try {
if (StringUtils.hasText(token) && tokenProvider.checkToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
System.out.println(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (SecurityException | MalformedJwtException e) {
request.setAttribute("exception", ExceptionCode.WRONG_TYPE_TOKEN.getCode());
} catch (ExpiredJwtException e) {
request.setAttribute("exception", ExceptionCode.EXPIRED_TOKEN.getCode());
} catch (UnsupportedJwtException e) {
request.setAttribute("exception", ExceptionCode.UNSUPPORTED_TOKEN.getCode());
} catch (IllegalArgumentException e) {
request.setAttribute("exception", ExceptionCode.WRONG_TOKEN.getCode());
} catch (Exception e) {
log.error("================================================");
log.error("JwtFilter - doFilterInternal() 오류발생");
log.error("token : {}", token);
log.error("Exception Message : {}", e.getMessage());
log.error("Exception StackTrace : {");
e.printStackTrace();
log.error("}");
log.error("================================================");
request.setAttribute("exception", ExceptionCode.UNKNOWN_ERROR.getCode());
}
filterChain.doFilter(request, response);
}
private String getToken(HttpServletRequest request) {
String token = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(token) && token.startsWith(BEARER_PREFIX)) {
return token.substring(7);
}
return null;
}
}
위의 코드를 보면 tokenProvider.checkToken에서 발생하는 Excepction에 대하여 request 의 속성에 "exception" 값으로 넣어준다.
잘못된 토큰, 토큰 만료, 지원되지않는 토큰 등에 대한 처리를 해줍니다.
request에 "exception" 속성의 값을 넣어준 뒤 실제 AuthenticationEntryPoint에서 response에 결과를 담아 요청자에게 돌려줍니다.
//SecurityConfiguration.java
@Configuration
@EnableWebSecurity
@EnableJpaAuditing
@RequiredArgsConstructor
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception{
httpSecurity
...
.antMatchers("/user").hasAnyRole("USER", "ADMIN")
.antMatchers("/admin").hasAnyRole("ADMIN")
.and()
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
...
}
}
위와같이 SpringSecurity 설정에 등록해주면 적용이 됩니다.
위 상황에서 USER 권한을 가진 사람이 ADMIN권한을 가진 url에 대하여 요청을 보내면 어떻게 될까요?
우리가 원하는건 "권한없음" 이라는 메시지를 전달해 주고 해당 요청을 차단하는 것 입니다.
위에 적은 첫번째 방법은 토큰에 대한 유효성 검사 이지만, 접근 권한에 대한 처리는 해주지 않았습니다.
어떻게 처리를 해줘야 할까요?
바로 AccessDeniedHandler를 상속해 custom 해주면 됩니다.
//CustomAccessDeniedHandler.java
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
ExceptionCode exceptionCode;
exceptionCode = ExceptionCode.PERMISSION_DENIED;
setResponse(response, exceptionCode);
}
private void setResponse(HttpServletResponse response, ExceptionCode exceptionCode) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
JSONObject responseJson = new JSONObject();
responseJson.put("message", exceptionCode.getMessage());
responseJson.put("code", exceptionCode.getCode());
response.getWriter().print(responseJson);
}
}
//SecurityConfiguration.java
@Configuration
@EnableWebSecurity
@EnableJpaAuditing
@RequiredArgsConstructor
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception{
httpSecurity
...
.antMatchers("/user").hasAnyRole("USER", "ADMIN")
.antMatchers("/admin").hasAnyRole("ADMIN")
.and()
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.accessDeniedHandler(new CustomAccessDeniedHandler())//추가된 코드
...
}
}
accessDeniedHandler를 통해 CustomAccessDeniedHandler를 등록해 주면,
접근 권한에 따른 Exception 발생시 CustomAccessDeniedHandler를 통해 처리해 줄 수 있습니다.