JWT를 사용할때 UserDetailsService를 직접 사용하는 방식에 대한 생각

신예찬·2025년 8월 28일
post-thumbnail

보안 처리에 대해...

서버 애플리케이션은 요청이 집중되고 다수의 민감한 정보를 가지고 있는 경우가 대다수이기 때문에 보안 문제에 매우 민감하다. 그렇기에 Spring에서는 Spring Security를 통한 보안 인터페이스를 제공한다. 이러한 이유로 대부분의 Spring 기반 Framework를 사용한 애플리케이션을 개발할때 Spring Security를 사용해 보안처리를 하게 된다.


인증/인가를 위한 보안 처리 방식에는 통상 Session 기법과 Token 기법 두가지를 사용한다. 이번 글에서 다루어볼 내용은 JWT Token 기반 인증 처리 방식이다. JWT Token 기반 인증 처리 방식을 사용할때 Spring Security 의 UserDetailsService를 여러 블로그에서 사용하는 글을 봤는데 이것에 대한 나의 생각과 어떤 방향으로 전환하면 좋을지에 대해 글을 풀어나가 보겠다.


Servlet Filter


사용자의 인증이 어느 영역에서 이루어지는지를 먼저 생각해볼 필요가 있다. 요청에 대한 인증 처리는 과거 자바의 웹 서비스를 지원하기 시작한 시점에 Servlet에서도 요구되는 스펙이었다. 그렇기에 기본적으로 Servlet단의 보안 기능을 확장한것이 Spring의 보안처리 방식이다. Spring Security는 이러한 Servlet의 보안 처리 주요 메커니즘인 Filter를 중심으로 기능이 확장되었다. 모든 요청은 FilterChainProxy를 통해 보안 필터를 통과하게 된다. 요청은 이런 Filter들의 순차적 집합체인 FilterChain을 하나씩 통과하게 된다. 이때 전달되는 요청을 Servlet 영역에서 HttpServletRequest로 전달되고, 응답은 HttpServletResponse로 전달된다. 필요에따라 filter단에서 이를 조회 및 조작한다.


Spring Boot에서는 이런 FilterChain에 추가적인 Filter를 ApplicationContext에서 관리중인 GenericFilterBean를 등록한다. 그렇기에 GenericFilterBean를 상속한 filter들은 별도의 설정을 하지 않아도 filter chain에 등록되게 된다.

그리고 이러한 설정은 Spring Security의 FiterChainProxy가 지원하는데, 설정하기 쉽도록 Spring Security는 이를 SecurityFilterChain을 커스텀 가능한 bean 형태로 제공하고 있다. 실습 코드에서 이 부분을 잘 확인해봐도 좋을거 같다.


인증

SecurityContext

인증을 하기 위한 filter에 도달하게 되면 개발자가 해야할 일은 딱 하나다. 해당 요청의 인증정보를 보관할 SecurityContextHolder에 인증정보를 담아두는 것이다. 사용자를 식별하고, 인증 하기 위한 Authenticaiton 객체를 저장하여 사용자의 인증정보를 호출 할 수 있도록 static call 가능한 형태로 API를 제공해주고 있다.

Authenticaiton 세가지 정보를 필수적으로 요구한다.

  • principal: 사용자를 식별한다.
  • credentials: 자격증명 수단으로 보통 비밀번호를 많이 사용한다.
  • authorities: 사용자에게 부여된 권한으로 GrantedAuthorityCollection이다. role이나 scope에 해당한다.

AuthenticationProvider

사용자가 SecurityContextHolder를 통해 등록한 Authentication 객체는 AuthenticationProvider에 의해 인증정보를 하게 된다. 그리고 이 AuthenticationProviderAuthenticationManager에 의해 관리된다. SecurityContextHolder에 등록한 인증정보가 AuthenticationManager에 의해 인증여부를 provider들에게 전달하게 된다.


이 provider들은 기본적으로 Authentication을 검증하는데, 각 provider들은 어떤 Authentication의 구현체를 처리할지 지정할 수 있다. 예를들어, UsernamePasswordAuthenticationTokenDaoAuthenticaiutonProvider에 의해 인증받는다. 그리고 실제 인증정보를 가져오기 위해서 UserDetailsService의 구현체를 통해 인증정보를 UserDetails 형태로 가져온다. 이 예시는 후에 나오는 JWT 인증을 구현하는 방식과 관련있으니 기억해두자.


아래 다이어그램을 기준으로 다시한번 흐름을 정리해보자.

  1. 인증 요청이 filter단으로 들어온다. 그리고 그 filter는 인증을 위한 AuthenticaitonAuthenticationManager에 전달한다.
  2. 인증을 하기 위해 AuthenticationManager는 내부적으로 하나 이상의 AuthenticationProvider를 관리하며, supports() 메서드로 해당 Authentication 타입을 처리할 수 있는 provider를 찾는다.
  3. 찾게된 AuthenticationProvider는 해당 특정 타입의 Authentication 구현체를 전달받아 Authenticationprincipal, credentials등을 사용해 인증정보를 새로이 Authentication으로 만들어 반환한다.
  4. 반환 AuthenticationSecurityContextHolder에 담겨 이후 인증단계에서 접근이 가능해진다.

JWT with Spring Security

이제 Spring Security를 사용해서 JWT 인증 처리를 하는 과정을 살펴보자. 사실 위에서 설명한 흐름에서 우리가 해야할 것은 구현부를 작성하고 알맞게 설정을 지정하는 일이 전부다. 구현해야할 부분을 정리해보자면,

Filter

  • 인증을 위한 토큰을 발급해 SecurityContextHolder에 넘겨줘야한다
  • SecurityFilterChain에 설정하며 이때 순서를 지정해준다.
  • 이때 어떤 인증 토큰을 넘길지 명시해야한다. 정확히는 Authentication 구현체를 명확히 지정해야한다.

AuthenticationManager

  • 인증 정보 제공자를 등록한다. SecurityFilterChain에 이를 지정하여 사용한다. 함수형 인터페이스기 때문에 직접적인 구현은 함수형으로 대체 가능하다.

AuthenticationProvider

  • 인증 정보 제공 방식을 선택한다. 처리할 AuthenticationTokensupport()문에 지정하고, authenticate()에서 해당 토큰을 인증하여 반환한다.

대충 이정도만 구현하더라도 AuthenticationProvider가 전달하는 Authentication 객체를 전달받아 SecurityContextHolder에 담을 수는 있다.

여기서 여타 블로그의 실습과 차이점이라면 바로 UserDetailsService에대한 언급이 없다는거다. 여타 블로그 글들과 비교하는것이 영 마음이 편치는 않지만 공식 문서의 설명을 보고 JWT를 Spring Security에서 설정하는 방식에 대해 고민해본 결과 UserDetailsService를 직접 주입해 사용하는 방식에 대한 아쉬운점이 발견되어 이것을 어떻게 해결했는지에 대한 글을 써보려고 한다.

Spring Security + JWT

일단 통상(통상?인지는 모르겠으나 이렇게 구현한 글들을 많이봐서...) 구현하는 방식을 보자.

UserDetailsService 구현 방

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
    private static final String PREFIX = "Bearer ";
    private final TokenUtils tokenUtils;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String header = request.getHeader("Authorization");

        if (isBearer(header)) {
            String token = header.substring(PREFIX.length());

            try {
                var userId = tokenUtils.getClaim(token, "userId");
                var authorities = getAuthorities(token);
                var authenticate = new UsernamePasswordAuthenticationToken(userId, null, authorities);
                SecurityContextHolder.getContext().setAuthentication(authenticate);
            } catch (AuthException e) {
                log.error("Authentication Error: {}", e.getMessage());
                SecurityContextHolder.clearContext();
                request.setAttribute("exception", e);
            }
        }
        
        filterChain.doFilter(request, response);
    }
    ...
}

JwtFilter를 만들어 요청 header 또는 body에 있는 Bearer 토큰을 가져온다. 토큰을 검증 후 추출을 통해 사용자 식별 및 권한 검증을 위한 정보들을 가져온다. 가져온 인증정보는 UsernamePasswordAuthenticationToken에 담아 전달한다.

이렇게되면 기본 인증 제공자에 등록된 DaoAuthenticationProvider에 의해 인증을 받아야한다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByEmail(username)
                .orElseThrow(() -> new MemberException(NOT_FOUND_MEMBER));
        return User.builder()
            .username(member.getMember())
            .password(passwordEncoder.encode(member.getPassword()))
            .build();
    }
}

여기서, UserDetailsService를 사용해 인증을 위한 UserDetails를 받아야 한다. 사용자가 별도로 UserDetailsService를 등록하지 않더라도 UserDetailsService를 설정할때 UserDetailsService의 구현체를 찾기 때문에 bean 등록만으로 설정은 충분하다.

정리하자면 많이들 사용하는 방식은 위와같은 흐름이다.

UsernamePasswordAuthenticationToken을 내가 만든 filter에서 던지면 AuthenticationManager가 들고있는 DaoAuthenticationProvider을 호출한다. 이때,

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
    private UserDetailsService userDetailsService;
    ...
    
	@Override
	protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
			throws AuthenticationException {
		prepareTimingAttackProtection();
		try {
			UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
			...

DaoAuthenticationProvider가 사용자 정보를 가져오려 할 때 UserDetailsService를 가져온다. 이 과정에서 사용되는 UserDetailsService는 사용자가 작성한 CustomUserDetailService가 될거다.

고민해봐야 할 부분

여기서 한번 생각 정리를 한번 해보자. 우리가 실제로 구현한건 인증 필터에서 UsernamePasswordAuthenticationToken을 던지면 이걸 DaoAuthenticaitonProvider가 잡아서 내가 구현한 UserDetailsService을 사용해 사용자의 정보를 가져온다.

'이러한 구현방식이 동작을 하지 않느냐? 라고하면 당연히 아닐것이다. 어쨋든 사용자를 식별 가능한 정보를 전달해주니까. 하지만 이것이 좋은 구현 방식이냐는 질문에는 대답이 어려울거같다.

내가 해당 구현 방식에 대해 고민한 관점은 다음과 같다.
1. 코드의 흐름상에 누가 인증을 하는지 나와있는가?
2. 인증 제공자를 제어할 수 있는가?
3. UsernamePasswordAuthenticationTokenUserDetailsService를 사용하는 스펙이 JWT의 의도와 일맥상통 하는가?

1번의 문제는 Spring Security의 인증 흐름에 대해 어느정도 이해가 있다면 충분히 위와같은 구현을 하더라도 구현자의 의도를 파악할 수 있다. 물론 프레임워크를 사용하는데에 있어 어느정도 학습이 필요한건 사실이다. 하지만 정말 구현부에 드러나지도 않고 프레임워크에 순전히 위임을 하더라도 넘어갈 수 있는 수준의 기본적인 내용인가? 모호한 부분인거 같다.

2번의 문제에 대해서는 "아니요"라고 명확히 대답할 수 있다. 실제로 인증을 제공하는 제공자가 DaoAuthenticationProvider기 때문에 이것을 제어하는 것이 아닌 인증 제공자가 사용자의 정보를 DAO에서 가져올 수 있도록 돕는것이 DaoAuthenticationProvider기 때문이다.

3번도 고민에 여지가 없다는 의견이다. 비밀번호를 다루려는 목적의 DaoAuthenticationProvider를 사용하는 구현방식은 부적절하다. JWT 토큰은 서버에 요청을 할때 서명을 우선적으로 검증하긴 하지만 실제로는 본문이 특별한 암호화 없이 작성이 되어있다. 이 말이 뜻하는것이 무엇이냐면, 올바른 서명을 가진 악의적 사용자가 본문을 조작해 요청을 보낼 수 있다는 것이다. 그렇기 때문에 JWT 토큰을 발행할 때는 매우 민감한 정보를 주입하지 않는다. 애초에 탈취 가능성이 충분히 있기 때문에 민감한 정보는 다루지 않아야 한다.

하지만 DaoAuthenticationProvider는 내부적으로 비밀번호 검증 과정까지 가지고 있다. 이 부분이 실제로 동작하지 않기 때문에 credentialsnull이라도 큰 문제는 없지만 어찌되었든 필요성이 없는 스펙인건 매한가지다.

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
    private Supplier<PasswordEncoder> passwordEncoder = SingletonSupplier
		.of(PasswordEncoderFactories::createDelegatingPasswordEncoder);
    
	protected void additionalAuthenticationChecks(UserDetails userDetails,
			UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
		if (authentication.getCredentials() == null) {
			this.logger.debug("Failed to authenticate since no credentials provided");
			throw new BadCredentialsException(this.messages
				.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
		}
		String presentedPassword = authentication.getCredentials().toString();
		if (!this.passwordEncoder.get().matches(presentedPassword, userDetails.getPassword())) {
			this.logger.debug("Failed to authenticate since password does not match stored value");
			throw new BadCredentialsException(this.messages
				.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
		}
	}
	...

게다가 생각을 해보면 모던 애플리케이션 서비스는 REST 통신을 위해 사용된다. 그렇기 때문에 SecurityFilterChain에 설정할때 csrf 토큰 검증과 form login을 비활성화한다. 문제가 되는건 form login을 비활성화 했을때인데, UsernamePasswordAuthenticationToken을 원래 던지는 녀석은 UsernamePasswordAuthenticationFilter이었는데 form login을 비활성화 한다면 해당 필터는 동작하지 않게 된다.

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                   AuthenticationEntryPoint authenticationEntryPoint,
                                                   AccessDeniedHandler accessDeniedHandler)
            throws Exception {
        http
                .authorizeHttpRequests(auth -> auth
                        ...
                )
                .formLogin(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .exceptionHandling(ex -> ex
                        .authenticationEntryPoint(authenticationEntryPoint)
                        .accessDeniedHandler(accessDeniedHandler))
                .addFilterBefore(new JwtFilter(tokenUtils),
                        UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

이렇다 보니 실제로 filter에서 토큰을 발급받아 인증 프로세스가 진행되는것이 아닌 방향으로 구현을 하게 된다. 즉, 본래 목적에 맞지 않는 토큰으로 본래 목적에 맞지 않는 인증 프로세스를 진행한다는 말이 된다.




So What?

그래서 인증 제공자를 억지로 있는걸 쓸 필요 없이 직접 구현해 이를 사용하자는것이 나의 생각이다.

어떤식으로 인증을 하는지는 인증 제공자가 해결하기만해도 충분할 것이다. 그리고 이것을 인증 관리자에게 인식만 시켜준다면 인증흐름 자체를 구현하는 것이 된다. 게다가 비밀번호라는 값 자체를 사용하지 않기에 JWT 구현 방향성과도 일치한다.

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
    private static final String PREFIX = "Bearer ";
    private final TokenUtils tokenUtils;
    private final AuthenticationManager authenticationManager;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String header = request.getHeader("Authorization");

        if (isBearer(header)) {
            String token = header.substring(PREFIX.length());

            try {
                var userId = tokenUtils.getClaim(token, "userId");
                var authorities = getAuthorities(token);
                var authToken = new CustomMemberAuthenticationToken(userId, authorities);

                var authenticate = authenticationManager.authenticate(authToken);
                SecurityContextHolder.getContext().setAuthentication(authenticate);
            } catch (AuthException e) {
                log.error("Authentication Error: {}", e.getMessage());
                SecurityContextHolder.clearContext();
                request.setAttribute("exception", e);
            }
        }
        ...

토큰에서 가져온 정보로 커스텀 인증 정보를 전달해준다. 이때 별도의 credentials는 받지 않는다. 어차피 없기 때문이다.

@Component
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
    private final MemberQueryRepository queryRepository;

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

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        if (authentication instanceof CustomMemberAuthenticationToken token) {
            var userId = token.getName();
            var member = queryRepository.findByUserId(userId)
                    .orElseThrow(() -> new AuthException("해당 아이디의 사용자를 찾을 수 없습니다: " + userId));

            specifyToken(token, member);
            return token;
        }
        throw new AuthException("Unsupported authentication type: " + authentication.getClass());
    }

    private void specifyToken(CustomMemberAuthenticationToken token, Member member) {
        MemberDetails details = MemberDetails.from(member);
        token.setDetails(details);// set user details
        token.setAuthenticated(true);
    }
}

다음은 인증정보를 위한 사용자 정보 조회 및 인증객체 생성이다. 하는 행위는 UserDetailsService와 동일해 보이지만 인증정보 제공자 자체를 제어하기 때문에 추가적인 인증을 위한 행위에대한 고민을 해볼 수 있고 로깅과 에러처리 등 제어 가능한 범위가 확장된다.

        @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                   AuthenticationProvider authenticationProvider,
                                                   AuthenticationEntryPoint authenticationEntryPoint,
                                                   AccessDeniedHandler accessDeniedHandler)
            throws Exception {
        http
                .authorizeHttpRequests(auth -> auth
                        ...
                )
                .formLogin(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .exceptionHandling(ex -> ex
                        .authenticationEntryPoint(authenticationEntryPoint)
                        .accessDeniedHandler(accessDeniedHandler))
                .addFilterBefore(new JwtFilter(tokenUtils, authenticationProvider::authenticate),
                        UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

정리

이렇듯 Spring Security에서 JWT 인증을 사용할때 UserDetailsService를 구현하는 방식이 아닌 인증 제공자 직접 구현 방식을 지양해야겠다는 의견을 정리해보고, 이에 대한 나름의 타당성을 지닌 솔루션을 짚어봤다. 정답이랄게 있지도 않은 문제이기도 하고 UserDetailsService를 사용하는 방식이 불가능하지도 않다. 다만, 어디까지 제어할 수 있는지를 선택가능하단 점과 JWT가 의도하는 인증 방식과 Spring Security에서 의도한 Framework의 사용 방향성에 차이를 이해하고 사용한다는 점에서 인증 제공자를 구현하는 방식에 대해 고려해보는 것이 어떨까 하는 결론으로 글을 마치겠다.

토리 스프링 시큐리티 한글 번역 문
Spring Security 공식 문서

0개의 댓글