在 Blazor Server 中,简单的实现 Identity 身份验证
大家都知道,blazor server 默认使用 websocket 的方式与浏览器进行交互,既然是基于 websocket,传统的基于 cookie 的身份验证机制也就无从谈起了(将凭据存入 local storage,并在发起 http 请求时通过 header 携带至服务器端的操作也一样)
根据 微软的官方文档 介绍的方法,我们决定以最简单的方式,改造现有系统:
基于 Identity Server 的身份校验和授权机制,通常使用 <AuthorizeView>
标签 和 AuthenticationStateProvider
来实现身份校验和授权,如下:
<!-- AuthorizeView 负责在浏览器端检查用户身份,根据 是否 Authorized,进行内容的区别渲染和显示 -->
<AuthorizeView>
<Authorized>
<p>Hello, @context.User.Identity?.Name!</p>
</Authorized>
<NotAuthorized>
<p>You're not authorized.</p>
</NotAuthorized>
</AuthorizeView>
public class AuthenticationStateProvider
{
// AuthenticationStateProvider 通过 GetAuthenticationStateAsync() 方法来获取用户的凭证并进一步确认用户的身份和授权状态
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
return ...;
}
}
对于 http 应用,因为 http 请求无状态,所以每次请求都需要携带凭据(无论 cookie 还是 header),并对请求者进行身份认证和授权。但对于 websocket 应用来说,一个 websocket 连接是一个有状态的长连接,且在断开后会根据 endpoint 信息自动重连,只需要在第一次连接时进行身份认证和授权即可,后续可以一直维持这个状态。看起来似乎比 http 更简单了,但 websocket 连接并不会自动携带 cookie,就由造成了新的问题,我们要如何获取用户的凭据呢?
我们尝试通过类似的思路来进行用户身份认证:尝试在 GetAuthenticationStateAsync()
方法中获取用户凭证,并确认用户的认证和授权状态。
但我们现在没有 HttpContext
了,怎么办呢?流程走不通了,换个方式,有没有办法让浏览器主动告知服务端当前用户的身份凭证呢?
首先,改造 AuthenticationStateProvider
类,提供一个方法,以接收来自客户端发出的身份凭证,并根据凭证来进行用户的认证和授权:
// 自定义 AuthenticationStateProvider
public class BlazorAuthenticationStateProvider : AuthenticationStateProvider
{
private AuthenticationState _state;
public WebAuthenticationStateProvider()
{
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
return await Task.FromResult(_state);
}
public bool Authenticate(string token)
{
var principal = new ClaimsPrincipal();
if (!string.IsNullOrEmpty(token))
{
var payload = /* 解析 token,并获取用户信息 */;
if (payload != null)
{
var claims = new List<Claim>(1)
{
new(ClaimTypes.Name, payload.Name)
};
var role = ((int)payload.AdminLevel).ToString();
var identity = new ClaimsIdentity(claims, authenticationType: "token", nameType: null, roleType: role);
principal = new ClaimsPrincipal(identity);
}
}
_state = new AuthenticationState(principal);
NotifyAuthenticationStateChanged(Task.FromResult(_state));
return principal?.Identity?.IsAuthenticated ?? false;
}
}
但是浏览器想要通过此方法通知服务端,分为三种情况:
- 用户首次访问,
<AuthorizeView>
组件被触发时,此时用户的浏览器中可能有 token,也可能没有 token,无论如何,都需要在<AuthorizeView>
组件首次被触发时,告知服务端 - 当用户登录时,通知服务端
- 当用户退出时,通知服务端
所以需要针对此 3 处分别改造:
首先自定义一个 AuthorizeView
// 自定义 AuthorizeView
public class BlazorAuthorizeView : AuthorizeView
{
private AuthenticationState _state;
private bool _is_authorized;
[Inject]
private AuthenticationStateProvider AuthenticationStateProvider { get; set; }
[CascadingParameter]
private Task<AuthenticationState> AuthenticationStateTask { get; set; }
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (_state is null)
{
builder.AddContent(0, Authorizing);
}
else if (_is_authorized)
{
var contentAuthorized = Authorized ?? ChildContent;
builder.AddContent(1, contentAuthorized?.Invoke(_state));
}
else
{
builder.AddContent(2, NotAuthorized?.Invoke(_state));
}
}
protected override async Task OnParametersSetAsync()
{
var state = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var principal = state?.User;
_state = await AuthenticationStateTask;
_is_authorized = principal?.Identity?.IsAuthenticated ?? false;
}
}
并将其应用于 App.razor
@implements IDisposable
@inject NavigationManager _navigation;
@inject AuthenticationStateProvider _state
@inject IJSRuntime _runtime
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<CascadingValue Value="routeData">
<WebAuthorizeView>
<Authorized>
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layouts.BasicLayout)" />
</Authorized>
<NotAuthorized>
<LayoutView Layout="@typeof(Layouts.AccountLayout)">
<Login></Login>
</LayoutView>
</NotAuthorized>
<Authorizing>
<script>
// 通过 http 请求将 httponly 的 cookie 自动发送到服务端并通过 response 重新返回给浏览器
// 如果使用 非 httponly 的 cookie 或 local storage 存储,直接读取即可
window.request('GET', '/passport/current-token').then(response => {
const body = response.data
if (body.code === 0) {
const token = body.data
// 将 token 通过 C#方法调用 发送给服务端
window.ServerObj.invokeMethodAsync('Authenticate', token);
}
})
</script>
</Authorizing>
</WebAuthorizeView>
</CascadingValue>
</Found>
<NotFound>
<LayoutView Layout="@typeof(Layouts.BasicLayout)">
@{
_navigation.NavigateTo("/exception/404");
}
</LayoutView>
</NotFound>
</Router>
<AntContainer />
</CascadingAuthenticationState>
<script>
window.loadServerObj = (instance) => {
window.ServerObj = instance
}
</script>
@code {
private DotNetObjectReference<App> _reference;
protected override async Task OnAfterRenderAsync(bool initial)
{
if (initial)
{
_reference = DotNetObjectReference.Create(this);
await _runtime.InvokeVoidAsync("window.loadServerObj", _reference);
}
}
[JSInvokable("Authenticate")]
public void Authenticate(string token)
{
(_state as BlazorAuthenticationStateProvider).Authenticate(token);
}
public void Dispose()
{
_reference?.Dispose();
}
}
同理,登录页面也是类似的处理, 用户点击登录按钮时,将用户名密码通过 http 接口发送给接口,并返回token,同时使用 Javascript 调用 C# 方法(Authenticate),将token传递给 AuthenticationStateProvider 进行身份认证和授权即可,不再赘述