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, h2 và postgresql. 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.
![]()
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:
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'
}<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-h2console</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>spring-boot-starter-security kéo theo spring-security-core, -web, -config và -crypto, tất cả đều là 7.1.1. Account có feature package riêng, com.example.demo.user, nằm cạnh product, order và customer. 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.
package com.example.demo.user;
public enum Role {
USER, ADMIN
}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.usernamevàemaillàunique. 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.passwordHashgiữ 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 columnpassword_hash.roledùngEnumType.STRING, vì lý do bài 26 đã nêu.enabledbắ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 equals và hashCode theo mẫu dựa trên id của bài 26.
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:
docker run -d --name sb-a34-pg -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=demo -p 55434:5432 postgres:18spring.datasource.url=jdbc:postgresql://localhost:55434/demo
spring.datasource.username=demo
spring.datasource.password=secretspring.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:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8134 --spring.profiles.active=postgres --spring.jpa.hibernate.ddl-auto=updateHibernate log ra:
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)docker exec sb-a34-pg psql -U demo -d demo -c '\d users' 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:
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 implementationUserDetailscủa chính Spring Security,org.springframework.security.core.userdetails.User. Entity không rời khỏi packageuser..password(user.getPasswordHash())trao cho provider hash đã lưu, thứ nó sẽ dùngPasswordEncoderđể so sánh..roles(user.getRole().name())biếnUSERthành authorityROLE_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.UsernameNotFoundExceptionlà 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;@Transactionalgiú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:
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:
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 inMemoryUserDetailsManagerKhi đã có JpaUserDetailsService, không còn password tự sinh nữa, và dòng INFO gọi tên bean mới:
2026-09-14T11:17:25.055+07:00 INFO 51765 --- [demo] [ main] r$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name jpaUserDetailsServiceChạy với --debug, condition evaluation report cho biết lý do:
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:
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);
}
}java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8134 --spring.profiles.active=postgres,inspect --spring.jpa.hibernate.ddl-auto=updateRút gọn còn những field quan trọng, bỏ phần liệt kê lặp lại của parent:
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: falseAuthenticationManagerlà mộtProviderManagervới đúng mộtDaoAuthenticationProvider.PasswordEncodercủa provider chính là beanBCryptPasswordEncoder: identity hash2738a1fccho thấy đó là cùng một object.userDetailsServicevàuserDetailsPasswordServicecủa provider đều làJpaUserDetailsService, nhìn qua một CGLIB proxy vìupdatePasswordcó@Transactional.hideUserNotFoundExceptions = truevàalwaysPerformAdditionalChecksOnUser = truequyết định những gì phần login và phần account bị vô hiệu hoá quan sát được.userNotFoundEncodedPasswordvẫn lànullvì chưa ai thử login.BasicAuthenticationFiltertrong API chain của bài 33 cóProviderManagerriêng, chỉ chứaAnonymousAuthenticationProvider, với manager toàn cục5e0602fflàm parent. Một token username và password sẽ đi tiếp tớiDaoAuthenticationProvidercủ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:
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());
}
}
}java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8134 --spring.profiles.active=bcryptCấu trúc của một BCrypt hash
Phần đầu của output:
-- 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
$2a$là biến thể của BCrypt.BCryptPasswordEncodermặc định ghi$2a$;BCryptVersion.$2Bsinh ra$2b$, và encoder mặc định vẫn match hash đó. Pattern kiểm tra của nó, đọc bằngjavap, 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: classBCrypttính1 << log_rounds, nên 10 nghĩa là 1024 vòng.new BCryptPasswordEncoder()dùngGENSALT_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 = 16byte 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
-- 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| Cost | Số vòng | matches(), tốt nhất trong 5 lần | So với cost 10 |
|---|---|---|---|
| 10 | 1,024 | 55.6 ms | 1× |
| 12 | 4,096 | 216.6 ms | 3.9× |
| 14 | 16,384 | 877.3 ms | 15.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
-- 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)=truePasswordEncoderFactories.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
-- 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)=trueencodetừ chối input dài hơn 72 byte vớiIllegalArgumentException: 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
charvà bốn byte, nên 19 emoji là 38 theoString.length()và 76 byte, và bị từ chối. matcheskhô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,hashpwbỏ qua bước kiểm tra độ dài khi được gọi để so sánh. Vìencodekhô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ề.
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) {
}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());
}
}package com.example.demo.user;
public class UserAlreadyExistsException extends RuntimeException {
public UserAlreadyExistsException(String message) {
super(message);
}
}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));
}
}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));
}
}RegisterRequestgiới hạn mỗi field theo column của nó.@Patternchỉ cho phép chữ thường, chữ số,.,_và-, nên không thể registerAlicebên cạnhalice.UserService.registerkiểm tra username rồi đến email, và némUserAlreadyExistsExceptionvớ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 đã mapDataIntegrityViolationExceptionđó sang 409.passwordEncoder.encodechạy trướcsave, nên entity chỉ bao giờ giữ hash.UserResponsekhô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:
.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:
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:
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'curl -i -s -H 'Content-Type: application/json' -d '{"username":"alice","email":"alice@example.com","password":"Wonderland-2026"}' http://localhost:8134/api/auth/registerHTTP/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:
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:
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' 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
curl -i -s -H 'Content-Type: application/json' -d '{"username":"Al","email":"not-an-email","password":"short"}' http://localhost:8134/api/auth/registerHTTP/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:
curl -i -s -H 'Content-Type: application/json' -d '{"username":"alice","email":"alice2@example.com","password":"Another-pass-1"}' http://localhost:8134/api/auth/registerHTTP/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ề:
{"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:
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/registerHTTP/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 GMTLog, stack trace đã rút gọn:
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:
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:
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 đó:
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
curl -i -s -u alice:Wonderland-2026 http://localhost:8134/api/auth/meHTTP/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 đó:
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.

Một câu log tạm thời trong me in ra các object liên quan:
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@AuthenticationPrincipallấyAuthentication.getPrincipal(): objectUsermàloadUserByUsernamedựng ra, không phải entity.- Password của nó là
null, credentials của token cũng vậy.ProviderManagercóeraseCredentialsAfterAuthentication = truevà 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.toStringin[PROTECTED]trong mọi trường hợp. - Authority là
ROLE_USERtừ.roles(...). Token còn liệt kê authorityFACTOR_PASSWORDmà bài 33 đã giải thích. Userkhông có id và email, nênmephải load lại entity. Một classUserDetailsriêng mang các field đó sẽ bớt được query này; bài này giữUsercủa Spring.
Sai password và username không tồn tại với HTTP Basic
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/meHTTP/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:
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 credentialsVới username không tồn tại:
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 malloryUsernameNotFoundException 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 AuthenticationManager mà BasicAuthenticationFilter đi tới.
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:
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:
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
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()));
}
}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.authenticatetrả về một token mới đã authenticated, hoặc ném mộtAuthenticationException.- Exception rời controller như mọi exception khác, nên advice biến nó thành
ProblemDetail. 401 mang headerWWW-Authenticatenhư 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.
curl -i -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"Wonderland-2026"}' http://localhost:8134/api/auth/loginHTTP/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:
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:
curl -i -s -H 'Content-Type: application/json' -d '{"username":"","password":""}' http://localhost:8134/api/auth/loginHTTP/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:
curl -i -s http://localhost:8134/api/auth/meHTTP/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
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/loginHTTP/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:
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 credentialsSQL: 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 credentialsBody 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:
| Request | Status | Min | Median | Max |
|---|---|---|---|---|
| login, sai password | 401 | 54.7 ms | 55.9 ms | 69.8 ms |
| login, username không tồn tại | 401 | 54.5 ms | 55.9 ms | 61.7 ms |
| HTTP Basic, sai password | 401 | 54.3 ms | 54.9 ms | 59.2 ms |
| HTTP Basic, username không tồn tại | 401 | 54.4 ms | 55.1 ms | 70.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:
javap -p -constants -classpath spring-security-core-7.1.1.jar:spring-security-crypto-7.1.1.jar org.springframework.security.authentication.dao.DaoAuthenticationProvider 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:
docker exec sb-a34-pg psql -U demo -d demo -c "update users set enabled = false where username = 'carol'"UPDATE 1HTTP 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:
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/meCả hai đều nhận 401 của entry point:
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:
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 disabledVớ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à alwaysPerformAdditionalChecksOnUser là true, 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:
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/loginCả hai nhận về:
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:
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, AccountExpiredException và CredentialsExpiredException. 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:
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:
> GET /api/auth/me HTTP/1.1
> Host: localhost:8134
> Authorization: Basic YWxpY2U6V29uZGVybGFuZC0yMDI2
> User-Agent: curl/8.7.1
> Accept: */*echo YWxpY2U6V29uZGVybGFuZC0yMDI2 | base64 -dalice:Wonderland-2026Base64 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:
| Request | Status | Số request | Median |
|---|---|---|---|
GET /api/auth/me với credentials Basic đúng | 200 | 30 | 55.8 ms |
GET /api/auth/me không có credentials | 401 | 30 | 0.7 ms |
GET /api/auth/me với credentials đúng, sau khi tăng cost lên 12 | 200 | 15 | 224.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ứ đó.

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:
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
return new BCryptPasswordEncoder(12);
}Table trên PostgreSQL trước đó, alice và carol đều ở cost 10:
docker exec sb-a34-pg psql -U demo -d demo -c "select username, password_hash from users order by id" 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:
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 đó:
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:
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ần | Trách nhiệm trong bài này | Code của bạn hay của Spring |
|---|---|---|
AppUser và table users | username, email, password hash, role, cờ enabled | của bạn |
AppUserRepository | findByUsername, existsByUsername, existsByEmail | interface của bạn, implementation của Spring Data |
JpaUserDetailsService | map AppUser sang UserDetails; lưu hash đã nâng cấp trong updatePassword | của bạn |
BCryptPasswordEncoder | encode khi register, matches khi login, upgradeEncoding sau khi thành công | class của Spring, bean của bạn |
DaoAuthenticationProvider | gọ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 rehash | của Spring, dựng từ bean của bạn |
ProviderManager | chính là AuthenticationManager: chạy provider và xoá credentials sau khi thành công | củ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 request | của Spring, bật bằng httpBasic ở bài 33 |
ProblemDetailSecurityHandler | body 401 và 403 khi filter chain từ chối request | của bạn, từ bài 33 |
UserService | kiểm tra trùng, encode, save, tìm profile | của bạn |
AuthController | register, login qua authenticate, me qua @AuthenticationPrincipal | của bạn |
GlobalExceptionHandler | 409 cho username đã có, 422 cho body không hợp lệ, 401 cho lỗi do authenticate ném ra | củ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ừ UserDetailsService và PasswordEncoder 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.