스프링 시큐리티는 DispatcherServlet이라는 서블릿 단보다 앞에 위치한다. 그 말은 Spring Context와 서블릿의 영향을 받기 전에 먼저 작동하는 필터라는 말이다. 그렇기에 우리가 정의한 GlobalExceptionHandler에서 Exception이 발생하는 것을 처리하는 과정을 Filter에서는 적용할 수 없다.
그렇다면 SpringSecurity에서 발생하는 인증/인가에 대해서는 어떻게 처리해야 할까?
SpringSecurity에서는 ExceptionHandlingConfigurer
라는 불변 클래스가 존재한다. 해당 클래스는 AuthenticationEntryPoint
클래스와 AccessDeniedHandler
클래스가 필드에 존재하는데 이 두 필드가 예외를 처리하는 클래스이다.
두 필드는 아래 보이는 LinkedHashMap타입의 맵에 디폴트값으로 추가가 되는데 만약 이 두 클래스를 구현한 구현체가 있다면 해당 구현체를 우선적으로 적용시킨다.
public final class ExceptionHandlingConfigurer<H extends HttpSecurityBuilder<H>> extends AbstractHttpConfigurer<ExceptionHandlingConfigurer<H>, H> {
private AuthenticationEntryPoint authenticationEntryPoint;
private AccessDeniedHandler accessDeniedHandler;
private LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> defaultEntryPointMappings = new LinkedHashMap();
private LinkedHashMap<RequestMatcher, AccessDeniedHandler> defaultDeniedHandlerMappings = new LinkedHashMap();
SecurityConfig에서 전역설정
방법은 의외로 간단했다. 먼저 http에 메서드 체이닝으로 예외에 대한 핸들리을 authenticationEntryPoint와 accessDeniedHandler로 지정해주고 새로 만들 객체이름으로 적어준다.
클래스를 미리 만들고 임포트해줘도되고 여기서 이름을 지정하고 객체를 생성하는것도 상관없다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtHelper jwtHelper) throws Exception {
// http 공통 설정
http
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(new AuthenticationFilter(jwtHelper), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exception -> exception.authenticationEntryPoint(new JwtAuthenticationEntryPoint()))
.exceptionHandling(exception -> exception.accessDeniedHandler(new CustomAccesDeniedHandler()))
;
AuthenticaionFilter
토큰에 대한 유효성을 검증하는 필터이다. 이 과정에서 인증이 제대로 이루어지지 않는다면 Security에서 인식할 수 있는 예외를 발생시키거나 Custom하여 만든 Exception을 발생시킨다. 나는 validateToken메서드 내부에서 토큰의 변조 및 상태검사가 이루어지며 CustomJWTException을 발생시켰다.
여기서 중요한점! CsutomException 클래스를 만든 경우 해당 예외 객체는 RuntimeException을 상속받을텐데 그렇다면 곧 만들 JwtAuthenticationEntryPoint에서 예외처리를 할 수 없다. SpringSecurity에서 내 예외 객체를 감지하지 못하기 때문이다. 그렇기에 나는 catch구문으로 직접 response에 데이터를 담아 전달해줬다. 만약 여기서 JwtAuthenticationEntryPoint로 예외를 전가시켜줘야 한다면 httpServeltRequest에 데이터를 담아 전달하면 된다.
@Slf4j
@RequiredArgsConstructor
public class AuthenticationFilter extends OncePerRequestFilter {
private static final List<String> EXCLUDE_URLS = List.of(
"/api/v1/auth/sign-up",
"/api/owner/v1/auth/sign-up",
"/api/v1/auth/sign-in",
"/api/v1/auth/renew",
"/api/v1/auth/sign-up/owner",
"/api/v1/auth/sign-up/customer",
"/api/master/v1/sign-in"
);
private final JwtHelper jwtHelper;
@Override
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//1.요청 경로를 가져오기
String requestURI = request.getRequestURI();
//2. 화이트리스트 경로 등록 후 필터에서 제외되도록 설정
boolean isWhitelisted = EXCLUDE_URLS.stream().anyMatch(requestURI::matches);
if (isWhitelisted) {
filterChain.doFilter(request, response);
return;
}
//3. Token 검증
try {
String accessToken = jwtHelper.resolveToken(request);
if (accessToken != null) {
jwtHelper.validateToken(accessToken);
Authentication authentication = jwtHelper.getAuthenticationFromAccessToken(accessToken);
if (authentication != null) {
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
return;
}
} else {
throw new CustomJwtException(ExceptionCode.NOT_FOUND_TOKEN);
}
} catch (CustomJwtException e) {
log.error("JWT validation failed: {}", e.getMessage());
// response에 바로 에러 응답을 설정하여 필터 체인 중단
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.setStatus(e.getHttpStatus().value());
// ObjectMapper로 예쁘게 출력할 수 있도록 수정
String errorResponse = String.format("{\"status\": \"%s\", \"message\": \"%s\", \"code\": \"%s\"}",
e.getHttpStatus(), e.getMessage(), e.getCode());
response.getWriter().write(errorResponse);
return;
}
filterChain.doFilter(request, response);
}
}
JwtAuthenticationEntryPoint 생성
인증
에 대한 예외를 처리할 수 있는 클래스이다. AuthenticationEntryPoint 인터페이스를 상속받아 commence를 구현하여 사용한다. 인증과정에서 문제가 발생할 경우 여기서 메세지를 response로 전달할 수 있다.
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// Get message and code from request attributes, defaulting to empty string and 0 if null
String message = (String) request.getAttribute("message");
int code = (request.getAttribute("code") != null) ? (int) request.getAttribute("code") : 4999;
// If message is null, set a default message
if (message == null) {
message = "Invalid Authentication";
}
response.setStatus(HttpServletResponse.SC_BAD_REQUEST); // 400 Bad Request
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
ObjectMapper objectMapper = new ObjectMapper();
String result = objectMapper.writeValueAsString(ExceptionResponse.builder()
.httpStatus(HttpStatus.FORBIDDEN)
.status(HttpStatus.FORBIDDEN)
.message(message)
.code(code)
.build());
response.getWriter().write(result);
}
}
CustomAccessDeniedHandler
인가
에 대한 예외를 발생시킬 수 있는 클래스이다. AccessDeniedHandler를 상속받고 handle메서드를 구현하여 예외메세지를 전달할 수 있다.
public class CustomAccesDeniedHandler implements org.springframework.security.web.access.AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 요청 속성에서 메시지와 코드 가져오기
String message = (String) request.getAttribute("message");
int code = (request.getAttribute("code") != null) ? (int) request.getAttribute("code") : 4999;
// 메시지가 없으면 기본 메시지 설정
if (message == null) {
message = "You do not have permission to access this resource";
}
response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403 Forbidden
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
ObjectMapper objectMapper = new ObjectMapper();
String result = objectMapper.writeValueAsString(ExceptionResponse.builder()
.httpStatus(HttpStatus.FORBIDDEN)
.status(HttpStatus.FORBIDDEN)
.message(message)
.code(code)
.build());
response.getWriter().write(result);
}
}
Security를 알면 알수록 보안적인 측면이나 예외적인 것들을 처리할 수 있는 방법을 더 많이 알게되는것같다. 앞으로 또 좋은 공부가 된 내용이 있다면 추가하고자 한다!
'TIL' 카테고리의 다른 글
[DDD 아키텍처/잡담] 도메인 주도 설계를 왜 해야 할까? (0) | 2025.02.28 |
---|---|
[TIL] 모놀리딕 스프링 부트 프로젝트를 수행하고 내가 공부해야 할 것들 (0) | 2025.02.26 |
[TIL] Stream 활용하기, CI/CD 파이프라인 개발, 엔티티 개발 협업 (0) | 2025.02.20 |
[TIL] Filter예외 처리, 서버 배포 (0) | 2025.02.19 |
[TIL] 팀원간 코드 리뷰 진행 및 인덱스가 많아지면 생기는 문제, JPA의 flush 발생하는 조건 (0) | 2025.02.18 |