Skip to content

8.12 - Mini case study ​

Tóm tắt: Một sự cố mà nguyên nhân là thứ tự hai dòng code: sau một lần dọn dẹp Program.cs, mọi endpoint công khai — đăng nhập, quên mật khẩu, health check — bắt đầu trả 401. Không ai đổi logic xác thực; chỉ có thứ tự middleware bị xáo. Đây là lớp sự cố đặc trưng của ASP.NET Core vì pipeline chạy đúng theo thứ tự bạn đăng ký, và phần lớn middleware không báo lỗi khi đặt sai chỗ — chúng chỉ hành xử khác đi. Bài này đi qua chẩn đoán, thứ tự đúng kèm lý do cho từng vị trí, và một test chạy trong CI để thứ tự không bị xáo lần nữa.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Chẩn đoán sự cố do thứ tự middleware.
  • Sắp xếp pipeline đúng và giải thích được từng vị trí.
  • Viết middleware tuỳ chỉnh đặt đúng chỗ.
  • Bảo vệ thứ tự bằng test tự động.

Nội dung bài học ​

8.12.1 — Sự cố ​

14:20  Deploy ban dan don dep Program.cs
14:23  Người dùng không đăng nhập được: POST /api/auth/login -> 401
14:24  Health check của Kubernetes thất bại -> pod bị khởi động lại liên tục
14:26  Rollback
csharp
// Program.cs SAU khi "don dep"
var app = builder.Build();

app.UseAuthentication();       // <- bi day len TREN
app.UseAuthorization();
app.UseHttpsRedirection();
app.UseRouting();              // <- gio nam DUOI
app.UseCors("Default");
app.MapControllers();
app.MapHealthChecks("/health/live");

Không có exception nào, không có cảnh báo nào. Ứng dụng khởi động bình thường và trả 401 cho mọi thứ.

8.12.2 — Vì sao ​

Pipeline ASP.NET Core là một chuỗi middleware, chạy đúng theo thứ tự đăng ký:

Request -> [Auth] -> [Authorization] -> [HTTPS] -> [Routing] -> [CORS] -> [Endpoint]

UseAuthorization cần biết endpoint nào đang được gọi để đọc thuộc tính [AllowAnonymous] hay [Authorize] trên nó. Thông tin đó do UseRouting xác định.

Thu tu SAI:
  UseAuthorization chạy TRƯỚC UseRouting
  -> Chưa biết endpoint nào -> không đọc được [AllowAnonymous]
  -> Áp dụng chính sách mặc định cho MỌI request -> 401

Hai hệ quả phụ cũng đến từ cùng nguyên nhân:

  • Health check trả 401 vì nó cũng là một endpoint, và [AllowAnonymous] của nó không được đọc.
  • CORS không hoạt động vì UseCors nằm sau UseAuthorization, nên request preflight OPTIONS (không mang token) bị chặn trước khi tới CORS (bài 2.12).

8.12.3 — Thứ tự đúng ​

csharp
var app = builder.Build();

// 1. Xử lý exception — NGOÀI CÙNG để bắt được lỗi của mọi thứ bên trong
app.UseExceptionHandler("/error");

// 2. HSTS và chuyển hướng HTTPS — trước mọi xử lý nội dung
app.UseHsts();
app.UseHttpsRedirection();

// 3. File tĩnh — trước routing, để không tốn chi phí định tuyến
app.UseStaticFiles();

// 4. Routing — XÁC ĐỊNH endpoint, mọi thứ sau đây biết endpoint là gì
app.UseRouting();

// 5. CORS — SAU routing, TRƯỚC auth (preflight không mang token)
app.UseCors("Default");

// 6. Rate limiting — trước auth để chặn lạm dụng sớm
app.UseRateLimiter();

// 7. Xác thực rồi phân quyền — thứ tự này bắt buộc
app.UseAuthentication();
app.UseAuthorization();

// 8. Middleware tuỳ chỉnh — sau auth để biết người dùng là ai
app.UseMiddleware<TenantResolutionMiddleware>();

// 9. Endpoint — trong cùng
app.MapControllers();
app.MapHealthChecks("/health/live");

Lý do cho từng vị trí quan trọng:

Vị tríVì sao ở đó
Exception handler ngoài cùngChỉ bắt được lỗi của những gì nằm bên trong nó
UseRouting trước authAuth cần biết endpoint để đọc [Authorize] và [AllowAnonymous]
UseCors trước authPreflight OPTIONS không mang token, sẽ bị auth từ chối
Rate limiter trước authChặn lạm dụng trước khi tốn công xác thực
UseAuthentication trước UseAuthorizationPhải biết bạn là ai rồi mới xét bạn được làm gì
Middleware tenant sau authCần claim đã xác thực để lấy tenant_id

Hàng đầu tiên đáng nhấn mạnh: đặt UseExceptionHandler ở giữa pipeline nghĩa là lỗi trong middleware phía trước nó không được bắt và trả về trang lỗi mặc định của server (bài 8.8).

8.12.4 — Middleware tuỳ chỉnh ​

csharp
public sealed class TenantResolutionMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext)
    {
        var tenantId = context.User.FindFirst("tenant_id")?.Value;

        if (tenantId is not null)
            tenantContext.SetTenant(tenantId);

        await next(context);        // PHẢI gọi, nếu không pipeline dừng ở đây
    }
}

Ba lỗi hay gặp khi viết middleware:

1. Quên gọi next. Request dừng ở middleware này và client nhận phản hồi rỗng, không có lỗi nào.

2. Ghi vào body sau khi đã gọi next.

csharp
// SAI — response đã được gửi đi
await next(context);
await context.Response.WriteAsync("them gi do");
// InvalidOperationException: Headers are read-only, response has already started

3. Tiêm service scoped vào constructor. Middleware được tạo một lần lúc khởi động — nó là singleton. Tiêm scoped vào constructor là captive dependency (bài 7.11).

csharp
// SAI — ITenantContext la scoped
public TenantResolutionMiddleware(RequestDelegate next, ITenantContext tenantContext)

// ĐÚNG — nhận qua tham số của InvokeAsync
public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext)

ASP.NET Core tiêm tham số của InvokeAsync từ scope của request hiện tại — đúng thứ bạn cần.

8.12.5 — Bảo vệ bằng test ​

csharp
[Fact]
public async Task EndpointCongKhai_KhongDoiXacThuc()
{
    var client = _factory.CreateClient();

    var response = await client.PostAsJsonAsync("/api/auth/login",
        new { Email = "[email protected]", Password = "sai" });

    // Có thể 400 hoặc 401 vì sai mật khẩu — nhưng KHÔNG được là 401 vì thiếu token
    Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode);
}

[Fact]
public async Task HealthCheck_LuonTraVe200()
{
    var client = _factory.CreateClient();
    var response = await client.GetAsync("/health/live");

    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

[Fact]
public async Task PreflightCors_TraVeHeaderDung()
{
    var client = _factory.CreateClient();
    var request = new HttpRequestMessage(HttpMethod.Options, "/api/leads");
    request.Headers.Add("Origin", "https://crm.company.com");
    request.Headers.Add("Access-Control-Request-Method", "POST");

    var response = await client.SendAsync(request);

    Assert.True(response.Headers.Contains("Access-Control-Allow-Origin"));
}

Ba test này chạy trong vài trăm mili giây và bắt được chính xác sự cố ở trên. Chúng kiểm tra hành vi quan sát được của pipeline, nên không gãy khi bạn refactor (bài 9.8).

8.12.6 — Kiểm tra thứ tự trong lúc chạy ​

Khi nghi ngờ, in ra pipeline:

csharp
app.Use(async (context, next) =>
{
    _logger.LogDebug("Trước: {Path}, Endpoint = {Endpoint}",
        context.Request.Path,
        context.GetEndpoint()?.DisplayName ?? "(chưa xác định)");

    await next();

    _logger.LogDebug("Sau: {Path} -> {Status}",
        context.Request.Path, context.Response.StatusCode);
});

Đặt middleware này ở các vị trí khác nhau và xem GetEndpoint() trả về gì. Nếu nó trả (chua xac dinh), bạn đang ở trước UseRouting — và mọi middleware phụ thuộc endpoint đặt ở đây sẽ không hoạt động.

Đây là cách chẩn đoán nhanh nhất cho mọi sự cố liên quan tới thứ tự pipeline.

8.12.7 — Rà lại code của bạn ​

Danh sách rà soát pipeline

  • UseExceptionHandler nằm ngoài cùng.
  • UseRouting đứng trước UseAuthentication và UseAuthorization.
  • UseCors đứng sau UseRouting và trước UseAuthentication.
  • UseAuthentication đứng trước UseAuthorization.
  • Middleware cần thông tin người dùng đặt sau UseAuthentication.
  • Middleware tuỳ chỉnh luôn gọi next trừ khi cố ý dừng.
  • Service scoped nhận qua tham số InvokeAsync, không qua constructor.
  • Có test kiểm tra endpoint công khai, health check và CORS preflight.

Bài tập áp dụng ​

Bài 1 — Tái hiện sự cố ​

Đổi UseAuthorization lên trước UseRouting và gọi một endpoint có [AllowAnonymous].

Tiêu chí hoàn thành: bạn thấy [AllowAnonymous] bị bỏ qua, và giải thích được vì sao bằng một câu về GetEndpoint().

Gợi ý và lời giải — Bài 1

Gợi ý. UseAuthorization cần biết endpoint nào sắp chạy để đọc thuộc tính của nó. Ai cung cấp thông tin đó?

Lời giải — thứ tự sai:

csharp
var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();      // <- TRƯỚC UseRouting
app.UseRouting();
app.MapControllers();
bash
curl -i http://localhost:5000/api/cong-khai/trang-thai
text
HTTP/1.1 401 Unauthorized

Endpoint có [AllowAnonymous] mà vẫn trả 401.

csharp
[AllowAnonymous]
[HttpGet("/api/cong-khai/trang-thai")]
public IActionResult TrangThai() => Ok(new { ok = true });

Vì sao — một câu:

text
UseAuthorization chạy TRƯỚC UseRouting, nên lúc đó GetEndpoint() trả về null,
và không có endpoint thì cũng không có thuộc tính [AllowAnonymous] để đọc.

Diễn giải đầy đủ:

text
UseRouting: so khớp URL với bảng route -> gán Endpoint vào HttpContext
UseAuthorization: đọc HttpContext.GetEndpoint() -> lấy metadata phân quyền
                  -> thấy [Authorize] hay [AllowAnonymous] thì xử theo

Đảo thứ tự -> UseAuthorization thấy endpoint = null
           -> không có metadata -> áp chính sách MẶC ĐỊNH
           -> nếu chính sách mặc định yêu cầu đăng nhập -> 401 cho mọi request

Và đây là biến thể NGUY HIỂM hơn của cùng lỗi:

csharp
// Chính sách mặc định KHÔNG yêu cầu đăng nhập
builder.Services.AddAuthorization();

app.UseAuthorization();      // trước UseRouting
app.UseRouting();
text
Giờ thì không có [Authorize] nào được đọc cả
-> UseAuthorization cho qua MỌI request
-> MỌI endpoint trở thành công khai
text
Và không có lỗi nào. Không có 401. Mọi thứ "hoạt động".
-> đây là lỗi bảo mật, và nó im lặng hoàn toàn.

Trong ASP.NET Core 6 trở lên, UseRouting được gọi tự động nếu bạn quên:

csharp
var app = builder.Build();
app.UseAuthorization();      // ASP.NET Core tự chèn UseRouting TRƯỚC middleware đầu tiên
app.MapControllers();
text
Nhưng nếu bạn gọi UseRouting TƯỜNG MINH ở sai chỗ, framework tôn trọng
thứ tự của bạn -> lỗi quay lại.

-> gọi tường minh thì phải gọi đúng chỗ, hoặc đừng gọi.

Thứ tự đúng, và lý do từng vị trí:

csharp
app.UseExceptionHandler();     // ngoài cùng: bắt lỗi của mọi tầng bên trong
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();          // trả tệp tĩnh sớm, không qua xác thực
app.UseRouting();              // XÁC ĐỊNH endpoint
app.UseCors();                 // sau routing (chính sách theo endpoint), trước xác thực
app.UseAuthentication();       // "anh là ai"
app.UseAuthorization();        // "anh được làm gì" — cần endpoint từ UseRouting
app.UseRateLimiter();          // sau xác thực: giới hạn theo người dùng
app.MapControllers();          // CHẠY endpoint
text
Quy tắc gói gọn:
    UseRouting  = tìm ra sẽ chạy gì
    ...         = mọi thứ cần BIẾT sẽ chạy gì thì nằm ở đây
    MapX        = chạy nó

Bài 2 — Quan sát endpoint ​

Thêm middleware ghi log GetEndpoint() ở ba vị trí khác nhau và so sánh kết quả.

Tiêu chí hoàn thành: bạn thấy GetEndpoint() trả null ở vị trí đầu và có giá trị ở vị trí sau, và dùng được kỹ thuật này để tự kiểm tra thứ tự.

Gợi ý và lời giải — Bài 2

Gợi ý. Ghi kết quả vào HttpContext.Items rồi trả về trong phản hồi — dễ đọc hơn đi tìm trong log.

Lời giải:

csharp
app.Use(async (ctx, next) =>
{
    ctx.Items["truoc-routing"] = ctx.GetEndpoint()?.DisplayName ?? "(null)";
    await next();
});

app.UseRouting();

app.Use(async (ctx, next) =>
{
    ctx.Items["sau-routing"] = ctx.GetEndpoint()?.DisplayName ?? "(null)";
    await next();
});

app.MapGet("/vi-tri", (HttpContext ctx) => new
{
    truocRouting  = ctx.Items["truoc-routing"],
    sauRouting    = ctx.Items["sau-routing"],
    trongEndpoint = ctx.GetEndpoint()?.DisplayName ?? "(null)",
}).WithDisplayName("GET /vi-tri");

Kết quả chạy trên ASP.NET Core 9 (.NET 9.0.203):

json
{
  "truocRouting": "(null)",
  "sauRouting": "GET /vi-tri",
  "trongEndpoint": "GET /vi-tri"
}

Một dòng dữ liệu, và nó giải thích toàn bộ bài 1:

text
truocRouting = null  -> mọi middleware nằm trước UseRouting đều KHÔNG biết
                        endpoint nào sắp chạy
                     -> không đọc được [Authorize], [AllowAnonymous],
                        [EnableCors], hay bất kỳ metadata nào

Dùng kỹ thuật này để tự kiểm tra thứ tự trong dự án của mình:

csharp
#if DEBUG
app.Use(async (ctx, next) =>
{
    if (ctx.GetEndpoint() is null && ctx.Request.Path.StartsWithSegments("/api"))
        ctx.RequestServices.GetRequiredService<ILogger<Program>>()
           .LogWarning("Middleware tại vị trí này chưa biết endpoint — nằm trước UseRouting");
    await next();
});
#endif

Và một cách xem toàn bộ ống dẫn, hữu ích khi nhận một dự án lạ:

csharp
// Liệt kê mọi endpoint đã đăng ký kèm metadata của chúng
app.MapGet("/debug/endpoints", (EndpointDataSource nguon) =>
    nguon.Endpoints.Select(e => new
    {
        ten = e.DisplayName,
        metadata = e.Metadata.Select(m => m.GetType().Name).Distinct(),
    }));
json
[
  {
    "ten": "GET /api/leads",
    "metadata": ["HttpMethodMetadata", "AuthorizeAttribute", "EndpointNameMetadata"]
  },
  {
    "ten": "GET /api/cong-khai/trang-thai",
    "metadata": ["HttpMethodMetadata", "AllowAnonymousAttribute"]
  }
]
text
Endpoint này chỉ nên bật ở môi trường phát triển — nó phơi bày
toàn bộ bề mặt API và chính sách phân quyền của từng endpoint.

Ba câu hỏi mà GetEndpoint() trả lời được ngay:

text
1. "Vì sao [Authorize] của tôi không có tác dụng?"
   -> middleware phân quyền có nằm sau UseRouting không?

2. "Vì sao CORS không hoạt động cho preflight?"
   -> UseCors có nằm sau UseRouting và trước UseAuthentication không?

3. "Vì sao rate limiter đếm theo IP thay vì theo người dùng?"
   -> UseRateLimiter có nằm sau UseAuthentication không?

Cả ba đều là cùng một lỗi ở ba vị trí khác nhau, và cả ba đều nhìn thấy được bằng một dòng GetEndpoint().


Bài 3 — Viết test bảo vệ ​

Thêm ba test ở mục 8.12.5 vào dự án và xác nhận chúng thất bại khi bạn xáo thứ tự.

Tiêu chí hoàn thành: test thất bại khi bạn cố tình xáo thứ tự, và bạn biết vì sao test kiểm tra hành vi đáng tin hơn test kiểm tra thứ tự.

Gợi ý và lời giải — Bài 3

Gợi ý. Một test chỉ có giá trị khi bạn đã thấy nó thất bại. Xáo thứ tự trước, chạy test, rồi mới sửa lại.

Lời giải — ba test:

csharp
public class ThuTuMiddlewareTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Endpoint_cong_khai_khong_bi_chan()
    {
        var res = await factory.CreateClient().GetAsync("/api/cong-khai/trang-thai");

        res.StatusCode.Should().Be(HttpStatusCode.OK,
            "[AllowAnonymous] chỉ đọc được khi UseAuthorization nằm SAU UseRouting");
    }

    [Fact]
    public async Task Endpoint_can_dang_nhap_tra_401_khi_khong_co_token()
    {
        var res = await factory.CreateClient().GetAsync("/api/leads");

        res.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
            "nếu trả 200, UseAuthorization đang không đọc được [Authorize]");
    }

    [Fact]
    public async Task Preflight_khong_bi_chan_boi_xac_thuc()
    {
        var req = new HttpRequestMessage(HttpMethod.Options, "/api/leads");
        req.Headers.Add("Origin", "https://crm.vn");
        req.Headers.Add("Access-Control-Request-Method", "POST");

        var res = await factory.CreateClient().SendAsync(req);

        res.StatusCode.Should().Be(HttpStatusCode.NoContent,
            "preflight không mang token, nên UseCors phải trả lời trước UseAuthentication");
        res.Headers.Should().ContainKey("Access-Control-Allow-Origin");
    }
}

Xáo thứ tự rồi chạy — cả ba phải đỏ đúng như dự đoán:

text
Đổi UseAuthorization lên trước UseRouting:
    Endpoint_cong_khai_khong_bi_chan          FAILED (401, mong đợi 200)
    Endpoint_can_dang_nhap_tra_401...         PASSED (đúng vì lý do sai)
    Preflight_khong_bi_chan...                FAILED (401, mong đợi 204)

Đổi UseCors xuống sau UseAuthentication:
    Preflight_khong_bi_chan...                FAILED (401)
    Hai test kia                              PASSED

Test thứ hai đáng chú ý: nó PASS trong cả hai trường hợp sai.

text
Với thứ tự sai, mọi request đều 401 -> /api/leads cũng 401 -> test xanh
-> một test xanh vì lý do hoàn toàn khác với lý do nó được viết
text
Bài học: một test xanh không chứng minh hệ thống đúng.
Bộ BA test mới phân biệt được "đúng" với "sai theo cách tình cờ khớp".

Và đây là điều quan trọng hơn — vì sao test HÀNH VI đáng tin hơn test THỨ TỰ:

csharp
// Test THỨ TỰ — trông chặt chẽ, nhưng giòn
[Fact]
public void UseCors_phai_nam_truoc_UseAuthentication()
{
    var nguon = File.ReadAllText("Program.cs");
    var viTriCors = nguon.IndexOf("UseCors");
    var viTriAuth = nguon.IndexOf("UseAuthentication");

    viTriCors.Should().BeLessThan(viTriAuth);
}
text
Vấn đề:
    - đổi tên file -> test hỏng
    - chuyển đăng ký sang một extension method -> test không thấy gì
    - có hai lời gọi UseCors -> IndexOf lấy nhầm cái đầu
    - và nó KHÔNG kiểm tra hệ thống có hoạt động đúng không,
      chỉ kiểm tra thứ tự chữ trong một file văn bản
text
Test hành vi kiểm tra thứ THẬT SỰ quan trọng:
    "preflight có nhận được 204 không"
-> đúng bất kể ống dẫn được viết thế nào
-> và vẫn đúng khi đội chuyển sang Minimal API, hay tách sang module

Và cách làm ba test này chạy nhanh — dùng WebApplicationFactory một lần:

csharp
public class ThuTuMiddlewareTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
text
IClassFixture dựng host MỘT lần cho cả lớp test
-> ba test chạy trong khoảng một giây
-> đủ nhanh để chạy ở mỗi pull request

Ba test nữa đáng thêm vào cùng nhóm:

csharp
[Fact] // Lỗi không rò rỉ chi tiết nội bộ
public async Task Loi_he_thong_khong_lo_chuoi_ket_noi()
{
    var res = await _client.GetAsync("/loi/he-thong");
    var body = await res.Content.ReadAsStringAsync();

    body.Should().NotContainAny("Server=", "Password=", "StackTrace", "at Crm.");
}

[Fact] // Rate limit đếm theo người dùng, không theo IP
public async Task Rate_limit_ap_theo_nguoi_dung() { /* ... */ }

[Fact] // Tệp tĩnh không đi qua xác thực
public async Task Tep_tinh_tra_ve_khong_can_token()
{
    (await _client.GetAsync("/favicon.ico")).StatusCode.Should().Be(HttpStatusCode.OK);
}

Sáu test này cùng nhau khoá lại toàn bộ thứ tự ống dẫn — và khi một trong số chúng đỏ, thông điệp lỗi chỉ thẳng vào middleware nào đang nằm sai chỗ.

Tự kiểm tra ​

Vì sao UseAuthorization phải đứng sau UseRouting?

Vì nó cần biết endpoint nào đang được gọi để đọc thuộc tính Authorize hoặc AllowAnonymous trên endpoint đó, và thông tin này do UseRouting xác định. Đặt trước thì nó áp dụng chính sách mặc định cho mọi request.

Vì sao UseCors phải đứng trước UseAuthentication?

Vì request preflight OPTIONS không mang token. Nếu xác thực chạy trước, nó từ chối request đó và phản hồi 401 không có header CORS, nên trình duyệt báo lỗi CORS.

Vì sao UseExceptionHandler phải ở ngoài cùng?

Vì nó chỉ bắt được lỗi của những middleware nằm bên trong nó. Đặt ở giữa pipeline thì lỗi trong các middleware phía trước không được bắt và client nhận trang lỗi mặc định của server.

Vì sao không tiêm service scoped vào constructor của middleware?

Vì middleware được tạo một lần lúc khởi động, tức nó là singleton. Tiêm scoped vào constructor là captive dependency. Phải nhận nó qua tham số của InvokeAsync, nơi ASP.NET Core tiêm từ scope của request hiện tại.

Ba lỗi hay gặp khi viết middleware tuỳ chỉnh là gì?

Quên gọi next khiến pipeline dừng và client nhận phản hồi rỗng, ghi vào body sau khi đã gọi next gây lỗi headers read-only, và tiêm service scoped vào constructor.

Cách chẩn đoán nhanh nhất cho sự cố thứ tự pipeline là gì?

Thêm một middleware ghi log GetEndpoint tại vị trí nghi ngờ. Nếu nó trả về chưa xác định thì bạn đang ở trước UseRouting, và mọi middleware phụ thuộc endpoint đặt ở đó sẽ không hoạt động.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. Pipeline chạy đúng theo thứ tự đăng ký, và đặt sai chỗ thường không báo lỗi.
  2. UseRouting trước, rồi CORS, rồi xác thực, rồi phân quyền. Thứ tự này có lý do cụ thể.
  3. Ba test đơn giản bảo vệ được cả pipeline khỏi việc bị xáo trong lần dọn dẹp tiếp theo.

Tham khảo ​

Điều hướng ​

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