尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

.NET 6 WebApi JWT鉴权实战:从401调试到Token续签

.NET 6 WebApi JWT鉴权实战:从401调试到Token续签 简介本资源是一套基于.NET 6平台构建Web API并集成JWT身份鉴权的完整实战源码面向C#后端开发初学者及Web API安全实践者解决现代API服务中用户认证与授权的核心问题。压缩包含68个文件总大小1.43MB涵盖11个C#业务类如AuthenticationController、AuthenticationModel等、14个JSON配置文件含appsettings.json及NuGet缓存、18个DLL程序集及解决方案文件AuthenticationService.sln清晰呈现分层架构模型层、服务层、控制器层与Swagger集成配置。已有4503人学习下载资源结构规范、模块解耦明确可直接运行调试并支持通过Swagger UI交互式测试带JWT保护的接口。读者可快速掌握.NET 6中JWT令牌生成/验证、Authorize特性应用、Swashbuckle认证配置等关键技能为后续扩展角色权限、刷新令牌或对接IdentityServer打下坚实基础。1. 为什么你在 .NET 6 WebApi 里手写 JWT 鉴权却总在登录后 401、刷新 Token 失败、Swagger 无法带 Token 调试这不是一个“教你怎么装包”的入门教程。这是我在三个生产级 SPA 项目里踩过坑、重写过四版鉴权模块后把 .NET 6 WebApi JWT 的真实落地链路拧干水分后的复盘从Program.cs里那行AddAuthentication(JwtBearerDefaults.AuthenticationScheme)开始到前端拿到access_token后能稳定调用受保护接口、支持密码更新时自动失效旧 Token、Swagger 点击 Authorize 就能填入 Bearer Token 并成功请求——全程不依赖 IdentityServer4、不引入 EntityFramework Core 用户表、不堆砌中间件只用原生 .NET 6 的最小可行鉴权闭环。它解决的不是“能不能跑”而是“上线后用户反馈登录态突然消失”“测试同学说 Postman 总要手动粘贴 token”“JWT 过期时间改了但老 token 还在用”这类血泪问题。适合正在用 Vue/React 做 SPA、后端用 .NET 6 写 WebApi、需要快速交付且后续要支撑用户密码修改、Token 续签、多设备登录互踢等真实场景的工程师。别被“JWT 简单”骗了——玄学就藏在ClockSkew、ValidateLifetime和TokenValidationParameters的组合里。2. 从零构建可验证的 JWT 鉴权管道不碰数据库也能跑通登录 → 发 Token → 验证 → 接口拦截2.1 为什么选对称密钥HMAC-SHA256而非 RSA先跑通再升级.NET 6 默认支持两种签名算法HMAC-SHA256对称密钥和 RSA非对称密钥。新手常卡在第一步——纠结该用哪个。我的经验是开发阶段和中小项目无条件选 HMAC-SHA256。原因很现实不需要生成密钥对、不用管.pem或.xml密钥文件路径SymmetricSecurityKey直接传入byte[]一行代码搞定所有验证逻辑都在内存完成调试时断点能直接看到tokenString解析出的ClaimsPrincipal后续要升级 RSA只需替换SigningCredentials和TokenValidationParameters.IssuerSigningKey其他代码零改动。提示密钥字符串必须 ≥ 32 字符256 bit否则HmacSha256构造函数会抛ArgumentException。别用mysecret这种弱密钥用dotnet dev-certs https -v生成的随机串或在线工具生成 64 位 hex 字符串。// Program.cs —— 注册 JWT 认证服务.NET 6 Minimal Hosting Model var builder WebApplication.CreateBuilder(args); // 1. 从配置读取密钥推荐appsettings.Development.json var jwtSettings builder.Configuration.GetSection(JwtSettings); var key Encoding.UTF8.GetBytes(jwtSettings[SecretKey] ?? your-32-byte-secret-key-here-must-be-exactly-32-characters); // 2. 添加认证服务指定 Scheme 名为 Bearer并配置参数 builder.Services.AddAuthentication(options { options.DefaultAuthenticateScheme JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidateAudience true, ValidateLifetime true, // 必开否则过期 token 仍能通过 ValidateIssuerSigningKey true, ValidIssuer jwtSettings[Issuer], ValidAudience jwtSettings[Audience], IssuerSigningKey new SymmetricSecurityKey(key), // 关键允许时钟偏差解决服务器与客户端时间不同步 ClockSkew TimeSpan.FromMinutes(5) }; }); // 3. 启用授权中间件顺序不能错Authentication → Authorization builder.Services.AddAuthorization();2.2 登录接口接收账号密码 → 校验 → 生成 Token → 返回结构化响应这里不连数据库用硬编码模拟用户校验实际项目替换为IUserRepository.ValidateAsync()即可。重点在于Token 生成逻辑必须与验证逻辑严格对齐同样的Issuer、Audience、SigningKey、Expires否则AddJwtBearer会静默失败。// Controllers/AuthController.cs [ApiController] [Route(api/[controller])] public class AuthController : ControllerBase { private readonly IConfiguration _configuration; public AuthController(IConfiguration configuration) { _configuration configuration; } [HttpPost(login)] public IActionResult Login([FromBody] LoginRequest request) { // 1. 模拟用户校验实际应查 DB 或调用 UserService if (request.Username ! admin || request.Password ! Pssw0rd123) return Unauthorized(new { message 用户名或密码错误 }); // 2. 构建 Claims注意ClaimTypes.NameIdentifier 是用户唯一标识必须有 var claims new[] { new Claim(ClaimTypes.NameIdentifier, 1001), // 用户 ID new Claim(ClaimTypes.Name, admin), new Claim(ClaimTypes.Role, Admin), new Claim(Permission, Read,Write,Delete) // 自定义权限字段 }; // 3. 读取配置中的 JWT 参数 var jwtSettings _configuration.GetSection(JwtSettings); var key Encoding.UTF8.GetBytes(jwtSettings[SecretKey]); var issuer jwtSettings[Issuer]; var audience jwtSettings[Audience]; var expires TimeSpan.FromHours(double.Parse(jwtSettings[ExpiresHours] ?? 2)); // 4. 生成 Token关键使用与 AddJwtBearer 中完全一致的 SigningCredentials var token new JwtSecurityToken( issuer: issuer, audience: audience, claims: claims, notBefore: DateTime.UtcNow, expires: DateTime.UtcNow.Add(expires), signingCredentials: new SigningCredentials( new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256) ); var tokenString new JwtSecurityTokenHandler().WriteToken(token); // 5. 返回标准结构含 refresh_token 可选本节暂不实现续签 return Ok(new { access_token tokenString, expires_in (int)expires.TotalSeconds, token_type Bearer, user new { id 1001, username admin, role Admin } }); } } public class LoginRequest { [Required] public string Username { get; set; } string.Empty; [Required] public string Password { get; set; } string.Empty; }2.3 受保护接口用[Authorize]拦截 [AllowAnonymous]白名单控制.NET 6的[Authorize]特性默认作用于所有控制器方法除非显式标注[AllowAnonymous]。但要注意[Authorize]不等于 “检查 Token 是否存在”而是 “检查 Token 是否有效且包含至少一个满足策略的 Claim”。默认策略要求用户已认证即IsAuthenticated true不强制角色。若需角色控制用[Authorize(Roles Admin)]或自定义策略。// Controllers/ValuesController.cs [ApiController] [Route(api/[controller])] [Authorize] // ← 全局启用鉴权所有方法都需有效 Token public class ValuesController : ControllerBase { // GET api/values → 需要有效 Token [HttpGet] public ActionResultIEnumerablestring Get() { // 从 HttpContext.User 中提取 Claims这才是鉴权后的真实数据 var userId User.FindFirst(ClaimTypes.NameIdentifier)?.Value; var username User.Identity.Name; var roles User.Claims.Where(c c.Type ClaimTypes.Role).Select(c c.Value).ToArray(); return Ok(new { message $Hello {username}, user_id userId, roles roles, timestamp DateTime.UtcNow }); } // POST api/values/test → 也需 Token因控制器级 [Authorize] [HttpPost(test)] public IActionResult Test([FromBody] object data) { return Ok(new { received data, authenticated User.Identity.IsAuthenticated }); } // GET api/values/public → 显式放行无需 Token [HttpGet(public)] [AllowAnonymous] public IActionResult Public() { return Ok(new { message This is public endpoint }); } }2.4 Swagger 集成让测试同学点一下就能带 Token 调用没配 Swagger 的 JWT 鉴权就是半成品。.NET 6默认不启用 Swagger UI需手动添加。关键是AddSecurityDefinition和AddSecurityRequirement的配合——前者声明鉴权方式后者告诉 Swagger “哪些接口需要它”。// Program.cs —— 在 builder.Build() 之后app.UseRouting() 之前添加 var app builder.Build(); // 配置 Swagger仅 Development 环境启用 if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(c { c.SwaggerEndpoint(/swagger/v1/swagger.json, WebApi v1); // 关键注入 Bearer Token 输入框 c.ConfigObject.AdditionalItems.Add(persistAuthorization, true); // 刷新页面后保留 token }); } // 启用认证 授权中间件顺序UseAuthentication → UseAuthorization app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();// Program.cs —— 在 Services.AddSwaggerGen 里配置 JWT 支持 builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c { c.SwaggerDoc(v1, new OpenApiInfo { Title WebApi, Version v1 }); // 1. 定义安全方案名为 Bearer类型为 httpscheme 为 bearer c.AddSecurityDefinition(Bearer, new OpenApiSecurityScheme { Description JWT Authorization header using the Bearer scheme. Example: \Authorization: Bearer {token}\, Name Authorization, In ParameterLocation.Header, Type SecuritySchemeType.Http, Scheme bearer, BearerFormat JWT }); // 2. 全局应用该安全方案所有接口默认需要 Bearer Token c.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference new OpenApiReference { Type ReferenceType.SecurityScheme, Id Bearer } }, new string[] {} // 空数组表示无需 scope } }); });注意Swagger UI 中点击右上角Authorize按钮输入Bearer your-token注意Bearer后有一个空格之后所有带锁图标的方法都会自动带上Authorization: Bearer xxx请求头。persistAuthorization: true是关键——否则刷新页面后 token 丢失测试同学得反复粘贴。3. 鉴权失效的三大黑匣子401 不报错、Token 过期却仍可用、Swagger 带 Token 仍 4013.1 现象Postman 调用/api/auth/login成功返回 token但用该 token 调/api/values却 401原因AddJwtBearer的TokenValidationParameters配置与JwtSecurityToken生成时的参数不一致。最常见的是ValidIssuer/ValidAudience字符串大小写不匹配如生成时用MyApp验证时写myappIssuerSigningKey使用的密钥字节数组长度不足 32HMAC-SHA256 要求 256 bitValidateLifetime false开发时误关导致过期 token 也被接受。解决在Login方法中打印生成的 token 字符串Console.WriteLine(tokenString)复制到 https://jwt.io 解码确认iss、aud、exp字段值在AddJwtBearer的TokenValidationParameters中用Console.WriteLine输出ValidIssuer和ValidAudience确保完全一致检查密钥key.Length必须为 32UTF8 编码下32 字符字符串转byte[]正好 32 字节。3.2 现象Token 过期时间设为 2 小时但 3 小时后仍能访问受保护接口原因ClockSkew设置过大默认 5 分钟且ValidateLifetime true未开启。ClockSkew是允许的时钟偏差不是“延长有效期”。若ValidateLifetime false则exp字段被忽略token 永不过期。解决确保ValidateLifetime true已在 2.1 节代码中显式设置ClockSkew仅用于容忍服务器与客户端时间差绝不应设为大于 Token 有效期的值。生产环境建议设为TimeSpan.Zero或TimeSpan.FromMinutes(2)验证修改服务器系统时间为未来 3 小时调用接口应立即返回 401。3.3 现象Swagger 点击Authorize输入 token调用接口仍返回 401原因Swagger 的AddSecurityRequirement未正确关联AddSecurityDefinition的Id或中间件顺序错误。解决检查AddSecurityDefinition(Bearer, ...)中的Id第一个参数是否与AddSecurityRequirement中Reference.Id的值完全一致大小写敏感确认app.UseAuthentication()在app.UseAuthorization()之前且都在app.MapControllers()之前浏览器开发者工具 Network 面板中查看请求头确认Authorization: Bearer xxx是否真实发出有时浏览器插件会拦截清除浏览器缓存或换隐身窗口测试排除persistAuthorization缓存干扰。3.4 现象用户更新密码后旧 token 仍能访问接口原因JWT 是无状态的服务端不存储 token无法主动废止。这是 JWT 的设计特性不是 bug。解决按项目阶段选择轻量级方案推荐初期在用户密码更新时记录该用户的LastPasswordChangedAt时间戳存 DB 或内存 Cache并在IAuthorizationHandler中检查nbfNot Before是否早于该时间。需在Login时将nbf设为LastPasswordChangedAt进阶方案引入 Redis 存储已注销的 token IDjti在OnTokenValidated事件中查询黑名单。但增加复杂度和延迟务实方案缩短ExpiresHours至 30 分钟配合前端自动刷新机制见第 5 章让旧 token 快速自然过期。4. 把 JWT 鉴权变成可维护的模块抽离配置、统一异常、支持多环境密钥4.1 配置分离用JwtSettings类封装所有可变参数硬编码Issuer、Audience、ExpiresHours会导致环境切换困难Development/Test/Production。应定义强类型配置类并通过IConfiguration绑定。// Models/JwtSettings.cs public class JwtSettings { public string SecretKey { get; set; } string.Empty; public string Issuer { get; set; } string.Empty; public string Audience { get; set; } string.Empty; public double ExpiresHours { get; set; } 2; public int RefreshTokenExpiresDays { get; set; } 7; // 后续续签用 } // appsettings.json { JwtSettings: { SecretKey: your-32-byte-secret-key-here-must-be-exactly-32-characters, Issuer: https://localhost:5001, Audience: https://localhost:5001, ExpiresHours: 2 } }// Program.cs —— 注册配置绑定 builder.Services.ConfigureJwtSettings(builder.Configuration.GetSection(JwtSettings));4.2 统一异常处理把 401/403 转成 JSON 友好格式默认的Microsoft.AspNetCore.Authentication.JwtBearer返回的是 HTML 401 页面对 API 不友好。需捕获AuthenticationFailedContext并重写响应。// Program.cs —— 在 AddJwtBearer 中配置事件 .AddJwtBearer(options { // ... 其他参数见 2.1 节 options.Events new JwtBearerEvents { OnAuthenticationFailed context { // 生产环境关闭详细错误避免泄露密钥信息 if (context.HttpContext.RequestServices.GetServiceIWebHostEnvironment().IsDevelopment()) { context.Response.StatusCode StatusCodes.Status401Unauthorized; context.Response.ContentType application/json; return context.Response.WriteAsJsonAsync(new { success false, message Authentication failed, error context.Exception.Message }); } else { context.Response.StatusCode StatusCodes.Status401Unauthorized; context.Response.ContentType application/json; return context.Response.WriteAsJsonAsync(new { success false, message Unauthorized }); } }, OnTokenExpired context { context.Response.StatusCode StatusCodes.Status401Unauthorized; context.Response.ContentType application/json; return context.Response.WriteAsJsonAsync(new { success false, message Token expired, expired context.Expires }); } }; });4.3 多环境密钥管理Development 用dotnet user-secretsProduction 用 Azure Key Vaultappsettings.json中的SecretKey绝不能提交到 Git。.NET 6推荐用user-secrets管理开发密钥# 在项目根目录执行确保 csproj 有 UserSecretsId dotnet user-secrets set JwtSettings:SecretKey a-very-secure-64-character-hex-string-generated-by-openssl生产环境应使用 Azure Key Vault 或 AWS Secrets Manager。示例Azure// Program.cs —— 在 builder 创建后添加 Key Vault 配置源 if (builder.Environment.IsProduction()) { var secretClient new SecretClient( new Uri(builder.Configuration[KeyVault:Endpoint]), new DefaultAzureCredential()); builder.Configuration.AddAzureKeyVault(secretClient, new KeyVaultSecretManager()); }注意KeyVaultSecretManager需引用Azure.Extensions.AspNetCore.Configuration.Secrets包且 Key Vault 中的 Secret 名必须为JwtSettings--SecretKey双短横线分隔层级。5. 进阶实战实现 Token 续签Refresh Token与密码更新自动失效5.1 Refresh Token 原理与存储策略为什么不用 JWT 存 Refresh TokenRefresh Token 的核心诉求是可主动废止、有独立过期时间、与 Access Token 解耦。若用 JWT 存储 Refresh Token则又回到“无法主动注销”的困境。因此必须用服务端存储Redis 最佳。Access Token短时效30 分钟无状态用于日常接口调用Refresh Token长时效7 天服务端存储其 Hash 值 用户 ID 过期时间用于换取新 Access Token流程Access Token 过期 → 前端用 Refresh Token 调/api/auth/refresh→ 后端校验 Refresh Token 有效性 → 生成新 Access Token 新 Refresh Token旧的立即失效。5.2 实现/api/auth/refresh接口校验、签发、失效旧 Token// Controllers/AuthController.cs —— 新增方法 [HttpPost(refresh)] [AllowAnonymous] public async TaskIActionResult Refresh([FromBody] RefreshRequest request) { if (string.IsNullOrEmpty(request.RefreshToken)) return BadRequest(new { message Refresh token is required }); // 1. 从 Redis 获取存储的 Refresh Token 记录伪代码实际用 StackExchange.Redis var redisKey $refresh:{request.RefreshToken}; var stored await _redis.StringGetAsync(redisKey); if (stored.IsNullOrEmpty) return Unauthorized(new { message Invalid refresh token }); var tokenData JsonSerializer.DeserializeRefreshTokenData(stored); if (tokenData.UserId ! User.FindFirst(ClaimTypes.NameIdentifier)?.Value || tokenData.ExpiresUtc DateTime.UtcNow) { // Token 已过期或不属于当前用户 → 删除并拒绝 await _redis.KeyDeleteAsync(redisKey); return Unauthorized(new { message Refresh token expired or invalid }); } // 2. 生成新 Access Token复用 Login 中的逻辑 var jwtSettings _configuration.GetSection(JwtSettings); var key Encoding.UTF8.GetBytes(jwtSettings[SecretKey]); var claims new[] { new Claim(ClaimTypes.NameIdentifier, tokenData.UserId), new Claim(ClaimTypes.Name, tokenData.Username), new Claim(ClaimTypes.Role, tokenData.Role) }; var newAccessToken new JwtSecurityToken( issuer: jwtSettings[Issuer], audience: jwtSettings[Audience], claims: claims, notBefore: DateTime.UtcNow, expires: DateTime.UtcNow.AddHours(double.Parse(jwtSettings[ExpiresHours] ?? 0.5)), signingCredentials: new SigningCredentials( new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256) ); // 3. 生成新 Refresh Token随机 GUIDHash 后存 Redis var newRefreshToken Guid.NewGuid().ToString(); var newRefreshTokenHash Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(newRefreshToken))); var newRefreshTokenData new RefreshTokenData { UserId tokenData.UserId, Username tokenData.Username, Role tokenData.Role, ExpiresUtc DateTime.UtcNow.AddDays(int.Parse(jwtSettings[RefreshTokenExpiresDays] ?? 7)) }; await _redis.StringSetAsync($refresh:{newRefreshTokenHash}, JsonSerializer.Serialize(newRefreshTokenData), TimeSpan.FromDays(int.Parse(jwtSettings[RefreshTokenExpiresDays] ?? 7))); // 4. 失效旧 Refresh Token await _redis.KeyDeleteAsync(redisKey); return Ok(new { access_token new JwtSecurityTokenHandler().WriteToken(newAccessToken), refresh_token newRefreshToken, expires_in 1800 // 30 分钟 }); } public class RefreshRequest { public string RefreshToken { get; set; } string.Empty; } public class RefreshTokenData { public string UserId { get; set; } string.Empty; public string Username { get; set; } string.Empty; public string Role { get; set; } string.Empty; public DateTime ExpiresUtc { get; set; } }5.3 密码更新时自动失效所有 Token用UserVersion控制 Token 有效性不依赖黑名单用版本号实现轻量级注销。原理每次密码修改UserVersion1Token 中携带UserVersionClaim验证时比对存储的UserVersion。// Models/User.cs模拟用户实体 public class User { public string Id { get; set; } string.Empty; public string Username { get; set; } string.Empty; public string PasswordHash { get; set; } string.Empty; public int UserVersion { get; set; } 1; // 初始为 1 } // AuthService.UpdatePasswordAsync() 中 public async Task UpdatePasswordAsync(string userId, string newPassword) { var user await _userRepository.GetByIdAsync(userId); user.PasswordHash _passwordHasher.HashPassword(newPassword); user.UserVersion; // 关键版本号自增 await _userRepository.UpdateAsync(user); } // Login 时写入 UserVersion Claim var claims new[] { new Claim(ClaimTypes.NameIdentifier, user.Id), new Claim(ClaimTypes.Name, user.Username), new Claim(UserVersion, user.UserVersion.ToString()), // 新增 // ... }; // 在 JwtBearerEvents.OnTokenValidated 中验证 options.Events.OnTokenValidated context { var userId context.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; var tokenUserVersion context.Principal.FindFirst(UserVersion)?.Value; if (!string.IsNullOrEmpty(userId) !string.IsNullOrEmpty(tokenUserVersion)) { var dbUserVersion _userRepository.GetUserVersionAsync(userId).Result; if (int.TryParse(tokenUserVersion, out var tv) tv ! dbUserVersion) { context.Fail(User version mismatch - password may have been changed); } } };血泪经验OnTokenValidated是验证通过后、授权前的最后钩子此处context.Fail()会触发OnAuthenticationFailed返回 401。比在 Controller 里手动检查更早、更统一。希望帮到你。本文还有配套的精品资源点击获取
返回列表