SecurityResponseExceptionHandler.java
package com.project.auth.config.web;
import com.project.auth.application.support.exception.AuthErrorCode;
import com.project.auth.application.support.logging.LogSanitizer;
import com.project.auth.presentation.support.exception.ApiErrorHttpStatusMapper;
import com.project.auth.presentation.support.response.ApiResult;
import com.project.auth.presentation.support.response.ApiResultFactory;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* Spring Security 예외를 처리하는 bootstrap 계층 advice.
*
* presentation 모듈은 ArchUnit 규칙에 의해 org.springframework.security.core / context에
* 직접 의존할 수 없으므로(LayerDependencyArchitectureTest 참고), 이 advice는 bootstrap에 둔다.
*
* AccessDeniedException은 현재 인증 상태에 따라 401/403으로 분기한다. 익명 principal이
* 보호된 리소스에 접근한 경우는 인증이 필요하다는 의미로 401을 반환하고, 인증된 principal이
* 권한이 부족한 경우에만 403을 반환한다. Spring Security의 ExceptionTranslationFilter가
* 필터 단계에서 던지는 예외에 대해 적용하는 분기 로직을 컨트롤러 단(@PreAuthorize 등 메서드
* 보안)에서 던져진 동일 예외에도 일관되게 적용한 것이다.
*
* 스레드 가정: SecurityContextHolder는 기본 ThreadLocal 전략을 사용한다고 가정한다.
* 비동기 컨트롤러(@Async, Callable, DeferredResult, WebFlux adapter 등)가 도입되면
* SecurityContext가 워커 스레드로 전파되도록 처리해야 한다(DelegatingSecurityContextRunnable
* / MODE_INHERITABLETHREADLOCAL / SecurityContextHolderStrategy 커스터마이즈 등).
* 그렇지 않으면 워커 스레드에서 컨텍스트가 비어 있어 인증된 사용자의 AccessDenied가 잘못
* 401로 분류된다. 비동기 경로가 추가될 때는 isAnonymous()가 HttpServletRequest.getUserPrincipal()
* 도 함께 참조하도록 보강한다.
*/
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE + 5)
public class SecurityResponseExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(SecurityResponseExceptionHandler.class);
private final ApiResultFactory apiResultFactory;
public SecurityResponseExceptionHandler(ApiResultFactory apiResultFactory) {
this.apiResultFactory = apiResultFactory;
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ApiResult<Void>> handleAuthenticationException(
AuthenticationException exception,
HttpServletRequest request
) {
log.warn("Authentication required. exceptionType={} method={} requestPath={} errorCode={}",
exception.getClass().getSimpleName(),
request.getMethod(),
LogSanitizer.requestPath(request.getRequestURI()),
AuthErrorCode.AUTHENTICATION_REQUIRED.code());
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(AuthErrorCode.AUTHENTICATION_REQUIRED))
.body(apiResultFactory.failure(
AuthErrorCode.AUTHENTICATION_REQUIRED.code(),
AuthErrorCode.AUTHENTICATION_REQUIRED.message()
));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResult<Void>> handleAccessDeniedException(
AccessDeniedException exception,
HttpServletRequest request
) {
if (isAnonymous(SecurityContextHolder.getContext().getAuthentication())) {
log.warn("AccessDenied for anonymous principal -> 401. exceptionType={} method={} requestPath={} errorCode={}",
exception.getClass().getSimpleName(),
request.getMethod(),
LogSanitizer.requestPath(request.getRequestURI()),
AuthErrorCode.AUTHENTICATION_REQUIRED.code());
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(AuthErrorCode.AUTHENTICATION_REQUIRED))
.body(apiResultFactory.failure(
AuthErrorCode.AUTHENTICATION_REQUIRED.code(),
AuthErrorCode.AUTHENTICATION_REQUIRED.message()
));
}
log.warn("Access denied for authenticated principal. exceptionType={} method={} requestPath={} errorCode={}",
exception.getClass().getSimpleName(),
request.getMethod(),
LogSanitizer.requestPath(request.getRequestURI()),
AuthErrorCode.ACCESS_DENIED.code());
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(AuthErrorCode.ACCESS_DENIED))
.body(apiResultFactory.failure(
AuthErrorCode.ACCESS_DENIED.code(),
AuthErrorCode.ACCESS_DENIED.message()
));
}
private static boolean isAnonymous(Authentication authentication) {
return authentication == null
|| !authentication.isAuthenticated()
|| authentication instanceof AnonymousAuthenticationToken;
}
}