AuthenticationProvider & UserDetailsService 커스터마이징
비즈니스 요구에 맞춘 로그인 흐름을 만들려면 UserDetailsService와 AuthenticationProvider를 이해하고 커스터마이징하는 것이 핵심입니다. 이 글에서는 이메일 로그인 + 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 반복 실패, 해외 로그인 등)
⏩ 다음 글 보기: 세션 기반 로그인 vs Stateless: CSRF/Remember-me/세션관리
👉 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 현실 설정
'Java & Spring' 카테고리의 다른 글
| JWT 심화: 만료/클레임/키 회전 + 4편과의 연결 전략 (0) | 2025.10.13 |
|---|---|
| 세션 기반 로그인 vs Stateless: CSRF/Remember-me/세션관리 (0) | 2025.10.12 |
| PasswordEncoder와 회원가입: BCrypt·Pepper·비밀번호 정책 (1) | 2025.10.11 |
| SecurityFilterChain 완전정복: 요청 매칭/인가 룰 제대로 이해하기 (0) | 2025.10.11 |
| Spring Security 6: JWT 로그인/리프레시 토큰 (0) | 2025.10.08 |