Blazor 极简登录模型 (更新NET6附代码)
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与客户端环境交互,保存/读取本地数据。
-
注册认证和授权服务。
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();
public async Task<LogoutResponse> Logout(LogoutRequest request) {
var response = await api.Logout(request);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
return response;
}
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组件使用了
<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>
<!--在此插入代码以实现登录UI-->
</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;
}
}
5. 填坑之旅
- 可以在Razor页中使用LocalStorage存储Token吗?——不可以,会造成成功登录以后页面需要再刷新一次才能渲染登录成功的UI,似乎是认证状态提供程序没有及时得到Claim造成的。
- 在AuthService中使用中间变量似乎也可以实现此机制。——AuthService运行在服务端,Token保存在服务端没有意义。
6. 更新
现在NET6
出来了, 我使用Minimal API
稍微重构了一下代码. 代码传送门在此:
GitHub
代码的变动不大,就是将Controller
更换了app.MapGet
和app.MapPost
方法。
public static WebApplication MapMinimalAuth(this WebApplication webApplication) {
webApplication.MapGet("/Auth/Current/{token}", async (string token, [FromServices]UserService service, HttpContext context) => {
//实现CurrentUser的代码
}
webApplication.MapPost("/auth/login", (LoginRequest request, UserService service) => service.Login(request));
webApplication.MapPost("/Auth/Logout", (LogoutRequest request, [FromServices] UserService service) =>service.Logout(request));
return webApplication;
}
另外由于NET6
的一些特性,有几点需要说明的:
6.1 Minimal API
目前还不支持[FromForm]
标注
参见:Minimal API does not support FormFormAttribute
如果在Map方法中参数使用[FromForm]
标注,则在框架层直接返回HTTP 400 Bad Request
,所以代码暂且使用了Json
格式传递登录/登出的数据。如果在生产情况下最好能对密码字段进行加密传输(虽然前端加密聊胜于无)。如果NET6
能支持[FromForm]
的话,将会更新代码,毕竟需要支持多方客户端上传方式。
6.2 Minimal API
的参数注入很好很强大
6.3 夹带私货
/UserRole
这个页面放了一个简单的,使用AntDesign Blazor
控件写的权限配置页面。也算一个简单的示例代码吧,毕竟没有人写需求。
6.4 示例解说
在MockData
中, 我们建立两个用户Adam
和Betty
. 其中Adam
有两个权限配置0001:录入
和0002:审核
, Betty
有两个权限配置0000:超级用户
和0001:录入
.
以下代码仅供演示使用, 不要将类似代码上线生产环境!
userStore = new ConcurrentDictionary<string, UserStore>() {
["Adam"] = new UserStore() {
Password = "123456",
UserId = "Adam",
Roles = new List<string> { "0001", "0002" },
IsOnline = false
},
["Betty"] = new UserStore() {
Password = "000000",
UserId = "Betty",
Roles = new List<string> { "0000", "0001" },
IsOnline = false
}
};
6.4.1 Adam登录
6.4.2 Betty登录以及简单权限管理
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· .NET10 - 预览版1新功能体验(一)