Blazor client-side + webapi (.net core 3.1) 添加jwt验证流程(非host)第三章 客户端存储及验证
准备工作:
安装Nuget包:Blazored.LocalStorge。
这是一个client-side 浏览器存储库,找了非常久。
官方文档中也有一款Microsoft.AspNetCore.ProtectedBrowserStorage,具有相同功能,代码实现的方式都是通过dotnet 和 js 互操作,使用sessionStorage,官方依然不推荐使用这个包,但是却没有提供其他方式。
安装nuget包:Microsoft.AspNetCore.Components.Authorization。
继承并实现StatusProvider
public class ApiAuthenticationStateProvider : AuthenticationStateProvider { private readonly HttpClient _httpClient; private readonly ILocalStorageService _localStorage; public ApiAuthenticationStateProvider(HttpClient httpClient, ILocalStorageService localStorage) { _httpClient = httpClient; _localStorage = localStorage; } public override async Task<AuthenticationState> GetAuthenticationStateAsync() { var savedToken = await _localStorage.GetItemAsync<string>("authToken"); if (string.IsNullOrWhiteSpace(savedToken)) { return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); } _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", savedToken); return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(ParseClaimsFromJwt(savedToken), "jwt"))); } public void MarkUserAsAuthenticated(string email) { var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, email) }, "apiauth")); var authState = Task.FromResult(new AuthenticationState(authenticatedUser)); NotifyAuthenticationStateChanged(authState); } public void MarkUserAsLoggedOut() { var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity()); var authState = Task.FromResult(new AuthenticationState(anonymousUser)); NotifyAuthenticationStateChanged(authState); } private IEnumerable<Claim> ParseClaimsFromJwt(string jwt) { var claims = new List<Claim>(); var payload = jwt.Split('.')[1]; var jsonBytes = ParseBase64WithoutPadding(payload); var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes); keyValuePairs.TryGetValue(ClaimTypes.Role, out object roles); if (roles != null) { if (roles.ToString().Trim().StartsWith("[")) { var parsedRoles = JsonSerializer.Deserialize<string[]>(roles.ToString()); foreach (var parsedRole in parsedRoles) { claims.Add(new Claim(ClaimTypes.Role, parsedRole)); } } else { claims.Add(new Claim(ClaimTypes.Role, roles.ToString())); } keyValuePairs.Remove(ClaimTypes.Role); } claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()))); return claims; } private byte[] ParseBase64WithoutPadding(string base64) { switch (base64.Length % 4) { case 2: base64 += "=="; break; case 3: base64 += "="; break; } return Convert.FromBase64String(base64); } }
创建AuthService,用于在页面中使用。同时先创建IAuthService接口
public interface IAuthService { Task<LoginResult> Login(LoginModel loginModel); Task Logout(); Task<RegisterResult> Register(RegisterModel registerModel); }
实现:
public class AuthService : IAuthService { private readonly HttpClient _httpClient; private readonly AuthenticationStateProvider _authenticationStateProvider; private readonly ILocalStorageService _localStorage; public AuthService(HttpClient httpClient, AuthenticationStateProvider authenticationStateProvider, ILocalStorageService localStorage) { _httpClient = httpClient; _authenticationStateProvider = authenticationStateProvider; _localStorage = localStorage; } public async Task<RegisterResult> Register(RegisterModel registerModel) { var result = await _httpClient.PostJsonAsync<RegisterResult>($"{Program.ServerUrl}/api/register", registerModel); return result; } public async Task<LoginResult> Login(LoginModel loginModel) { var loginAsJson = JsonSerializer.Serialize(loginModel); var response = await _httpClient.PostAsync($"{Program.ServerUrl}/api/Login", new StringContent(loginAsJson, Encoding.UTF8, "application/json")); var loginResult = JsonSerializer.Deserialize<LoginResult>(await response.Content.ReadAsStringAsync(), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (!response.IsSuccessStatusCode) { return loginResult; } await _localStorage.SetItemAsync("authToken", loginResult.Token); ((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsAuthenticated(loginModel.Email); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", loginResult.Token); return loginResult; } public async Task Logout() { await _localStorage.RemoveItemAsync("authToken"); ((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsLoggedOut(); _httpClient.DefaultRequestHeaders.Authorization = null; } }
最后将上面的服务都注入,由于不同模版生产的Program.cs不太一样,这里只展示我自己的Program.cs
public class Program { public static async Task Main(string[] args) { var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.RootComponents.Add<App>("app"); //注入服务 builder.Services.AddBlazoredLocalStorage(); builder.Services.AddAuthorizationCore(); builder.Services.AddScoped<AuthenticationStateProvider, ApiAuthenticationStateProvider>(); builder.Services.AddScoped<IAuthService, AuthService>(); await builder.Build().RunAsync(); } public const string ServerUrl = "https://localhost:5002"; }
添加页面
接下来添加Login.razor和Register.razor。
Login.razor
@page "/login" @using client.Interfaces @using shared @inject IAuthService AuthService @inject NavigationManager NavigationManager <h1>Login</h1> @if (ShowErrors) { <div class="alert alert-danger" role="alert"> <p>@Error</p> </div> } <div class="card"> <div class="card-body"> <h5 class="card-title">Please enter your details</h5> <EditForm Model="loginModel" OnValidSubmit="HandleLogin"> <DataAnnotationsValidator /> <ValidationSummary /> <div class="form-group"> <label for="email">Email address</label> <InputText Id="email" Class="form-control" @bind-Value="loginModel.Email" /> <ValidationMessage For="@(() => loginModel.Email)" /> </div> <div class="form-group"> <label for="password">Password</label> <InputText Id="password" type="password" Class="form-control" @bind-Value="loginModel.Password" /> <ValidationMessage For="@(() => loginModel.Password)" /> </div> <button type="submit" class="btn btn-primary">Submit</button> </EditForm> </div> </div> @code { [Parameter] public string returnUrl { get; set; } private LoginModel loginModel = new LoginModel(); private bool ShowErrors; private string Error = ""; /// <summary> /// 登陆点击事件 /// </summary> /// <returns></returns> private async Task HandleLogin() { ShowErrors = false; var result = await AuthService.Login(loginModel); if (result.Successful) { if (!string.IsNullOrEmpty(returnUrl)) { NavigationManager.NavigateTo($"/{returnUrl}"); } else { NavigationManager.NavigateTo("/"); } } else { Error = result.Error; ShowErrors = true; } } }
Register.razor
@page "/register" @using client.Interfaces @using shared @inject IAuthService AuthService @inject NavigationManager NavigationManager <h1>Register</h1> @if (ShowErrors) { <div class="alert alert-danger" role="alert"> @foreach (var error in Errors) { <p>@error</p> } </div> } <div class="card"> <div class="card-body"> <h5 class="card-title">Please enter your details</h5> <EditForm Model="RegisterModel" OnValidSubmit="HandleRegistration"> <DataAnnotationsValidator /> <ValidationSummary /> <div class="form-group"> <label for="email">Email address</label> <InputText Id="email" class="form-control" @bind-Value="RegisterModel.Email" /> <ValidationMessage For="@(() => RegisterModel.Email)" /> </div> <div class="form-group"> <label for="password">Password</label> <InputText Id="password" type="password" class="form-control" @bind-Value="RegisterModel.Password" /> <ValidationMessage For="@(() => RegisterModel.Password)" /> </div> <div class="form-group"> <label for="password">Confirm Password</label> <InputText Id="password" type="password" class="form-control" @bind-Value="RegisterModel.ConfirmPassword" /> <ValidationMessage For="@(() => RegisterModel.ConfirmPassword)" /> </div> <button type="submit" class="btn btn-primary">Submit</button> </EditForm> </div> </div> @code { private RegisterModel RegisterModel = new RegisterModel(); private bool ShowErrors; private IEnumerable<string> Errors; private async Task HandleRegistration() { ShowErrors = false; var result = await AuthService.Register(RegisterModel); if (result.Successful) { NavigationManager.NavigateTo("/login"); } else { Errors = result.Errors; ShowErrors = true; } } }
完成后,我们需要创建一个AuthorizeView组件,用来显示是否登陆。AuthorizeView是一个集成组件,它会自动根据登陆状态显示不同内容,前提是我们前面实现并注入的AuthenticationStateProvider。
它的结构类似这样:
<AuthorizeView> <Authorized> <!--加入已登录后的内容--> Hello, @context.User.Identity.Name! <a href="LogOut">Log out</a> </Authorized> <NotAuthorized> <!--加入未登录的内容--> <a href="Register">Register</a> <a href="Login">Log in</a> </NotAuthorized> </AuthorizeView>
我们将上面的内容放入Component文件夹(或者shared,这个取决于你自己),我这里取名为:LoginDisplay.razor。
这时候你会看到错误,不存在这个TagHelper,这是因为我们还没有导入到razor中,打开_import.razor
添加一些引用:
@using Microsoft.AspNetCore.Components.Authorization
@using Blazored.LocalStorage
@using client.Services
@using client.Providers //取决于你的项目名
再将LoginDisplay组件放到MainLayout.razor中Microsoft.AspNetCore.Components.Authorization
@inherits LayoutComponentBase <div class="sidebar"> <NavMenu /> </div> <div class="main"> <div class="top-row px-4"> <LoginDisplay></LoginDisplay> //放入 <a href="http://blazor.net" target="_blank" class="ml-md-auto">About</a> </div> <div class="content px-4"> @Body </div> </div>
完成上面后,客户端的内容基本已经完成,现在可以开始测试了。
tips:如果你在使用Blazor wasm 3.2 preview2 ,且Microsoft.AspNetCore.Components.Authorization 版本为3.1.2,那你可能会遇到跟我一样的问题,上述代码可能可能无法loading。浏览器控制台输出提示无法找到此组件。
这时候就需要给修改一下注入。
builder.Services.AddAuthorizationCore(options => { });
解决方案来自:https://github.com/dotnet/aspnetcore/issues/18733
应该是一个bug,因为当我换成3.1.0 preview4时,代码就正常能运行。
然后运行代码即可,项目完整源码:https://github.com/simplerjiang/AuthApiAndBlazor