안녕하세요 이번 포스팅에서는 Better 팀의 Iter 프로젝트 에서 진행했던 Spring Security
을 이용한 회원 인증/인가 시스템에서 제가 겪었던 문제점과 새롭게 알게된 점을 주제로 작성하고자합니다 💁♂️
제가 직면한 문제는 Spring Security
의 시큐리티 기능을 활성화하고, 인증/인가에 대한 설정을 구성하는 설정 클래스인 Security Config
에서 인증 여부와 관계 없이 접근을 허용
하는 permitAll()
이 먹히지 않는 문제였습니다 😢
분명 지금까지 antMatchers(모든 접근을 허용한 URL).permitAll()
을 하면, 인증이 없어도 API 호출 및 자원 접근이 가능하다고 알고 있었는데 말이죠...
문제를 좀 더 알아보기 위해 클래스를 하나하나씩 분석해봅시다 ❗️
먼저 SecurityConfig
클래스 부터 분석해보겠습니다.
// Spring Security 기능을 활성화 하고 전반적인 구성을 위한 설정 클래스
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity(debug = true) // Spring Security 활성화
public class SecurityConfig {
private final JwtService jwtService;
private final JwtProperties jwtProperties;
private final UserRepository userRepository;
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring().antMatchers("/user/**");
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors()
.and()
.csrf().disable()
.formLogin().disable()
.httpBasic().disable()
.headers().frameOptions().disable()
.and()
.authorizeRequests()
.antMatchers("/user/test").permitAll()
.anyRequest().authenticated()
.and()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint())
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// Password Encryption
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
// Jwt 유효성 검증을 위한 filter
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtService,jwtProperties,userRepository);
}
// AuthenticationException 발생 시 처리하는 클래스
@Bean
public JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint() {
return new JwtAuthenticationEntryPoint();
}
}
어느 블로그에서도 쉽게 찾아볼 수 있는 코드입니다.
Jwt(Json Web Token)
기반의 인증 방식을 택했기 때문에, csrf, formLogin 을 비활성화 하고 session 방식을 Stateless 로 설정합니다.
: [Server] 쿠키&세션&토큰 에 대해 알아봅시다.
또한 API 요청 Authorization
Header 에 존재하는 Access Token 의 유효성을 검증하는 커스텀 필터인 JwtAuthenticationFilter
을 Bean 으로 등록하며, JwtAuthenticationFilter
에서 인증 관련 예외(AuthenticationException) 발생 시, 해당 예외를 처리하기 위해 ExceptionTranslationFilter
에 의해 호출되는 JwtAuthenticationEntryPoint
을 Bean 으로 등록합니다.
: [Spring Security] Spring Security Filter Chain 에 대해
여기서 가장 눈여겨보아야 할 부분은 permitAll() 부분입니다.
.authorizeRequests()
.antMatchers("/user/test").permitAll()
.anyRequest().authenticated()
제가 의도했던 부분은 User 관련 API 호출 테스트를 위해 /user/test
API 호출시 JWT 검증 없이 정상적인 호출 결과를 확인하는 것이였습니다
하지만, 기대와는 달리, JWT 없이 /user/test
호출 시, 인증 예외 발생하는 경우 ExceptionTranslationFilter
에 의해 동작하는 AuthenticationEntryPoint
을 상속한 JwtAuthenticationEntryPoint
가 호출되었습니다 🤔
해당 문제에 대해서는 밑에서 JwtAuthenticationEntryPoint
코드를 살펴본 후, 더 자세히 알아보도록 하겠습니다.
두번째로 실제 요청 헤더에 포함되어 있는 Jwt
유효성 검증을 위한 커스텀 필터 JwtAuthenticationFilter
을 살펴보겠습니다 ❗️
/**
* 인증이 필요한 회원 API 요청 시, jwt 인증 용도의 필터
* - 인증 마다 SecurityContext 생성 후 저장
**/
@Getter
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final JwtProperties jwtProperties;
private final UserRepository userRepository;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (request.getMethod().equals("OPTIONS")) {
filterChain.doFilter(request, response);
return;
}
log.info("checkAccessTokenAndAuthentication() called!");
String accessToken = this.checkAccessTokenAndAuthentication(request);
if (accessToken == null) {
throw new JwtAuthenticationException("jwt Authentication exception occurs!");
}
// 3. Access Token 을 파싱해서 User 정보 가져오기
User user = this.userRepository.findByUserId(this.jwtService.getUserIdFromToken(accessToken))
.orElseThrow(() -> new UsernameNotFoundException("일치하는 회원이 존재하지 않습니다."));
// 4. Thread Local 로 동작 하는 SecurityContext 에 저장
UserAuthentication userAuthentication = new UserAuthentication(user);
SecurityContextHolder.getContext().setAuthentication(userAuthentication);
filterChain.doFilter(request, response);
}
// 요청 Authorization 헤더 에서 jwt 유효성 검증 후 리턴
private String checkAccessTokenAndAuthentication(HttpServletRequest request) {
String accessToken = this.jwtService.extractAccessToken(request)
.orElseThrow(() -> new JwtAuthenticationException("jwt Authentication exception occurs!"));
if(!this.jwtService.validateToken(accessToken)) {
throw new JwtAuthenticationException("jwt Authentication exception occurs!");
}
return accessToken;
}
}
해당 프로젝트에서는 Spring Security
에서 디폴트로 설정되어 있는 세션 기반 인증 방식이 아니기 때문에 기본적으로 제공되는 UsernamePasswordAuthenticationFilter
을 사용하지 않으며, SecurityConfig
클래스에서 addFilterBefore()
메소드를 통해 UsernamePasswordAuthenticationFilter
앞에 JwtAuthenticationFilter
을 커스텀 필터로 등록해줍니다 ❗️
: Spring/Spring Security8. JWT를 사용하기 전 Filter 등록 테스트
// SecurityConfig 의 SecurityFilterChain 메소드 일부
...
.and()
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
세번째로는 앞서 언급했던 JwtAuthenticationEntryPoint
을 살펴보겠습니다 ❗️
/** JwtAuthenticationFilter 예외 발생 시 **/
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
log.error("Authentication Exception Occurs!");
this.sendErrorMessage(new BadCredentialsException("로그인이 필요합니다.(인증 실패)"),response);
}
private void sendErrorMessage(Exception authenticationException,
HttpServletResponse response
) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
try {
OutputStream os = response.getOutputStream(); // 응답 body write
JavaTimeModule javaTimeModule = new JavaTimeModule();
LocalDateTimeSerializer localDateTimeSerializer = new LocalDateTimeSerializer(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Seoul")));
javaTimeModule.addSerializer(localDateTimeSerializer); // 직렬화 방식 add
ObjectMapper objectMapper = new ObjectMapper().registerModule(javaTimeModule); // LocalDateTime serialize
objectMapper.writeValue(os, ErrorMessage.of(authenticationException, HttpStatus.UNAUTHORIZED));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
JwtAuthenticationEntrypoint
는 필터 동작 중 예외를 감지하는 ExceptionTranslationFilter
에 의해 호출됩니다.
즉, 필터 체인 과정에서 인증 예외가 발생하면 ExceptionTranslationFilter
접근시, 해당 필터에 의해 JwtAuthenticationEntryPoint
가 호출되며, 일관적인 인증 예외 응답을 보냅니다.
: Spring Security의 ExceptionTranslationFilter와 AuthenticationEntryPoint
: Spring Security - ExceptionTranslationFilter
이 또한, Spring Security
의 전반적인 설정을 구성하는 SecurityConfig
에서 선제적으로 적용해야합니다.
// SecurityConfig 의 SecurityFilterChain 메소드 일부
...
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint()) // jwtAuthenticationEntryPoint 등록
.and()
이렇게 코드를 구현하고 테스트를 위해 Postman 을 통해 /user/test
을 호출해보았습니다.
그러나 JwtAuthenticationFilter
에서 JWT 인증 실패 시, 호출되는 JwtAuthenticationEntrypoint
에 의해 동작되는 commence
메소드에 의해 401 에러가 나는 것을 확인하였습니다..🤔
직면한 문제를 해결하기 위해서는 PermitAll() 의 역할과 기능에 대해 정확한 이해가 필요했습니다.
antMatcher(URL).permitAll() 은 해당 URL 에 대한 모든 사용자의 요청을 허용하는 메소드입니다.
제가 여기서 놓쳤던 부분은 permitAll() 을 적용해도, 구성된 Spring Security
의 필터 체인을 거친다는 점이였습니다.
즉, URI 에 permitAll() 처리를 해도 여타 API 와 마찬가지로 설정된 필터 체인을 모두 거치며 필터를 무시하지 못합니다❗️
PermitAll()
에 대한 저의 잘못된 이해가 해당 문제의 근본적인 원인이였습니다 😢
그렇다면 위에 정의된 것처럼 모든 사용자의 요청을 허용한다는 것은 무슨 뜻일까요?
여기서 말하는 모든 사용자의 요청을 허용한다는 것은 모든 필터 체인을 거친 후 인증 정보가 없어도 즉, Thread Local 로 동작하는
SecurityContextHolder
안에 존재하는SecurityContext
에Authentication
인증 객체가 존재하지 않거나 필터 동작 과정 중 예외가 발생해도 해당 API 호출이 정상적으로 가능하다는 뜻입니다.
만약 모든 필터 체인을 거쳤는데 인증 객체를 담는 SecurityContext
에 인증 객체가 존재하지 않으면, 해당 요청이 인증되지 않았음을 의미합니다.
그러나, 만약 해당 API 에 permitAll()을 적용한다면 SecurityContext
에 인증 객체가 존재 여부와 상관 없이 API 호출이 이루어집니다.
정리하자면, permitAll() 적용 시, 필터 체인 동작 과정에서 인증/인가 예외가 발생해도 ExceptionTranslationFilter 을 거치지 않으며, 인증 객체 존재 여부 상관 없이 정상적으로 API 호출이 이루어집니다.
자, 앞에서 살펴보았던 JwtAuthenticationFilter
을 다시 살펴볼까요?
// JwtAuthenticationFilter 일부분
if (accessToken == null) {
throw new JwtAuthenticationException("jwt Authentication exception occurs!");
}
해당 부분은 HttpServletRequest 에서 Authrization
헤더에서 엑세스 토큰을 파싱해 오고 만약 엑세스 토큰이 null 이면 JwtAuthentication
예외 클래스를 직접 Exception 을 던지고 있습니다.
// Authentication 을 상속한 JwtAuthentication 예외 클래스
@Getter
public class JwtAuthenticationException extends AuthenticationException {
public JwtAuthenticationException(String message) {
super(message);
}
}
문제는 바로 이부분 때문이였습니다 🤔
앞서, JWT 인증 절차를 거치지 않아야 했던 /user/test
는 당연히 Authorization
헤더 자체가 존재하지 않습니다.
따라서 위와 같이 JwtAuthenticationFilter
에서 직접 Exception 을 던지면 filterChain.doFilter(request,response)
호출이 되지 않고, permitAll() 적용 여부 상관 없이 필터 체인인 ExceptionTranslationFilter
로 처리가 바로 넘어가게 됩니다.
👨💻 [문제 원인] : 직접 throw 로 예외를 던져준것 -> 예외를 직접 던지지 말고 발생시키기만 하고 다음 필터 호출로 이어져야 함
하지만 /user/test
는 permitAll() 의 적용 대상이였기 때문에 아무리 인증이 안된 API 더라도 ExceptionTranslationFilter
을 거치면 안됩니다 🚫
결국 해결책은 JwtAuthenticationFilter
에서 직접 Exception 을 던지는 것이 아니라 예외를 catch 로 잡아주고 filterChain.doFilter(request,response)
을 통해 다음 필터로 처리를 '자연스레' 넘겨주면 됩니다.
그러면 JwtAuthenticationFilter
에서 발생한 예외가 catch 된 채로 다음 필터로 넘어 갈 것이고, 해당 API 가 permitAll() 의 대상인지 판별 후 ExceptionTranslationFilter
에서 처리할 것인지 말것인지 결정할 것입니다.
여기서 만약 permitAll() 의 대상이 아니면 ExceptionTranslationFilter
가 JwtAuthenticationFilter
에서 발생한 예외를 감지
해 해당 필터로 처리가 넘어갈 것이고, permitAll() 의 대상이면 API 호출을 위해 핸들러(컨트롤러) 로 정상적으로 사용자의 요청이 넘어갈 것입니다 😀
-> 필터 체인 과정에서 인증/인가 관련된 예외가 발생하기만 해도 ExceptionTranslationFilter
가 자동으로 감지 ❗️
/**
* 인증이 필요한 회원 API 요청 시, jwt 인증 용도의 필터
* - 인증 마다 SecurityContext 생성 후 저장
**/
// 수정된 filter
@Getter
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final JwtProperties jwtProperties;
private final UserRepository userRepository;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (request.getMethod().equals("OPTIONS")) {
filterChain.doFilter(request, response);
return;
}
try {
log.info("checkAccessTokenAndAuthentication() called!");
String accessToken = this.checkAccessTokenAndAuthentication(request);
if(accessToken == null) {
throw new JwtAuthenticationException("jwt Authentication exception occurs!");
}
// 3. Access Token 을 파싱해서 User 정보 가져오기
User user = this.userRepository.findByUserId(this.jwtService.getUserIdFromToken(accessToken))
.orElseThrow(() -> new UsernameNotFoundException("일치하는 회원이 존재하지 않습니다."));
// 4. Thread Local 로 동작 하는 SecurityContext 에 저장
UserAuthentication userAuthentication = new UserAuthentication(user);
SecurityContextHolder.getContext().setAuthentication(userAuthentication);
} catch (JwtAuthenticationException | UsernameNotFoundException exception) {
log.error("JwtAuthentication Authentication Exception Occurs! - {}",exception.getClass());
}
filterChain.doFilter(request, response);
// permitAll() 의 정상적인 처리를 원한다면, 다음 필터로 넘겨 추후 판단하여 ExceptionTranslationFilter 을 무시할지 아니면
// permitAll() 이 적용되지 않은 API 여서 ExceptionTransliationFilter 을 거칠지 판단
// 바로 throw 을 하면 ExceptionTranslationFilter 로 처리가 넘어간다.(permitAll 과 상관 없이)
}
// 요청 Authorization 헤더 에서 jwt 유효성 검증 후 리턴
private String checkAccessTokenAndAuthentication(HttpServletRequest request) {
// 1. HttpServletRequest 에서 Access Token 파싱
Optional<String> token = this.jwtService.extractAccessToken(request);
if (token.isEmpty() || !this.jwtService.validateToken(token.get())) {
return null;
}
return token.get();
}
}
해당 코드는 수정된 JwtAuthenticationFilter
입니다 ❗️
Jwt 을 이용한 인증 과정에서 발생한 Exception을 throw 하지 않고 catch 로 잡아두는 것을 볼 수 있습니다.
이로써, 예외가 발생해도 filterChain.doFilter(request,response)
로 다음 필터로 처리가 넘어갈 수 있습니다.
따라서, 필터 체인의 흐름과 permitAll() 적용 여부에 따라 API 의 호출 과정일 이루어집니다 👨💻
코드를 수정하고, 아까와 동일하게 /user/test
로 요청을 보냈고 인증여부 상관없이 정상적으로 API가 처리된 것을 확인할 수 있습니다 👏
만약 Spring Security
의 필터 체인 자체을 생략해야 하는 API가 있다면 어떻게 해야 할까요?
Spring Security
설정 클래스에 사용자 정의 @WebSecurityCustomizer
을 Bean 으로 등록해주면 됩니다.
WebSecurity 설정을 통해 특정 리소스에 대해 Spring Security
적용을 생략할 수 있습니다.
// SecurityConfig 클래스 내부
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> {
web.ignoring()
.antMatchers(
"/api-document/**",
"/swagger-ui/**"
);
};
}
WebSecurity 는 HttpSecurity 상위에 존재하며, WebSecurity 의 ignoring
에 API 을 등록하면, Spring Security
의 필터 체인이 적용되지 않습니다. 하지만 이경우, Cross-Site Scripting
,XSS
공격 등에 취약해 집니다.
앞에서도 살펴보았듯이, HttpSecurity 에서 permitAll() 은 인증 처리 결과을 무시하는 것이지 Spring Security
의 필터 체인이 적용은 정상적으로 됩니다.
그래서 WebSecurity는 보안과 전혀 상관없는 로그인 페이지, 공개 페이지(어플리캐이션 소개 페이지 등), 어플리캐이션의 health 체크를 하기위한 API에 사용하고, 그 이외에는 HttpSecurity 의 permitAll() 을 사용하는 것이 좋습니다 ❗️
: jomminii_before - Spring Security 설정
: Framework/SpringBoot[스프링 시큐리티] HttpSecurity vs WebSecurity
: SpringBoot : Security Configuration using HTTPSecurity vs WebSecurity
But ❗️❗️
@Component 등으로 @Bean 으로 등록된 커스텀 필터라면 @WebSecurityCustomizer
의 web.ignoring() 설정을 잡으면 일반적인 Spring Security
의 필터 체인 처럼 해당 필터를 거치지 않을까요?
만약 커스텀 필터(ex. JwtAuthenticationFilter) 을 @Bean 으로 등록했다면 @WebSecurityCustomizer
의 web.ignoring() 이 적용되지 않기에 shouldNotFilter
을 적용해줘야 합니다.
// 필터 타면 안되는 경우 - true, 필터 타야 하는 경우 - false 반환
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return StringUtils.startsWithAny(request.getRequestURI(), "/필터 타면 안되는 엔드 포인트");
: [Filter] 빈으로 등록한 Custom Filter 사용 주의
이번 문제에 대해 제가 고생한 근본적인 이유는 permitAll() 이 적용되었을 때 인증 처리 과정에 대한 이해가 부족해서였습니다.
Spring Security
는 서버의 인증/인가 시스템 구축을 위한 최고의 선택이 될 수 있지만, 개념이 방대하고 어렵기 때문에 충분한 이해가 선제적으로 필요한 것 같습니다.
시간이 날때마다 Spring Security
을 더 공부해야겠습니다 🤔
이번 포스팅은 Iter 프로젝트에서 Spring Security
을 이용해서 인증/인가 시스템을 구축하는 과정에서 직면한 문제의 원인을 알아보고 해결해보는 시간을 가졌습니다. 감사합니다 🙏
Spring Security - permitAll() Filter 호출 에러
Stack Overflow - Spring Security with filters permitAll not working
[SpringSecurity] JwtAuthenticationFilter 구현
[Spring Security] 스프링시큐리티 설정값들의 역할과 설정방법(2)
[스프링시큐리티] Spring Security 5.7 (WebSecurityConfigurerAdapter 에러해결방법)
좋은 글 감사합니다.
should not filter를 사용하려면 OncePerReuqestFilter 를 적용해야 할테고
GenericFilter 등의 Filter로는 해당 메소드가 없어서 적용되지 않을 겁니다. =)
authorizeRequests 전에 securityMatcher 를 거시면 security 를 적용할 path 만 넣어서 하실 수 있을거에요.
예제에서는 특정 uri만 빼고 적용하시려는 것이니깐 (해보진 않았으나) NegatedRequestMatcher 를 설정해서 넣으시면 되지 않을까 싶습니다.
이 경우엔 굳이 web.ignoring() 을 걸지 않아도 될 거에요.
좋은글 감사합니다