Blazor 极简登录模型

时间:2021-02-03 10:41:02   收藏:0   阅读:0

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. 准备

  1. Nuget安装Blazored.LocalStorage包。此包使用JS与客户端环境交互,保存/读取本地数据。

  2. 注册认证和授权服务。

    //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. 填坑之旅

  1. 可以在Razor页中使用LocalStorage存储Token吗?——不可以,会造成成功登录以后页面需要再刷新一次才能渲染登录成功的UI,似乎是认证状态提供程序没有及时得到Claim造成的。
  2. 在AuthService中使用中间变量似乎也可以实现此机制。——AuthService运行在服务端,Token保存在服务端没有意义。
评论(0
© 2014 mamicode.com 版权所有 京ICP备13008772号-2  联系我们:gaon5@hotmail.com
迷上了代码!