Session login: JSESSIONID, session fixation và scale session
Tiếp đi: đăng nhập bằng session.
Ở bài 4.4.2, login thành công nhưng request sau vẫn 401, vì server không nhớ gì. Có hai cách phổ biến để "nhớ" đăng nhập:
- Session-based: server giữ session, client gửi session cookie.
- Token-based: client giữ token, gửi lại mỗi request (bài sau).
Bài này học cách thứ nhất, cũng là mặc định của Spring Security cho web app.
1. Bài toán
shop-app có thêm trang quản trị /admin cho nhân viên dùng trên trình duyệt. Họ đăng nhập một lần rồi làm việc cả ngày, không thể nhập password ở mỗi click.
2. Vấn đề
- HTTP là stateless: mỗi request độc lập.
- HTTP Basic bắt browser gửi password ở mọi request, không có logout thật sự.
- Khi đã nhớ đăng nhập bằng cookie thì xuất hiện rủi ro mới: CSRF, session fixation, đánh cắp cookie.
- Chạy 3 instance sau load balancer: session nằm trong RAM của instance 1, request kế tiếp sang instance 2 là "mất đăng nhập".
3. Giải pháp: HttpSession + cookie JSESSIONID
Client Server
| POST /login (user, pass) |
|----------------------------->| xác thực → tạo HttpSession
| | lưu SecurityContext vào session
| Set-Cookie: JSESSIONID=abc |
|<-----------------------------|
| GET /admin/orders |
| Cookie: JSESSIONID=abc |
|----------------------------->| tìm session abc → nạp SecurityContext
| 200 OK |
|<-----------------------------|
Server giữ state (session store), client chỉ giữ một ID ngẫu nhiên vô nghĩa.
4. Code từng bước
Bước 1: Dependency. Đã có spring-boot-starter-security và spring-boot-starter-web.
Bước 2: Cấu hình cookie
server.servlet.session.timeout=30m
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=lax
Bước 3: SecurityFilterChain cho web
@Bean
@Order(2)
SecurityFilterChain webChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/css/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/admin", true))
.logout(logout -> logout
.logoutUrl("/logout")
.deleteCookies("JSESSIONID"))
.sessionManagement(session -> session
.sessionFixation(fixation -> fixation.changeSessionId())
.maximumSessions(1));
// CSRF để mặc định: BẬT, vì auth dựa vào cookie
return http.build();
}
Nếu tự viết REST login endpoint (JSON) thay vì formLogin, Spring Security 6 không tự lưu context vào session; phải lưu tường minh:
private final SecurityContextRepository contextRepository = new HttpSessionSecurityContextRepository();
@PostMapping("/api/session/login")
public void login(@RequestBody LoginRequest req, HttpServletRequest request, HttpServletResponse response) {
Authentication auth = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(req.username(), req.password()));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
request.changeSessionId(); // chống session fixation
contextRepository.saveContext(context, request, response);
}
Bước 4: Test bằng curl
# Lấy trang login + CSRF token, lưu cookie
curl -c jar.txt localhost:8080/login
# Đăng nhập (form + _csrf), cookie JSESSIONID mới được ghi vào jar
curl -b jar.txt -c jar.txt -X POST localhost:8080/login \
-d "username=admin&password=<password>&_csrf=<token-from-page>"
# Request sau chỉ cần cookie
curl -b jar.txt localhost:8080/admin/orders
# 200 OK
5. Cách hoạt động
Request + Cookie: JSESSIONID=abc
|
SecurityContextHolderFilter
| SecurityContextRepository.loadContext()
| → HttpSession "abc" → attribute SPRING_SECURITY_CONTEXT
| → SecurityContextHolder.setContext(...)
|
CsrfFilter (POST/PUT/DELETE phải có token khớp session)
|
AuthorizationFilter → Controller
|
FilterChainProxy dọn ThreadLocal sau request
Session fixation:
1. Attacker lấy một session id hợp lệ: JSESSIONID=evil
2. Lừa victim dùng id đó (link, subdomain cookie...)
3. Victim đăng nhập
4. Nếu server GIỮ NGUYÊN id → attacker dùng JSESSIONID=evil là thành victim
Spring: changeSessionId() sau login → id mới → "evil" vô dụng
CSRF: browser tự gắn cookie vào request tới shop.com, kể cả request khởi phát từ evil.com:
<form action="https://shop.com/admin/orders/100/cancel" method="POST"></form>
<script>document.forms[0].submit();</script>
Spring yêu cầu CSRF token (header X-CSRF-TOKEN hoặc field _csrf) mà trang evil không đọc được do Same-Origin Policy. Thiếu hoặc sai → 403.
Scale với Spring Session Redis:
Load Balancer
/ | \
app-1 app-2 app-3 (stateless, không giữ session trong RAM)
\ | /
Redis (session store)
key: spring:session:sessions:<id>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring.data.redis.host=${REDIS_HOST}
spring.data.redis.password=${REDIS_PASSWORD}
spring.session.timeout=30m
Spring Session thay HttpSession của Tomcat bằng một filter (SessionRepositoryFilter) đứng trước Spring Security. Code security không đổi gì, nhưng giờ instance nào cũng đọc được session.
6. So sánh
| Session cookie | Bearer token (JWT) | |
|---|---|---|
| State | Server giữ | Client giữ, server có thể không giữ |
| Browser tự gửi? | Có | Không (JS tự gắn header) |
| CSRF | Cần bảo vệ | Thường không áp dụng nếu chỉ dùng header |
| Logout / revoke | Dễ: xóa session | Khó: token sống tới khi hết hạn |
| Scale ngang | Cần sticky session hoặc store chung | Dễ |
| Hợp với | Web app server-side, BFF | REST API, mobile, microservices |
| Cách scale session | Ưu | Nhược |
|---|---|---|
| Sticky session | Không cần hạ tầng thêm | Instance chết là mất session; phân tải lệch |
| Session replication (Tomcat cluster) | Không cần Redis | Tốn mạng, khó scale nhiều node |
| Spring Session Redis/JDBC | Instance stateless, deploy thoải mái | Thêm Redis, thêm 1 lookup/request |
7. Khi nào nên dùng / không nên dùng
- Nên dùng session cho web app truyền thống, trang admin, hoặc BFF đứng trước SPA.
- Cần logout tức thì, khóa tài khoản ngay: session dễ hơn JWT.
- Không nên cho mobile app hoặc API service-to-service.
- Dùng session thì không tắt CSRF, cookie phải
HttpOnly,Secure,SameSite.
Vậy REST API của tôi dùng JWT thì tắt CSRF vì đang dùng JWT đúng không?
Câu trả lời "vì dùng JWT" chưa đủ chính xác. Yếu tố quyết định không phải token có phải JWT hay không, mà là browser có tự động gửi credentials hay không.
- JWT gửi qua header
Authorization: Bearerdo JavaScript gắn: trang evil không gắn được → CSRF truyền thống không áp dụng → tắt được. - JWT để trong cookie: browser tự gửi → vẫn bị CSRF → phải giữ CSRF protection hoặc SameSite + kiểm Origin.
We can disable CSRF for a stateless REST API when authentication relies exclusively on bearer tokens explicitly sent in the Authorization header, rather than on credentials the browser attaches automatically.
CORS với CSRF khác nhau sao? Tôi hay lẫn.
Bạn lẫn là bình thường vì cả hai đều nói về "cross". Chỉnh lại thế này:
| CSRF | CORS | |
|---|---|---|
| Là gì | Một kiểu tấn công | Một cơ chế của browser |
| Khai thác/kiểm soát | Credentials tự động gửi (cookie) | JavaScript khác origin có được đọc response không |
| Phòng/cấu hình | CSRF token, SameSite | Access-Control-Allow-Origin... |
CORS không phải authentication hay authorization, và không chống CSRF: một form POST cross-site vẫn được gửi đi, CORS chỉ chặn JS đọc kết quả. Thêm nữa, SameSite dựa trên site, CORS dựa trên origin, hai khái niệm không giống hệt nhau.
Dùng Redis cho session thì Redis chết là cả hệ thống logout hết à?
Đúng, Redis trở thành dependency quan trọng: mất Redis thì không đọc được session, user bị coi như chưa đăng nhập. Cách giảm rủi ro:
- Redis có replica + Sentinel, hoặc dịch vụ managed (Azure Cache for Redis, ElastiCache) với failover.
- Đặt timeout kết nối ngắn để app không treo theo Redis.
- Không nhét object to vào session; session chỉ nên chứa SecurityContext và vài thứ nhỏ, serialize được.
Đây cũng là một lý do nhiều hệ thống chuyển API sang token: đổi gánh nặng "store chung" lấy gánh nặng "revoke token" (bài sau).
Phỏng vấn hỏi session-based authentication thì trả lời sao?
Q1. Session-based authentication hoạt động thế nào trong Spring Security?
🇻🇳 Tiếng Việt:
Sau khi login thành công, Spring lưu
SecurityContextvàoHttpSessionvà trả cookieJSESSIONID. Request sau,SecurityContextHolderFilterdùngSecurityContextRepositorynạp context từ session vàoSecurityContextHolder. Server giữ state, client chỉ giữ ID.
🇬🇧 English:
After a successful login, Spring stores the
SecurityContextin theHttpSessionand returns aJSESSIONIDcookie. On later requests,SecurityContextHolderFilteruses theSecurityContextRepositoryto load the context from the session into theSecurityContextHolder. The server keeps the state; the client only holds an ID.
Q2. What is session fixation and how does Spring prevent it?
🇻🇳 Tiếng Việt:
Attacker ép victim dùng một session ID attacker đã biết; nếu ID không đổi sau login, attacker chiếm được phiên đã xác thực. Spring mặc định đổi session ID sau khi xác thực thành công (
changeSessionId).
🇬🇧 English:
The attacker makes the victim use a session ID the attacker already knows; if the ID is kept after login, the attacker gets an authenticated session. Spring Security changes the session ID after successful authentication by default, using
changeSessionId.
Q3. Scale session-based app ra nhiều instance thế nào?
🇻🇳 Tiếng Việt:
Có sticky session, nhưng instance chết là mất session. Cách tốt hơn là Spring Session với Redis hoặc JDBC: session nằm ở store chung, mọi instance stateless. Đổi lại phải vận hành Redis có HA và chịu thêm một lookup mỗi request.
🇬🇧 English:
Sticky sessions work, but sessions are lost when an instance dies. A better option is Spring Session with Redis or JDBC, so sessions live in a shared store and every instance is stateless. The trade-off is running a highly available Redis and one extra lookup per request.
Q4. Khi nào cần CSRF protection?
🇻🇳 Tiếng Việt:
Khi browser tự gửi credentials, điển hình là session cookie, kể cả JWT để trong cookie. API chỉ nhận Bearer token trong header thì thường tắt được.
🇬🇧 English:
Whenever the browser sends credentials automatically, typically session cookies, including a JWT stored in a cookie. An API that only accepts bearer tokens in the Authorization header can usually disable it.
Cách nhớ ngắn gọn
- Session = server nhớ, cookie
JSESSIONIDchỉ là chìa khóa. - Login xong đổi session ID (fixation); cookie thì CSRF bật.
- Cookie:
HttpOnly,Secure,SameSite. - Nhiều instance → Spring Session Redis.
Tự kiểm tra
Spring lưu SecurityContext vào session qua thành phần nào?
SecurityContextRepository, mặc định HttpSessionSecurityContextRepository, dưới key SPRING_SECURITY_CONTEXT.
Tự viết REST login bằng authenticationManager.authenticate(), request sau vẫn 401. Vì sao?
Spring Security 6 không tự lưu context; phải gọi securityContextRepository.saveContext(...).
Vì sao app 3 instance mất đăng nhập ngẫu nhiên?
Session nằm trong RAM từng instance, load balancer chuyển request sang instance khác. Dùng sticky session hoặc Spring Session Redis.
JWT để trong cookie, có tắt CSRF được không?
Không nên, vì browser tự gửi cookie nên vẫn bị CSRF.
HttpOnly chống được XSS không?
Không. Nó chỉ ngăn JS đọc cookie; script độc vẫn gửi request được trong ngữ cảnh user.