Skip to content

10.8 - ASP.NET Core Identity (Overview) ​

Tóm tắt: ASP.NET Core Identity không phải "hệ thống đăng nhập bằng cookie" mà là một bộ thư viện quản lý người dùng — băm mật khẩu, khoá tài khoản, xác nhận email, đặt lại mật khẩu, 2FA. Bạn dùng được những phần đó mà không dùng cookie: AddIdentityCore cho bạn UserManager mà không kéo theo cookie authentication, còn AddDefaultIdentity thì kéo theo cả cookie lẫn Razor Pages — thừa và gây nhầm lẫn cho một API JWT. Thứ đáng giá nhất là băm mật khẩu: PBKDF2 với 100.000 vòng lặp và salt riêng cho mỗi người dùng, cộng cơ chế nâng cấp thuật toán tự động. Và SecurityStamp là chìa khoá cho thứ JWT không làm được: vô hiệu hoá mọi phiên đăng nhập ngay lập tức.

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

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

  • Chọn đúng giữa AddIdentityCore và AddDefaultIdentity.
  • Giải thích vì sao không tự viết băm mật khẩu.
  • Cấu hình lockout chống dò mật khẩu.
  • Dùng SecurityStamp để vô hiệu hoá phiên.
  • Sinh và dùng token xác nhận email, đặt lại mật khẩu.

Nội dung bài học ​

10.8.1 — AddIdentityCore cho API JWT ​

csharp
builder.Services
    .AddIdentityCore<AppUser>(options =>
    {
        options.User.RequireUniqueEmail = true;

        options.Password.RequiredLength         = 12;
        options.Password.RequireDigit           = true;
        options.Password.RequireUppercase       = true;
        options.Password.RequireNonAlphanumeric = false;

        options.Lockout.MaxFailedAccessAttempts = 5;
        options.Lockout.DefaultLockoutTimeSpan  = TimeSpan.FromMinutes(15);
        options.Lockout.AllowedForNewUsers      = true;

        options.SignIn.RequireConfirmedEmail = true;
    })
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<AppDbContext>()
    .AddDefaultTokenProviders();     // cho xác nhận email, đặt lại mật khẩu
AddIdentityCoreAddDefaultIdentity
UserManager, RoleManagerCóCó
Cookie authenticationKhôngCó
Razor Pages đăng nhập sẵnKhôngCó
Hợp vớiAPI JWTỨng dụng web có giao diện server

AddDefaultIdentity trong một API JWT gây hai vấn đề: nó đăng ký cookie scheme làm mặc định, nên [Authorize] có thể redirect tới /Account/Login thay vì trả 401 — một lỗi rất khó hiểu khi mới gặp. Và nó kéo theo Razor Pages mà bạn không bao giờ dùng.

AddDefaultTokenProviders() hay bị quên, và thiếu nó thì GeneratePasswordResetTokenAsync ném exception lúc chạy.

RequiredLength = 12 thay vì mặc định 6: độ dài là yếu tố quan trọng nhất của mật khẩu. Ngược lại, RequireNonAlphanumeric bị tắt có chủ đích — yêu cầu ký tự đặc biệt khiến người dùng chọn Password1! thay vì một cụm từ dài, mà cụm từ dài thì mạnh hơn nhiều. Đây cũng là khuyến nghị của NIST.

csharp
public sealed class AppUser : IdentityUser
{
    public string? FullName  { get; set; }
    public string? Territory { get; set; }
    public DateTime CreatedAt { get; set; }
}

10.8.2 — Băm mật khẩu: đừng tự viết ​

csharp
// SAI — SHA-256 KHÔNG phải hàm băm mật khẩu
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(password));

SHA-256 được thiết kế để nhanh — GPU hiện đại tính hàng tỷ hash mỗi giây, nên một bảng mật khẩu bị lộ sẽ bị bẻ trong vài giờ. Hàm băm mật khẩu phải cố ý chậm.

Identity dùng PBKDF2-HMAC-SHA256 với 100.000 vòng lặp và salt 128 bit riêng cho mỗi người dùng:

csharp
var user = new AppUser { UserName = email, Email = email };
var result = await _userManager.CreateAsync(user, password);   // tu bam

if (!result.Succeeded)
    return BadRequest(result.Errors.Select(e => e.Description));

Salt riêng cho mỗi người dùng nghĩa là hai người cùng mật khẩu vẫn có hash khác nhau, nên rainbow table vô dụng và kẻ tấn công phải bẻ từng tài khoản một.

Điểm ít người biết: mỗi hash có byte định dạng ở đầu ghi thuật toán và số vòng lặp. Khi Microsoft nâng tham số, Identity tự băm lại mật khẩu bằng tham số mới ở lần đăng nhập kế tiếp — bạn không phải làm gì.

Muốn chặt hơn nữa (Argon2id, bcrypt) thì cài IPasswordHasher<AppUser> riêng. Nhưng mặc định của Identity đủ tốt cho gần như mọi hệ thống.

10.8.3 — Lockout ​

csharp
public async Task<Result<TokenPair>> LoginAsync(
    string email, string password, CancellationToken ct)
{
    var user = await _userManager.FindByEmailAsync(email);
    if (user is null)
        return Result.Fail("Email hoặc mật khẩu không đúng");

    if (await _userManager.IsLockedOutAsync(user))
        return Result.Fail("Tài khoản tạm khoá, vui lòng thử lại sau");

    if (!await _userManager.CheckPasswordAsync(user, password))
    {
        await _userManager.AccessFailedAsync(user);          // tang bo dem
        return Result.Fail("Email hoặc mật khẩu không đúng");
    }

    await _userManager.ResetAccessFailedCountAsync(user);     // thành công -> reset

    if (_identityOptions.SignIn.RequireConfirmedEmail && !user.EmailConfirmed)
        return Result.Fail("Vui lòng xác nhận email trước khi đăng nhập");

    return Result.Success(await _tokens.IssuePairAsync(user, ct));
}

Ba chi tiết:

AccessFailedAsync phải được gọi thủ công khi bạn tự viết luồng đăng nhập. CheckPasswordAsync không tự tăng bộ đếm — nhiều người tưởng có và kết quả là lockout không bao giờ kích hoạt. (SignInManager.PasswordSignInAsync thì có, nhưng nó gắn với cookie.)

Thông báo giống nhau cho email không tồn tại và mật khẩu sai — bài 10.2.

Lockout không thay thế rate limit. Lockout bảo vệ một tài khoản khỏi bị dò mật khẩu; rate limit bảo vệ hệ thống khỏi tấn công thử một mật khẩu phổ biến trên hàng nghìn tài khoản (password spraying) — kiểu tấn công mà lockout không thấy gì vì mỗi tài khoản chỉ sai một lần. Cần cả hai (bài 9.6).

10.8.4 — SecurityStamp: vô hiệu hoá mọi phiên ​

IdentityUser có cột SecurityStamp — một GUID đổi mỗi khi có thay đổi ảnh hưởng bảo mật: đổi mật khẩu, gỡ vai trò, bật/tắt 2FA, gỡ external login.

csharp
await _userManager.UpdateSecurityStampAsync(user);

Đây là công cụ giải quyết đúng giới hạn lớn nhất của JWT (bài 10.2) — thu hồi tức thì:

csharp
// 1. Nhet stamp vao token luc phat
claims.Add(new Claim("sstamp", await _userManager.GetSecurityStampAsync(user)));

// 2. Đối chiếu khi xác thực token
options.Events = new JwtBearerEvents
{
    OnTokenValidated = async ctx =>
    {
        var userManager = ctx.HttpContext.RequestServices
            .GetRequiredService<UserManager<AppUser>>();

        var userId = ctx.Principal!.FindFirstValue(ClaimTypes.NameIdentifier);
        var tokenStamp = ctx.Principal.FindFirstValue("sstamp");

        var user = userId is null ? null : await userManager.FindByIdAsync(userId);

        if (user is null || user.SecurityStamp != tokenStamp)
            ctx.Fail("Phiên đăng nhập không còn hiệu lực");
    }
};

Đổi mật khẩu là mọi token cũ chết ngay lập tức, trên mọi thiết bị.

Cái giá: một truy vấn database cho mỗi request. Thực tế nên cache stamp trong Redis với TTL ngắn (30–60 giây) và xoá khi stamp đổi — giống cách cache quyền ở bài 10.7. Khi đó cửa sổ rủi ro là 60 giây thay vì 15 phút.

10.8.5 — Token cho email và đặt lại mật khẩu ​

csharp
// Xác nhận email
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var encoded = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
var link = $"{_options.ClientUrl}/confirm-email?userId={user.Id}&token={encoded}";
await _email.SendAsync(user.Email!, "Xác nhận email", link, ct);

// Dat lai mat khau
var resetToken = await _userManager.GeneratePasswordResetTokenAsync(user);
// ... gửi link tương tự ...

// Xác nhận
var decoded = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(encodedToken));
var result = await _userManager.ResetPasswordAsync(user, decoded, newPassword);

if (result.Succeeded)
{
    await _userManager.UpdateSecurityStampAsync(user);        // giet moi phien cu
    await _refreshTokens.RevokeAllForUserAsync(user.Id, "password-changed", ct);
}

Mã hoá URL là bắt buộc. Token chứa +, /, = — trong query string, + bị hiểu là dấu cách và token hỏng. Đây là lỗi kinh điển "link đặt lại mật khẩu báo token không hợp lệ" mà nguyên nhân hoàn toàn không liên quan tới Identity.

Endpoint quên mật khẩu phải trả kết quả giống nhau dù email có tồn tại hay không:

csharp
[HttpPost("forgot-password")]
[AllowAnonymous]
[EnableRateLimiting("forgot-password")]
public async Task<IActionResult> ForgotPassword(ForgotPasswordRequest request, CancellationToken ct)
{
    var user = await _userManager.FindByEmailAsync(request.Email);

    if (user is not null && await _userManager.IsEmailConfirmedAsync(user))
        await SendResetLinkAsync(user, ct);

    // LUÔN trả về như nhau
    return Ok(new { message = "Nếu email tồn tại, chúng tôi đã gửi hướng dẫn." });
}

Trả 404 cho email không tồn tại biến endpoint này thành công cụ dò tài khoản.

Token mặc định sống 1 ngày — quá dài cho đặt lại mật khẩu:

csharp
builder.Services.Configure<DataProtectionTokenProviderOptions>(o =>
    o.TokenLifespan = TimeSpan.FromHours(2));

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

Danh sách rà soát Identity

  • API JWT dùng AddIdentityCore, không dùng AddDefaultIdentity.
  • AddDefaultTokenProviders() được gọi nếu dùng xác nhận email hay đặt lại mật khẩu.
  • Không tự viết băm mật khẩu; dùng IPasswordHasher của Identity.
  • Độ dài mật khẩu tối thiểu từ 12 ký tự.
  • AccessFailedAsync được gọi thủ công trong luồng đăng nhập tự viết.
  • Có cả lockout và rate limit, hiểu rằng chúng chống hai kiểu tấn công khác nhau.
  • SecurityStamp được đối chiếu để thu hồi phiên ngay lập tức.
  • Việc đối chiếu SecurityStamp có cache để không truy vấn mỗi request.
  • Token trong URL được Base64Url encode.
  • Endpoint quên mật khẩu trả kết quả giống nhau dù email tồn tại hay không.
  • Đổi mật khẩu kéo theo cập nhật SecurityStamp và thu hồi refresh token.
  • TokenLifespan được rút ngắn so với mặc định 1 ngày.

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

Bài 1 — Lockout không kích hoạt vì thiếu một dòng ​

Viết luồng đăng nhập chỉ dùng CheckPasswordAsync, nhập sai 10 lần và xác nhận tài khoản không bị khoá. Thêm AccessFailedAsync và kiểm chứng lại.

Tiêu chí hoàn thành: bạn nêu được vì sao CheckPasswordAsync không tự đếm số lần sai, dù UserManager có đủ thông tin để làm việc đó.

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

Gợi ý. So sánh tên hai phương thức: CheckPasswordAsync và PasswordSignInAsync. Một cái kiểm tra, một cái đăng nhập — và chúng thuộc hai lớp khác nhau.

Lời giải — luồng thiếu sót:

csharp
var user = await _userManager.FindByEmailAsync(request.Email);
if (user is null) return Unauthorized();

if (!await _userManager.CheckPasswordAsync(user, request.Password))
    return Unauthorized();                      // KHÔNG ghi nhận lần sai

return Ok(await _tokens.IssueAsync(user, ct));
bash
for i in $(seq 1 10); do
  curl -s -o /dev/null -w '%{http_code} ' -X POST .../auth/login \
    -d '{"email":"[email protected]","password":"sai-mat-khau"}' \
    -H 'Content-Type: application/json'
done
echo
text
401 401 401 401 401 401 401 401 401 401
sql
SELECT AccessFailedCount, LockoutEnd FROM AspNetUsers WHERE Email = '[email protected]';
-- 0    NULL

Không đếm, không khoá. Kẻ tấn công thử được không giới hạn.

Câu trả lời cho tiêu chí. CheckPasswordAsync nằm trên UserManager và làm đúng một việc: so khớp mật khẩu với hash. Nó là một hàm thuần về mặt ý nghĩa — không có tác dụng phụ. Việc đếm lần sai, khoá tài khoản, kiểm tra xác nhận email và xử lý hai lớp thuộc về luồng đăng nhập, và luồng đó nằm ở SignInManager.

Tách như vậy là có chủ ý: CheckPasswordAsync còn được dùng ở những nơi không phải đăng nhập — ví dụ xác nhận mật khẩu trước khi đổi email — nơi mà khoá tài khoản là sai.

Hai cách sửa:

csharp
// Cách 1 — tự ghi nhận, giữ nguyên cấu trúc hiện có
if (!await _userManager.CheckPasswordAsync(user, request.Password))
{
    await _userManager.AccessFailedAsync(user);          // tăng bộ đếm, khoá nếu vượt ngưỡng
    return Unauthorized();
}

if (await _userManager.IsLockedOutAsync(user))
    return Unauthorized();                                // kiểm TRƯỚC khi phát token

await _userManager.ResetAccessFailedCountAsync(user);    // thành công -> reset
csharp
// Cách 2 — để SignInManager lo (khuyến nghị)
var result = await _signInManager.CheckPasswordSignInAsync(
    user, request.Password, lockoutOnFailure: true);

if (result.IsLockedOut) return Unauthorized();
if (!result.Succeeded)  return Unauthorized();

Cách 2 tốt hơn vì nó xử lý luôn ba thứ dễ quên: IsLockedOut, IsNotAllowed (chưa xác nhận email) và RequiresTwoFactor. Viết tay cách 1 thì phải nhớ đủ cả ba.

Chú ý lockoutOnFailure: true — mặc định là false. Đây là một mặc định gây ngạc nhiên và là lý do nhiều hệ thống có lockout cấu hình đầy đủ mà không bao giờ kích hoạt.

Kiểm chứng sau khi sửa:

text
401 401 401 401 401 401 401 401 401 401
sql
-- AccessFailedCount = 5, LockoutEnd = 2026-09-25 09:05:00

Bộ đếm dừng ở 5 vì tài khoản đã khoá từ lần thứ 5.

Cấu hình ngưỡng:

csharp
builder.Services.Configure<IdentityOptions>(o =>
{
    o.Lockout.MaxFailedAccessAttempts = 5;
    o.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
    o.Lockout.AllowedForNewUsers = true;
});

Một đánh đổi phải biết. Lockout theo tài khoản mở ra hướng tấn công từ chối dịch vụ: kẻ xấu cố tình nhập sai để khoá tài khoản người khác. Vì vậy nên kết hợp thêm giới hạn tần suất theo IP (bài 9.6), và dùng khoá tạm thời có thời hạn thay vì khoá vĩnh viễn.


Bài 2 — Token đặt lại mật khẩu hỏng vì một dấu cộng ​

Sinh token đặt lại mật khẩu, đặt thẳng vào query string không encode, và tìm một token chứa dấu +. Xác nhận nó báo không hợp lệ, rồi sửa bằng Base64UrlEncode.

Tiêu chí hoàn thành: bạn giải thích được vì sao lỗi này chỉ xảy ra với một phần người dùng, và vì sao nó khó tái hiện.

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

Gợi ý. Trong query string, + có một nghĩa đặc biệt đã có từ trước khi URL encoding được chuẩn hoá.

Lời giải — bản lỗi:

csharp
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var link = $"https://crm.company.com/reset?email={user.Email}&token={token}";
await _email.SendAsync(user.Email, "Đặt lại mật khẩu", link);

Token là Base64 chuẩn, nên bảng chữ cái của nó gồm A–Z, a–z, 0–9, +, /, =.

text
CfDJ8Nrs+kZ1vUxNnM4...aB+cD/eF=

Khi trình duyệt gửi link đó, máy chủ đọc được:

text
CfDJ8Nrs kZ1vUxNnM4...aB cD/eF=

Mọi dấu + thành dấu cách, vì trong application/x-www-form-urlencoded — định dạng của query string — + nghĩa là khoảng trắng.

csharp
var result = await _userManager.ResetPasswordAsync(user, token, newPassword);
// Succeeded = false, Errors = [InvalidToken]

Câu trả lời cho tiêu chí — token là dữ liệu ngẫu nhiên. Mỗi token có hay không có dấu + là chuyện may rủi. Với token dài khoảng 150 ký tự Base64, xác suất không chứa + là rất nhỏ, nhưng cũng có những token sạch. Kết quả: phần lớn người dùng gặp lỗi, một số thì không.

Và vì mỗi lần sinh ra một token khác nhau, thử lại có thể thành công — nên người gỡ rối dễ kết luận "chắc do mạng" rồi bỏ qua. Cùng một người dùng, cùng một đoạn code, lúc chạy lúc không.

Cách sửa:

csharp
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var encoded = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));

var link = $"https://crm.company.com/reset" +
           $"?email={Uri.EscapeDataString(user.Email)}&token={encoded}";
csharp
// Phía nhận
var token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(request.Token));
var result = await _userManager.ResetPasswordAsync(user, token, request.NewPassword);

Base64Url dùng - và _ thay cho + và /, và bỏ dấu = ở cuối — nên nó an toàn trong URL mà không cần escape thêm.

Đừng quên email cũng phải encode. Email hợp lệ có thể chứa + ([email protected] — cách gắn nhãn rất phổ biến), và nó vỡ theo đúng cùng một cách.

Ba chỗ khác cùng lớp lỗi này:

ChỗCách sửa
Token xác nhận emailBase64UrlEncode
Token mời thành viênBase64UrlEncode
Chuỗi tìm kiếm trên URLUri.EscapeDataString

Một bài test chặn được cả lớp lỗi, không phụ thuộc may rủi:

csharp
[Fact]
public async Task LinkDatLaiMatKhau_HoatDongVoiMoiToken()
{
    for (int i = 0; i < 50; i++)              // sinh nhiều token để chắc chắn gặp dấu +
    {
        var user = await CreateUserAsync($"user{i}@test.com");
        var link = await _service.BuildResetLinkAsync(user);

        var query = QueryHelpers.ParseQuery(new Uri(link).Query);
        var token = Encoding.UTF8.GetString(
            WebEncoders.Base64UrlDecode(query["token"]!));

        var result = await _userManager.ResetPasswordAsync(user, token, "MatKhauMoi123!");
        Assert.True(result.Succeeded, $"Thất bại ở lần {i}");
    }
}

Lặp 50 lần để bài test không phụ thuộc vào việc token hôm nay có chứa + hay không.


Bài 3 — SecurityStamp thu hồi phiên trên thiết bị khác ​

Cài đối chiếu stamp trong OnTokenValidated, đăng nhập trên hai thiết bị, đổi mật khẩu ở thiết bị một, và xác nhận thiết bị hai mất quyền ngay lập tức.

Tiêu chí hoàn thành: bạn nêu được cái giá phải trả để có "ngay lập tức", và khi nào cái giá đó xứng đáng.

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

Gợi ý. JWT hấp dẫn vì xác thực nó không cần chạm database. Đối chiếu stamp phá vỡ đúng tính chất đó.

Lời giải — đưa stamp vào token:

csharp
claims.Add(new Claim("security_stamp", user.SecurityStamp!));

Đối chiếu khi xác thực:

csharp
options.Events = new JwtBearerEvents
{
    OnTokenValidated = async ctx =>
    {
        var users = ctx.HttpContext.RequestServices
            .GetRequiredService<UserManager<AppUser>>();

        var userId = ctx.Principal!.FindFirstValue(ClaimTypes.NameIdentifier);
        var stampInToken = ctx.Principal.FindFirstValue("security_stamp");

        var user = userId is null ? null : await users.FindByIdAsync(userId);

        if (user is null || user.SecurityStamp != stampInToken)
            ctx.Fail("Thông tin bảo mật đã thay đổi");
    }
};

Kiểm chứng:

bash
# Thiết bị 1 và 2 cùng đăng nhập
TOKEN1=$(login); TOKEN2=$(login)

# Thiết bị 1 đổi mật khẩu — Identity tự đổi SecurityStamp
curl -s -X POST .../auth/change-password -H "Authorization: Bearer $TOKEN1" \
     -d '{"current":"...","new":"..."}'

# Thiết bị 2 gọi API ngay sau đó
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN2" .../customers
# 401

Mất quyền ngay, không chờ token hết hạn.

Cái giá: một lần chạm database cho MỖI request.

Đó là phá bỏ lý do chính người ta chọn JWT. Với 1.000 request/giây, bạn vừa thêm 1.000 truy vấn SELECT mỗi giây — chỉ để đọc một chuỗi.

Giảm giá bằng cache, nhưng phải hiểu mình đang đổi gì:

csharp
var stamp = await _cache.GetOrCreateAsync($"stamp:{userId}", async e =>
{
    e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
    return (await users.FindByIdAsync(userId))?.SecurityStamp;
});

Giờ độ trễ thu hồi là 30 giây thay vì tức thì — nhưng tải database giảm hàng trăm lần. Và bạn xoá cache được ngay khi đổi mật khẩu để lấy lại tính tức thì cho đúng trường hợp đó:

csharp
await _userManager.UpdateSecurityStampAsync(user);
await _cache.RemoveAsync($"stamp:{user.Id}");

Bảng đánh đổi đầy đủ:

CáchĐộ trễ thu hồiChạm database mỗi request
Không đối chiếuTới khi token hết hạn (15–20 phút)0
Đối chiếu, không cacheTức thì1
Đối chiếu, cache 30 giây30 giây (0 nếu xoá cache chủ động)~0
Access token 5 phút5 phút0

Khi nào cái giá xứng đáng:

  • Xứng đáng: hệ thống tài chính, y tế, hoặc bất kỳ chỗ nào mà "người vừa bị sa thải còn dùng được 15 phút" là không chấp nhận được.
  • Không xứng đáng: ứng dụng nội bộ thông thường. Access token 5 phút cộng kiểm tra trạng thái ở /refresh đã đủ, và không thêm hạ tầng gì.

Một lưu ý dễ bỏ sót. UpdateSecurityStampAsync được Identity gọi tự động khi đổi mật khẩu, nhưng không khi bạn đổi vai trò hay quyền bằng SQL trực tiếp. Nếu muốn thu hồi phiên khi gỡ quyền, phải gọi tay.

Tự kiểm tra ​

AddIdentityCore khác AddDefaultIdentity thế nào?

AddIdentityCore cho UserManager và RoleManager mà không kéo theo cookie authentication hay Razor Pages. AddDefaultIdentity kéo theo cả hai, và trong một API JWT thì nó đăng ký cookie scheme làm mặc định nên Authorize có thể redirect tới trang đăng nhập thay vì trả 401.

Vì sao không dùng SHA-256 để băm mật khẩu?

Vì SHA-256 được thiết kế để nhanh, GPU hiện đại tính hàng tỷ hash mỗi giây nên bảng mật khẩu bị lộ sẽ bị bẻ trong vài giờ. Hàm băm mật khẩu phải cố ý chậm. Identity dùng PBKDF2 với 100.000 vòng lặp và salt 128 bit riêng cho mỗi người dùng.

Salt riêng cho mỗi người dùng giải quyết gì?

Hai người cùng mật khẩu vẫn có hash khác nhau, nên rainbow table vô dụng và kẻ tấn công phải bẻ từng tài khoản một thay vì bẻ một lần cho cả bảng.

Vì sao lockout có thể không kích hoạt?

Vì CheckPasswordAsync không tự tăng bộ đếm thất bại. Khi tự viết luồng đăng nhập, bạn phải gọi AccessFailedAsync thủ công sau mỗi lần sai, và ResetAccessFailedCountAsync khi thành công. SignInManager.PasswordSignInAsync thì tự làm, nhưng nó gắn với cookie.

Lockout có thay thế rate limit không?

Không, chúng chống hai kiểu tấn công khác nhau. Lockout bảo vệ một tài khoản khỏi bị dò mật khẩu. Rate limit bảo vệ hệ thống khỏi password spraying, tức thử một mật khẩu phổ biến trên hàng nghìn tài khoản, kiểu tấn công mà lockout không thấy gì vì mỗi tài khoản chỉ sai một lần.

SecurityStamp dùng để làm gì?

Nó là một GUID đổi mỗi khi có thay đổi ảnh hưởng bảo mật như đổi mật khẩu hay gỡ vai trò. Nhét nó vào token và đối chiếu lúc xác thực cho phép thu hồi mọi phiên ngay lập tức, đúng thứ JWT không làm được. Cái giá là một truy vấn mỗi request, nên nên cache với TTL ngắn.

Kết luận ​

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

  1. AddIdentityCore cho API JWT — AddDefaultIdentity kéo theo cookie và gây redirect thay vì 401.
  2. AccessFailedAsync không tự gọi. Thiếu nó là lockout không bao giờ kích hoạt.
  3. SecurityStamp là cách thu hồi phiên tức thì mà JWT thuần không có.

Tham khảo ​

Điều hướng ​

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