사용 기술 스택
- Spring Boot 3.x.x
- Spring Security 6 ( + jwt token 방식)
- Spring Data Jpa
문제 발생
Spring Security를 적용하면서 인증이 필요하지 않은 경로를 직접 지정해주어 인증을 안하도록 설정하였다.
대표적으로는 로그인 (/auth/login)이 있는데, 로그인을 할 때 access token을 확인할 필요가 없기 때문에 permitAll()을 사용하여 인증에서 제외했다.
http
.authorizeHttpRequests(requests -> requests
.requestMatchers("/", "/auth/login", "/access-token").permitAll() // 이 부분
.requestMatchers("/swagger-ui.html", "/swagger-ui/**", "api-docs/**").permitAll()
.anyRequest().authenticated()
)
여기까지는 크게 문제 없어보인다. 그리고 실제로 인증이 필요하지 않은 경로에 대해서는 access token을 보내주지 않아도 정상 작동하는 것도 확인했었다. 잘 되는줄만 알았다. 아래 메세지를 받기 전까지......
...?? 그럴리가 없는데? permitAll() 했다고!!
근거 없는 자신감으로 나는 "이미 토큰 안보내줘도 되도록 설계가 되어 있습니다~ " 라고 답변했고 돌아오는 답변은
"로그인 할 때 액세스 토큰을 임의로 보내버리면 401이 발생하고 있습니다. " 였다. 또한 반환 받은 에러메세지까지 보여주셨다..
{
"data": {
"errorCode": "1204",
"errorDescription": "INVALID_ACCESS_TOKEN",
"message": "유효하지 않은 토큰입니다."
},
"status": "error"
}
여기서부터 나는 이해가 안가기 시작했다. 이제까지 permitAll()로 해결도 잘 됐었고 어차피 토큰 유효성 검사하는 필터도 타지 않을건데 이 에러가 왜나는거지? 이 때 까지도 내 잘못인줄 모르고 그냥 혹시나 하는 마음에 permitAll()을 검색해봤다.
근데.........!!!!!! 근데!!!!!!!!!! permitAll()을 해도 Spring Security의 필터 체인을 거친다는 것이였다. ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ 어설프게 알았던 나는 permitAll()이면 다 되는줄 알았다..ㅋ
내 눈으로 확인해보고 싶어서 로그인 할 때 그냥 임의의 토큰을 넣어서 보냈더니 Filter를 호출했고 Filter에서 token 유효성 하는 로직에 걸려 에러가 발생한 것이였다. (임의의 토큰이므로 유효하지 않음)
충격을 받은 채로 수정 작업에 들어갔다.
코드 살펴보기
* Security 설정 파일
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
private final CustomUserDetailsService userService;
private final TokenAuthenticationFilter tokenAuthenticationFilter;
private final ObjectMapper objectMapper;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 특정 HTTP 요청에 대한 웹 기반 보안 구성
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(requests -> requests
.requestMatchers("/static/**").permitAll()
.requestMatchers("/", "/auth/login", "/access-token", "/partners/findEmail", "/partners/password", "/actuator/**").permitAll()
.requestMatchers("/swagger-ui.html", "/swagger-ui/**", "api-docs/**").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(new CustomAuthenticationEntryPoint(objectMapper))
)
.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.logout(LogoutConfigurer::permitAll)
.csrf(AbstractHttpConfigurer::disable)
.userDetailsService(userService);
return http.build();
}
}
코드 설명
.requestMatchers("/static/**").permitAll()
.requestMatchers("/", "/auth/login", "/access-token", "/partners/findEmail", "/partners/password", "/actuator/**").permitAll()
.requestMatchers("/swagger-ui.html", "/swagger-ui/**", "api-docs/**").permitAll()
- 해당 경로들은 인증이 없어도 액세스를 허용한다. (그렇다고 해서 Filter를 안타는 것은 아니다)
.anyRequest().authenticated()
- 위에서 정의한 경로들 외에는 인증이 요구된다. (유효한 access token 값 필요)
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(new CustomAuthenticationEntryPoint(objectMapper))
)
- 인증 예외가 발생했을 때 실행될 사용자 정의 인증 진입점을 설정한다.
- CustomAuthenticationEntryPoint 클래스의 인스턴스가 생성되어 전달되는데, 이 클래스는 인증 예외에 대한 사용자 지정 로직을 정의할 수 있다.
.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
- Spring Security 필터 체인에 사용자 정의 필터를 추가하는 부분이다.
- 구체적으로는 UsernamePasswordAuthenticationFilter 앞에 사용자가 만든 tokenAuthenticationFilter를 추가하도록 함
- 사용자가 만든 tokenAuthenticationFilter가 기본적인 아이디/비밀번호 기반의 인증을 수행하는 UsernamePasswordAuthenticationFilter보다 먼저 실행되도록 하는 설정이다. 이렇게 함으로써 토큰 기반의 사용자 정의 인증 로직이 먼저 실행되고, 그 후에 기본적인 아이디/비밀번호 기반의 인증이 수행된다.
csrf(AbstractHttpConfigurer::disable)
- Spring Security에서 CSRF(Cross-Site Request Forgery) 공격을 방지하기 위한 기능을 비활성화하는 설정
* Jwt Token 유효성 검사 필터
@RequiredArgsConstructor
@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private static final String HEADER_AUTHORIZATION = "Authorization";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
if (authorizationHeader == null) {
filterChain.doFilter(request, response);
return;
}
String token = tokenProvider.getAccessToken(authorizationHeader);
if (tokenProvider.validateToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
throw new CredentialsExpiredException(ErrorMessage.INVALID_ACCESS_TOKEN.getMessage());
}
filterChain.doFilter(request, response);
}
}
에러 발생 이유?
위에서도 설명했지만, permitAll()에 대한 잘못된 이해가 있었다.
여기서 핵심은 permitAll()을 사용해도 Spring Security의 필터 체인을 거친다는 점이다. 따라서 나는 /auth/login이라는 경로에 대해서 permitAll()이라고 설정해도 addFilterBefore에 등록 했던 토큰 유효성 검사 필터인 TokenAuthenticationFilter 를 타게 되는 것이었다.
그럼 permitAll()은 어떤 기능을 하는걸까?
permitAll()은 특정한 경로나 리소스에 대해서 모든 사용자에 대해 인증을 요구하지 않고 액세스를 허용한다.
모든 사용자에게 인증을 요구하지 않는다는 말이 뭘까?
먼저, SecurityContext에 대해서 알아보자.
SecurityContext는 Spring Security에서 현재 사용자의 보안 정보를 저장하고 제공하는 인터페이스이다.
주로 Authentication 객체를 저장하고, 이를 통해 현재 사용자의 인증 상태와 권한 정보에 접근할 수 있게 해준다.
SecurityContextHolder를 통해 SecurityContext에 접근하고 현재 Authentication을 얻을 수 있다. 이를 통해 현재 사용자의 인증 정보 및 권한 정보를 얻을 수 있다.
permitAll()을 사용했을 경우에는 모든 필터 체인을 거친 후 SecurityContextHolder 안에 존재하는 SecurityContext 에 Authentication 인증 객체가 존재하지 않아도 해당 API 호출이 정상적으로 가능하게 해준다. (Filter는 타지만 인증객체가 존재하지 않아도 프로세스가 정상적으로 동작하게 해준다는 의미)
따라서, 필터 체인을 타기 때문에 내가 토큰을 보내지 않을때는 그냥 정상적으로 호출이 된거고, 유효하지 않은 토큰 값을 넣어서 permitAll()을 해준 특정 경로를 호출 했을 때는 필터의 아래 로직에서 validate에 실패해서 401에러가 발생하고 있는 것 같았다.
if (tokenProvider.validateToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
throw new CredentialsExpiredException(ErrorMessage.INVALID_ACCESS_TOKEN.getMessage());
}
해결해보자!
나는 해당 로그인 로직에 대해서 필터체인을 거치게 하고 싶지 않았다.
따라서 특정 경로에 대해서 필터 체인을 아예 타지 않도록 수정하는 방법으로 진행했다.
Spring Security Config 에다가 WebSecurityCustomizer를 Bean으로 등록하면 된다. 이렇게 하는 이유는 WebSecurity가 HttpSecurity 보다 상위에 존재하기 때문에 WebSecurity의 ignoring에 경로를 적으면 Spring Security의 필터 체인이 적용되지 않는다고 한다.
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> {
web.ignoring()
.antMatchers("/auth/login"); // 필터를 타면 안되는 경로
};
}
만약, 필터가 커스텀 필터라면 위의 방법으로는 안되고 shouldNotFilter를 적용 해줘야 한다.
* 해당 필터에서 shouldNotFilter를 override 하여 등록해준다.
@RequiredArgsConstructor
@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private static final String HEADER_AUTHORIZATION = "Authorization";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
if (authorizationHeader == null) {
filterChain.doFilter(request, response);
return;
}
String token = tokenProvider.getAccessToken(authorizationHeader);
if (tokenProvider.validateToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
throw new CredentialsExpiredException(ErrorMessage.INVALID_ACCESS_TOKEN.getMessage());
}
filterChain.doFilter(request, response);
}
// 이 부분!!
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
return StringUtils.startsWithAny(request.getRequestURI(), "/auth/login");
}
}
주의할 점
WebSecurity의 ignoring에 경로를 등록하게 되면 보안에 취약해 진다는 점이 있다. 따라서 보안과 상관 없는 경로에 적용하는 것을 추천한다.
🔆 참고 블로그
[Spring Security] - SecurityConfig 클래스의 permitAll() 이 적용되지 않았던 이유
안녕하세요 이번 포스팅에서는 Better 팀의 Iter 프로젝트 에서 진행했던 Spring Security 을 이용한 회원 인증/인가 시스템에서 제가 겪었던 문제점과 새롭게 알게된 점을 주제로 작성하고자합니다
velog.io
'BE > Spring-Boot' 카테고리의 다른 글
[SpringBoot] 파일 시스템에서 특정 디렉토리 및 파일 모니터링 (3) | 2024.09.03 |
---|---|
[SpringBoot] 스케줄러(@Scheduled)가 간헐적으로 동작 안하는 문제 (0) | 2024.05.07 |
[SpringBoot] Controller Unit Test에서 발생한 401, 403 에러를 해결해보자! (+ Spring Security) (1) | 2023.12.27 |
[JPA Auditing] 생성자/수정자 자동화 (+생성 일시/수정 일시) (0) | 2023.11.03 |
[JPA] 엔티티 이름이 예약어일 경우 (SQLSyntaxErrorException) (0) | 2023.09.08 |