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

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

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

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

  1. 可以在Razor页中使用LocalStorage存储Token吗?——不可以,会造成成功登录以后页面需要再刷新一次才能渲染登录成功的UI,似乎是认证状态提供程序没有及时得到Claim造成的。
  2. 在AuthService中使用中间变量似乎也可以实现此机制。——AuthService运行在服务端,Token保存在服务端没有意义。

6. 更新

现在NET6出来了, 我使用Minimal API稍微重构了一下代码. 代码传送门在此:
GitHub

代码的变动不大,就是将Controller更换了app.MapGetapp.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中, 我们建立两个用户AdamBetty. 其中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登录以及简单权限管理




posted @ 2021-02-02 15:02  charset  阅读(8672)  评论(9编辑  收藏  举报