使用 Blazor 开发内部后台:登录
Posted dotNET跨平台
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了使用 Blazor 开发内部后台:登录相关的知识,希望对你有一定的参考价值。
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语法,做一个可灵活配置的、用于导航的首页。再会!
以上是关于使用 Blazor 开发内部后台:登录的主要内容,如果未能解决你的问题,请参考以下文章