Authentication flow: username/password được kiểm tra thế nào?
Tiếp đi: luồng xác thực (authentication).
Bài trước ta biết Spring Security dùng SecurityFilterChain để kiểm tra request trước Controller. Bài này trả lời: Spring Security thực sự xác thực username/password như thế nào, và từng class làm việc gì.
1. Bài toán
Client của shop-app gửi:
GET /api/orders
Authorization: Basic <base64(john:password)>
Spring Security phải:
- Lấy username/password từ request.
- Tìm user trong hệ thống.
- Kiểm tra password.
- Nếu đúng, ghi nhận user đã đăng nhập.
- Kiểm tra quyền truy cập API.
- Cho request vào Controller.
Vậy class nào chịu trách nhiệm bước nào?
2. Vấn đề
Nếu tự viết, bạn sẽ trộn lẫn: đọc header, query DB, so password, lưu user hiện tại vào đâu đó... trong một chỗ. Đổi sang LDAP hay JWT là phải viết lại hết. Lưu password dạng plaintext thì lộ DB là lộ hết.
3. Giải pháp: tách vai trò
HTTP Request (Authorization: Basic ...)
|
v
BasicAuthenticationFilter trích username/password
|
v
AuthenticationManager điều phối
|
v
DaoAuthenticationProvider thực hiện xác thực
|-----> UserDetailsService load user từ DB
|-----> PasswordEncoder so password với hash
v
SecurityContextHolder lưu principal đã xác thực
| Thành phần | Nhiệm vụ |
|---|---|
AuthenticationManager | Interface điều phối: authenticate(Authentication) |
ProviderManager | Implementation phổ biến, giữ danh sách provider |
AuthenticationProvider | Xác thực theo một kiểu cụ thể (DAO, LDAP, JWT...) |
UserDetailsService | Chỉ load user theo username |
PasswordEncoder | Hash và so khớp password |
SecurityContextHolder | Giữ Authentication của request hiện tại |
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
AuthenticationManager
|
ProviderManager
+-- DaoAuthenticationProvider
+-- LdapAuthenticationProvider
+-- CustomAuthenticationProvider
Không phải provider nào cũng chạy. ProviderManager hỏi từng provider supports(authentication.getClass()), provider nào hỗ trợ loại token đó mới xử lý.
4. Code từng bước
Mục tiêu: đăng ký, lưu password đã hash vào PostgreSQL, đăng nhập và gọi API được bảo vệ.
shop-app/
├── config/SecurityConfig.java
├── controller/AuthController.java, OrderController.java
├── dto/LoginRequest.java, RegisterRequest.java
├── entity/AppUser.java
├── repository/UserRepository.java
├── security/CustomUserDetailsService.java
└── service/AuthService.java
Bước 1: Dependency
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</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-data-jpa</artifactId></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
Bước 2: Config
spring.datasource.url=jdbc:postgresql://localhost:5432/shop
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=update # chỉ demo, production dùng Flyway/Liquibase
logging.level.org.springframework.security=DEBUG
Bước 3: Code Java. Entity và Repository:
@Entity
@Table(name = "app_users")
public class AppUser {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password; // BCrypt hash, không bao giờ plaintext
@Column(nullable = false)
private String role = "USER";
// getters/setters
}
public interface UserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByUsername(String username);
boolean existsByUsername(String username);
}
CustomUserDetailsService, chỗ nối database với Spring Security:
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) {
AppUser user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return User.builder()
.username(user.getUsername())
.password(user.getPassword())
.roles(user.getRole())
.build();
}
}
AppUser: entity của ứng dụng.UserDetails: interface Spring Security cần.User: implementation có sẵn củaUserDetails.
SecurityConfig:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // demo API stateless, không dùng cookie
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
Có UserDetailsService bean và PasswordEncoder bean, Spring Boot tự dựng DaoAuthenticationProvider bên trong ProviderManager.
AuthService:
@Service
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
// constructor injection ...
@Transactional
public void register(RegisterRequest request) {
if (userRepository.existsByUsername(request.username())) {
throw new IllegalArgumentException("Username already exists");
}
AppUser user = new AppUser();
user.setUsername(request.username());
user.setPassword(passwordEncoder.encode(request.password()));
user.setRole("USER");
userRepository.save(user);
}
public void login(LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password()));
}
}
public record RegisterRequest(String username, String password) {}
public record LoginRequest(String username, String password) {}
Controller đọc user hiện tại:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping
public String getOrders(Authentication authentication) {
return "Orders for " + authentication.getName();
}
}
Bước 4: Test bằng curl
curl -X POST localhost:8080/api/auth/register -H "Content-Type: application/json" \
-d '{"username":"john","password":"<password>"}'
# Registered successfully
curl -X POST localhost:8080/api/auth/login -H "Content-Type: application/json" \
-d '{"username":"john","password":"<password>"}'
# Credentials verified
curl -u john:<password> localhost:8080/api/orders
# Orders for john
5. Cách hoạt động
AuthService.login()
|
AuthenticationManager.authenticate()
|
ProviderManager
|
DaoAuthenticationProvider
+----> CustomUserDetailsService → UserRepository → Database
+----> BCryptPasswordEncoder.matches(raw, hash)
|
Authenticated Authentication (principal, authorities, credentials đã xóa)
|
Return to AuthService
Sai password → BadCredentialsException. Đúng → Authentication có isAuthenticated() == true.
BCrypt:
String hash1 = encoder.encode("secret");
String hash2 = encoder.encode("secret"); // khác hash1 vì salt ngẫu nhiên
encoder.matches("secret", hash1); // true
encoder.matches("secret", hash2); // true
encoder.matches("wrong", hash1); // false
BCrypt là hash một chiều có salt và work factor, không giải mã được. Spring không "decrypt" password, chỉ hash lại rồi so.
SecurityContextHolder và ThreadLocal. Mặc định (MODE_THREADLOCAL) mỗi thread giữ một SecurityContext:
Tomcat Thread-1 → SecurityContext → Authentication: admin
Tomcat Thread-2 → SecurityContext → Authentication: john
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Nhờ vậy Service lấy được user hiện tại mà Controller không phải truyền xuống.
6. So sánh
| AuthenticationManager | AuthenticationProvider | UserDetailsService | |
|---|---|---|---|
| Vai trò | Điều phối | Xác thực một kiểu | Load user |
| Kiểm password? | Không trực tiếp | Có (DAO dùng PasswordEncoder) | Không |
| Số lượng | Thường 1 | Nhiều | Thường 1 |
7. Khi nào nên dùng / không nên dùng
- Chỉ cần user trong DB: implement
UserDetailsServicelà đủ, không cần custom provider. - Xác thực kiểu lạ (OTP, API key, hệ thống ngoài): viết
AuthenticationProviderriêng. InMemoryUserDetailsManagerchỉ cho demo/test.- Không dùng MD5/SHA-256 trực tiếp cho password; dùng BCrypt/Argon2 (hoặc
DelegatingPasswordEncoder).
Vậy gọi login thành công xong, request sau gọi /api/orders không gửi header thì có được không?
Không, sẽ nhận 401 Unauthorized. Đây là điểm rất dễ nhầm:
login()ở trên chỉ kiểm tra credentials. Nó không tạo session, không phát token.- Gọi
authenticationManager.authenticate()từ Service không tự lưu kết quả vàoSecurityContextHolder, càng không lưu sang request sau. - Ta đang dùng HTTP Basic +
STATELESS, nên mỗi request phải tự gửi credentials.
Muốn "nhớ" đăng nhập có hai đường: session (bài 4.4.4) hoặc token/JWT (bài 4.4.5).
Trong method @Async tôi lấy SecurityContextHolder thì bị null, sao vậy?
Đúng như bạn thấy, vì @Async chạy trên thread khác, mà SecurityContext nằm trong ThreadLocal của request thread. Thread mới không tự kế thừa.
@Async
public void processOrder() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); // null
}
Cách xử lý: bọc executor để truyền context:
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor delegate = new ThreadPoolTaskExecutor();
delegate.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
}
Hoặc đơn giản hơn: truyền userId vào method async như tham số thường. Không nên dùng MODE_INHERITABLETHREADLOCAL với thread pool, vì thread được tái sử dụng có thể mang context của user cũ.
UserDetailsService có kiểm tra password không?
Không. Nó chỉ load user (kèm hash password và quyền). Việc so password do DaoAuthenticationProvider làm bằng PasswordEncoder.matches(raw, hash). Provider còn kiểm tra tài khoản bị khóa/hết hạn (isAccountNonLocked, isEnabled...).
Một chi tiết bảo mật: mặc định Spring Security che UsernameNotFoundException thành BadCredentialsException, để kẻ tấn công không phân biệt được "sai username" hay "sai password".
Phỏng vấn hỏi authentication flow trong Spring Security thì trả lời sao?
Q1. How does Spring Security authenticate a user?
🇻🇳 Tiếng Việt:
Một filter (ví dụ
BasicAuthenticationFilter) trích credentials thànhAuthenticationchưa xác thực, gọiAuthenticationManager.ProviderManagerủy quyền cho provider hỗ trợ, ví dụDaoAuthenticationProvider, provider này dùngUserDetailsServiceload user vàPasswordEncoderso password. Thành công thì filter lưu kết quả vàoSecurityContextHolder.
🇬🇧 English:
A filter such as
BasicAuthenticationFilterextracts the credentials into an unauthenticatedAuthenticationand calls theAuthenticationManager. TheProviderManagerdelegates to a provider that supports it, for exampleDaoAuthenticationProvider, which loads the user throughUserDetailsServiceand checks the password with aPasswordEncoder. On success, the filter stores the result in theSecurityContextHolder.
Q2. AuthenticationManager khác AuthenticationProvider thế nào?
🇻🇳 Tiếng Việt:
AuthenticationManagerđiều phối quá trình xác thực.AuthenticationProviderthực hiện xác thực cho một loạiAuthenticationmà nó hỗ trợ.ProviderManagergiữ nhiều provider.
🇬🇧 English:
AuthenticationManagercoordinates the authentication process, while anAuthenticationProviderperforms authentication for a supported authentication type.ProviderManagerholds a list of providers.
Q3. Spring Security lưu user đã đăng nhập ở đâu? Có chia sẻ giữa các thread không?
🇻🇳 Tiếng Việt:
Trong
SecurityContext, truy cập quaSecurityContextHolder. Mặc định gắn với thread hiện tại bằngThreadLocal, nên mỗi thread có context riêng; task async phải truyền context tường minh.
🇬🇧 English:
In a
SecurityContext, accessed throughSecurityContextHolder. By default it is bound to the current thread withThreadLocal, so each thread has its own context, and async tasks need explicit context propagation.
Q4. Why BCrypt instead of MD5 or SHA-256 for passwords?
🇻🇳 Tiếng Việt:
BCrypt được thiết kế cho password: có salt ngẫu nhiên và work factor cấu hình được, cố tình chậm, làm brute-force tốn kém. MD5/SHA-256 quá nhanh nên dễ bị dò.
🇬🇧 English:
BCrypt is designed for password hashing. It uses a random salt and a configurable work factor, so brute-force attacks are much more expensive than with fast general-purpose hashes like MD5 or SHA-256.
Cách nhớ ngắn gọn
- Filter → Manager → Provider → (UserDetailsService + PasswordEncoder) → SecurityContextHolder.
- UserDetailsService chỉ load, Provider mới kiểm.
- SecurityContextHolder = ThreadLocal; @Async phải truyền context.
- Password: BCrypt, không bao giờ plaintext hay MD5.
Tự kiểm tra
Service muốn biết user hiện tại mà Controller không truyền xuống thì làm sao?
Lấy Authentication từ SecurityContextHolder.getContext() trong request thread hiện tại.
Hai lần encode cùng một password bằng BCrypt cho cùng hash không?
Không, salt ngẫu nhiên nên hash khác nhau; matches() vẫn trả true cho cả hai.
ProviderManager chọn provider nào để chạy?
Provider có supports() trả true cho class của Authentication được gửi vào.
Sai username, Spring trả lỗi gì cho client và vì sao?
Mặc định cũng là bad credentials (401), để không tiết lộ username có tồn tại hay không.
Login API gọi authenticate() thành công. Request sau không gửi gì thì sao?
401, vì cấu hình stateless và login chưa tạo session hay token.