🤨 문제 상황

Spring Security 5.6.2 에서 permitAll()로 권한 설정한 api를 요청했을 때 403 exception이 발생했다. permitAll인데 왜 403이 발생하지? 라는 생각이 들어 자세히 찾아보기로 했다.

@Configuration
@EnableWebSecurity
class WebSecurity {
    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .cors().and()
            .csrf().disable()
            .anonymous().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatcher(HttpMethod.GET, "/v{\\\\d+}/users**").permitAll()

        return http.build()
    }
}

 

위와 같이 security 설정을 하고 Debug 모드로 실행해보니, controller까지 요청이 들어오지 않았다.

→ security에서 403을 내려주는 것으로 보인다.

 

오잉? permitAll()은 해당 api의 요청을 모두 허용하는 것이 아닌가?

🧐 원인 분석

permitAll의 함수를 따라들어가보면,

public ExpressionInterceptUrlRegistry permitAll() {
    return access(permitAll);
}

ExpressionUrlAuthorizationConfigurer 클래스의 함수라는 것을 확인할 수 있다.

 

그런데 이름을 보다 보면, Authorization?? 인가를 확인하는 클래스네? 인증이 아닌 인가를 처리하는 곳에서 작업이 일어나는구나!

그럼 인증이 안돼서, 즉 SecurityContextHoder에 인증정보가 없어서 인증 안된 사용자라고 판단하는것인가? 라는 가설을 세울 수 있다.

 

FilterChain에서 발생한 에러는 ExceptionTranslation에서 처리할 것이다. 그러니 ExceptionTranslationFilter에서 어떤 에러가 발생하는지 확인하면서 올라가며 가설을 검증하는 것이 좋아보인다..

  • 참고
ExceptionTranslatorFilter 주석
Handles any AccessDeniedException and AuthenticationException thrown within the filter chain. This filter is necessary because it provides the bridge between Java exceptions and HTTP responses. It is solely concerned with maintaining the user interface. This filter does not do any actual security enforcement. If an AuthenticationException is detected, the filter will launch the authenticationEntryPoint. This allows common handling of authentication failures originating from any subclass of org. springframework. security. access. intercept. AbstractSecurityInterceptor.
// AbstractSecurityInterceptor.java 199 lines
...
if (SecurityContextHolder.getContext().getAuthentication() == null) {
    credentialsNotFound(this.messages.getMessage("AbstractSecurityInterceptor.authenticationNotFound",
    "An Authentication object was not found in the SecurityContext"), object, attributes);
}
...

 

ExceptionTranslationFilter 부터 쭉 타고 올라가다 보면, AbstractSecurityInterceptor 에서 위 코드와 같이 SecurityContextHolder에서 authentication을 찾고, 없으면 AuthenticationCredentialsNotFoundException 을 발생시킨다.

 

이 에러를 ExceptionTranslationFilter에서 try-catch로 잡아서 처리한다. 이 때, 아래 코드를 보면 되는데

// ExceptionTranslationFilter.java 178 lines
private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
            AuthenticationException exception) throws ServletException, IOException {
    this.logger.trace("Sending to authentication entry point since authentication failed", exception);
    sendStartAuthentication(request, response, chain, exception);
}

 

sendStartAuthentication 으로 보내게 된다. 그럼 해당 함수를 보자.

// ExceptionTranslationFilter.java 208 lines
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 = SecurityContextHolder.createEmptyContext();
    SecurityContextHolder.setContext(context);
    this.requestCache.saveRequest(request, response);
    this.authenticationEntryPoint.commence(request, response, reason);
}

 

여기서 authenticationEntryPoint로 보내는 것을 볼 수 있다. 즉 기본값인 Http403ForbiddenEntryPoint 로 보내져서 403이 발생하는 것이다.

 

즉, 어떤 인증도 수행하지 않아서 SecurityContext에는 인증정보가 없는 것이고, 그래서 authenticationEntryPoint로 보내져 403에러가 발생했던 것이다.

 

그럼 인증이 필요없는 API는 어떻게 처리하나?

 

그럴줄 알고, Spring Security에서는 AnonymousAuthenticationFilter를 만들어 두었다. 재밌는건, AnonymousAuthenticationFilter에서 isAuthenticated()는 true가 된다. 다른 말로는 SecurityContextHolder의 authentication에 익명사용자라는 AnonymousAuthenticationToken 을 설정해준다.

어떻게 보면 당연한 이야기인게, 인증하지 않은 사용자를 인가처리(permitAll 과 같은 작업) 하기 위해 익명사용자라는 것으로 감싸는 것이기 때문에, 인증이 되었다고 보지 않으면 위와 같은 403 에러가 발생할 것이다.

 

그럼 발생했던 문제는 어떻게 해결할 수 있나?

 

🖋️ 해결 방법

해결 방법은 단순하다.

.anonymous().disable() // 제거

anonymous를 disable하지 않으면 된다.

 

❓ 궁금한 점

그럼 authenticated()는 anonymous 사용자도 통과되나? isAuthenticated()가 true니까 통과될 것 같은데?
→ 실제로 해보면 안된다. 사실 되면 이상한거다.(spring이 그렇게 못 만들었을리 없다)

위 내용을 읽으면 눈치 챘겠지만, authenticated()는 인가의 역할이다. AnonymousAuthenticationToken 의 isAuthenticated()는 인증의 역할이다. 2개의 역할은 다르기 때문에 다른 시각에서 봐야한다.

인가는 AuthorizationFilter 에서 처리하는데, 코드를 한번 보자.

// AuthorizationFilter.java 54 lines
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {

    this.authorizationManager.verify(this::getAuthentication, request);
    filterChain.doFilter(request, response);
}


인가는 authorizationManager가 관리하고 있고, verify함수에서 처리한다.

// AuthorizationManager.java 41 lines
default void verify(Supplier<Authentication> authentication, T object) {
    AuthorizationDecision decision = check(authentication, object);
    if (decision != null && !decision.isGranted()) {
        throw new AccessDeniedException("Access Denied");
    }
}


유저가 authenticated 되어있는지 판단하는 manger는 AuthenticatedAuthorizationManager 이다. AuthorizationManger의 주석에는 다음과 같이 설명한다.
| An AuthorizationManager that determines if the current user is authenticated.

// AuthenticationAuthorizationManager.java 53 lines
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, T object) {
    boolean granted = isGranted(authentication.get());
    return new AuthorizationDecision(granted);
}

private boolean isGranted(Authentication authentication) {
    return authentication != null && isNotAnonymous(authentication) && authentication.isAuthenticated();
}

 

위 코드를 보면, anonymous일 때는 isGanted 가 false가 되는 것을 확인할 수 있다. 이렇게 인가작업인 authenticated()는 anonymous를 통과하지 못하도록 막는다.

 

잠시만, 그럼 permitAll에서도 막히는거 아니야??

 

Authority(hasRole, permitAll 등)을 판단하는 manager는 AuthorityAuthorizationManager 이다. 두 매니저가 다르기 때문에 로직도 다르다.

// AuthorityAuthorizationManager.java 125 lines
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, T object) {
    boolean granted = isGranted(authentication.get());
    return new AuthorityAuthorizationDecision(granted, this.authorities);
}

private boolean isGranted(Authentication authentication) {
    return authentication != null && authentication.isAuthenticated() && isAuthorized(authentication);
}

 

여기서는 authentication의 isAuthenticated() 를 확인하기 때문에 anonymous user는 통과할 수 있는 것이다.

 

또 잠시만, 그럼 hasRole() 설정에서 anonymousUser는 ExceptionTranslationFilterhandleAuthenticationException 로 가는게 아니고 handleAccessDeniedException로 처리되겠네? AuthorizationManager에서 걸리면 AccessDenied 아니여?

 

당연히 아니다. (Spring이 그렇게 멍청하지 않다니까?)

아래 코드를 봐보자.

// ExceptionTranslationFilter.java 184 lines
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
            FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
    Authentication authentication = SecurityContextHolder.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);
        }
        sendStartAuthentication(request, response, chain,
            new InsufficientAuthenticationException(
                this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
                    "Full authentication is required to access this resource")));
        }
        else {
            if (logger.isTraceEnabled()) {
                logger.trace(
                    LogMessage.format("Sending %s to access denied handler since access is denied", authentication),
                        exception);
            }
        this.accessDeniedHandler.handle(request, response, exception);
    }
}

 

코드를 유심히 보면, anonymous일 때 sendStartAuthentication() 를 호출하는 것을 확인할 수 있다. 실제로 테스트해보면 AuthenticationException으로 처리되고, entrypoint를 설정하면 401로 내려간다.

 

  • 유저가 로그인하지 않았을 때는 403이 아닌, 401을 내려주고 싶은데 어떻게 해?
    → 위에서 봤듯이, filterchain에서 AuthenticationException이 발생하고, 이를 잡아서 authenticationEntryPoint를 호출하는 것을 보았다. default값인 Http403ForbiddenEntryPoint 대신에 HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED) 으로 설정해주면 된다.
    http
        .exceptionHandling().authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
     

 

📒 정리

  1. webSecurity의 permitAll, authenticated, hasRole 등은 인가 관련 설정이다.
  2. 인증하지 않은 사용자도 해당 인가 설정을 적용하기 위해 spring security는 Anonymous 라는 개념을 사용한다.
  3. AuthenticationEntryPoint로 인증, AccessDeniedHandler로 인가 Exception을 custom하게 처리할 수 있다.

spring은 설정이 간편하지만, 그만큼 설정에 따라 많은것이 바뀐다. 그래서 잘 알고 쓰는게 확실히 중요하다고 느낀다.

'Spring' 카테고리의 다른 글

FilterChain을 2번 호출한다고?  (1) 2024.09.14
Spring @RequestParam 주의할 점  (0) 2024.08.16
테스트 환경 설정하기  (1) 2024.08.08

+ Recent posts