분명히 접근을 막았는데 왜 엉뚱한 곳으로 튈까
배경
- 회원가입 API:
/auth/join - Security 설정:
- 해당 URL 은
permitAll()로 접근 허용 CustomAccessDeniedHandler등록 OCustomAuthenticaionEntryPoint등록 X
- 해당 URL 은
SecurityConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
http
.csrf(csrf -> csrf.disable())
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(exception -> exception
//.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.accessDeniedHandler(new CustomAccessDeniedHandler()))
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
.anyRequest().authenticated())
.addFilterBefore(new JwtExceptionHandlerFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterAt(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
문제
/auth/join 에 요청을 보내니, 403 에러 코드를 응답받았다.
로그를 확인해보니 AuthorizationDeniedException 이 발생한 것을 확인할 수 있었다.
에러 로그
1
2
3
4
5
6
7
org.springframework.security.web.access.ExceptionTranslationFilter - Sending AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED],
Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]] to authentication entry point since access is denied
org.springframework.security.authorization.AuthorizationDeniedException: Access Denied
at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:99) ~[spring-security-web-6.5.3.jar:6.5.3]
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374) ~[spring-security-web-6.5.3.jar:6.5.3]
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:125) ~[spring-security-web-6.5.3.jar:6.5.3]
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:119) ~[spring-security-web-6.5.3.jar:6.5.3
에러를 확인하고, 코드를 확인해보니 POST 요청을 보내야하나, GET 으로 요청을 보내고 있는 것을 확인했다.
원인을 확인 한 후에, 문득 궁금한 점이 생겼다.
- 왜 403 에러 응답 코드를 받았지?
AuthorizationDeniedException발생했는데, 왜CustomAccessDeniedHandler가 동작하지 않았을까?
왜 403 에러 응답 코드를 받았을까?
나는 /auth/join 엔드포인트를 permitAll() 로 권한에 상관없이 모든 요청을 받을 수 있게 처리했다.
그런데 왜 인가 오류 코드인 403을 받은걸까?
로그를 분석해보자.
로그분석
1
2
3
4
5
6
7
8
9
10
11
TRACE org.springframework.security.web.FilterChainProxy - Invoking AuthorizationFilter (13/13)
TRACE RequestMatcherDelegatingAuthorizationManager - Authorizing GET /auth/join
DEBUG Secured GET /auth/join
WARN DefaultHandlerExceptionResolver - Resolved [HttpRequestMethodNotSupportedException: Request method 'GET' is not supported]
...
TRACE org.springframework.security.web.access.intercept.RequestMatcherDelegatingAuthorizationManager - Authorizing GET /error
TRACE org.springframework.security.web.access.intercept.RequestMatcherDelegatingAuthorizationManager - Checking authorization on GET /error using org.springframework.security.authorization.AuthenticatedAuthorizationManager@639dec8d
TRACE org.springframework.security.web.context.SupplierDeferredSecurityContext - Created SecurityContextImpl [Null authentication]
TRACE org.springframework.security.web.authentication.AnonymousAuthenticationFilter - Set SecurityContextHolder to AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]]
TRACE org.springframework.security.web.access.ExceptionTranslationFilter - Sending AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]] to authentication entry point since access is denied
org.springframework.security.authorization.AuthorizationDeniedException: Access Denied
GET /auth/join 요청
- /auth/join은 POST만 매핑되어 있으므로 GET 요청은 매핑이 안됨
HttpRequestMethodNotSupportedException: Request method 'GET' is not supported
- Spring MVC에서
HttpRequestMethodNotSupportedException발생 - 시큐리티는 permitAll이라 접근 자체는 허용 → Access Denied 발생하지 않음
Spring MVC의 기본 예외 처리
- 기본적으로 예외 발생 시 /error로 포워딩
- 이 /error 요청도 SecurityFilterChain을 다시 통과
/error 요청 시 SecurityFilterChain 흐름
- SecurityContext에 익명 사용자(AnonymousAuthenticationToken) 세팅
- AuthorizationFilter에서 접근 권한 검사 수행 → 로그에 Access Denied 메시지 출력
- 실제 403이 먼저 터진 것이 아니라, /error 를 거치며 찍힌 로그
흐름
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
1. GET /auth/join 요청
↓
2. Spring Security Filter Chain 통과 (permitAll()이므로 정상 통과)
↓
3. DispatcherServlet → AuthController 도달
↓
4. @PostMapping("/join")인데 GET 요청 → HttpRequestMethodNotSupportedException
↓
5. DefaultHandlerExceptionResolver가 405 처리
↓
6. Spring Boot가 자동으로 /error 경로로 포워드
↓
7. 새로운 요청: GET /error
↓
8. Spring Security Filter Chain 재실행
↓
9. /error는 permitAll() 설정에 없음 → anyRequest().authenticated() 적용
↓
10. AnonymousAuthenticationToken으로는 authenticated() 통과 불가
↓
11. AccessDeniedException 발생
결론
즉, 403이 먼저 터진 것처럼 보이지만 실제 원인은
GET 매핑 없음 → MVC 예외 → /error → 시큐리티 필터 로그 였던 것!!
AuthorizationDeniedException이 발생했는데, 왜 CustomAccessDeniedHandler가 동작하지 않았을까?
에러 로그를 보면 AuthorizationDeniedException 이 발생했지만, 등록해둔 CustomAccessDeniedHandler 가 호출되지 않았다. 시큐리티 내부 동작을 확인해보자.
ExceptionTranslationFilter가 하는일
ExceptionTranslationFilter는 시큐리티에서 인증/인가 예외를 처리하는 필터이다.
1
2
3
4
5
6
7
8
9
10
11
private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, RuntimeException exception) throws IOException, ServletException {
// 인증 관련 예외인경우
if (exception instanceof AuthenticationException) {
handleAuthenticationException(request, response, chain, (AuthenticationException) exception);
}
// 인가 관련 예외인경우
else if (exception instanceof AccessDeniedException) {
handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception);
}
}
- AuthenticationException → 인증 관련 예외 처리
- AccessDeniedException → 인가 관련 예외 처리
코드를 확인해보면 예외가 무엇인지 확인 후 각각 다른 처리를 하도록 되어있는 것을 확인할 수 있다.
인증 예외인 경우
1
2
3
4
5
6
7
8
9
10
11
// 인증 관련 예외의 경우 호출
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
AuthenticationException reason) throws ServletException, IOException {
// SEC-112: Clear the SecurityContextHolder's Authentication, as the
// existing Authentication is no longer considered valid
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
this.securityContextHolderStrategy.setContext(context);
this.requestCache.saveRequest(request, response);
// 👉 AuthenticationEntryPoint 호출
this.authenticationEntryPoint.commence(request, response, reason);
}
- 인증 예외 발생 시 AuthenticationEntryPoint가 처리 책임을 갖는 것을 확인할 수 있다.
- 인증되지 않는 사용자를 로그인 페이지나 API 로그인 응답(401)을 반환하는 역할을 수행한다.
인가 예외인 경우
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 인가 관련 예외인 경우 호출
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied",
authentication), exception);
}
AuthenticationException ex = new InsufficientAuthenticationException(
this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource"));
ex.setAuthenticationRequest(authentication);
// 👉 익명 사용자는 인증 예외처럼 처리
sendStartAuthentication(request, response, chain, ex);
}
else {
if (logger.isTraceEnabled()) {
logger.trace(
LogMessage.format("Sending %s to access denied handler since access is denied", authentication),
exception);
}
// 👉 실제 로그인 된 사용자는 AccessDeniedHandler 호출
this.accessDeniedHandler.handle(request, response, exception);
}
}
- 익명 사용자인 경우 → 인증 예외처럼 처리 (
AuthenticationEntryPoint호출) - 로그인된 사용자이거나 권한 부족 →
AccessDeniedHandler호출
결론
즉, 시큐리티는 /error 요청을 익명 사용자로 처리하였고, 해당 예외를 인가 예외로 판단한 후 AuthenticationEntryPoint 를 호출한 것이였다. 따라서 CustomAccessDeniedHandler 가 호출되지 않았던 것이다.