在 Blazor Server 中,简单的实现 Identity 身份验证

momo314相同方式共享非商业用途署名转载

 大家都知道,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;
    }
}

但是浏览器想要通过此方法通知服务端,分为三种情况:

  1. 用户首次访问,<AuthorizeView> 组件被触发时,此时用户的浏览器中可能有 token,也可能没有 token,无论如何,都需要在 <AuthorizeView> 组件首次被触发时,告知服务端
  2. 当用户登录时,通知服务端
  3. 当用户退出时,通知服务端

所以需要针对此 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 进行身份认证和授权即可,不再赘述

✎﹏ 本文来自于 momo314和他们家的猫,文章原创,转载请注明作者并保留原文链接。