Spring Boot/Form login & Basic auth — UserDetailsService, BCrypt, DaoAuthenticationProvider
~24 phútSpring Security cơ bảnMiễn phí

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

AspectBCryptArgon2
Year19992015 (Password Hashing Competition winner)
Default Spring SecurityAvailable
Memory-hard❌ (CPU only)✅ (CPU + memory)
GPU resistanceLowHigh
AdoptionUniversalGrowing
@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:

  1. Existing user: \{bcrypt\}$2a$12$... — verify with BCrypt.
  2. New user / password change: encode với default (Argon2 hoặc latest).
  3. 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:

StateExceptionHTTP
enabled = falseDisabledException401
accountNonExpired = falseAccountExpiredException401
accountNonLocked = falseLockedException401
credentialsNonExpired = falseCredentialsExpiredException401

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

11. Tóm tắt

  • Form login = UsernamePasswordAuthenticationFilter + DaoAuthenticationProvider + UserDetailsService + password encoder.
  • UserDetailsService.loadUserByUsername() query DB → return UserDetails (immutable view).
  • Password encoding mandatory — BCrypt strength 12 default. Argon2id more secure.
  • DelegatingPasswordEncoder support migration giữa algo (\{bcrypt\}..., \{argon2\}...).
  • UserDetails 4 state: enabled, accountNonExpired, accountNonLocked, credentialsNonExpired. Map to specific AuthenticationException.
  • Brute-force protection: implement LoginAttemptService + listen AuthenticationFailureBadCredentialsEvent.
  • 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_" cho hasRole() work. Hoặc hasAuthority() không prefix.

12. Tự kiểm tra

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();
  }
}
  1. Re-encode already-hashed password:
    encoder.encode(user.getPassword())     // BUG — user.getPassword() đã là BCrypt hash

    Re-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 DB

    DaoAuthenticationProvider internally call encoder.matches(rawInput, storedHash). UserDetailsService chỉ provide hash, không re-encode.

  2. 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 cho hasAuthority("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.

Q2
Vì sao BCrypt strength 12 thay 10? Tradeoff?

BCrypt strength = log2(rounds). Strength N → 2^N rounds.

StrengthRoundsTime per hashBrute force / sec (M3 chip)
4 (min)16~1ms1000
10 (default)1024~100ms10
12 (recommend 2026)4096~400ms2.5
1416384~1.5s0.7
1665536~6s0.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 login

Strategy: identify weak hash, upgrade on next successful login. Module 05 advanced.

Q3
UserDetails có 4 boolean state. Map mỗi state vào use case enterprise.
StateMethodExceptionUse case
enabledisEnabled()DisabledExceptionAdmin ban user: manually set active = false. User cannot login.
accountNonExpiredisAccountNonExpired()AccountExpiredExceptionContractor / temporary access: user account valid until specific date (expiresAt column). After expiry, account inactive.
accountNonLockedisAccountNonLocked()LockedExceptionBrute-force lockout: too many failed login (5 in 15 min) → auto-lock. Auto-unlock after 30 min hoặc admin manual unlock.
credentialsNonExpiredisCredentialsNonExpired()CredentialsExpiredExceptionPassword 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.
Q4
HTTP Basic vs Form Login vs JWT. Khi nào pick cái nào?
AspectHTTP BasicForm LoginJWT (Bearer)
Setup1 line configUI page + endpointIssue + validate logic
StatefulNo (per-request credential)Yes (HTTP session)No (token self-contained)
Session costNoneMemory + Redis clusterNone
LogoutHard (browser cache)Easy (invalidate session)Hard (token still valid)
Mobile appOK but verboseBad (no cookie)Excellent
SPA frontendOKNeed CSRF handlingCleanest
Server-to-serverAcceptableBadExcellent
Token expiryNone — until logoutSession timeout (30 min default)JWT exp claim (e.g. 1h access + 30d refresh)
Distributed scaleExcellent (no state)Need session storeExcellent (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 JWT

Recommend 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.

Q5
DelegatingPasswordEncoder + 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$...") → boolean

Upgrade 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:

  1. User login với password "secret".
  2. Spring Security check: stored {bcrypt-old}$2a$10$... matches "secret" via bcrypt → success.
  3. Listener trigger — upgrade hash to {argon2}$argon2id$....
  4. 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} eventually
Q6
Implement 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 → 401

Implementation:

@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:

  1. In-memory ConcurrentHashMap: scale issues với multi-pod. Use Redis distributed counter.
  2. Per-username lock: attacker tries 100 different username — each locks specific user. Better: per-IP rate limit + per-username lockout.
  3. UI feedback: don't reveal "user exists" — generic "Login failed" message.
  4. Self-service unlock: email với reset link. Admin manual unlock UI.
  5. 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...