스프링 시큐리티를 사용하며 의도하지 않은 403 에러가 떴을 때가 제일 스트레스였던 몇 주가 흘렀습니다. 그 동안 공부했던 내용을 한번 정리해보면 다음엔 고생을 덜 하지않을까 싶기도 하고 그 동안 인증에 대해 놓쳤던 개념(특히 익명유저)에 대해 정리해보고자 합니다.
스프링 시큐리티 필터 순서
FilterChainProxy
가 사실상 스프링 시큐리티 필터 동작의 시작점이기 때문에 디버깅하기에 적당한 곳입니다. 여기서 확인해보니 12개의 필터가 기본적으로 등록되어 있습니다. 모든 것을 알기 어렵지만 제일 중요한 사실은 인증 (Authentication) 후 인가 (Authorization) 필터가 동작한다는 사실입니다.
그 외에도 여러가지 필터는 표에서 살펴보면 됩니다.
Filter | Added by |
---|---|
CsrfFilter | HttpSecurity#csrf |
UsernamePasswordAuthenticationFilter | HttpSecurity#formLogin |
BasicAuthenticationFilter | HttpSecurity#httpBasic |
AuthorizationFilter | HttpSecurity#authorizeHttpRequests |
인증 Authentication
사실상 중요한 부분이라 영문 그대로 가져와봤습니다.
- First, the
ExceptionTranslationFilter
invokesFilterChain.doFilter(request, response)
to invoke the rest of the application. - If the user is not authenticated or it is an
AuthenticationException
, then Start Authentication.- The SecurityContextHolder is cleared out.
- The
HttpServletRequest
is saved so that it can be used to replay the original request once authentication is successful. - The
AuthenticationEntryPoint
is used to request credentials from the client. For example, it might redirect to a log in page or send aWWW-Authenticate
header.
- Otherwise, if it is an
AccessDeniedException
, then Access Denied. TheAccessDeniedHandler
is invoked to handle access denied.
If the application does not throw an AccessDeniedException
or an AuthenticationException
, then ExceptionTranslationFilter
does not do anything.
The pseudocode for ExceptionTranslationFilter
looks something like this:
ExceptionTranslationFilter pseudocode
try {
filterChain.doFilter(request, response);
} catch (AccessDeniedException | AuthenticationException ex) {
if (!authenticated || ex instanceof AuthenticationException) {
startAuthentication();
} else {
accessDenied();
}
}
요약하자면 다음과 같습니다.
- ExceptionTranslationFilter 가 인증 후의 에러를 처리 (필터의 순서를 확인해보면 인증 → ExceptionTranslationFilter → 인가 순서)
- 인증이 안된 유저가 들어오면
AuthenticationEntryPoint
를 실행시켜 인증을 요구 (ex. 로그인 페이지로 리다이렉트) - 인증 과정에서 에러가 없다면 다음 필터를 정상적으로 실행하지만 아니라면
AccessDeniedHandler
실행
예전에 스프링 시큐리티 구글링을 많이 해봤는데 AuthenticationEntryPoint 를 인증이 안됐을때 실행하는 핸들러, AccessDeniedHandler 가 인가가 안됐을때 실행되는 핸들러로 소개하는 글이 많았습니다. 어찌보면 맞는 이야기긴 하지만 사실상 AuthenticationEntryPoint 는 예외처리 핸들러가 아니라 인증 정보를 요구하는 핸들러입니다. 예를 들어, 인증되지 않은 유저가 오면 인증 정보를 가져오도록 유도 (ex. 로그인 페이지로 리다이렉트) 합니다.
Request Credentials with AuthenticationEntryPoint
AuthenticationEntryPoint
is used to send an HTTP response that requests credentials from a client.
Sometimes, a client proactively includes credentials (such as a username and password) to request a resource. In these cases, Spring Security does not need to provide an HTTP response that requests credentials from the client, since they are already included.
In other cases, a client makes an unauthenticated request to a resource that they are not authorized to access. In this case, an implementation of AuthenticationEntryPoint
is used to request credentials from the client. The AuthenticationEntryPoint
implementation might perform a redirect to a log in page, respond with an WWW-Authenticate header, or take other action.
인가 Authorization
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http.authorizeHttpRequests {
it.requestMatchers("/authenticated").authenticated()
**it.requestMatchers("/permit", "/hello/error", "/error").permitAll()**
}
return http.build();
}
}
authorizeHttpReqquests 에서 자주 사용되는 메소드
- authenticated() : 인증된 유저여야함.
하지만 익명 유저는 안됨 - permitAll(): no authorization required
여기서 의문점은 인증된 유저에 대한 정의였습니다. 특히 permitAll 인가를 받으려면 그 전에 인증이 되어야하는데 저는 분명 인증을 하는 로직을 넣은 적이 없기 때문입니다. 어떻게 인증이 안된 유저가 permitAll 인가 로직까지 진입될 수 있는지에 대한 의문이 생겼습니다.
실험삼아 permitAll 앤드포인트 하나를 만들고 SecurityContextHolder.getContext().authentication.isAuthenticated
가 뭐일지 로그를 찍어봤습니다. 저는 인증에 대한 로직을 넣은 적이 없기 때문에 당연히 False
가 나올 것이라 예상했습니다.
하지만 True
즉, 인증된 유저라고 떠버렸습니다. 왜 그랬을까요?
왜 따로 인증로직을 거치지 않았는데 isAuthenticated = true?
그 이유는 AnonymousAuthenticationFilter에서 Authentication 객체를 넣어주며 isAuthenticated = true 를 만들어 버렸기 때문이었습니다.
- anonymous user란?
- 어플리케이션에서 모든 유저에 대한 인증은 쉽지 않습니다. 예를 들어 네이버 웹은 일반 방문자와 회원이 둘다 공존하는데 일반 방문자 모두를 인증하는 것은 쉽지 않기 때문입니다.
- 그래서 오히려 인증된 유저와 인증되지 않은 유저로 구별하는 것보다는 익명 유저가 무슨 권한을 갖고 있느냐로 분류하는게 더 좋을 때가 있습니다.
- 모든 유저를 anonymous 유저라는 인증 정보를 넣어주고 그 다음에 Authorization 인가 처리를 통해 권한 처리를 해주는 것이 더 나은 것이라고 생각하여 생긴 개념입니다.
- AnonymousAuthenticationFilter 에서 AnonymousAuthenticationToken 를 인증 정보로 넣어줬기 때문에 인증 로직을 거치지 않아도 인증된 유저가 된 것입니다.
익명유저는 인증된 유저가 아니다?
그렇다면 익명 유저로서 인증을 받은 유저는 http.authorizeHttpRequests {it.requestMatchers("/authenticated").authenticated()}
의 authenticated 앤드포인트를 통과할 수 있을까요?
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http.authorizeHttpRequests {
it.requestMatchers("/authenticated").authenticated()
}
return http.build();
}
}
AccessForbidden 이 떴습니다. 즉 익명유저는 인증된 유저는 맞으나 인가 로직에서 말하는 인증된 유저가 아닌 것입니다. 그 이유를 디버깅 해보았습니다.
authorization filter 에서 check 를 하는데 이때 authentication 은 anonymous authentication token입니다.
isGranted 에서 isAnonymous = true 이기 때문에 isGranted = false 로 반환되고요
결론적으로 AuthorizationFilter 에서 decision.isGranted = false 이기 때문에 AccessDenied 가 발생했습니다. 우리는 익명유저라는 인증정보를 갖고있기 때문에 인가 로직에서도 authenticated
조건을 통과하길 기대했습니다. SecurityContextHolder.getContext().getAuthentication()
는 isAuthenticated = true 를 반환할 것이지만 인가에서 말하는 authenticated 은 인증된 유저이면서도 익명유저가 아님을 기대합니다. authentication 에 anonymous authentication token 를 갖고있어 인증된 유저는 맞지만 authorization filter 에서 anonymous 는 허용하지 않기 때문에 결국엔 접근 금지라는 결과가 나왔습니다.
3줄 요약
- AnonymousAuthenticationFilter 에서 특별한 인증 로직을 거치지 않는다면 익명유저라는 인증 정보를 넣어준다.
- 따라서 익명 유저는
SecurityContextHolder.getContext().getAuthentication().isAuthenticated = true
- 다만 Authorization
http.authorizeHttpRequests {it.requestMatchers("/authenticated").authenticated()}
에서 말하는 인증은 인증된 유저이면서도 익명 유저이면 안된다. 따라서 익명유저라면 접근금지가 된다.
그렇다면 또다시 의문이 생깁니다. 스프링 시큐리티 유저들이라면 UsernamePasswordAuthenticationFilter 를 통해 시큐리티 컨텍스트를 넣어줍니다. 그리고 순서상 UsernamePasswordAuthenticationFilter 다음에 AnonymousFilter가 옵니다. 그렇다면 AnonymousFilter 에서 익명유저라고 시큐리티 컨텍스트를 오버라이딩해서 인증 로직이 정상 동작하지 않을텐데 왜 평소에는 AnonymousFilter 를 신경쓰지 않아도 잘 작동했던 것일까요?
UsernamePasswordAuthenticationFilter 다음에 AnonymousFilter 를 사용하는데 왜 덮어씌워지지 않는가?
디버깅 해본 결과 AnonymousFilter 에서 이미 context 가 저장되어 있으면 원래 context 를 반환하기 때문입니다. 즉 원래 컨텍스트가 있다면 오버라이딩을 하지 않습니다.
번외 에러 삽질
Q1. 모든 에러가 403 AccessForbidden 으로 떠요
스프링은 에러가 발생하면 기본적으로 ModelAndView(”/error”) 를 반환합니다. 이때 에러페이지에 대한 인가가 안되어 있기 때문에 모든 에러가 403으로 뜨게 됩니다.
해결 방안1. /error permitAll()
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http.authorizeHttpRequests {
it.requestMatchers("/authenticated").authenticated()
it.requestMatchers("/permit", "/hello/error", **"/error").permitAll()**
}
return http.build();
}
}
“/error” 앤드포인트에 대해 인가 허용을 해주면 에러 페이지가 403으로 막히는 일이 없게 됩니다. 이제서야 원래 뜨던 500, 404 에러들이 정상 출력됩니다.it.requestMatchers("/error").permitAll()
로 허용을 해줄 수 있습니다.
해결 방안2. dispatcherType 으로 허용
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http.authorizeHttpRequests {
**it.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()**
it.requestMatchers("/authenticated").authenticated()
it.requestMatchers("/permit", "/hello/error", ).permitAll()
}
return http.build();
}
}
그 외에도 에러 페이지 자체에 대한 인가를 하는 것이 아닌 타입으로 인가를 해주는 방법이 있습니다. it.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
를 해주면 모든 에러에 대해 인가를 해줄 수 있습니다.
Q2. permitAll() 이 안 먹혀요
permitAll() 를 해주더라도 모든 필터를 거치게 됩니다. 마지막 인가 단계에서 인가 관련된 허용을 해줬을 뿐이지 모든 스프링 필터를 거치게 됩니다. 스프링 필터를 거치고 싶지 않다면 web.ignoring()
를 사용해야 합니다.
web.ignoring() vs. permitAll()
- web.ignoring() : 시큐리티 필터를 아예 거치지 않음
- permitAll() : 인증된 유저 + 모든 인가를 해준다
Q3. GET 요청은 괜찮지만 POST 요청 모두 403 에러가 나타나요
GET 요청은 해당이 안되지만 POST 요청은 csrf 토큰을 확인합니다. csrf 토큰에 의해 403 에러가 발생하고 있던 이슈이므로 csrf.disable() 을 해주면 해결됩니다.
Q4. api 별로 다른 필터를 걸고 싶어요
시큐리티 필터체인을 여러개 등록하면 됩니다.
@Configuration
@EnableWebSecurity
public class MultiHttpSecurityConfig {
@Bean
public UserDetailsService userDetailsService() throws Exception {
// ensure the passwords are encoded properly
UserBuilder users = User.withDefaultPasswordEncoder();
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(users.username("user").password("password").roles("USER").build());
manager.createUser(users.username("admin").password("password").roles("USER","ADMIN").build());
return manager;
}
@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(authorize -> authorize
.anyRequest().hasRole("ADMIN")
)
.httpBasic(withDefaults());
return http.build();
}
@Bean
public SecurityFilterChain formLoginFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.formLogin(withDefaults());
return http.build();
}
}
Q5. web.ignoring 이 안 먹혀요 + 필터는 @Component 등록 함
저도 이전에 필터 체인에 사용되는 커스텀 필터를 싱글톤으로 등록하고 가져다 쓰기 위해 스프링 빈으로 등록하여 사용했었습니다. 하지만 이 방법의 문제점은 결국에 SecurityContext 의 필터가 아닌 ApplicationContext 의 필터로 등록된다는 점입니다. 따라서 web.ignoring 과 상관없이 항상 필터를 통과하기 때문에 정상작동이 되진 않습니다.
그렇기 때문에 필터는 컴포넌트 등록을 하지말고 security filter chain 에 직접 인스턴스화하는 것을 권장합니다!
참고
'☘️Spring' 카테고리의 다른 글
[Spring] 스프링 시큐리티 기초 정리 (1) | 2024.01.21 |
---|---|
[Spring] JPA N+1 문제 해결 (35) | 2023.10.30 |
[Spring] MockMvcTest vs End-to-End Tests (1) | 2023.10.26 |
[Spring] web mvc 코드로 이해하기 (0) | 2023.09.02 |
[Spring] 의존관계 자동/수동 주입 (0) | 2023.07.09 |