Session login: JSESSIONID, session fixation và scale session

5 câu hỏi~30 phútTừ khóa: formLogin, HttpSession, JSESSIONID, SecurityContextRepository, session fixation, CSRF, HttpOnly, SameSite, sticky session, Spring Session Redis

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:

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 đề

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 cookieBearer token (JWT)
StateServer giữClient giữ, server có thể không giữ
Browser tự gửi?CóKhông (JS tự gắn header)
CSRFCần bảo vệThường không áp dụng nếu chỉ dùng header
Logout / revokeDễ: xóa sessionKhó: token sống tới khi hết hạn
Scale ngangCần sticky session hoặc store chungDễ
Hợp vớiWeb app server-side, BFFREST API, mobile, microservices
Cách scale sessionƯuNhược
Sticky sessionKhông cần hạ tầng thêmInstance chết là mất session; phân tải lệch
Session replication (Tomcat cluster)Không cần RedisTốn mạng, khó scale nhiều node
Spring Session Redis/JDBCInstance stateless, deploy thoải máiThêm Redis, thêm 1 lookup/request

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

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.

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:

CSRFCORS
Là gìMột kiểu tấn côngMột cơ chế của browser
Khai thác/kiểm soátCredentials tự động gửi (cookie)JavaScript khác origin có được đọc response không
Phòng/cấu hìnhCSRF token, SameSiteAccess-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:

Đâ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 SecurityContext vào HttpSession và trả cookie JSESSIONID. Request sau, SecurityContextHolderFilter dùng SecurityContextRepository nạp context từ session vào SecurityContextHolder. Server giữ state, client chỉ giữ ID.

🇬🇧 English:

After a successful login, Spring stores the SecurityContext in the HttpSession and returns a JSESSIONID cookie. On later requests, SecurityContextHolderFilter uses the SecurityContextRepository to load the context from the session into the SecurityContextHolder. 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


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.