使用 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首页的效果:

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改为:

    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 开发内部后台:登录的主要内容,如果未能解决你的问题,请参考以下文章

使用 Blazor 开发内部后台:了解 Blazor 组件

使用Blazor开发内部后台:认识Blazor

WTM Blazor,Blazor开发利器

在Blazor实现微信小程序扫码登录

使用MASA Blazor开发一个标准的查询表格页

一起学Blazor WebAssembly 开发