Command Palette

Search for a command to run...

[Spring Boot Basics] Xác thực người dùng từ database với Spring Security: UserDetailsService, BCrypt, đăng ký và đăng nhập

Bài 33 đặt catalogue API sau Spring Security: một SecurityFilterChain cho /api/** với HTTP Basic, session policy stateless và body ProblemDetail cho 401 và 403, cùng hai user khai báo trong code bằng InMemoryUserDetailsManager với password {noop}. Bài này chuyển các account vào table users. Một JPA entity giữ username, email, password hash, role và cờ enabled; một UserDetailsService đọc table đó cho Spring Security; POST /api/auth/register tạo account, còn POST /api/auth/login kiểm tra credentials và trả về profile của user.

Các ví dụ dùng Spring Boot 4.1.1, kéo theo Spring Security 7.1.1, và Java 21, trên một project Initializr có các dependency web, validation, security, data-jpa, h2postgresql. Phần lớn các lần chạy dùng H2 in-memory; những lần cần nhìn vào table users dùng PostgreSQL 18 trong Docker. Application chạy ở port 8134 thay vì 8080 mặc định.

Một form login ở một bên, table users chứa các BCrypt hash ở bên kia, một ổ khoá ở giữa

Thiết kế đi theo kế hoạch của Chương 5: endpoint login kiểm tra credentials và trả về profile, nhưng không tạo session và không cấp token; các lời gọi API vẫn gửi HTTP Basic và API vẫn stateless. Log trích dẫn được ghi với logging.pattern.console=%logger{0}: %msg%n, trừ những đoạn có timestamp.

Table users và entity AppUser

Project cần các starter của Chương 3 và 4, cộng thêm starter security từ bài 33:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-h2console'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	runtimeOnly 'com.h2database:h2'
	runtimeOnly 'org.postgresql:postgresql'
	testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

spring-boot-starter-security kéo theo spring-security-core, -web, -config-crypto, tất cả đều là 7.1.1. Account có feature package riêng, com.example.demo.user, nằm cạnh product, ordercustomer. Role tạm thời chỉ là một column enum; role và các rule dùng đến nó thuộc về bài 36.

src/main/java/com/example/demo/user/Role.java
package com.example.demo.user;
 
public enum Role {
    USER, ADMIN
}
src/main/java/com/example/demo/user/AppUser.java
package com.example.demo.user;
 
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
 
@Entity
@Table(name = "users")
public class AppUser {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, length = 50, unique = true)
    private String username;
 
    @Column(nullable = false, length = 254, unique = true)
    private String email;
 
    @Column(nullable = false, length = 100)
    private String passwordHash;
 
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private Role role = Role.USER;
 
    private boolean enabled = true;
 
    protected AppUser() {
    }
 
    public AppUser(String username, String email, String passwordHash) {
        this.username = username;
        this.email = email;
        this.passwordHash = passwordHash;
    }
 
    public Long getId() { return id; }
 
    public String getUsername() { return username; }
 
    public String getEmail() { return email; }
 
    public String getPasswordHash() { return passwordHash; }
    public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
 
    public Role getRole() { return role; }
 
    public boolean isEnabled() { return enabled; }
    public void setEnabled(boolean enabled) { this.enabled = enabled; }
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof AppUser other)) return false;
        return id != null && id.equals(other.getId());
    }
 
    @Override
    public int hashCode() {
        return AppUser.class.hashCode();
    }
}
  • @Table(name = "users") giữ tên table số nhiều như trong cả series.
  • usernameemailunique. Khi register, service kiểm tra cả hai trước để trả 409 với message rõ ràng; constraint vẫn là lớp bảo đảm cuối cùng.
  • passwordHash giữ password đã encode, không bao giờ giữ bản thân password. length = 100 đủ cho một BCrypt hash 60 ký tự và cả dạng 68 ký tự có prefix {bcrypt} ở phần sau. Naming strategy của Boot map field này sang column password_hash.
  • role dùng EnumType.STRING, vì lý do bài 26 đã nêu.
  • enabled bắt đầu là true; phần về account bị vô hiệu hoá cho thấy Spring Security làm gì khi nó là false.

Entity không có setter cho username hay email, còn equalshashCode theo mẫu dựa trên id của bài 26.

src/main/java/com/example/demo/user/AppUserRepository.java
package com.example.demo.user;
 
import java.util.Optional;
 
import org.springframework.data.jpa.repository.JpaRepository;
 
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
 
    Optional<AppUser> findByUsername(String username);
 
    boolean existsByUsername(String username);
 
    boolean existsByEmail(String email);
}

findByUsername dành cho Spring Security; hai method exists dành cho register. Bài 27 đã giải thích cách Spring Data suy ra query của chúng.

Table mà Hibernate tạo trên PostgreSQL

Container và profile postgres của bài này:

Bash
docker run -d --name sb-a34-pg -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=demo -p 55434:5432 postgres:18
src/main/resources/application-postgres.properties
spring.datasource.url=jdbc:postgresql://localhost:55434/demo
spring.datasource.username=demo
spring.datasource.password=secret

spring.jpa.open-in-view=false vẫn nằm trong application.properties như ở Chương 4. Chạy với profile đó và ddl-auto=update, cách Chương 4 đã dùng cho các thử nghiệm trên PostgreSQL:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8134 --spring.profiles.active=postgres --spring.jpa.hibernate.ddl-auto=update

Hibernate log ra:

Text
SQL: create table users (id bigint generated by default as identity, email varchar(254) not null, enabled boolean not null, password_hash varchar(100) not null, role varchar(20) not null check ((role in ('USER','ADMIN'))), username varchar(50) not null, primary key (id))
SQL: alter table if exists users drop constraint if exists UK6dotkott2kjsp8vw4d0m25fb7
SQL: alter table if exists users add constraint UK6dotkott2kjsp8vw4d0m25fb7 unique (email)
SQL: alter table if exists users drop constraint if exists UKr43af9ap4edm43mmtq01oddj6
SQL: alter table if exists users add constraint UKr43af9ap4edm43mmtq01oddj6 unique (username)
Bash
docker exec sb-a34-pg psql -U demo -d demo -c '\d users'
Text
                                       Table "public.users"
    Column     |          Type          | Collation | Nullable |             Default
---------------+------------------------+-----------+----------+----------------------------------
 id            | bigint                 |           | not null | generated by default as identity
 email         | character varying(254) |           | not null |
 enabled       | boolean                |           | not null |
 password_hash | character varying(100) |           | not null |
 role          | character varying(20)  |           | not null |
 username      | character varying(50)  |           | not null |
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)
    "uk6dotkott2kjsp8vw4d0m25fb7" UNIQUE CONSTRAINT, btree (email)
    "ukr43af9ap4edm43mmtq01oddj6" UNIQUE CONSTRAINT, btree (username)
Check constraints:
    "users_role_check" CHECK (role::text = ANY (ARRAY['USER'::character varying, 'ADMIN'::character varying]::text[]))

Hai unique constraint đến dưới dạng các câu alter table riêng, với tên tự sinh. Ở production, table này đến từ một Flyway migration như bài 31, với tên constraint do bạn đặt.

Load user bằng UserDetailsService

Spring Security không tự query table nào. Với authentication bằng username và password, nó hỏi một UserDetailsService để lấy UserDetails theo username, rồi so password được gửi lên với password mà object đó mang theo. Implementation này đọc AppUser:

src/main/java/com/example/demo/user/JpaUserDetailsService.java
package com.example.demo.user;
 
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsPasswordService;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
public class JpaUserDetailsService implements UserDetailsService, UserDetailsPasswordService {
 
    private final AppUserRepository repository;
 
    public JpaUserDetailsService(AppUserRepository repository) {
        this.repository = repository;
    }
 
    @Override
    public UserDetails loadUserByUsername(String username) {
        AppUser user = repository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("No user named " + username));
        return User.withUsername(user.getUsername())
                .password(user.getPasswordHash())
                .roles(user.getRole().name())
                .disabled(!user.isEnabled())
                .build();
    }
 
    @Override
    @Transactional
    public UserDetails updatePassword(UserDetails user, String newPassword) {
        AppUser appUser = repository.findByUsername(user.getUsername()).orElseThrow();
        appUser.setPasswordHash(newPassword);
        return User.withUserDetails(user).password(newPassword).build();
    }
}
  • User.withUsername(...) dựng implementation UserDetails của chính Spring Security, org.springframework.security.core.userdetails.User. Entity không rời khỏi package user.
  • .password(user.getPasswordHash()) trao cho provider hash đã lưu, thứ nó sẽ dùng PasswordEncoder để so sánh.
  • .roles(user.getRole().name()) biến USER thành authority ROLE_USER, như các dòng log ở phần sau cho thấy.
  • .disabled(!user.isEnabled()) map column sang trạng thái account mà Spring Security kiểm tra.
  • UsernameNotFoundException là quy ước cho username không tồn tại. Message của nó chỉ nằm ở server.
  • updatePassword, từ UserDetailsPasswordService, cho Spring Security lưu hash mới cho một user đã có. Nó chưa làm gì cho tới khi phần cuối tăng cost của BCrypt; @Transactional giúp entity bị thay đổi được flush thành một câu UPDATE.

Trong SecurityConfig của bài 33, các user in-memory bị bỏ đi và một bean PasswordEncoder thế chỗ. Hai filter chain giữ nguyên:

src/main/java/com/example/demo/common/SecurityConfig.java
import org.springframework.security.core.userdetails.User; 
import org.springframework.security.core.userdetails.UserDetails; 
import org.springframework.security.provisioning.InMemoryUserDetailsManager; 
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 
import org.springframework.security.crypto.password.PasswordEncoder; 
 
@Configuration
public class SecurityConfig {
 
    // apiSecurityFilterChain and webSecurityFilterChain are unchanged from article 33
 
    @Bean
    InMemoryUserDetailsManager userDetailsService() { 
        UserDetails alice = User.withUsername("alice") 
                .password("{noop}alice-secret") 
                .roles("USER") 
                .build(); 
        UserDetails bob = User.withUsername("bob") 
                .password("{noop}bob-secret") 
                .roles("USER", "ADMIN") 
                .build(); 
        return new InMemoryUserDetailsManager(alice, bob); 
    } 
    @Bean
    PasswordEncoder passwordEncoder() { 
        return new BCryptPasswordEncoder(); 
    } 
}

Không class config nào nhắc tới JpaUserDetailsService. Hai phần con tiếp theo cho thấy Spring Security và Spring Boot tìm ra nó như thế nào.

Khi nào Spring Boot ngừng sinh password?

Bài 33 đã cho thấy password tự sinh của Boot và thấy nó biến mất khi có InMemoryUserDetailsManager; service JPA cũng khiến Boot lùi lại như vậy. Để so sánh, project Initializr vừa sinh, trước mọi dòng code của chương này, in ra:

Text
2026-09-14T11:13:13.959+07:00  WARN 50769 --- [demo] [           main] .s.a.UserDetailsServiceAutoConfiguration :
 
Using generated security password: 6e956b91-b29d-43c4-addf-0ed8a2c4d1ef
 
This generated password is for development use only. Your security configuration must be updated before running your application in production.
 
2026-09-14T11:13:13.973+07:00  INFO 50769 --- [demo] [           main] r$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name inMemoryUserDetailsManager

Khi đã có JpaUserDetailsService, không còn password tự sinh nữa, và dòng INFO gọi tên bean mới:

Text
2026-09-14T11:17:25.055+07:00  INFO 51765 --- [demo] [           main] r$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name jpaUserDetailsService

Chạy với --debug, condition evaluation report cho biết lý do:

Text
   UserDetailsServiceAutoConfiguration:
      Did not match:
         - @ConditionalOnMissingBean (types: org.springframework.security.authentication.AuthenticationManager,org.springframework.security.authentication.AuthenticationProvider,org.springframework.security.core.userdetails.UserDetailsService,org.springframework.security.authentication.AuthenticationManagerResolver,org.springframework.security.oauth2.jwt.JwtDecoder; SearchStrategy: all) found beans of type 'org.springframework.security.authentication.AuthenticationManager' authenticationManager and found beans of type 'org.springframework.security.core.userdetails.UserDetailsService' jpaUserDetailsService (OnBeanCondition)

UserDetailsServiceAutoConfiguration là class tạo inMemoryUserDetailsManager với password tự sinh, và nó lùi lại ngay khi có một bean thuộc bất kỳ type nào trong năm type trên. Lần chạy này có hai bean như vậy: jpaUserDetailsService và bean authenticationManager mà phần login thêm vào. Một bản build không có bean AuthenticationManager đó chỉ báo found beans of type 'org.springframework.security.core.userdetails.UserDetailsService' jpaUserDetailsService, nên chỉ riêng UserDetailsService là đủ. Report ở bài 33 cũng gọi tên bean userDetailsService của nó ở đúng chỗ này.

DaoAuthenticationProvider được nối từ các bean của bạn như thế nào?

Dòng INFO đến từ InitializeUserDetailsManagerConfigurer trong spring-security-config. Bytecode của nó ở 7.1.1 làm như sau: nếu có bean AuthenticationProvider, nó log một cảnh báo và bỏ qua UserDetailsService; nếu có nhiều hơn một bean UserDetailsService, nó log Found %s UserDetailsService beans và không cấu hình gì; khi có đúng một bean, nó tạo DaoAuthenticationProvider từ bean đó, tìm một bean PasswordEncoder và một bean UserDetailsPasswordService, gán chúng vào provider nếu có, rồi đăng ký provider với AuthenticationManager toàn cục. Một lab runner in ra kết quả, đọc các field private bằng reflection:

src/main/java/com/example/demo/lab/AuthManagerInspector.java
package com.example.demo.lab;
 
import java.lang.reflect.Field;
 
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.stereotype.Component;
 
import jakarta.servlet.Filter;
 
@Component
@Profile("inspect")
class AuthManagerInspector implements CommandLineRunner {
 
    private final AuthenticationManager authenticationManager;
    private final FilterChainProxy filterChainProxy;
    private final org.springframework.security.crypto.password.PasswordEncoder passwordEncoder;
 
    AuthManagerInspector(AuthenticationManager authenticationManager, FilterChainProxy filterChainProxy,
            org.springframework.security.crypto.password.PasswordEncoder passwordEncoder) {
        this.authenticationManager = authenticationManager;
        this.filterChainProxy = filterChainProxy;
        this.passwordEncoder = passwordEncoder;
    }
 
    @Override
    public void run(String... args) throws Exception {
        System.out.println("PasswordEncoder bean: " + passwordEncoder.getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(passwordEncoder)));
        System.out.println("AuthenticationManager bean: " + describe(authenticationManager, ""));
        for (var chain : filterChainProxy.getFilterChains()) {
            for (Filter filter : chain.getFilters()) {
                if (filter instanceof BasicAuthenticationFilter basic) {
                    Object manager = read(basic, BasicAuthenticationFilter.class, "authenticationManager");
                    System.out.println("BasicAuthenticationFilter uses: " + describe(manager, ""));
                    System.out.println("same instance as the bean: " + (manager == authenticationManager));
                }
            }
        }
    }
 
    private String describe(Object manager, String indent) throws Exception {
        StringBuilder sb = new StringBuilder(manager.getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(manager)));
        if (manager instanceof ProviderManager pm) {
            for (AuthenticationProvider provider : pm.getProviders()) {
                sb.append("\n").append(indent).append("  provider: ").append(provider.getClass().getName());
                for (Class<?> c = provider.getClass(); c != Object.class; c = c.getSuperclass()) {
                    for (Field f : c.getDeclaredFields()) {
                        if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
                        f.setAccessible(true);
                        Object v = f.get(provider);
                        String shown = v == null ? "null" : (v instanceof Boolean || v instanceof String) ? v.toString()
                                : (v instanceof java.util.function.Supplier<?> s) ? "Supplier of " + s.get().getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(s.get()))
                                : v.getClass().getName();
                        sb.append("\n").append(indent).append("    ").append(c.getSimpleName()).append(".").append(f.getName()).append(" = ").append(shown);
                    }
                }
            }
            Object parent = read(pm, ProviderManager.class, "parent");
            sb.append("\n").append(indent).append("  eraseCredentialsAfterAuthentication = ").append(read(pm, ProviderManager.class, "eraseCredentialsAfterAuthentication"));
            sb.append("\n").append(indent).append("  parent: ").append(parent == null ? "null" : describe(parent, indent + "  "));
        }
        return sb.toString();
    }
 
    private static Object read(Object target, Class<?> type, String name) throws Exception {
        Field f = type.getDeclaredField(name);
        f.setAccessible(true);
        return f.get(target);
    }
}
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8134 --spring.profiles.active=postgres,inspect --spring.jpa.hibernate.ddl-auto=update

Rút gọn còn những field quan trọng, bỏ phần liệt kê lặp lại của parent:

Text
PasswordEncoder bean: org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder@2738a1fc
AuthenticationManager bean: org.springframework.security.authentication.ProviderManager@5e0602ff
  provider: org.springframework.security.authentication.dao.DaoAuthenticationProvider
    DaoAuthenticationProvider.passwordEncoder = Supplier of org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder@2738a1fc
    DaoAuthenticationProvider.userNotFoundEncodedPassword = null
    DaoAuthenticationProvider.userDetailsService = com.example.demo.user.JpaUserDetailsService$$SpringCGLIB$$0
    DaoAuthenticationProvider.userDetailsPasswordService = com.example.demo.user.JpaUserDetailsService$$SpringCGLIB$$0
    DaoAuthenticationProvider.compromisedPasswordChecker = null
    AbstractUserDetailsAuthenticationProvider.hideUserNotFoundExceptions = true
    AbstractUserDetailsAuthenticationProvider.alwaysPerformAdditionalChecksOnUser = true
  eraseCredentialsAfterAuthentication = true
  parent: null
BasicAuthenticationFilter uses: org.springframework.security.authentication.ProviderManager@49dacc93
  provider: org.springframework.security.authentication.AnonymousAuthenticationProvider
  eraseCredentialsAfterAuthentication = true
  parent: org.springframework.security.authentication.ProviderManager@5e0602ff
same instance as the bean: false
  • AuthenticationManager là một ProviderManager với đúng một DaoAuthenticationProvider.
  • PasswordEncoder của provider chính là bean BCryptPasswordEncoder: identity hash 2738a1fc cho thấy đó là cùng một object.
  • userDetailsServiceuserDetailsPasswordService của provider đều là JpaUserDetailsService, nhìn qua một CGLIB proxy vì updatePassword@Transactional.
  • hideUserNotFoundExceptions = truealwaysPerformAdditionalChecksOnUser = true quyết định những gì phần login và phần account bị vô hiệu hoá quan sát được. userNotFoundEncodedPassword vẫn là null vì chưa ai thử login.
  • BasicAuthenticationFilter trong API chain của bài 33 có ProviderManager riêng, chỉ chứa AnonymousAuthenticationProvider, với manager toàn cục 5e0602ff làm parent. Một token username và password sẽ đi tiếp tới DaoAuthenticationProvider của parent.

Các tutorial viết cho Spring Security 5 tự dựng provider này bằng một constructor không nhận argument và setUserDetailsService. Ở 7.1.1, javap chỉ liệt kê một constructor, DaoAuthenticationProvider(UserDetailsService), và không có setter đó; với cách cấu hình ở trên thì không có gì phải tự dựng.

PasswordEncoder và BCrypt

PasswordEncoder có ba method quan trọng ở đây: encode biến password thành chuỗi để lưu, matches kiểm tra password được gửi lên với chuỗi đã lưu, và upgradeEncoding cho biết chuỗi đã lưu có nên được encode lại hay không. BCryptPasswordEncoder hiện thực chúng bằng BCrypt, một algorithm hash cố tình chậm, có salt ngẫu nhiên và cost điều chỉnh được. Một lab runner thử nó bên ngoài mọi request:

src/main/java/com/example/demo/lab/BcryptTour.java
package com.example.demo.lab;
 
import java.nio.charset.StandardCharsets;
 
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder.BCryptVersion;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
 
@Component
@Profile("bcrypt")
class BcryptTour implements CommandLineRunner {
 
    @Override
    public void run(String... args) {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        String password = "Wonderland-2026";
 
        System.out.println("-- two hashes of the same password");
        String first = encoder.encode(password);
        String second = encoder.encode(password);
        System.out.println(first + "  length=" + first.length());
        System.out.println(second + "  length=" + second.length());
        System.out.println("equal=" + first.equals(second));
        System.out.println("matches(first)=" + encoder.matches(password, first)
                + " matches(second)=" + encoder.matches(password, second)
                + " matches(wrong, first)=" + encoder.matches("wonderland-2026", first));
        String[] parts = first.split("\\$");
        System.out.println("parts: version=" + parts[1] + " cost=" + parts[2]
                + " salt=" + parts[3].substring(0, 22) + " (" + parts[3].substring(0, 22).length() + ")"
                + " hash=" + parts[3].substring(22) + " (" + parts[3].substring(22).length() + ")");
 
        System.out.println("-- $2b$");
        BCryptPasswordEncoder encoder2b = new BCryptPasswordEncoder(BCryptVersion.$2B, 10);
        String hash2b = encoder2b.encode(password);
        System.out.println(hash2b + " default encoder matches=" + encoder.matches(password, hash2b));
 
        System.out.println("-- cost timings, matches(), best of 5 after one warm-up");
        for (int cost : new int[] { 10, 12, 14 }) {
            BCryptPasswordEncoder costEncoder = new BCryptPasswordEncoder(cost);
            String hash = costEncoder.encode(password);
            costEncoder.matches(password, hash);
            long best = Long.MAX_VALUE;
            for (int i = 0; i < 5; i++) {
                long start = System.nanoTime();
                costEncoder.matches(password, hash);
                best = Math.min(best, System.nanoTime() - start);
            }
            System.out.printf("cost %d: %s  %.1f ms%n", cost, hash.substring(0, 7), best / 1_000_000.0);
        }
 
        System.out.println("-- DelegatingPasswordEncoder");
        PasswordEncoder delegating = PasswordEncoderFactories.createDelegatingPasswordEncoder();
        String prefixed = delegating.encode(password);
        System.out.println(prefixed + "  length=" + prefixed.length());
        System.out.println("matches(prefixed)=" + delegating.matches(password, prefixed));
        try {
            System.out.println("matches(no prefix)=" + delegating.matches(password, first));
        } catch (RuntimeException e) {
            System.out.println(e.getClass().getName() + ": " + e.getMessage());
        }
        System.out.println("upgradeEncoding(no prefix)=" + safeUpgrade(delegating, first));
 
        System.out.println("-- 72 bytes");
        tryEncode(encoder, "72 x 'a'", "a".repeat(72));
        tryEncode(encoder, "73 x 'a'", "a".repeat(73));
        String key = "🔑";
        tryEncode(encoder, "18 x key emoji", key.repeat(18));
        tryEncode(encoder, "19 x key emoji", key.repeat(19));
        String hash72 = encoder.encode("a".repeat(72));
        try {
            System.out.println("matches(72 a + 'XYZ', hash of 72 a)=" + encoder.matches("a".repeat(72) + "XYZ", hash72));
        } catch (RuntimeException e) {
            System.out.println("matches(73+) " + e.getClass().getName() + ": " + e.getMessage());
        }
        System.out.println("matches(72 a, hash of 72 a)=" + encoder.matches("a".repeat(72), hash72));
    }
 
    private static String safeUpgrade(PasswordEncoder encoder, String hash) {
        try {
            return String.valueOf(encoder.upgradeEncoding(hash));
        } catch (RuntimeException e) {
            return e.getClass().getName() + ": " + e.getMessage();
        }
    }
 
    private static void tryEncode(PasswordEncoder encoder, String label, String raw) {
        int bytes = raw.getBytes(StandardCharsets.UTF_8).length;
        try {
            String hash = encoder.encode(raw);
            System.out.println(label + ": length()=" + raw.length() + " bytes=" + bytes + " -> " + hash);
        } catch (RuntimeException e) {
            System.out.println(label + ": length()=" + raw.length() + " bytes=" + bytes + " -> "
                    + e.getClass().getName() + ": " + e.getMessage());
        }
    }
}
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8134 --spring.profiles.active=bcrypt

Cấu trúc của một BCrypt hash

Phần đầu của output:

Text
-- two hashes of the same password
$2a$10$Gk.Bb3ZvkxLiBarPBMbXwOFUm/sAwD22K4g1Wcj9Fd/dKBE9C/3Qi  length=60
$2a$10$p33Soq0G0JiR8nzodYkOHeNdIuAvwLYal4DBe7QbZM2oopSEWBxBa  length=60
equal=false
matches(first)=true matches(second)=true matches(wrong, first)=false
parts: version=2a cost=10 salt=Gk.Bb3ZvkxLiBarPBMbXwO (22) hash=FUm/sAwD22K4g1Wcj9Fd/dKBE9C/3Qi (31)
-- $2b$
$2b$10$AETHl.GhH2mcfFGy0CNrA.rBwruB2cojSnMQhSXEEUZeHSpbpcdfu default encoder matches=true

BCrypt hash đầu tiên từ lần chạy được tách thành version 2a, cost 10, salt 22 ký tự và hash 31 ký tự, nằm trên hash thứ hai của cùng password có chung version và cost nhưng khác salt và hash; equals trả về false còn matches trả về true cho cả hai

  • $2a$ là biến thể của BCrypt. BCryptPasswordEncoder mặc định ghi $2a$; BCryptVersion.$2B sinh ra $2b$, và encoder mặc định vẫn match hash đó. Pattern kiểm tra của nó, đọc bằng javap, là \A\$2(a|y|b)?\$(\d\d)\$[./0-9A-Za-z]{53}.
  • 10$ là cost, logarit cơ số 2 của số vòng key setup: class BCrypt tính 1 << log_rounds, nên 10 nghĩa là 1024 vòng. new BCryptPasswordEncoder() dùng GENSALT_DEFAULT_LOG2_ROUNDS = 10, và class chấp nhận từ 4 tới 31.
  • 22 ký tự tiếp theo là salt: BCRYPT_SALT_LEN = 16 byte ngẫu nhiên, viết bằng bảng chữ Base64 riêng của BCrypt, ./0-9A-Za-z.
  • 31 ký tự cuối là hash, 23 byte output trong cùng bảng chữ đó.

Toàn bộ chuỗi 60 ký tự là thứ được lưu vào password_hash. Nó chứa mọi thứ matches cần, trừ password.

Cùng một password, hai lần hash

equal=false, vậy mà matches chấp nhận cả hai chuỗi và từ chối wonderland-2026 viết thường chữ w. BCrypt.checkpw đọc lại cost và salt từ chuỗi đã lưu, hash password được gửi lên với chính chúng, rồi so kết quả bằng equalsNoEarlyReturn, một phép so sánh không dừng lại ở ký tự khác nhau đầu tiên. Vì mỗi lần encode lấy một salt mới, hai user có cùng password nhận hai hash khác nhau, và một bảng hash tính sẵn không thể phủ được mọi row.

BCrypt cost 10, 12 và 14

Text
-- cost timings, matches(), best of 5 after one warm-up
cost 10: $2a$10$  55.6 ms
cost 12: $2a$12$  216.6 ms
cost 14: $2a$14$  877.3 ms
CostSố vòngmatches(), tốt nhất trong 5 lầnSo với cost 10
101,02455.6 ms
124,096216.6 ms3.9×
1416,384877.3 ms15.8×

Mỗi bậc cost tăng gấp đôi khối lượng tính toán, và thời gian đo đi theo đúng như vậy. Các con số chỉ mang tính tham khảo, đo trên một máy. Mỗi lần login dù thành công hay thất bại, và mỗi request HTTP Basic, đều trả một lần matches, nên cost là sự đánh đổi giữa độ trễ đó và cái giá của mỗi lần đoán với kẻ đã lấy được table. Hãy đo trên chính các máy sẽ chạy application.

DelegatingPasswordEncoder và prefix của password

Text
-- DelegatingPasswordEncoder
{bcrypt}$2a$10$vnYl6mrumo/KC.4MsB7fluww6/wuJAENUy3Ro8y/e.wzizFZp1dB2  length=68
matches(prefixed)=true
java.lang.IllegalArgumentException: Given that there is no default password encoder configured, each password must have a password encoding prefix. Please either prefix this password with '{noop}' or set a default password encoder in `DelegatingPasswordEncoder`.
upgradeEncoding(no prefix)=true

PasswordEncoderFactories.createDelegatingPasswordEncoder() trả về một DelegatingPasswordEncoder. encode của nó dùng BCrypt và ghi id của encoder trong cặp ngoặc nhọn phía trước hash; matches chọn encoder dựa vào prefix đó. Prefix là thứ đã cho phép bài 33 lưu password {noop}, và cho phép application chuyển sang algorithm khác về sau trong khi hash cũ vẫn dùng được. Một hash đã lưu mà không có prefix thì không được so với encoder nào: matches ném IllegalArgumentException ở trên, cùng message mà probe của bài 33 đã in ra.

Series này lưu BCrypt hash không có prefix với một bean BCryptPasswordEncoder, vì mọi row đều đến từ đúng encoder đó. Khai báo PasswordEncoderFactories.createDelegatingPasswordEncoder() làm bean cũng là một lựa chọn hợp lệ; khi đó mọi giá trị đã lưu cần có prefix, và column 100 ký tự vẫn đủ chỗ cho 68 ký tự.

Giới hạn 72 byte của BCrypt

Text
-- 72 bytes
72 x 'a': length()=72 bytes=72 -> $2a$10$DGKVpx9On7A.LKbDXaxv5eezqouMgRwOoF12P8OxYEHJK04sz.mae
73 x 'a': length()=73 bytes=73 -> java.lang.IllegalArgumentException: password cannot be more than 72 bytes
18 x key emoji: length()=36 bytes=72 -> $2a$10$56EwCZBrC/hOZ1FlyE1j.uy5TWcC2ccmNmXSmTLtKFa1g8mtzat6C
19 x key emoji: length()=38 bytes=76 -> java.lang.IllegalArgumentException: password cannot be more than 72 bytes
matches(72 a + 'XYZ', hash of 72 a)=true
matches(72 a, hash of 72 a)=true
  • encode từ chối input dài hơn 72 byte với IllegalArgumentException: password cannot be more than 72 bytes. Spring Security 7.1.1 không âm thầm cắt bớt password khi hash.
  • Giới hạn tính theo byte UTF-8, không theo ký tự. Emoji chìa khoá (U+1F511) là hai char và bốn byte, nên 19 emoji là 38 theo String.length() và 76 byte, và bị từ chối.
  • matches không từ chối input dài. Một password 75 byte match với hash của 72 byte đầu tiên của nó. Trong bytecode, hashpw bỏ qua bước kiểm tra độ dài khi được gọi để so sánh. Vì encode không bao giờ tạo hash từ hơn 72 byte, hệ quả là mọi byte sau byte thứ 72 bị bỏ qua khi login.

Phần register biến giới hạn này thành 422 trước khi encode chạy.

Đăng ký tài khoản với POST /api/auth/register

Register là một endpoint bình thường như ở Chương 3, cộng thêm một quy tắc về bảo mật: password được encode trước khi lưu và không bao giờ được trả về.

src/main/java/com/example/demo/user/RegisterRequest.java
package com.example.demo.user;
 
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
 
public record RegisterRequest(
        @NotBlank @Size(min = 3, max = 50) @Pattern(regexp = "[a-z0-9._-]+") String username,
        @NotBlank @Email @Size(max = 254) String email,
        @NotBlank @Size(min = 8, max = 72) String password) {
}
src/main/java/com/example/demo/user/UserResponse.java
package com.example.demo.user;
 
public record UserResponse(Long id, String username, String email, Role role) {
 
    static UserResponse from(AppUser user) {
        return new UserResponse(user.getId(), user.getUsername(), user.getEmail(), user.getRole());
    }
}
src/main/java/com/example/demo/user/UserAlreadyExistsException.java
package com.example.demo.user;
 
public class UserAlreadyExistsException extends RuntimeException {
 
    public UserAlreadyExistsException(String message) {
        super(message);
    }
}
src/main/java/com/example/demo/user/UserService.java
package com.example.demo.user;
 
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
public class UserService {
 
    private final AppUserRepository repository;
    private final PasswordEncoder passwordEncoder;
 
    public UserService(AppUserRepository repository, PasswordEncoder passwordEncoder) {
        this.repository = repository;
        this.passwordEncoder = passwordEncoder;
    }
 
    @Transactional
    public AppUser register(RegisterRequest request) {
        if (repository.existsByUsername(request.username())) {
            throw new UserAlreadyExistsException("Username " + request.username() + " is already taken");
        }
        if (repository.existsByEmail(request.email())) {
            throw new UserAlreadyExistsException("Email " + request.email() + " is already registered");
        }
        String hash = passwordEncoder.encode(request.password());
        return repository.save(new AppUser(request.username(), request.email(), hash));
    }
 
    @Transactional(readOnly = true)
    public AppUser findByUsername(String username) {
        return repository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("No user named " + username));
    }
}
src/main/java/com/example/demo/user/AuthController.java
package com.example.demo.user;
 
import jakarta.validation.Valid;
 
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
@RequestMapping("/api/auth")
public class AuthController {
 
    private final UserService userService;
 
    public AuthController(UserService userService) {
        this.userService = userService;
    }
 
    @PostMapping("/register")
    public ResponseEntity<UserResponse> register(@Valid @RequestBody RegisterRequest request) {
        AppUser user = userService.register(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.from(user));
    }
}
  • RegisterRequest giới hạn mỗi field theo column của nó. @Pattern chỉ cho phép chữ thường, chữ số, ., _-, nên không thể register Alice bên cạnh alice.
  • UserService.register kiểm tra username rồi đến email, và ném UserAlreadyExistsException với message rõ ràng. Hai request đồng thời có thể cùng vượt qua bước kiểm tra; unique constraint vẫn chặn câu INSERT thứ hai, và bài 26 đã map DataIntegrityViolationException đó sang 409.
  • passwordEncoder.encode chạy trước save, nên entity chỉ bao giờ giữ hash.
  • UserResponse không có field password, nên không hash nào lọt được vào JSON.
  • findByUsername được endpoint login và me ở phần dưới dùng tới.

Register và login phải gọi được khi chưa có credentials, nên cả hai endpoint POST được thêm vào các rule public trong API chain của bài 33:

src/main/java/com/example/demo/common/SecurityConfig.java
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll() 
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())

Rule ghi rõ hai path thay vì /api/auth/**: GET /api/auth/me, được thêm vào sau, nằm dưới cùng prefix và phải luôn yêu cầu authentication. Advice có thêm một 409 cho exception của service, bên cạnh các handler sẵn có cho DataIntegrityViolationException và cho body không hợp lệ, đã có ở bài 26:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
import com.example.demo.user.UserAlreadyExistsException; 
 
    @ExceptionHandler(UserAlreadyExistsException.class) 
    public ProblemDetail userExists(UserAlreadyExistsException e) { 
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage()); 
    } 

Đăng ký một user bằng curl

Các lần chạy trên H2 dùng lệnh này, với SQL và hai logger authentication ở mức DEBUG:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8134 --logging.level.org.hibernate.SQL=DEBUG --logging.level.org.springframework.security.authentication=DEBUG --logging.level.org.springframework.security.web.authentication.www=DEBUG '--logging.pattern.console=%logger{0}: %msg%n'
Bash
curl -i -s -H 'Content-Type: application/json' -d '{"username":"alice","email":"alice@example.com","password":"Wonderland-2026"}' http://localhost:8134/api/auth/register
Text
HTTP/1.1 201
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:42:02 GMT
 
{"id":1,"username":"alice","email":"alice@example.com","role":"USER"}

Các security header đến từ chain của bài 33. Log của request:

Text
SQL: select au1_0.id from users au1_0 where au1_0.username=? fetch first ? rows only
SQL: select au1_0.id from users au1_0 where au1_0.email=? fetch first ? rows only
SQL: insert into users (email,enabled,password_hash,role,username,id) values (?,?,?,?,?,default)

Hai query exists, rồi đến câu INSERT. Để xem thứ gì được lưu, cùng request đó được chạy trên PostgreSQL, tiếp theo là carol với password Hatter-Tea-2026:

Bash
docker exec sb-a34-pg psql -U demo -d demo -c 'select id, username, password_hash, length(password_hash) as len, role, enabled from users order by id'
Text
 id | username |                        password_hash                         | len | role | enabled
----+----------+--------------------------------------------------------------+-----+------+---------
  1 | alice    | $2a$10$NKoTpp5KtZNKpR2LmYE84.dz2I/JnOOHPt0hQmnC/jqzalEjtSzpi |  60 | USER | t
  2 | carol    | $2a$10$u/wmILXQJ9Eq24Px1/eWr.m38MW57mPoG//MlgprOIEPkMN7/I.qm |  60 | USER | t
(2 rows)

Hai chuỗi $2a$10$ dài 60 ký tự, và hash của alice không phải hash mà BcryptTour in ra cho cùng password: salt khác, hash khác.

Input không hợp lệ và username bị trùng

Bash
curl -i -s -H 'Content-Type: application/json' -d '{"username":"Al","email":"not-an-email","password":"short"}' http://localhost:8134/api/auth/register
Text
HTTP/1.1 422
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:42:02 GMT
 
{"detail":"email must be a well-formed email address, password size must be between 8 and 72, username must match \"[a-z0-9._-]+\", username size must be between 3 and 50","instance":"/api/auth/register","status":422,"title":"Unprocessable Content"}

422 đến từ handler MethodArgumentNotValidException đã có ở bài 26, và không câu SQL nào được gửi đi. Register alice lần nữa với một email khác:

Bash
curl -i -s -H 'Content-Type: application/json' -d '{"username":"alice","email":"alice2@example.com","password":"Another-pass-1"}' http://localhost:8134/api/auth/register
Text
HTTP/1.1 409
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:42:03 GMT
 
{"detail":"Username alice is already taken","instance":"/api/auth/register","status":409,"title":"Conflict"}

Chỉ có query existsByUsername chạy. Một username mới đi cùng alice@example.com nhận về:

JSON
{"detail":"Email alice@example.com is already registered","instance":"/api/auth/register","status":409,"title":"Conflict"}

Một 409 gọi tên username đã có người dùng cũng xác nhận rằng account đó tồn tại. Điều này gắn liền với form register; điều quan trọng là login, ở phần dưới, không làm như vậy.

Password dài hơn 72 byte

@Size(max = 72) đếm char. Mười chín emoji chìa khoá là 38 char và 76 byte:

Bash
PASSWORD=$(printf '\xF0\x9F\x94\x91%.0s' $(seq 1 19))
curl -i -s -H 'Content-Type: application/json' -d "{\"username\":\"keys\",\"email\":\"keys@example.com\",\"password\":\"$PASSWORD\"}" http://localhost:8134/api/auth/register
Text
HTTP/1.1 302
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Set-Cookie: JSESSIONID=70FAA83D52C674B9EBA7EC8C0D75EA40; Path=/; HttpOnly
Location: http://localhost:8134/login;jsessionid=70FAA83D52C674B9EBA7EC8C0D75EA40
Content-Length: 0
Date: Mon, 14 Sep 2026 04:42:04 GMT

Log, stack trace đã rút gọn:

Text
SQL: select au1_0.id from users au1_0 where au1_0.username=? fetch first ? rows only
SQL: select au1_0.id from users au1_0 where au1_0.email=? fetch first ? rows only
[dispatcherServlet]: Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalArgumentException: password cannot be more than 72 bytes] with root cause
java.lang.IllegalArgumentException: password cannot be more than 72 bytes
	at org.springframework.security.crypto.bcrypt.BCrypt.hashpw(BCrypt.java:616)
	at org.springframework.security.crypto.bcrypt.BCrypt.hashpw(BCrypt.java:603)
	at org.springframework.security.crypto.bcrypt.BCrypt.hashpw(BCrypt.java:593)
	at org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder.encodeNonNullPassword(BCryptPasswordEncoder.java:109)
	at org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder.encode(AbstractValidatingPasswordEncoder.java:42)
	at com.example.demo.user.UserService.register(UserService.java:27)

Validation cho qua, encode ném exception, và không handler nào trong advice khớp với IllegalArgumentException, nên request lỗi đi tiếp tới trang /error của Spring Boot qua một ERROR dispatch. Bài 33 đã cho thấy lần dispatch đó kết thúc ở đâu khi có hai chain: /error không nằm dưới /api/**, nên chain form login nhận nó như một request anonymous, redirect về /login và mở một session. Client của API nhận một 302 và cookie JSESSIONID thay vì một lỗi. Chỗ cần sửa nằm trước encode, ngay trong request DTO:

src/main/java/com/example/demo/user/RegisterRequest.java
package com.example.demo.user;
 
import java.nio.charset.StandardCharsets; 
 
import jakarta.validation.constraints.AssertTrue; 
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
 
public record RegisterRequest(
        @NotBlank @Size(min = 3, max = 50) @Pattern(regexp = "[a-z0-9._-]+") String username,
        @NotBlank @Email @Size(max = 254) String email,
        @NotBlank @Size(min = 8, max = 72) String password) {
 
    @AssertTrue(message = "must be at most 72 bytes in UTF-8") 
    public boolean isPasswordWithinBcryptLimit() { 
        return password == null || password.getBytes(StandardCharsets.UTF_8).length <= 72; 
    } 
}

Cùng request đó sau khi sửa:

Text
HTTP/1.1 422
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:42:29 GMT
 
{"detail":"passwordWithinBcryptLimit must be at most 72 bytes in UTF-8","instance":"/api/auth/register","status":422,"title":"Unprocessable Content"}

Hibernate Validator coi method is... là một property tên passwordWithinBcryptLimit và báo vi phạm dưới tên đó. Một custom constraint như ở bài 19 sẽ đặt message ngay trên password.

Xác thực bằng HTTP Basic với table users

API chain của bài 33 vốn đã biến header Authorization: Basic thành một lần thử authentication, và giờ lần thử đó đi tới JpaUserDetailsService. Một endpoint cần user đã authenticated sẽ cho thấy điều đó:

src/main/java/com/example/demo/user/AuthController.java
package com.example.demo.user;
 
import jakarta.validation.Valid;
 
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; 
import org.springframework.security.core.userdetails.UserDetails; 
import org.springframework.web.bind.annotation.GetMapping; 
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
@RequestMapping("/api/auth")
public class AuthController {
 
    private final UserService userService;
 
    public AuthController(UserService userService) {
        this.userService = userService;
    }
 
    @PostMapping("/register")
    public ResponseEntity<UserResponse> register(@Valid @RequestBody RegisterRequest request) {
        AppUser user = userService.register(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.from(user));
    }
 
    @GetMapping("/me") 
    public UserResponse me(@AuthenticationPrincipal UserDetails principal) { 
        return UserResponse.from(userService.findByUsername(principal.getUsername())); 
    } 
}

GET /api/auth/me và object principal

Bash
curl -i -s -u alice:Wonderland-2026 http://localhost:8134/api/auth/me
Text
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Content-Length: 69
Date: Mon, 14 Sep 2026 04:42:08 GMT
 
{"id":1,"username":"alice","email":"alice@example.com","role":"USER"}

Log của request đó:

Text
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
DaoAuthenticationProvider: Authenticated user
BasicAuthenticationFilter: Set SecurityContextHolder to UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=alice, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_USER, FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-09-14T04:42:08.004130Z]]]
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?

BasicAuthenticationFilter decode header, DaoAuthenticationProvider load alice qua JpaUserDetailsService (câu SELECT đầu tiên) và so khớp password, rồi filter lưu kết quả vào SecurityContextHolder cho request này. Câu SELECT thứ hai là me load profile.

Đường đi của một lần authentication: credentials từ header Authorization Basic hoặc từ POST /api/auth/login trở thành một token chưa authenticated, đi tới AuthenticationManager và DaoAuthenticationProvider; provider gọi JpaUserDetailsService và table users, kiểm tra trạng thái account trước password rồi gọi PasswordEncoder.matches; username không tồn tại, account bị vô hiệu hoá và password sai rẽ nhánh thành AuthenticationException; khi thành công, token đã authenticated được BasicAuthenticationFilter lưu cho request hoặc được controller login biến thành profile

Một câu log tạm thời trong me in ra các object liên quan:

Text
AuthController: me: principal=org.springframework.security.core.userdetails.User [Username=alice, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]] | class=org.springframework.security.core.userdetails.User password=null authorities=[ROLE_USER] | authentication=org.springframework.security.authentication.UsernamePasswordAuthenticationToken credentials=null
  • @AuthenticationPrincipal lấy Authentication.getPrincipal(): object UserloadUserByUsername dựng ra, không phải entity.
  • Password của nó là null, credentials của token cũng vậy. ProviderManagereraseCredentialsAfterAuthentication = true và xoá cả hai ngay khi authentication thành công, nên cả hash lẫn password được gửi lên đều không đi sâu hơn vào application. toString in [PROTECTED] trong mọi trường hợp.
  • AuthorityROLE_USER từ .roles(...). Token còn liệt kê authority FACTOR_PASSWORD mà bài 33 đã giải thích.
  • User không có id và email, nên me phải load lại entity. Một class UserDetails riêng mang các field đó sẽ bớt được query này; bài này giữ User của Spring.

Sai password và username không tồn tại với HTTP Basic

Bash
curl -i -s -u alice:wrong-password http://localhost:8134/api/auth/me
curl -i -s -u mallory:wrong-password http://localhost:8134/api/auth/me
Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Content-Length: 130
Date: Mon, 14 Sep 2026 04:42:09 GMT
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/auth/me","status":401,"title":"Unauthorized"}
HTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Content-Length: 130
Date: Mon, 14 Sep 2026 04:42:09 GMT
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/auth/me","status":401,"title":"Unauthorized"}

Hai response giống nhau từng byte, do ProblemDetailSecurityHandler của bài 33 viết ra. Khác biệt chỉ nằm trong log của server, stack trace đã rút gọn. Với password sai:

Text
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
DaoAuthenticationProvider: Failed to authenticate since password does not match stored value
ProviderManager: Authentication failed with provider DaoAuthenticationProvider since Bad credentials
ProviderManager: Denying authentication since all attempted providers failed
BasicAuthenticationFilter: Failed to process authentication request
org.springframework.security.authentication.BadCredentialsException: Bad credentials

Với username không tồn tại:

Text
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
DaoAuthenticationProvider: Failed to find user 'mallory'
ProviderManager: Authentication failed with provider DaoAuthenticationProvider since Bad credentials
ProviderManager: Denying authentication since all attempted providers failed
BasicAuthenticationFilter: Failed to process authentication request
org.springframework.security.authentication.BadCredentialsException: Bad credentials
Caused by: org.springframework.security.core.userdetails.UsernameNotFoundException: No user named mallory

UsernameNotFoundException từ JpaUserDetailsService kết thúc thành cause của một BadCredentialsException. Phần endpoint login giải thích sự chuyển đổi này và đo cái giá của nó.

Endpoint đăng nhập: POST /api/auth/login

HTTP Basic không có endpoint riêng; client chỉ biết credentials sai qua request nào thất bại đầu tiên. Một endpoint login cho client một chỗ để kiểm tra credentials và lấy profile, và bài 35 biến chính endpoint này thành nơi cấp token. Nó dùng cùng AuthenticationManagerBasicAuthenticationFilter đi tới.

src/main/java/com/example/demo/user/LoginRequest.java
package com.example.demo.user;
 
import jakarta.validation.constraints.NotBlank;
 
public record LoginRequest(@NotBlank String username, @NotBlank String password) {
}

Expose AuthenticationManager trong Spring Security 7

Khi thêm AuthenticationManager vào constructor của controller mà chưa khai báo bean, application không khởi động được:

Text
APPLICATION FAILED TO START
***************************
 
Description:
 
Parameter 1 of constructor in com.example.demo.user.AuthController required a bean of type 'org.springframework.security.authentication.AuthenticationManager' that could not be found.
 
 
Action:
 
Consider defining a bean of type 'org.springframework.security.authentication.AuthenticationManager' in your configuration.

Spring Security dựng AuthenticationManager toàn cục bên trong AuthenticationConfiguration, nhưng cả nó lẫn Spring Boot 4.1.1 đều không publish manager đó thành bean. Một method @Bean làm việc này:

src/main/java/com/example/demo/common/SecurityConfig.java
import org.springframework.security.authentication.AuthenticationManager; 
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; 
 
@Configuration
public class SecurityConfig {
 
    // apiSecurityFilterChain, webSecurityFilterChain and passwordEncoder are unchanged
 
    @Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) { 
        return configuration.getAuthenticationManager(); 
    } 
}

AuthenticationConfiguration.getAuthenticationManager() ở 7.1.1 không khai báo checked exception, nên method không cần throws. Output của inspector ở trên đến từ chính bean này: ProviderManager@5e0602ff với DaoAuthenticationProvider, đồng thời là parent của manager bên trong BasicAuthenticationFilter, nên cả hai cách login chạy cùng một provider. Không bước nào ở đây cần tới WebSecurityConfigurerAdapter, class đã bị xoá như bài 33 đã cho thấy.

Gọi authenticate() từ controller

src/main/java/com/example/demo/user/AuthController.java
package com.example.demo.user;
 
import jakarta.validation.Valid;
 
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager; 
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 
import org.springframework.security.core.Authentication; 
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
@RequestMapping("/api/auth")
public class AuthController {
 
    private final UserService userService;
    private final AuthenticationManager authenticationManager; 
 
    public AuthController(UserService userService) { 
    public AuthController(UserService userService, AuthenticationManager authenticationManager) { 
        this.userService = userService;
        this.authenticationManager = authenticationManager; 
    }
 
    @PostMapping("/register")
    public ResponseEntity<UserResponse> register(@Valid @RequestBody RegisterRequest request) {
        AppUser user = userService.register(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.from(user));
    }
 
    @PostMapping("/login") 
    public UserResponse login(@Valid @RequestBody LoginRequest request) { 
        Authentication authentication = authenticationManager.authenticate( 
                UsernamePasswordAuthenticationToken.unauthenticated(request.username(), request.password())); 
        return UserResponse.from(userService.findByUsername(authentication.getName())); 
    } 
 
    @GetMapping("/me")
    public UserResponse me(@AuthenticationPrincipal UserDetails principal) {
        return UserResponse.from(userService.findByUsername(principal.getUsername()));
    }
}
src/main/java/com/example/demo/common/GlobalExceptionHandler.java
import org.springframework.http.HttpHeaders; 
import org.springframework.http.ResponseEntity; 
import org.springframework.security.authentication.BadCredentialsException; 
 
    @ExceptionHandler(BadCredentialsException.class) 
    public ResponseEntity<ProblemDetail> badCredentials(BadCredentialsException e) { 
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Invalid username or password"); 
        return ResponseEntity.of(problem) 
                .header(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"catalogue\"") 
                .build(); 
    } 
  • UsernamePasswordAuthenticationToken.unauthenticated(username, password) là request gửi cho manager. authenticate trả về một token mới đã authenticated, hoặc ném một AuthenticationException.
  • Exception rời controller như mọi exception khác, nên advice biến nó thành ProblemDetail. 401 mang header WWW-Authenticate như bài 15 yêu cầu, với realm của entry point bài 33.
  • authentication.getName() là username; profile lấy từ service, giống như me.
Bash
curl -i -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"Wonderland-2026"}' http://localhost:8134/api/auth/login
Text
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Content-Length: 69
Date: Mon, 14 Sep 2026 04:42:05 GMT
 
{"id":1,"username":"alice","email":"alice@example.com","role":"USER"}

Log, trong đó dòng thứ ba là một câu log tạm thời đặt sau authenticate:

Text
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
DaoAuthenticationProvider: Authenticated user
AuthController: login result: UsernamePasswordAuthenticationToken authenticated=true principal=org.springframework.security.core.userdetails.User credentials=null
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?

Body rỗng bị validation chặn trước mọi bước authentication:

Bash
curl -i -s -H 'Content-Type: application/json' -d '{"username":"","password":""}' http://localhost:8134/api/auth/login
Text
HTTP/1.1 422
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:42:07 GMT
 
{"detail":"password must not be blank, username must not be blank","instance":"/api/auth/login","status":422,"title":"Unprocessable Content"}

Endpoint login có đăng nhập user không?

Không. Ngay sau response 200 ở trên, cùng client đó gọi me mà không gửi credentials:

Bash
curl -i -s http://localhost:8134/api/auth/me
Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Content-Length: 130
Date: Mon, 14 Sep 2026 04:42:06 GMT
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/auth/me","status":401,"title":"Unauthorized"}

Response của login không có Set-Cookie, không có token, controller không đặt kết quả vào security context nào, và API chain là STATELESS, nên không có gì còn lại sau response. Endpoint trả lời hai câu hỏi: credentials này có hợp lệ không, và chúng thuộc về ai. Mọi request sau đó tự authenticate, tạm thời vẫn bằng HTTP Basic.

Username không tồn tại và sai password: status, body và thời gian

Bash
curl -i -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"wrong-password"}' http://localhost:8134/api/auth/login
curl -i -s -H 'Content-Type: application/json' -d '{"username":"mallory","password":"wrong-password"}' http://localhost:8134/api/auth/login
Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:42:06 GMT
 
{"detail":"Invalid username or password","instance":"/api/auth/login","status":401,"title":"Unauthorized"}
HTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:42:07 GMT
 
{"detail":"Invalid username or password","instance":"/api/auth/login","status":401,"title":"Unauthorized"}

Cùng status, cùng header ngoại trừ Date, cùng body. Log của server thì vẫn phân biệt được:

Text
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
DaoAuthenticationProvider: Failed to authenticate since password does not match stored value
ProviderManager: Authentication failed with provider DaoAuthenticationProvider since Bad credentials
Text
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
DaoAuthenticationProvider: Failed to find user 'mallory'
ProviderManager: Authentication failed with provider DaoAuthenticationProvider since Bad credentials

Body giấu được khác biệt thì chưa đủ nếu thời gian response để lộ nó. Sau ba cặp request làm nóng, 30 request mỗi loại chạy xen kẽ nhau, đo bằng %{time_total} của curl:

RequestStatusMinMedianMax
login, sai password40154.7 ms55.9 ms69.8 ms
login, username không tồn tại40154.5 ms55.9 ms61.7 ms
HTTP Basic, sai password40154.3 ms54.9 ms59.2 ms
HTTP Basic, username không tồn tại40154.4 ms55.1 ms70.8 ms

Các median chênh nhau chưa tới một millisecond, và đều nằm ở mức một lần so sánh BCrypt. Hai cơ chế trong DaoAuthenticationProvider tạo ra kết quả đó. Thứ nhất là hideUserNotFoundExceptions = true, ném lại UsernameNotFoundException dưới dạng BadCredentialsException("Bad credentials"), đúng sự chuyển đổi đã thấy trong log của Basic. Thứ hai nằm ngay trong class:

Bash
javap -p -constants -classpath spring-security-core-7.1.1.jar:spring-security-crypto-7.1.1.jar org.springframework.security.authentication.dao.DaoAuthenticationProvider
Text
  private static final java.lang.String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";
  private volatile java.lang.String userNotFoundEncodedPassword;
  public org.springframework.security.authentication.dao.DaoAuthenticationProvider(org.springframework.security.core.userdetails.UserDetailsService);
  protected final org.springframework.security.core.userdetails.UserDetails retrieveUser(java.lang.String, org.springframework.security.authentication.UsernamePasswordAuthenticationToken) throws org.springframework.security.core.AuthenticationException;
  private void prepareTimingAttackProtection();
  private void mitigateAgainstTimingAttack(org.springframework.security.authentication.UsernamePasswordAuthenticationToken);

retrieveUser gọi prepareTimingAttackProtection trước tiên; method này encode chuỗi userNotFoundPassword một lần bằng PasswordEncoder của bạn và giữ hash trong userNotFoundEncodedPassword, field mà inspector cho thấy là null trước lần login đầu tiên. Khi loadUserByUsername ném UsernameNotFoundException, mitigateAgainstTimingAttack gọi matches giữa password được gửi lên và hash đó rồi mới để exception đi tiếp. Vì vậy một username không tồn tại cũng tốn một câu SELECT và một lần so sánh BCrypt, y như một password sai.

Account bị vô hiệu hoá

Quản trị viên vô hiệu hoá carol ngay trong table trên PostgreSQL:

Bash
docker exec sb-a34-pg psql -U demo -d demo -c "update users set enabled = false where username = 'carol'"
Text
UPDATE 1

HTTP Basic với password đúng của carol, rồi với một password sai, gọi vào application đang chạy profile postgres:

Bash
curl -i -s -u carol:Hatter-Tea-2026 http://localhost:8134/api/auth/me
curl -i -s -u carol:wrong-password http://localhost:8134/api/auth/me

Cả hai đều nhận 401 của entry point:

Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Content-Length: 130
Date: Mon, 14 Sep 2026 04:42:23 GMT
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/auth/me","status":401,"title":"Unauthorized"}

Log với password sai:

Text
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
DaoAuthenticationProvider: Failed to authenticate since user account is disabled
DaoAuthenticationProvider: Failed to authenticate since password does not match stored value
ProviderManager: Authentication failed for user 'carol' since their account status is User is disabled
org.springframework.security.authentication.DisabledException: User is disabled
ProviderManager: Denying authentication since all attempted providers failed
BasicAuthenticationFilter: Failed to process authentication request
org.springframework.security.authentication.DisabledException: User is disabled

Với password đúng, log có đúng những dòng đó trừ dòng password does not match. Exception trong cả hai trường hợp là DisabledException: User is disabled. Provider kiểm tra trạng thái account (locked, rồi disabled, rồi expired) trước password; khi một bước kiểm tra thất bại và alwaysPerformAdditionalChecksOnUsertrue, nó vẫn chạy phép so sánh password, rồi ném exception về trạng thái account bất kể kết quả so sánh ra sao. Với HTTP Basic, client không thấy gì trong số này, vì entry point viết cùng một body cho mọi lần thất bại.

Endpoint login với handler của phần trước:

Bash
curl -i -s -H 'Content-Type: application/json' -d '{"username":"carol","password":"Hatter-Tea-2026"}' http://localhost:8134/api/auth/login
curl -i -s -H 'Content-Type: application/json' -d '{"username":"carol","password":"wrong-password"}' http://localhost:8134/api/auth/login

Cả hai nhận về:

Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Content-Length: 133
Date: Mon, 14 Sep 2026 04:42:24 GMT
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/auth/login","status":401,"title":"Unauthorized"}

DisabledException không phải BadCredentialsException, nên advice không xử lý nó. Exception thoát ra khỏi controller, và filter chain chuyển nó tới entry point của bài 33. Status vẫn là 401, nhưng body là của entry point chứ không phải Invalid username or password: một client so sánh body sẽ biết carol tồn tại và đã bị vô hiệu hoá, dù gửi password nào. Handler cần bao cả các lỗi về trạng thái account:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
import org.springframework.security.authentication.AccountStatusException; 
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException; 
 
    @ExceptionHandler(BadCredentialsException.class) 
    public ResponseEntity<ProblemDetail> badCredentials(BadCredentialsException e) { 
    @ExceptionHandler({ BadCredentialsException.class, AccountStatusException.class }) 
    public ResponseEntity<ProblemDetail> authenticationFailed(AuthenticationException e) { 
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Invalid username or password");
        return ResponseEntity.of(problem)
                .header(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"catalogue\"")
                .build();
    }

AccountStatusException là class cha của DisabledException, LockedException, AccountExpiredExceptionCredentialsExpiredException. Sau khi sửa, carol với password nào cũng như alice với password sai đều nhận cùng một response:

Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:42:30 GMT
 
{"detail":"Invalid username or password","instance":"/api/auth/login","status":401,"title":"Unauthorized"}

Log vẫn ghi DisabledException: User is disabled, đúng nơi quản trị viên cần tới.

Xác thực stateless và chi phí trên mỗi request

HTTP Basic gửi chính password kèm theo mọi request. Header mà curl -u đã gửi cho me:

Text
> GET /api/auth/me HTTP/1.1
> Host: localhost:8134
> Authorization: Basic YWxpY2U6V29uZGVybGFuZC0yMDI2
> User-Agent: curl/8.7.1
> Accept: */*
Bash
echo YWxpY2U6V29uZGVybGFuZC0yMDI2 | base64 -d
Text
alice:Wonderland-2026

Base64 là encoding, không phải encryption; ai thấy header là có password, nên HTTP Basic chỉ nên đi qua HTTPS. Không response nào từ API chain mang Set-Cookie: chain là STATELESS, và không có JSESSIONID nào để nhớ user giữa các request. Cookie session duy nhất trong bài đến từ chain form login, ở phần 72 byte. Vì vậy server lặp lại toàn bộ authentication ở mỗi lời gọi: câu SELECT trên users và một lần so sánh BCrypt. Các request tới me, chạy xen kẽ trong cùng lần chạy với phần đo login:

RequestStatusSố requestMedian
GET /api/auth/me với credentials Basic đúng2003055.8 ms
GET /api/auth/me không có credentials401300.7 ms
GET /api/auth/me với credentials đúng, sau khi tăng cost lên 1220015224.1 ms

Gần như toàn bộ 55 ms là lần kiểm tra BCrypt cost 10 trong bảng ở phần lab, và ở cost 12 mỗi request mất 224.1 ms. Một client gọi mười lần thì trả chi phí đó mười lần. Token cho phép server kiểm tra một chữ ký thay vì một password hash ở mỗi request, và bài 35 xây đúng thứ đó.

Ba hàng cho biết mỗi request đã làm gì: register chạy hai query exists, encode password và insert row, trả 201 không kèm password; login authenticate bằng một câu select và một lần matches, trả 200 không có cookie hay token, sau đó một request không có credentials nhận 401; mọi request sau đó gửi password trong header Authorization Basic và lặp lại select cùng matches, median 55.8 ms so với 0.7 ms của một 401 anonymous

Nâng cấp hash khi đăng nhập với UserDetailsPasswordService

Inspector đã cho thấy JpaUserDetailsService được nối vào làm userDetailsPasswordService của provider. Sau một lần authentication thành công, createSuccessAuthentication gọi upgradeEncoding với hash đã lưu; khi nó trả về true, provider encode lại password được gửi lên và chuyển hash mới cho updatePassword. Với BCryptPasswordEncoder, upgradeEncoding trả về true khi cost đã lưu thấp hơn cost của encoder. Tăng cost:

src/main/java/com/example/demo/common/SecurityConfig.java
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); 
        return new BCryptPasswordEncoder(12); 
    }

Table trên PostgreSQL trước đó, alicecarol đều ở cost 10:

Bash
docker exec sb-a34-pg psql -U demo -d demo -c "select username, password_hash from users order by id"
Text
 username |                        password_hash
----------+--------------------------------------------------------------
 alice    | $2a$10$NKoTpp5KtZNKpR2LmYE84.dz2I/JnOOHPt0hQmnC/jqzalEjtSzpi
 carol    | $2a$10$u/wmILXQJ9Eq24Px1/eWr.m38MW57mPoG//MlgprOIEPkMN7/I.qm
(2 rows)

Build lại và chạy với cùng database, alice login một lần qua POST /api/auth/login và nhận 200 kèm profile. Log:

Text
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
SQL: update users set email=?,enabled=?,password_hash=?,role=?,username=? where id=?
DaoAuthenticationProvider: Authenticated user
AuthController: login result: UsernamePasswordAuthenticationToken authenticated=true principal=org.springframework.security.core.userdetails.User credentials=null
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?

Câu SELECT đầu tiên là loadUserByUsername, câu thứ hai là updatePassword load entity, và câu UPDATE là dirty checking của nó lúc commit. Table sau đó:

Text
 username |                        password_hash
----------+--------------------------------------------------------------
 alice    | $2a$12$hzD5SxwZqzX9LUybKoUEU.cHTCKXGP0fexFM5F3kHqiM.vlYR3qxG
 carol    | $2a$10$u/wmILXQJ9Eq24Px1/eWr.m38MW57mPoG//MlgprOIEPkMN7/I.qm
(2 rows)

Lần login thứ hai của alice không gửi câu UPDATE nào:

Text
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
DaoAuthenticationProvider: Authenticated user
AuthController: login result: UsernamePasswordAuthenticationToken authenticated=true principal=org.springframework.security.core.userdetails.User credentials=null
SQL: select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?

Một hash chỉ có thể được nâng cấp đúng lúc user gửi password đúng, vì đó là lúc duy nhất application có password. carol chưa login nên vẫn giữ hash cost 10 cho tới khi cô ấy login.

Các thành phần và trách nhiệm

Thành phầnTrách nhiệm trong bài nàyCode của bạn hay của Spring
AppUser và table usersusername, email, password hash, role, cờ enabledcủa bạn
AppUserRepositoryfindByUsername, existsByUsername, existsByEmailinterface của bạn, implementation của Spring Data
JpaUserDetailsServicemap AppUser sang UserDetails; lưu hash đã nâng cấp trong updatePasswordcủa bạn
BCryptPasswordEncoderencode khi register, matches khi login, upgradeEncoding sau khi thành côngclass của Spring, bean của bạn
DaoAuthenticationProvidergọi UserDetailsService, kiểm tra trạng thái account, so sánh password, giấu username không tồn tại, kích hoạt rehashcủa Spring, dựng từ bean của bạn
ProviderManagerchính là AuthenticationManager: chạy provider và xoá credentials sau khi thành côngcủa Spring; method bean của bạn expose nó
BasicAuthenticationFilterđọc Authorization: Basic, lưu kết quả vào SecurityContextHolder cho một requestcủa Spring, bật bằng httpBasic ở bài 33
ProblemDetailSecurityHandlerbody 401 và 403 khi filter chain từ chối requestcủa bạn, từ bài 33
UserServicekiểm tra trùng, encode, save, tìm profilecủa bạn
AuthControllerregister, login qua authenticate, me qua @AuthenticationPrincipalcủa bạn
GlobalExceptionHandler409 cho username đã có, 422 cho body không hợp lệ, 401 cho lỗi do authenticate ném racủa bạn

FAQ

Làm sao lấy bean AuthenticationManager trong Spring Boot 4 và Spring Security 7?

Hãy tự khai báo nó: một method @Bean nhận AuthenticationConfiguration và trả về configuration.getAuthenticationManager(). Thiếu method này, việc inject AuthenticationManager fail lúc khởi động với required a bean of type 'org.springframework.security.authentication.AuthenticationManager' that could not be found. ProviderManager được trả về chứa DaoAuthenticationProvider mà Spring Security dựng từ UserDetailsServicePasswordEncoder của bạn, và cũng là parent của manager mà BasicAuthenticationFilter dùng.

BCryptPasswordEncoder có cắt bớt password dài hơn 72 byte không?

Khi hash thì không. Ở Spring Security 7.1.1, encode ném IllegalArgumentException: password cannot be more than 72 bytes, tính theo byte UTF-8, nên 19 emoji bốn byte bị từ chối dù String.length() chỉ là 38. matches thì không ném: một password gồm 72 byte cộng thêm XYZ vẫn match hash của 72 byte đó. Hãy kiểm tra độ dài theo byte trước khi gọi encode, nếu không một exception không được xử lý sẽ rơi xuống trang lỗi.

Vì sao DelegatingPasswordEncoder báo mỗi password phải có prefix?

Vì hash đã lưu không có prefix {id}. PasswordEncoderFactories.createDelegatingPasswordEncoder() chọn encoder theo prefix, và với một hash $2a$10$... trơn, matches ném IllegalArgumentException: Given that there is no default password encoder configured, each password must have a password encoding prefix. Hãy lưu {bcrypt}$2a$10$... do chính encode của nó sinh ra, hoặc dùng bean BCryptPasswordEncoder cho hash không có prefix.

Endpoint login gọi AuthenticationManager.authenticate() có tạo session không?

Không, với một chain stateless. Response 200 từ POST /api/auth/login không có Set-Cookie, và request tiếp theo không có credentials nhận 401. authenticate chỉ trả về một Authentication; controller phải tự lưu nó ở đâu đó, còn bài 35 sẽ đưa cho client một token.

Account bị vô hiệu hoá có nên nhận lỗi khác với sai password không?

Không, ở endpoint login. DaoAuthenticationProvider ném DisabledException mà không quan tâm kết quả kiểm tra password, nên một response khác biệt sẽ tiết lộ account tồn tại và đã bị vô hiệu hoá, kể cả với người gửi sai password. Khi chỉ xử lý BadCredentialsException, body đã khác nhau thật; xử lý thêm AccountStatusException với cùng một 401 làm các response giống hệt nhau, còn log vẫn giữ DisabledException: User is disabled.

BCrypt mất bao lâu ở cost 10, 12 và 14?

Trên một máy với Spring Security 7.1.1, lần nhanh nhất trong năm lần gọi matches mất 55.6 ms ở cost 10, 216.6 ms ở cost 12 và 877.3 ms ở cost 14. Một request HTTP Basic tới API có median 55.8 ms ở cost 10 và 224.1 ms ở cost 12, so với 0.7 ms của một request bị từ chối vì không có credentials. Các con số chỉ mang tính tham khảo; hãy đo trên server của bạn.

Vì sao một exception khi register lại trả về 302 tới /login?

Vì exception đã đi tới trang lỗi của Spring Boot. IllegalArgumentException: password cannot be more than 72 bytes không có handler, ERROR dispatch tới /error nằm ngoài /api/**, và chain form login trả lời lần dispatch anonymous đó bằng 302 tới /login kèm cookie JSESSIONID. Hãy từ chối ngay trong request DTO thứ mà encode từ chối, tức password dài hơn 72 byte UTF-8, và client sẽ nhận 422.

Kết luận

Account giờ nằm trong table users, và Spring Security đi tới chúng qua một JpaUserDetailsService duy nhất. Spring Security tự dựng DaoAuthenticationProvider từ bean đó và bean BCryptPasswordEncoder, còn Spring Boot ngừng tạo user tự sinh ngay khi service tồn tại. BCrypt lưu một chuỗi 60 ký tự chứa sẵn version, cost và salt, tốn khoảng 56 ms cho mỗi lần kiểm tra ở cost 10, và ở 7.1.1 từ chối hash quá 72 byte trong khi bỏ qua mọi byte sau đó lúc login. Register validate, encode rồi trả 409 hoặc 422, và một bước kiểm tra byte trong DTO giữ cho password quá dài không kết thúc thành một redirect từ chain form login.

Endpoint login gọi AuthenticationManager được expose từ AuthenticationConfiguration, trả về profile và không để lại session. Username không tồn tại và password sai cho ra cùng body 401 và cùng thời gian, nhờ hideUserNotFoundExceptions và phép so sánh BCrypt giả; account bị vô hiệu hoá cần AccountStatusException trong handler để trông giống hệt. @AuthenticationPrincipal trao User của Spring với password đã bị xoá, tăng cost sẽ nâng cấp hash dần khi user login, còn HTTP Basic trả một câu SELECT và một lần kiểm tra BCrypt cho mỗi request.

Bài tiếp theo bỏ bước kiểm tra password ở mỗi request đó: JWT cho REST API, stateless authentication, tạo và kiểm tra token.

Bài viết liên quan

[Spring Boot Basics] Phân quyền trong Spring Security: role, @PreAuthorize, cấu hình CORS và CSRF

Authorization trong Spring Security trên Spring Boot 4.1.1, cho một JWT API: authority và tiền tố ROLE_, hasRole so với hasAuthority, hasRole("ROLE_ADMIN") làm gì trong URL rule và trong SpEL, các rule authorizeHttpRequests cho catalogue với 401, 403 và 200, @EnableMethodSecurity, @PreAuthorize và @PostAuthorize với rule ownership, bẫy self-invocation và catch-all advice biến một 403 từ method security thành 500, một RoleHierarchy bean, CORS với preflight bị chặn trong headless Chrome và được http.cors cùng CorsConfigurationSource trả lời, allowedOrigins("*") kèm allowCredentials, và CSRF trên session chain với csrf.spa, cookie XSRF-TOKEN và header X-XSRF-TOKEN, và vì sao bearer API có thể tắt nó.

[Spring Boot Basics] Kiến thức Java cần có trước khi học Spring Boot: OOP, Generics, Stream, Record và Annotation

Những gì bạn cần nắm về Java trước khi vào Spring Boot 4.1.1: interface và polymorphism, List/Set/Map, generics và type erasure, lambda và Stream, Optional, record làm DTO, và phần quan trọng nhất — tự viết annotation rồi đọc lại bằng reflection, đúng cách @Component và @GetMapping hoạt động.

[Spring Boot Basics] JSON với Jackson 3 và DTO trong Spring Boot: serialize, deserialize và MapStruct

JSON trong Spring Boot 4.1.1 với Jackson 3: JacksonJsonHttpMessageConverter và bean jacksonJsonMapper, package tools.jackson, JsonMapper immutable và exception unchecked, đo các giá trị mặc định của Jackson 3 so với use-jackson2-defaults, @JsonProperty, @JsonIgnore, @JsonInclude, @JsonFormat, BigDecimal, enum và Optional, record, @JsonAlias và @JsonCreator, property spring.jackson và JsonMapperBuilderCustomizer, vì sao DTO tốt hơn để lộ entity, map bằng tay và MapStruct 1.6.3 với Gradle và Maven.

[Spring Boot Basics] JWT cho REST API với Spring Boot: stateless authentication, tạo và kiểm tra token

Stateless authentication bằng JWT với Spring Security và nimbus-jose-jwt trên Spring Boot 4.1.1: cặp RSA key tạo bằng openssl, JwtDecoder mà Spring Boot tạo từ public-key-location, bean NimbusJwtEncoder, endpoint login trả về accessToken, tokenType và expiresIn, một token được tách thành header, payload và signature, oauth2ResourceServer thay cho HTTP Basic, @AuthenticationPrincipal Jwt, authority SCOPE_ mặc định và claim roles được map thành ROLE_ bằng authorities-claim-name và authority-prefix, FACTOR_BEARER, response 401 kèm WWW-Authenticate Bearer và body ProblemDetail cho token sai định dạng, bị sửa, ký bằng key khác, alg none và hết hạn, clock skew 60 giây, validator cho iss và exp, và một user bị vô hiệu hoá vẫn dùng được token cho tới khi hết hạn.