Spring Security 인증 커스터마이징

Spring Security 인증 아키텍처

Spring Security의 인증(Authentication)은 AuthenticationManagerAuthenticationProviderUserDetailsService 순서로 위임됩니다. 기본 제공 구현체만으로도 충분하지만, 실무에서는 LDAP 연동, 다중 인증 소스, OTP 검증 등 커스텀 인증 로직이 필요한 경우가 대부분입니다.

Spring Security Filter Chain에서 다룬 것처럼 요청은 FilterChain을 거쳐 UsernamePasswordAuthenticationFilter에 도달하고, 여기서 AuthenticationManager에 인증을 위임합니다.

AuthenticationProvider 커스터마이징

AuthenticationProvider는 실제 인증 로직을 수행하는 핵심 인터페이스입니다.

@Component
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;
    private final OtpService otpService;

    @Override
    public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {

        String username = authentication.getName();
        String password = authentication.getCredentials().toString();

        // 1. 사용자 조회
        UserDetails user = userDetailsService.loadUserByUsername(username);

        // 2. 비밀번호 검증
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new BadCredentialsException("잘못된 비밀번호입니다.");
        }

        // 3. 계정 상태 확인
        if (!user.isEnabled()) {
            throw new DisabledException("비활성화된 계정입니다.");
        }

        // 4. 추가 검증 (OTP 등)
        String otp = ((CustomAuthenticationToken) authentication).getOtp();
        if (otp != null && !otpService.verify(username, otp)) {
            throw new BadCredentialsException("OTP 검증 실패");
        }

        return new UsernamePasswordAuthenticationToken(
            user, null, user.getAuthorities());
    }

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

supports() 메서드가 중요합니다. AuthenticationManager는 여러 Provider를 순회하며, supports()가 true를 반환하는 Provider에게 인증을 위임합니다.

커스텀 Authentication Token

OTP나 디바이스 정보 같은 추가 데이터를 전달하려면 커스텀 Token이 필요합니다.

public class CustomAuthenticationToken
        extends UsernamePasswordAuthenticationToken {

    private final String otp;
    private final String deviceId;

    // 인증 전 (credentials 포함)
    public CustomAuthenticationToken(
            String username, String password,
            String otp, String deviceId) {
        super(username, password);
        this.otp = otp;
        this.deviceId = deviceId;
    }

    // 인증 후 (authorities 포함)
    public CustomAuthenticationToken(
            Object principal,
            Collection<? extends GrantedAuthority> authorities,
            String deviceId) {
        super(principal, null, authorities);
        this.otp = null;
        this.deviceId = deviceId;
    }

    public String getOtp() { return otp; }
    public String getDeviceId() { return deviceId; }
}

UserDetailsService 구현

DB에서 사용자를 조회하는 UserDetailsService 구현체입니다.

@Service
@RequiredArgsConstructor
public class JpaUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {

        Member member = memberRepository.findByEmail(username)
            .orElseThrow(() -> new UsernameNotFoundException(
                "사용자를 찾을 수 없습니다: " + username));

        return User.builder()
            .username(member.getEmail())
            .password(member.getPassword())
            .authorities(member.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
                .toList())
            .accountLocked(member.isLocked())
            .disabled(!member.isActive())
            .accountExpired(member.isExpired())
            .credentialsExpired(member.isCredentialsExpired())
            .build();
    }
}

PasswordEncoder 전략

인코더 특징 권장 여부
BCryptPasswordEncoder 기본값, strength 조절 가능 ✅ 대부분의 경우
Argon2PasswordEncoder 메모리 기반, GPU 공격 방어 ✅ 높은 보안 요구
SCryptPasswordEncoder CPU+메모리 집약적 ⚠️ 특수 환경
DelegatingPasswordEncoder 다중 인코더 지원, 마이그레이션 ✅ 레거시 전환
@Configuration
public class PasswordConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // DelegatingPasswordEncoder: 신규는 bcrypt, 레거시 sha256도 지원
        Map<String, PasswordEncoder> encoders = new HashMap<>();
        encoders.put("bcrypt", new BCryptPasswordEncoder(12));
        encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
        encoders.put("sha256", new StandardPasswordEncoder());

        DelegatingPasswordEncoder delegate =
            new DelegatingPasswordEncoder("bcrypt", encoders);
        delegate.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder(12));
        return delegate;
    }
}

DelegatingPasswordEncoder는 저장된 비밀번호의 접두사({bcrypt}, {argon2})로 어떤 인코더를 사용할지 자동 판단합니다. 레거시 시스템에서 점진적으로 인코딩 방식을 전환할 때 필수입니다.

다중 AuthenticationProvider 구성

DB 인증과 LDAP 인증을 동시에 지원하는 구성입니다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomAuthenticationProvider dbProvider;
    private final LdapAuthenticationProvider ldapProvider;

    @Bean
    public AuthenticationManager authenticationManager() {
        return new ProviderManager(List.of(
            dbProvider,     // DB 인증 먼저 시도
            ldapProvider    // 실패 시 LDAP 시도
        ));
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authenticationManager(authenticationManager())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .build();
    }
}

ProviderManager는 첫 번째 Provider가 AuthenticationException을 던지면 다음 Provider로 넘어갑니다. supports()가 false면 아예 건너뜁니다.

로그인 실패 제한(Account Lockout)

Spring Bucket4j Rate Limiting과 별도로, 인증 레벨에서 로그인 시도 횟수를 제한하는 패턴입니다.

@Component
@RequiredArgsConstructor
public class LoginAttemptService {

    private final LoadingCache<String, Integer> attemptsCache =
        Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofMinutes(15))
            .build(key -> 0);

    private static final int MAX_ATTEMPTS = 5;

    public void loginFailed(String username) {
        int attempts = attemptsCache.get(username, k -> 0) + 1;
        attemptsCache.put(username, attempts);
    }

    public void loginSucceeded(String username) {
        attemptsCache.invalidate(username);
    }

    public boolean isBlocked(String username) {
        return attemptsCache.get(username, k -> 0) >= MAX_ATTEMPTS;
    }
}
// AuthenticationProvider에서 활용
@Override
public Authentication authenticate(Authentication auth) {
    String username = auth.getName();

    if (loginAttemptService.isBlocked(username)) {
        throw new LockedException("로그인 시도 초과. 15분 후 재시도하세요.");
    }

    try {
        // ... 인증 로직
        loginAttemptService.loginSucceeded(username);
        return result;
    } catch (BadCredentialsException e) {
        loginAttemptService.loginFailed(username);
        throw e;
    }
}

정리

Spring Security 인증 커스터마이징의 핵심은 AuthenticationProvider, UserDetailsService, PasswordEncoder 세 계층을 용도에 맞게 구현하는 것입니다. 커스텀 Token으로 추가 인증 데이터를 전달하고, DelegatingPasswordEncoder로 레거시를 점진 전환하며, 다중 Provider로 여러 인증 소스를 지원할 수 있습니다. 실무에서는 반드시 로그인 실패 제한과 계정 잠금 로직을 함께 구현하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux