Tại sao bảo mật xác thực lại quan trọng trong ứng dụng di động?
Nếu bạn đang xây dựng ứng dụng di động với .NET MAUI, chắc chắn sẽ có lúc bạn tự hỏi: làm sao để người dùng đăng nhập an toàn mà không cần nhập lại mật khẩu mỗi lần mở app? Nghe đơn giản, nhưng thực ra đây là vấn đề bảo mật cốt lõi — không chỉ là trải nghiệm người dùng.
Khác với ứng dụng web chạy trong sandbox trình duyệt, ứng dụng di động hoạt động trực tiếp trên thiết bị. Điều này có nghĩa là token xác thực, nếu lưu trữ sai cách, hoàn toàn có thể bị trích xuất bởi malware hoặc qua backup thiết bị.
Và nếu bạn không triển khai refresh token đúng cách? Người dùng sẽ bị đăng xuất bất ngờ — hoặc tệ hơn, access token hết hạn nhưng app cứ gửi request liên tục và nhận lỗi 401 hoài.
Trong bài viết này, mình sẽ cùng bạn đi từ lý thuyết đến thực hành: hiểu cách JWT hoạt động trong ngữ cảnh mobile, dùng SecureStorage để lưu token đúng cách trên từng nền tảng, rồi xây dựng DelegatingHandler tự động gắn token và refresh khi hết hạn. Tất cả tích hợp sẵn với IHttpClientFactory và dependency injection.
JWT hoạt động như thế nào trong ứng dụng di động?
JSON Web Token (JWT) là một chuẩn mở (RFC 7519) cho phép truyền tải thông tin xác thực giữa client và server một cách an toàn. Trong ngữ cảnh .NET MAUI, quy trình xác thực JWT diễn ra như sau:
- Người dùng nhập thông tin đăng nhập (username/password) trên app
- App gửi credentials đến API server qua HTTPS
- Server xác minh credentials, tạo một cặp access token (ngắn hạn, thường 15-60 phút) và refresh token (dài hạn, thường 7-30 ngày)
- App lưu cả hai token vào
SecureStorage - Mỗi request đến API, app tự động gắn access token vào header
Authorization: Bearer <token> - Khi access token hết hạn, app dùng refresh token để xin cặp token mới — không cần người dùng đăng nhập lại
Quy trình này nghe có vẻ phức tạp, nhưng thực tế khi đã setup xong thì mọi thứ chạy hoàn toàn tự động phía client.
Cấu trúc của một JWT
Mỗi JWT gồm 3 phần được mã hóa Base64 và nối bằng dấu chấm:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. // Header
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik // Payload
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c // Signature
- Header: Chứa thuật toán mã hóa (HS256, RS256) và loại token
- Payload: Chứa các claims — thông tin về người dùng như
sub(subject/user ID),exp(expiration),role, v.v. - Signature: Chữ ký số được tạo từ Header + Payload + Secret Key, đảm bảo token không bị giả mạo
Lưu ý quan trọng: JWT không mã hóa dữ liệu — ai cũng có thể decode phần Payload. JWT chỉ đảm bảo rằng dữ liệu không bị thay đổi. Nên nhớ, tuyệt đối không đặt thông tin nhạy cảm như mật khẩu hay số thẻ tín dụng vào Payload.
SecureStorage — Lưu trữ token đúng cách trên từng nền tảng
.NET MAUI cung cấp interface ISecureStorage để lưu trữ dữ liệu nhạy cảm một cách an toàn. Nói thẳng luôn: đây là lựa chọn duy nhất chấp nhận được để lưu JWT token. Tuyệt đối không dùng Preferences cho mục đích này — dữ liệu trong Preferences không được mã hóa.
Cách SecureStorage hoạt động trên từng nền tảng
| Nền tảng | Cơ chế bảo mật | Chi tiết |
|---|---|---|
| Android | EncryptedSharedPreferences | Mã hóa AES-256 GCM cho values, mã hóa deterministic cho keys |
| iOS | KeyChain | Lưu trữ trong Keychain của hệ điều hành, có thể đồng bộ iCloud |
| Windows | DataProtectionProvider | Mã hóa bằng DPAPI, lưu trong LocalSettings |
Xây dựng TokenStorageService
Thay vì gọi SecureStorage trực tiếp khắp nơi trong code (mình từng mắc lỗi này ở dự án đầu tiên), hãy tạo một service chuyên quản lý token:
public interface ITokenStorageService
{
Task<string?> GetAccessTokenAsync();
Task<string?> GetRefreshTokenAsync();
Task SaveTokensAsync(string accessToken, string refreshToken);
Task ClearTokensAsync();
Task<bool> HasValidTokenAsync();
}
public class TokenStorageService : ITokenStorageService
{
private const string AccessTokenKey = "access_token";
private const string RefreshTokenKey = "refresh_token";
private const string TokenExpiryKey = "token_expiry";
public async Task<string?> GetAccessTokenAsync()
{
return await SecureStorage.Default.GetAsync(AccessTokenKey);
}
public async Task<string?> GetRefreshTokenAsync()
{
return await SecureStorage.Default.GetAsync(RefreshTokenKey);
}
public async Task SaveTokensAsync(string accessToken, string refreshToken)
{
await SecureStorage.Default.SetAsync(AccessTokenKey, accessToken);
await SecureStorage.Default.SetAsync(RefreshTokenKey, refreshToken);
// Parse expiry từ JWT payload
var expiry = ParseExpiryFromJwt(accessToken);
await SecureStorage.Default.SetAsync(
TokenExpiryKey,
expiry.ToString("O"));
}
public Task ClearTokensAsync()
{
SecureStorage.Default.Remove(AccessTokenKey);
SecureStorage.Default.Remove(RefreshTokenKey);
SecureStorage.Default.Remove(TokenExpiryKey);
return Task.CompletedTask;
}
public async Task<bool> HasValidTokenAsync()
{
var expiryStr = await SecureStorage.Default.GetAsync(TokenExpiryKey);
if (string.IsNullOrEmpty(expiryStr))
return false;
var expiry = DateTime.Parse(expiryStr, null,
System.Globalization.DateTimeStyles.RoundtripKind);
// Trừ thêm 30 giây để tránh race condition
return DateTime.UtcNow < expiry.AddSeconds(-30);
}
private static DateTime ParseExpiryFromJwt(string token)
{
var parts = token.Split('.');
if (parts.Length != 3)
throw new ArgumentException("Invalid JWT format");
var payload = parts[1];
// Thêm padding nếu cần
switch (payload.Length % 4)
{
case 2: payload += "=="; break;
case 3: payload += "="; break;
}
var bytes = Convert.FromBase64String(payload);
var json = System.Text.Encoding.UTF8.GetString(bytes);
using var doc = System.Text.Json.JsonDocument.Parse(json);
var exp = doc.RootElement.GetProperty("exp").GetInt64();
return DateTimeOffset
.FromUnixTimeSeconds(exp)
.UtcDateTime;
}
}
Lưu ý quan trọng cho từng nền tảng
Android — Auto Backup: Đây là cái bẫy mà nhiều dev không để ý. Android Auto Backup có thể sao lưu dữ liệu SharedPreferences, nhưng khi khôi phục trên thiết bị khác, khóa mã hóa đã thay đổi nên dữ liệu không thể giải mã được. .NET MAUI xử lý tự động bằng cách xóa key lỗi, nhưng bạn nên cấu hình loại trừ trong AndroidManifest.xml:
<application android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules">
</application>
iOS — Simulator: Khi phát triển trên iOS Simulator, bạn cần bật Keychain entitlement trong file Entitlements.plist. Nếu không, SecureStorage sẽ throw exception — và tin mình đi, debug lỗi này khá mất thời gian nếu bạn không biết nguyên nhân.
DelegatingHandler — Tự động gắn token và refresh khi hết hạn
Đây là phần mình thích nhất trong toàn bộ kiến trúc. Thay vì gắn token thủ công vào mỗi request (cực kỳ dễ quên và khó bảo trì), chúng ta sẽ tạo một DelegatingHandler — một middleware nằm trong pipeline của HttpClient, tự động xử lý mọi thứ liên quan đến authentication.
DelegatingHandler là gì?
DelegatingHandler là một lớp trong .NET cho phép bạn chặn (intercept) mọi HTTP request trước khi gửi đi và mọi response trước khi trả về cho caller. Nó hoạt động theo mô hình chain-of-responsibility:
[Your Code] → [AuthHandler] → [LoggingHandler] → [HttpClientHandler] → [Server]
↑ ↓ ↓ ↓ ↓
└──────────────←────────────────←────────────────────←──────────────←┘
Xây dựng AuthTokenHandler
Okay, đây là phần code quan trọng nhất. Hãy đọc kỹ phần comment trong code nhé:
public class AuthTokenHandler : DelegatingHandler
{
private readonly ITokenStorageService _tokenStorage;
private readonly IAuthService _authService;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
public AuthTokenHandler(
ITokenStorageService tokenStorage,
IAuthService authService)
{
_tokenStorage = tokenStorage;
_authService = authService;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
// 1. Gắn access token vào request
var accessToken = await _tokenStorage.GetAccessTokenAsync();
if (!string.IsNullOrEmpty(accessToken))
{
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", accessToken);
}
// 2. Gửi request
var response = await base.SendAsync(request, cancellationToken);
// 3. Nếu 401, thử refresh token
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
response = await HandleUnauthorizedAsync(
request, response, cancellationToken);
}
return response;
}
private async Task<HttpResponseMessage> HandleUnauthorizedAsync(
HttpRequestMessage originalRequest,
HttpResponseMessage originalResponse,
CancellationToken cancellationToken)
{
// Dùng SemaphoreSlim để tránh nhiều request
// cùng refresh token đồng thời
await _refreshLock.WaitAsync(cancellationToken);
try
{
// Kiểm tra lại — có thể request khác đã refresh thành công
if (await _tokenStorage.HasValidTokenAsync())
{
var newToken = await _tokenStorage.GetAccessTokenAsync();
return await RetryRequestAsync(
originalRequest, newToken!, cancellationToken);
}
// Thực hiện refresh
var refreshToken = await _tokenStorage.GetRefreshTokenAsync();
if (string.IsNullOrEmpty(refreshToken))
{
// Không có refresh token — buộc đăng nhập lại
await _tokenStorage.ClearTokensAsync();
_authService.NotifySessionExpired();
return originalResponse;
}
var refreshResult = await _authService
.RefreshTokenAsync(refreshToken);
if (refreshResult.IsSuccess)
{
await _tokenStorage.SaveTokensAsync(
refreshResult.AccessToken!,
refreshResult.RefreshToken!);
return await RetryRequestAsync(
originalRequest,
refreshResult.AccessToken!,
cancellationToken);
}
// Refresh thất bại — session hết hạn
await _tokenStorage.ClearTokensAsync();
_authService.NotifySessionExpired();
return originalResponse;
}
finally
{
_refreshLock.Release();
}
}
private async Task<HttpResponseMessage> RetryRequestAsync(
HttpRequestMessage originalRequest,
string newAccessToken,
CancellationToken cancellationToken)
{
// Tạo request mới vì HttpRequestMessage
// không thể gửi lại sau khi đã send
var newRequest = await CloneRequestAsync(originalRequest);
newRequest.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", newAccessToken);
return await base.SendAsync(newRequest, cancellationToken);
}
private static async Task<HttpRequestMessage> CloneRequestAsync(
HttpRequestMessage request)
{
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
if (request.Content != null)
{
var content = await request.Content.ReadAsByteArrayAsync();
clone.Content = new ByteArrayContent(content);
foreach (var header in request.Content.Headers)
clone.Content.Headers.TryAddWithoutValidation(
header.Key, header.Value);
}
foreach (var header in request.Headers)
clone.Headers.TryAddWithoutValidation(
header.Key, header.Value);
foreach (var prop in request.Options)
clone.Options.TryAdd(prop.Key, prop.Value);
return clone;
}
}
Tại sao cần SemaphoreSlim?
Hãy tưởng tượng app gửi 5 request song song và cả 5 đều nhận 401. Nếu không có lock, cả 5 sẽ cùng gọi refresh token API — gây ra race condition và có thể làm invalidate refresh token (vì nhiều server chỉ cho phép mỗi refresh token dùng một lần). SemaphoreSlim đảm bảo chỉ một request thực hiện refresh, các request còn lại chờ rồi dùng token mới.
IAuthService — Xử lý logic xác thực
Tiếp theo, chúng ta cần một service quản lý toàn bộ luồng xác thực. Phần này khá straightforward:
public interface IAuthService
{
Task<AuthResult> LoginAsync(string username, string password);
Task<AuthResult> RefreshTokenAsync(string refreshToken);
Task LogoutAsync();
void NotifySessionExpired();
event EventHandler? SessionExpired;
}
public record AuthResult(
bool IsSuccess,
string? AccessToken = null,
string? RefreshToken = null,
string? Error = null);
public class AuthService : IAuthService
{
private readonly HttpClient _httpClient;
public event EventHandler? SessionExpired;
public AuthService(IHttpClientFactory httpClientFactory)
{
// Dùng named client KHÔNG có AuthTokenHandler
// để tránh vòng lặp vô hạn khi refresh
_httpClient = httpClientFactory.CreateClient("AuthApi");
}
public async Task<AuthResult> LoginAsync(
string username, string password)
{
var loginRequest = new
{
Username = username,
Password = password
};
var response = await _httpClient.PostAsJsonAsync(
"api/auth/login", loginRequest);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
return new AuthResult(false, Error: error);
}
var result = await response.Content
.ReadFromJsonAsync<TokenResponse>();
return new AuthResult(
true,
result!.AccessToken,
result.RefreshToken);
}
public async Task<AuthResult> RefreshTokenAsync(
string refreshToken)
{
var refreshRequest = new { RefreshToken = refreshToken };
var response = await _httpClient.PostAsJsonAsync(
"api/auth/refresh", refreshRequest);
if (!response.IsSuccessStatusCode)
return new AuthResult(false, Error: "Refresh failed");
var result = await response.Content
.ReadFromJsonAsync<TokenResponse>();
return new AuthResult(
true,
result!.AccessToken,
result.RefreshToken);
}
public Task LogoutAsync()
{
SecureStorage.Default.RemoveAll();
return Task.CompletedTask;
}
public void NotifySessionExpired()
{
SessionExpired?.Invoke(this, EventArgs.Empty);
}
private record TokenResponse(
string AccessToken,
string RefreshToken);
}
Đăng ký Dependency Injection trong MauiProgram.cs
Giờ là bước kết nối tất cả lại với nhau. Phần này cần cẩn thận vì đăng ký sai thứ tự hoặc sai lifetime sẽ gây ra bug rất khó tìm:
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
var apiBaseUrl = "https://your-api.example.com/";
// 1. Đăng ký services
builder.Services.AddSingleton<ITokenStorageService,
TokenStorageService>();
builder.Services.AddSingleton<IAuthService, AuthService>();
// 2. Đăng ký DelegatingHandler như transient
builder.Services.AddTransient<AuthTokenHandler>();
// 3. HttpClient cho Auth API (KHÔNG có AuthTokenHandler)
builder.Services.AddHttpClient("AuthApi", client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
});
// 4. HttpClient cho các API khác (CÓ AuthTokenHandler)
builder.Services.AddHttpClient("ProtectedApi", client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<AuthTokenHandler>();
// 5. Đăng ký ViewModels và Pages
builder.Services.AddTransient<LoginViewModel>();
builder.Services.AddTransient<LoginPage>();
builder.Services.AddTransient<MainViewModel>();
builder.Services.AddTransient<MainPage>();
return builder.Build();
}
}
Điểm quan trọng cần lưu ý
- Hai named HttpClient riêng biệt:
"AuthApi"không cóAuthTokenHandlerđể tránh vòng lặp vô hạn khi refresh token."ProtectedApi"có handler để tự động gắn và refresh token. Sai chỗ này là app chạy đến stack overflow ngay - AuthTokenHandler đăng ký transient:
DelegatingHandlerphải được đăng ký transient theo khuyến nghị của Microsoft để tránh vấn đề captive dependency - TokenStorageService đăng ký singleton: Đảm bảo chỉ có một instance quản lý token trong toàn bộ ứng dụng
Xây dựng luồng Login/Logout với MVVM
Bây giờ chúng ta tạo ViewModel cho trang đăng nhập. Mình dùng CommunityToolkit.Mvvm ở đây để giảm boilerplate — nếu bạn chưa dùng thư viện này thì nên thử, nó tiết kiệm khá nhiều code:
public partial class LoginViewModel : ObservableObject
{
private readonly IAuthService _authService;
private readonly ITokenStorageService _tokenStorage;
public LoginViewModel(
IAuthService authService,
ITokenStorageService tokenStorage)
{
_authService = authService;
_tokenStorage = tokenStorage;
}
[ObservableProperty]
private string _username = string.Empty;
[ObservableProperty]
private string _password = string.Empty;
[ObservableProperty]
private string _errorMessage = string.Empty;
[ObservableProperty]
private bool _isLoading;
[RelayCommand]
private async Task LoginAsync()
{
if (string.IsNullOrWhiteSpace(Username)
|| string.IsNullOrWhiteSpace(Password))
{
ErrorMessage = "Vui lòng nhập đầy đủ thông tin đăng nhập.";
return;
}
IsLoading = true;
ErrorMessage = string.Empty;
try
{
var result = await _authService
.LoginAsync(Username, Password);
if (result.IsSuccess)
{
await _tokenStorage.SaveTokensAsync(
result.AccessToken!,
result.RefreshToken!);
await Shell.Current.GoToAsync("//MainPage");
}
else
{
ErrorMessage = "Tên đăng nhập hoặc mật khẩu không đúng.";
}
}
catch (HttpRequestException)
{
ErrorMessage = "Không thể kết nối đến server. "
+ "Vui lòng kiểm tra kết nối mạng.";
}
finally
{
IsLoading = false;
}
}
}
Kiểm tra token khi khởi động app
Trong App.xaml.cs, kiểm tra xem người dùng đã đăng nhập chưa để điều hướng phù hợp:
public partial class App : Application
{
private readonly ITokenStorageService _tokenStorage;
private readonly IAuthService _authService;
public App(
ITokenStorageService tokenStorage,
IAuthService authService)
{
InitializeComponent();
_tokenStorage = tokenStorage;
_authService = authService;
// Lắng nghe sự kiện session hết hạn
_authService.SessionExpired += OnSessionExpired;
}
protected override Window CreateWindow(
IActivationState? activationState)
{
return new Window(new AppShell());
}
private async void OnSessionExpired(object? sender, EventArgs e)
{
await MainThread.InvokeOnMainThreadAsync(async () =>
{
await Shell.Current.GoToAsync("//LoginPage");
await Shell.Current.DisplayAlert(
"Phiên đăng nhập hết hạn",
"Vui lòng đăng nhập lại để tiếp tục.",
"OK");
});
}
}
Xử lý các tình huống đặc biệt trên thiết bị di động
Phần này thường bị bỏ qua trong các tutorial khác, nhưng lại cực kỳ quan trọng khi đưa app lên production.
App bị kill bởi hệ điều hành
Trên cả Android và iOS, hệ điều hành có thể kill app bất kỳ lúc nào để giải phóng bộ nhớ. Khi app được mở lại, token trong SecureStorage vẫn còn nguyên — chính vì vậy mà chúng ta cần kiểm tra token validity khi khởi động thay vì chỉ dựa vào trạng thái trong bộ nhớ.
Không có kết nối mạng
Khi thiết bị offline, mọi nỗ lực refresh token sẽ thất bại. Nhưng đừng vội đăng xuất người dùng ngay — kiểm tra kết nối trước đã:
// Trong AuthTokenHandler, trước khi refresh
var connectivity = Connectivity.Current.NetworkAccess;
if (connectivity != NetworkAccess.Internet)
{
// Vẫn trả về response 401 nhưng không xóa token
// Để app có thể retry khi có mạng lại
return originalResponse;
}
Token bị revoke từ server
Nếu admin revoke token từ phía server (ví dụ khi phát hiện tài khoản bị xâm nhập), refresh token sẽ bị reject. AuthTokenHandler của chúng ta đã xử lý trường hợp này rồi — khi refresh thất bại, nó gọi NotifySessionExpired() để đưa người dùng về màn hình đăng nhập. Đơn giản mà hiệu quả.
Bảo mật nâng cao: Những điều cần lưu ý
Không lưu thông tin nhạy cảm trong JWT Payload
Mình nhấn mạnh lại lần nữa vì đây là lỗi phổ biến: JWT Payload chỉ được encode Base64, không mã hóa. Ai có token đều đọc được nội dung. Chỉ đặt vào Payload những gì cần cho authorization — user ID, roles, permissions. Không bao giờ đặt mật khẩu, email, hay thông tin cá nhân nhạy cảm.
Luôn dùng HTTPS
JWT được gửi qua header HTTP, nên nếu dùng HTTP thường thì token có thể bị chặn bắt qua man-in-the-middle attack. .NET MAUI trên Android 9+ mặc định chặn cleartext HTTP (đó là điều tốt), nhưng bạn vẫn nên kiểm tra kỹ tất cả URL API đều dùng HTTPS.
Đặt thời hạn access token ngắn
Access token nên có thời hạn ngắn, khoảng 15-60 phút. Nếu token bị đánh cắp, kẻ tấn công chỉ sử dụng được trong thời gian ngắn. Refresh token thì dài hạn hơn nhưng nên rotate — mỗi lần refresh thì cấp refresh token mới và hủy cái cũ.
Certificate pinning (Tùy chọn nâng cao)
Để chống man-in-the-middle triệt để hơn, bạn có thể triển khai certificate pinning — xác minh rằng chứng chỉ SSL của server khớp với chứng chỉ đã pin sẵn trong app. Tuy nhiên, cần lưu ý là phải cập nhật app khi chứng chỉ thay đổi, nên cân nhắc kỹ trước khi áp dụng.
Kiểm thử hệ thống xác thực
Một điều mình rất thích ở kiến trúc này: nhờ dependency injection và interface-based design, việc viết unit test trở nên khá dễ dàng. Không cần mock phức tạp, chỉ cần thay thế implementation qua interface:
public class AuthTokenHandlerTests
{
[Fact]
public async Task SendAsync_AttachesTokenToRequest()
{
// Arrange
var mockTokenStorage = new Mock<ITokenStorageService>();
mockTokenStorage
.Setup(x => x.GetAccessTokenAsync())
.ReturnsAsync("test-access-token");
var mockAuthService = new Mock<IAuthService>();
var handler = new AuthTokenHandler(
mockTokenStorage.Object,
mockAuthService.Object)
{
InnerHandler = new MockHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.OK))
};
var client = new HttpClient(handler);
// Act
var response = await client.GetAsync(
"https://api.example.com/data");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task SendAsync_RefreshesTokenOn401()
{
// Arrange
var mockTokenStorage = new Mock<ITokenStorageService>();
mockTokenStorage
.Setup(x => x.GetAccessTokenAsync())
.ReturnsAsync("expired-token");
mockTokenStorage
.Setup(x => x.GetRefreshTokenAsync())
.ReturnsAsync("valid-refresh-token");
mockTokenStorage
.Setup(x => x.HasValidTokenAsync())
.ReturnsAsync(false);
var mockAuthService = new Mock<IAuthService>();
mockAuthService
.Setup(x => x.RefreshTokenAsync("valid-refresh-token"))
.ReturnsAsync(new AuthResult(
true, "new-access-token", "new-refresh-token"));
var callCount = 0;
var handler = new AuthTokenHandler(
mockTokenStorage.Object,
mockAuthService.Object)
{
InnerHandler = new MockHttpMessageHandler(() =>
{
callCount++;
return callCount == 1
? new HttpResponseMessage(
HttpStatusCode.Unauthorized)
: new HttpResponseMessage(HttpStatusCode.OK);
})
};
var client = new HttpClient(handler);
// Act
var response = await client.GetAsync(
"https://api.example.com/data");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
mockTokenStorage.Verify(
x => x.SaveTokensAsync(
"new-access-token", "new-refresh-token"),
Times.Once);
}
}
Câu hỏi thường gặp (FAQ)
Có nên dùng Preferences thay cho SecureStorage để lưu JWT không?
Tuyệt đối không. Preferences lưu dữ liệu dạng plain text — trên Android là SharedPreferences (đọc được nếu thiết bị root), trên iOS là UserDefaults (cũng không mã hóa). SecureStorage dùng cơ chế mã hóa native của từng nền tảng nên là lựa chọn duy nhất an toàn cho token.
Tại sao cần cả access token và refresh token? Dùng một token duy nhất không được sao?
Nếu chỉ dùng một token với thời hạn dài, khi bị đánh cắp thì kẻ tấn công sử dụng được rất lâu. Mô hình hai token giải quyết vấn đề này: access token ngắn hạn giới hạn thiệt hại nếu bị lộ, refresh token dài hạn chỉ gửi đến một endpoint duy nhất (/refresh) nên khó bị chặn bắt hơn nhiều.
Làm sao xử lý khi cả access token và refresh token đều hết hạn?
Khi refresh token hết hạn hoặc bị revoke, AuthTokenHandler sẽ nhận lỗi từ refresh endpoint. Lúc này handler gọi NotifySessionExpired(), app xóa token cũ và điều hướng người dùng về màn hình đăng nhập. Đây là hành vi hoàn toàn hợp lý — buộc người dùng xác thực lại để đảm bảo an toàn.
HttpClientFactory có bắt buộc không? Tạo HttpClient thủ công được không?
Về kỹ thuật thì bạn có thể tạo HttpClient thủ công. Nhưng thật lòng mà nói, IHttpClientFactory giải quyết được vấn đề socket exhaustion (tạo quá nhiều HttpClient sẽ cạn kiệt socket), quản lý vòng đời handler tự động, và tích hợp tốt với DI container. Trong app production, đây là chuẩn mà Microsoft khuyến nghị.
SecureStorage có hoạt động khi app ở chế độ offline không?
Có. SecureStorage lưu dữ liệu cục bộ trên thiết bị nên hoạt động hoàn toàn offline. Token đã lưu có thể đọc bất kỳ lúc nào mà không cần kết nối mạng. Điều này rất hữu ích khi kết hợp với kiến trúc offline-first — app vẫn hiển thị được dữ liệu cached và đồng bộ khi có mạng trở lại.