본문 바로가기
Java & Spring

AuthenticationProvider & UserDetailsService 커스터마이징

by yamoojin83 2025. 10. 12.

AuthenticationProvider & UserDetailsService 커스터마이징

비즈니스 요구에 맞춘 로그인 흐름을 만들려면 UserDetailsServiceAuthenticationProvider를 이해하고 커스터마이징하는 것이 핵심입니다. 이 글에서는 이메일 로그인 + BCrypt(+Pepper) 조합을 예시로, 엔티티 → 서비스 → 프로바이더 → 시큐리티 설정까지 전체 흐름을 구성합니다.

1) 사용자 엔티티 & 리포지토리


@Entity
@Table(name = "users")
public class User {
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable=false, unique=true)
  private String email;

  @Column(nullable=false)
  private String password; // {bcrypt}... 형식(DelegatingPasswordEncoder)

  @Column(nullable=false)
  private String roles; // "ROLE_USER,ROLE_ADMIN" 등 CSV

  private boolean enabled = true;
  private boolean locked = false;

  // getters/setters/constructors...
}

public interface UserRepository extends JpaRepository<User, Long> {
  Optional<User> findByEmail(String email);
}

2) 커스텀 UserDetails & UserDetailsService


public class AppUserDetails implements UserDetails {
  private final User user;
  public AppUserDetails(User user) { this.user = user; }

  @Override public Collection<? extends GrantedAuthority> getAuthorities() {
    return Arrays.stream(user.getRoles().split(","))
        .map(String::trim).filter(s -> !s.isEmpty())
        .map(SimpleGrantedAuthority::new).toList();
  }
  @Override public String getPassword() { return user.getPassword(); }
  @Override public String getUsername() { return user.getEmail(); }
  @Override public boolean isAccountNonLocked() { return !user.isLocked(); }
  @Override public boolean isEnabled() { return user.isEnabled(); }
  @Override public boolean isAccountNonExpired() { return true; }
  @Override public boolean isCredentialsNonExpired() { return true; }
}

@Service
@RequiredArgsConstructor
public class AppUserDetailsService implements UserDetailsService {
  private final UserRepository users;

  @Override
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    return users.findByEmail(email)
        .map(AppUserDetails::new)
        .orElseThrow(() -> new UsernameNotFoundException("user not found"));
  }
}

3) PasswordEncoder & Pepper(전역 비밀)

이전 글(/50)의 DelegatingPasswordEncoder + Pepper 전략을 그대로 재사용합니다.


@Configuration
public class PasswordConfig {
  @Bean PasswordEncoder passwordEncoder() {
    Map<String, PasswordEncoder> map = new HashMap<>();
    map.put("bcrypt", new BCryptPasswordEncoder(12));
    return new DelegatingPasswordEncoder("bcrypt", map);
  }
}

@Component
@RequiredArgsConstructor
public class PasswordHasher {
  private final PasswordEncoder encoder;
  @Value("${security.pepper}") private String pepper;
  public boolean matches(String raw, String encoded) { return encoder.matches(raw + pepper, encoded); }
}

4) 커스텀 AuthenticationProvider

기본 DaoAuthenticationProvider로도 충분하지만, 검증/잠금/감사 로깅을 세밀하게 제어하려면 직접 구현이 편합니다.


@Component
@RequiredArgsConstructor
public class EmailPasswordAuthProvider implements AuthenticationProvider {

  private final AppUserDetailsService userDetailsService;
  private final PasswordHasher hasher;

  @Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    String email = (String) authentication.getPrincipal();
    String rawPw = (String) authentication.getCredentials();

    AppUserDetails user = (AppUserDetails) userDetailsService.loadUserByUsername(email);

    if (!user.isEnabled() || !user.isAccountNonLocked()) {
      throw new LockedException("account locked or disabled");
    }
    if (!hasher.matches(rawPw, user.getPassword())) {
      // 실패 로깅/시도 카운트 증가 등 추가 가능
      throw new BadCredentialsException("bad credentials");
    }
    return new UsernamePasswordAuthenticationToken(user.getUsername(), null, user.getAuthorities());
  }

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

5) SecurityFilterChain에 프로바이더 연결

폼 로그인 페이지를 제공하고, API는 별도 체인으로 분리하는 구성을 예시로 듭니다.


@Configuration
@EnableMethodSecurity
public class SecurityConfig {

  @Bean @Order(1)
  SecurityFilterChain api(HttpSecurity http) throws Exception {
    http.securityMatcher("/api/**")
        .csrf(csrf -> csrf.disable())
        .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .httpBasic(Customizer.withDefaults()); // 예시
    return http.build();
  }

  @Bean @Order(2)
  SecurityFilterChain web(HttpSecurity http, AuthenticationProvider emailProvider) throws Exception {
    http
      .authenticationProvider(emailProvider)
      .authorizeHttpRequests(auth -> auth
          .requestMatchers("/", "/login", "/css/**", "/js/**").permitAll()
          .requestMatchers("/admin/**").hasRole("ADMIN")
          .anyRequest().authenticated())
      .formLogin(fl -> fl.loginPage("/login").defaultSuccessUrl("/"))
      .logout(lo -> lo.logoutSuccessUrl("/"));
    return http.build();
  }
}

6) 여러 AuthenticationProvider 체인

소셜 로그인, 사내 SSO, 2FA(OTP) 같은 추가 방식을 도입할 땐 복수의 Provider를 등록해서 순차 시도하게 할 수 있습니다. http.authenticationProvider(X)를 여러 번 호출하면 등록 순서대로 동작합니다.

7) 운영 팁

  • 오류 메시지는 “존재하지 않는 이메일/틀린 비밀번호”를 동일 문구로 숨겨 정보 유출 방지
  • 로그인 시도 제한계정 잠금을 도입하고, 일정 시간 후 자동 해제
  • 성공/실패를 감사 로깅하여 이상 징후 탐지(동일 IP 반복 실패, 해외 로그인 등)

 

 

👉 1편: SecurityFilterChain 완전정복: 요청 매칭/인가 룰 제대로 이해하기

👉 2편: PasswordEncoder와 회원가입: BCrypt·Pepper·비밀번호 정책

👉 3편: AuthenticationProvider & UserDetailsService 커스터마이징

👉 4편: 세션 기반 로그인 vs Stateless: CSRF/Remember-me/세션관리

👉 5편: JWT 심화: 만료/클레임/키 회전 + 4편과의 연결 전략

👉 6편: OAuth2 로그인(Google/GitHub) + JWT 브릿지

👉 7편: 권한 모델링: ROLE vs 권한 문자열, @PreAuthorize, 도메인 권한

👉 8편: CORS·XSS·헤더 보안: SPA/REST 현실 설정

👉 9편: 리소스 서버 분리 & API Gateway 연동(Spring Cloud Gateway)

👉 10편: 감사 로깅/Audit: 로그인 실패·권한거부 탐지 규격