Search

Authentication vs Authorization Trong .NET: Phân Biệt Đúng Cách Và Áp Dụng Thực Tế

Authentication vs Authorization Trong .NET: Phân Biệt Đúng Cách Và Áp Dụng Thực Tế

Trong hơn nhiều năm làm việc với các hệ thống .NET, đặc biệt là hệ thống multi-tenant, mình nhận ra một điều: phần lớn lỗ hổng bảo mật không đến từ những kỹ thuật hack phức tạp, mà đến từ việc developer hiểu sai — hoặc hiểu mơ hồ — hai khái niệm nền tảng: AuthenticationAuthorization.

Hai khái niệm này nghe quen thuộc, nhưng khi vào code thực tế, ranh giới giữa chúng bị xóa nhòa rất nhanh. Mình từng review code của một junior trong team, thấy anh ấy check role ngay trong controller mà chưa verify token — nghĩa là ai cũng có thể gửi một request với header role: admin và hệ thống tin luôn. Đó là hậu quả của việc gộp Authentication và Authorization thành một bước.

Bài viết này sẽ đi từ khái niệm đến code thực tế bằng ASP.NET Core + JWT, bao gồm cả những tình huống mà documentation ít khi đề cập.

Authentication — Xác minh "Bạn là ai?"

Authentication là bước đầu tiên: hệ thống cần biết người gửi request là ai. Không phải họ được phép làm gì — chỉ đơn giản là xác nhận danh tính.

Trong thực tế, bước này thường diễn ra khi người dùng đăng nhập bằng email/password, khi client gửi request kèm JWT token, hoặc khi hệ thống xác thực qua OAuth (Google, Microsoft, GitHub...).

Điểm mấu chốt cần nhớ: Authentication chỉ trả lời một câu hỏi duy nhất — "Người này có đúng là người họ tự xưng không?" Nó không quan tâm người đó là admin hay user thường, có quyền xóa dữ liệu hay không.

Triển khai Authentication trong ASP.NET Core

Đầu tiên, cấu hình JWT Authentication trong Program.cs:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
        };
    });

Một sai lầm mình thấy khá nhiều: để ValidateLifetime = false trong lúc dev rồi quên bật lại khi deploy. Kết quả là token hết hạn vẫn dùng được — tương đương với việc chìa khóa cũ mãi mãi mở được cửa.

Tạo JWT Token khi đăng nhập

public class AuthService
{
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly IConfiguration _config;

    public AuthService(UserManager<ApplicationUser> userManager, IConfiguration config)
    {
        _userManager = userManager;
        _config = config;
    }

    public async Task<string?> LoginAsync(string email, string password)
    {
        var user = await _userManager.FindByEmailAsync(email);
        if (user == null)
            return null;

        var isValid = await _userManager.CheckPasswordAsync(user, password);
        if (!isValid)
            return null;

        var roles = await _userManager.GetRolesAsync(user);

        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, user.Id),
            new(ClaimTypes.Email, user.Email!),
            new("TenantId", user.TenantId) // Quan trọng cho multi-tenant
        };

        // Thêm tất cả roles vào claims
        foreach (var role in roles)
        {
            claims.Add(new Claim(ClaimTypes.Role, role));
        }

        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));

        var token = new JwtSecurityToken(
            issuer: _config["Jwt:Issuer"],
            audience: _config["Jwt:Audience"],
            claims: claims,
            expires: DateTime.UtcNow.AddHours(1),
            signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
        );

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

Có một chi tiết đáng chú ý ở đây: mình thêm TenantId vào claims. Trong hệ thống multi-tenant, đây là thông tin cực kỳ quan trọng — nó cho phép Authorization sau này kiểm tra không chỉ "user này có quyền gì" mà còn "user này thuộc tenant nào và có được truy cập dữ liệu của tenant khác không."

Controller đăng nhập

[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
    private readonly AuthService _authService;

    public AuthController(AuthService authService)
    {
        _authService = authService;
    }

    [HttpPost("login")]
    public async Task<IActionResult> Login([FromBody] LoginRequest request)
    {
        var token = await _authService.LoginAsync(request.Email, request.Password);

        if (token == null)
            return Unauthorized(new { message = "Email hoặc mật khẩu không đúng" });

        return Ok(new { token });
    }
}

Lưu ý: mình không trả về message cụ thể kiểu "Email không tồn tại" hay "Sai mật khẩu" — vì điều đó giúp attacker biết được email nào đã đăng ký trong hệ thống. Chỉ trả về một message chung là đủ.

Authorization — Xác định "Bạn được phép làm gì?"

Sau khi biết user là ai (Authentication), bước tiếp theo là xác định họ được phép làm gì. Đây mới là phần phức tạp thực sự, đặc biệt khi hệ thống lớn dần.

Authorization thường dựa trên ba cách tiếp cận chính:

Role-based — đơn giản nhất: admin được làm mọi thứ, user chỉ được xem. Phù hợp cho ứng dụng nhỏ, ít phân quyền.

Permission-based (Policy-based) — linh hoạt hơn: thay vì gắn quyền theo role, bạn định nghĩa các permission cụ thể (CanDeleteUser, CanEditInvoice...) rồi gán cho từng role hoặc từng user. Đây là hướng mình khuyên dùng cho hệ thống vừa và lớn.

Resource-based — chi tiết nhất: kiểm tra quyền dựa trên chính resource đang truy cập. Ví dụ: "User này có phải owner của invoice này không? Invoice này có thuộc tenant của user không?"

Trong thực tế, hệ thống production thường kết hợp cả ba.

Role-based Authorization

Cách đơn giản nhất trong ASP.NET Core:

[Authorize(Roles = "Admin")]
[HttpDelete("users/{id}")]
public async Task<IActionResult> DeleteUser(string id)
{
    // Chỉ Admin mới vào được đây
    await _userService.DeleteAsync(id);
    return Ok(new { message = "Đã xóa user" });
}

Cách này hoạt động tốt khi hệ thống chỉ có 2-3 role đơn giản. Nhưng khi bạn bắt đầu có các yêu cầu kiểu "Moderator được xóa comment nhưng không được xóa user", "Editor được sửa bài nhưng không được publish" — lúc đó role-based thuần túy sẽ trở nên cồng kềnh rất nhanh.

Policy-based Authorization — Cách mình khuyên dùng

Đăng ký policy trong Program.cs:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanDeleteUser", policy =>
        policy.RequireRole("Admin"));

    options.AddPolicy("CanEditInvoice", policy =>
        policy.RequireAssertion(context =>
        {
            var roles = context.User.FindAll(ClaimTypes.Role).Select(c => c.Value);
            return roles.Contains("Admin") || roles.Contains("Accountant");
        }));

    options.AddPolicy("SameTenant", policy =>
        policy.AddRequirements(new SameTenantRequirement()));
});

Sử dụng trong controller:

[Authorize(Policy = "CanEditInvoice")]
[HttpPut("invoices/{id}")]
public async Task<IActionResult> UpdateInvoice(int id, [FromBody] UpdateInvoiceDto dto)
{
    // Chỉ Admin hoặc Accountant mới vào được
    await _invoiceService.UpdateAsync(id, dto);
    return Ok();
}

Ưu điểm lớn nhất của policy-based: khi cần thay đổi quyền, bạn chỉ sửa ở một chỗ (nơi đăng ký policy) thay vì tìm khắp các controller để sửa [Authorize(Roles = "...")].

Resource-based Authorization — Phần hay nhất và cũng phức tạp nhất

Đây là tình huống thực tế mà mình gặp liên tục: "User chỉ được sửa invoice của chính mình, và chỉ trong phạm vi tenant của mình."

Tạo Authorization Handler:

public class InvoiceOwnerRequirement : IAuthorizationRequirement { }

public class InvoiceOwnerHandler
    : AuthorizationHandler<InvoiceOwnerRequirement, Invoice>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        InvoiceOwnerRequirement requirement,
        Invoice invoice)
    {
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        var userTenantId = context.User.FindFirst("TenantId")?.Value;

        // Admin bypass — nhưng vẫn phải cùng tenant
        if (context.User.IsInRole("Admin") && invoice.TenantId == userTenantId)
        {
            context.Succeed(requirement);
            return Task.CompletedTask;
        }

        // User thường: phải là owner VÀ cùng tenant
        if (invoice.CreatedBy == userId && invoice.TenantId == userTenantId)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

Sử dụng trong controller:

[Authorize]
[HttpPut("invoices/{id}")]
public async Task<IActionResult> UpdateInvoice(int id, [FromBody] UpdateInvoiceDto dto)
{
    var invoice = await _invoiceService.GetByIdAsync(id);
    if (invoice == null)
        return NotFound();

    var authResult = await _authorizationService
        .AuthorizeAsync(User, invoice, new InvoiceOwnerRequirement());

    if (!authResult.Succeeded)
        return Forbid();

    await _invoiceService.UpdateAsync(id, dto);
    return Ok();
}

Đoạn code trên kiểm tra hai điều cùng lúc: user có phải owner của invoice không, và invoice có thuộc tenant của user không. Thiếu một trong hai điều kiện đều bị chặn. Đây là lớp bảo vệ mà nếu thiếu trong hệ thống multi-tenant, user của công ty A hoàn toàn có thể sửa invoice của công ty B — chỉ cần biết được ID.


Những sai lầm thực tế mình đã gặp

Sai lầm 1: Check quyền ở frontend rồi nghĩ là xong

// Angular component — CHỈ LÀ UI, KHÔNG PHẢI BẢO MẬT
@Component({
  template: `
    <button *ngIf="user.role === 'Admin'" (click)="deleteUser()">
      Xóa User
    </button>
  `
})

Ẩn nút "Xóa" trên giao diện là tốt cho UX, nhưng không phải bảo mật. Bất kỳ ai cũng có thể mở DevTools, gửi request thẳng đến API mà không cần qua giao diện. Mọi logic Authorization bắt buộc phải nằm ở backend.

Sai lầm 2: Tin tưởng thông tin role từ client

Mình từng thấy code kiểu này:

// SAI — role lấy từ request body, client muốn gửi gì cũng được
[HttpDelete("users/{id}")]
public IActionResult DeleteUser(string id, [FromBody] DeleteRequest request)
{
    if (request.Role != "Admin")
        return Forbid();

    // ...
}

Role phải lấy từ JWT token đã được verify ở server, không bao giờ tin bất kỳ dữ liệu nào client gửi lên.

Sai lầm 3: Quên kiểm tra tenant trong multi-tenant

Đây là lỗi nguy hiểm nhất mà mình từng gặp. Code check đúng role, check đúng permission, nhưng quên check tenant:

// THIẾU — chỉ check quyền, không check tenant
[Authorize(Roles = "Admin")]
[HttpGet("invoices/{id}")]
public async Task<IActionResult> GetInvoice(int id)
{
    var invoice = await _context.Invoices.FindAsync(id);
    return Ok(invoice); // Admin tenant A xem được invoice tenant B!
}

Fix đúng:

[Authorize(Roles = "Admin")]
[HttpGet("invoices/{id}")]
public async Task<IActionResult> GetInvoice(int id)
{
    var tenantId = User.FindFirst("TenantId")?.Value;

    var invoice = await _context.Invoices
        .FirstOrDefaultAsync(i => i.Id == id && i.TenantId == tenantId);

    if (invoice == null)
        return NotFound();

    return Ok(invoice);
}

Nếu bạn dùng EF Core Global Query Filter cho multi-tenancy, bộ lọc tenant sẽ được áp dụng tự động — nhưng vẫn nên hiểu rõ cơ chế bên dưới thay vì phó mặc hoàn toàn cho framework.

Sai lầm 4: Token không có expiry hoặc expiry quá dài

Mình từng thấy hệ thống set token expiry là 30 ngày. Nếu token bị lộ, attacker có 30 ngày thoải mái truy cập. Khuyến nghị thực tế: access token nên có thời hạn ngắn (15-60 phút) kết hợp với refresh token để lấy access token mới khi hết hạn.

Tổng hợp luồng xử lý chuẩn

Mọi request đến API đều đi qua pipeline sau theo đúng thứ tự:

Bước 1 là Authentication: middleware verify JWT token, nếu token không hợp lệ hoặc hết hạn thì trả về 401 Unauthorized ngay, không đi tiếp.

Bước 2 là Authorization: kiểm tra role, policy, hoặc resource-based permission. Nếu user không có quyền thì trả về 403 Forbidden.

Bước 3 là Controller logic: chỉ khi cả hai bước trên đều pass thì code xử lý business logic mới được chạy.

Nguyên tắc bắt buộc: không bao giờ xét quyền khi chưa xác thực. Nếu bạn không biết người gửi request là ai thì việc check họ có quyền gì là vô nghĩa.


Khi nào dùng Role-based, khi nào dùng Policy-based?

Không có câu trả lời đúng tuyệt đối, nhưng theo kinh nghiệm thực tế: nếu ứng dụng nhỏ, dưới 3 role và quyền hạn rõ ràng (admin làm mọi thứ, user chỉ xem) thì role-based là đủ, đừng over-engineer. Khi ứng dụng bắt đầu có yêu cầu phân quyền chi tiết hơn, chuyển sang policy-based sớm — migrate sau sẽ rất đau. Với hệ thống multi-tenant thì resource-based authorization gần như bắt buộc, vì bạn cần kiểm tra cả ownership lẫn tenant scope trên từng resource cụ thể.


Kết luận

Authentication trả lời "Bạn là ai?", Authorization trả lời "Bạn được phép làm gì?" Hai câu hỏi này phải được xử lý tách biệt và luôn theo đúng thứ tự.

Trong thực tế, phần lớn lỗ hổng bảo mật không đến từ Authentication yếu mà đến từ Authorization thiếu — check role nhưng quên check tenant, check permission nhưng quên check resource ownership, hoặc tệ nhất là chỉ check ở frontend.

Nếu bạn đang xây dựng hệ thống mới, hãy đầu tư thời gian thiết kế Authorization đúng từ đầu. Việc refactor lại authorization sau khi hệ thống đã có hàng trăm endpoint là một trong những việc khó chịu nhất mà mình từng phải làm.

Culi Dev

Culi Dev

Enjoy coding, enjoy life!

Leave a comment

Your email address will not be published. Required fields are marked *

Your experience on this site will be improved by allowing cookies Cookie Policy