본문 바로가기

카테고리 없음

Spring Security에서 AuthenticationProvider를 추가하니 로그인 인증이 실패하기 시작했다

결론부터 말하자면, 직접 구현한 AuthenticationProvider를 빈으로 등록해서다.

 


본래 필터 체인에 등록해 사용하던 Jwt 인증 필터는 Authorization 헤더에서 액세스 토큰을 얻고, 검증한 뒤 authentication 객체를 SecurityContextHolder에 등록하는 구조로 구성했었다. 

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final List<RequestMatcher> WHITELIST = List.of(
        new AntPathRequestMatcher("/auth/login")
    );
    private final JwtUtils jwtUtils;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String accessToken = JwtUtils.extractAccessToken(request);

        jwtUtils.checkTokenValid(accessToken, TokenType.ACCESS);

        Authentication authentication = jwtUtils.getAuthentication(accessToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        filterChain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        return WHITELIST.stream().anyMatch(matcher -> matcher.matches(request));
    }
}

 

토큰의 claims에서 아이디를 구해 user를 조회하고, UserDetails 인스턴스를 생성해 Authentication 객체를 구해 반환하는 메서드를 JwtUtils라는 편의 클래스에 구현해 사용했었다.

 

그러다 JwtUtils에 너무 많은 기능들이 몰려있는 게 보여서 SRP 원칙에 맞게 역할과 책임을 분리하다가, JwtAuthenticationFilter 또한 UsernamePasswordAuthenticationFilter와 같이 AuthenticationManager에게 인증을 위임해 책임을 분리할 수 있을 거라는 생각이 들었다.

 

다음은 당시 변경한 코드이다.

@Getter
public class JwtAuthenticationToken extends AbstractAuthenticationToken {

    private final Object principal;
    private final Object credentials;

    public JwtAuthenticationToken(String accessToken) {
        super(null);
        this.principal = null;
        this.credentials = accessToken;
        super.setAuthenticated(false);
    }

    public JwtAuthenticationToken(Object principal, Object accessToken, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = accessToken;
        super.setAuthenticated(true);
    }

    public static JwtAuthenticationToken unauthenticated(String accessToken) {
        return new JwtAuthenticationToken(accessToken);
    }

    public static JwtAuthenticationToken authenticated(Object principal, Object accessToken, Collection<? extends GrantedAuthority> authorities) {
        return new JwtAuthenticationToken(principal, accessToken, authorities);
    }

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

    @Override
    public Object getCredentials() {
        return credentials;
    }
}
@Setter
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final List<RequestMatcher> WHITELIST = List.of(
        new AntPathRequestMatcher("/auth/login")
    );
    private AuthenticationManager authenticationManager;

    public void afterPropertiesSet() {
        Assert.notNull(this.authenticationManager, "authenticationManager must be specified");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String accessToken = JwtExtractor.extractAccessToken(request);
        JwtAuthenticationToken token = JwtAuthenticationToken.unauthenticated(accessToken);

        Authentication authentication = authenticationManager.authenticate(token);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        filterChain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        return WHITELIST.stream().anyMatch(matcher -> matcher.matches(request));
    }
}
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationProvider implements AuthenticationProvider {

    private final JwtTokenParser jwtTokenParser;
    private final JwtTokenValidator jwtTokenValidator;
    private final UserRepository userRepository;

    @Override
    @Transactional(readOnly = true)
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String accessToken = (String) authentication.getCredentials();
        jwtTokenValidator.validate(accessToken, TokenType.ACCESS);

        Claims claims = jwtTokenParser.parseClaims(accessToken);

        String loginId = claims.getSubject();
        User user = userRepository.findByLoginId(loginId)
            .orElseThrow(() -> new CustomException(USER_NOT_FOUND));

        UserDetails userDetails = new DuktownUserDetails(user);

        return JwtAuthenticationToken.authenticated(userDetails, null, userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return JwtAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

 

 

 

Authentication타입 클래스 자체를 새로 만들었다. (JwtAuthenticationToken)


JwtAuthenticationFilter에서 해당 토큰을 만들어 AuthenticationManager에서 인증을 요청하면, 구현체인 ProviderManager가 등록된 AuthenticationProvider의 supports를 실행해 해당 인증을 처리할 수 있는 대상을 찾는다.

JwtAuthenticationProvider에서 해당 인증 객체를 지원하므로, 토큰을 검증하고, 사용자를 조회해 UserDetails 인스턴스를 생성해 인증 처리된 Authentication 객체를 반환한다.

 

 

전에는 JwtAuthenticationFilter를 Component로 등록해 SecurityConfig에 주입받아 필터 체인을 구성했는데, AuthenticationManager 사용하는 방식으로 변경하며 SecurityConfig에 직접 빈으로 등록하는 방식으로 변경했다.

    @Bean
    public LoginAuthenticationFilter loginAuthenticationFilter() {
        LoginAuthenticationFilter filter = new LoginAuthenticationFilter(objectMapper);
        filter.setFilterProcessesUrl(LOGIN_PATH);
        filter.setAuthenticationManager(authenticationManager());
        filter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
        filter.setAuthenticationFailureHandler(authenticationFailureHandler);
        return filter;
    }

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        JwtAuthenticationFilter filter = new JwtAuthenticationFilter();
        filter.setAuthenticationManager(authenticationManager());
        return filter;
    }

 

설정해주는 AuthenticationManager 빈은 아래와 같이 또 빈으로 등록해 주었었다.

@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
    return authenticationConfiguration.getAuthenticationManager();

 

AuthenticationManager 자체는 SecurityConfig에서 주입받을 수 없기 때문에, AuthenticationConfiguration의 getAuthenticationManager()를 활용해 빈을 등록해 주었었다.

 

 

하지만 여기서 문제가 발생했다.

로그인이 실패한다.

 

여기서부터 엄청나게 고민했다.

supports를 잘못 구현했나... 설마 UsernamePasswordAuthenticationToken과 JwtAuthenticationToken이 같은 상위 클래스를 구현했다고 인식을 못하나... JwtAuthenticationFilter의 WHITELIST를 잘못 설정했나... 아니면 SecurityConfig 문제...?

디버깅하면서 오만 군데 로그를 찍고 삽질을 했었다.

 

그리고 마지막으로 찜찜했던 AuthenticationManager 설정에 관해 찾아보고 원인을 찾을 수 있었다.

https://medium.com/@jojiapp/spring-security-5-7-0-m2-%EB%B6%80%ED%84%B0-%EB%B3%80%EA%B2%BD%EB%90%9C-%EC%84%A4%EC%A0%95-%EB%B0%A9%EB%B2%95-d85311ee1f54

 

[Spring Security] 5.7.0-M2 부터 변경된 설정 방법

WebSecurityConfigurerAdapter (Deprecated)

medium.com

 

이 게시글을 참고했다.

아래 설명은 spring security 6.4.4 버전 기준이다.


AuthenticationConfiguration 클래스는 스프링에서 기본적으로 등록되는 `@Configuration` 클래스이다.

이 클래스는 AuthenticationManagerBuilder를 빈으로 등록하고, 이후 인증 관련 전반적인 설정을 담당한다.

내부 클래스인 DefaultPasswordEncoderAuthenticationManagerBuilder는 기본적으로 DaoAuthenticationProvider를 생성하는 userDetailsService(...) 메서드를 제공한다. 

따라서 UserDetailsService 빈만 등록하면, 해당 메서드가 내부적으로 호출되어 DaoAuthenticationProvider가 자동으로 등록된다.

 

아래는 AuthenticationConfiguration 클래스의 getAuthenticationManager() 메서드이다.

public AuthenticationManager getAuthenticationManager() throws Exception {
    if (this.authenticationManagerInitialized) {
        return this.authenticationManager;
    } else {
        AuthenticationManagerBuilder authBuilder = (AuthenticationManagerBuilder)this.applicationContext.getBean(AuthenticationManagerBuilder.class);
        if (this.buildingAuthenticationManager.getAndSet(true)) {
            return new AuthenticationManagerDelegator(authBuilder);
        } else {
            Iterator var2 = this.globalAuthConfigurers.iterator();

            while(var2.hasNext()) {
                GlobalAuthenticationConfigurerAdapter config = (GlobalAuthenticationConfigurerAdapter)var2.next();
                authBuilder.apply(config);
            }

            this.authenticationManager = (AuthenticationManager)authBuilder.build();
            if (this.authenticationManager == null) {
                this.authenticationManager = this.getAuthenticationManagerBean();
            }

            this.authenticationManagerInitialized = true;
            return this.authenticationManager;
        }
    }
}

 

이 메서드는 다음과 같은 흐름으로 동작한다.

1. AuthenticationManager가 이미 초기화되어 있다면 바로 반환한다.

2. AuthenticationManagerBuilder를 빈으로부터 가져온다.

3. 다른 스레드에서 이미 빌드 중이라면 AuthenticationManagerDelegator를 반환한다.

4. 아니면 등록된 GlobalAuthenticationConfigurerAdapter들을 순회해 authBuilder.apply(...)를 통해 설정을 적용한다.

5. 최종적으로 build()를 호출하여 ProviderManager를 설정한다.

 

AuthenticationManagerBuilder는 인증 설정을 구성할 수 있는 빌더로, 내부적으로 List<AuthenticationProvider>를 관리한다.

여기에 DaoAuthenticationProvider가 등록되는 시점은 GlobalAuthenticationConfigurerAdapter 구현체 중 하나인 InitializeUserDetailsBeanManagerConfigurer의 configure()이 호출되면서다.

이때 해당 클래스는 내부적으로 UserDetailsService, PasswordEncoder 등을 조회해 DaoAuthenticationProvider를 직접 생성하여 builder.authenticationProvider(...)를 통해 명시적으로 등록한다.


 

나는 현재 UserDetailsService와 PasswordEncoder를 빈으로 등록해 사용 중이어서 앞서 설명한 내용대로 당연히 DaoAuthenticationProvider가 등록되어 사용되는 줄 알았다.

그런데 디버깅 결과는 좀 달랐다.

 

SecurityConfig에 아래 코드 한 줄을 추가하고 해당 라인을 breakPoint로 해 디버깅했다.

.authenticationProvider(jwtAuthenticationProvider)

 

step into하다보면 이런 코드를 만난다.

public AuthenticationManagerBuilder authenticationProvider(AuthenticationProvider authenticationProvider) {
    this.authenticationProviders.add(authenticationProvider);
    return this;
}

AuthenticationManagerBuilder.class 내부의 코드인데, add 직전까지는 해당 리스트 사이즈가 0이다.

 

DaoAuthenticationProvider가 등록되지 않는다!

 

원인은 다음과 같다.

InitializeUserDetailsBeanManagerConfigurer의 내부에는 isConfigured()가 true일 때 warn 로그를 찍는 코드가 있다.

if (auth.isConfigured()) {
    if (beanNames.length > 0) {
        this.logger.warn("Global AuthenticationManager configured with an AuthenticationProvider bean. UserDetailsService beans will not be used by Spring Security for automatically configuring username/password login. Consider removing the AuthenticationProvider bean. Alternatively, consider using the UserDetailsService in a manually instantiated DaoAuthenticationProvider. If the current configuration is intentional, to turn off this warning, increase the logging level of 'org.springframework.security.config.annotation.authentication.configuration.InitializeUserDetailsBeanManagerConfigurer' to ERROR");
    }

}

 

isConfigured()를 찾아보면 다음과 같다.

public boolean isConfigured() {
    return !this.authenticationProviders.isEmpty() || this.parentAuthenticationManager != null;
}

하나라도 AuthenticationProvider가 등록되어 있거나, 부모 AuthenticationManager가 등록되어 있으면 더 이상 자동 구성을 하지 않겠다는 뜻이다.

 

 

결국 JwtAuthenticationProvider를 Component로 등록하고, DaoAuthenticationProvider에 대해서는 아무 조치도 해주지 않았기 때문에 로그인이 되지 않는 문제가 발생했던 것이었다.

 

 

따라서 authenticationManager 빈 등록 메서드를 다음과 같이 수정했다.

public AuthenticationManager authenticationManager() throws Exception {
    DaoAuthenticationProvider dao = new DaoAuthenticationProvider();
    dao.setUserDetailsService(userDetailsService);
    dao.setPasswordEncoder(passwordEncoder());

    authenticationManagerBuilder
        .authenticationProvider(dao)
        .authenticationProvider(jwtAuthenticationProvider);

    return authenticationConfiguration.getAuthenticationManager();
}

AuthenticationManagerBuilder를 이용해 직접 생성한 DaoAuthenticationProvider와, 주입받은 JwtAuthenticationProvider를 등록한다.

 

Global AuthenticationManager configured with an AuthenticationProvider bean. UserDetailsService beans will not be used by Spring Security for automatically configuring username/password login. Consider removing the AuthenticationProvider bean. Alternatively, consider using the UserDetailsService in a manually instantiated DaoAuthenticationProvider. If the current configuration is intentional, to turn off this warning, increase the logging level of 'org.springframework.security.config.annotation.authentication.configuration.InitializeUserDetailsBeanManagerConfigurer' to ERROR

이런 WARN 로그가 찍히고 있었는데 알아차리는 게 매우 늦었다...

물론 수정했다고 해서 WARN 로그가 사라지지는 않는다. 수동 등록했기 때문에 위 로그가 떠도 정상이다.

 

 

 

스프링 시큐리티는 공부할수록 끝이 없는 것 같다.

맨날 문제 터지면 무한 공부 반복

5버전은 거의 아예 모른다. 6만 알아가는 중.

다음에는 또 어떤 문제가 터질지 아주 흥미진진하다....