受欢迎的博客标签

syl之2: 使用 Blazor server开发小程序内部后台(admin)wjl

Published

 

前言

本文将介绍一个简单的设计方案:如何基于Blazor开发内部后台登录页面(及相关模块)。为了方便初学者理解正文,本文会先介绍一些工程上必须掌握的基础知识,有经验的开发者可以选择性跳过。

托管Blazor server应用(Blazor server)

Blazor WA应用可以单独部署,称之为独立Blazor WA(Standalone),通常用于(不需要后端的)离线应用或者后端服务基于非ASP.NET Core的情形。而将Blazor作为ASP.NET Core应用的前端部分一起部署,则被称为托管Blazor(Hosted)。很显然,若要开发一个前后端分离的应用,采用托管Blazor,才能最大程度地发挥Blazor的开发和部署优势。

项目基本结构

托管Blazor WA应用的项目解决方案,主要包含三大子项目:

  • 小程序:客户端项目:前端模块,即Blazor应用。

  • WeixinAdmin:ASP.NET Core Blazor server服务端项目:后端模块,调用ASP.NET Core Web API。在最后部署的时候,是由此项目进行发布的,因此该项目会引用Client项目。

  • Weixin.Api:ASP.NET Core Web API项目:共享模块,主要是存放前后端可以共用的数据或逻辑,其他2个项目都要引用它。

而针对Client项目,内部也有自己的默认结构,这里请读者自行阅读Blazor项目结构官方文档,篇幅所限,后文将默认读者已经熟悉这些基础结构。

 

WeixinAdmin:ASP.NET Core Blazor server

 

依赖注入

依赖注入是ASP.NET Core里一个非常基础的设计模式。Blazor里延续了和后端开发同样的风格。例如前端向后端发送请求,需要使用HttpClient,在Program.cs文件里,可以看到:

  1.  
    public class Program
  2.  
    {
  3.  
    public static async Task Main(string[] args)
  4.  
    {
  5.  
    var builder = WebAssemblyHostBuilder.CreateDefault(args);
  6.  
    builder.RootComponents.Add<App>("#app");
  7.  
     
  8.  
    builder.Services
  9.  
    .AddScoped(sp => new HttpClient
  10.  
    {
  11.  
    BaseAddress = new Uri(builder.HostEnvironment.BaseAddress),
  12.  
    Timeout = TimeSpan.FromSeconds(3)
  13.  
    });
  14.  
     
  15.  
    await builder.Build().RunAsync();
  16.  
    }
  17.  
    }
 

又例如:我们按照Ant-Design-Blazor项目的《快速上手》说明,引入该开源组件Nuget包后,也需要在这里加上依赖注入的代码行(其他需要的操作详见项目文档):

builder.Services.AddAntDesign();
 

这对ASP.NET Core后端开发者来说,完全没有理解门槛。而在Page文件里,需要使用HttpClient时,只需要使用@inject关键词声明即可:

  1.  
    @inject HttpClient MyHttpClient
  2.  
     
  3.  
    <div>
  4.  
    .......
  5.  
    </div>
  6.  
     
  7.  
    @code{
  8.  
    private async Task<string> GetAsync()
  9.  
    {
  10.  
    string rsp = await MyHttpClient.GetStringAsync(xxxx);
  11.  
    return rsp;
  12.  
    }
  13.  
    }
 

这里请读者自行阅读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一样要用到依赖注入:

  1.  
    builder.Services.AddBlazoredLocalStorage(config =>
  2.  
    {
  3.  
    config.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
  4.  
    config.JsonSerializerOptions.IgnoreNullValues = true;
  5.  
    config.JsonSerializerOptions.IgnoreReadOnlyProperties = true;
  6.  
    config.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
  7.  
    config.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
  8.  
    config.JsonSerializerOptions.ReadCommentHandling = JsonCommentHandling.Skip;
  9.  
    config.JsonSerializerOptions.WriteIndented = false;
  10.  
    });
 

设计后端接口

既然已经确认要使用JWT,那么后端自然要提供一个认证的接口:

  1.  
    public class AccountController : ApiControllerBase
  2.  
    {
  3.  
    private readonly IMemoryCache _cache;
  4.  
    private readonly IOptionsMonitor<JwtOption> _jwtOpt;
  5.  
     
  6.  
    private readonly IPasswordCryptor _passwordCryptor;
  7.  
     
  8.  
    private readonly MyDbContext _efContext;
  9.  
     
  10.  
    public AccountController(ILogger<AccountController> logger,
  11.  
    IMemoryCache cache,
  12.  
    IOptionsMonitor<JwtOption> jwtOpt,
  13.  
    IPasswordCryptor passwordCryptor,
  14.  
    MyDbContext efContext) : base(logger)
  15.  
    {
  16.  
    _cache = cache;
  17.  
    _jwtOpt = jwtOpt;
  18.  
    _passwordCryptor = passwordCryptor;
  19.  
    _efContext = efContext;
  20.  
    }
  21.  
    [HttpPost]
  22.  
    public async Task<IActionResult> Login([FromForm] LoginRqtDto rqtDto)
  23.  
    {
  24.  
    var cryptedPwd = _passwordCryptor.Encrypt(rqtDto.Password, default);
  25.  
    string adminIdCacheKey = CacheKeyHelper.GetAdminIdCacheKey(rqtDto.Account);
  26.  
    if (!_cache.TryGetValue(adminIdCacheKey, out int adminId))
  27.  
    {
  28.  
    adminId = await _efContext.Admins
  29.  
    .Where(a => a.Account == rqtDto.Account && a.Password == cryptedPwd)
  30.  
    .Select(a => a.AdminId)
  31.  
    .FirstOrDefaultAsync();
  32.  
    if (adminId < 1)
  33.  
    {
  34.  
    return Unauthorized();
  35.  
    }
  36.  
    _cache.Set(adminIdCacheKey, adminId, TimeSpan.FromDays(1));
  37.  
    }
  38.  
    else
  39.  
    {
  40.  
    bool checkPwd = await _efContext.Admins.AnyAsync(a => a.AdminId == adminId && a.Password == cryptedPwd);
  41.  
    if (!checkPwd)
  42.  
    {
  43.  
    return Unauthorized();
  44.  
    }
  45.  
    }
  46.  
    var claims = new Claim[]
  47.  
    {
  48.  
    new(ClaimTypes.NameIdentifier, adminId.ToString()),
  49.  
    new(ClaimTypes.Name, rqtDto.Account),
  50.  
    new(ClaimTypes.Role, "admin")
  51.  
    };
  52.  
    var jwtSetting = _jwtOpt.CurrentValue;
  53.  
    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSetting.Key));
  54.  
    var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
  55.  
    var expiry = DateTime.Now.AddHours(jwtSetting.ExpiryInHours);
  56.  
    var token = new JwtSecurityToken(jwtSetting.Issuer, jwtSetting.Audience, claims, expires: expiry, signingCredentials: creds);
  57.  
    var tokenText = new JwtSecurityTokenHandler().WriteToken(token);
  58.  
    return Ok(tokenText);
  59.  
    }
  60.  
    }
 

还需要配置JWT相关的参数:

  1.  
    "JWT": {
  2.  
    "Key": "xxx",
  3.  
    "Issuer": "xxx",
  4.  
    "Audience": "xxx",
  5.  
    "ExpiryInHours": 8
  6.  
    }
 

及依赖注入:

  1.  
    public static IServiceCollection AddAuth(this IServiceCollection services, IConfiguration configuration)
  2.  
    {
  3.  
    services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  4.  
    .AddJwtBearer(options =>
  5.  
    {
  6.  
    options.TokenValidationParameters = new TokenValidationParameters
  7.  
    {
  8.  
    ValidateIssuer = true,
  9.  
    ValidateAudience = true,
  10.  
    ValidateLifetime = true,
  11.  
    ValidateIssuerSigningKey = true,
  12.  
    ValidIssuer = configuration.GetValue<string>("JWT:Issuer"),
  13.  
    ValidAudience = configuration.GetValue<string>("JWT:Audience"),
  14.  
    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration.GetValue<string>("JWT:Key"))),
  15.  
    RequireExpirationTime = true
  16.  
    };
  17.  
    });
  18.  
     
  19.  
    services.Configure<JwtOption>(configuration.GetSection("JWT"));
  20.  
    return services;
  21.  
    }
 

以上代码仅供读者参考,可按实际需要增删改。另有一句与本文主旨无关的提醒:虽然是内部后台系统,但管理员登录密码还是要做加盐Hash处理,明文保存密码在任何地方都不可取!

设计前端服务

有的读者可能更喜欢UI先行,那么可以先看下面一节“设计登录页面”。

有了跟后端一样的依赖注入,我们可以将前端的认证也封装成服务。在项目中增加Services文件夹,添加AuthService.cs文件:

  1.  
    using Blazored.LocalStorage;
  2.  
    using Microsoft.AspNetCore.Components.Authorization;
  3.  
     
  4.  
    internal class AuthService : IAuthService
  5.  
    {
  6.  
    private readonly HttpClient _httpClient;
  7.  
    private readonly AuthenticationStateProvider _authenticationStateProvider;
  8.  
    private readonly ILocalStorageService _localStorage;
  9.  
     
  10.  
    public AuthService(HttpClient httpClient,
  11.  
    AuthenticationStateProvider authenticationStateProvider,
  12.  
    ILocalStorageService localStorage)
  13.  
    {
  14.  
    _httpClient = httpClient;
  15.  
    _authenticationStateProvider = authenticationStateProvider;
  16.  
    _localStorage = localStorage;
  17.  
    }
  18.  
     
  19.  
    public async Task<bool> Login(LoginRqtDto rqtDto)
  20.  
    {
  21.  
    var content = new FormUrlEncodedContent(new KeyValuePair<string, string>[]
  22.  
    {
  23.  
    new(nameof(LoginRqtDto.Account), rqtDto.Account),
  24.  
    new(nameof(LoginRqtDto.Password), rqtDto.Password),
  25.  
    });
  26.  
    using var rsp = await _httpClient.PostAsync("/account/login", content);
  27.  
    if (!rsp.IsSuccessStatusCode)
  28.  
    {
  29.  
    return false;
  30.  
    }
  31.  
    var authToken = await rsp.Content.ReadAsStringAsync();
  32.  
    await _localStorage.SetItemAsync("authToken", authToken);
  33.  
    ((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsAuthenticated(rqtDto.Account);
  34.  
    _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken);
  35.  
    return true;
  36.  
    }
  37.  
     
  38.  
    public async Task Logout()
  39.  
    {
  40.  
    await _localStorage.RemoveItemAsync("authToken");
  41.  
    ((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsLoggedOut();
  42.  
    _httpClient.DefaultRequestHeaders.Authorization = null;
  43.  
    }
  44.  
    }
 

首先要注意的是AuthenticationStateProvider,这是一个抽象类,由Microsoft.AspNetCore.Components.Authorization类库提供,它用来提供当前用户的认证状态信息。既然是抽象类,我们需要自定义一个它的子类,基于JWT和LocalStorage实现它要求的规则(即GetAuthenticationStateAsync方法):

  1.  
    using System.Security.Claims;
  2.  
    using Blazored.LocalStorage;
  3.  
    using Microsoft.AspNetCore.Components.Authorization;
  4.  
     
  5.  
    public class ApiAuthenticationStateProvider : AuthenticationStateProvider
  6.  
    {
  7.  
    private readonly HttpClient _httpClient;
  8.  
    private readonly ILocalStorageService _localStorage;
  9.  
     
  10.  
    public ApiAuthenticationStateProvider(HttpClient httpClient, ILocalStorageService localStorage)
  11.  
    {
  12.  
    _httpClient = httpClient;
  13.  
    _localStorage = localStorage;
  14.  
    }
  15.  
     
  16.  
    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
  17.  
    {
  18.  
    var savedToken = await _localStorage.GetItemAsync<string>("authToken");
  19.  
     
  20.  
    if (string.IsNullOrWhiteSpace(savedToken))
  21.  
    {
  22.  
    return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
  23.  
    }
  24.  
     
  25.  
    _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", savedToken);
  26.  
     
  27.  
    return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(ParseClaimsFromJwt(savedToken), "jwt")));
  28.  
    }
  29.  
     
  30.  
    public void MarkUserAsAuthenticated(string account)
  31.  
    {
  32.  
    var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, account) }, "apiauth"));
  33.  
    var authState = Task.FromResult(new AuthenticationState(authenticatedUser));
  34.  
    NotifyAuthenticationStateChanged(authState);
  35.  
    }
  36.  
     
  37.  
    public void MarkUserAsLoggedOut()
  38.  
    {
  39.  
    var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
  40.  
    var authState = Task.FromResult(new AuthenticationState(anonymousUser));
  41.  
    NotifyAuthenticationStateChanged(authState);
  42.  
    }
  43.  
     
  44.  
    private static IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
  45.  
    {
  46.  
    var claims = new List<Claim>();
  47.  
    var payload = jwt.Split('.')[1];
  48.  
    var jsonBytes = ParseBase64WithoutPadding(payload);
  49.  
    var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
  50.  
     
  51.  
    if (keyValuePairs.TryGetValue(ClaimTypes.Role, out object roles) && roles is string rolesText)
  52.  
    {
  53.  
    if (rolesText.StartsWith('['))
  54.  
    {
  55.  
    var parsedRoles = JsonSerializer.Deserialize<string[]>(rolesText);
  56.  
    foreach (var parsedRole in parsedRoles)
  57.  
    {
  58.  
    claims.Add(new Claim(ClaimTypes.Role, parsedRole));
  59.  
    }
  60.  
    }
  61.  
    else
  62.  
    {
  63.  
    claims.Add(new Claim(ClaimTypes.Role, rolesText));
  64.  
    }
  65.  
     
  66.  
    keyValuePairs.Remove(ClaimTypes.Role);
  67.  
    }
  68.  
     
  69.  
    claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString())));
  70.  
     
  71.  
    return claims;
  72.  
    }
  73.  
     
  74.  
    private static byte[] ParseBase64WithoutPadding(string base64)
  75.  
    {
  76.  
    switch (base64.Length % 4)
  77.  
    {
  78.  
    case 2: base64 += "=="; break;
  79.  
    case 3: base64 += "="; break;
  80.  
    }
  81.  
    return Convert.FromBase64String(base64);
  82.  
    }
  83.  
    }
 

逻辑并不复杂。以上代码需要读者对JWT和System.Security.Claims类库比较熟悉,建议初学者动手实践和调试。

ILocalStorageService自然是由上文提到的Blazor.LocalStorage类库依赖注入。

之前系列文章都提到了Blazor在.NET全栈开发下,具有极大的开发效率优势。这里就有体现——既然后端已经提供了接口,注意到LoginRqtDto类:

  1.  
    using System.ComponentModel.DataAnnotations;
  2.  
     
  3.  
    public class LoginRqtDto
  4.  
    {
  5.  
    [Display(Name = "账号")]
  6.  
    [Required]
  7.  
    [StringLength(20, MinimumLength = 3)]
  8.  
    public string Account { get; set; }
  9.  
    [Display(Name = "密码")]
  10.  
    [Required]
  11.  
    [StringLength(20, MinimumLength = 5]
  12.  
    public string Password { get; set; }
  13.  
    }
 

我们自然可以将该类放到Shared项目中,使得前端Blazor项目在调用Login接口时可以不必再另写请求参数的Model。另外,不单单是类本身的属性,特性也可以被前后端共同利用,这一点放到下文再讲。

写完了该服务,可别忘了依赖注入!我的习惯是让Program.cs里的代码尽可能精简,因此,我会创建一个Extensions文件夹,添加ServiceCollectionExtension.cs文件:

  1.  
    using Microsoft.AspNetCore.Components.Authorization;
  2.  
    using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
  3.  
    using Microsoft.Extensions.DependencyInjection;
  4.  
     
  5.  
    internal static class ServiceCollectionExtension
  6.  
    {
  7.  
    public static IServiceCollection AddAuth(this IServiceCollection services)
  8.  
    {
  9.  
    services
  10.  
    .AddAuthorizationCore()
  11.  
    .AddScoped<AuthenticationStateProvider, ApiAuthenticationStateProvider>()
  12.  
    .AddScoped<IAuthService, AuthService>();
  13.  
     
  14.  
    return services;
  15.  
    }
  16.  
    }
 

现在只需要在Program.cs里加一行代码:

builder.Services.AddAuth();
 

设计登录页面

登录页面的独特之处,在于布局。例如内容页面是有侧边导航栏的,但登录页面显然就没什么必要了。因此,我建议单独写一个LoginLayout组件,和默认布局MainLayout分开,只用于Login页面:

  1.  
    @inherits LayoutComponentBase
  2.  
     
  3.  
    <Layout Style="padding:0;margin:0">
  4.  
    <Header Style="height:10%">
  5.  
    <div style="margin:10px;">
  6.  
    <AntDesign.Row Justify="space-around" Align="middle">
  7.  
    <AntDesign.Col Span="8">
  8.  
    <img src="/imgs/logo.png" style="align-self:center" />
  9.  
    </AntDesign.Col>
  10.  
    <AntDesign.Col Span="8" Offset="8" Style="text-align:center">
  11.  
    <span style="color:white; font-size:24px">欢迎使用 @ProductionName 后台管理系统</span>
  12.  
    </AntDesign.Col>
  13.  
    </AntDesign.Row>
  14.  
    </div>
  15.  
    </Header>
  16.  
    <Content Style="background-color:white; min-height:500px">
  17.  
    <AntDesign.Row>
  18.  
    <AntDesign.Col Span="20" Offset="2">
  19.  
    <div style="margin:100px 0">
  20.  
    @Body
  21.  
    </div>
  22.  
    </AntDesign.Col>
  23.  
    </AntDesign.Row>
  24.  
    </Content>
  25.  
    <MyFooter />
  26.  
    </Layout>
  27.  
     
  28.  
    @code {
  29.  
    private const string ProductionName = "Demo";
  30.  
    }
 

借助于Antd的Layout和Grid组件,可以很轻松地搭建整个Login页面的布局,这里我采用了最简单的上中下三层布局。注意到@Body,Body是一种约定命名,表示布局内的页面主体。

对Login页面来说,@Body其实就是账户输入、密码输入和登录按钮。让我们在Pages文件夹里添加一个Login.razor:

  1.  
    @page "/login"
  2.  
    @layout LoginLayout
  3.  
    @inject NavigationManager NavigationManager
  4.  
    @inject MessageService MsgService
  5.  
    @inject IAuthService AuthService
  6.  
     
  7.  
    <AntDesign.Form Model="@_loginData" Style="height:100%"
  8.  
    OnFinish="OnFinish"
  9.  
    LabelColSpan="4"
  10.  
    WrapperColSpan="4">
  11.  
    <FormItem WrapperColOffset="10" WrapperColSpan="4">
  12.  
    <AntDesign.Input Placeholder="请输入账号" AllowClear="true" @bind-Value="@context.Account">
  13.  
    <Prefix>
  14.  
    <Icon Type="user"></Icon>
  15.  
    </Prefix>
  16.  
    </AntDesign.Input>
  17.  
    </FormItem>
  18.  
    <FormItem WrapperColOffset="10" WrapperColSpan="4">
  19.  
    <InputPassword Placeholder="请输入密码" @bind-Value="@context.Password">
  20.  
    <Prefix>
  21.  
    <Icon Type="lock"></Icon>
  22.  
    </Prefix>
  23.  
    </InputPassword>
  24.  
    </FormItem>
  25.  
    <FormItem WrapperColOffset="11" WrapperColSpan="2">
  26.  
    <Button Type="@ButtonType.Primary" HtmlType="submit" Block>
  27.  
    登录
  28.  
    </Button>
  29.  
    </FormItem>
  30.  
    </AntDesign.Form>
  31.  
     
  32.  
    @code {
  33.  
    private LoginRqtDto _loginData = new();
  34.  
     
  35.  
    private async Task OnFinish(EditContext editContext)
  36.  
    {
  37.  
    var result = await AuthService.Login(_loginData);
  38.  
    if (!result)
  39.  
    {
  40.  
    await MsgService.Error("帐号或密码错误!");
  41.  
    return;
  42.  
    }
  43.  
    await MsgService.Success("登录成功!");
  44.  
    NavigationManager.NavigateTo("/home");
  45.  
    }
  46.  
    }
 

我们使用@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组件:

  1.  
    @using Microsoft.AspNetCore.Components.Authorization
  2.  
     
  3.  
    <CascadingAuthenticationState>
  4.  
    <Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
  5.  
    <Found Context="routeData">
  6.  
    <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
  7.  
    </Found>
  8.  
    <NotFound>
  9.  
    <MyNotFound />
  10.  
    </NotFound>
  11.  
    </Router>
  12.  
    </CascadingAuthenticationState>
  13.  
     
  14.  
    <AntContainer />
 

然后在MainLayout的Content部分使用AuthorizeView组件:

  1.  
    <Content Style="background-color:white; min-height:500px">
  2.  
    <AuthorizeView>
  3.  
    <Authorized>
  4.  
    @Body
  5.  
    </Authorized>
  6.  
    <NotAuthorized>
  7.  
    <div style="margin: 100px 0; width:100%; text-align: center; color: red;">
  8.  
    <span style="font-size:20px">检测到登录超时,请重新<a href="/login" style="text-decoration:underline">登录</a>!</span>
  9.  
    </div>
  10.  
    </NotAuthorized>
  11.  
    </AuthorizeView>
  12.  
    <BackTop></BackTop>
  13.  
    </Content>
 

单从标签命名上看就很容易理解:认证通过则显示@Body的内容,否则显示一行字提示用户访问登录页。让我们看下不登录情况下直接访问Home首页的效果:

NotAuthorized时的Content

这样,对于默认使用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改为:

  1.  
    public class LoginRqtDto
  2.  
    {
  3.  
    [Display(Name = "账号")]
  4.  
    [Required(ErrorMessageResourceName = "RequiredError", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))]
  5.  
    [StringLength(20, MinimumLength = 3, ErrorMessageResourceName = "StringLengthError_IncludingMin", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))]
  6.  
    public string Account { get; set; }
  7.  
    [Display(Name = "密码")]
  8.  
    [Required(ErrorMessageResourceName = "RequiredError", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))]
  9.  
    [StringLength(20, MinimumLength = 5, ErrorMessageResourceName = "StringLengthError_IncludingMin", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))]
  10.  
    public string Password { get; set; }
  11.  
    }
 

好了,收工。这里resx文件里“名称”列,我也不是随意取的,而是照搬官方源码里的名称。有兴趣的读者可以参阅System.ComponentModel.DataAnnotations类库的相关源码。

我也希望未来能有更简单的方式实现控件本地化校验提示。

结束语

下一篇文章会简单许多,我将介绍如何使用Antd的Card组件和优雅的Razor语法,做一个可灵活配置的、用于导航的首页。再会!