Spring Security를 도입하면 SecurityConfig 클래스 파일에서 웹 애플리케이션의 인증(Authentication) 및 권한 부여(Authorization) 메커니즘을 설정하고 관리하게 된다.
🌱 SecurityConfig.class
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.addFilterBefore(new jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(authorize -> authorize
// 이 부분 주목!!!!!!!
.requestMatchers("api/auth/**", "api/accounts/**").permitAll()
.anyRequest().authenticated())
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint));
return http.build();
}
}
해당 프로젝트는 Spring Security에서 디폴트로 설정되어 있는 세션 기반 인증 방식을 사용하지 않기 때문에 기본적으로 제공되는 UsernamePasswordAuthenticationFilter를 사용하지 않는다. 따라서 addFilterBefore 메서드를 이용해 UsernamePasswordAuthenticationFilter 필터 앞단에 JwtAuthenticationFilter를 커스텀 필터로 등록해 주었다.
원래 permitAll()을 할 경우 "필터 체인 동작 과정에서 인증 / 인가 예외가 발생해도 ExceptionTranslationFilter를 거치지 않으며, 인증 객체 존재 여부와 상관없이 정상적으로 API 호출이 이루어진다"고 알고 있었는데 permitAll() 메서드에 특정 API를 등록했음에도 Spring Security의 필터 체인을 거쳐 에러가 발생했다.
즉, permitAll()이 먹히지가 않았다.
API URI이 "/auth"로 시작될 경우 JWT 검증 없이 호출된다는 의도로 작성한 코드임에도, 포스트맨을 통해 로그인 "api/auth/login" API 호출 시 인증 관련 예외인 JwtAuthenticationEntryPoint로 넘어간다는 것을 확인할 수 있다.
🌱 JwtAuthenticationEntryPoint.class
@RequiredArgsConstructor
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(SC_UNAUTHORIZED);
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(
ResponseData.of(INVALID_TOKEN, authException.getMessage())));
}
}
🤔 permitAll( ) 이 정확하게 뭔데?
requestMatchers(URL).permitAll(): 해당 URL에 대한 모든 사용자의 요청을 허용하는 메서드
여기서 모든 사용자의 요청을 허용한다는 것은 모든 필터 체인을 거친 후 인증 정보가 없거나(SecurityContext에 Authentication 인증 객체가 존재하지 않음) 필터 동작 과정 중 예외가 발생해도 등록된 해당 API 호출이 정상적으로 가능하다는 뜻이다.
따라서 로그인 API에 permitAll()을 적용하게 되면, SecurityContext에 인증 객체의 존재 여부와 상관없이 API 호출이 이루어지게 된다.
🌱 JwtAuthenticationFilter.class - 수정 전
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 주목
String accessToken = getAccessTokenFromHeader(request);
//
Claims claims = tokenProvider.validateToken(accessToken);
AccessTokenPayload accessTokenPayload = tokenProvider.createAccessTokenPayload(claims);
Authentication authentication = tokenProvider.getAuthentication(accessTokenPayload.email(),
accessTokenPayload.role().name());
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
private String getAccessTokenFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(TOKEN_TYPE)) {
return bearerToken.substring(7);
}
throw new JwtException("Missing Token"); // 직접 JwtException을 던짐
}
}
여기서 문제는 http 헤더로부터 bearer 토큰을 추출하는 getAccessTokenFromHeader 메서드에서 조건문 충족이 안되면 JwtException 예외 클래스를 직접 던지고 있다는 점이었다.
앞에 SecurityConfig에서 "api/auth/login"은 permitAll을 해주었기 때문에 JWT 인증 절차를 거치지 않아도 되어 Authorization 헤더가 존재하지 않는다. 따라서 위와 같이 헤더가 없는 경우 직접 Exception을 던진다면 doFilterInternal 메서드의 맨 하단에 있는 filterChain.doFilter(request, response) 호출이 되지 않고, permitAll의 적용 여부와 상관없이 ExceptionTranslationFilter로 처리가 넘어가게 된다.
🌱 JwtAuthenticationFilter.class - 수정 후
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String accessToken = this.getAccessTokenFromHeader(request);
if (accessToken == null) throw new JwtException("access token is null");
Claims claims = tokenProvider.validateToken(accessToken);
AccessTokenPayload accessTokenPayload = tokenProvider.createAccessTokenPayload(claims);
Authentication authentication = tokenProvider.getAuthentication(accessTokenPayload.email(),
accessTokenPayload.role().name());
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
} catch(JwtException ex) {
logger.info("Failed to authorize/authenticate with JWT due to " + ex.getMessage());
}
filterChain.doFilter(request, response);
}
private String getAccessTokenFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(TOKEN_TYPE)) {
return bearerToken.substring(7);
}
return null;
}
}
이를 해결하기 위해서는 JWT 인증 과정에서 Exception이 발생해도 직접 던지지 않고 try-catch 문을 통해 잡아두도록 고쳐야 한다.
이렇게 수정하면 catch 블록에서 예외가 처리되더라도 filterChain.doFilter(request, response) 메서드를 호출하여 다음 필터로 처리가 이동한다. 이후 해당 API가 permitAll()의 대상인지 여부에 따라 ExceptionTranslationFilter에서의 처리 여부가 결정된다.
만약 permitAll의 대상이 아니었다면 ExceptionTranslationFilter가 JwtAuthenticationFilter에서 발생한 예외를 자동으로 감지해 해당 필터로 처리가 넘어가고, permitAll의 대상이라면 컨트롤러로 사용자의 요청이 넘어갈 것이다.
🤔 아예 필터 체인을 생략하고 싶다면?
@Configuration
public class SecurityConfig {
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().antMatchers("/h2-console/**", "/favicon.ico", "/docs/**");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(request -> request
.anyRequest().authenticated())
.addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
return http.build();
}
}
일반적으로는 "/docs/**", "/*.ico" 와 같이 Spring Security의 필터 체인 자체를 생략해야 할 경우 @WebSecurityCustomizer의 web.ignoring()을 사용할 수 있다. (WebSecurity는 HttpSecurity 상위에 존재하기 때문에 특정 API를 ignoring()하게 등록하면, Spring Security의 필터 체인이 적용되지 않음)
- web.ignoring()
web.ignoring() 메서드는 Spring Security의 보안 필터 체인이 적용되기 전에 특정 경로를 아예 무시하도록 설정된다. 따라서 무시된 경로에 대한 요청은 Spring Security의 모든 필터를 통과하지 않고 바로 애플리케이션으로 전달된다. - Security Filter Chain
Security Filter Chain은 Spring Security가 HTTP 요청을 처리하기 위해 사용하는 필터들의 순서를 정의한 것이다. 커스텀 필터를 추가할 때는 보통 HttpSecurity 설정을 통해 추가한다.
위 설정에서는 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가해 모든 요청이 JwtAuthenticationFilter를 거치게 된다.
하지만 커스텀 필터(ex. JwtAuthenticationFilter)를 @Component나 @Bean 어노테이션을 사용해 등록했다면, @WebSecurityCustomizer의 web.ignoring()이 적용되지 않는다. 이는 필터 체인의 구성 순서와 커스텀 필터의 등록 방식 때문인데 web.ignoring() 설정으로 특정 경로를 무시하더라도, 커스텀 필터는 보안 필터 체인에 추가된 이상 무시된 경로를 포함한 모든 요청에 대해 필터링을 수행한다.
이런 경우, 2가지 방법으로 특정 경로에 대해 필터링을 하지 않도록 설정할 수 있다.
1️⃣ 커스텀 필터 내부에서 특정 경로를 확인하고, 무시할 경로에 대해서는 필터링을 하지 않도록 처리
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 특정 경로를 무시
if (path.startsWith("/h2-console") || path.startsWith("/static")) {
filterChain.doFilter(request, response);
return;
}
// JWT 인증 로직 처리
// ...
filterChain.doFilter(request, response);
}
}
이렇게 하면 JwtAuthenticationFilter는 특정 경로에 대해서는 필터링을 하지 않고 다음 필터로 넘어가게 되어, web.ignoring() 설정과 동일한 효과를 얻을 수 있다.
2️⃣ shouldNotFilter를 오버라이드하여 필터를 생략하고 싶은 경우 true를 반환하도록 설정
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// JWT 인증 로직 처리
// ...
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
// 특정 경로를 무시하도록 설정
return path.startsWith("/h2-console") || path.startsWith("/static");
}
}
위의 코드에서 shouldNotFilter 메서드는 요청 경로가 '/h2-console' 또는 '/static'으로 시작하는 경우 true를 반환해 해당 경로에 대한 요청은 필터링되지 않고, 나머지 요청에 대해서만 doFilterInternal 메서드가 호출된다.
😅 추가적으로 개선할 점
1️⃣ permitAll의 관리 지점이 SecurityConfig와 shouldNotFilter 두 곳 존재
2️⃣ AuthenticationException과 JWTException이 통합돼 InsufficientAuthenticationException로만 발생
📚 참고
Spring Security with filters permitAll not working
[Spring Security] - SecurityConfig 클래스의 permitAll() 이 적용되지 않았던 이유
'Back-end > TroubleShooting' 카테고리의 다른 글
[Spring] 로그인 서비스 테스트 AuthenticationManagerBuilder NPE (0) | 2024.05.14 |
---|---|
[Spring Security] AuthenticationException과 JWTException 분리 (0) | 2024.05.10 |
[Spring Security] OncePerRequestFilter.shouldNotFilter 잘 사용하기 (0) | 2024.05.10 |
[Spring Boot] JPA delete 후 insert 시 duplicate entry 에러 (1) | 2023.05.06 |
[Spring Boot] JPA 동시성 이슈, 낙관적 락(Optimistic Lock)을 이용한 해결 (0) | 2023.03.27 |