ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Blazor 伪造后端登录注册:接口契约、Token 校验与真实 API 平滑迁移

Blazor 伪造后端登录注册:接口契约、Token 校验与真实 API 平滑迁移

简介:基于Blazor WebAssembly实现的登录与注册模块,面向C#/.NET开发者,解决无需JavaScript库即可完成前端交互与身份验证的问题,采用伪造后端模拟API调用以降低开发门槛。压缩包共61个文件,以.cs源码、.razor组件、.json配置和.css样式为主,整体仅828KB,目录按Services、Models、Pages、Shared等模块划分,便于逐段阅读。目前已有500人学习下载。示例涵盖Blazor组件化开发、数据注解式表单验证、路由导航与NavigationManager跳转,并通过FakeBackendHandler拦截HttpClient请求,配合localStorage保存用户会话,同时演示AuthenticationStateProvider管理认证状态。对于希望快速上手Blazor WebAssembly组件复用、客户端状态管理和前后端交互模拟的初学者,是一份结构清晰、可直接运行的参考实现。

1. 为什么要在 Blazor 里先搭一套伪造后端登录注册

后端接口还在设计阶段,前端页面却已经画好了登录和注册弹窗,这种情况在 C# 项目里太常见了。等接口联调会拖慢进度,硬编一个固定返回又无法覆盖“用户已存在”“密码错误”“Token 过期”这些真实分支。blazor-login-register 的思路是把伪造后端作为项目的一等公民:用内存或 JSON 文件存储用户,在 Blazor 应用内实现一套完整的登录、注册、Token 校验流程。这套方案的直接价值是让前端认证模块先闭环,业务逻辑、表单校验、状态管理都能独立验证。适合正在做 Blazor WebAssembly 或 Blazor Server 认证模块的 C# 开发者,尤其是后端进度还不明确、又不想干等接口的团队。

2. 伪造后端从哪下手:先定接口契约,再选数据载体

2.1 接口契约:登录、注册、校验三个端点必须先写清楚

伪造后端不能只在组件里写两个 if 就完事,那样登录注册逻辑绑死在 UI 层,后面替换真实 API 几乎要重写。我一般会先写一份接口约定,用 C# 的 record 类型把请求和响应模型固定下来,让前端和后端都能对着同一份数据结构开发。

// Models/AuthModels.cs public record LoginRequest(string Username, string Password); public record RegisterRequest(string Username, string Password, string Email); public record AuthResponse(bool Success, string Token, string Message, string Username); // 伪造后端校验 Token 用的请求/响应 public record TokenValidationRequest(string Token); public record TokenValidationResponse(bool IsValid, string Username);

这里把 LoginRequest 和 RegisterRequest 定义成纯数据载体,是因为登录注册字段在前后端之间传播时,不应该夹杂任何 UI 特性。用 record 而不是 class,除了语法更简洁,还能免费获得值比较能力,后面写单元测试断言时可以直接比较两个对象字段。

参数说明:Username 和 Password 是必须字段,Email 在注册请求里可空但建议保留。AuthResponse 里的 Message 字段承载错误码,比如user_not_found、bad_password、username_taken,前端拿 Error Code 做映射,而不是直接展示英文字符串。TokenValidationRequest 设计成独立模型,是为了让前端的“每次请求都带 Token”习惯从第一天就养成,避免后续换成真实 API 时手忙脚乱。

定义好模型后,伪造后端必须把所有路由集中到一个服务类里,不能散落在各个 razor 页面中。下面给出核心方法的签名:

// Services/MockAuthService.cs public class MockAuthService { public Task<AuthResponse> LoginAsync(LoginRequest request); public Task<AuthResponse> RegisterAsync(RegisterRequest request); public Task<TokenValidationResponse> ValidateTokenAsync(string token); public Task<bool> LogoutAsync(string token); }

每个方法返回 Task 而不是同步结果,这是刻意模仿真实 HTTP 调用的异步模式。Blazor 组件里统一用await调用,将来替换成 HttpClient 时,不用改调用点。如果伪造后端返回同步结果,组件代码里就不需要 async/await,等接真实 API 时要改一堆地方,这就是给自己挖坑。

2.2 数据载体:用内存字典还是 JSON 文件,区别在重启后的状态

伪造后端存储用户有两条路线:存在静态 Dictionary 里,实现最快,但 Blazor 应用重新编译或刷新进程后数据全部丢失;写进本地 JSON 文件,数据持久化在磁盘,适合反复调试注册功能时不用每次重新创建账号。这个项目建议选 JSON 文件,因为登录注册的测试场景天然跨页面跳转,需要重启服务调试 UI 样式,如果每次重启都要从头注册,效率太低。

// Services/UserStore.cs public class UserStore { private readonly string _storagePath; private Dictionary<string, UserRecord> _users; public UserStore(IWebHostEnvironment env) { _storagePath = Path.Combine(env.ContentRootPath, "App_Data", "users.json"); _users = LoadUsers(); } private Dictionary<string, UserRecord> LoadUsers() { if (!File.Exists(_storagePath)) { return new Dictionary<string, UserRecord>( StringComparer.OrdinalIgnoreCase); } var json = File.ReadAllText(_storagePath); return JsonSerializer.Deserialize<Dictionary<string, UserRecord>>(json) ?? new Dictionary<string, UserRecord>( StringComparer.OrdinalIgnoreCase); } public void SaveUsers() { var dir = Path.GetDirectoryName(_storagePath); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); var json = JsonSerializer.Serialize(_users, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(_storagePath, json); } }

这个 UserRecord 类要单独定义,字段应该明确区分存储字段和业务字段:

public class UserRecord { public string Username { get; set; } public string PasswordHash { get; set; } public string Email { get; set; } public DateTime CreatedAt { get; set; } }

注意 LoadUsers 里给 Dictionary 用了StringComparer.OrdinalIgnoreCase,这样“Tom”和“tom”会被认为同一个用户,符合大多数真实系统的业务规则。如果漏掉这个比较器,伪造后端的行为和真实数据库的 unique 索引不一致,等接真实 API 时才发现大小写用户名重复注册,血泪代价很大。

SaveUsers 方法在每次注册成功或修改密码后调用,不要放在构造里。JSON 文件路径中的 App_Data 目录可以手动创建,也可以用Directory.CreateDirectory兜底。文件格式用缩进模式,方便开发时直接打开查看注册了哪些用户。

2.3 Token 的伪造逻辑:用 GUID 加时间戳模拟 JWT,别真的去搞密钥

真实 JWT 要用密钥签名、配置发行人、受众,这套东西放在伪造后端里会干扰精力。伪造 Token 的目的只是让前端有东西可存、可传、可校验,所以用一个带过期时间的 Base64 字符串就够了。

public string GenerateToken(string username) { var quote = $"{Guid.NewGuid():N}|{DateTime.UtcNow.AddHours(2):O}|{username}"; return Convert.ToBase64String(Encoding.UTF8.GetBytes(quote)); } public bool TryValidateToken(string token, out string username) { username = null; if (string.IsNullOrEmpty(token)) return false; try { var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(token)); var parts = decoded.Split('|'); if (parts.Length != 3) return false; var expireTime = DateTime.Parse(parts[1], null, DateTimeStyles.RoundtripKind); if (expireTime < DateTime.UtcNow) return false; username = parts[2]; return true; } catch { return false; } }

GenerateToken 里把 GUID、过期时间、用户名用竖线分隔拼接后 Base64 编码。GUID 保证每次生成的 Token 都不同,防止前端把固定 Token 硬编码。过期时间用 ISO 8601 格式是为了统一解析,避免不同环境下的日期格式歧义。

TryValidateToken 里拆解字符串后,先检查是否过期,再取出用户名。这里刻意没有验证签名或篡改,因为伪造后端不需要防御攻击,只需要模拟“Token 无效”的返回路径。前端如果手动改乱了 Token,解析时会走 catch 分支返回 false,这在验证前端跳转逻辑时完全够用。

参数说明:过期时间默认 2 小时,你可以改成AddMinutes(30)来测试前端自动登出的计时逻辑。如果你需要模拟 refresh token 刷新机制,可以再生成一个 refreshToken 字段,但基本版里一个 Token 字段就够,加多了反而让前端误以为要去实现刷新流程。

2.4 把 Mock 服务挂到应用里:延迟与异常分支不能少

伪造后端还需要模拟网络延迟和异常抛出,否则前端永远不知道真实接口慢起来是什么体验。注册和登录方法里,我一般让响应延迟 300ms 到 800ms,随机取值,这样 Loading 状态就能真实展示。

public async Task<AuthResponse> LoginAsync(LoginRequest request) { await Task.Delay(Random.Shared.Next(300, 800)); if (!_users.TryGetValue(request.Username, out var user)) { return new AuthResponse(false, null, "user_not_found", null); } if (user.PasswordHash != HashPassword(request.Password)) { return new AuthResponse(false, null, "bad_password", null); } var token = GenerateToken(request.Username); return new AuthResponse(true, token, "ok", request.Username); }

HashPassword 这里哪怕用最原始的 SHA256 也行,关键是让前端感觉到“密码不是纯文本存库”。模拟延迟的另一个作用是暴露前端的重复点击问题——如果组件没有 Lock 按钮,用户会在 800ms 里提交好几遍,导致重复注册。这个延迟就是故意用来逼你写 Loading 状态处理的。

路由注册方面,如果你把 Mock 服务以一个独立的 ASP.NET Core Minimal API 跑在另一个端口,那么注意在 Program.cs 里加 CORS,因为 Blazor WebAssembly 从浏览器调跨域 API 会被拦。但如果选择直接作为服务注入到 Blazor 应用内部,就不存在 CORS 问题,这也是伪造后端最常见的做法。

3. Blazor 登录注册组件的实现:表单校验与状态管理

3.1 注册组件:两次密码校验的两种做法

注册页面最常见的业务规则是“确认密码必须一致”,这个校验可以放在前端表单层,也可以放在伪造后端做二次校验。实际项目中前端用 DataAnnotations 解决第一层,后端再检查一遍是为了兼容没有前端的客户端调用。伪造后端阶段只需做前端校验。

@* Pages/Register.razor *@ @inject MockAuthService AuthService <EditForm Model="_model" OnValidSubmit="HandleSubmit"> <DataAnnotationsValidator /> <ValidationSummary /> <div class="form-group"> <label>用户名</label> <InputText @bind-Value="_model.Username" class="form-control" /> <ValidationMessage For="() => _model.Username" /> </div> <div class="form-group"> <label>密码</label> <InputText @bind-Value="_model.Password" type="password" class="form-control" /> <ValidationMessage For="() => _model.Password" /> </div> <div class="form-group"> <label>确认密码</label> <InputText @bind-Value="_model.ConfirmPassword" type="password" class="form-control" /> <ValidationMessage For="() => _model.ConfirmPassword" /> </div> <button type="submit" class="btn btn-primary">注册</button> </EditForm>

注意这里用的模型是一个单独的 RegisterModel,而不是直接使用 RegisterRequest。因为 ConfirmPassword 字段只属于前端 UI,不该进入后端契约。RegisterModel 里的验证特性是第一种做法:

public class RegisterModel { [Required(ErrorMessage = "用户名不能为空")] [MinLength(3, ErrorMessage = "用户名至少3位")] public string Username { get; set; } [Required(ErrorMessage = "密码不能为空")] [MinLength(6, ErrorMessage = "密码至少6位")] public string Password { get; set; } [Required(ErrorMessage = "确认密码不能为空")] [Compare(nameof(Password), ErrorMessage = "两次密码不一致")] public string ConfirmPassword { get; set; } }

Compare是 DataAnnotations 自带特性,比较两个属性值,差异时直接显示错误。第二种做法是在 HandleSubmit 里手动判断_model.Password != _model.ConfirmPassword,然后追加到 validation messages。手动做法的好处是可以同时清空两个密码框,但代码量更多。我建议用 Compare 特性,因为 Blazor 的<ValidationMessage>会自动显示错误,少写不少逻辑。

HandleSubmit 里调用伪造后端后,还要处理业务错误:

private async Task HandleSubmit() { var request = new RegisterRequest( _model.Username, _model.Password, _model.Email); var result = await AuthService.RegisterAsync(request); if (result.Success) { Navigation.NavigateTo("/login"); } else { _errorMessage = result.Message switch { "username_taken" => "用户名已被占用", _ => "注册失败" }; } }

这段映射里,username_taken是伪造后端返回的业务错误码,前端负责翻译成用户能看懂的文字。这里有一个容易忽略的点:Navigation.NavigateTo("/login")最好传一个绝对路径,不要用相对路径,否则在 Blazor 子路由下会拼出错误路径。

3.2 登录组件:Loading 状态与错误码映射

登录页面比注册页面更强调 Loading 状态,因为登录是高频操作,用户会在等待时反复点“登录”按钮,导致伪造后端收到多个并发请求。就算伪造后端没并发问题,真实 API 肯定会遇到。

@* Pages/Login.razor *@ <EditForm Model="_login" OnValidSubmit="LoginAsync"> <div class="form-group"> <label>用户名</label> <InputText @bind-Value="_login.Username" class="form-control" /> </div> <div class="form-group"> <label>密码</label> <InputText @bind-Value="_login.Password" type="password" class="form-control" /> </div> @if (!string.IsNullOrEmpty(_errorMessage)) { <div class="alert alert-danger">@_errorMessage</div> } <button type="submit" class="btn btn-primary" disabled="@_isSubmitting"> @(_isSubmitting ? "登录中..." : "登录") </button> </EditForm>

后台逻辑用_isSubmitting字段控制按钮禁用,并且在 finally 里恢复。如果直接尝试用await Task.Delay模拟真实延迟,你会发现按钮在点击后没有立即变成 Loading,这是因为 EditForm 的 OnValidSubmit 在异步方法里的运行顺序问题,解决方案是先将_isSubmitting设为 true,然后立刻调用StateHasChanged()。

private async Task LoginAsync() { _isSubmitting = true; StateHasChanged(); try { var resp = await AuthService.LoginAsync( new LoginRequest(_login.Username, _login.Password)); if (resp.Success) { await _authStateProvider.SetTokenAsync(resp.Token, resp.Username); Navigation.NavigateTo("/", forceLoad: false); } else { _errorMessage = resp.Message switch { "user_not_found" => "用户名不存在", "bad_password" => "用户名或密码错误", _ => "登录暂时不可用" }; } } finally { _isSubmitting = false; StateHasChanged(); } }

switch 表达式的作用是把后端 Error Code 映射成中文文案。这里的字符串映射是为了演示,如果你面临多语言需求,可以把它换成 Dictionary 或资源文件。映射逻辑要放在组件里而不是伪造后端,因为后端不应该关心前端用户的语言是中文还是英文。

3.3 认证状态管理:Scoped 服务加事件通知,避免组件各存各的

登录成功后,导航栏要变成用户信息加退出按钮,受保护页面要检查 Token 是否存在。如果每个组件各自维护一个 bool 值,状态很难同步。常见的架构是定义一个 AuthStateProvider 服务,作为认证状态的唯一数据源。

// Services/AuthStateProvider.cs public class AuthStateProvider { private readonly MockAuthService _authService; public string Token { get; private set; } public string Username { get; private set; } public bool IsAuthenticated => !string.IsNullOrEmpty(Token); public event Action StateChanged; public AuthStateProvider(MockAuthService authService) { _authService = authService; } public async Task SetTokenAsync(string token, string username) { Token = token; Username = username; StateChanged?.Invoke(); await Task.CompletedTask; } public void Logout() { Token = null; Username = null; StateChanged?.Invoke(); } }

这个服务里StateChanged事件告诉所有订阅者“认证状态变了”,各组件在OnInitialized里订阅,并在事件发生时调用StateHasChanged()刷新 UI。在 Blazor Server 端,这个服务要注册成 Scoped 而不是 Singleton,否则所有用户共享同一个登录状态,那就非常危险。

为了简化订阅逻辑,可以写一个基类组件:

public class AuthComponentBase : ComponentBase, IDisposable { [Inject] protected AuthStateProvider AuthState { get; set; } protected override void OnInitialized() { AuthState.StateChanged += OnAuthStateChanged; } private void OnAuthStateChanged() => InvokeAsync(StateHasChanged); public void Dispose() { AuthState.StateChanged -= OnAuthStateChanged; } }

其他页面比如 Profile.razor 继承 AuthComponentBase,然后在 UI 里判断AuthState.IsAuthenticated。这样做的好处是,登录和退出操作触发事件后,所有页面自动刷新,不需要手工写导航逻辑。

3.4 受保护页面的验证逻辑:伪造后端阶段就模拟真实管道

受保护页面不能只在 UI 上隐藏按钮,还要在进入页面时验证 Token 是否仍然有效。伪造后端阶段必须把这个逻辑做成一个可复用方法,否则将来替换真实 API 时,受保护页面的检查逻辑会被打回重做。

// Services/ProtectedPageHelper.cs public static class ProtectedPageHelper { public static async Task<NavigationResult> EnsureAuthenticatedAsync( AuthStateProvider authState, MockAuthService authService) { if (!authState.IsAuthenticated) { return NavigationResult.RedirectToLogin; } var validation = await authService.ValidateTokenAsync(authState.Token); if (!validation.IsValid) { authState.Logout(); return NavigationResult.RedirectToLogin; } return NavigationResult.Ok; } }

在需要保护的组件里,先调用这个方法:

protected override async Task OnInitializedAsync() { var result = await ProtectedPageHelper.EnsureAuthenticatedAsync( AuthState, AuthService); if (result == NavigationResult.RedirectToLogin) { Navigation.NavigateTo("/login"); return; } // 继续加载页面数据 }

注意这里调用了ValidateTokenAsync,意味着每次进入受保护页面都会调用一次伪造后端的方法。将来换成真实 API 时,这就是一次 HTTP 请求。如果你的 Blazor Server 项目对刷新频繁,可以改成从 localStorage 读取 Token 并做一次简单的有效期判断,但伪造后端阶段建议保持完整验证,这样迁移时只要替换ValidateTokenAsync的实现即可。

4. 避坑实践:伪造后端从入门到翻车的六个常见问题

4.1 刷新页面后登录状态丢失

现象:登录成功后跳转到首页一切正常,但按 F5 刷新页面,又跳回登录页。

原因:Blazor WebAssembly 应用进程在刷新时被完全销毁,内存里保存的 Token 和用户名全部清空。如果 AuthStateProvider 只是把 Token 存到属性里,没有持久化,刷新后自然找不到登录态。

解决:在 AuthStateProvider 初始化时从浏览器 localStorage 读取 Token。可以把 IJSInProcessRuntime 注入服务,或者用封装好的 Storage 帮助类。读取后调用ValidateTokenAsync验证是否过期,如果有效就恢复 Username。关键点是注册服务时使用 Scoped 生命周期,确保每个页面会话拿到的是同一个实例,但从 storage 恢复的逻辑只执行一次。

4.2 注册成功后跳转登录页,仍提示用户已存在

现象:注册成功后再去登录,输入同样的用户名和密码,却提示用户名不存在,或者反过来提示“用户不存在”。

原因:伪造后端的 UserStore 在启动时加载了 JSON 文件内容,注册时往内存字典里添加了新用户,但保存 JSON 文件的时机不对。如果注册回调里没有立即调用SaveUsers(),下次启动服务后用户数据丢失,自然就“不存在”了。

解决:注册方法里在成功添加用户后立刻调用SaveUsers()。还有另一个隐藏坑:如果注册方法里先检查用户名是否存在,再插入用户,但是对字典的读和写之间没有加锁,并发双击会导致两个请求都通过检查,然后抛异常。伪造后端阶段可以用lock (_lockObj)包住检查加写入的操作,模拟真实数据库的原子性。

4.3 HttpClient 跨域请求被浏览器拦截

现象:Blazor WebAssembly 项目里的登录按钮点击后,控制台报 CORS error,请求没有真正发出去。

原因:伪造后端如果作为独立 ASP.NET Core WebAPI 项目运行,而 Blazor WebAssembly 项目跑在另一个 localhost 端口,浏览器会拦截跨域 HTTP 响应。

解决:在伪造后端项目的 Program.cs 里显式添加 CORS 策略。不能用AllowAnyOrigin,因为真实项目中不可能放开所有来源,这里只允许本地开发地址。可以这样:

builder.Services.AddCors(options => { options.AddPolicy("DevCors", policy => policy.SetIsOriginAllowed(origin => origin.StartsWith("http://localhost:") || origin.StartsWith("https://localhost:"))); }); var app = builder.Build(); app.UseCors("DevCors");

如果你选择把伪造后端直接注入到 Blazor Server 服务端,而不是走 HttpClient,那就没有 CORS 问题,但那样失去了模拟真实网络请求的意义,因为真实 API 一定是跨网络的。

4.4 手动改 users.json 文件后,程序读不到最新数据

现象:注册逻辑调试中,打开 users.json 看到用户已写入,但修改文件内容后重新登录,伪造后端用的还是旧数据。

原因:UserStore 构造函数里把 JSON 文件读到内存字典后,后续所有操作都以内存字典为准,文件只是启动时的一次性加载。手工改文件后没有触发内存刷新。

解决:有两种修复思路。一种是每次登录和注册都直接读文件,但这样效率低并且代码更繁琐。另一种是在 UserStore 内部维护一个文件监视器,或者提供 Refresh 方法,只在调试时调。最省事的做法是修改完文件后重启 Blazor 应用,伪造后端就能重新加载。如果不想重启,那就按下面的代码改成从文件恢复:

public Dictionary<string, UserRecord> GetUsers() { using var fs = new FileStream(_storagePath, FileMode.OpenOrCreate, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(fs); var json = reader.ReadToEnd(); return JsonSerializer.Deserialize<Dictionary<string, UserRecord>>(json) ?? new Dictionary<string, UserRecord>(); }

调用GetUsers()的时机放在每次校验密码之前,可以保证手动修改立即生效。这个方法在真实 API 场景下对应的是“每次查询数据库”,所以使用它会更贴近最终的交互模式。

4.5 密码框用密码管理器自动填充,导致校验值不对

现象:登录页面用浏览器自带密码管理器填充用户名和密码后,点登录提示“用户名或密码错误”,但手输就能成功。

原因:Blazor 的<InputText>默认绑定onchange事件,而密码管理器自动填充时不触发 onchange,导致模型里的 Password 字段还是空字符串或旧值。

解决:把密码输入框的绑定事件改成oninput,这样每次输入或填充都会更新模型:

<InputText @bind-Value="_login.Password" type="password" @bind:event="oninput" class="form-control" />

这个小问题在伪造后端阶段就会出现,如果不处理,迁移到真实 API 时会变成特别玄学的 bug。注意注册页面的确认密码字段同样需要加@bind:event="oninput",否则自动填充也会导致两次密码不一致的假报错。

4.6 替换真实 API 后,受保护页面全部 401

现象:伪造后端阶段一切正常,把 MockAuthService 换成 HttpClient 调用真实 API 后,任何受保护页面请求都返回 401 Unauthorized。

原因:伪造后端的 Token 校验方法ValidateTokenAsync只在页面初始化时显式调用,并没有在每次 HTTP 请求时自动携带 Authorization Header。真实 API 需要前端在每次请求头里加 Token,如果伪造后端阶段没有在 HttpClient 的请求管道里统一添加,迁移后自然漏掉。

解决:在伪造后端阶段就定义一个自定义 DelegatingHandler,把 Token 从 AuthStateProvider 取出来加到请求头:

public class AuthMessageHandler : DelegatingHandler { private readonly AuthStateProvider _authState; public AuthMessageHandler(AuthStateProvider authState) { _authState = authState; } protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { if (_authState.IsAuthenticated) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authState.Token); } return await base.SendAsync(request, cancellationToken); } }

注册 HttpClient 时加上这个 Handler:

builder.Services.AddScoped<AuthStateProvider>(); builder.Services.AddTransient<AuthMessageHandler>(); builder.Services.AddHttpClient("AuthApi") .AddHttpMessageHandler<AuthMessageHandler>();

这样伪造后端阶段就模拟了“每个请求都自动携带 Token”的管道,等替换真实 API 时,这个 Handler 可以直接沿用,只需要改 BaseAddress 和认证地址。如果你在伪造后端阶段偷懒不做这层,迁移时就要在所有调用点手工加 Header,那几乎等同于重写。

5. 进阶:把伪造后端换成真实 API,迁移时只需改一个地方

先定义 IAuthService 接口,把 MockAuthService 和将来的 RealAuthService 都实现它。接口里就三个方法:LoginAsync、RegisterAsync、ValidateTokenAsync。组件里全部注入 IAuthService,而不是直接注入 MockAuthService。

public interface IAuthService { Task<AuthResponse> LoginAsync(LoginRequest request); Task<AuthResponse> RegisterAsync(RegisterRequest request); Task<TokenValidationResponse> ValidateTokenAsync(string token); }

伪造后端的 MockAuthService 实现这个接口,里面保持现有逻辑。真实 API 的 RealAuthService 用 HttpClient 调用远程端点,返回的 AuthResponse 字段完全一致。在 Program.cs 里切换注册方式:

var useMock = builder.Configuration.GetValue<bool>("UseMockBackend"); if (useMock) { builder.Services.AddScoped<IAuthService, MockAuthService>(); builder.Services.AddScoped<UserStore>(); } else { builder.Services.AddScoped<IAuthService, RealAuthService>(); builder.Services.AddScoped<HttpClient>(sp => new HttpClient { BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]) }); }

UseMockBackend这个配置项放在 appsettings.json 里,默认 true,部署到测试环境时改成 false。切换后,组件代码一行不用改,因为接口没变。真正需要改的是 Token 的生成和校验逻辑,但那是服务内部实现,与 UI 无关。

验证迁移是否成功的标准有三个。第一,登录成功后在 localStorage 里能看到 Token,且 Token 解密后包含用户名和过期时间;第二,直接访问一个受保护页面 URL,未登录状态会跳转登录,登录后能正常进入;第三,手动改坏 Token 后访问受保护页面,会被强制登出并回到登录页。这三条都通过,说明前端的认证闭环和真实 API 的认证闭环行为一致。

另外要关注 Token 刷新机制。伪造后端阶段 Token 过期时间是 2 小时,真实 API 可能只有 30 分钟,甚至带 refresh token。如果要支持刷新,可以在 AuthStateProvider 里加一个 RefreshToken 属性和 TryRefreshAsync 方法。伪造后端阶段可以直接返回一个新的 Token,真实 API 阶段则调用刷新端点。前端组件只需要在收到 401 响应时调用 TryRefreshAsync,然后重试一次请求。

从那以后,我每次搭 Blazor 项目,不管后端进度如何,都会先用伪造后端把认证管道完整实现一遍,尤其强制走一遍受保护页面携带 Authorization Header 的逻辑。这个习惯帮我避开了不少替换 API 时的翻车情况,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进