Handling Security Exceptions
문제 해결 과정
공식 문서도 찾아보고 다양한 포스팅을 참고해보니 여러가지 방법들이 있었습니다.
- 예외 처리용 Filter 추가
- Spring Security 필터 체인에 커스텀 필터를 추가하여 인증 또는 권한 관련 예외를 처리할 수 있는 구조를 만듭니다.
- 이 필터에서 예외가 발생할 경우, 직접 처리하거나 다른 핸들러로 위임할 수 있습니다.
- Filter 내에서 에러 응답 직접 처리
- 필터 내에서 발생한 예외를 직접 HttpServletResponse를 사용하여 클라이언트에 적절한 HTTP 상태 코드(예: 401, 403)를 응답으로 보낼 수 있습니다.
- 이를 통해 빠르게 예외를 처리하고 클라이언트에게 응답을 전달할 수 있습니다.
- AccessDeniedHandler 및 AuthenticationEntryPoint 구현
- AccessDeniedHandler는 인증된 사용자가 권한이 없는 리소스에 접근할 때(예: 403 Forbidden) 호출됩니다.
- AuthenticationEntryPoint는 인증되지 않은 사용자가 보호된 리소스에 접근할 때(예: 401 Unauthorized) 호출되어 인증 절차를 시작하거나 에러를 응답합니다.
- HandlerExceptionResolver.resolveException() 호출하여 GlobalExceptionHandler에 위임
- 필터에서 발생한 예외를 HandlerExceptionResolver를 통해 Spring의 전역 예외 처리기로 넘깁니다.
- resolveException() 메서드를 호출하여 예외를 GlobalExceptionHandler에 위임하고, 해당 핸들러에서 @ExceptionHandler를 통해 적절한 방식으로 예외를 처리할 수 있습니다.
handlerExceptionResolver.resolveException((HttpServletRequest) request, (HttpServletResponse) response, null, ex);```
저는 이 중 3번과 4번 중 고민을 하였습니다...
Spring Security와의 원활한 통합을 고려한다면 3번 방식이 적합하지만, 프로젝트의 전체적인 예외 처리 일관성을 유지하기 위해서는 4번 방식도 좋은 선택이 될 수 있다고 생각했습니다.
그러나 Filter에서 발생하는 오류는 주로 Unauthorized(401) 또는 Forbidden(403)과 같은 인증/인가 관련 예외이기 때문에, Spring Security의 표준적인 처리 흐름을 따르는 3번 방식을 선택하여 구현하였습니다.
다만, 추후 로직이 복잡해지거나 다양한 예외 상황이 추가된다면, 예외 처리를 더 유연하고 일관성 있게 관리하기 위해 HandlerExceptionResolver로 넘기는 방식(4번 방식)을 사용할지 고려중입니다. (현재 핸들러 공사중이라 보류...)
V1. Filter 내에서 에러 응답 직접 처리
- 일단 테스트 용도로 jwtTokenUtils에서 발생하는 커스텀 예외를
try-catch
문으로 잡아 확인해보고 직접 필터에서 에러 응답을 보내주었습니다.
package com.muud.global.security.filter;
...
@RequiredArgsConstructor
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenUtils jwtTokenUtils;
private final UserPrincipalService principalService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
String token = jwtTokenUtils.getTokenFromHeader(request);
if (token != null) {
String email = jwtTokenUtils.getEmailFromToken(token);
UserPrincipal userDetails = principalService.loadUserByUsername(email);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}catch(AuthException authException) {
log.error(authException.getMessage());
response.sendError(authException.getErrorCode().defaultHttpStatus().value());
}
}
...
}
결과
로그인 없이 api 요청
일반 사용자로 로그인한 후 어드민 권한 api 요청
→ 이렇게 처리해도 StatusCode는 세팅 되었지만, error response body를 보내지 않았습니다. 다른 api 에러 응답들과 같이 일관적인 형식으로 보내주도록 수정하겠습니다.
V2. AccessDeniedHandler 추가
- 권한이 없는 사용자가 리소스에 접근했을 때 발생하는 에러(403)를 서비스 형식에 맞게 처리하기 위해 AccessDeniedHandler를 구현한 커스텀 핸들러를 추가하였습니다.
- AccessDeniedException는 저희 서비스에서 커스텀으로 사용하고 있던 ACCESS_DENIED와 매핑되기 때문에 직접 ApiResponseError를 생성하여 내려주었습니다.
- 참고로 GlobalHandler에서 처리하도록 넘기고 싶으면
package com.muud.global.security.exception;
...
@Slf4j
@Component
@RequiredArgsConstructor
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
ApiResponseError apiResponseError = ApiResponseError.from(ACCESS_DENIED.defaultException());
response.setStatus(apiResponseError.status());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(apiResponseError));
}
}
- 처음에 JwtToken과 관련된 예외(401) 처리도 AuthenticationEntryPoint를 구현하여 처리 하였지만, 현재까지는 복잡한 로직을 포함하고 있지 않기 때문에 Filter에서 예외 처리를 해주었습니다.
package com.muud.global.security.filter;
...
@RequiredArgsConstructor
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenUtils jwtTokenUtils;
private final UserPrincipalService principalService;
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException {
try {
String token = jwtTokenUtils.getTokenFromHeader(request);
if (token != null) {
String email = jwtTokenUtils.getEmailFromToken(token);
UserPrincipal userDetails = principalService.loadUserByUsername(email);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
catch (CustomException e) {
log.error("Exception occurred: {}", e.getMessage());
setResponse(response, e);
}
catch (ServletException | IOException e) {
log.error("Exception during filter processing: {}", e.getMessage(), e);
setResponse(response, new AuthException(e.getMessage(), e.getCause()));
}
}
...
private void setResponse(HttpServletResponse response, CustomException e) {
try {
ApiResponseError responseError = ApiResponseError.from(e);
response.setStatus(responseError.status());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(responseError));
} catch (IOException ex) {
log.error("Exception during filter processing: {}", e.getMessage(), e);
}
}
}
- SecurityConfig에 다음과 같이 등록해줍니다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**", "/swagger-ui/**", "/v3/api-docs/**", "/health-check").permitAll()
...
.anyRequest().authenticated()
)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exceptionHandling -> exceptionHandling
.accessDeniedHandler(accessDeniedHandlerImpl)
);
return http.build();
}
결과
권한이 없는 경우 응답이 잘 내려옴
토큰이 유효하지 않은 경우도 잘 내려옴
틀린 부분이나 개선 사항 발견하시면 댓글로 공유 부탁드립니다:)
읽어주셔서 감사합니다. 😊
'TroubleShooting' 카테고리의 다른 글
[GitHub] BFG Repo-Cleaner를 이용하여 깃허브에 올라간민감 정보 지우기 (0) | 2024.11.02 |
---|---|
[CI/CD] GitHub Actions ./gradledew perpission denied 이슈 (1) | 2024.10.27 |