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// 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
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.
// 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 được10.12.3 — Refresh token rotation
Mỗi lần refresh, cấp refresh token mới và vô hiệu cái cũ:
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.
// 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
| Token | Thời hạn khuyến nghị | Lưu ở đâu |
|---|---|---|
| Access token | 5–15 phút | Bộ nhớ (không localStorage) |
| Refresh token | 7–30 ngày | Cookie HttpOnly Secure SameSite=Strict |
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:
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:
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");
}
};// 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:
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:
UPDATE AspNetUsers SET IsActive = 0 WHERE Email = '[email protected]';curl -s -X POST .../auth/refresh -d "{\"refreshToken\":\"$R1\"}" | jq -r '.accessToken' | cut -c1-20
# eyJhbGciOiJIUzI1NiIsVẫ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:
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ãiMỗ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:
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ì:
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,
SecurityStampkhớ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:
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:
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:
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ỷ
# 401Loạ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ởi | Bạn vô hiệu hoá tài khoản | Kẻ tấn công dùng token bị đánh cắp |
| Giả định | Bạn đã biết có vấn đề | Bạn không biết gì |
| Phát hiện | Không — chỉ thi hành quyết định có sẵn | Có — 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:
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ếtBa chi tiết làm rotation hoạt động đúng trong thực tế:
- 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.
AbsoluteExpiresAtkhông gia hạn — nếu không, phiên kéo dài vô hạn.- Lưu hash chứ không lưu token thô — người đọc được database không dùng được token.
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:
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
done0s -> 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ố:
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útLầ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ách | Cửa sổ | Cái giá |
|---|---|---|
| Giữ nguyên | 20 phút | 0 |
Token 5 phút + ClockSkew 30 giây | 5,5 phút | Refresh thường xuyên gấp 3 |
Đối chiếu SecurityStamp mỗi request | Tức thì | 1 lần chạm database mỗi request |
| Danh sách chặn trong Redis | Tức thì | 1 lần chạm Redis mỗi request |
// 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.
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:
- 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.
- Rotation biến việc đánh cắp token thành sự kiện quan sát được.
- Refresh token thuộc về cookie
HttpOnly, không thuộc vềlocalStorage.
Tham khảo
- Xác thực trong ASP.NET Core
- Cấu hình JWT bearer authentication
- Phân quyền trong ASP.NET Core
- RFC 8693 — OAuth 2.0 Token Exchange
Điều hướng
- Bài trước: 10.11 — Mở rộng và đào sâu
- Bài tiếp theo: 10.13 — Ví dụ thực tế nhanh
- Về module: Trang mục lục