문제 상황
분명히 나는 아래와 같이 ExceptionHandler 작업을 해주었는데 이를 통과하지 않는 현상이 지속됐다.
다른 예외는 잘만 통과하는데 대체 왜 스프링 시큐리티 예외만 저 메소드를 거쳐가지 않을까?
이를 이해하려면 스프링 시큐리티의 작동 순서를 알아야 한다.
이 이유에 관한 작동 순서는 별도의 글로 간략히 작성해두었다.
해결 (구현) 과정
AuthenticationEntryPoint 구현
시큐리티의 예외는 기본적으로 EntryPoint에서 처리한다고 한다.
따라서 이 EntryPoint를 커스텀 구현해 예외를 일정하게 반환하는 커스텀 처리(ErrorResponse)를 해줄 것이다.
엔트리포인트는 resolver의 함수를 호출하는데, resolver 빈은 두가지가 있다고 한다.
그래서 엔트리포인트가 사용해야 하는 빈을 명확하게 표현해주기 위해 @Qualifier를 이용해 명칭을 명시해주었다.
AuthenticationEntryPoint 인터페이스를 구현하는 구현체를 만들어준다.
@Component("jwtAuthenticationEntryPoint")
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final HandlerExceptionResolver resolver;
public JwtAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
resolver.resolveException(request, response, null, (Exception) request.getAttribute("exception"));
}
}
여기서 기존 commence에서 다르게 설정해준 점은 resolver를 호출할 때 전달하는 마지막 파라미터이다.
필터에서 실제 발생하는 예외를 exception이라는 이름으로 설정해서 넘겨줄 예정이다.
이 이유는 저 부분을 커스텀하지 않으면 내부에서 발생하는 정확한 예외 내용이 전달되는 것이 아니라
AuthenticationException과 같이 큰 범위의 예외로 뭉뚱그려져서 전달되기 때문이다.
Configuration 작성
엔트리포인트 작성을 완료했다면 Config를 작성하러 가자.
프로젝트 진행 중에 작성하는 글이라 기존에 적용한 다른 설정들이 있으나 무시해주자.
중요하게 보고, 설정해야하는 부분은 엔트리포인트에 대한 설정과 http를 빌드하기 전 마지막에 호출하는 exceptionHandling().authenticationEntryPoint(entryPoint) 부분이다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig{
private final JwtTokenProvider jwtTokenProvider;
@Qualifier("jwtAuthenticationEntryPoint")
private final AuthenticationEntryPoint entryPoint;
// JWT를 사용하기 위해서는 기본적으로 password encoder가 필요함.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// REST API이기에 basic auth, csrf 보안 사용해제
.httpBasic().disable()
.cors().disable()
// 서버에 인증정보 저장하지 않음. jwt 사용
.csrf().disable()
// JWT를 사용하기 때문에 세션을 사용하지 않음
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 회원가입 요청은 로그인을 요구하지 않음
.antMatchers("/api/register").permitAll()
// 로그인 요청은 로그인을 요구하지 않음
.antMatchers("/api/login").permitAll()
.anyRequest().authenticated()
.and()
// 커스텀 필터를 UsernamePasswordAuthenticationFilter 전에 실행
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
// 시큐리티 예외처리를 @ControllerAdvice 에서 별도 처리 -> 이 부분 집중!!
.exceptionHandling()
.authenticationEntryPoint(entryPoint);
return http.build();
}
}
해당 설정은 필터에서 발생하는 예외(시큐리티 예외)의 처리를 자동으로 autowired되는 디폴트 entrypoint에게 맡기지 않고 우리가 위에 작성한 entrypoint로 처리하겠다는 것이다.
물론 필터에서도 예외에 대한 정보를 ServletRequest에 담아줘야 동일하게 처리될 수 있다.
필터 작성
내가 작성한 필터를 예시로 보자.
여기서 중요하게 봐야하는 점은 request.setAttribute부분이다.
코드를 일부만 적으면 보는데에 헷갈릴 수 있으니 해당 클래스를 전부 작성하겠다.
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
// 1. Request Header에서 순수 JWT 토큰만 추출
String token = resolveAccessToken((HttpServletRequest) request);
// 2. validateToken으로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateAccessToken(token)) {
// 유효한 토큰일 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
Authentication authentication = jwtTokenProvider.getaAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 3. access token이 만료됐다면 refresh token 검사
// else if (jwtTokenProvider.validateRefreshToken())
} catch (Exception e) {
log.error("exception shows in filter = {}", e.toString());
request.setAttribute("exception", e); // 이 부분에서 예외정보를 넘겨준다!!!
} finally {
chain.doFilter(request, response);
}
}
// Request Header에서 토큰 정보 추출
private String resolveAccessToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
// 토큰 맨 앞에 Bearer를 붙임
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
log.info("bearer token front = {}", bearerToken.substring(0, 7));
log.info("bearer token back = {}", bearerToken.substring(7));
return bearerToken.substring(7);
}
return null;
}
}
이후 해당 try catch문에서 발생한 예외는 "exception"에 담겨 엔트리포인트로 전달된다.
+) 트러블 슈팅
이 과정에서 조금 헤맸던 부분이 있는데
provider 단에서 try catch문으로 예외처리를 했었다.
그렇게 하면 예외가 필터까지 전달이 안되고 디폴트 예외처리가 진행되니 우리가 설정한 세팅을 통과하지 못한다.
따라서 jwtTokenProvider과 같은 별도의 provider에서 예외처리를 하지말고 예외를 던지게 두자.
그래야 필터까지 예외가 넘어와 정확한 예외정보를 "exception"을 통해 우리의 커스텀 엔트리포인트까지 전달시켜 처리할 수 있다.
@RestControllerAdvice / @ControllerAdvice
여기까지 설정하고 나면 @RestControllerAdvice / @ControllerAdvice가 붙은 클래스에서 시큐리티 예외를 잡을 수 있게 된다.
혹여 해당 어노테이션 사용법을 모를 수 있으니 코드를 추가로 첨부한다.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ExpiredJwtException.class)
public ResponseEntity<ErrorResponse> handleExpiredJwtException() {
ErrorCode errorCode = ErrorCode.EXPIRED_TOKEN;
ErrorResponse response = new ErrorResponse(errorCode);
return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
}
}
결과
기본 예외처리와 같은 부분 (enum 설정 등)은 추후 별도의 게시글로 다루도록 하겠다.
그럼 이만!
'Spring > 프로젝트' 카테고리의 다른 글
[Spring Security] Refresh Token 적용기 (0) | 2023.07.18 |
---|---|
JPA @Id에 Long을 쓴 이유 (0) | 2023.06.23 |
ERD 설계 과정 (0) | 2023.06.20 |