Giới thiệu và kiến trúc Spring Security
Xem thửMột nhóm làm API nội bộ và hoãn việc bảo mật "cho tới sau khi chạy được". Ba tháng sau họ triển khai lên một máy chủ có địa chỉ công khai để thử với đối tác. Hai ngày sau, một công cụ quét tự động tìm thấy endpoint /admin/export và tải xuống toàn bộ danh sách khách hàng, vì không có gì chặn nó. Sau sự cố, câu hỏi của cả nhóm là "bắt đầu bảo mật từ đâu?". Câu trả lời ngạc nhiên đơn giản: thêm một dependency, và ứng dụng đã được bảo vệ theo một mặc định hợp lý. Rồi nhiều việc khác thay đổi theo mà không ai nhắc: các client cũ bắt đầu nhận 401, các yêu cầu POST bị từ chối, trình duyệt bị chuyển tới một trang đăng nhập mà không ai viết.
Bài này mở hộp đó ra: bạn chỉ thêm spring-boot-starter-security, không viết một dòng cấu hình nào, và đo điều gì thực sự thay đổi. Spring Security hoạt động như một chuỗi bộ lọc đứng trước Spring MVC; hiểu chuỗi đó là hiểu mọi hành vi khó đoán của nó.
Sau bài này, bạn có thể
- Giải thích Spring Security là chuỗi bộ lọc và liệt kê các bộ lọc quan trọng theo đúng thứ tự.
- Dự đoán mã trạng thái cho từng loại client (chương trình, trình duyệt, người đã đăng nhập).
- Giải thích vì sao bảo mật chạy trước định tuyến, và hệ quả với đường dẫn không tồn tại.
- Hiểu CSRF mặc định, và vì sao cùng thiếu token mà có lúc 401 có lúc 403.
- Làm theo luồng đăng nhập bằng form và nói điều gì thay đổi về phiên.
Chuẩn bị môi trường và dự án
Mọi thí nghiệm của bài chạy trong một dự án Maven nhỏ. Mục này nói rõ cần cài gì, khai báo thư viện nào và cấu hình ra sao; mã của từng lớp được trình bày ngay tại chỗ nó được dùng, kèm giải thích, nên bạn không cần mở nơi nào khác. Số liệu và thông báo lỗi trong bài được đo từ chính mã đó (JDK 17, Spring Boot 4.1.1, Spring Security 7.1.1). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 11-spring-security/01-kien-truc-mac-dinh, chỉ để bạn clone cho nhanh nếu không muốn tự gõ lại.
Yêu cầu môi trường
| Thành phần | Phiên bản đã kiểm chứng | Ghi chú |
|---|---|---|
| JDK | 17 trở lên | java -version |
| Maven | 3.9 trở lên | mvn -v |
| Spring Boot | 4.1.1 | khai báo ở parent trong pom.xml; quản lý Spring Security 7.1.1 |
| Docker | không cần | bài này không dùng cơ sở dữ liệu |
Cấu trúc
01-kien-truc-mac-dinh/
pom.xml parent Boot 4.1.1, starter webmvc, security, test
src/main/java/dev/canhdominich/boot/security/
SecurityApplication.java @SpringBootApplication, không có cấu hình bảo mật nào
HelloController.java GET /hello trả tên người dùng, POST /orders
DefaultSecurityDemo.java khởi động ứng dụng, gửi yêu cầu thật và in kết quả
Khai báo thư viện: pom.xml
pom.xml
Dự án Maven dùng spring-boot-starter-parent 4.1.1 với spring-boot-starter-webmvc, spring-boot-starter-security (Spring Security 7.1.1: chuỗi bộ lọc, đăng nhập, CSRF) và spring-boot-starter-test. Không có cấu hình bảo mật nào trong mã: mọi hành vi trong bài là mặc định. Plugin exec-maven-plugin chạy DefaultSecurityDemo bằng mvn -q compile exec:exec.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>dev.canhdominich.javabackend</groupId>
<artifactId>security-default</artifactId>
<version>1.0.0</version>
<name>Spring Security - Kien truc va cau hinh mac dinh</name>
<properties>
<java.version>17</java.version>
<main.class>dev.canhdominich.boot.security.DefaultSecurityDemo</main.class>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<configuration>
<executable>java</executable>
<arguments>
<argument>-classpath</argument>
<classpath/>
<argument>${main.class}</argument>
</arguments>
</configuration>
</plugin>
</plugins>
</build>
</project>
Ứng dụng chỉ có một dependency bảo mật
Dự án chỉ khác một dòng so với một ứng dụng web Boot bình thường: pom.xml có thêm spring-boot-starter-security (kéo Spring Security 7.1.1 theo phiên bản do Boot quản lý). Lớp ứng dụng không có gì liên quan tới bảo mật:
src/main/java/dev/canhdominich/boot/security/SecurityApplication.java
package dev.canhdominich.boot.security;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SecurityApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityApplication.class, args);
}
}
HelloController có hai endpoint. GET /hello đọc tên người dùng hiện tại từ Principal (đối tượng đại diện cho người đang đăng nhập, do Spring Security đặt vào yêu cầu); POST /orders là một thao tác ghi để thử CSRF:
src/main/java/dev/canhdominich/boot/security/HelloController.java
package dev.canhdominich.boot.security;
import java.security.Principal;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
@GetMapping("/hello")
public Map<String, String> hello(Principal principal) {
return Map.of("message", "hello " + principal.getName());
}
@PostMapping("/orders")
public Map<String, String> create() {
return Map.of("created", "yes");
}
}
DefaultSecurityDemo khởi động ứng dụng ở cổng ngẫu nhiên, bắt dòng log khởi động để lấy mật khẩu sinh ngẫu nhiên, in danh sách bộ lọc, rồi gửi một loạt yêu cầu bằng HttpClient (không tự theo redirect, để thấy mã 302 và header Location):
src/main/java/dev/canhdominich/boot/security/DefaultSecurityDemo.java
package dev.canhdominich.boot.security;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.web.server.context.WebServerApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.security.web.FilterChainProxy;
public class DefaultSecurityDemo {
public static void main(String[] args) throws Exception {
run();
}
public static List<String> run() throws Exception {
List<String> lines = new ArrayList<>();
PrintStream original = System.out;
ByteArrayOutputStream captured = new ByteArrayOutputStream();
SpringApplication application = new SpringApplication(SecurityApplication.class);
application.setDefaultProperties(Map.of("server.port", "0", "spring.main.banner-mode", "off", "logging.level.root", "INFO"));
ConfigurableApplicationContext context;
System.setOut(new PrintStream(captured, true));
try {
context = application.run();
} finally {
System.setOut(original);
}
try (context) {
Matcher matcher = Pattern.compile("Using generated security password: (\\S+)").matcher(captured.toString());
String password = matcher.find() ? matcher.group(1) : "";
lines.add("generated password logged at startup: " + (!password.isEmpty()) + " (a random UUID, " + password.length() + " characters)");
lines.add("filters in the default chain: " + filterNames(context));
int port = ((WebServerApplicationContext) context).getWebServer().getPort();
String base = "http://127.0.0.1:" + port;
HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build();
String basic = "Basic " + Base64.getEncoder().encodeToString(("user:" + password).getBytes());
String wrong = "Basic " + Base64.getEncoder().encodeToString("user:wrong".getBytes());
lines.add("-- who gets in");
lines.add(send(http, "GET", base + "/hello", null, null));
lines.add(send(http, "GET", base + "/hello", null, "text/html"));
lines.add(send(http, "GET", base + "/hello", wrong, null));
lines.add(send(http, "GET", base + "/hello", basic, null));
lines.add(send(http, "GET", base + "/no-such-page", null, null));
lines.add(send(http, "GET", base + "/no-such-page", basic, null));
lines.add("-- headers added to an authenticated answer");
HttpResponse<String> ok = http.send(HttpRequest.newBuilder(URI.create(base + "/hello")).header("Authorization", basic).GET().build(), HttpResponse.BodyHandlers.ofString());
lines.add("response headers: " + new TreeSet<>(ok.headers().map().keySet().stream().filter(name -> !name.equalsIgnoreCase("date") && !name.equalsIgnoreCase("content-length") && !name.equalsIgnoreCase("keep-alive") && !name.equalsIgnoreCase("connection")).toList()));
lines.add("-- CSRF protection on POST");
lines.add(send(http, "POST", base + "/orders", basic, null));
lines.add("-- the default login page and form login");
HttpResponse<String> loginPage = http.send(HttpRequest.newBuilder(URI.create(base + "/login")).header("Accept", "text/html").GET().build(), HttpResponse.BodyHandlers.ofString());
String cookie = loginPage.headers().firstValue("Set-Cookie").orElse("");
Matcher token = Pattern.compile("name=\"_csrf\" type=\"hidden\" value=\"([^\"]+)\"").matcher(loginPage.body());
String csrf = token.find() ? token.group(1) : "";
lines.add("GET /login -> " + loginPage.statusCode() + ", form has username, password and _csrf fields: "
+ (loginPage.body().contains("name=\"username\"") && loginPage.body().contains("name=\"password\"") && !csrf.isEmpty())
+ ", session cookie attributes: " + cookieAttributes(cookie));
String sessionCookie = cookie.split(";")[0];
HttpResponse<String> badLogin = http.send(HttpRequest.newBuilder(URI.create(base + "/login"))
.header("Content-Type", "application/x-www-form-urlencoded").header("Cookie", sessionCookie)
.POST(HttpRequest.BodyPublishers.ofString("username=user&password=wrong&_csrf=" + csrf)).build(), HttpResponse.BodyHandlers.ofString());
lines.add("POST /login with a wrong password -> " + badLogin.statusCode() + ", Location " + path(badLogin.headers().firstValue("Location").orElse("")));
HttpResponse<String> login = http.send(HttpRequest.newBuilder(URI.create(base + "/login"))
.header("Content-Type", "application/x-www-form-urlencoded").header("Cookie", sessionCookie)
.POST(HttpRequest.BodyPublishers.ofString("username=user&password=" + password + "&_csrf=" + csrf)).build(), HttpResponse.BodyHandlers.ofString());
String newCookie = login.headers().firstValue("Set-Cookie").map(c -> c.split(";")[0]).orElse(sessionCookie);
lines.add("POST /login with the right password and the token -> " + login.statusCode() + ", Location " + path(login.headers().firstValue("Location").orElse("")) + ", session id changed: " + !newCookie.equals(sessionCookie));
HttpResponse<String> afterLogin = http.send(HttpRequest.newBuilder(URI.create(base + "/hello")).header("Cookie", newCookie).GET().build(), HttpResponse.BodyHandlers.ofString());
lines.add("GET /hello with only the session cookie -> " + afterLogin.statusCode() + " " + afterLogin.body());
HttpResponse<String> postWithSession = http.send(HttpRequest.newBuilder(URI.create(base + "/orders")).header("Cookie", newCookie).POST(HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString());
lines.add("POST /orders with the session cookie and no CSRF token -> " + postWithSession.statusCode());
}
lines.forEach(System.out::println);
return lines;
}
private static String filterNames(ConfigurableApplicationContext context) {
FilterChainProxy proxy = context.getBean(FilterChainProxy.class);
List<String> names = new ArrayList<>();
proxy.getFilterChains().get(0).getFilters().forEach(filter -> names.add(filter.getClass().getSimpleName()));
return names.toString();
}
private static String cookieAttributes(String cookie) {
List<String> attributes = new ArrayList<>();
for (String part : cookie.split(";")) {
String trimmed = part.trim();
if (!trimmed.startsWith("JSESSIONID")) {
attributes.add(trimmed);
}
}
return attributes.toString();
}
private static String path(String location) {
return location.isEmpty() ? "(none)" : URI.create(location).getPath() + (URI.create(location).getQuery() == null ? "" : "?" + URI.create(location).getQuery());
}
private static String send(HttpClient http, String method, String url, String authorization, String accept) throws Exception {
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url)).method(method, HttpRequest.BodyPublishers.noBody());
if (authorization != null) {
builder.header("Authorization", authorization);
}
if (accept != null) {
builder.header("Accept", accept);
}
HttpResponse<String> response = http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
StringBuilder out = new StringBuilder(method + " " + url.substring(url.indexOf("/", 8)) + (authorization == null ? " (no credentials)" : authorization.equals("") ? "" : (authorization.contains("dXNlcjp3cm9uZw==") ? " (wrong password)" : " (right password)")) + (accept == null ? "" : " Accept " + accept) + " -> " + response.statusCode());
response.headers().firstValue("WWW-Authenticate").ifPresent(v -> out.append(", WWW-Authenticate: ").append(v));
response.headers().firstValue("Location").ifPresent(v -> out.append(", Location: ").append(path(v)));
if (response.statusCode() == 200) {
out.append(", body ").append(response.body());
}
return out.toString();
}
}
Chạy bằng mvn -q compile exec:exec. Kết quả (đã bỏ log ở stderr; mật khẩu sinh ngẫu nhiên đổi mỗi lần chạy nên chỉ in độ dài):
generated password logged at startup: true (a random UUID, 36 characters)
filters in the default chain: [DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, UsernamePasswordAuthenticationFilter, DefaultResourcesFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter, BasicAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter]
-- who gets in
GET /hello (no credentials) -> 401, WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
GET /hello (no credentials) Accept text/html -> 302, Location: /login
GET /hello (wrong password) -> 401, WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
GET /hello (right password) -> 200, body {"message":"hello user"}
GET /no-such-page (no credentials) -> 401, WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
GET /no-such-page (right password) -> 404
-- headers added to an authenticated answer
response headers: [cache-control, content-type, expires, pragma, x-content-type-options, x-frame-options, x-xss-protection]
-- CSRF protection on POST
POST /orders (right password) -> 401, WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
-- the default login page and form login
GET /login -> 200, form has username, password and _csrf fields: true, session cookie attributes: [Path=/, HttpOnly]
POST /login with a wrong password -> 302, Location /login?error
POST /login with the right password and the token -> 302, Location /, session id changed: true
GET /hello with only the session cookie -> 200 {"message":"hello user"}
POST /orders with the session cookie and no CSRF token -> 403
Một chuỗi bộ lọc, mười sáu bước
Dòng thứ hai là bản đồ của cả chương. Spring Security đăng ký một bộ lọc duy nhất (FilterChainProxy) vào ứng dụng, và nó chạy chuỗi mười sáu bộ lọc con theo thứ tự in ra. Có thể nhóm chúng theo việc làm:
- Chuẩn bị ngữ cảnh và header:
DisableEncodeUrlFilter,WebAsyncManagerIntegrationFilter,SecurityContextHolderFilter(nạp ngữ cảnh bảo mật của yêu cầu, ví dụ từ phiên),HeaderWriterFilter(thêm các header an toàn). - Chống giả mạo yêu cầu:
CsrfFilter. - Đăng nhập và đăng xuất:
LogoutFilter,UsernamePasswordAuthenticationFilter(xử lý form đăng nhập), và các bộ lọc tạo trang/loginvà/logoutmặc định. - Xác thực bằng header:
BasicAuthenticationFilter(đọcAuthorization: Basic). - Tiện ích:
RequestCacheAwareFilter(nhớ trang bạn định vào trước khi bị chuyển tới đăng nhập),SecurityContextHolderAwareRequestFilter,AnonymousAuthenticationFilter(gán danh tính "ẩn danh" nếu chưa ai xác thực). - Quyết định:
ExceptionTranslationFilter(bắt lỗi từ chối và đổi thành 401, 302 hay 403 phù hợp) vàAuthorizationFilter(cuối cùng, kiểm tra quy tắc: mặc định "mọi yêu cầu phải xác thực").
Thứ tự là điều có nghĩa: các bộ lọc xác thực chạy trước bộ lọc ủy quyền để khi AuthorizationFilter hỏi "ai đang gọi?", câu trả lời đã có. Và CsrfFilter chạy trước các bộ lọc xác thực, điều giải thích một kết quả lạ ở phần sau.
Đang tải Animation…
Cách đọc hình: các nhóm đi từ trái sang phải rồi xuống hàng dưới theo thứ tự chạy thật: ngữ cảnh và header, CSRF, đăng nhập và đăng xuất, xác thực Basic, ẩn danh, và cuối cùng là hai bộ lọc quyết định. Controller chỉ được gọi nếu chuỗi cho qua.
Ai được vào, và câu trả lời tuỳ client
Với cấu hình mặc định, mọi đường dẫn cần xác thực. Cùng một địa chỉ /hello cho ba kết quả tuỳ người gọi:
- Không có thông tin, không có
Acceptđặc biệt: 401, kèmWWW-Authenticate: Basic realm="Realm". Đây là cách HTTP bảo một chương trình "hãy gửi thông tin Basic". - Không có thông tin,
Accept: text/html(trình duyệt): 302 tới/login. Người dùng nhận trang đăng nhập mặc định thay vì một mã lỗi. - Mật khẩu sai qua Basic: 401; mật khẩu đúng: 200 với
{"message":"hello user"}. Mật khẩu là chuỗi UUID dài 36 ký tự được sinh ngẫu nhiên mỗi lần khởi động và in ra log (Using generated security password: ...), tài khoản làuser. Đây là mặc định để thử, không phải để triển khai thật.
Spring Security chọn kiểu từ chối theo loại client: một điểm vào cho chương trình (401) và một điểm vào cho trình duyệt (302 tới form).
Cách đọc hình: mỗi hàng là một loại người gọi và câu trả lời đo được: chương trình không có thông tin nhận 401, trình duyệt nhận 302 tới /login, người gọi với mật khẩu đúng nhận 200.
Bảo mật chạy trước định tuyến
Hai dòng về /no-such-page (một đường dẫn không có controller nào): người lạ nhận 401, còn người đã xác thực nhận 404. Nghĩa là chuỗi bộ lọc quyết định "bạn có được phép không" trước khi Spring MVC tìm xem đường dẫn có tồn tại không. Hệ quả tốt: một người lạ không phân biệt được đường dẫn có thật với đường dẫn không có, nên không "vẽ bản đồ" ứng dụng của bạn bằng cách thử địa chỉ. Hệ quả cần nhớ: nếu bạn thấy 401 ở một đường dẫn bạn nghĩ không tồn tại, hãy kiểm tra xác thực trước tiên.
Cách đọc hình: cùng đường dẫn không tồn tại: người lạ bị từ chối ở bộ lọc (401), người đã đăng nhập đi qua rồi mới không tìm thấy (404).
Header an toàn mặc định
Một phản hồi 200 của ứng dụng có thêm các header mà bạn không viết: Cache-Control, Expires, Pragma (không cho trình duyệt và proxy lưu nội dung đã xác thực), X-Content-Type-Options (không đoán kiểu nội dung), X-Frame-Options (không cho trang bị nhúng trong khung của trang khác, chống clickjacking), và X-XSS-Protection. Chúng do HeaderWriterFilter thêm vào, và đó là lý do một phần lớn "bảo mật mặc định" không nhìn thấy trong mã.
CSRF: vì sao có lúc 401, có lúc 403
CSRF (Cross-Site Request Forgery) là kiểu tấn công khiến trình duyệt của người dùng đã đăng nhập gửi một yêu cầu ghi tới ứng dụng của bạn từ một trang khác. Cách chống mặc định: mọi yêu cầu có thể thay đổi dữ liệu (như POST) phải mang một mã thông báo CSRF mà chỉ trang của bạn biết (trang /login có trường ẩn _csrf). Thiếu mã, yêu cầu bị từ chối. Thí nghiệm đo hai trường hợp thiếu mã giống hệt nhau:
POST /ordersvới Basic (mật khẩu đúng, không có mã CSRF): 401.POST /ordersvới cookie phiên của người đã đăng nhập bằng form, không có mã CSRF: 403.
Vì sao khác nhau? Từ danh sách bộ lọc, CsrfFilter đứng trước BasicAuthenticationFilter. Với yêu cầu Basic, lúc CsrfFilter từ chối, người gọi vẫn chưa được xác thực (bộ lọc Basic chưa chạy tới), nên điểm vào xác thực trả 401. Với cookie phiên, SecurityContextHolderFilter (chạy trước CSRF) đã nạp người dùng từ phiên, nên Spring Security biết đây là một người đã xác thực bị từ chối: 403. Hai mã khác nhau cho cùng một lý do (thiếu mã CSRF) và đó là một điểm hay gây hiểu nhầm khi gỡ lỗi. Với API chỉ dùng token trong header (bài JWT), CSRF thường được tắt có chủ đích vì trình duyệt không tự gửi header đó.
Cách đọc hình: hàng trên: Basic đi qua CsrfFilter khi chưa xác thực nên nhận 401. Hàng dưới: phiên đã nạp người dùng trước CsrfFilter nên nhận 403. Cả hai do cùng thiếu mã CSRF.
Đăng nhập bằng form và phiên
Phần cuối đo luồng đăng nhập của trình duyệt, từng bước:
GET /login: 200. Trang đăng nhập mặc định có hai ôusername,passwordvà một trường ẩn_csrf. Cookie phiênJSESSIONIDđã được tạo ngay từ đây, với thuộc tínhPath=/vàHttpOnly(mã JavaScript không đọc được cookie).POST /loginvới mật khẩu sai: 302 tới/login?error. Quay lại form kèm thông báo lỗi.POST /loginvới mật khẩu đúng và mã CSRF: 302 tới/, vàJSESSIONIDđã đổi giá trị. Spring Security cố tình cấp mã phiên mới sau khi đăng nhập để chặn tấn công "cố định phiên" (kẻ tấn công gài sẵn một mã phiên mà nạn nhân sau đó dùng để đăng nhập).GET /hellochỉ với cookie phiên mới: 200. Cookie là bằng chứng đăng nhập từ giờ; mật khẩu không được gửi lại.
Cách đọc hình: hàng trên: trang đăng nhập, cookie phiên có từ trước đăng nhập, và mật khẩu sai quay lại form. Hàng dưới: mật khẩu đúng chuyển tới /, mã phiên đổi, và cookie mới đủ để vào /hello.
| Điều quan sát | Giải thích |
|---|---|
| 16 bộ lọc theo thứ tự cố định | FilterChainProxy chạy chúng trước Spring MVC |
| 401 cho chương trình, 302 cho trình duyệt | Mỗi loại client có điểm vào xác thực riêng |
| Đường dẫn lạ: 401 với người lạ, 404 với người đã vào | Bảo mật quyết định trước định tuyến |
| Header an toàn tự thêm | HeaderWriterFilter |
| Thiếu CSRF: 401 với Basic, 403 với phiên | CsrfFilter chạy trước xác thực Basic |
| Mã phiên đổi sau đăng nhập | Chặn cố định phiên |
Quyết định thiết kế và đánh đổi
| Quyết định | Lựa chọn | Đánh đổi |
|---|---|---|
| Bắt đầu bảo mật | Thêm starter và giữ mặc định | Phải sớm thay mật khẩu tạm và tự quy định ai được vào đâu |
| Từ chối mặc định | Mọi thứ cần xác thực | Phải mở có chủ đích những đường dẫn công khai |
| Xác thực cho trình duyệt | Form và phiên | Cần CSRF, cần quản lý phiên |
| Xác thực cho chương trình | Basic hoặc token | Basic gửi mật khẩu mỗi yêu cầu (cần HTTPS) |
| CSRF | Giữ bật khi dùng cookie | Mọi form và AJAX ghi cần mang mã |
| Header an toàn | Giữ mặc định | Một số tích hợp (nhúng khung) cần nới có chủ đích |
Playbook: gặp sự cố với cấu hình mặc định thì làm gì
| Triệu chứng | Kiểm tra đầu tiên | Nguyên nhân hay gặp | Cách xử lý |
|---|---|---|---|
| Mọi client đột nhiên nhận 401 | Có spring-boot-starter-security mới thêm không | Mặc định đòi xác thực cho mọi thứ | Cấu hình ai được vào (bài sau) |
POST bị từ chối dù đã đăng nhập | Mã trạng thái: 401 hay 403 | Thiếu mã CSRF | Gửi _csrf hoặc tắt CSRF có chủ đích cho API token |
Trình duyệt bị chuyển tới /login | Header Accept | Điểm vào cho trình duyệt | Đúng thiết kế; chương trình thì gửi Accept: application/json hoặc Basic |
| 401 ở đường dẫn không tồn tại | Có thông tin đăng nhập không | Bảo mật chạy trước định tuyến | Xác thực trước khi nghi ngờ đường dẫn |
| Không biết bộ lọc nào chặn | Danh sách bộ lọc của chuỗi | Cấu hình tuỳ chỉnh sai thứ tự | In FilterChainProxy, bật log mức DEBUG của Spring Security |
| Mật khẩu mặc định thay đổi mỗi lần chạy | Log khởi động | Mật khẩu sinh ngẫu nhiên | Đặt spring.security.user.password hoặc dùng nguồn người dùng thật |
Lỗi thường gặp khi chạy lab
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Không tìm thấy mật khẩu sinh ngẫu nhiên trong log | Mức log gốc đang tắt | Đặt logging.level.root=INFO như DefaultSecurityDemo |
Dòng POST /orders cho 403 thay vì 401 | Client có phiên đã đăng nhập chứ không phải Basic | Đúng như bài: 403 cho người đã xác thực, 401 cho người chưa xác thực |
Bài thực hành
Làm trong dự án của bài (thư mục 11-spring-security/01-kien-truc-mac-dinh). Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.
- Chạy thí nghiệm.
mvn -q compile exec:exec. Tiêu chí: các dòng khớp bài, danh sách có đúng 16 bộ lọc. - Chạy như server thật.
mvn -q spring-boot:run, đọc dòngUsing generated security passwordtrong log, rồicurl -i http://localhost:8080/hellovàcurl -i -u user:<mật khẩu> http://localhost:8080/hello. Tiêu chí: 401 rồi 200. - Đặt mật khẩu cố định. Tạo
src/main/resources/application.propertiesvớispring.security.user.name=anvàspring.security.user.password=secret, chạy lại và gọicurl -i -u an:secret .... Tiêu chí: 200 vớihello an. - Xem log bộ lọc. Thêm
logging.level.org.springframework.security=DEBUGvà gọicurl -i http://localhost:8080/hello. Tiêu chí: log liệt kê các bộ lọc mà yêu cầu đi qua và nơi nó bị từ chối. - Thử trình duyệt. Mở
http://localhost:8080/hello, đăng nhập bằng form, rồi mở công cụ phát triển và so cookieJSESSIONIDtrước và sau khi đăng nhập. Tiêu chí: giá trị cookie đã đổi. - Chạy bộ test (tùy chọn). Bộ test nằm trong bản sao lưu ở kho mã của khóa học; sau khi clone,
mvn testchoTests run: 5, Failures: 0, Errors: 0. Dọn dẹp bằngmvn clean.
Hiểu lầm và giới hạn
- "Bảo mật là thứ thêm vào cuối." Starter bảo vệ ngay, và hành vi của cả ứng dụng thay đổi theo.
- "401 và 403 là một." 401: chưa xác thực; 403: đã xác thực nhưng bị từ chối (và bài đo trường hợp CSRF lệch nhau theo thứ tự bộ lọc).
- "Mật khẩu mặc định đủ dùng." Nó đổi mỗi lần khởi động và chỉ để thử.
- "CSRF chỉ liên quan form." Nó liên quan mọi yêu cầu ghi khi xác thực bằng cookie.
- Số đo thuộc về Spring Boot 4.1.1 và Spring Security 7.1.1; số và tên bộ lọc có thể khác ở bản khác. Bài chưa nói về cấu hình
SecurityFilterChain(bài sau), mã hóa mật khẩu, vai trò, CORS, OAuth2, và kiểm thử bảo mật.
Tự kiểm tra
- Spring Security hoạt động ở đâu trong đường đi của một yêu cầu, và vì sao điều đó quan trọng?
- Vì sao
GET /hellokhông có thông tin cho 401 với chương trình và 302 với trình duyệt? - Vì sao đường dẫn không tồn tại cho 401 với người lạ nhưng 404 với người đã đăng nhập?
- Cùng thiếu mã CSRF mà POST với Basic nhận 401, POST với phiên nhận 403: vì sao?
- Vì sao mã phiên đổi sau khi đăng nhập thành công?
Gợi ý đáp án. (1) Một chuỗi bộ lọc đứng trước Spring MVC; controller chỉ chạy khi chuỗi cho qua. (2) Spring Security chọn điểm vào xác thực theo loại client (Basic cho chương trình, form cho trình duyệt). (3) Bảo mật quyết định trước định tuyến; người đã vào mới tới bước tìm controller. (4) CsrfFilter chạy trước BasicAuthenticationFilter nên người gọi Basic còn ẩn danh, còn phiên đã được nạp trước CSRF. (5) Để chặn tấn công cố định phiên.
Điều cần nhớ
- Spring Security là chuỗi 16 bộ lọc trước Spring MVC; thứ tự quyết định hành vi.
- Mặc định: mọi thứ cần xác thực; 401 cho chương trình, 302 tới
/logincho trình duyệt; mật khẩu tạm mỗi lần khởi động. - Bảo mật chạy trước định tuyến; header an toàn và CSRF có sẵn.
- Đăng nhập bằng form cấp mã phiên mới; cookie là bằng chứng từ đó.
Đọc thêm
- Spring Security Reference: Architecture
- Spring Security Reference: Servlet Security: The Big Picture
- Spring Security Reference: Cross Site Request Forgery (CSRF)
Bài tiếp theo bỏ mặc định: tự cấu hình ai được vào đâu, mã hóa mật khẩu và phân quyền theo vai trò.