Form login & Basic auth — UserDetailsService, BCrypt, DaoAuthenticationProvider
Form login flow internals, UserDetailsService implement, password encoding với BCrypt + Argon2, DaoAuthenticationProvider, in-memory vs DB user store, password upgrade strategy, account lock/expire, session fixation.
Bài 02 đã config DSL. Bài này bóc traditional auth: form login + HTTP Basic + UserDetailsService + password encoding. Foundation cho mọi Spring Security app — kể cả JWT-based eventually dựa trên UserDetailsService query DB.
1. Form login — flow
sequenceDiagram
participant Browser
participant Tomcat
participant SecurityFilter as UsernamePasswordAuthenticationFilter
participant AM as AuthenticationManager
participant Provider as DaoAuthenticationProvider
participant UDS as UserDetailsService
participant DB as Database
Browser->>Tomcat: POST /login (username, password)
Tomcat->>SecurityFilter: route to filter (matches /login)
SecurityFilter->>SecurityFilter: build UsernamePasswordAuthenticationToken
SecurityFilter->>AM: authenticate(unauthenticated)
AM->>Provider: try authenticate
Provider->>UDS: loadUserByUsername(username)
UDS->>DB: SELECT * FROM users WHERE email = ?
DB-->>UDS: User row
UDS-->>Provider: UserDetails
Provider->>Provider: passwordEncoder.matches(raw, hash)
Provider-->>AM: Authentication (authenticated)
AM-->>SecurityFilter: success
SecurityFilter->>Browser: 302 redirect /home + session cookie/login endpoint Spring Security tự handle — bạn không viết controller.
2. Setup form login
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/register").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login") // GET → custom login page
.loginProcessingUrl("/api/auth/login") // POST → process credentials
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/login?error")
.usernameParameter("email")
.passwordParameter("password")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/api/auth/logout")
.logoutSuccessUrl("/login?logout")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
);
return http.build();
}
}
Default behavior:
GET /login→ render Spring Security default login form (HTML).POST /login(form-encoded) → process credentials.- Success → redirect
/. - Failure → redirect
/login?error.
Production: provide custom login page với React/Thymeleaf.
3. UserDetailsService — user lookup
DaoAuthenticationProvider use UserDetailsService to fetch user:
@Service
public class AppUserDetailsService implements UserDetailsService {
private final UserRepository userRepo;
public AppUserDetailsService(UserRepository userRepo) {
this.userRepo = userRepo;
}
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
AppUser user = userRepo.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
return User.builder()
.username(user.getEmail())
.password(user.getPassword()) // BCrypt hash
.authorities(toAuthorities(user.getRoles()))
.accountExpired(!user.isActive())
.accountLocked(user.isLocked())
.credentialsExpired(false)
.disabled(!user.isActive())
.build();
}
private Collection<? extends GrantedAuthority> toAuthorities(Set<Role> roles) {
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.toList();
}
}
Spring Boot autoconfig pick up UserDetailsService bean → register vào DaoAuthenticationProvider.
3.1 In-memory user (testing/demo)
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
UserDetails admin = User.withUsername("admin")
.password(encoder.encode("admin123"))
.roles("ADMIN")
.build();
UserDetails user = User.withUsername("user")
.password(encoder.encode("user123"))
.roles("USER")
.build();
return new InMemoryUserDetailsManager(admin, user);
}
Chỉ dùng test/demo. Production luôn DB.
3.2 Database user (production)
AppUser entity (continue Module 04 TaskFlow):
@Entity
@Table(name = "users")
public class AppUser extends AuditableEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password; // BCrypt hash
@Column(nullable = false)
private String name;
@Column(nullable = false)
private boolean active = true;
@Column(nullable = false)
private boolean locked = false;
@ManyToMany(fetch = FetchType.EAGER) // EAGER ok cho roles (small)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
}
@Entity
@Table(name = "roles")
public class Role {
@Id @GeneratedValue Long id;
@Column(unique = true, nullable = false, length = 50)
private String name; // USER, ADMIN, MANAGER
}
Migration:
-- V3__add_users_roles.sql
CREATE TABLE roles (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE
);
ALTER TABLE users ADD COLUMN password VARCHAR(60) NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN active BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE users ADD COLUMN locked BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id)
);
INSERT INTO roles (name) VALUES ('USER'), ('ADMIN'), ('MANAGER');
4. Password encoding — BCrypt
Never store plaintext password. Always hash with adaptive function (BCrypt, Argon2, scrypt).
4.1 BCrypt setup
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // strength 12
}
strength = 2^N rounds. Default 10 (~100ms). Production 12+ (~400ms — slower = harder brute force).
Hash format:
$2a$12$EhSpMo65qH3R2v7nFuT9uesgpEv0bA5DVUKa8dCb3l4G4EkD9Xpwu
$2a $12 $EhSpMo65qH3R2v7nFuT9ue sgpEv0bA5DVUKa8dCb3l4G4EkD9Xpwu
algo cost salt (22 char) hash (31 char)
Self-contained: no separate salt column. Salt embedded.
4.2 BCrypt vs Argon2
| Aspect | BCrypt | Argon2 |
|---|---|---|
| Year | 1999 | 2015 (Password Hashing Competition winner) |
| Default Spring Security | ✅ | Available |
| Memory-hard | ❌ (CPU only) | ✅ (CPU + memory) |
| GPU resistance | Low | High |
| Adoption | Universal | Growing |
@Bean
public PasswordEncoder passwordEncoder() {
return new Argon2PasswordEncoder(16, 32, 1, 4096, 3);
// ^ ^ ^ ^ ^
// saltLen hashLen parallelism memoryKB iterations
}
Argon2id (default) most secure 2026. BCrypt still acceptable cho legacy compat.
4.3 Multiple encoders — DelegatingPasswordEncoder
App migrate algo gradually — Spring Security default support multiple:
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
Hash format: \{algo\}hash:
{bcrypt}$2a$12$...
{argon2}$argon2id$v=19$...
{noop}plaintext # never use, dev only
Workflow:
- Existing user:
\{bcrypt\}$2a$12$...— verify with BCrypt. - New user / password change: encode với default (Argon2 hoặc latest).
- Old hash gradually replaced.
4.4 Register endpoint pattern
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AppUserService userService;
private final PasswordEncoder encoder;
@PostMapping("/register")
public ResponseEntity<UserDto> register(@Valid @RequestBody RegisterRequest req) {
if (userService.existsByEmail(req.email())) {
throw new DuplicateException("Email already registered");
}
AppUser user = new AppUser();
user.setEmail(req.email());
user.setName(req.name());
user.setPassword(encoder.encode(req.password())); // HASH
user.setRoles(Set.of(userService.findRole("USER")));
AppUser saved = userService.save(user);
return ResponseEntity.created(...).body(UserDto.from(saved));
}
}
public record RegisterRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 2, max = 100) String name,
@NotBlank @Size(min = 8, max = 128) String password
) {}
5. Account state — locked, disabled, expired
UserDetails interface có 4 boolean state:
public interface UserDetails {
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
DaoAuthenticationProvider check 4 state. Fail → throw specific exception:
| State | Exception | HTTP |
|---|---|---|
enabled = false | DisabledException | 401 |
accountNonExpired = false | AccountExpiredException | 401 |
accountNonLocked = false | LockedException | 401 |
credentialsNonExpired = false | CredentialsExpiredException | 401 |
Use case:
@Service
public class AppUserDetailsService implements UserDetailsService {
public UserDetails loadUserByUsername(String email) {
AppUser user = userRepo.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("Not found"));
return User.builder()
.username(user.getEmail())
.password(user.getPassword())
.authorities(toAuthorities(user.getRoles()))
.disabled(!user.isActive()) // banned account
.accountLocked(user.isLocked()) // too many failed login
.accountExpired(user.getExpiresAt() != null && user.getExpiresAt().isBefore(Instant.now()))
.credentialsExpired(user.getPasswordChangedAt() != null
&& user.getPasswordChangedAt().isBefore(Instant.now().minus(Duration.ofDays(90))))
.build();
}
}
Pattern enterprise:
- Locked: too many failed login (e.g. 5 in 15 min). Auto unlock after timeout.
- Disabled: admin manually ban.
- Account expired: contractor account valid 6 months.
- Credentials expired: password rotation 90 days (compliance).
6. Brute-force protection — login throttle
Default Spring Security: no rate limiting. Implement manually:
@Component
public class LoginAttemptService {
private final Map<String, Integer> attempts = new ConcurrentHashMap<>();
private static final int MAX_ATTEMPTS = 5;
public void loginFailed(String username) {
attempts.merge(username, 1, Integer::sum);
if (attempts.get(username) >= MAX_ATTEMPTS) {
// Lock account
userService.lockAccount(username);
}
}
public void loginSucceeded(String username) {
attempts.remove(username);
}
public boolean isBlocked(String username) {
return attempts.getOrDefault(username, 0) >= MAX_ATTEMPTS;
}
}
@Component
public class AuthEventListener {
@EventListener
public void on(AuthenticationFailureBadCredentialsEvent event) {
String username = event.getAuthentication().getName();
loginAttemptService.loginFailed(username);
}
@EventListener
public void on(AuthenticationSuccessEvent event) {
String username = event.getAuthentication().getName();
loginAttemptService.loginSucceeded(username);
}
}
Production: Redis-based rate limiting (distributed across pods).
7. HTTP Basic Authentication
http.httpBasic(Customizer.withDefaults());
Browser hiện popup credential dialog. Send Authorization: Basic base64(user:pass) header.
curl -u admin:admin123 http://localhost:8080/api/admin/users
# Authorization: Basic YWRtaW46YWRtaW4xMjM=
Use case:
- Admin endpoints internal.
- API server-to-server (without OAuth).
- Health check probe.
Drawbacks:
- Credential trên header mỗi request — leak risk hơn JWT.
- Không logout — browser cache credential.
- HTTPS bắt buộc — base64 không encrypt.
Default Spring Security enable Basic. Disable nếu chỉ dùng JWT:
http.httpBasic(httpBasic -> httpBasic.disable());
8. Session fixation protection
Default behavior Spring Security: generate new session ID after successful login.
http.sessionManagement(s -> s
.sessionFixation().migrateSession() // default — keep session attributes, new ID
// .sessionFixation().newSession() // create completely new session
// .sessionFixation().none() // KEEP same ID (NOT RECOMMEND)
);
Why: prevent session fixation attack — attacker pre-set session ID, victim login, attacker reuse.
REST API (stateless) skip — no session.
9. Pitfall tổng hợp
❌ Nhầm 1: Plain text password. ✅ Always BCrypt/Argon2 hash.
❌ Nhầm 2: Encode password trong UserDetailsService.
return User.withUsername(user.getEmail())
.password(passwordEncoder.encode(user.getPassword())) // BUG — already hashed!
.build();
✅ loadUserByUsername returns hash as-is. Provider compare raw input vs hash via encoder.
❌ Nhầm 3: EAGER fetch roles + N+1 problem trong loadUserByUsername.
@ManyToMany(fetch = FetchType.LAZY)
private Set<Role> roles;
// LazyInitializationException trong loadUserByUsername (transaction may end)
✅ EAGER OK cho roles (small set). Hoặc @Transactional(readOnly = true) trên loadUserByUsername.
❌ Nhầm 4: Forget BCryptPasswordEncoder bean.
✅ Without, Spring Security 5+ throw "There is no PasswordEncoder mapped". Always register bean.
❌ Nhầm 5: Roles không có "ROLE_" prefix.
new SimpleGrantedAuthority(role.getName()) // "ADMIN"
http.authorizeHttpRequests(auth -> auth.requestMatchers("/admin").hasRole("ADMIN"))
// hasRole("ADMIN") looks for "ROLE_ADMIN" — mismatch!
✅ Add prefix: "ROLE_" + role.getName(). Hoặc DB store full "ROLE_ADMIN".
❌ Nhầm 6: Custom login page không match loginProcessingUrl.
✅ Form HTML must POST to URL match Spring config.
❌ Nhầm 7: No rate limit — brute force vulnerability.
✅ Implement LoginAttemptService + Redis distributed counter.
10. 📚 Deep Dive Spring Reference
Spring Security:
Standards:
Algo references:
11. Tóm tắt
- Form login =
UsernamePasswordAuthenticationFilter+DaoAuthenticationProvider+UserDetailsService+ password encoder. UserDetailsService.loadUserByUsername()query DB → returnUserDetails(immutable view).- Password encoding mandatory — BCrypt strength 12 default. Argon2id more secure.
DelegatingPasswordEncodersupport migration giữa algo (\{bcrypt\}...,\{argon2\}...).UserDetails4 state: enabled, accountNonExpired, accountNonLocked, credentialsNonExpired. Map to specificAuthenticationException.- Brute-force protection: implement
LoginAttemptService+ listenAuthenticationFailureBadCredentialsEvent. - HTTP Basic simple — browser popup, base64 credential. Use cho admin/internal. HTTPS mandatory.
- Session fixation default
migrateSession— generate new session ID after login. Stateless API skip. - Roles convention: prefix
"ROLE_"chohasRole()work. HoặchasAuthority()không prefix.
12. Tự kiểm tra
Q1Đoạn `UserDetailsService` sau có 2 vấn đề. Liệt kê + fix.@Service
public class UserService implements UserDetailsService {
@Autowired UserRepository userRepo;
@Autowired PasswordEncoder encoder;
public UserDetails loadUserByUsername(String email) {
AppUser user = userRepo.findByEmail(email).orElseThrow();
return User.withUsername(user.getEmail())
.password(encoder.encode(user.getPassword())) // (1)
.authorities(user.getRoles().stream().map(r -> new SimpleGrantedAuthority(r.getName())).toList()) // (2)
.build();
}
}
▸
@Service
public class UserService implements UserDetailsService {
@Autowired UserRepository userRepo;
@Autowired PasswordEncoder encoder;
public UserDetails loadUserByUsername(String email) {
AppUser user = userRepo.findByEmail(email).orElseThrow();
return User.withUsername(user.getEmail())
.password(encoder.encode(user.getPassword())) // (1)
.authorities(user.getRoles().stream().map(r -> new SimpleGrantedAuthority(r.getName())).toList()) // (2)
.build();
}
}- Re-encode already-hashed password:
encoder.encode(user.getPassword()) // BUG — user.getPassword() đã là BCrypt hashRe-encode hash → DOUBLE HASH. Spring Security compare raw input vs DOUBLE HASH → never match → login always fail.
Fix: return hash as-is:
.password(user.getPassword()) // raw hash from DBDaoAuthenticationProviderinternally callencoder.matches(rawInput, storedHash). UserDetailsService chỉ provide hash, không re-encode. - Roles không có "ROLE_" prefix:
new SimpleGrantedAuthority(r.getName()) // "ADMIN" (no prefix) // Then in security config: http.authorizeHttpRequests(auth -> auth .requestMatchers("/admin/**").hasRole("ADMIN") // looks for "ROLE_ADMIN" — mismatch! );hasRole("ADMIN")Spring Security shorthand chohasAuthority("ROLE_ADMIN"). Authority "ADMIN" không match.Fix 2 cách:
// Cach 1: add prefix in mapping .authorities(user.getRoles().stream() .map(r -> new SimpleGrantedAuthority("ROLE_" + r.getName())) .toList()) // Cach 2: store DB voi prefix INSERT INTO roles VALUES (1, 'ROLE_ADMIN'), (2, 'ROLE_USER'); // Cach 3: dung hasAuthority thay hasRole http.authorizeHttpRequests(auth -> auth .requestMatchers("/admin/**").hasAuthority("ADMIN") // exact match, no prefix );Recommend: cách 1. DB store role name "ADMIN", code add prefix khi build authorities. Convention nhất quán với
hasRole()Spring shortcut.
Code đúng:
@Service
@RequiredArgsConstructor
public class AppUserDetailsService implements UserDetailsService {
private final UserRepository userRepo;
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
AppUser user = userRepo.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
return User.builder()
.username(user.getEmail())
.password(user.getPassword()) // raw hash
.authorities(user.getRoles().stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.getName())) // with prefix
.toList())
.disabled(!user.isActive())
.accountLocked(user.isLocked())
.build();
}
}Bonus: @Transactional(readOnly = true) đảm bảo lazy load roles work.
Q2Vì sao BCrypt strength 12 thay 10? Tradeoff?▸
BCrypt strength = log2(rounds). Strength N → 2^N rounds.
| Strength | Rounds | Time per hash | Brute force / sec (M3 chip) |
|---|---|---|---|
| 4 (min) | 16 | ~1ms | 1000 |
| 10 (default) | 1024 | ~100ms | 10 |
| 12 (recommend 2026) | 4096 | ~400ms | 2.5 |
| 14 | 16384 | ~1.5s | 0.7 |
| 16 | 65536 | ~6s | 0.17 |
Vì sao 12:
- Brute force resistance: attacker với 1 GPU thử 10 password/sec — 1 char alphanumeric (62 char) tốn 6.2s, 6 char tốn ~6 năm. 12 char tốn aeons.
- Login UX still acceptable: 400ms per login — user notice but tolerate.
- Industry standard 2026: OWASP recommend strength 10-12 for BCrypt.
Tradeoff:
- Login latency: 100ms (10) → 400ms (12). Visible cho user.
- Server CPU: high traffic auth (Black Friday register surge) — multiple cores busy hashing.
- Compute cost: AWS instance cost increase nếu auth-heavy app.
Không nên strength quá cao:
- 14+: login vượt 1s — UX poor.
- 16+: DOS vector — attacker bombard /login → server CPU starvation.
Recommend:
- Standard apps: strength 12.
- High-security (banking, healthcare): strength 14 + Argon2id.
- High-traffic (consumer apps): strength 10 + rate limiting.
Migration strategy:
// DelegatingPasswordEncoder support multiple
@Bean
public PasswordEncoder passwordEncoder() {
String defaultId = "argon2";
Map<String, PasswordEncoder> encoders = Map.of(
"argon2", new Argon2PasswordEncoder(...),
"bcrypt12", new BCryptPasswordEncoder(12),
"bcrypt10", new BCryptPasswordEncoder(10) // legacy
);
return new DelegatingPasswordEncoder(defaultId, encoders);
}
// Existing user with {bcrypt10}$2a$10$... → upgrade to argon2 on next loginStrategy: identify weak hash, upgrade on next successful login. Module 05 advanced.
Q3UserDetails có 4 boolean state. Map mỗi state vào use case enterprise.▸
| State | Method | Exception | Use case |
|---|---|---|---|
enabled | isEnabled() | DisabledException | Admin ban user: manually set active = false. User cannot login. |
accountNonExpired | isAccountNonExpired() | AccountExpiredException | Contractor / temporary access: user account valid until specific date (expiresAt column). After expiry, account inactive. |
accountNonLocked | isAccountNonLocked() | LockedException | Brute-force lockout: too many failed login (5 in 15 min) → auto-lock. Auto-unlock after 30 min hoặc admin manual unlock. |
credentialsNonExpired | isCredentialsNonExpired() | CredentialsExpiredException | Password rotation policy: compliance (PCI-DSS, HIPAA) require password change every 90 days. Force user reset password. |
Implementation:
@Entity
public class AppUser {
@Column(nullable = false)
private boolean active = true;
@Column(nullable = false)
private boolean locked = false;
@Column
private Instant expiresAt; // null = never expire
@Column
private Instant passwordChangedAt;
}
public UserDetails loadUserByUsername(String email) {
AppUser user = userRepo.findByEmail(email).orElseThrow(...);
Instant now = Instant.now();
Duration passwordMaxAge = Duration.ofDays(90);
return User.builder()
.username(user.getEmail())
.password(user.getPassword())
.authorities(toAuthorities(user.getRoles()))
.disabled(!user.isActive())
.accountLocked(user.isLocked())
.accountExpired(user.getExpiresAt() != null && user.getExpiresAt().isBefore(now))
.credentialsExpired(user.getPasswordChangedAt() != null
&& user.getPasswordChangedAt().isBefore(now.minus(passwordMaxAge)))
.build();
}Custom error message per state:
@Bean
public AuthenticationEntryPoint authEntryPoint() {
return (req, res, ex) -> {
String detail = switch (ex.getClass().getSimpleName()) {
case "DisabledException" -> "Account disabled. Contact admin.";
case "LockedException" -> "Account locked due to too many failed attempts. Try again in 30 minutes.";
case "AccountExpiredException" -> "Account expired. Contact admin to renew.";
case "CredentialsExpiredException" -> "Password expired. Please reset password.";
default -> "Authentication failed.";
};
// ... return Problem Details with detail
};
}Pattern enterprise:
- Lockout policy: 5 failed → lock 15 min, 10 failed → lock 24h, 20 failed → lock until admin unlock.
- Audit log: every state change logged for compliance.
- Notifications: email user khi locked/expired.
- Self-service unlock: via email reset link.
Q4HTTP Basic vs Form Login vs JWT. Khi nào pick cái nào?▸
| Aspect | HTTP Basic | Form Login | JWT (Bearer) |
|---|---|---|---|
| Setup | 1 line config | UI page + endpoint | Issue + validate logic |
| Stateful | No (per-request credential) | Yes (HTTP session) | No (token self-contained) |
| Session cost | None | Memory + Redis cluster | None |
| Logout | Hard (browser cache) | Easy (invalidate session) | Hard (token still valid) |
| Mobile app | OK but verbose | Bad (no cookie) | Excellent |
| SPA frontend | OK | Need CSRF handling | Cleanest |
| Server-to-server | Acceptable | Bad | Excellent |
| Token expiry | None — until logout | Session timeout (30 min default) | JWT exp claim (e.g. 1h access + 30d refresh) |
| Distributed scale | Excellent (no state) | Need session store | Excellent (no state) |
Decision tree:
Building public REST API consumed by SPA / mobile?
→ JWT (industry standard 2026)
Server-side rendered web app (Thymeleaf, JSP)?
→ Form Login + HTTP session
Internal admin tool / monitoring dashboard?
→ HTTP Basic (simple, no UI for login)
Server-to-server API (microservice internal)?
→ JWT or mTLS (depending on security requirements)
Legacy enterprise app migrating?
→ Keep current, plan migration to JWTRecommend cho TaskFlow:
- Public API: JWT (access token 1h + refresh token 30d).
- Admin endpoints: JWT với ADMIN role check.
- Health check: public, no auth.
- Webhooks (optional): HMAC signature validation thay vì JWT.
Hybrid pattern (production):
@Configuration
public class SecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/admin/**")
.httpBasic(Customizer.withDefaults()) // simple Basic for admin
.authorizeHttpRequests(auth -> auth.anyRequest().hasRole("ADMIN"));
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.oauth2ResourceServer(oauth2 -> oauth2.jwt(...)) // JWT for API
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
}Different auth method per area. Module 05 bài 04 implement JWT pipeline.
Q5DelegatingPasswordEncoder + multiple algorithm — implement password upgrade workflow.▸
Setup:
@Bean
public PasswordEncoder passwordEncoder() {
String defaultId = "argon2"; // new password use Argon2
Map<String, PasswordEncoder> encoders = Map.of(
"argon2", new Argon2PasswordEncoder(16, 32, 1, 4096, 3),
"bcrypt", new BCryptPasswordEncoder(12),
"bcrypt-old", new BCryptPasswordEncoder(10), // legacy strength
"noop", NoOpPasswordEncoder.getInstance() // dev only
);
return new DelegatingPasswordEncoder(defaultId, encoders);
}Hash format trong DB:
// Existing user (legacy)
{bcrypt-old}$2a$10$EhSpMo65...
// New user (after upgrade)
{argon2}$argon2id$v=19$m=4096,t=3,p=1$...
// Match flow:
// 1. User input "raw_password"
// 2. Encoder read prefix {argon2} from stored hash
// 3. Lookup encoder map → Argon2PasswordEncoder
// 4. argon2.matches("raw_password", "$argon2id$...") → booleanUpgrade strategy 1 — eager (one-shot batch):
// Migration script — re-hash all user passwords
// CANNOT — we don't know plaintext
// Option: force all users reset password trên next login
UPDATE users SET force_password_reset = true;Upgrade strategy 2 — lazy (on next login):
@Component
public class PasswordUpgradeListener {
private final PasswordEncoder encoder;
private final UserRepository userRepo;
@EventListener
public void on(AuthenticationSuccessEvent event) {
String username = event.getAuthentication().getName();
String rawPassword = (String) event.getAuthentication().getCredentials();
// Note: credential cleared by default — disable for upgrade
if (rawPassword == null) return;
AppUser user = userRepo.findByEmail(username).orElseThrow();
String currentHash = user.getPassword();
if (currentHash.startsWith("{bcrypt-old}") || !currentHash.startsWith("{argon2}")) {
String newHash = encoder.encode(rawPassword); // re-hash with default (argon2)
user.setPassword(newHash);
userRepo.save(user);
log.info("Upgraded password hash for user {}", username);
}
}
}Workflow:
- User login với password "secret".
- Spring Security check: stored
{bcrypt-old}$2a$10$...matches "secret" via bcrypt → success. - Listener trigger — upgrade hash to
{argon2}$argon2id$.... - Next login: stored argon2 hash, no upgrade needed.
Caveat — credential erasure:
@Bean
public AuthenticationManager authManager(...) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
// ...
ProviderManager pm = new ProviderManager(provider);
pm.setEraseCredentialsAfterAuthentication(false); // KEEP credential for upgrade listener
return pm;
}Default Spring clears credential post-auth — good for security. Disable cho upgrade window. Re-enable after fully migrated.
Spring Security 5.6+ alternative — `UserDetailsPasswordService`:
@Service
public class AppUserDetailsService implements UserDetailsService, UserDetailsPasswordService {
public UserDetails updatePassword(UserDetails user, String newPassword) {
AppUser appUser = userRepo.findByEmail(user.getUsername()).orElseThrow();
appUser.setPassword(newPassword); // already-encoded by Spring
userRepo.save(appUser);
return user.withPassword(newPassword);
}
}Spring Security tự gọi updatePassword when detect upgrade need. No event listener needed. Cleaner.
Monitoring:
-- Track migration progress
SELECT
SUBSTRING(password FROM '^\{[^}]+\}') AS hash_type,
COUNT(*) AS count
FROM users
GROUP BY hash_type;
-- Goal: 100% {argon2} eventuallyQ6Implement brute-force protection với account lockout. Cần component nào?▸
Architecture:
Failed login attempt → AuthenticationFailureBadCredentialsEvent
↓
LoginAttemptListener (event handler)
↓
LoginAttemptService (track count)
↓ (if exceed threshold)
UserService.lockAccount(username)
↓
Next login: UserDetails.isAccountNonLocked() = false
↓
LockedException → 401Implementation:
@Service
@RequiredArgsConstructor
public class LoginAttemptService {
private final RedisTemplate<String, Integer> redisTemplate;
private final UserRepository userRepo;
private static final int MAX_ATTEMPTS = 5;
private static final Duration WINDOW = Duration.ofMinutes(15);
private static final Duration LOCKOUT = Duration.ofMinutes(30);
public void loginFailed(String username) {
String key = "login_attempts:" + username;
Integer current = redisTemplate.opsForValue().get(key);
if (current == null) {
redisTemplate.opsForValue().set(key, 1, WINDOW);
} else {
redisTemplate.opsForValue().set(key, current + 1, WINDOW);
}
if (current != null && current + 1 >= MAX_ATTEMPTS) {
lockAccount(username);
}
}
public void loginSucceeded(String username) {
String key = "login_attempts:" + username;
redisTemplate.delete(key);
}
private void lockAccount(String username) {
userRepo.findByEmail(username).ifPresent(user -> {
user.setLocked(true);
user.setLockedUntil(Instant.now().plus(LOCKOUT));
userRepo.save(user);
log.warn("Account locked: {}", username);
});
}
public boolean isLocked(String username) {
return userRepo.findByEmail(username)
.map(user -> user.isLocked() &&
user.getLockedUntil() != null &&
user.getLockedUntil().isAfter(Instant.now()))
.orElse(false);
}
}
@Component
@RequiredArgsConstructor
public class LoginAttemptListener {
private final LoginAttemptService loginAttemptService;
@EventListener
public void onFailure(AuthenticationFailureBadCredentialsEvent event) {
String username = event.getAuthentication().getName();
loginAttemptService.loginFailed(username);
}
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
String username = event.getAuthentication().getName();
loginAttemptService.loginSucceeded(username);
}
}UserDetails check lock:
@Service
public class AppUserDetailsService implements UserDetailsService {
public UserDetails loadUserByUsername(String email) {
AppUser user = userRepo.findByEmail(email).orElseThrow(...);
boolean locked = user.isLocked() &&
user.getLockedUntil() != null &&
user.getLockedUntil().isAfter(Instant.now());
// Auto-unlock if lockout expired
if (user.isLocked() && user.getLockedUntil().isBefore(Instant.now())) {
user.setLocked(false);
user.setLockedUntil(null);
userRepo.save(user);
locked = false;
}
return User.builder()
.username(user.getEmail())
.password(user.getPassword())
.authorities(...)
.accountLocked(locked)
.build();
}
}Migration:
-- V4__add_lockout.sql
ALTER TABLE users ADD COLUMN locked_until TIMESTAMPTZ;
CREATE INDEX idx_users_locked_until ON users(locked_until) WHERE locked_until IS NOT NULL;Pitfalls:
- In-memory ConcurrentHashMap: scale issues với multi-pod. Use Redis distributed counter.
- Per-username lock: attacker tries 100 different username — each locks specific user. Better: per-IP rate limit + per-username lockout.
- UI feedback: don't reveal "user exists" — generic "Login failed" message.
- Self-service unlock: email với reset link. Admin manual unlock UI.
- Account locked alert: notify user via email "Account locked due to suspicious activity".
Production-ready:
- Redis distributed counter (thay HashMap).
- Per-IP rate limiting (Bucket4j library).
- CAPTCHA after 3 failed attempts.
- WebAuthn / FIDO2 cho high-security users.
- Audit log mọi auth event.
Bài tiếp theo: JWT authentication — stateless auth, signing, refresh token
Bài này có giúp bạn hiểu bản chất không?
Bình luận (0)
Đang tải...