Spring Security & Testing/UserDetailsService & BCrypt — user lookup và password hashing
8/20
Bài 8 / 20~12 phútAuthenticationMiễn phí lượt xem

UserDetailsService & BCrypt — user lookup và password hashing

Cơ chế bên dưới của UserDetailsService: Spring gọi loadUserByUsername như thế nào, trả về gì, và tại sao. BCrypt — tại sao không dùng MD5/SHA, cơ chế salt per-password + cost factor adaptive, và tại sao chậm là thiết kế đúng. Account state flags (locked/disabled/expired) trong UserDetails.

TL;DR: UserDetailsService là interface duy nhất Spring Security cần để tìm user: implement loadUserByUsername(email) query DB, trả UserDetails — một view bất biến gồm password hash + roles + 4 trạng thái tài khoản. BCryptPasswordEncoder không chỉ là hàm hash: mỗi lần gọi encode() tạo salt ngẫu nhiên 16 byte rồi nhúng vào chuỗi output — matches() đọc salt từ chuỗi lưu sẵn để tự verify, không cần cột riêng. Cost factor (strength 12 ≈ 400 ms/hash) là thiết kế chủ đích: chậm với user bình thường là chấp nhận được, chậm với attacker brute-force là tường thành.

Bài Authentication flow đã cho thấy toàn bộ pipeline xác thực. Bài Form login, HTTP Basic & session sẽ đặt UserDetailsService vào DSL thực tế. Bài này bóc đúng một thứ: UserDetailsService + BCryptPasswordEncoder hoạt động ra sao bên dưới — và tại sao.

1. Kịch bản: tại sao cần tách lớp user lookup?

Giả sử bạn đang xây TaskFlow. Auth của TaskFlow cần:

  • Lần 1: user đăng nhập bằng email + password → kiểm tra DB.
  • Lần 2: tích hợp SSO (LDAP hoặc OAuth2) → query LDAP thay vì DB.
  • Lần 3: thêm in-memory user cho integration test → không chạm DB thật.

Nếu logic "tìm user bằng cách nào" bị hard-code bên trong DaoAuthenticationProvider, mỗi lần đổi nguồn user bạn phải thay framework code. Spring giải quyết bằng cách tách biệt hai câu hỏi:

  1. Làm thế nào xác thực?DaoAuthenticationProvider lo (so hash, kiểm tra trạng thái).
  2. Tìm user ở đâu?UserDetailsService lo (trả về UserDetails).

Bạn chỉ implement interface nhỏ đó. DaoAuthenticationProvider gọi nó mà không cần biết DB, LDAP, hay in-memory.

2. UserDetailsService — interface và hợp đồng

Interface này nhỏ đến bất ngờ:

// org.springframework.security.core.userdetails.UserDetailsService
public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

Một method. Hợp đồng:

  • Nhận username (thường là email trong modern app).
  • Trả UserDetails nếu tìm thấy user.
  • Ném UsernameNotFoundException nếu không tìm thấy — không trả null.

UserDetails là view bất biến đại diện user tại thời điểm authentication:

// org.springframework.security.core.userdetails.UserDetails
public interface UserDetails {
    String getPassword();                            // BCrypt hash stored in DB
    String getUsername();
    Collection<? extends GrantedAuthority> getAuthorities();
    boolean isEnabled();
    boolean isAccountNonExpired();
    boolean isAccountNonLocked();
    boolean isCredentialsNonExpired();
}

UserDetails không phải JPA entity. Nó là DTO xác thực — Spring Security tạo ra, dùng xong, bỏ. Entity JPA của bạn (AppUser) không cần implement nó; thay vào đó loadUserByUsername chuyển đổi entity sang UserDetails bằng builder.

Tại sao không trả null?

Nếu loadUserByUsername trả null, DaoAuthenticationProvider sẽ ném NullPointerException — không phải BadCredentialsException. Stack trace lộ ra thay vì thông điệp lỗi đẹp. Convention Spring Security: ném UsernameNotFoundException (subclass AuthenticationException) để framework xử lý đúng.

3. Implement UserDetailsService query DB

@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())            // BCrypt hash from DB, as-is
            .authorities(buildAuthorities(user.getRoles()))
            .disabled(!user.isActive())
            .accountLocked(user.isLocked())
            .accountExpired(false)
            .credentialsExpired(false)
            .build();
    }

    private List<GrantedAuthority> buildAuthorities(Set<Role> roles) {
        return roles.stream()
            .map(r -> new SimpleGrantedAuthority("ROLE_" + r.getName()))
            .toList();
    }
}

Điểm then chốt:

  • @Transactional(readOnly = true): roles thường load lazy trong JPA. Nếu không có transaction, getRoles() trong buildAuthorities ném LazyInitializationException. readOnly = true mở transaction nhẹ, không dùng savepoint.
  • .password(user.getPassword()): trả hash nguyên bản từ DB. Không bao giờ gọi encoder.encode() ở đây — password trong entity đã là BCrypt hash; encode lại thành "hash của hash", matches() không bao giờ pass.
  • User.builder(): org.springframework.security.core.userdetails.User là implementation tiêu chuẩn của UserDetails — Spring cung cấp sẵn, không cần tự viết.

Spring Boot tự wire bean UserDetailsService vào DaoAuthenticationProvider mặc định khi bạn không khai báo AuthenticationManager thủ công.

4. BCrypt — cơ chế bên dưới

4.1 Tại sao không dùng MD5 hay SHA-256?

MD5 và SHA-256 là hàm hash nhanh — thiết kế để hash dữ liệu lớn nhanh nhất có thể. GPU hiện đại tính được hàng tỉ MD5 hash/giây. Attacker với một GPU $300 có thể thử toàn bộ từ điển mật khẩu phổ biến (RockYou2024: 10 tỉ password) trong vài phút.

BCrypt giải quyết bằng cách đảo ngược yêu cầu: chậm là mục tiêu, không phải hệ quả phụ.

BCrypt strength 12 = 2^12 = 4096 vòng lặp nội bộ
→ 1 lần encode() mất ~400ms (CPU thông thường)
→ attacker thử 2-3 password/giây thay vì 1 tỉ/giây
→ brute-force 8-char alphanumeric: từ vài giây → hàng nghìn năm

Tốc độ CPU tăng gấp đôi mỗi vài năm. Cost factor có thể tăng theo — đó là "adaptive" trong "adaptive hash function". SHA-256 không có thuộc tính này.

4.2 Salt per-password — tại sao cần

Nếu hai user có cùng password "abc123", hash SHA-256 của họ giống hệt nhau. Attacker tấn công bảng rainbow (pre-computed hash table) có thể phá ngay khi nhìn vào DB dump.

BCrypt tạo salt ngẫu nhiên 16 byte cho mỗi lần encode(), rồi nhúng salt vào chuỗi output:

$2a$12$EhSpMo65qH3R2v7nFuT9ue   sgpEv0bA5DVUKa8dCb3l4G4EkD9Xpwu
 |   |  |______________________|  |______________________________|
algo cost  salt (22 char = 16B)       hash (31 char)

Hai user cùng password "abc123" có hai hash hoàn toàn khác nhau — salt khác nhau. Rainbow table vô dụng.

Khi verify, matches(rawPassword, storedHash):

  1. Đọc salt từ 22 ký tự đầu của storedHash.
  2. Hash rawPassword với salt đó + cost factor đó.
  3. So sánh với phần hash 31 ký tự sau.

Không cần cột salt riêng trong DB — hash tự chứa đủ thông tin để verify.

flowchart LR
    A["encode(password)"] --> B["generate random salt<br/>16 bytes"]
    B --> C["BCrypt(password, salt, 2^cost)"]
    C --> D["concat: algo+cost+salt+hash<br/>60 chars"]
    D --> E["store in DB"]

    F["matches(raw, stored)"] --> G["parse salt from stored[7:29]"]
    G --> H["BCrypt(raw, parsedSalt, cost)"]
    H --> I{"compare<br/>with stored hash"}
    I -->|"equal"| J["true"]
    I -->|"differ"| K["false"]

4.3 Setup BCryptPasswordEncoder

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(12);   // strength / cost factor
}

Cost factor mặc định là 10 (khoảng 100 ms/hash). Khuyến nghị production năm 2026: 12 (khoảng 400 ms). OWASP Password Storage Cheat Sheet đặt 10-12 là ngưỡng phù hợp cho most apps.

StrengthRounds (2^N)Thời gian/hashBrute-force/giây (GPU)
101024~100 ms~10
124096~400 ms~2.5
1416384~1.5 s~0.7

Không nên chọn strength quá 14: login vượt 1 giây gây UX kém, và attacker có thể dùng /login như vector DoS — mỗi request buộc server tốn CPU hash.

5. Account state — 4 cờ trong UserDetails

DaoAuthenticationProvider kiểm tra 4 boolean sau khi password match thành công. Mỗi flag fail tương ứng một exception riêng:

FlagInterface methodException khi falseUse case thực tế
enabledisEnabled()DisabledExceptionAdmin ban thủ công (active = false)
accountNonExpiredisAccountNonExpired()AccountExpiredExceptionTài khoản contractor hết hạn hợp đồng
accountNonLockedisAccountNonLocked()LockedExceptionKhóa tự động sau 5 lần sai password
credentialsNonExpiredisCredentialsNonExpired()CredentialsExpiredExceptionPolicy đổi password 90 ngày (PCI-DSS)
// Implement day du 4 flags tu entity
return User.builder()
    .username(user.getEmail())
    .password(user.getPassword())
    .authorities(buildAuthorities(user.getRoles()))
    .disabled(!user.isActive())
    .accountLocked(user.isLocked())
    .accountExpired(
        user.getExpiresAt() != null
        && user.getExpiresAt().isBefore(Instant.now()))
    .credentialsExpired(
        user.getPasswordChangedAt() != null
        && user.getPasswordChangedAt()
               .isBefore(Instant.now().minus(Duration.ofDays(90))))
    .build();

Lý do tách thành 4 flag thay vì một active boolean: frontend và backend cần phản ứng khác nhau với từng trạng thái. LockedException nên hiển thị "Tài khoản bị khóa, thử lại sau 30 phút". CredentialsExpiredException nên redirect tới trang đổi password bắt buộc. Một active = false chung không đủ thông tin để làm điều đó.

6. Cơ chế bên dưới — DaoAuthenticationProvider gọi gì

Để hiểu đúng vị trí của UserDetailsService trong pipeline, trace xuống DaoAuthenticationProvider.additionalAuthenticationChecks():

flowchart TB
    Filter["UsernamePasswordAuthenticationFilter<br/>POST /login"] --> ProviderManager
    ProviderManager --> DAP["DaoAuthenticationProvider"]
    DAP --> UDS["UserDetailsService<br/>loadUserByUsername(email)"]
    UDS --> DB["SELECT * FROM users WHERE email = ?"]
    DB --> UDS
    UDS --> DAP
    DAP --> Checks["4 state checks<br/>(enabled, nonLocked, nonExpired, credNonExpired)"]
    Checks --> PWCheck["passwordEncoder.matches(raw, hash)"]
    PWCheck -->|"false"| BadCred["throw BadCredentialsException"]
    PWCheck -->|"true"| Success["return authenticated token"]

Thứ tự quan trọng: state check trước, password sau. Nếu account bị disable, DaoAuthenticationProvider không cần check password — tiết kiệm 400 ms BCrypt và không lộ thông tin "account tồn tại hay không" qua timing attack. Thực ra trong source Spring Security 6, preAuthenticationChecks (state) chạy trước additionalAuthenticationChecks (password).

Liên hệ các bài khác

  • Authentication flow: DaoAuthenticationProvider nằm ở đâu trong ProviderManager chain — bài đó cho thấy toàn bộ delegate chain từ filter xuống provider. Bài này đào sâu cái provider đó làm gì bên trong.
  • Form login, HTTP Basic & session: DSL .formLogin(...) + .httpBasic(...) và cách register UserDetailsService / BCryptPasswordEncoder bean vào config thực tế — bài đó là phần "wire together", bài này là phần "cơ chế bên dưới".

Pitfall

Encode lại hash trong loadUserByUsername:

// SAI — double hash, login never succeeds
return User.builder()
    .password(encoder.encode(user.getPassword()))   // BUG
    .build();

user.getPassword() đã là chuỗi BCrypt $2a$12$.... encoder.encode() hash nó lần nữa thành hash của hash. matches(rawInput, doubleHash) không bao giờ trả true.

✅ Trả hash nguyên bản:

.password(user.getPassword())   // raw BCrypt hash from DB

DaoAuthenticationProvider tự gọi encoder.matches(rawInput, storedHash)UserDetailsService chỉ cung cấp stored hash.

Quên @Transactional khi roles là lazy:

// SAI — LazyInitializationException ném khi getRoles() gọi ngoài transaction
@Override
public UserDetails loadUserByUsername(String email) {
    AppUser user = userRepo.findByEmail(email).orElseThrow(...);
    return User.builder()
        .authorities(buildAuthorities(user.getRoles()))   // NPE / LazyInit
        .build();
}

✅ Thêm @Transactional(readOnly = true) trên method, hoặc dùng fetch = FetchType.EAGER cho roles (chấp nhận được vì set nhỏ).

Thiếu prefix "ROLE_" trong authorities:

// SAI
new SimpleGrantedAuthority(r.getName())   // "ADMIN"
// Trong config:
.hasRole("ADMIN")   // Spring tìm "ROLE_ADMIN" -- khong match

✅ Add prefix khi build authority:

new SimpleGrantedAuthority("ROLE_" + r.getName())

hasRole("ADMIN") là shorthand cho hasAuthority("ROLE_ADMIN"). Không có prefix → authorization silent fail.

📚 Deep Dive

Tài liệu & nguồn gốc thuật toán

Argon2id — khi nào chọn thay BCrypt? Argon2id (thắng Password Hashing Competition 2015, được NIST SP 800-63B và OWASP khuyến nghị) là hàm memory-hard: mỗi lần hash buộc dùng một lượng RAM cấu hình được (mặc định Spring ~16 MiB), khiến GPU/ASIC — mạnh về compute nhưng nghẽn memory bandwidth — mất lợi thế song song hoá. BCrypt chỉ CPU-hard nên GPU đời mới vẫn rút ngắn dần khoảng cách. Spring Security có sẵn Argon2PasswordEncoder (factory defaultsForSpringSecurity_v5_8()), thay cho BCryptPasswordEncoder mà không đổi gì khác trong pipeline. Chọn Argon2id khi compliance yêu cầu (fintech, healthcare) hoặc app mới chưa có hash cũ; giữ BCrypt khi DB đã đầy hash $2a$ — hoặc dùng DelegatingPasswordEncoder để migrate dần khi user đăng nhập lại.

Tóm tắt

  • UserDetailsService tách biệt "tìm user ở đâu" khỏi "xác thực như thế nào" — implement một method loadUserByUsername, trả UserDetails, ném UsernameNotFoundException (không trả null).
  • UserDetails là view bất biến: password hash + roles + 4 flag trạng thái (enabled, nonExpired, nonLocked, credNonExpired).
  • loadUserByUsername trả hash nguyên bản từ DB — không encode lại. DaoAuthenticationProvider gọi encoder.matches() độc lập.
  • BCrypt chậm là thiết kế: salt ngẫu nhiên 16 byte per-password (chống rainbow table) + cost factor (2^N vòng lặp) làm brute-force không thực tế. Strength 12 ≈ 400 ms/hash là điểm cân bằng UX vs security cho app thông thường năm 2026.
  • 4 account state flag ánh xạ sang 4 exception riêng — frontend/backend xử lý khác nhau từng loại.
  • @Transactional(readOnly = true) trên loadUserByUsername là bắt buộc khi roles lazy.

Tự kiểm tra

Tự kiểm tra
Q1
`UserDetailsService.loadUserByUsername()` phải làm gì khi không tìm thấy user? Tại sao không trả null?

Method phải ném UsernameNotFoundException — subclass của AuthenticationException. Framework bắt exception đó và xử lý thành BadCredentialsException để trả về response lỗi phù hợp.

Nếu trả null, DaoAuthenticationProvider gọi null.getPassword()NullPointerException. Stack trace lộ ra thay vì thông điệp 401 đẹp, và exception không phải AuthenticationException nên framework không biết xử lý đúng.

Ngoài ra, ném exception thay vì trả null còn bảo vệ khỏi timing attack: nếu trả null tức thì còn tìm thấy user mới check password (tốn 400 ms BCrypt), attacker đo thời gian phản hồi và biết email nào tồn tại trong hệ thống.

Q2
Giải thích tại sao BCrypt tạo salt riêng cho mỗi password thay vì dùng một salt toàn cục. Hệ quả nếu không có salt?

Nếu hai user cùng dùng password "abc123" mà không có salt (hoặc dùng salt cố định toàn cục), hash của họ giống hệt nhau. Attacker nhìn vào DB dump nhận ra ngay ai dùng cùng password, và chỉ cần crack một người là crack tất cả.

Nguy hiểm hơn: attacker có thể chuẩn bị rainbow table — bảng pre-computed từ plaintext sang hash cho hàng tỉ password phổ biến. Tra bảng là tra O(1).

Salt ngẫu nhiên 16 byte per-password buộc attacker phải tính lại hash riêng cho mỗi user, với salt khác nhau. Không thể dùng rainbow table. Hai user cùng password có hai hash hoàn toàn khác nhau.

BCrypt nhúng salt vào chuỗi output ($2a$12$[22-char salt][31-char hash]) — không cần cột DB riêng cho salt, matches() tự parse ra.

Q3
Một developer viết code sau trong loadUserByUsername: .password(encoder.encode(user.getPassword())). Mô tả chính xác vấn đề xảy ra khi user đăng nhập.

user.getPassword() trả chuỗi BCrypt hash đã lưu trong DB, ví dụ $2a$12$EhSp.... Gọi encoder.encode() trên chuỗi đó tạo ra hash của hash — một chuỗi BCrypt hoàn toàn mới.

Khi user đăng nhập, DaoAuthenticationProvider gọi encoder.matches(rawInput, doubleHash). rawInput là password gốc user gõ vào (ví dụ "secret123"). doubleHash là BCrypt của chuỗi BCrypt kia. Chúng không bao giờ match — login luôn thất bại với BadCredentialsException, ngay cả khi password đúng.

UserDetailsService chỉ có nhiệm vụ cung cấp stored hash. Việc so sánh encoder.matches(raw, stored) là của DaoAuthenticationProvider. Fix: .password(user.getPassword()) — trả hash thẳng.

Q4
Tại sao `DaoAuthenticationProvider` kiểm tra account state (locked, disabled…) trước khi check password? Lợi ích bảo mật là gì?

Nếu check password trước, mỗi request đến account bị disabled vẫn tiêu tốn ~400 ms để BCrypt hash. Attacker có thể dùng điều này làm vector DoS nhẹ — gửi liên tục request login để chiếm CPU server (mỗi request buộc BCrypt tính toán đắt tiền).

Kiểm tra state trước (một phép đọc boolean từ UserDetails) phản hồi ngay lập tức khi account không hợp lệ — không tốn BCrypt CPU.

Ngoài ra, kiểm tra state trước còn chống timing attack tinh tế: nếu check password trước cho account disabled, thời gian phản hồi dài (BCrypt) lộ rằng account tồn tại và password đúng. Trả lời nhanh đồng đều với mọi lý do thất bại khiến attacker khó đoán.

Trong source Spring Security 6, preAuthenticationChecks.check(user) chạy trước additionalAuthenticationChecks(user, token) — thứ tự rõ ràng trong code.

Q5
Tại sao BCrypt strength 12 (không phải 10 hay 14) được khuyến nghị cho ứng dụng phổ thông năm 2026? Tradeoff của việc tăng quá cao là gì?

Strength 10 (mặc định cũ) tương đương 1024 vòng, cho ~100 ms/hash. GPU hiện đại có thể thử khoảng 10 password/giây — với từ điển 10 triệu password phổ biến, brute-force mất khoảng 12 ngày. Chấp nhận được năm 2015, nhưng GPU ngày càng rẻ và nhanh hơn.

Strength 12 tương đương 4096 vòng (~400 ms/hash, ~2.5 lần thử/giây) — cùng từ điển đó mất hàng năm. OWASP năm 2026 khuyến nghị 10-12 cho most apps. 400 ms với user bình thường là chấp nhận được (login không phải thao tác lặp lại liên tục).

Tăng lên 14+ (hơn 1 giây): UX login kém, đặc biệt khi app cần xác thực nhiều request. Nguy hiểm hơn: endpoint /login trở thành DoS amplifier — attacker gửi 100 concurrent login request, server phải tính 100 BCrypt song song, CPU saturation. Không có rate limiting thì đây là vấn đề thực tế.

Điểm cân bằng: 12 cho consumer app thông thường; 14 + Argon2id cho banking/healthcare có compliance requirements; 10 + aggressive rate limiting cho high-traffic auth service.

Bài tiếp theo: Form login, HTTP Basic & session

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên