10.11 - Mở rộng và đào sâu
Tóm tắt: Những chủ đề bảo mật nối tiếp Module 10, kèm mức độ ưu tiên. Điểm quan trọng nhất cần biết trước: đừng tự viết hệ thống xác thực. Không phải vì bạn không đủ khả năng, mà vì các chi tiết dễ sai lại là những chi tiết quan trọng nhất — thuật toán băm mật khẩu và số vòng lặp, chống tấn công thời gian, sinh số ngẫu nhiên an toàn, luồng đặt lại mật khẩu. Dùng ASP.NET Core Identity hoặc một identity provider. Trong các mục dưới đây, thứ đáng làm ngay nhất và bị bỏ qua nhiều nhất là cấu hình Data Protection: quên nó thì hệ nhiều instance sẽ đăng xuất người dùng ngẫu nhiên, và triệu chứng đó rất khó lần ra nguyên nhân.
Mục tiêu bài học
Sau bài này bạn có thể:
- Cấu hình Data Protection cho hệ nhiều instance.
- Hiểu PKCE giải quyết vấn đề gì.
- Biết khi nào cần mTLS và passkey.
- Dùng OWASP làm danh sách kiểm tra.
Nội dung bài học
10.11.1 — Data Protection: làm ngay
Điều kiện kích hoạt: chạy nhiều hơn một instance.
ASP.NET Core dùng Data Protection API để mã hoá cookie xác thực, token chống CSRF và dữ liệu tạm. Mặc định, mỗi instance sinh khoá riêng:
Instance A mã hoá cookie bằng khoá A
Người dùng request tiếp theo -> load balancer chuyển sang Instance B
Instance B không giải mã được cookie đó -> coi như chưa đăng nhập
-> Người dùng bị ĐĂNG XUẤT ngẫu nhiênTriệu chứng rất dễ nhận ra khi đã biết, và rất khó lần ra khi chưa biết: người dùng bị đăng xuất ngẫu nhiên, và tần suất tăng theo số instance.
// Lưu khoá dùng chung
builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys")
.SetApplicationName("CrmApi"); // PHAI giong nhau moi instanceSetApplicationName quan trọng không kém: mặc định nó lấy tên thư mục ứng dụng, nên hai instance triển khai ở đường dẫn khác nhau sẽ không chia sẻ được khoá dù cùng lưu vào Redis.
Các cách lưu khác: PersistKeysToFileSystem với volume dùng chung, hoặc PersistKeysToAzureBlobStorage kèm ProtectKeysWithAzureKeyVault (bài 19.6).
10.11.2 — OAuth 2.1 và PKCE
Điều kiện kích hoạt: có SPA hoặc ứng dụng mobile đăng nhập qua identity provider.
PKCE giải quyết một lỗ hổng cụ thể của authorization code flow:
1. App mo trinh duyet toi identity provider
2. Người dùng đăng nhập
3. Provider chuyển hướng về app kèm MỘT MÃ (authorization code)
4. App doi ma do lay token
Lỗ hổng: bước 3 đi qua hệ điều hành. Một app độc hại đăng ký
cùng URL scheme có thể CHẶN được mã đó và đổi lấy token.PKCE vá lỗ hổng đó:
1. App sinh một chuỗi ngẫu nhiên (code_verifier), giữ BÍ MẬT
2. Gửi bản băm của nó (code_challenge) kèm yêu cầu đăng nhập
3. Khi đổi mã lấy token, app gửi kèm code_verifier gốc
4. Provider băm lại và so sánh
App độc hại chặn được mã nhưng KHÔNG có code_verifier -> không đổi được token.builder.Services.AddAuthentication()
.AddOpenIdConnect(options =>
{
options.ResponseType = "code";
options.UsePkce = true; // BẮT BUỘC cho SPA và mobile
options.SaveTokens = true;
});OAuth 2.1 gộp các thực hành tốt đã tích luỹ và loại bỏ hai luồng không còn an toàn: implicit flow (trả token thẳng trên URL, nên nó nằm trong lịch sử trình duyệt và log) và resource owner password credentials (app thu thập mật khẩu trực tiếp — đánh mất toàn bộ lợi ích của việc uỷ quyền).
Nếu đang dùng implicit flow, hãy chuyển sang authorization code với PKCE.
10.11.3 — Passkey và WebAuthn
Điều kiện kích hoạt: muốn loại bỏ mật khẩu.
Passkey dùng cặp khoá công khai lưu trong thiết bị (Face ID, Windows Hello, khoá phần cứng). Khoá riêng không bao giờ rời thiết bị.
| Mật khẩu | Passkey | |
|---|---|---|
| Lộ khi server bị xâm nhập | Có (nếu băm yếu) | Không — server chỉ giữ khoá công khai |
| Lừa đảo (phishing) | Có | Không — khoá gắn với tên miền |
| Dùng lại giữa các trang | Thường có | Không thể |
| Trải nghiệm | Gõ mật khẩu | Chạm vân tay |
Hàng thứ hai là lợi ích lớn nhất: passkey gắn với tên miền nên một trang giả mạo không thể dùng nó, kể cả khi người dùng bị lừa hoàn toàn. Đây là điều mà mọi biện pháp chống phishing khác chỉ làm được một phần.
Thư viện phổ biến trên .NET: Fido2.NET. Thực tế triển khai: dùng passkey làm lựa chọn thêm bên cạnh mật khẩu, không thay thế hoàn toàn — vì không phải thiết bị nào cũng hỗ trợ, và người dùng cần đường khôi phục khi mất thiết bị.
10.11.4 — mTLS
Điều kiện kích hoạt: giao tiếp giữa các service nội bộ, hoặc đối tác B2B.
Thay vì chỉ server chứng minh danh tính (HTTPS thông thường), cả hai bên đều xuất trình chứng chỉ:
builder.WebHost.ConfigureKestrel(options =>
{
options.ConfigureHttpsDefaults(https =>
{
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
https.ClientCertificateValidation = (cert, chain, errors) =>
_trustedThumbprints.Contains(cert.Thumbprint);
});
});Ưu điểm: không có token để đánh cắp hay hết hạn. Nhược điểm: quản lý chứng chỉ — phát hành, phân phối, gia hạn, thu hồi. Chứng chỉ hết hạn mà không ai để ý là nguyên nhân sự cố khá phổ biến.
Thực tế: service mesh làm việc này tự động và trong suốt, nên nếu bạn đã có mesh thì đã có mTLS mà không phải viết gì (bài 18.10).
10.11.5 — OWASP làm danh sách kiểm tra
Ba lỗ hổng đứng đầu danh sách rủi ro API, và cách chặn từng cái:
| Rủi ro | Cách chặn |
|---|---|
| Phân quyền cấp đối tượng bị phá | Kiểm tra quyền trên từng bản ghi (bài 10.13) |
| Xác thực bị phá | Không tự viết; dùng Identity hoặc provider |
| Lộ dữ liệu quá mức | Trả DTO, không trả entity |
Lỗ hổng thứ ba dễ mắc và dễ sửa:
// SAI — tra ca entity, lo PasswordHash, SecurityStamp, InternalNotes
return Ok(user);
// DUNG — chi tra truong can
return Ok(new UserDto(user.Id, user.DisplayName, user.Email));Trả thẳng entity còn một tác hại nữa: mọi lần thêm cột vào bảng đều tự động lộ ra API, kể cả cột nội bộ mà không ai nghĩ tới việc che.
Bốn biện pháp khác nên có mặc định:
// 1. Header bao mat
app.UseHsts();
app.Use(async (ctx, next) =>
{
ctx.Response.Headers["X-Content-Type-Options"] = "nosniff";
ctx.Response.Headers["X-Frame-Options"] = "DENY";
ctx.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
await next();
});
// 2. Rate limit tren endpoint dang nhap — chong do mat khau
// 3. Log mỗi lần đăng nhập thất bại kèm IP
// 4. Khoa tam tai khoan sau N lan saiBiện pháp 2 đặc biệt quan trọng: không có nó, kẻ tấn công thử được hàng nghìn mật khẩu mỗi phút (bài 9.6).
10.11.6 — Thứ tự nên học
| Ưu tiên | Chủ đề | Vì sao |
|---|---|---|
| Cao | Data Protection dùng chung | Thiếu là đăng xuất ngẫu nhiên |
| Cao | Phân quyền theo tài nguyên | Lỗ hổng phổ biến nhất |
| Cao | Rate limit endpoint đăng nhập | Chống dò mật khẩu |
| Cao | Trả DTO thay vì entity | Chống lộ dữ liệu quá mức |
| Trung bình | PKCE | Bắt buộc nếu có SPA hoặc mobile |
| Trung bình | Header bảo mật | Rẻ, thêm một lớp phòng thủ |
| Thấp | Passkey | Tốt nhưng cần đường khôi phục |
| Thấp | mTLS | Mesh thường lo giúp |
Sau Module 10, bước tiếp theo là Module 11 — SignalR.
10.11.7 — Rà lại code của bạn
Danh sách rà soát bảo mật nâng cao
- Data Protection lưu khoá dùng chung và có SetApplicationName.
- Không tự viết băm mật khẩu hay luồng đặt lại mật khẩu.
- Nếu có SPA hoặc mobile, đã bật PKCE và không dùng implicit flow.
- Endpoint đăng nhập có rate limit.
- Có log và cảnh báo cho đăng nhập thất bại liên tục.
- API trả DTO, không trả entity.
- Có các header bảo mật cơ bản.
- Chứng chỉ có giám sát hạn và cảnh báo trước khi hết hạn.
Bài tập áp dụng
Bài 1 — Tái hiện "cứ bị đăng xuất ngẫu nhiên"
Chạy hai instance không dùng khoá Data Protection chung, đăng nhập rồi gọi API nhiều lần và đếm số lần bị đăng xuất.
Tiêu chí hoàn thành: bạn giải thích được vì sao tỷ lệ lỗi xấp xỉ (N−1)/N với N instance, và vì sao lỗi này không xuất hiện khi chạy một instance.
Gợi ý và lời giải — Bài 1
Gợi ý. Mỗi instance tự sinh khoá riêng khi khởi động nếu bạn không chỉ định nơi lưu. Cookie mã hoá bằng khoá của instance này thì instance kia không giải mã được.
Lời giải — dựng lại:
# Hai instance, không cấu hình Data Protection
dotnet run --urls http://localhost:5001 &
dotnet run --urls http://localhost:5002 &upstream crm { server localhost:5001; server localhost:5002; }
server { listen 8080; location / { proxy_pass http://crm; } }COOKIE=$(curl -s -c - -X POST http://localhost:8080/login -d '...' | grep -o 'auth.*')
for i in $(seq 1 20); do
curl -s -o /dev/null -w '%{http_code} ' -b "$COOKIE" http://localhost:8080/api/v1/me
done200 401 200 401 401 200 401 200 200 401 401 200 401 401 200 401 200 401 401 200Khoảng một nửa số request bị đăng xuất.
Vì sao tỷ lệ là (N−1)/N. Cookie được mã hoá bằng khoá của một instance. Load balancer phân phối đều, nên xác suất request rơi đúng instance đó là 1/N. Còn lại (N−1)/N request rơi vào instance không giải mã được:
| Số instance | Tỷ lệ lỗi |
|---|---|
| 1 | 0% |
| 2 | 50% |
| 3 | 67% |
| 10 | 90% |
Và đây là câu trả lời cho vế thứ hai: với một instance, N=1 nên tỷ lệ lỗi bằng 0. Mọi cookie đều do chính nó mã hoá. Lỗi chỉ xuất hiện đúng lúc bạn mở rộng — thường là lúc tải tăng, tức là lúc tệ nhất.
Triệu chứng người dùng báo lại rất khó lần: "thỉnh thoảng bị đăng xuất", "bấm lại thì được". Log ứng dụng không có gì bất thường — với instance nhận request, cookie đó thật sự không hợp lệ.
Cách sửa — khoá dùng chung và SetApplicationName giống nhau:
builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(
ConnectionMultiplexer.Connect(redisConnectionString), "crm-dp-keys")
.SetApplicationName("crm-api"); // PHẢI giống nhau mọi instanceHoặc dùng thư mục chia sẻ:
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/var/crm/dp-keys"))
.SetApplicationName("crm-api");SetApplicationName là dòng hay bị quên nhất. Tên ứng dụng tham gia vào việc phân tách khoá, nên hai instance dùng chung Redis mà khác tên vẫn không giải mã được của nhau — và triệu chứng y hệt như không chia sẻ gì.
Thứ này ảnh hưởng nhiều hơn bạn nghĩ:
| Thành phần | Hỏng thế nào khi không chia sẻ khoá |
|---|---|
| Cookie xác thực | Đăng xuất ngẫu nhiên |
| Token chống CSRF | 400 ngẫu nhiên khi gửi form |
| Token đặt lại mật khẩu | Link trong email khi được khi không |
TempData | Dữ liệu mất giữa hai lần chuyển hướng |
JWT không bị ảnh hưởng, vì nó ký bằng khoá bạn cấu hình tường minh trong appsettings, không phải bằng khoá Data Protection tự sinh. Đây là một lý do thực tế khiến JWT dễ mở rộng ngang hơn cookie.
Bài 2 — Rà các endpoint trả thẳng entity
Tìm mọi endpoint trả thẳng entity và liệt kê các trường nội bộ đang bị lộ.
Tiêu chí hoàn thành: bạn liệt kê được hai chiều rò rỉ, không chỉ chiều trả về.
Gợi ý và lời giải — Bài 2
Gợi ý. Entity đi ra ngoài là một chiều. Chiều còn lại là dữ liệu từ ngoài đi vào entity.
Lời giải — tìm bằng công cụ:
grep -rnE 'return (Ok|Created\w*)\(\s*(customer|lead|user|order|entity)\b' \
--include=*.cs src/ | head -20Hoặc chính xác hơn, kiểm kiểu trả về:
grep -rnE 'Task<ActionResult<(Customer|Lead|AppUser|Order)>>' --include=*.cs src/Chiều 1 — rò rỉ khi trả về:
[HttpGet("{id:guid}")]
public async Task<ActionResult<AppUser>> GetById(Guid id, CancellationToken ct)
=> Ok(await _db.Users.FindAsync([id], ct)); // trả thẳng entity{
"id": "8f3a...",
"email": "[email protected]",
"passwordHash": "AQAAAAIAAYagAAAAEJ8x...",
"securityStamp": "TXIHJQ2RLXQ...",
"concurrencyStamp": "a1b2c3d4...",
"accessFailedCount": 2,
"lockoutEnd": null,
"internalNotes": "đang trong diện xem xét",
"tenantId": "acme"
}| Trường lộ | Rủi ro |
|---|---|
passwordHash | Tấn công ngoại tuyến để dò mật khẩu |
securityStamp | Biết được token nào còn hiệu lực |
internalNotes | Ghi chú nội bộ về chính người dùng |
tenantId | Lộ cấu trúc đa tổ chức |
Chiều 2 — over-posting, và đây là chiều hay bị quên:
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, AppUser input, CancellationToken ct)
{
_db.Entry(input).State = EntityState.Modified; // gắn THẲNG entity từ body
await _db.SaveChangesAsync(ct);
return NoContent();
}curl -X PUT .../users/8f3a... -H "Authorization: Bearer $TOKEN_USER" \
-d '{"id":"8f3a...","email":"[email protected]","tenantId":"MOT-TENANT-KHAC"}'Người dùng vừa tự chuyển mình sang tổ chức khác. Hoặc tự gán isSuperAdmin, tự đặt lockoutEnd = null để gỡ khoá.
Model binder gán mọi trường có trong JSON. Trường nào bạn không định cho sửa mà vẫn nằm trên entity thì vẫn sửa được.
Cách sửa — DTO riêng cho từng chiều:
public sealed record UserDto(Guid Id, string Email, string DisplayName, string[] Roles);
public sealed record UpdateUserRequest(string DisplayName, string? PhoneNumber);[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdateUserRequest req, CancellationToken ct)
{
var user = await _db.Users.FindAsync([id], ct);
if (user is null) return NotFound();
user.DisplayName = req.DisplayName; // CHỈ những trường cho phép
user.PhoneNumber = req.PhoneNumber;
await _db.SaveChangesAsync(ct);
return NoContent();
}DTO đầu vào chỉ có hai trường, nên tenantId trong JSON bị bỏ qua hoàn toàn — không phải bị từ chối, mà là không tồn tại trong hợp đồng.
Một bài test kiến trúc chặn cả lớp lỗi:
[Fact]
public void KhongControllerNaoTraVeEntity()
{
var entityTypes = typeof(CrmDbContext).Assembly.GetTypes()
.Where(t => t.Namespace?.Contains(".Domain.Entities") == true)
.ToHashSet();
var offenders = typeof(Program).Assembly.GetTypes()
.Where(t => typeof(ControllerBase).IsAssignableFrom(t))
.SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.DeclaredOnly))
.Where(m => ReturnsOrAccepts(m, entityTypes))
.Select(m => $"{m.DeclaringType!.Name}.{m.Name}")
.ToList();
Assert.Empty(offenders);
}Nó kiểm cả hai chiều — kiểu trả về và kiểu tham số — nên bắt được cả over-posting.
Bài 3 — Một nghìn lần đăng nhập sai
Gửi 1.000 request đăng nhập sai liên tiếp và xem hệ thống có chặn không.
Tiêu chí hoàn thành: bạn nêu được vì sao chỉ lockout theo tài khoản là chưa đủ, và vì sao chỉ rate limit theo IP cũng chưa đủ.
Gợi ý và lời giải — Bài 3
Gợi ý. Nghĩ về hai kiểu tấn công đối xứng: một mật khẩu thử trên nhiều tài khoản, và nhiều mật khẩu thử trên một tài khoản.
Lời giải — thử tải:
for i in $(seq 1 1000); do
curl -s -o /dev/null -w '%{http_code}\n' -X POST .../auth/login \
-H 'Content-Type: application/json' \
-d "{\"email\":\"[email protected]\",\"password\":\"thu-$i\"}"
done | sort | uniq -cKhông có bảo vệ nào:
1000 4011.000 lần thử trong vài giây, không gì cản.
Chỉ có lockout theo tài khoản:
5 401 <- năm lần sai
995 401 <- vẫn 401, nhưng do bị khoáTài khoản [email protected] được bảo vệ. Nhưng:
# Kẻ tấn công đổi chiến thuật: MỘT mật khẩu, NHIỀU tài khoản
for email in $(cat emails.txt); do
curl -s -o /dev/null -w '%{http_code}\n' -X POST .../auth/login \
-d "{\"email\":\"$email\",\"password\":\"Password123!\"}"
doneKhông tài khoản nào bị khoá — mỗi tài khoản chỉ sai đúng một lần. Đây là password spraying, và lockout theo tài khoản hoàn toàn mù với nó. Với danh sách 10.000 email và một mật khẩu phổ biến, tỷ lệ trúng thường đủ để có ít nhất vài tài khoản.
Chỉ có rate limit theo IP:
options.AddPolicy("login", ctx => RateLimitPartition.GetFixedWindowLimiter(
ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1) })); 10 401
990 429Chặn được ca này. Nhưng kẻ tấn công phân tán qua botnet hoặc proxy xoay vòng thì mỗi IP chỉ gửi vài request — dưới ngưỡng, và không tài khoản nào bị khoá vì chúng cũng phân tán theo email.
Câu trả lời cho tiêu chí — hai cơ chế chặn hai trục khác nhau:
| Chặn theo | Mù với | |
|---|---|---|
| Lockout theo tài khoản | Nhiều mật khẩu × một tài khoản | Password spraying |
| Rate limit theo IP | Nhiều request × một nguồn | Tấn công phân tán |
Chúng bù cho nhau, nên phải có cả hai, cộng thêm một lớp thứ ba nhìn theo chiều tổng thể:
// 3. Chỉ số toàn hệ thống — bắt được tấn công phân tán
_metrics.LoginFailures.Add(1, new KeyValuePair<string, object?>("reason", "bad-password"));CẢNH BÁO khi: tỷ lệ đăng nhập thất bại toàn hệ thống > 30% trong 5 phút
CẢNH BÁO khi: số email khác nhau bị thử từ một /24 > 50 trong 10 phútPassword spraying phân tán không chạm ngưỡng nào của hai lớp đầu, nhưng nó đẩy tỷ lệ thất bại chung lên rất cao — và đó là dấu hiệu duy nhất bạn có.
Ba biện pháp bổ sung đáng cân nhắc:
- Trễ tăng dần thay vì khoá cứng: lần sai thứ n chờ
2^ngiây, tối đa 30 giây. Làm tấn công tự động trở nên vô nghĩa mà không khoá được tài khoản của người dùng thật. - CAPTCHA sau vài lần sai — chuyển chi phí sang phía tấn công.
- Kiểm tra mật khẩu bị lộ khi đăng ký, qua API k-anonymity của Have I Been Pwned. Password spraying sống nhờ mật khẩu phổ biến; chặn từ gốc là hiệu quả nhất.
Và một điều không được quên: thông báo lỗi phải giống nhau cho mọi trường hợp thất bại. "Email không tồn tại" so với "Sai mật khẩu" cho kẻ tấn công biết email nào có thật — biến 10.000 email phỏng đoán thành một danh sách mục tiêu đã lọc.
Tự kiểm tra
Vì sao không nên tự viết hệ thống xác thực?
Vì các chi tiết dễ sai lại là những chi tiết quan trọng nhất: thuật toán băm mật khẩu và số vòng lặp, chống tấn công thời gian, sinh số ngẫu nhiên an toàn, và luồng đặt lại mật khẩu. Dùng ASP.NET Core Identity hoặc một identity provider.
Thiếu cấu hình Data Protection gây triệu chứng gì?
Người dùng bị đăng xuất ngẫu nhiên, và tần suất tăng theo số instance. Nguyên nhân là mỗi instance sinh khoá riêng nên không giải mã được cookie do instance khác tạo.
Vì sao SetApplicationName quan trọng?
Vì mặc định nó lấy tên thư mục ứng dụng, nên hai instance triển khai ở đường dẫn khác nhau sẽ không chia sẻ được khoá dù cùng lưu vào một nơi.
PKCE giải quyết lỗ hổng gì?
Trong authorization code flow, bước chuyển hướng trả mã về app đi qua hệ điều hành, nên một app độc hại đăng ký cùng URL scheme có thể chặn mã đó. PKCE thêm một bí mật mà chỉ app thật có, nên app độc hại chặn được mã cũng không đổi được token.
Lợi ích lớn nhất của passkey là gì?
Nó gắn với tên miền nên một trang giả mạo không thể dùng được, kể cả khi người dùng bị lừa hoàn toàn. Đây là điều mà mọi biện pháp chống phishing khác chỉ làm được một phần.
Vì sao trả thẳng entity từ API là nguy hiểm?
Vì nó lộ những trường nội bộ như PasswordHash hay SecurityStamp, và mọi lần thêm cột vào bảng sau này đều tự động lộ ra API mà không ai nghĩ tới việc che.
Kết luận
Ba điều đáng nhớ nhất:
- Đừng tự viết xác thực — những chi tiết dễ sai là những chi tiết quan trọng nhất.
- Data Protection dùng chung là bắt buộc với hệ nhiều instance.
- Trả DTO, không trả entity — nếu không, mỗi cột mới là một lần lộ dữ liệu.
Tham khảo
- Xác thực trong ASP.NET Core
- Phân quyền trong ASP.NET Core
- Cấu hình JWT bearer authentication
- RFC 8693 — OAuth 2.0 Token Exchange
Điều hướng
- Bài trước: 10.10 — Security Best Practices
- Bài tiếp theo: 10.12 — Mini case study
- Về module: Trang mục lục