Blazor 極簡登錄模型
(適用Server Side和WASM Client)
不少介紹Blazor網站包括微軟自己的文檔網站,對Blazor采用的認證/授權機制有詳細的介紹,但是往往給出的是Identity Server的例子。搜索引擎可以找到的如:
https://chrissainty.com/securing-your-blazor-apps-introduction-to-authentication-with-blazor/
https://docs.microsoft.com/zh-cn/aspnet/core/blazor/security/?view=aspnetcore-5.0
但是如筆者的場景,沒有SQL Server,沒有OCID機制的企業內部網絡,想實現自己登錄機制,在網絡上並沒有多少資料。下文介紹的是基於Token的內網用戶名/密碼認證,出於登錄演示機制的考慮,並不保證代碼在安全邏輯層面是可靠的。不要使用未加改造的本文代碼,使用在生產網絡中!
本文將以Server Side的方式介紹,WASM方式僅需修改少數代碼即可完成移植,不再贅述。
0. 准備
-
Nuget安裝Blazored.LocalStorage包。此包使用JS與客戶端環境交互,保存/讀取本地數據。
-
注冊認證和授權服務。
//ConfigureServices services.AddAuthentication(); services.AddAuthorization(); services.AddControllers(); services.AddHttpClient(); services.AddBlazoredLocalStorage(); //Configure app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { //... endpoints.MapControllers(); //... })
1. 機制
不同於Asp.net(以及core,MVC等)模型,Blazor使用的服務器/瀏覽器通訊是SignalR技術,基於WebSockets。SignalR技術是一種長連接通訊,這就和普通的BS登錄模型產生了理解上的沖突——長連接通訊斷開以后,會試圖重連,網絡層會自動透過IP地址端口等要素驗證,似乎不需要解決已經登錄而有別的用戶通過此連接接管的問題。更要命的是,SignalR技術並沒有普通的HTTP Cookie概念。所以我們現在所說的基於Token的登錄,僅僅是使用MVC模型的HTTP登錄;然而如何讓SignalR知道此用戶是被授權訪問的?答案是Blazor提供的AuthenticationStateProvider。如果razor視圖使用CascadingAuthenticationState
,Blazor在渲染前會檢查AuthorizeRouteView
中的/AuthorizeView/Authorized, NotAuthorized, Authorizing
標簽,並根據客戶端得到的授權狀態渲染。
2. 擴展認證狀態提供程序AuthenticationStateProvider
認證狀態提供程序的最核心是 Task<AuthenticationState> GetAuthenticationStateAsync()
方法。基於最簡單的登錄機制,我們的擴展提供程序如下。
public class CustomStateProvider : AuthenticationStateProvider {
private readonly IAuthService api;
public CustomStateProvider(IAuthService _api) => api = _api; //DI
public override async Task<AuthenticationState>
GetAuthenticationStateAsync() {
var identity = new ClaimsIdentity();
var currentUser = await GetCurrentUser();
if (currentUser.IsAuthenticated) {
List<Claim> claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.Name, currentUser.Claims[ClaimTypes.Name]));
for (int i = 0; i < currentUser.Roles.Count; i++) {
claims.Add(new Claim(ClaimTypes.Role, currentUser.Roles[i]));
}
identity = new ClaimsIdentity(claims, "Basic Password");
}
return new AuthenticationState(new ClaimsPrincipal(identity));
}
private async Task<CurrentUser> GetCurrentUser() => await api.CurrentUserInfo();
//Logout 從略
public async Task<LoginResponse> Login(LoginRequest request) {
var response = await api.Login(request);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
return response;
}
}
3. 擴展認證服務IAuthService
我們使用AuthService來與服務端進行交互,實現認證機制。
public interface IAuthService {
Task<LoginResponse> Login(LoginRequest request);
Task<LogoutResponse> Logout(LogoutRequest request);
Task<CurrentUser> CurrentUserInfo();
}
public class AuthService : IAuthService {
private readonly HttpClient httpClient;
private readonly NavigationManager navigationManager;
private readonly Blazored.LocalStorage.ILocalStorageService storage;
public AuthService(HttpClient _httpClient,
NavigationManager _navigationManager,
Blazored.LocalStorage.ILocalStorageService _storage){
httpClient = _httpClient;
navigationManager = _navigationManager;
storage = _storage;
httpClient.BaseAddress = new Uri(navigationManager.BaseUri);
}
public async Task<CurrentUser> CurrentUserInfo() {
CurrentUser user = new CurrentUser() { IsAuthenticated = false };
string token = string.Empty;
try { // 瀏覽器還未加載完js時,不能使用LocalStorage
token = await storage.GetItemAsStringAsync("Token");
} catch (Exception ex) {
Debug.WriteLine(ex.Message);
return user;
}
if(!string.IsNullOrEmpty(token)) {
try {
user = await httpClient.GetFromJsonAsync<CurrentUser>($"Auth/Current/{token}");
if (user.IsExpired) {
await storage.RemoveItemAsync("Token");
}
} catch( Exception ex) {
Debug.WriteLine(ex.Message);
}
}
return user;
}
public async Task<LoginResponse> Login(LoginRequest request) {
var from = new FormUrlEncodedContent(new Dictionary<string, string>() {
["UserId"] = request.UserId, ["Password"] = request.PasswordHashed
});
var result = await httpClient.PostAsync("Auth/Login", form);
if (result.IsSuccessStatusCode) {
var response = await result.Content.ReadFromJsonAsync<LoginResponse>();
if (response.IsSuccess) {
await storage.SetItemAsync("Token", response.Token);
return response;
}
}
return new LoginResponse() { IsSuccess = false };
}
//Logout代碼從略
}
從安全上來說,以上機制情況下,客戶端拿到Token以后,可以在別的機器透過僅上傳Token來使服務端驗證,所以應該在服務端保存客戶端的一些信息來驗證並實現復雜的安全機制。不要使用上述代碼在生產環境中!
上述代碼完成編寫以后,需要透過注冊服務的機制來讓Blazor使用。
services.AddScoped<CustomStateProvider>();
services.AddScoped<AuthenticationStateProvider>(implementationFactory =>
implementationFactory.GetRequiredService<CustomStateProvider>());
services.AddScoped<IAuthService, AuthService>();
4. 使用客戶端
在MainLayout.razor
中編寫登錄頁面。UI組件使用了 Ant Design Blazor
<AuthorizeView>
<Authorized>
<Space Class="auth-bar">
<SpaceItem>
<label>你好, @context.User.Identity.Name!</label>
</SpaceItem>
<SpaceItem>
<Button Type=@ButtonType.Dashed OnClick="OnLogout" Class="trans">
登出
</Button>
</SpaceItem>
</Space>
</Authorized>
<NotAuthorized>
<Space Class="auth-bar">
<SpaceItem>
<Icon Type="sync" Spin Style=@authLoading />
</SpaceItem>
<SpaceItem>
<label>登錄憑據:</label>
</SpaceItem>
<SpaceItem>
<Input @bind-Value=@username Placehold="請輸入用戶名" Disabled=@isAuthLoading />
</SpaceItem>
<SpaceItem>
<label>密碼:</label>
</SpaceItem>
<SpaceItem>
<InputPassword @bind-Value=@password Placehold="請輸入密碼" Disabled=@isAuthLoading />
</SpaceItem>
<SpaceItem>
<Button Type=@ButtonType.Default OnClick="OnLogin" Disabled=@isAuthLoading Class="trans">
登錄
</Button>
</SpaceItem>
</Space>
</NotAuthorized>
<Authorizing>
<em>正在刷新授權信息...</em>
</Authorizing>
</AuthorizeView>
頁面需要注入以下服務:
@inject CustomStateProvider AuthStateProvider;
@inject Blazored.LocalStorage.ILocalStorageService Storage;
編寫登錄按鈕的處理事件:
async Task OnLogin() {
isAuthLoading = true;
try {
var response = await AuthStateProvider.Login(new LoginRequest() {
UserId = username, PasswordHashed = SecurityHelper.Encode(password)
});
password = string.Empty;
if (response.IsSuccess) {
await Message.Success("成功登錄", .15D);
} else {
await Message.Warning(response.Message);
}
} catch (Exception ex) {
await Message.Error(ex.Message);
} finally {
isAuthLoading = false;
}
}
頁面上使用的一些css樣式:
.auth-bar {
display: flex;
justify-content: flex-end;
margin-right: 16px;
}
.trans {
opacity: 0.7;
}
5. 填坑之旅
- 可以在Razor頁中使用LocalStorage存儲Token嗎?——不可以,會造成成功登錄以后頁面需要再刷新一次才能渲染登錄成功的UI,似乎是認證狀態提供程序沒有及時得到Claim造成的。
- 在AuthService中使用中間變量似乎也可以實現此機制。——AuthService運行在服務端,Token保存在服務端沒有意義。