Skip to content

10.12 - Mini case study ​

Tóm tắt: Một sự cố bảo mật với hậu quả thật: nhân viên nghỉ việc, tài khoản bị vô hiệu hoá đúng quy trình, nhưng 4 ngày sau người đó vẫn gọi API xoá 340 bản ghi khách hàng. Nguyên nhân không nằm ở access token — cái đó hết hạn sau 15 phút — mà ở refresh token có hạn 30 ngày và không được kiểm tra lại trạng thái tài khoản khi đổi lấy token mới. Vô hiệu hoá tài khoản chỉ chặn được đăng nhập mới, không chặn được vòng lặp refresh đang chạy. Bài này trình bày thiết kế vòng đời token đúng, kèm refresh token rotation — cơ chế vừa giới hạn thiệt hại vừa phát hiện được token bị đánh cắp.

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

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

  • Thiết kế vòng đời access token và refresh token.
  • Kiểm tra lại trạng thái tài khoản khi refresh.
  • Cài refresh token rotation và phát hiện token bị đánh cắp.
  • Thu hồi token tức thì khi cần.

Nội dung bài học ​

10.12.1 — Sự cố ​

Ngày 1, 09:00  Nhân viên nghỉ việc
Ngay 1, 09:15  Admin vo hieu hoa tai khoan (IsActive = false)
Ngay 1, 09:15  Access token hien tai con han 12 phut
Ngay 1, 09:27  Access token het han
Ngày 1, 09:27  Client tự động gọi /refresh -> NHẬN TOKEN MỚI  (!!)
...
Ngày 5, 14:30  Gọi DELETE /api/customers/bulk -> xoá 340 bản ghi
csharp
// Code refresh — thiếu MỘT bước kiểm tra
public async Task<TokenPair> RefreshAsync(string refreshToken, CancellationToken ct)
{
    var stored = await _db.RefreshTokens
        .FirstOrDefaultAsync(t => t.Token == refreshToken, ct);

    if (stored is null || stored.ExpiresUtc < DateTime.UtcNow)
        throw new UnauthorizedAccessException();

    // THIẾU: kiểm tra tài khoản còn hoạt động không

    return GenerateTokenPair(stored.UserId);
}

Vô hiệu hoá tài khoản chỉ ảnh hưởng luồng đăng nhập. Luồng refresh không kiểm tra gì về trạng thái tài khoản, nên nó tiếp tục cấp token mới mỗi 15 phút suốt 30 ngày.

10.12.2 — Sửa: kiểm tra lại khi refresh ​

csharp
public async Task<TokenPair> RefreshAsync(string refreshToken, CancellationToken ct)
{
    var stored = await _db.RefreshTokens
        .Include(t => t.User)
        .FirstOrDefaultAsync(t => t.Token == refreshToken, ct);

    if (stored is null || stored.ExpiresUtc < DateTime.UtcNow || stored.RevokedUtc is not null)
        throw new UnauthorizedAccessException("Refresh token không hợp lệ");

    // KIỂM TRA LẠI trạng thái tài khoản
    if (!stored.User.IsActive)
    {
        await RevokeAllTokensAsync(stored.UserId, "Tài khoản đã bị vô hiệu hoá", ct);
        throw new UnauthorizedAccessException("Tài khoản đã bị vô hiệu hoá");
    }

    if (stored.User.SecurityStamp != stored.SecurityStamp)
    {
        // Mật khẩu hoặc quyền đã đổi kể từ khi cấp token
        await RevokeAllTokensAsync(stored.UserId, "Thông tin bảo mật đã thay đổi", ct);
        throw new UnauthorizedAccessException("Vui lòng đăng nhập lại");
    }

    return await RotateTokenAsync(stored, ct);
}

SecurityStamp là một giá trị thay đổi mỗi khi có thay đổi ảnh hưởng bảo mật: đổi mật khẩu, đổi vai trò, bật xác thực hai lớp. So sánh nó khi refresh khiến mọi token cũ tự vô hiệu sau những thay đổi đó — không cần danh sách chặn riêng.

csharp
// Khi đổi mật khẩu hoặc quyền
user.SecurityStamp = Guid.NewGuid().ToString();
await _db.SaveChangesAsync(ct);
// Mọi refresh token cũ giờ đều không dùng được

10.12.3 — Refresh token rotation ​

Mỗi lần refresh, cấp refresh token mới và vô hiệu cái cũ:

csharp
private async Task<TokenPair> RotateTokenAsync(RefreshToken current, CancellationToken ct)
{
    var next = new RefreshToken
    {
        Token = GenerateRandomToken(),
        UserId = current.UserId,
        SecurityStamp = current.User.SecurityStamp,
        ExpiresUtc = DateTime.UtcNow.AddDays(30),
        CreatedUtc = DateTime.UtcNow
    };

    current.RevokedUtc = DateTime.UtcNow;
    current.ReplacedByToken = next.Token;   // LƯU chuỗi thay thế

    _db.RefreshTokens.Add(next);
    await _db.SaveChangesAsync(ct);

    return new TokenPair(CreateAccessToken(current.User), next.Token);
}

Lợi ích thứ nhất: token bị đánh cắp chỉ dùng được tới lần refresh tiếp theo của người dùng thật.

Lợi ích thứ hai quan trọng hơn: nó phát hiện được việc token bị đánh cắp.

csharp
// Neu ai do dung refresh token DA BI XOAY
if (stored.RevokedUtc is not null)
{
    // Token này đã được dùng rồi -> có người đang dùng token đánh cắp
    _logger.LogWarning(
        "Phát hiện dùng lại refresh token đã xoay. UserId={UserId}, IP={Ip}",
        stored.UserId, context.Connection.RemoteIpAddress);

    // THU HỒI TOÀN BỘ chuỗi token của người dùng này
    await RevokeAllTokensAsync(stored.UserId, "Phát hiện dùng lại token", ct);

    throw new UnauthorizedAccessException("Phiên đăng nhập không hợp lệ");
}

Cơ chế phát hiện:

1. Kẻ tấn công lấy được refresh token R1
2. Người dùng thật refresh:  R1 -> R2, R1 bị xoay
3. Ke tan cong dung R1       -> R1 DA BI XOAY
   -> Hệ thống biết có ai đó dùng token cũ -> THU HỒI HẾT
4. Cả hai bên phải đăng nhập lại — người dùng thật bị phiền một chút,
   nhưng kẻ tấn công mất quyền truy cập

Đây là điểm mạnh nhất của rotation: không chỉ giới hạn thiệt hại mà còn biến việc đánh cắp thành một sự kiện quan sát được.

Gộp cả bản sửa ở mục 10.12.2 và rotation, luồng refresh kiểm tra theo thứ tự:

10.12.4 — Vòng đời token ​

TokenThời hạn khuyến nghịLưu ở đâu
Access token5–15 phútBộ nhớ (không localStorage)
Refresh token7–30 ngàyCookie HttpOnly Secure SameSite=Strict
csharp
Response.Cookies.Append("refresh_token", token, new CookieOptions
{
    HttpOnly = true,                        // JavaScript KHÔNG đọc được
    Secure = true,                          // chi qua HTTPS
    SameSite = SameSiteMode.Strict,         // chong CSRF
    Expires = DateTimeOffset.UtcNow.AddDays(30),
    Path = "/api/auth"                      // chỉ gửi tới endpoint auth
});

Vì sao không lưu token trong localStorage: bất kỳ mã JavaScript nào trên trang cũng đọc được nó, kể cả mã chèn vào qua XSS hoặc qua một thư viện bên thứ ba bị xâm nhập. Cookie HttpOnly thì JavaScript không đọc được.

Path = "/api/auth" là lớp phòng thủ nhỏ nhưng hữu ích: refresh token chỉ được gửi kèm khi gọi endpoint xác thực, nên nó không lộ ra trong mọi request thông thường.

Và ClockSkew mặc định 5 phút nên giảm xuống:

csharp
options.TokenValidationParameters.ClockSkew = TimeSpan.FromSeconds(30);

Với access token 15 phút, dung sai 5 phút nghĩa là token dùng được tới 20 phút — một phần ba thời gian thừa (bài 10.2).

10.12.5 — Thu hồi tức thì ​

Access token vẫn còn hạn tối đa 15 phút sau khi thu hồi. Nếu nghiệp vụ không chấp nhận được, thêm danh sách chặn:

csharp
options.Events = new JwtBearerEvents
{
    OnTokenValidated = async context =>
    {
        var jti = context.Principal?.FindFirst(JwtRegisteredClaimNames.Jti)?.Value;
        if (jti is null) return;

        var cache = context.HttpContext.RequestServices
            .GetRequiredService<IDistributedCache>();

        if (await cache.GetStringAsync($"revoked:{jti}") is not null)
            context.Fail("Token đã bị thu hồi");
    }
};
csharp
// Khi thu hồi — TTL bằng thời gian còn lại của token
var conLai = token.ValidTo - DateTime.UtcNow;
await _cache.SetStringAsync($"revoked:{jti}", "1",
    new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = conLai });

Cái giá: mỗi request thêm một lần đọc Redis. Với hầu hết hệ thống nội bộ, access token 15 phút là đủ và không cần lớp này (bài 18.6).

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

Danh sách rà soát vòng đời token

  • Luồng refresh kiểm tra lại trạng thái tài khoản còn hoạt động.
  • Có SecurityStamp và nó đổi khi mật khẩu hoặc quyền thay đổi.
  • Refresh token được xoay mỗi lần dùng.
  • Dùng lại refresh token đã xoay thì thu hồi toàn bộ chuỗi.
  • Có cảnh báo khi phát hiện dùng lại token đã xoay.
  • Access token có hạn 5-15 phút.
  • Refresh token lưu trong cookie HttpOnly Secure SameSite Strict.
  • Không lưu token trong localStorage.
  • ClockSkew giảm xuống, không để mặc định 5 phút.

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

Bài 1 — Tái hiện: tài khoản đã vô hiệu vẫn tự gia hạn ​

Vô hiệu hoá một tài khoản rồi gọi endpoint refresh với token cũ. Xác nhận nó vẫn cấp token mới nếu thiếu bước kiểm tra.

Tiêu chí hoàn thành: bạn nêu được vì sao thiếu một dòng kiểm tra lại biến cửa sổ rủi ro 15 phút thành vô hạn.

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

Gợi ý. Vẽ vòng lặp: access token hết hạn sau 15 phút, nhưng cái gì cấp access token mới?

Lời giải — code thiếu sót:

csharp
public async Task<Result<TokenPair>> RefreshAsync(string refreshToken, CancellationToken ct)
{
    var stored = await _db.RefreshTokens
        .Include(t => t.User)
        .FirstOrDefaultAsync(t => t.TokenHash == Hash(refreshToken), ct);

    if (stored is null || stored.ExpiresUtc < DateTime.UtcNow)
        return Result.Fail("Refresh token không hợp lệ");

    // THIẾU: kiểm tra tài khoản còn hoạt động không

    return Result.Success(await IssueAsync(stored.User, ct));
}

Tái hiện:

sql
UPDATE AspNetUsers SET IsActive = 0 WHERE Email = '[email protected]';
bash
curl -s -X POST .../auth/refresh -d "{\"refreshToken\":\"$R1\"}" | jq -r '.accessToken' | cut -c1-20
# eyJhbGciOiJIUzI1NiIs

Vẫn cấp token mới cho một tài khoản đã bị vô hiệu.

Vì sao cửa sổ thành vô hạn — vẽ ra vòng lặp:

text
09:00  Vô hiệu hoá tài khoản
09:14  Access token cũ hết hạn
09:14  Client tự động gọi /refresh
09:14  -> nhận access token MỚI, hạn thêm 15 phút
09:29  Hết hạn -> refresh -> lại 15 phút
...     lặp lại mãi mãi

Mỗi lần refresh đặt lại đồng hồ. Không có điểm dừng nào, vì điểm dừng duy nhất — thời hạn của refresh token, thường 30 ngày — cũng được gia hạn nếu bạn rotate.

Đây chính là lý do trong sự cố gốc, người đã nghỉ việc vẫn truy cập được 5 ngày sau và xoá 340 bản ghi. Không phải vì access token sống 5 ngày, mà vì nó được làm mới 480 lần.

Hai dòng sửa, đặt ngay sau khi nạp token:

csharp
if (!stored.User.IsActive)
{
    await RevokeAllTokensAsync(stored.UserId, "Tài khoản đã bị vô hiệu hoá", ct);
    return Result.Fail("Phiên đăng nhập không còn hợp lệ");
}

if (stored.User.SecurityStamp != stored.SecurityStamp)
{
    await RevokeAllTokensAsync(stored.UserId, "Thông tin bảo mật đã thay đổi", ct);
    return Result.Fail("Phiên đăng nhập không còn hợp lệ");
}

Thu hồi cả chuỗi chứ không chỉ từ chối lần này. Chỉ trả Fail thì client cứ 15 phút lại thử — log đầy lỗi, và nếu sau này có ai đó kích hoạt lại tài khoản thì phiên cũ sống lại.

/refresh là nơi duy nhất trong hệ thống bắt buộc phải chạm database. Xác thực access token thì không — đó là lý do JWT nhanh. Nhưng refresh thì đã phải tra database để kiểm token, nên thêm hai điều kiện vào cùng truy vấn đó không tốn gì:

csharp
var stored = await _db.RefreshTokens
    .Include(t => t.User)
    .Where(t => t.TokenHash == hash && t.User.IsActive)      // gộp vào WHERE
    .FirstOrDefaultAsync(ct);

Danh sách kiểm phải có ở /refresh:

  • tài khoản còn hoạt động,
  • SecurityStamp khớp (mật khẩu hay quyền chưa đổi),
  • refresh token chưa bị thu hồi,
  • chưa vượt hạn tuyệt đối (AbsoluteExpiresAt — không gia hạn khi rotate).

Thiếu mục cuối thì phiên đăng nhập kéo dài vô hạn với người dùng thật nữa, không riêng kẻ xấu.


Bài 2 — Rotation và phát hiện tái sử dụng ​

Thêm xoay refresh token, dùng lại token cũ, và xác nhận toàn bộ chuỗi bị thu hồi.

Tiêu chí hoàn thành: bạn chỉ ra được rotation phát hiện được loại tấn công nào mà bài 1 không phát hiện được.

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

Gợi ý. Bài 1 xử lý trường hợp bạn biết tài khoản có vấn đề. Rotation xử lý trường hợp bạn không biết gì cả.

Lời giải — cài rotation:

csharp
private async Task<TokenPair> RotateTokenAsync(RefreshToken current, CancellationToken ct)
{
    var next = new RefreshToken
    {
        TokenHash = Hash(raw = GenerateRandomToken()),
        UserId = current.UserId,
        SecurityStamp = current.User.SecurityStamp,
        AbsoluteExpiresAt = current.AbsoluteExpiresAt,     // KHÔNG gia hạn trần tuyệt đối
        ExpiresUtc = DateTime.UtcNow.AddDays(30),
        CreatedUtc = DateTime.UtcNow
    };

    current.RevokedUtc = DateTime.UtcNow;
    current.ReplacedByHash = next.TokenHash;               // nối chuỗi

    _db.RefreshTokens.Add(next);
    await _db.SaveChangesAsync(ct);

    return new TokenPair(CreateAccessToken(current.User), raw);
}

Phát hiện tái sử dụng:

csharp
if (stored.RevokedUtc is not null)
{
    var age = DateTime.UtcNow - stored.RevokedUtc.Value;

    if (age < TimeSpan.FromSeconds(10) && stored.ReplacedByHash is not null)
        return await ReturnExistingReplacementAsync(stored, ct);   // race nhiều tab

    _logger.LogCritical(
        "Refresh token ĐÃ BỊ HUỶ được dùng lại. User {UserId}, IP {Ip}",
        stored.UserId, ip);

    await RevokeAllTokensAsync(stored.UserId, "Phát hiện dùng lại token", ct);
    return Result.Fail("Phiên đăng nhập không hợp lệ, vui lòng đăng nhập lại");
}

Kiểm chứng:

bash
R2=$(refresh "$R1")        # R1 -> R2
R3=$(refresh "$R2")        # R2 -> R3

refresh "$R1"              # dùng lại R1
# 401

refresh "$R3"              # token hiện tại CŨNG bị huỷ
# 401

Loại tấn công rotation phát hiện được — và bài 1 thì không:

Bài 1 (kiểm trạng thái)Bài 2 (rotation)
Kích hoạt bởiBạn vô hiệu hoá tài khoảnKẻ tấn công dùng token bị đánh cắp
Giả địnhBạn đã biết có vấn đềBạn không biết gì
Phát hiệnKhông — chỉ thi hành quyết định có sẵnCó — tự phát hiện

Bài 1 là thi hành: ai đó ở bộ phận nhân sự bấm nút vô hiệu hoá, hệ thống làm theo. Nếu không ai bấm, không gì xảy ra.

Rotation là phát hiện: token bị đánh cắp qua XSS, qua log lộ, qua máy bị nhiễm — không ai biết, không ai bấm gì. Nhưng khoảnh khắc cả hai bên cùng dùng chuỗi token, hệ thống tự nhận ra và tự cắt.

Hai cơ chế bù cho nhau, cần cả hai:

text
Bài 1 chặn: người đã nghỉ việc, người bị thu hồi quyền
Bài 2 chặn: token bị đánh cắp mà không ai biết

Ba chi tiết làm rotation hoạt động đúng trong thực tế:

  1. Cửa sổ ân hạn 10 giây cho race nhiều tab — thiếu nó, người dùng bị đăng xuất khi mở nhiều tab.
  2. AbsoluteExpiresAt không gia hạn — nếu không, phiên kéo dài vô hạn.
  3. Lưu hash chứ không lưu token thô — người đọc được database không dùng được token.
sql
CREATE TABLE RefreshTokens (
    TokenHash         VARBINARY(32)    NOT NULL,   -- SHA-256, KHÔNG lưu plaintext
    ReplacedByHash    VARBINARY(32)    NULL,       -- liên kết chuỗi rotation
    AbsoluteExpiresAt DATETIME2(3)     NOT NULL
);

Bài 3 — Đo chính xác cửa sổ rủi ro ​

Vô hiệu hoá tài khoản và đo chính xác bao lâu access token cũ còn dùng được.

Tiêu chí hoàn thành: bạn ra được một con số cụ thể cho hệ thống mình, và chỉ ra được hai thành phần tạo nên con số đó.

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

Gợi ý. Một thành phần bạn tự chọn khi phát token. Thành phần kia là một mặc định bạn có thể chưa biết.

Lời giải — đo:

bash
TOKEN=$(login)
psql -c "UPDATE \"AspNetUsers\" SET \"IsActive\" = false WHERE \"Email\" = '[email protected]';"
START=$(date +%s)

while true; do
  code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $TOKEN" \
    https://localhost:5001/api/v1/customers)
  now=$(date +%s)
  echo "$((now - START))s -> $code"
  [ "$code" != "200" ] && break
  sleep 10
done
text
0s -> 200
10s -> 200
...
880s -> 200
890s -> 401

≈ 890 giây, tức khoảng 14 phút 50 giây.

Hai thành phần tạo nên con số:

text
Thời gian còn lại của access token khi vô hiệu hoá   (0 đến 15 phút)
  + ClockSkew mặc định                                (5 phút)
  = cửa sổ rủi ro tối đa: 20 phút

Lần đo trên rơi vào khoảng giữa nên ra gần 15 phút. Nếu vô hiệu hoá ngay sau khi người dùng vừa đăng nhập, con số sẽ chạm trần 20 phút.

ClockSkew là thành phần hay bị bỏ sót. Rất nhiều đội tin rằng token 15 phút nghĩa là rủi ro 15 phút, trong khi mặc định của TokenValidationParameters cộng thêm 5 phút nữa — tăng 33% so với ý định.

Ba cách thu hẹp, và cái giá của từng cách:

CáchCửa sổCái giá
Giữ nguyên20 phút0
Token 5 phút + ClockSkew 30 giây5,5 phútRefresh thường xuyên gấp 3
Đối chiếu SecurityStamp mỗi requestTức thì1 lần chạm database mỗi request
Danh sách chặn trong RedisTức thì1 lần chạm Redis mỗi request
csharp
// Cách rẻ nhất, không thêm hạ tầng
expires: DateTime.UtcNow.AddMinutes(5),
ClockSkew = TimeSpan.FromSeconds(30)

Khuyến nghị theo mức nhạy cảm:

  • Hệ thống nội bộ thông thường: token 5 phút + kiểm trạng thái ở /refresh (bài 1). Cửa sổ 5,5 phút là chấp nhận được, và không thêm hạ tầng gì.
  • Thao tác nhạy cảm — chuyển tiền, đổi quyền, xoá hàng loạt: thêm danh sách chặn cho riêng những endpoint đó, đừng áp cho toàn API.

Và một điều quan trọng hơn con số: vô hiệu hoá tài khoản phải đồng thời thu hồi refresh token. Nếu không, bài 1 lặp lại — cửa sổ 20 phút trở thành vô hạn, và con số bạn vừa đo mất hết ý nghĩa.

csharp
public async Task DeactivateUserAsync(Guid userId, CancellationToken ct)
{
    var user = await _db.Users.FindAsync([userId], ct);
    user!.IsActive = false;
    await _userManager.UpdateSecurityStampAsync(user);
    await RevokeAllTokensAsync(userId, "Tài khoản bị vô hiệu hoá", ct);   // BẮT BUỘC
    await _db.SaveChangesAsync(ct);
}

Tự kiểm tra ​

Vì sao vô hiệu hoá tài khoản không chặn được người dùng?

Vì nó chỉ ảnh hưởng luồng đăng nhập. Luồng refresh không kiểm tra trạng thái tài khoản nên tiếp tục cấp access token mới mỗi khi cái cũ hết hạn, suốt thời hạn của refresh token.

SecurityStamp hoạt động thế nào?

Nó là một giá trị đổi mỗi khi có thay đổi ảnh hưởng bảo mật như đổi mật khẩu hay đổi vai trò. So sánh nó khi refresh khiến mọi token cấp trước thay đổi đó tự vô hiệu, không cần danh sách chặn riêng.

Refresh token rotation đem lại hai lợi ích gì?

Token bị đánh cắp chỉ dùng được tới lần refresh tiếp theo của người dùng thật, và quan trọng hơn là hệ thống phát hiện được việc đánh cắp khi thấy ai đó dùng một token đã bị xoay.

Phản ứng đúng khi phát hiện dùng lại token đã xoay là gì?

Thu hồi toàn bộ chuỗi token của người dùng đó và ghi cảnh báo kèm địa chỉ IP. Cả người dùng thật lẫn kẻ tấn công đều phải đăng nhập lại, nhưng kẻ tấn công mất quyền truy cập.

Vì sao không lưu token trong localStorage?

Vì bất kỳ mã JavaScript nào trên trang cũng đọc được, kể cả mã chèn vào qua XSS hoặc qua một thư viện bên thứ ba bị xâm nhập. Cookie HttpOnly thì JavaScript không đọc được.

Vì sao ClockSkew mặc định là vấn đề?

Mặc định 5 phút nghĩa là access token 15 phút thực tế dùng được tới 20 phút, tức thừa một phần ba thời gian. Với hệ thống có đồng bộ thời gian qua NTP, 30 giây là quá đủ.

Kết luận ​

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

  1. Vô hiệu hoá tài khoản không chặn vòng lặp refresh — phải kiểm tra lại ở luồng refresh.
  2. Rotation biến việc đánh cắp token thành sự kiện quan sát được.
  3. Refresh token thuộc về cookie HttpOnly, không thuộc về localStorage.

Tham khảo ​

Điều hướng ​

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