Skip to content

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ên

Triệ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.

csharp
// Lưu khoá dùng chung
builder.Services.AddDataProtection()
    .PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys")
    .SetApplicationName("CrmApi");        // PHAI giong nhau moi instance

SetApplicationName 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.
csharp
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ẩuPasskey
Lộ khi server bị xâm nhậpCó (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 trangThường cóKhông thể
Trải nghiệmGõ mật khẩuChạ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ỉ:

csharp
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 roCá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ứcTrả DTO, không trả entity

Lỗ hổng thứ ba dễ mắc và dễ sửa:

csharp
// 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:

csharp
// 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 sai

Biệ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ênChủ đềVì sao
CaoData Protection dùng chungThiếu là đăng xuất ngẫu nhiên
CaoPhân quyền theo tài nguyênLỗ hổng phổ biến nhất
CaoRate limit endpoint đăng nhậpChống dò mật khẩu
CaoTrả DTO thay vì entityChống lộ dữ liệu quá mức
Trung bìnhPKCEBắt buộc nếu có SPA hoặc mobile
Trung bìnhHeader bảo mậtRẻ, thêm một lớp phòng thủ
ThấpPasskeyTốt nhưng cần đường khôi phục
ThấpmTLSMesh 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:

bash
# Hai instance, không cấu hình Data Protection
dotnet run --urls http://localhost:5001 &
dotnet run --urls http://localhost:5002 &
nginx
upstream crm { server localhost:5001; server localhost:5002; }
server { listen 8080; location / { proxy_pass http://crm; } }
bash
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
done
text
200 401 200 401 401 200 401 200 200 401 401 200 401 401 200 401 200 401 401 200

Khoả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ố instanceTỷ lệ lỗi
10%
250%
367%
1090%

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:

csharp
builder.Services.AddDataProtection()
    .PersistKeysToStackExchangeRedis(
        ConnectionMultiplexer.Connect(redisConnectionString), "crm-dp-keys")
    .SetApplicationName("crm-api");          // PHẢI giống nhau mọi instance

Hoặc dùng thư mục chia sẻ:

csharp
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ầnHỏ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 CSRF400 ngẫu nhiên khi gửi form
Token đặt lại mật khẩuLink trong email khi được khi không
TempDataDữ 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ụ:

bash
grep -rnE 'return (Ok|Created\w*)\(\s*(customer|lead|user|order|entity)\b' \
  --include=*.cs src/ | head -20

Hoặc chính xác hơn, kiểm kiểu trả về:

bash
grep -rnE 'Task<ActionResult<(Customer|Lead|AppUser|Order)>>' --include=*.cs src/

Chiều 1 — rò rỉ khi trả về:

csharp
[HttpGet("{id:guid}")]
public async Task<ActionResult<AppUser>> GetById(Guid id, CancellationToken ct)
    => Ok(await _db.Users.FindAsync([id], ct));       // trả thẳng entity
json
{
  "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
passwordHashTấn công ngoại tuyến để dò mật khẩu
securityStampBiết được token nào còn hiệu lực
internalNotesGhi chú nội bộ về chính người dùng
tenantIdLộ cấu trúc đa tổ chức

Chiều 2 — over-posting, và đây là chiều hay bị quên:

csharp
[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();
}
bash
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:

csharp
public sealed record UserDto(Guid Id, string Email, string DisplayName, string[] Roles);

public sealed record UpdateUserRequest(string DisplayName, string? PhoneNumber);
csharp
[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:

csharp
[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:

bash
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 -c

Không có bảo vệ nào:

text
   1000 401

1.000 lần thử trong vài giây, không gì cản.

Chỉ có lockout theo tài khoản:

text
      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:

bash
# 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!\"}"
done

Khô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:

csharp
options.AddPolicy("login", ctx => RateLimitPartition.GetFixedWindowLimiter(
    ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
    _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1) }));
text
     10 401
    990 429

Chặ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 theoMù với
Lockout theo tài khoảnNhiều mật khẩu × một tài khoảnPassword spraying
Rate limit theo IPNhiều request × một nguồnTấ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ể:

csharp
// 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"));
text
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út

Password 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:

  1. Trễ tăng dần thay vì khoá cứng: lần sai thứ n chờ 2^n giâ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.
  2. CAPTCHA sau vài lần sai — chuyển chi phí sang phía tấn công.
  3. 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:

  1. Đừ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.
  2. Data Protection dùng chung là bắt buộc với hệ nhiều instance.
  3. 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 ​

Điều hướng ​

Phát hành theo giấy phép MIT.