반응형
반응형

개요

 OAuth2와 JWT를 사용하여 인증 정책을 수립했다. 그런데 문제가 발생했다. 인증이 필요한 API 요청 시 403 에러가 발생하는 것이다. 헤더에 JWT 토큰도 잘 들어가 있고, 토큰 값을 통해 생성한 인증객체도 Security Context Holder에 들어가 있으며, 인가 권한은 ROLE_USER 인데 말이다. 원인을 파악해보자.

뜬금없이 발생하는 403


Security Filter Chain 주요 필터

Security Filter Chain 주요 필터

 

 원인을 찾기 전 현재 Security Filter의 구성이 어떻게 이루어져있는지 간단하게 정리해보았다.

 

1. OAuth2AuthorizationRequestRedirectFilter

 OAuth2AuthorizationRequestRedirectFilter에서는 yml 또는 properties에 설정한 값을 기반으로,  registrationId에 대한 OAuth2 인증 서버의 로그인 URI를 redirect 해준다.

 시큐리티 설정에 OAuth2 로그인 요청 URI는 아래와 같은 형식으로 정의해달라고 하는데, 그 이유가 바로 이 필터를 거치게 하려 함이다.

/oauth2/authorization/[registrationId]

 

2. OAuth2LoginAuthenticationFilter

OAuth2LoginAuthenticationFilter 에서 하는 일이 아주 많다.

 첫째, 로그인 성공 시 자신의 서버로 redirect 되는 authorization code를 받고,

 둘째,  authorization code 를 활용하여 OAuth2 인증 서버로 accessToken을 요청하고,

 셋째, accessToken을 활용하여 OAuth2 리소스 서버로 유저 정보를 요청한다.

 넷째, OAuth2UserService 구현체 인스턴스를 호출하며, 내부에서 Authentication 인스턴스를 생성한다.

 

 정리하면, OAuth2 인증을 받고, 인증 유저에 대한 정보를 획득한 후 인증 객체를 생성하는 역할이다.

 

참고로 인증이 성공하면 추후 AuthenticationSuccessHandler의 구현체 인스턴스를 호출하게 되는데 이때 JWT 토큰을 발급하며, 클라이언트는 이 값을 받아 헤더에 추가한다. (이건 필자의 인증 정책 중 하나이니 굳이 이해할 필요는 없다. JWT 를 발급한다는 것만 알면 된다.

 

3. JWT Filter

 헤더의 JWT 토큰 값을 추출 후 검증한다. 유효한 토큰일 경우 Authentication 객체를 생성하여 Security Context Holder 내에 저장한다.

 


로그인은 성공했으나, 인증된 자원에 대한 요청은 실패

 OAuth2 로그인은 성공하고 JWT 토큰도 잘 발급이 되고, 헤더로 유효한 JWT 토큰도 오는 걸 확인했으나, 인증이 필요한 API 요청에만 실패했다. SpringSecurity 설정에 인증 필터를 거치지 않도록 설정한 view/login, /error 등에 대해서는 403 에러가 발생하지 않는 점을 고려해봤을 때 시큐리티 필터에서 '인증' 관련 문제가 있음을 추정하게 되었다. 

public class SecurityConfig {

    private final UserRepository userRepository;

    private final JwtProvider jwtProvider;
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

        http
                .authorizeHttpRequests((authz -> authz
                        .requestMatchers("/api/**").hasRole("USER")
                        .anyRequest().authenticated())
                )
		...
        return http.build();
    }


    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().requestMatchers("/view/login", "/error", "/error/*", "/img/**", "/favicon.ico");
    }
}

 


발견되지 않는 에러 로그

 일반적으로 예외가 발생하면 에러 로그가 콘솔에 출력될텐데, 에러 로그가 출력되지 않았다. 시큐리티 로그에 /error 경로에 대한 요청이 들어오는걸로 봐서 내부 어딘가에서 예외가 발생했고, /error 경로로 리다이렉트 된것 같은데, 에러 로그가 보이지 않아 근원지를 찾을 수 없었다.

 


루트 로그 레벨을 trace 로 변경

 /error 리다이렉트 원인이 되는 로그가 분명 찍었을 것이라 판단하고 루트 로그 레벨을 trace로 변경해보았다. 그리고 다시 실행을 하니 원인을 찾을 수 있었다. AuthrozationFilter에서 AccessDeniedException이 발생했고, 이로 인해 /error 경로로 리다이렉트 되고 있던 것이었다. 놀랍게도 로그 레벨은 TRACE 였다. 

TRACE 에러는 왜...
TRACE 레벨로 발생하는 예외


AuthorizationFilter 예외 발생 원인 분석

 내부 소스를 확인해 보니 authrozationManager.check() 호출 후 얻은 AuthroziationDecision의 isGranted() 메서드의 호출 결과에 따라 Access Denied 예외가 발생함을 확인할 수 있었다. isGranted의 구현체 클래스를 따라가니 아래의 세 조건을 만족할 경우에만 true를 리턴하고 있었다.

예외 발생 부분
isGranted 메서드

 

  그런데 필자가 생성했던 Authentication 객체는... authenticated 값이 false 였던..것이었다.

지나갑니다~

 

 

아... 나 커스텀 Authentication 클래스 사용했었구나.

 

 

커스...터뮤ㅠㅠ

 


커스텀 Authentication 클래스 확인

 헐레벌떡 확인해보니 부모 클래스에 authenticated 값이 있었지만, 따로 설정해주지 않았고, 부모 생성자만 호출해서는 authenticated 값도 설정되지 않고 있었다. 그렇다. 따로 설정을 해줬어야 했다.

public class UserAuthenticationToken extends AbstractAuthenticationToken {

    private final Long principal;
    private final String credentials;


    public UserAuthenticationToken(Long userId, String email, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = userId;
        this.credentials = email;
    }

    @Override
    public String getCredentials() {
        return credentials;
    }

    @Override
    public Long getPrincipal() {
        return principal;
    }
}

 

 super.setAuthenticated(true) 코드를 넣어 authenticated 값을 true로 설정했다.

public UserAuthenticationToken(Long userId, String email, Collection<? extends GrantedAuthority> authorities) {
    super(authorities);
    super.setAuthenticated(true);
    this.principal = userId;
    this.credentials = email;
}

 


테스트

잘된다.   👷 (삽을 어디뒀더라...)


회고

 유저에 대한 '인가'를 하면 당연히 '인증'된 객체로 처리될 줄 알았으나, 안된다. 비록 삽질을 하긴 했지만 매우 건강한 삽질이 아니었나 싶다.

 Authentication 객체의 authenticated 값을 AuthorizationFilter 에서 확인한다는 것과 커스텀 Authentication 클래스를 사용할 때 권한을 인가할지라도 인증은 되지 않아 authenticated 값이 자동으로 설정되지 않는다는 것도 알게 되었다. 인증 객체의 부모 생성자 메서드를 좀더 자세히 확인했더라면, 이 문제는 발생하지 않았을 테지만 OAuth2 필터 복습도 하고, 인증 매커니즘에 대해 다시 한번 정리를 하게 된 건강한 삽질이었다!

 

 

반응형

+ Recent posts