포스트

분명히 접근을 막았는데 왜 엉뚱한 곳으로 튈까

분명히 접근을 막았는데 왜 엉뚱한 곳으로 튈까

배경

  • 회원가입 API: /auth/join
  • Security 설정:
    • 해당 URL 은 permitAll()로 접근 허용
    • CustomAccessDeniedHandler 등록 O
    • CustomAuthenticaionEntryPoint 등록 X

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 으로 요청을 보내고 있는 것을 확인했다.

원인을 확인 한 후에, 문득 궁금한 점이 생겼다.

  1. 왜 403 에러 응답 코드를 받았지?
  2. 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 가 호출되지 않았던 것이다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.