현재 개발 중인 프로젝트에서 Spring Security와 JWT를 이용해 로그인 로직을 구현하고 있다. 구현 중 SecurityConfig의 permitAll() 메서드와 JwtAuthenticationFilter의 shoudNotFilter() 메서드의 효율적인 관리를 위한 과정을 정리해 보았다.
🔙 이전 진행사항
📌 문제 상황
🌱 SecurityConfig.class
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF 비활성화 등 코드 생략
.addFilterBefore(new jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(request -> request
.requestMatchers("api/auth/**", "api/accounts/**").permitAll()
.anyRequest().authenticated());
return http.build();
}
}
🌱 JwtAuthenticationFilter.class
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String[] excludePath = {"api/auth/**", "api/accounts/signup"};
String path = request.getRequestURI();
return Arrays.stream(excludePath).anyMatch(path::startsWith);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
// JWT 인증 로직 처리
// ...
filterChain.doFilter(request, response);
} catch(JwtException ex) {
logger.info("Failed to authorize/authenticate with JWT due to " + ex.getMessage());
}
filterChain.doFilter(request, response);
}
}
[Spring Security] SecurityConfig permitAll() 적용 안 되는 이유
위 글의 하단 부분에서 정리한 것과 같이 커스텀 필터(ex. JwtAuthenticationFilter)를 @Component나 @Bean 어노테이션을 사용해 등록하고 특정 API에 한정해 Spring Security의 필터 체인 자체를 생략해야 할 경우 shouldNotFilter를 오버라이드해 설정해주어야 한다.
하지만 shouldNotFilter에서 수행하는 작업은 이미 유사하게 SecurityConfig에 permitAll() 메서드에서도 수행 중인데 이를 따로 관리한다는 것이 비효율적으로 느껴졌다. 그렇게 해결방안을 찾던 중 Spring Security의 RequestMatcher를 이용해 HttpMethod의 일치 여부를 확인하는 방법을 찾게 되었다.
public final class AntPathRequestMatcher implements RequestMatcher, RequestVariablesExtractor {
@Override
public boolean matches(HttpServletRequest request) {
if (this.httpMethod != null && StringUtils.hasText(request.getMethod())
&& this.httpMethod != HttpMethod.valueOf(request.getMethod())) {
return false;
}
if (this.pattern.equals(MATCH_ALL)) {
return true;
}
String url = getRequestPath(request);
return this.matcher.matches(url);
}
private String getRequestPath(HttpServletRequest request) {
if (this.urlPathHelper != null) {
return this.urlPathHelper.getPathWithinApplication(request);
}
String url = request.getServletPath();
String pathInfo = request.getPathInfo();
if (pathInfo != null) {
url = StringUtils.hasLength(url) ? url + pathInfo : pathInfo;
}
return url;
}
}
RequestMatcher의 구현체인 AntPathRequestMatcher의 matches 메서드 로직을 살펴보면 아래와 같다.
- HttpMethod 일치 여부 확인
- pattern이 MATCH_ALL(/**)인지 확인
- url 획득(url은 servletPath + pathInfo 이기 때문에 context-path는 무시됨)
- this.matcher에게 일치 판정 위임(이는 최종적으로 AntPathMatcher에 위임되어 Ant 패턴을 통해 매칭 판정)
이 방법을 사용하면 FilterChain과 OncePerRequestFilter에서 모두 Ant 패턴을 통해 검증을 수행할 수 있으므로, 애플리케이션의 루트 URL인 context-path를 고려하지 않고 두 곳에서 사용할 경로 정보를 한 곳에서 관리할 수 있게 된다.
📌 수정 후
구현할 로직
- 엔드포인트 경로를 Ant 패턴 형식으로 한 곳에서 관리
- 프로젝트에서 사용하는 Role 종류는 "가입자", "어드민"이 있고 로그인을 수행하지 않은 "방문자"도 존재
(방문자 < 가입자 < 어드민) - 인자로 Role을 넘기면 주어진 권한에 따라 허용되는 RequestMatcher를 동적으로 생성 및 캐싱
🌲 RequestMatcherHolder.class
@Component
public class RequestMatcherHolder {
private static final List<RequestInfo> REQUEST_INFO_LIST = List.of(
// auth
new RequestInfo(POST, "/api/auth/login", null),
// user
new RequestInfo(POST, "/api/users/signup", null),
// static resources
new RequestInfo(GET, "/docs/**", null),
new RequestInfo(GET, "/*.ico", null),
new RequestInfo(GET, "/resources/**", null),
new RequestInfo(GET, "/error", null)
);
private final ConcurrentHashMap<String, RequestMatcher> reqMatcherCacheMap = new ConcurrentHashMap<>();
/**
* 최소 권한이 주어진 요청에 대한 RequestMatcher 반환
* @param minRole 최소 권한 (Nullable)
* @return 생성된 RequestMatcher
*/
public RequestMatcher getRequestMatchersByMinRole(@Nullable Role minRole) {
var key = getKeyByRole(minRole);
return reqMatcherCacheMap.computeIfAbsent(key, k ->
new OrRequestMatcher(REQUEST_INFO_LIST.stream()
.filter(reqInfo -> Objects.equals(reqInfo.minRole(), minRole))
.map(reqInfo -> new AntPathRequestMatcher(reqInfo.pattern(),
reqInfo.method().name()))
.toArray(AntPathRequestMatcher[]::new)));
}
private String getKeyByRole(@Nullable Role minRole) {
return minRole == null ? "VISITOR" : minRole.name();
}
private record RequestInfo(HttpMethod method, String pattern, Role minRole) {
}
}
- private 레코드인 RequestInfo를 통해 엔드포인트 인가 관리에 필요한 정보를 담는다.
- 엔드포인트를 관리하기 위한 REQUEST_INFO_LIST가 있고, (HTTP 메서드, URL 패턴, 최소 역할(Role))로 구성되어 있다.
- getRequestMatchersByMinRole 메서드를 통해 최소 역할에 따른 요청 매처를 반환한다. minRole이 null이면 모든 사용자에게 허용되는 경로를 나타내는 "permitAll" 요청 매처를 반환하고, 그렇지 않으면 해당 역할에 필요한 요청 매처를 동적으로 생성하고 캐시에서 찾아 반환한다.
- getKeyByRole 메서드는 최소 역할을 기반으로 캐시 키를 생성하는 private 메서드이다.
해당 사항을 SecurityConfig의 filterChain 메서드와 JwtAuthenticationFilter의 shouldNotFilter 메서드에 적용하면 아래와 같다.
🌲 SecurityConfig.class
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final RequestMatcherHolder requestMatcherHolder;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF 비활성화 등 코드 생략
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(request -> request
.requestMatchers(requestMatcherHolder.getRequestMatchersByMinRole(null)).permitAll()
.requestMatchers(requestMatcherHolder.getRequestMatchersByMinRole(ASSOCIATE))
.hasAnyAuthority(ASSOCIATE.name(), ADMIN.name())
.requestMatchers(requestMatcherHolder.getRequestMatchersByMinRole(ADMIN))
.hasAnyAuthority(ADMIN.name())
.anyRequest().authenticated())
// exceptionHandling 생략
return http.build();
}
}
🌲 JwtAuthenticationFilter.class
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final RequestMatcherHolder requestMatcherHolder;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return requestMatcherHolder.getRequestMatchersByMinRole(null).matches(request);
}
// 코드 생략
}
이렇게 Spring Security의 RequestMatcher를 사용하여 특정 경로에 대한 필터링을 일관되게 관리하고, 중복된 설정을 제거하여 효율성을 높일 수 있었다. 또한, context-path를 신경 쓰지 않고 경로를 지정해 경로 관리 간편화의 장점을 얻을 수 있게 되었다.
📚 참고
[Spring Security] OncePerRequestFilter.shouldNotFilter 메서드 효율적으로 오버라이딩 하기
'Back-end > TroubleShooting' 카테고리의 다른 글
[Spring] 로그인 서비스 테스트 AuthenticationManagerBuilder NPE (0) | 2024.05.14 |
---|---|
[Spring Security] AuthenticationException과 JWTException 분리 (0) | 2024.05.10 |
[Spring Security] SecurityConfig permitAll() 적용 안 되는 이유 (0) | 2024.05.10 |
[Spring Boot] JPA delete 후 insert 시 duplicate entry 에러 (1) | 2023.05.06 |
[Spring Boot] JPA 동시성 이슈, 낙관적 락(Optimistic Lock)을 이용한 해결 (0) | 2023.03.27 |