🌿Spring

[Spring Security] 커스텀 필터 구현하기 / UsernamePasswordAuthenticationFilter 바탕

소영 🍀 2025. 5. 21. 14:22

⚙️ 개발 환경

Spring Boot 3.4.0

Spring Security 6.4.1

 

🚀 목표

디스코드잇 미션에서 기존에는 아주 간단한 회원가입, 로그인 인증을 사용했습니다.

username, password를 DB에 저장하고, 로그인 시에는 그 값을 가져와 비교하여 응답을 내리는 방식이었습니다.

 

이번에는 Spring Security에서 커스텀 필터를 구현하여 로그인 시 필터에서 인증하도록 하겠습니다.

그리고 이를 바탕으로 로그인 API인 POST /api/login를 구현하겠습니다.

 

🏫 인증 아키텍처: UsernamePasswordAuthenticationFilter를 중심으로

먼저 기본적으로는 어떻게 인증되고 있는지 살펴봐야 합니다.

username, password를 사용하는 방식은 유지하므로

UsernamePasswordAuthenticationFilter를 참고하여 비슷한 플로우로 만들게요.

 


인증 필터 UsernamePasswordAuthenticationFilter에서는

request로 받은 사용자의 인증 정보를 Authentication 객체에 담아

AuthenticationManager.authenticate() 메서드를 호출합니다.

 

실제 코드에서는 UsernamePasswordAuthenticationToken을 넘기고 있습니다.

이 UsernamePasswordAuthenticationToken은 Authentication의 구현체 중 하나인 AbstractAuthenticationToken을 상속받고 있으니, Authentication 객체에 해당하죠.

 

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login", "POST");
    private String usernameParameter = "username";
    private String passwordParameter = "password";
    private boolean postOnly = true;

	...

    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            String username = this.obtainUsername(request);
            username = username != null ? username.trim() : "";
            String password = this.obtainPassword(request);
            password = password != null ? password : "";
            UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }
    
    ...
    
 }

UsernamePasswordAuthenticationFilter에서는 request로부터 받은 정보들로만 Authentication 객체를 만들고

AuthenticationManager에 인증을 요청해 달라 합니다.

 

AuthenticationManager의 구현체인 ProviderManager에서 이 인증을 어떻게 처리할지 관리합니다.


 

ProviderManager의 authenticate() 메서드 일부를 가져왔습니다.

ProviderManager는 List<AuthenticationProvider> providers라는 필드를 가지고 있죠.

이 AuthenticationProvider들이 실제로 DB에서 User 정보를 확인하여 인증을 해줄 클래스들입니다.

 

ProviderManager는 리스트를 순회하며 UsernamePasswordAuthenticationToken(Authentication) 객체를 처리할 수 있는,

support 하는 Provider를 찾습니다.

적당한 Provider가 있으면 이제 그 Provider에게 authenticate()를 위임합니다.

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
    private static final Log logger = LogFactory.getLog(ProviderManager.class);
    private AuthenticationEventPublisher eventPublisher;
    private List<AuthenticationProvider> providers;
    protected MessageSourceAccessor messages;
    private AuthenticationManager parent;
    private boolean eraseCredentialsAfterAuthentication;

    ...
    
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Class<? extends Authentication> toTest = authentication.getClass();
        AuthenticationException lastException = null;
        AuthenticationException parentException = null;
        Authentication result = null;
        Authentication parentResult = null;
        int currentPosition = 0;
        int size = this.providers.size();
        Iterator var9 = this.getProviders().iterator();
	
    	// 📌 등록된 모든 Provider를 순회
        while(var9.hasNext()) {
            AuthenticationProvider provider = (AuthenticationProvider)var9.next();
            // 📌 이 인증을 처리할 수 있는 Provider인지 확인
            if (provider.supports(toTest)) {
                if (logger.isTraceEnabled()) {
                    Log var10000 = logger;
                    String var10002 = provider.getClass().getSimpleName();
                    ++currentPosition;
                    var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));
                }
                
		// 📌 이 Authentication 인증을 지원하는 Provider라면 authenticate 시도
                try {
                    result = provider.authenticate(authentication);
                    if (result != null) {
                    	// 📌 인증 성공하면 원본 인증 객체에서 details 등의 정보를 복사함
                        this.copyDetails(authentication, result);
                        break;
                    }
                } catch (InternalAuthenticationServiceException | AccountStatusException var14) {
                    this.prepareException(var14, authentication);
                    throw var14;
                } catch (AuthenticationException var15) {
                    lastException = var15;
                }
            }
        }

        ...
        
        }
        
        ...
    }

 

기본으로 제공되는 Provider 중

UsernamePasswordAuthenticationToken 처리를 지원하는(supported)

Provider는 DaoAuthenticationProvider가 있습니다.

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider

 

 

데이터베이스에 저장된 사용자 정보를 바탕으로 인증을 처리하는 가장 일반적인 AuthenticationProvider입니다.

DaoAuthenticationProvider의 retriveUser() 메서드는 UserDetailsService를 이용하여 사용자 정보를 조회합니다.

 

이 UserDetailsService는 여러 구현체가 있는데 그중에서는 기본적으로 InMemoryUserDetailsService로 DB가 아닌 메모리에서 유저 정보를 조회합니다. 우리는 DB로부터 조회하도록 설정해야 합니다.

protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    this.prepareTimingAttackProtection();

    try {
    	// User 조회
        UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
        if (loadedUser == null) {
            throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
        } else {
            return loadedUser;
        }
    } 
    ...
}

 

또한 UserDetails 역시 기본 설정으로 되어 있는 구현체 User 클래스를 보면 password, username 같이 기본적인 필드로만 이루어져 있습니다. 우리는 User 엔티티를 담고 있을 필요가 있으므로 UserDetails의 커스텀 구현체가 필요하겠네요.

public class User implements UserDetails, CredentialsContainer {
    private static final long serialVersionUID = 620L;
    private static final Log logger = LogFactory.getLog(User.class);
    private String password;
    private final String username;
    private final Set<GrantedAuthority> authorities;
    private final boolean accountNonExpired;
    ...
 }

 

DaoAuthentication에서 필요한 UsernamePasswordAuthenticationToken을 사용하고, UserDetialsService의 구현체를 이용하기 때문에

커스텀 UserDetailsService를 주입하면 DaoAuthenticationProvider는 그대로 사용할 수 있어 보입니다.

📌 커스텀 인증 로직 설계

  1. LoginRequest DTO
    1. username, password를 담는 request dto
  2. CustomAuthenticationFilter
    1. UsernamePasswordAuthentication 상속
    2. POST /api/auth/login 로그인 요청 시 처리 
    3. UsernamePasswordAuthenticationToken 생성하여 AuthenticationManager에 인증 위임
    4. 인증 성공 시 SuccessHandler, 실패 시 FailureHandler 실행 
  3. CustomUserDetailsService
    1. UserDetailsService 구현체
    2. DB에서 username을 기준으로 유저 정보를 조회하여 UserDetails로 감싸 반환
  4. CustomUserDetails
    1. UserDetails 구현체
    2. User 엔티티를 포함함
  5. DaoAuthenticationProvider
    1. authentication 인증 수행
  6. SuccessHandler, FailureHandler
    1. 인증 성공 시 200 status, UserDto 응답
    2. 인증 실패 시 401 ErrorResponse 응답

📌 구현

CustomAuthenticationFilter

Request Body json에서 username, password를 뽑아야 하므로 ObejctMapper와 request.getInputStream()을 활용합니다.

이 username과 password로 UsernamePasswordAuthenticationToken, 즉 Authentication 객체를 만들어

AuthenticationManager에게 인증을 요청합니다.

public class CustomAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private final ObjectMapper objectMapper;
    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher(
        "/api/auth/login", "POST");


    public CustomAuthenticationFilter(AuthenticationManager authenticationManager,
        ObjectMapper objectMapper) {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
        this.objectMapper = objectMapper;
    }


    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
        HttpServletResponse response)
        throws AuthenticationException, IOException, ServletException {

        // DTO에서 username, password 추출
        LoginRequest loginRequest = objectMapper.readValue(request.getInputStream(),
            LoginRequest.class);
        String username = loginRequest.username();
        String password = loginRequest.password();

        // Authentication 객체 생성
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
            username, password);
        this.setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    protected void setDetails(HttpServletRequest request,
        UsernamePasswordAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }
}

CustomUserDetails, CustomUserDetailsService

User 엔티티를 필드로 가지고

이를 바탕으로 username, password를 반환하도록 합니다.

@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 아직 ROLE 설정을 하지 않았으므로 빈 리스트 반환
        return List.of();
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }
    
    public User getUser(){
        return user;
    }
}

 

DB에서 User 정보를 조회하기 위해

JPA Repository에서 조회합니다.

 

User 객체를 찾으면 CustomUserDetails 객체로 감싸 반환합니다.

못 찾은 경우 UsernameNotFoundException을 던지는데, 이 경우는 기존 UserDetailsService 구현체들을 참고하였습니다.

 

@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException(username));
        return new CustomUserDetails(user);
    }
}

 


AuthenticationHandler

인증 시도 후에는 어떻게 할지 핸들러를 구현합니다.

성공 시에는 CustomAuthenticationSuccessHandler를, 실패 시 CustomAuthenticationFailureHandler를 호출합니다.

 

참고로 UsernamePasswordAuthenticationFilter는 SavedRequestAwareAuthenticationSuccessHandler와 SimpleUrlAuthenticationFailureHandler를 사용합니다.

디버깅을 통해 확인할 수 있습니다.

 

성공 시 200 status와 User를 DTO로 매핑한 UserDto를 반환합니다.

DTO 매핑은 mapper를 이용했습니다.

 

추가) response에서 UTF-8 인코딩 설정을 안 하면, 한글이 깨져 나타납니다.

추가) SecurityContext, HttpSession에 인증 정보를 저장하지 않으로 로그인 성공 이후 요청에 대해 모두 403 Forbidden으로 처리됐습니다. 커스텀 필터를 사용한 경우 핸들러에 인증 정보를 저장하는 로직이 필요합니다.

@RequiredArgsConstructor
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private final ObjectMapper objectMapper;
    private final UserMapper userMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
        Authentication authentication) throws IOException, ServletException {

        // 인증 정보 저장
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authentication);
        SecurityContextHolder.setContext(context);

        // 세션에도 저장해야 다음 요청에서도 인증 유지됨
        HttpSession session = request.getSession(true);
        session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
            context);

        // 인증된 사용자 정보
        CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
        // DTO 매핑
        User user = userDetails.getUser();
        UserDto userDto = userMapper.toDto(user);

        // response
        response.setStatus(HttpStatus.OK.value());
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        objectMapper.writeValue(response.getWriter(), userDto);
    }
}

 

인증 실패 시에는 401 status와 ErrorResponse를 반환합니다.

@RequiredArgsConstructor
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException exception) throws IOException, ServletException {
        // ErrorResponse
        ErrorResponse errorResponse = new ErrorResponse("USER_401", exception.getMessage(), null,
            exception.getClass().getSimpleName(), 401);

        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        objectMapper.writeValue(response.getWriter(), errorResponse);
    }
}

 


Security에 등록

필요한 Bean을 등록하고, Sprint Security에 커스텀 필터를 등록하면 됩니다.

 

  • PasswordEncoder
    • DaoAuthenticationProvider에서는 기본적으로 PasswordEncoder를 이용하여 암호화된 비밀번호와 비교하기 때문에 필요합니다.
    • BCyptPasswordEncoder를 사용했습니다.
  • UserDetails, UserDetailsService
    • 구현한 커스텀 구현체들로 등록합니다.
  • AuthenticationManager
    • Spring Security 버전이 올라가면서 커스텀 인증 로직에 주입하려면 Bean으로 등록하여 설정하도록 변경된 것으로 알고 있습니다. 
    • 커스텀 로직을 쓰지 않는다면 Spring은 내부적으로 기본 AuthenticationManager를 만들어주지만 우리는CustomAuthenticationFilter을 등록해야 하므로 생성자에 AuthenticationManager를 주입해야 하므로 필요합니다.
    • 즉,  AuthenticationManager를 수동으로 구성해야 하는데 이때는 필요한 Provider를 생성하고 그에 필요한 의존성을 주입해야 합니다.
      • DaoAuthenticationProvider 생성하고, UserDetailsService / PasswordEncoder 주입했습니다.
  • CustomAuthenticationFilter
    • 커스텀 필터를 빈으로 등록합니다.
    • 처리할 요청 URL과 핸들러를 지정합니다.
  • CustomAuthenticationHandler
    • SuccessHandler, FailureHandler
    • CustomAuthenticationFilter를 위해 등록합니다.
    • 이 빈들은 @Component로 등록해도 괜찮습니다. 저는 SecurityConfig에서 모아 보고 싶어 @Configuration에서 @Bean으로 등록했습니다.
  • SecurityFilterChain
    • 필터는 빈으로 등록하고, 체인에도 등록해야 합니다.
    • UsernamePasswordAuthenticationFilter를 대체하는 필터이므로 addFilterAt()으로 추가합니다.
    • formLogin(), httpBasic() 설정을 제거합니다.
      • POST /api/auth/login이라는 별도의 API로 로그인하기 때문입니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {


    @Bean
    SecurityFilterChain chain(HttpSecurity httpSecurity,
        CustomAuthenticationFilter customAuthenticationFilter) throws Exception {
        httpSecurity
            .authorizeHttpRequests(auth -> auth
                // 정적 리소스 요청 허용
                .requestMatchers("/assets/**", "/favicon.ico", "/index.html").permitAll()
                
                ...
                
                // 회원가입, 로그인 API 요청 허용
                .requestMatchers(HttpMethod.POST, "/api/users").permitAll()
                .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
                // 그 외 인증 필요
                .anyRequest().authenticated())
            // 회원가입과 로그인은 csrf 검증 제외
            .csrf(csrf -> csrf.ignoringRequestMatchers("/api/users", "/api/auth/login"))
            // 📌 커스텀 인증 필터 추가: UserAuthenticationFilter 대신 CustomAuthenticationFilter 사용
            .addFilterAt(customAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            // LogoutFilter 제외
            .logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer.disable());
        return httpSecurity.build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    UserDetailsService userDetailsService(UserRepository userRepository) {
        return new CustomUserDetailsService(userRepository);
    }

    @Bean
    public AuthenticationManager authenticationManager(UserDetailsService userDetailsService,
        PasswordEncoder passwordEncoder) throws Exception {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder);
        return new ProviderManager(provider);
    }

    @Bean
    CustomAuthenticationFilter customAuthenticationFilter(
        AuthenticationManager authenticationManager, ObjectMapper objectMapper,
        CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler,
        CustomAuthenticationFailureHandler customAuthenticationFailureHandler) {
        CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(
            authenticationManager, objectMapper);
        // /api/auth/login 경로에만 필터 적용
        customAuthenticationFilter.setFilterProcessesUrl("/api/auth/login");
        // 핸들러 적용
        customAuthenticationFilter.setAuthenticationSuccessHandler(
            customAuthenticationSuccessHandler);
        customAuthenticationFilter.setAuthenticationFailureHandler(
            customAuthenticationFailureHandler);
        return customAuthenticationFilter;
    }

    @Bean
    public CustomAuthenticationSuccessHandler successHandler(UserMapper userMapper,
        ObjectMapper objectMapper) {
        return new CustomAuthenticationSuccessHandler(objectMapper, userMapper);
    }

    @Bean
    public CustomAuthenticationFailureHandler failureHandler(ObjectMapper objectMapper) {
        return new CustomAuthenticationFailureHandler(objectMapper);
    }

}

 


추가)

 

처음에는 AuthenticationManager를 따로 빈으로 등록해야 한다는 걸 알지 못했습니다.

그래서 CustomAuthenticationFilter를 빈으로 등록할 때 HttpSecurity에서 AuthenticationManager를 꺼내려고 시도했습니다. 

 

@Bean
CustomAuthenticationFilter customAuthenticationFilter(HttpSecurity http,
    ObjectMapper objectMapper,
    CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler,
    CustomAuthenticationFailureHandler customAuthenticationFailureHandler) {
    // 📌 AuthenticationManager 구하기
    AuthenticationManager authenticationManager = http.getSharedObject(
        AuthenticationManager.class);
    CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(
        authenticationManager, objectMapper);
    ...
    return customAuthenticationFilter;
}

 

그러나 실행 후 AuthenticationManager를 설정하라는 오류가 떠

따로 빈으로 등록하는 법을 찾았습니다.

Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'customAuthenticationFilter' defined in class path resource [com/spirnt/mission/discodeit/config/SecurityConfig.class]: authenticationManager must be specified

 

직접 해보지는 않았지만 아래 방법으로도 가능한 것 같습니다.

 

https://emgc.tistory.com/113

 

[Spring Security] 커스텀 필터 생성 시 'authenticationmanager must be specified'

목적 스프링 시큐리티의 커스텀 필터에서 authenticationManager를 주입하는 방법을 익히기 위함 목차 authenticationManager란? 'authenticationmanager must be specified' 발생 이유 'authenticationmanager must be specified' 해

emgc.tistory.com


결과

로그인에 실패하면

아예 없는 사용자이든, 잘못된 비밀번호든 모두 BadCredentialsException으로 표시되고

401 status를 응답합니다.

 

인증에 성공하면 200 status + UserDto를 응답합니다.

 

참고자료

https://cares-log.tistory.com/19

https://velog.io/@10000ji_/Spring-Security-Spring-Security%EC%97%90%EC%84%9C-%EC%BB%A4%EC%8A%A4%ED%85%80-%ED%95%84%ED%84%B0-%EB%A7%8C%EB%93%A4%EA%B8%B0#addfilterat-%EB%A9%94%EC%86%8C%EB%93%9C%EB%A1%9C-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%A0%95%EC%9D%98-%ED%95%84%ED%84%B0-%EC%B6%94%EA%B0%80

 

반응형