James: 《使用Blazor开发内部后台》系列是技术社区中一位朋友投稿的系列文章,介绍自己为公司的 WebForm 遗留系统使用 Blazor 重写前端 UI 的经历。
本文为第三篇,如果错过了前两篇,建议先阅读一下:
使用 Blazor 开发内部后台(一):认识Blazor使用
使用 Blazor 开发内部后台(二):了解 Blazor 组件
前言
前文为读者介绍了Blazor及组件的相关基础概念,现在让我们来处理一些实际的问题。本文将介绍一个简单的设计方案:如何基于Blazor开发内部后台登录页面(及相关模块)。为了方便初学者理解正文,本文会先介绍一些工程上必须掌握的基础知识,有经验的开发者可以选择性跳过。
托管Blazor WA应用(Hosted Blazor Web Assembly)
Blazor WA应用可以单独部署,称之为独立Blazor WA(Standalone),通常用于(不需要后端的)离线应用或者后端服务基于非ASP.NET Core的情形。而将Blazor作为ASP.NET Core应用的前端部分一起部署,则被称为托管Blazor(Hosted)。很显然,若要开发一个前后端分离的应用,采用托管Blazor,才能最大程度地发挥Blazor的开发和部署优势。
项目基本结构
托管Blazor WA应用的项目解决方案,主要包含三大子项目:
XXX.Client客户端项目:前端模块,即Blazor应用。
XXX.Server服务端项目:后端模块,通常是ASP.NET Core Web API。在最后部署的时候,是由此项目进行发布的,因此该项目会引用Client项目。
XXX.Shared类库项目:共享模块,主要是存放前后端可以共用的数据或逻辑,其他2个项目都要引用它。
而针对Client项目,内部也有自己的默认结构,这里请读者自行阅读Blazor项目结构官方文档,篇幅所限,后文将默认读者已经熟悉这些基础结构。
依赖注入
依赖注入是ASP.NET Core里一个非常基础的设计模式。Blazor里延续了和后端开发同样的风格。例如前端向后端发送请求,需要使用HttpClient,在Program.cs文件里,可以看到:
public class Program{public static async Task Main(string[] args){var builder = WebAssemblyHostBuilder.CreateDefault(args);builder.RootComponents.Add<App>("#app");builder.Services.AddScoped(sp => new HttpClient{BaseAddress = new Uri(builder.HostEnvironment.BaseAddress),Timeout = TimeSpan.FromSeconds(3)});await builder.Build().RunAsync();}}
又例如:我们按照Ant-Design-Blazor项目的《快速上手》说明,引入该开源组件Nuget包后,也需要在这里加上依赖注入的代码行(其他需要的操作详见项目文档):
builder.Services.AddAntDesign();
这对ASP.NET Core后端开发者来说,完全没有理解门槛。而在Page文件里,需要使用HttpClient时,只需要使用@inject关键词声明即可:
@inject HttpClient MyHttpClient<div>.......
</div>@code{private async Task<string> GetAsync(){string rsp = await MyHttpClient.GetStringAsync(xxxx);return rsp;}
}
这里请读者自行阅读Blazor依赖注入的官方文档。对Angular开发者来说,应该也会感到十分亲切。
设计认证方式
谈到登录,自然最先要考虑登录的认证方式,常见的有Cookie、Session或Token。对后端渲染的应用来说,使用Session应该更简单;而对前后端分离的应用来说,后端Web API应当是无状态的,因此一般只选择Cookie或Token,由前端持有自己的身份票据,后端做验证而不存储。
而在Cookie和Token之间,我按照官方文档的建议选择了使用Json Web Token。这里有必要将官方的理由引用过来,方便读者参考:
还有对 SPA 进行身份验证的其他选项,例如使用 SameSite cookie。但是,Blazor WebAssembly 的工程设计决定,OAuth 和 OIDC 是在 Blazor WebAssembly 应用中进行身份验证的最佳选择。出于以下功能和安全原因,选择了以 JSON Web 令牌 (JWT) 为基础的基于令牌的身份验证而不是基于 cookie 的身份验证:
使用基于令牌的协议可以减小攻击面,因为并非所有请求中都会发送令牌。
服务器终结点不要求针对跨站点请求伪造 (CSRF) 进行保护,因为会显式发送令牌。因此,可以将 Blazor WebAssembly 应用与 MVC 或 Razor Pages 应用一起托管。
令牌的权限比 cookie 窄。例如,令牌不能用于管理用户帐户或更改用户密码,除非显式实现了此类功能。
令牌的生命周期更短(默认为一小时),这限制了攻击时间窗口。还可随时撤销令牌。
自包含 JWT 向客户端和服务器提供身份验证进程保证。例如,客户端可以检测和验证它收到的令牌是否合法,以及是否是在给定身份验证过程中发出的。如果有第三方尝试在身份验证进程中偷换令牌,客户端可以检测被偷换的令牌并避免使用它。
OAuth 和 OIDC 的令牌不依赖于用户代理行为正确以确保应用安全。
基于令牌的协议(例如 OAuth 和 OIDC)允许用同一组安全特征对托管和独立应用进行验证和授权。
官方最推荐的方式是使用OAuth和OIDC。但开发内部后台,还要另搞一个OAuth服务器,对绝大多数开发者来说维护和部署成本过高了。所以我使用了传统的Password模式+后端自生成JWT。对内部后台应用来说,这么做已经足够安全。
还需要考虑的问题是,前端如何存放JWT呢?我们仍有两种选择,Cookie和LocalStorage。如果拿到了JWT放到一个前端自生成的Cookie里……那为什么不一开始就用Cookie呢?显得有些自我矛盾。我选择了储存到LocalStorage里。借助开源项目Blazor.LocalStorage,我们可以很轻松地达到目的,当然,跟Antd一样要用到依赖注入:
builder.Services.AddBlazoredLocalStorage(config =>{config.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;config.JsonSerializerOptions.IgnoreNullValues = true;config.JsonSerializerOptions.IgnoreReadOnlyProperties = true;config.JsonSerializerOptions.PropertyNameCaseInsensitive = true;config.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;config.JsonSerializerOptions.ReadCommentHandling = JsonCommentHandling.Skip;config.JsonSerializerOptions.WriteIndented = false;});
设计后端接口
既然已经确认要使用JWT,那么后端自然要提供一个认证的接口:
public class AccountController : ApiControllerBase{private readonly IMemoryCache _cache;private readonly IOptionsMonitor<JwtOption> _jwtOpt;private readonly IPasswordCryptor _passwordCryptor;private readonly MyDbContext _efContext;public AccountController(ILogger<AccountController> logger,IMemoryCache cache,IOptionsMonitor<JwtOption> jwtOpt,IPasswordCryptor passwordCryptor,MyDbContext efContext) : base(logger){_cache = cache;_jwtOpt = jwtOpt;_passwordCryptor = passwordCryptor;_efContext = efContext;}[HttpPost]public async Task<IActionResult> Login([FromForm] LoginRqtDto rqtDto){var cryptedPwd = _passwordCryptor.Encrypt(rqtDto.Password, default);string adminIdCacheKey = CacheKeyHelper.GetAdminIdCacheKey(rqtDto.Account);if (!_cache.TryGetValue(adminIdCacheKey, out int adminId)){adminId = await _efContext.Admins.Where(a => a.Account == rqtDto.Account && a.Password == cryptedPwd).Select(a => a.AdminId).FirstOrDefaultAsync();if (adminId < 1){return Unauthorized();}_cache.Set(adminIdCacheKey, adminId, TimeSpan.FromDays(1));}else{bool checkPwd = await _efContext.Admins.AnyAsync(a => a.AdminId == adminId && a.Password == cryptedPwd);if (!checkPwd){return Unauthorized();}}var claims = new Claim[]{new(ClaimTypes.NameIdentifier, adminId.ToString()),new(ClaimTypes.Name, rqtDto.Account),new(ClaimTypes.Role, "admin")};var jwtSetting = _jwtOpt.CurrentValue;var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSetting.Key));var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);var expiry = DateTime.Now.AddHours(jwtSetting.ExpiryInHours);var token = new JwtSecurityToken(jwtSetting.Issuer, jwtSetting.Audience, claims, expires: expiry, signingCredentials: creds);var tokenText = new JwtSecurityTokenHandler().WriteToken(token);return Ok(tokenText);}}
还需要配置JWT相关的参数:
"JWT": {"Key": "xxx","Issuer": "xxx","Audience": "xxx","ExpiryInHours": 8}
及依赖注入:
public static IServiceCollection AddAuth(this IServiceCollection services, IConfiguration configuration){services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>{options.TokenValidationParameters = new TokenValidationParameters{ValidateIssuer = true,ValidateAudience = true,ValidateLifetime = true,ValidateIssuerSigningKey = true,ValidIssuer = configuration.GetValue<string>("JWT:Issuer"),ValidAudience = configuration.GetValue<string>("JWT:Audience"),IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration.GetValue<string>("JWT:Key"))),RequireExpirationTime = true};});services.Configure<JwtOption>(configuration.GetSection("JWT"));return services;}
以上代码仅供读者参考,可按实际需要增删改。另有一句与本文主旨无关的提醒:虽然是内部后台系统,但管理员登录密码还是要做加盐Hash处理,明文保存密码在任何地方都不可取!
设计前端服务
有的读者可能更喜欢UI先行,那么可以先看下面一节“设计登录页面”。
有了跟后端一样的依赖注入,我们可以将前端的认证也封装成服务。在项目中增加Services文件夹,添加AuthService.cs文件:
using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components.Authorization;internal 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<bool> Login(LoginRqtDto rqtDto){var content = new FormUrlEncodedContent(new KeyValuePair<string, string>[]{new(nameof(LoginRqtDto.Account), rqtDto.Account),new(nameof(LoginRqtDto.Password), rqtDto.Password),});using var rsp = await _httpClient.PostAsync("/account/login", content);if (!rsp.IsSuccessStatusCode){return false;}var authToken = await rsp.Content.ReadAsStringAsync();await _localStorage.SetItemAsync("authToken", authToken);((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsAuthenticated(rqtDto.Account);_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken);return true;}public async Task Logout(){await _localStorage.RemoveItemAsync("authToken");((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsLoggedOut();_httpClient.DefaultRequestHeaders.Authorization = null;}}
首先要注意的是AuthenticationStateProvider,这是一个抽象类,由Microsoft.AspNetCore.Components.Authorization类库提供,它用来提供当前用户的认证状态信息。既然是抽象类,我们需要自定义一个它的子类,基于JWT和LocalStorage实现它要求的规则(即GetAuthenticationStateAsync方法):
using System.Security.Claims;
using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components.Authorization;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 account){var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, account) }, "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 static 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);if (keyValuePairs.TryGetValue(ClaimTypes.Role, out object roles) && roles is string rolesText){if (rolesText.StartsWith('[')){var parsedRoles = JsonSerializer.Deserialize<string[]>(rolesText);foreach (var parsedRole in parsedRoles){claims.Add(new Claim(ClaimTypes.Role, parsedRole));}}else{claims.Add(new Claim(ClaimTypes.Role, rolesText));}keyValuePairs.Remove(ClaimTypes.Role);}claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString())));return claims;}private static byte[] ParseBase64WithoutPadding(string base64){switch (base64.Length % 4){case 2: base64 += "=="; break;case 3: base64 += "="; break;}return Convert.FromBase64String(base64);}}
逻辑并不复杂。以上代码需要读者对JWT和System.Security.Claims类库比较熟悉,建议初学者动手实践和调试。
ILocalStorageService自然是由上文提到的Blazor.LocalStorage类库依赖注入。
之前系列文章都提到了Blazor在.NET全栈开发下,具有极大的开发效率优势。这里就有体现——既然后端已经提供了接口,注意到LoginRqtDto类:
using System.ComponentModel.DataAnnotations;public class LoginRqtDto{[Display(Name = "账号")][Required][StringLength(20, MinimumLength = 3)]public string Account { get; set; }[Display(Name = "密码")][Required][StringLength(20, MinimumLength = 5]public string Password { get; set; }}
我们自然可以将该类放到Shared项目中,使得前端Blazor项目在调用Login接口时可以不必再另写请求参数的Model。另外,不单单是类本身的属性,特性也可以被前后端共同利用,这一点放到下文再讲。
写完了该服务,可别忘了依赖注入!我的习惯是让Program.cs里的代码尽可能精简,因此,我会创建一个Extensions文件夹,添加ServiceCollectionExtension.cs文件:
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;internal static class ServiceCollectionExtension{public static IServiceCollection AddAuth(this IServiceCollection services){services.AddAuthorizationCore().AddScoped<AuthenticationStateProvider, ApiAuthenticationStateProvider>().AddScoped<IAuthService, AuthService>();return services;}}
现在只需要在Program.cs里加一行代码:
builder.Services.AddAuth();
设计登录页面
登录页面的独特之处,在于布局。例如内容页面是有侧边导航栏的,但登录页面显然就没什么必要了。因此,我建议单独写一个LoginLayout组件,和默认布局MainLayout分开,只用于Login页面:
@inherits LayoutComponentBase<Layout Style="padding:0;margin:0"><Header Style="height:10%"><div style="margin:10px;"><AntDesign.Row Justify="space-around" Align="middle"><AntDesign.Col Span="8"><img src="/imgs/logo.png" style="align-self:center" /></AntDesign.Col><AntDesign.Col Span="8" Offset="8" Style="text-align:center"><span style="color:white; font-size:24px">欢迎使用 @ProductionName 后台管理系统</span></AntDesign.Col></AntDesign.Row></div></Header><Content Style="background-color:white; min-height:500px"><AntDesign.Row><AntDesign.Col Span="20" Offset="2"><div style="margin:100px 0">@Body</div></AntDesign.Col></AntDesign.Row></Content><MyFooter />
</Layout>@code {private const string ProductionName = "Demo";
}
借助于Antd的Layout和Grid组件,可以很轻松地搭建整个Login页面的布局,这里我采用了最简单的上中下三层布局。注意到@Body,Body是一种约定命名,表示布局内的页面主体。
对Login页面来说,@Body其实就是账户输入、密码输入和登录按钮。让我们在Pages文件夹里添加一个Login.razor:
@page "/login"
@layout LoginLayout
@inject NavigationManager NavigationManager
@inject MessageService MsgService
@inject IAuthService AuthService<AntDesign.Form Model="@_loginData" Style="height:100%"OnFinish="OnFinish"LabelColSpan="4"WrapperColSpan="4"><FormItem WrapperColOffset="10" WrapperColSpan="4"><AntDesign.Input Placeholder="请输入账号" AllowClear="true" @bind-Value="@context.Account"><Prefix><Icon Type="user"></Icon></Prefix></AntDesign.Input></FormItem><FormItem WrapperColOffset="10" WrapperColSpan="4"><InputPassword Placeholder="请输入密码" @bind-Value="@context.Password"><Prefix><Icon Type="lock"></Icon></Prefix></InputPassword></FormItem><FormItem WrapperColOffset="11" WrapperColSpan="2"><Button Type="@ButtonType.Primary" HtmlType="submit" Block>登录</Button></FormItem>
</AntDesign.Form>@code {private LoginRqtDto _loginData = new();private async Task OnFinish(EditContext editContext){var result = await AuthService.Login(_loginData);if (!result){await MsgService.Error("帐号或密码错误!");return;}await MsgService.Success("登录成功!");NavigationManager.NavigateTo("/home");}
}
我们使用@layout指令来指定当前页面组件使用哪一种布局;使用Antd提供的Form组件,可以很方便地完成控件布局并添加提交功能;再一次使用LoginRqtDto类,将其属性与控件的值双向绑定,实现最大化代码复用;使用依赖注入,在页面内方便地调用内置的NavigationManager和Antd提供的MessageService,分别用于页面跳转和消息提示。
页面效果如下:
依赖于Antd组件的出色实现,诸如密码的开闭显示等细节,都不必我们手动实现。还有一些细节并未在上面的代码里体现。例如,后端使用System.ComponentModel.DataAnnotations类库,可以很方便地对接口参数进行校验(如上文提到的LoginRqtDto类)。那么同样是使用C#,Blazor是否也可以这样做呢?
当然可以!Antd组件同样利用了接口参数的校验特性!相较于一般前后端开发,都需要通过API文档、团队纪律和组织沟通,来保证前后端各种数据和逻辑的一致性。而使用Blazor开发,在代码层面就可以天然地让前后端的行为一致!只要让定义接口的人将自己的数据放到Shared项目里即可。
(关于上图,有过Antd-Blazor开发经验的读者可能会好奇:这里校验提示为什么是中文而不是默认的英文?我将在下文“本地化校验提示”做简要说明。)
使用AuthorizeView组件动态显示内容
登录页面及服务设计好之后,还没有结束。对SPA应用来说,每个页面有自己单独的路由,用户可以手动输入路由绕过登录页面来访问其他页面。我们理所应当地希望如果用户未登录或认证失败,那么其他页面对用户将不提供任何有价值的数据。
对后端来说,数据相关的接口都必须加上[Authorize]特性,以校验访问者的身份。
对前端来说,应当以友好的方式提示用户登录,而不是依旧发送页面请求,依赖后端接口返回401或403再手动处理。
MainLayout和AuthorizeView组件可以帮助我们统一处理这种情况。
使用AuthorizeView组件之前,我们需要在App.razor文件里,使用CascadingAuthenticationState组件包裹Router组件:
@using Microsoft.AspNetCore.Components.Authorization<CascadingAuthenticationState><Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true"><Found Context="routeData"><AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" /></Found><NotFound> <MyNotFound /></NotFound></Router>
</CascadingAuthenticationState><AntContainer />
然后在MainLayout的Content部分使用AuthorizeView组件:
<Content Style="background-color:white; min-height:500px"><AuthorizeView><Authorized>@Body</Authorized><NotAuthorized><div style="margin: 100px 0; width:100%; text-align: center; color: red;"><span style="font-size:20px">检测到登录超时,请重新<a href="/login" style="text-decoration:underline">登录</a>!</span></div></NotAuthorized></AuthorizeView><BackTop></BackTop></Content>
单从标签命名上看就很容易理解:认证通过则显示@Body的内容,否则显示一行字提示用户访问登录页。让我们看下不登录情况下直接访问Home首页的效果:
这样,对于默认使用MainLayout布局的其他所有页面,若用户未认证,则只会显示上图的效果。同理,我们可以实现布局的Header部分动态显示:未认证情况下,不应显示上方“首页/关于”导航栏和右上方的账号信息,这里本文不再赘述。
本地化校验提示
至此本文核心内容都已经结束了。但在编写登录页面的过程中,有一个细节值得一提。
在设计登录页面一节中,我提到了前端校验提示。目前Antd组件在校验提示上,还是使用System.ComponentModel.DataAnnotations类库的默认提示:提示是全英文的。
在上文提到的LoginRqtDto中,我们可以使用Display特性,来修改校验失败提示时属性的展示名称。但并不能修改整个提示的内容,因此读者只会看到中英文混合的一段提示文本。
注意到校验特性的父类ValidationAttribute,有ErrorMessageResourceName和ErrorMessageResourceType两个属性。也就是说该父类在设计上,是支持本地化的,我们可以创建Resource资源,来替换类库默认的错误提示。
在XXX.Shared项目中,创建Resources文件夹,添加一个DA_zh_CN.resx文件(命名随意):
IDE VS会自动生成一个的DA_zh_CN.designer.cs文件,为你创建DA_zh_CN类。
将上文提到的LoginRqtDto改为:
public class LoginRqtDto{[Display(Name = "账号")][Required(ErrorMessageResourceName = "RequiredError", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))][StringLength(20, MinimumLength = 3, ErrorMessageResourceName = "StringLengthError_IncludingMin", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))]public string Account { get; set; }[Display(Name = "密码")][Required(ErrorMessageResourceName = "RequiredError", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))][StringLength(20, MinimumLength = 5, ErrorMessageResourceName = "StringLengthError_IncludingMin", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))]public string Password { get; set; }}
好了,收工。这里resx文件里“名称”列,我也不是随意取的,而是照搬官方源码里的名称。有兴趣的读者可以参阅System.ComponentModel.DataAnnotations类库的相关源码。
我也希望未来能有更简单的方式实现控件本地化校验提示。
结束语
下一篇文章会简单许多,我将介绍如何使用Antd的Card组件和优雅的Razor语法,做一个可灵活配置的、用于导航的首页。再会!