걸음마부터 달리기
[24/11/14] 시큐리티의 ExceptionTranslationFilter 본문
ExceptionTranslationFilter 는 Security에서 발생한 인가와 인증 예외에 대해 예외처리를 수행했다.
AccessDeniedException와 AutheticationException의 예외 타입에 대해서만 처리를 해준다는 것을 앞의 게시물에서 보았다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
/* http
.cors().and()
.csrf().disable()
.httpBasic().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeHttpRequests().requestMatchers("/","/api/v1/auth/**","/api/v1/search/**","/file/**").permitAll()
.requestMatchers(HttpMethod.GET,"/api/v1/board/**","/api/v1/user/*").permitAll()
.anyRequest().authenticated().and()
.exceptionHandling().authenticationEntryPoint(new FailedAuthentication());
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();*/
http
.cors(cors -> cors.disable())
.csrf(csrf -> csrf.disable())
.httpBasic(httpBasic -> httpBasic.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/api/v1/auth/**", "/api/v1/search/**", "/file/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/board/**", "/api/v1/user/*").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(exception -> exception
.authenticationEntryPoint(new FailedAuthentication())
);
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
//spring Security는 인가를 실패하면 기본 오류 페이지를 내보냄.
//근데 API 통신을 하고 있으니 에러 JSON을 내보내야함.
class FailedAuthentication implements AuthenticationEntryPoint { //(1)
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{ \"code:\": \"AF\",\"message\": \"Authorization failed.\"}");
}
}
문제는 (1)번 부분이다. 시큐리티에서 인증에 실패하면 AutheticationException가 발생하기에 ExceptionTranslationFilter가 이 예외를 잡아서 AuthenticationEntryPoint에서 로직을 수행한다.
내 상황은 API 서버로 만들고 있기에 Error Json을 보내주기 위해서 AuthenticationEntryPoint의 FailedAuthentication으로 구현했다.
그런데 Postman으로 Validation에 걸리게 보내봤더니 저 FailedAuthentication에서 해당 에러 Json이 출력됐다.
얼탱이가 없었다.
내가 보고있는건 이 구조이기 때문이다. 결국 시큐리티는 필터쪽에서 이루어지니까 사실상 컨테이너와는 연관이 없다. 하지만 DelegatingFilter 덕분에 필터 자체는 스프링 빈으로 등록해버리고 각각에 맞춰 이 빈들에게 위임하는 형식으로 가는데...
Validation은 Spring 기술이다. WAS 기술이 아니다. 따라서 Validation에서 MethodArgumentNotValidException 예외를 던지는데 왜 ExceptionTranslationFilter가 AccessDeniedException와 AutheticationException 타입의 예외도 아닌데 잡아서 처리하는거지? ( 왜 잡아서 AuthenticationEntryPoint가 동작하는거지?)
1) 현재 컨테이너 쪽에서 / 어플리케이션 쪽에서 내가 예외를 핸들링 하지 않고 있기에 언체크 예외로 우선 시큐리티까지 올라간다. 2) 시큐리티에서도 현재 처리 안하고 있으니까 WAS까지 전파된다.3) WAS까지 왔으니 /error 페이지로 내부적으로 요청을 보낸다.4) 여기서 다시 서블릿 컨테이너 타면서 시큐리티 필터들을 타는데 이때 인증이 안된 상태에서 요청하고 있으니 AutheticationException 예외가 발생한다.5) AutheticationException 가 발생하니 ExceptionTranslationFilter가 동작해서 AuthenticationEntryPoint가 동작한 결과가 해당 JSON 이다.
---------------------------------------------------------------------------------------------------------------------------------------------------------
대부분 시큐리티에서 커스텀 필터를 사용할때 OncePerRequestFilter를 사용하는걸 볼 수 있다.
왜 굳이 그냥 Filter 인터페이스 받아서 구현하면 되는데 왜 OncePerRequestFilter를 받을까 궁금했다.
OncePerRequestFilter는 한번의 Request 요청에 한번만 동작하는 필터이다.
Redirect시 브라우저쪽에서 다시 요청하므로 아예 다른 Request이어서 이때는 OncePerRequestFilter 가 수행되지만
서버 내부의 Forward시에는 OncePerRequestFilter 가 하나의 Request이기에 수행되지 않는다.
따라서 Filter는 중복 수행의 위험이 있어서 굳이 Filter로 구현하지 않고 OncePerRequestFilter 로 구현하는 것이다.
JWT 인증관련 커스텀 필터를 첨부한다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JWTProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
String token = parseBearerToken(request);
if (token == null) {
filterChain.doFilter(request, response);
return;
}
String email = jwtProvider.validate(token);
if (email == null) { //검증 안되면
filterChain.doFilter(request, response);
return;
}
//JWT 인증방식이라 Credentials를 null로...
AbstractAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(email, null, AuthorityUtils.NO_AUTHORITIES);
//WebAuthenticationDetails는 인증과정에서 Authentication 객체에 추가정보 Ip를 추가해줌
//네트워크로 서블릿 관련 작업이라 try catch
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(authenticationToken);
SecurityContextHolder.setContext(securityContext);
filterChain.doFilter(request, response);
} catch (Exception e){
e.printStackTrace();
}
filterChain.doFilter(request,response);
}
//헤더에 오는 Authentication : Bearer 파싱을 여기서 하고 그 파싱된걸 필터에서 jwtProvider가 검증 후 리턴
private String parseBearerToken(HttpServletRequest request){
String authorization = request.getHeader("Authorization");
//null , 공백 , 길이 0 이면 false , 아니면 true
boolean hasAuthorization = StringUtils.hasText(authorization);
if(!hasAuthorization) return null;
boolean isBearer = authorization.startsWith("Bearer ");
if(!isBearer) return null;
//여기까지 왔으면 검증은 안됐지만 일단 Bearer 토큰이 있는거
//Bearer 뒤 실제 토큰값을 가져옴
String token = authorization.substring(7);
return token;
}
}