Form login, HTTP Basic & session fixation — cơ chế xác thực truyền thống
UsernamePasswordAuthenticationFilter nhận POST /login, DaoAuthenticationProvider verify BCrypt, session fixation protection đổi session ID sau login. Khi nào chọn Form Login, HTTP Basic hay JWT.
TL;DR: UsernamePasswordAuthenticationFilter nhận POST /login, build UsernamePasswordAuthenticationToken chưa xác thực rồi đẩy tới AuthenticationManager → DaoAuthenticationProvider → UserDetailsService.loadUserByUsername() → BCryptPasswordEncoder.matches(). Thành công → tạo session, ghi SecurityContext, redirect. HTTP Basic gửi Authorization: Basic base64(user:pass) trên mỗi request — base64 là encoding không phải encryption, bắt buộc HTTPS. Session fixation protection mặc định Spring Security là migrateSession: đổi session ID ngay sau login thành công để vô hiệu hoá session ID attacker đã cài trước. Bài này bóc đúng ba concept đó, nối tiếp UserDetailsService & BCrypt và dẫn vào JWT structure & validation.
1. Form login — luồng từ POST /login tới session cookie
Khi user submit form đăng nhập, Spring Security xử lý toàn bộ ở tầng filter — controller của bạn không tham gia. Chuỗi gọi trải qua sáu bước:
sequenceDiagram
participant B as Browser
participant F as UsernamePasswordAuthenticationFilter
participant AM as AuthenticationManager
participant P as DaoAuthenticationProvider
participant UDS as UserDetailsService
participant DB as Database
B->>F: POST /login (username, password)
F->>F: build UsernamePasswordAuthenticationToken (unauthenticated)
F->>AM: authenticate(token)
AM->>P: try authenticate
P->>UDS: loadUserByUsername(username)
UDS->>DB: SELECT user WHERE email = ?
DB-->>UDS: row
UDS-->>P: UserDetails
P->>P: passwordEncoder.matches(rawInput, storedHash)
P-->>AM: Authentication (authenticated)
AM-->>F: success
F->>B: 302 redirect + Set-Cookie: JSESSIONID=newIdĐiểm cốt lõi cần hiểu — và đây cũng là gốc của nhiều bug:
UsernamePasswordAuthenticationFilterso khớp URLPOST /loginvà chỉ URL đó. Nếu bạn cấu hìnhloginProcessingUrl("/api/auth/login"), filter chỉ kích hoạt với đúng path đó.UsernamePasswordAuthenticationTokenđược tạo vớiauthenticated = false. Sau khiDaoAuthenticationProviderverify thành công, provider tạo ra token mới vớiauthenticated = truevà danh sách authorities. Hai token riêng biệt — không phải cùng object được mutate.DaoAuthenticationProviderphụ thuộc vàoUserDetailsServicevàPasswordEncoder— cả hai cần có bean trong context. Spring Boot autoconfig wire chúng nếu bạn khai báo đúng (xem UserDetailsService & BCrypt cho chi tiết implement).
Cấu hình form login
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/register", "/css/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login") // GET -> custom login page
.loginProcessingUrl("/api/auth/login") // POST -> Spring processes here
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/login?error")
.usernameParameter("email") // match HTML input name
.passwordParameter("password")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/api/auth/logout")
.logoutSuccessUrl("/login?logout")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
);
return http.build();
}
}
loginPage("/login") nói Spring "tôi có trang login riêng". Nếu bỏ dòng này, Spring Security tự sinh trang login HTML mặc định — chỉ hợp cho development.
2. Cơ chế bên dưới — UsernamePasswordAuthenticationFilter làm gì
UsernamePasswordAuthenticationFilter kế thừa AbstractAuthenticationProcessingFilter. Luồng thực tế trong source:
AbstractAuthenticationProcessingFilter.doFilter()
└─ requiresAuthentication(request)? <- check URL match /login
└─ attemptAuthentication(request) <- extract username/password từ form
└─ authenticationManager.authenticate(token)
└─ DaoAuthenticationProvider.authenticate(token)
├─ userDetailsService.loadUserByUsername(username)
├─ additionalAuthChecks(userDetails, token)
│ └─ passwordEncoder.matches(raw, storedHash)
└─ createSuccessAuthentication(principal, token, userDetails)
Sau khi attemptAuthentication trả về Authentication thành công:
successfulAuthentication()được gọi.SecurityContextđược populate với authentication object.SecurityContextRepository(mặc định:HttpSessionSecurityContextRepository) lưu context vào HTTP session.AuthenticationSuccessHandlerthực thi — mặc định làSavedRequestAwareAuthenticationSuccessHandler, redirect tới URL gốc user muốn truy cập (nếu có) hoặcdefaultSuccessUrl.
Ngược lại nếu fail:
unsuccessfulAuthentication()được gọi.SecurityContextbị clear.AuthenticationFailureHandlerthực thi — redirectfailureUrl.
3. HTTP Basic Authentication — base64 không phải encryption
HTTP Basic (RFC 7617) là cơ chế xác thực đơn giản nhất: client gửi credential dưới dạng Authorization: Basic <base64(username:password)> trên mỗi request.
Authorization: Basic YWRtaW46YWRtaW4xMjM=
^--- base64("admin:admin123")
Base64 là encoding hai chiều — ai cũng decode được. Đây không phải mã hoá, không phải hash. Tuyệt đối phải đi qua HTTPS; trên HTTP thuần, credential phơi hoàn toàn.
Kích hoạt trong Spring Security:
http.httpBasic(Customizer.withDefaults());
Khi browser nhận WWW-Authenticate: Basic response, nó hiện popup hỏi credential. Credential được browser cache và gửi lại tự động mọi request sau — không có "logout" thật sự cho đến khi đóng tab hoặc xoá cache.
Vô hiệu hoá khi app chỉ dùng JWT (tránh Browser popup xuất hiện không mong muốn):
http.httpBasic(AbstractHttpConfigurer::disable);
Khi nào dùng HTTP Basic
HTTP Basic phù hợp trong ba tình huống cụ thể:
- Admin endpoint nội bộ: dashboard monitoring, actuator — truy cập từ mạng nội bộ, không cần UI login phức tạp.
- Server-to-server API đơn giản: service A gọi service B, không có user browser. Basic với credential từ Vault/secret manager là đủ nếu cả hai trong cùng VPN.
- Health check / probe:
curl -u probe:secret https://internal/health— đơn giản, không cần token management.
Drawback chính: credential đi kèm mọi request. Mỗi request đều là cơ hội leak (log, proxy, intermediary). JWT chỉ gửi credential một lần lúc đăng nhập, sau đó dùng token ngắn hạn.
4. Brute-force throttle — giới hạn số lần đăng nhập sai
Spring Security mặc định không có rate limiting cho form login — bạn phải implement. Cơ chế chuẩn là lắng nghe Spring Security event:
@Component
@RequiredArgsConstructor
public class LoginAttemptListener {
private final LoginAttemptService attemptService;
@EventListener
public void onFailure(AuthenticationFailureBadCredentialsEvent event) {
String username = event.getAuthentication().getName();
attemptService.recordFailure(username);
}
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
String username = event.getAuthentication().getName();
attemptService.clearFailures(username);
}
}
@Service
@RequiredArgsConstructor
public class LoginAttemptService {
// Production: thay bang Redis distributed counter
private final Map<String, Integer> failureCount = new ConcurrentHashMap<>();
private static final int MAX_FAILURES = 5;
public void recordFailure(String username) {
int count = failureCount.merge(username, 1, Integer::sum);
if (count >= MAX_FAILURES) {
userRepository.lockAccount(username);
log.warn("Account locked after {} failures: {}", MAX_FAILURES, username);
}
}
public void clearFailures(String username) {
failureCount.remove(username);
}
}
ConcurrentHashMap trong ví dụ trên chỉ phù hợp single-node. Khi deploy nhiều pod, dùng Redis với TTL để counter tự hết hạn:
// Redis-based: 5 failures trong 15 phut -> lock
redisTemplate.opsForValue()
.set("login_fail:" + username, count, Duration.ofMinutes(15));
Account bị lock được reflect qua UserDetails.isAccountNonLocked() = false: DaoAuthenticationProvider ném LockedException và response là HTTP 401. Xem UserDetailsService & BCrypt cho chi tiết implement UserDetails 4 state.
5. Session fixation — tại sao nguy hiểm và cách Spring bảo vệ
Tấn công session fixation diễn ra thế nào
Session fixation là cuộc tấn công attacker cài session ID trước khi victim đăng nhập, rồi dùng session ID đó để mạo danh victim sau khi login thành công. Chuỗi tấn công cụ thể:
1. Attacker gửi request tới https://app.com -> nhận JSESSIONID=ABC123
2. Attacker gửi link cho victim: https://app.com/login?jsessionid=ABC123
(hoặc dùng XSS inject cookie)
3. Victim click link, đăng nhập thành công
-> Session ABC123 giờ được gắn với tài khoản victim
4. Attacker dùng cookie JSESSIONID=ABC123 -> truy cập như victim
Nguy hiểm vì attacker không cần biết password. Chỉ cần victim dùng session ID mà attacker đã biết.
Tại sao đổi session ID sau login là đủ
Giải pháp đơn giản nhưng hiệu quả: vô hiệu hoá session ID cũ ngay khi login thành công, tạo session ID mới. Attacker đang giữ ABC123 — nhưng sau khi victim login, ABC123 không còn giá trị. Session mới XYZ789 chỉ có victim biết (nằm trong response cookie của browser victim).
Spring Security thực hiện điều này tự động với chiến lược migrateSession (mặc định):
http.sessionManagement(session -> session
.sessionFixation().migrateSession() // DEFAULT: copy attributes, new session ID
// .sessionFixation().newSession() // create fresh session, no attribute copy
// .sessionFixation().none() // KEEP same ID — DO NOT USE in production
);
Ba chiến lược:
| Chiến lược | Hành vi | Khi nào dùng |
|---|---|---|
migrateSession() | Copy attributes sang session mới, đổi ID | Mặc định — web app có pre-login state (cart, language...) |
newSession() | Tạo session trắng hoàn toàn, đổi ID | App không cần giữ state trước login |
none() | Giữ nguyên session ID | Không dùng production — để ngỏ session fixation |
REST API stateless không bị ảnh hưởng
App REST thuần với JWT không dùng HTTP session — mỗi request tự carry token, không có session ID để fix. Với stateless API, nên tắt session management hoàn toàn:
http.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
6. Pitfall thường gặp
Pitfall 1 — double-encode password trong UserDetailsService:
// SAI: re-encode hash da co san
return User.withUsername(user.getEmail())
.password(encoder.encode(user.getPassword())) // user.getPassword() la BCrypt hash roi!
.build();
// DUNG: tra nguyen hash, de provider tu compare
return User.withUsername(user.getEmail())
.password(user.getPassword()) // raw BCrypt hash tu DB
.build();
DaoAuthenticationProvider gọi encoder.matches(rawInput, storedHash) nội bộ. UserDetailsService chỉ cung cấp hash đã lưu, không re-encode.
Pitfall 2 — loginProcessingUrl không khớp với action form HTML:
<!-- SAI: form action không khớp loginProcessingUrl -->
<form action="/login" method="post">
<!-- DUNG: phải khớp chính xác với config -->
<form action="/api/auth/login" method="post">
Filter chỉ kích hoạt khi URL match chính xác. Không match → request qua filter mà không được xử lý → redirect login lặp vô tận.
Pitfall 3 — HTTP Basic bật mặc định làm lộ popup browser:
Spring Boot auto-enable httpBasic khi không có cấu hình explicit. App dùng JWT sẽ thấy browser popup credential khi token expire — trải nghiệm xấu.
// Tắt Basic nếu chỉ dùng JWT
http.httpBasic(AbstractHttpConfigurer::disable);
Pitfall 4 — session fixation none() trong web app có form login:
// NGUY HIEM: app co form login ma tat session fixation protection
http.sessionManagement(s -> s.sessionFixation().none());
Chỉ none() khi app hoàn toàn stateless (SessionCreationPolicy.STATELESS) — khi đó không có session để fix. Web app có form login mà dùng none() là mở lỗ hổng bảo mật.
7. Form Login vs HTTP Basic vs JWT — khi nào chọn gì
Ba cơ chế xác thực phổ biến phục vụ ba use case khác nhau. Hiểu tradeoff để chọn đúng:
| Khía cạnh | Form Login | HTTP Basic | JWT (Bearer) |
|---|---|---|---|
| Session | Stateful (HTTP session) | Per-request credential | Stateless (token) |
| Logout | Dễ (invalidate session) | Khó (browser cache) | Khó (token còn hạn) |
| Mobile app | Không phù hợp (cookie) | Được, nhưng rủi ro | Tốt nhất |
| SPA frontend | Cần CSRF handling | Được | Phổ biến nhất |
| Server-to-server | Không phù hợp | Được (internal) | Tốt nhất |
| Scale ngang | Cần session store (Redis) | Không cần state | Không cần state |
| Revoke ngay | Dễ (xoá session) | Không apply | Khó (phải blacklist) |
Quy tắc chọn:
- Form Login: web app server-side render (Thymeleaf, JSP) — user browser trực tiếp.
- HTTP Basic: admin endpoint nội bộ, health probe, server-to-server trong private network.
- JWT: public REST API, SPA, mobile app, microservice — nơi stateless và scale là ưu tiên.
App production thường kết hợp: JWT cho /api/** và HTTP Basic cho /admin/** với @Order khác nhau trên hai SecurityFilterChain.
Bài JWT structure & validation đào sâu vào cơ chế JWT — signing HS256 vs RS256, claims, và cách Spring Security validate token.
Liên hệ các bài khác
- UserDetailsService & BCrypt — bài này dùng
UserDetailsServicevàBCryptPasswordEncodernhư building block. Đọc trước để hiểuloadUserByUsername()trả gì và tại sao không re-encode hash. - JWT structure & validation — bước tiếp theo sau khi hiểu form login/HTTP Basic: tại sao JWT được ưa chuộng hơn session-based cho REST API, cấu trúc token base64url, và Spring Security validate signature như thế nào.
Tóm tắt
- Form login flow:
UsernamePasswordAuthenticationFilter→DaoAuthenticationProvider→UserDetailsService→BCryptPasswordEncoder.matches()→ session cookie. Controller không tham gia. - HTTP Basic:
Authorization: Basic base64(user:pass)trên mỗi request — encoding, không phải encryption, bắt buộc HTTPS. Không có logout thật sự. - Brute-force throttle: lắng nghe
AuthenticationFailureBadCredentialsEvent→ đếm failure → lock account sau N lần. Production dùng Redis distributed counter. - Session fixation: attacker cài session ID trước → victim login → attacker dùng session ID đó. Giải pháp: đổi session ID ngay sau login. Spring mặc định
migrateSession(). - Chọn cơ chế: Form Login cho web SSR, HTTP Basic cho internal/probe, JWT cho REST API/SPA/mobile.
Tự kiểm tra
Q1Sau khi UsernamePasswordAuthenticationFilter gọi authenticationManager.authenticate(token) thành công, Spring Security làm gì với Authentication object trả về? Trả lời theo cơ chế session.▸
UsernamePasswordAuthenticationFilter gọi authenticationManager.authenticate(token) thành công, Spring Security làm gì với Authentication object trả về? Trả lời theo cơ chế session.successfulAuthentication() được gọi trong AbstractAuthenticationProcessingFilter. Nó thực hiện ba việc theo thứ tự:
- Ghi vào
SecurityContext:SecurityContextHolder.getContext().setAuthentication(authentication). - Persist context vào session:
HttpSessionSecurityContextRepositorylưuSecurityContextvàoHttpSessionvới keySPRING_SECURITY_CONTEXT. - Gọi
AuthenticationSuccessHandler: mặc định làSavedRequestAwareAuthenticationSuccessHandler, redirect tới URL user định vào (nếu có) hoặcdefaultSuccessUrl.
Từ request kế tiếp, SecurityContextHolderFilter đọc session, restore SecurityContext — user không cần login lại. Đây là cơ chế "stateful" của form login: identity được giữ qua session ID trong cookie.
Q2Giải thích tại sao HTTP Basic với Authorization: Basic YWRtaW46c2VjcmV0 **không an toàn** trên HTTP thuần. Base64 khác với mã hoá như thế nào?▸
Authorization: Basic YWRtaW46c2VjcmV0 **không an toàn** trên HTTP thuần. Base64 khác với mã hoá như thế nào?Base64 là encoding (biến đổi dạng biểu diễn) không phải encryption (ẩn nội dung). Bất kỳ ai cũng decode được trong một dòng lệnh:
echo "YWRtaW46c2VjcmV0" | base64 -d
# admin:secretTrên HTTP thuần, mọi packet đi qua network có thể bị bắt bởi: proxy trung gian, ISP, attacker trên cùng WiFi (cafe, hotel), thiết bị network giữa client và server. Bắt được packet là đọc được credential ngay.
HTTPS (TLS) mã hoá toàn bộ payload HTTP — kể cả header Authorization. Attacker bắt được packet TLS chỉ thấy bytes ngẫu nhiên, không thể decode credential. Đó là lý do HTTP Basic chỉ chấp nhận được khi đi qua HTTPS.
Q3Session fixation attack diễn ra theo chuỗi nào? Tại sao chiến lược migrateSession() của Spring vô hiệu hoá được tấn công này?▸
migrateSession() của Spring vô hiệu hoá được tấn công này?Chuỗi tấn công: (1) Attacker lấy session ID hợp lệ chưa xác thực từ server (gửi GET request bình thường). (2) Attacker dụ victim dùng session ID đó — qua URL parameter hoặc XSS set cookie. (3) Victim đăng nhập thành công — server gắn tài khoản victim vào session ID đó. (4) Attacker dùng session ID đã biết, giờ mang quyền của victim.
Tại sao migrateSession() chặn được: ngay khi login thành công, Spring Security tạo session ID mới và copy attributes sang. Session ID cũ (attacker biết) bị invalidate. Attacker giữ session ID cũ → server reject (không tìm thấy session). Session ID mới chỉ có browser của victim biết qua Set-Cookie response.
Điều kiện đủ duy nhất: session ID mới phải được tạo trước khi bất kỳ dữ liệu nhạy cảm nào được ghi vào session — Spring Security đảm bảo điều này ngay trong luồng successfulAuthentication().
Q4App dùng form login. Sau khi deploy thêm 2 pod, user báo bị logout ngẫu nhiên. Nguyên nhân gì? Fix như thế nào?▸
Nguyên nhân: session được lưu in-memory trên từng pod. Load balancer route request user sang pod khác — pod đó không có session → Spring Security redirect login. Đây là vấn đề "sticky session" (session affinity) hoặc thiếu session store chia sẻ.
Hai hướng fix:
- Session store dùng chung (Redis): Spring Session + Redis lưu
HttpSessionngoài JVM — mọi pod đọc/ghi cùng store:// pom.xml // spring-session-data-redis // application.yml spring.session.store-type: redis - Chuyển sang stateless JWT: không có session → không có vấn đề. Token được validate trên mọi pod độc lập. Đây là hướng được ưa chuộng hơn cho REST API. Xem JWT structure & validation cho implementation.
Sticky session (load balancer luôn route cùng user về cùng pod) là giải pháp tạm — vẫn có vấn đề khi pod restart hoặc scale down.
Q5Viết đúng cấu hình SecurityFilterChain cho app có hai vùng: /api/** dùng JWT stateless và /admin/** dùng HTTP Basic. Điểm quan trọng khi dùng hai chain là gì?▸
SecurityFilterChain cho app có hai vùng: /api/** dùng JWT stateless và /admin/** dùng HTTP Basic. Điểm quan trọng khi dùng hai chain là gì?Dùng hai SecurityFilterChain bean với @Order và securityMatcher khác nhau:
@Bean
@Order(1)
public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/admin/**")
.httpBasic(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.anyRequest().hasRole("ADMIN")
);
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.sessionManagement(s -> s
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
);
return http.build();
}Ba điểm quan trọng:
@Order: chain có order nhỏ hơn được check trước. Chain/admin/**order 1 được eval trước. Nếu không đặt order, Spring throw exception vì ambiguous.securityMatcher: giới hạn phạm vi chain. Request/api/userskhông match chain 1 (/admin/**) → tới chain 2. Không cósecurityMatcher= match mọi request.- Chain stateless phải tắt session:
SessionCreationPolicy.STATELESSngăn Spring tạo hoặc dùngHttpSessioncho API chain — quan trọng để JWT không bị "stateful hoá" vô tình.
Bài tiếp theo: JWT structure & validation
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên