Authentication flow: username/password được kiểm tra thế nào?

5 câu hỏi~30 phútTừ khóa: AuthenticationManager, ProviderManager, AuthenticationProvider, DaoAuthenticationProvider, UserDetailsService, PasswordEncoder, BCrypt, SecurityContextHolder, ThreadLocal

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:

  1. Lấy username/password từ request.
  2. Tìm user trong hệ thống.
  3. Kiểm tra password.
  4. Nếu đúng, ghi nhận user đã đăng nhập.
  5. Kiểm tra quyền truy cập API.
  6. 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ầnNhiệm vụ
AuthenticationManagerInterface điều phối: authenticate(Authentication)
ProviderManagerImplementation phổ biến, giữ danh sách provider
AuthenticationProviderXác thực theo một kiểu cụ thể (DAO, LDAP, JWT...)
UserDetailsServiceChỉ load user theo username
PasswordEncoderHash và so khớp password
SecurityContextHolderGiữ 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();
    }
}

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

AuthenticationManagerAuthenticationProviderUserDetailsService
Vai tròĐiều phốiXác thực một kiểuLoad user
Kiểm password?Không trực tiếpCó (DAO dùng PasswordEncoder)Không
Số lượngThường 1NhiềuThường 1

7. Khi nào nên dùng / không nên dùng

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:

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ành Authentication chưa xác thực, gọi AuthenticationManager. ProviderManager ủy quyền cho provider hỗ trợ, ví dụ DaoAuthenticationProvider, provider này dùng UserDetailsService load user và PasswordEncoder so password. Thành công thì filter lưu kết quả vào SecurityContextHolder.

🇬🇧 English:

A filter such as BasicAuthenticationFilter extracts the credentials into an unauthenticated Authentication and calls the AuthenticationManager. The ProviderManager delegates to a provider that supports it, for example DaoAuthenticationProvider, which loads the user through UserDetailsService and checks the password with a PasswordEncoder. On success, the filter stores the result in the SecurityContextHolder.

Q2. AuthenticationManager khác AuthenticationProvider thế nào?

🇻🇳 Tiếng Việt:

AuthenticationManager điều phối quá trình xác thực. AuthenticationProvider thực hiện xác thực cho một loại Authentication mà nó hỗ trợ. ProviderManager giữ nhiều provider.

🇬🇧 English:

AuthenticationManager coordinates the authentication process, while an AuthenticationProvider performs authentication for a supported authentication type. ProviderManager holds 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 qua SecurityContextHolder. Mặc định gắn với thread hiện tại bằng ThreadLocal, nên mỗi thread có context riêng; task async phải truyền context tường minh.

🇬🇧 English:

In a SecurityContext, accessed through SecurityContextHolder. By default it is bound to the current thread with ThreadLocal, 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


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.