ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

ASP.NET Core 集成 JWT 完整指南:认证授权与安全加固实战

ASP.NET Core 集成 JWT 完整指南:认证授权与安全加固实战 1. 为什么我从 Session 切到了 JWT一次真实的架构选型记录做 ASP.NET Core 开发的朋友应该都有过这种经历项目一开始老老实实用 Session 存登录状态等做到前后端分离、客户端从浏览器换成小程序和 App 之后Session 开始变得束手束脚。我也一样今年维护的一个老项目就是典型的 Web Forms 架构迁移到 ASP.NET Core 的场景当时面临两个选择继续沿用 Session 方案做跨域兼容还是直接集成 JWT 认证授权。先说结论我选了 JWT并且把整条认证链路从登录签发、接口校验、权限隔离到 token 续签全部重做了一遍。这个决定不是拍脑袋做的原因主要有三个。第一客户端不再是单一浏览器。除了网页端还有公众号 H5、小程序、第三方 API 对接方Session 依赖 Cookie 的会话机制在非浏览器环境下要多写一堆兼容代码而 JWT 本身就是无状态的token 放在请求头里客户端只需要在每次请求时带上Authorization: Bearer token即可。第二认证和授权天然分离。Session 方案通常只能告诉你这个人是谁权限判断要在每个接口里查一遍会话数据而 JWT 可以在 token 的 claims 里直接携带角色、权限点、用户 ID、租户信息后端在中间件层就能完成大部分授权判断接口层代码干净很多。第三扩展性。JWT 的校验逻辑是纯算法的不依赖服务端存储这意味着一组校验代码可以在多个 API 服务之间复用以后就算把单体应用拆成微服务认证中心依然能无缝对接。这篇文章会围绕我这次集成的完整过程来写内容包括 JWT 的核心原理拆解、ASP.NET Core 8 里的具体集成代码、自定义授权策略的实现、token 过期与续签方案以及安全加固方面的实战经验。如果你正在做前后端分离项目或者在考虑怎么给 API 加一层统一的认证保护这篇文章应该能给你省下不少踩坑的时间。2. JWT 的三段式结构Header、Payload、Signature 到底存了什么在动手写代码之前我建议先把 JWT 的结构吃透。很多人用 JWT 只停留在调一个 GenerateToken 方法的程度出了问题无从下手其实就是对 token 本身的构成不够清楚。2.1 一个 JWT 长什么样一个标准的 JWT 由三部分组成用英文句点分隔样式如下eyJhbGciOiJIUzI1NiIsImtpZCI6Im15LWtleS1pZCJ9.eyJzdWIiOiIxMDAxIiwibmFtZSI6ImFkbWluIiwicm9sZSI6IkFkbWluIiwiZXhwIjoxNzA1MDAwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c第一部分是 Header第二部分是 Payload第三部分是 Signature。每段都是 Base64Url 编码后的 JSON所以理论上你可以把任意一段复制出来解码看内容这也是为什么不要在 JWT 的 payload 里存密码、身份证号这类敏感信息——它只是编码不是加密。2.2 Header声明算法和密钥标识Header 通常包含两个字段{ alg: HS256, typ: JWT, kid: my-key-id }alg指明签名算法常见的有 HS256对称签名、RS256非对称签名。kid是可选的密钥标识当你有多个签名密钥轮换时后端靠kid来快速定位用哪把密钥验签。这个字段我在后面的安全加固部分会专门展开讲因为网上很多 JWT 漏洞分析都跟kid的处理不当有关。2.3 Payload携带业务声明Payload 是 JWT 的正文可以放标准声明Registered Claims和自定义声明Custom Claims。标准声明里最常用的是这几个subSubject用户主体标识通常放用户 IDexpExpiration Time过期时间时间戳格式iatIssued At签发时间nbfNot Before生效时间早于该时间不可用issIssuer签发者audAudience受众标识 token 给谁用自定义声明则根据需要自由添加比如用户角色、权限点、租户 ID、昵称、头像 URL 等。ASP.NET Core 里通过Claim对象来构造这些数据后面代码部分会具体演示。2.4 Signature整个 token 的可信基础签名是用 Header 里声明的算法把Base64Url(Header) . Base64Url(Payload)拼起来再用密钥做哈希计算得到的。拿 HS256 来说它的验证本质就是后端用同一个密钥重新计算一遍签名再跟前端传来的签名比对——能对上说明 token 在传输过程中没有被篡改对不上直接拒绝请求。这里有个非常重要的认知JWT 的签名保护的是完整性不是机密性。如果你需要让某些数据在 token 里不可被客户端直接看到应该考虑加密方案比如 JWEJSON Web Encryption或者干脆把敏感数据放服务端缓存token 里只放一个索引。这点很多初学者会栽跟头。3. ASP.NET Core 8 集成 JWT 的完整实操从 NuGet 包到第一个受保护接口接下来进入正题。我用的开发环境是 Visual Studio 2022 .NET 8项目类型是空的 Web API 模板。整个集成过程分五步走每一步我都标注了需要注意的细节。3.1 安装认证相关的 NuGet 包在已有项目里执行以下命令或者直接在 NuGet 包管理器里搜索安装dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer这个包会带上System.IdentityModel.Tokens.Jwt和Microsoft.IdentityModel.Tokens等依赖前者用于生成 token后者提供签名验证等底层能力。一个包就够了不需要额外装别的。3.2 配置 appsettings.json我把 JWT 相关的配置单独放一个节点{ Jwt: { Issuer: YourAppIssuer, Audience: YourAppAudience, SecretKey: your-256-bit-secret-key-here-change-it-in-production, ExpireMinutes: 120, RefreshTokenExpireDays: 7, ClockSkewSeconds: 60 } }SecretKey这一个值的设置我多说一句长度最少 32 个字符。因为 HS256 要求密钥至少 256 位如果密钥太短某些 JWT 库会直接抛异常或者即便能运行暴力破解的成本也会低很多。生产环境里应该把这个值放到环境变量、密钥管理服务比如 Azure Key Vault或者用户机密里绝不要明文提交到代码仓库。ClockSkewSeconds是用来容忍客户端和服务端时间偏差的默认值是 5 分钟但实际项目里一般不需要这么大的窗口设置成 60 秒够用了。窗口越大token 被重放利用的时间就越长。3.3 注册 JWT 认证服务在Program.cs里加上认证服务的注册。注意我这里的配置虽然看起来有点长其实是把每一步都显式写出来了方便后面安全策略的扩展using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; var builder WebApplication.CreateBuilder(args); var jwtSettings builder.Configuration.GetSection(Jwt); var secretKey Encoding.UTF8.GetBytes(jwtSettings[SecretKey]!); builder.Services.AddAuthentication(options { options.DefaultAuthenticateScheme JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer jwtSettings[Issuer], ValidateAudience true, ValidAudience jwtSettings[Audience], ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey(secretKey), ValidateLifetime true, ClockSkew TimeSpan.FromSeconds( Convert.ToDouble(jwtSettings[ClockSkewSeconds])) }; options.Events new JwtBearerEvents { OnMessageReceived context { // 支持从 query string 读取 token // 主要用于 SignalR 等无法自定义 Header 的场景 var accessToken context.Request.Query[access_token]; if (!string.IsNullOrEmpty(accessToken) context.HttpContext.Request.Path.StartsWithSegments(/hubs)) { context.Token accessToken; } return Task.CompletedTask; }, OnChallenge context { // 认证失败时返回 401这里可以自定义响应格式 return Task.CompletedTask; } }; }); builder.Services.AddAuthorization(); var app builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();UseAuthentication和UseAuthorization的顺序不能反——必须先做身份认证再做授权判断。这两个中间件要在MapControllers之前注册否则走到了 Controller 层才发现用户未认证链路就乱了。3.4 编写 Token 生成服务生成 token 的逻辑我封装成了一个独立的 service一来方便复用二来可以把 Claims 的构建集中管理。下面是核心代码using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; public class JwtTokenService { private readonly IConfiguration _configuration; public JwtTokenService(IConfiguration configuration) { _configuration configuration; } public string GenerateToken(LoginUser user) { var jwtSettings _configuration.GetSection(Jwt); var secretKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(jwtSettings[SecretKey]!)); var claims new ListClaim { new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new(ClaimTypes.Name, user.UserName), new(ClaimTypes.Role, user.Role), new(department, user.Department), new(tenant_id, user.TenantId.ToString()) }; var credentials new SigningCredentials( secretKey, SecurityAlgorithms.HmacSha256); var token new JwtSecurityToken( issuer: jwtSettings[Issuer], audience: jwtSettings[Audience], claims: claims, notBefore: DateTime.UtcNow, expires: DateTime.UtcNow.AddMinutes( Convert.ToDouble(jwtSettings[ExpireMinutes])), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); } }LoginUser是一个简单的 DTO包含了用户 Id、用户名、角色、部门和租户 ID 这些字段。你需要根据自己的用户表结构调整 Claims 的组装逻辑。这里有个细节值得注意JwtRegisteredClaimNames.Sub和ClaimTypes.NameIdentifier是不同的 Claim Type如果你在 Controller 里用User.FindFirstValue(ClaimTypes.NameIdentifier)去取用户 ID而生成时用的是JwtRegisteredClaimNames.Sub会取到 null因为两者映射的 URI 不一样。我建议在整个项目里统一用JwtRegisteredClaimNames.Sub或自己定义一个常量避免这种低级但很隐蔽的坑。3.5 写一个带登录接口的 Controller 验证效果接着注册服务在登录接口里调用 token serviceusing Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; [ApiController] [Route(api/[controller])] public class AuthController : ControllerBase { private readonly JwtTokenService _tokenService; public AuthController(JwtTokenService tokenService) { _tokenService tokenService; } [HttpPost(login)] [AllowAnonymous] public IActionResult Login([FromBody] LoginRequest request) { // 这里应该调用用户服务验证用户名密码 // 真实项目中建议配合验证码后续章节会讲 if (request.UserName ! admin || request.Password ! 123456) { return Unauthorized(new { message 用户名或密码错误 }); } var user new LoginUser { Id 1, UserName admin, Role Admin, Department IT, TenantId 100 }; var token _tokenService.GenerateToken(user); return Ok(new { token, expiresIn 7200 }); } [HttpGet(profile)] [Authorize] public IActionResult GetProfile() { var userId User.FindFirstValue(JwtRegisteredClaimNames.Sub); var role User.FindFirstValue(ClaimTypes.Role); return Ok(new { userId, role }); } }[AllowAnonymous]是登录接口必须加的不然会出现还没登录怎么登录的死循环。其他接口默认加[Authorize]没有 token 或 token 无效的请求会被中间件拦截返回 401。运行项目后用 Postman 或 Apifox 测一下先 POSTapi/auth/login拿 token再 GETapi/auth/profile并在请求头加Authorization: Bearer token能正常返回用户信息就说明整个链路通了。4. 授权不只是登录基于自定义策略实现按钮级权限控制很多团队做到上一步就收工了反正 token 能生成、接口能保护住。但真实业务里往往需要更细的权限控制——比如只有部门经理能审批报销单、只有租户管理员能修改租户配置。这种需求靠[Authorize]标签远远不够需要引入策略授权。4.1 什么是策略为什么要用它策略Policy本质上是一组授权规则的集合它在认证成功之后对用户身份做二次判断决定这个人能不能做这件事。跟直接在代码里写if (role Admin)相比策略的好处是规则集中管理、可以组合复用、支持基于断言和基于角色两种模式而且能配合依赖注入做复杂的逻辑判断。在 ASP.NET Core 里注册策略很简单builder.Services.AddAuthorization(options { options.AddPolicy(RequireAdmin, policy policy.RequireRole(Admin)); options.AddPolicy(DepartmentManager, policy policy.RequireClaim(department, IT) .RequireRole(Manager, Admin)); options.AddPolicy(CanApproveExpense, policy policy.RequireAssertion(context context.User.HasClaim(c c.Type permission c.Value.Contains(expense:approve)))); });然后在 Controller 或 Action 上声明策略即可[Authorize(Policy CanApproveExpense)] [HttpPost(approve)] public IActionResult ApproveExpense([FromBody] ExpenseRequest request) { // 只有包含 expense:approve 权限声明的用户才能进入这里 return Ok(); }4.2 自定义授权处理器比角色更灵活的权限模型角色 常规策略能覆盖大部分场景但遇到复杂的权限判断——比如数据权限只能看自己部门的数据、多租户隔离只能操作自己租户内的资源——就得写自定义AuthorizationHandler。下面是一个租户隔离的例子public class TenantAuthorizationHandler : AuthorizationHandlerTenantRequirement { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, TenantRequirement requirement) { var tenantIdClaim context.User.FindFirstValue(tenant_id); if (string.IsNullOrEmpty(tenantIdClaim)) { return Task.CompletedTask; } // 当请求路径中包含 tenant/{tenantId} 时校验两者是否一致 var httpContext context.Resource as HttpContext; var routeTenantId httpContext?.Request.RouteValues[tenantId]?.ToString(); if (routeTenantId ! null routeTenantId tenantIdClaim) { context.Succeed(requirement); } return Task.CompletedTask; } } public class TenantRequirement : IAuthorizationRequirement { }注册方式builder.Services.AddSingletonIAuthorizationHandler, TenantAuthorizationHandler(); builder.Services.AddAuthorization(options { options.AddPolicy(TenantAccess, policy policy.Requirements.Add(new TenantRequirement())); });这种基于 Handler 的方式是我比较推荐的因为它在认证通过和接口授权之间插入了一层可注入的、可单元测试的逻辑层。另外建议在开发时开启AuthorizationHandlerContext.Fail()的日志记录不然授权失败时查起来非常难受你只知道 403 了但不清楚是哪个 Handler 拒绝了请求。4.3 HttpContext.User 里的 Claims 从哪里来上面代码里用context.User.FindFirstValue(tenant_id)取声明可能有人会疑惑这个User对象为什么会有 token 里的 claims答案藏在 JWT 中间件的处理流程里。当请求带着 token 进来时JwtBearerHandler会调用TokenValidationParameters里的验证逻辑验证通过后把 token 的 payload 反序列化成ClaimsPrincipal默认映射到System.Security.Claims.ClaimsIdentity上然后赋值给HttpContext.User。后续无论在授权 Handler 还是 Controller 里访问User拿到的都是这个从 token 解析出来的身份对象。这里有个容易踩的坑是关于JwtSecurityTokenHandler.DefaultInboundClaimTypeMap的映射问题。默认情况下sub会被映射成ClaimTypes.NameIdentifierrole会被映射成ClaimTypes.Rolename会被映射成ClaimTypes.Name。如果你在自定义 claims 里用了像department这种非标准名称则不会触发映射原样保留。而之前提过的sub取不到值的问题根源就是映射机制在做翻译。为了减少混乱不少人会直接关掉映射JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();关掉之后所有 claims 都保持 token 里的原始名称不再自动替换成ClaimTypes那套 URI 风格。但要注意这也意味着[Authorize(Roles Admin)]这种写法会失效因为角色 claim 的名字不再被识别为ClaimTypes.Role。要么配套使用策略policy.RequireClaim(role, Admin)要么在生成 token 时继续用ClaimTypes.Role类型。我的建议是全项目统一一种风格要么全用标准 ClaimTypes要么全用自定义字符串混用是最容易出问题的。5. 登录接口的安全增强验证码与登录限流前面说过真实项目不要把账号登录做成裸奔接口。结合搜索热词里的SPA项目开发之jwt验证码实现我把验证码和登录限流的整合方案也放进来。5.1 图片验证码的接入方式方案很多比较轻量的是在服务端生成图片验证码把验证码文本存到缓存返回图片流给前端。我这里用的是一个简单的画图逻辑没有依赖第三方 SDK[HttpPost(captcha)] [AllowAnonymous] public IActionResult GenerateCaptcha() { var code GenerateRandomCode(6); var captchaId Guid.NewGuid().ToString(N); var cached DistributedCache.SetStringAsync( $captcha:{captchaId}, code, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow TimeSpan.FromMinutes(5) }).GetAwaiter().GetResult(); var bitmap DrawCaptchaImage(code); using var ms new MemoryStream(); bitmap.Save(ms, ImageFormat.Png); return Ok(new { captchaId, image Convert.ToBase64String(ms.ToArray()) }); }前端把验证码图片用img srcdata:image/png;base64,...展示登录时把captchaId和用户输入的验证码一起提交。后端校验签名时同时验证验证码[HttpPost(login)] [AllowAnonymous] public async TaskIActionResult Login(LoginRequest request) { var cachedCode await _cache.GetStringAsync($captcha:{request.CaptchaId}); if (cachedCode null || !cachedCode.Equals(request.CaptchaCode, StringComparison.OrdinalIgnoreCase)) { return BadRequest(new { message 验证码错误或已过期 }); } await _cache.RemoveAsync($captcha:{request.CaptchaId}); // 继续进行用户名密码验证... }注意验证码必须一次性使用验证通过后立刻删除。不然攻击者可以先用同一个验证码做批量尝试。5.2 登录限流不给暴力破解机会验证码只能挡住最基础的脚本轰炸还应该加登录接口的限流。ASP.NET Core 8 可以通过中间件或第三方库比如 AspNetCore.RateLimit实现但最简单的方式是利用IPolicyRateLimiter。在Program.cs里配置builder.Services.AddRateLimiter(options { options.RejectionStatusCode StatusCodes.Status429TooManyRequests; options.AddPolicy(login_limit, context RateLimitPartition.GetFixedWindowLimiter( partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? unknown, factory: _ new FixedWindowRateLimiterOptions { AutoReplenishment true, PermitLimit 5, QueueLimit 0, Window TimeSpan.FromMinutes(1) })); }); app.UseRateLimiter();登录接口上标注[HttpPost(login)] [AllowAnonymous] [EnableRateLimiting(login_limit)] public IActionResult Login(LoginRequest request)这个配置的含义是同一个 IP 地址在 1 分钟内最多调用 5 次登录接口超过直接返回 429。理论上登录这种高风险接口还应该配合账号维度的限流比如一个账号连续失败 5 次锁定 15 分钟这需要自己实现因为涉及账号状态存储不是简单的 IP 维度接口。我目前的方案是 IP 限流 验证码双管齐下对于内部系统来说已经足够。6. Token 过期怎么办刷新令牌与续签方案的落地选择JWT 的一个经典痛点就是过期处理。假如exp设置了 120 分钟用户用着用着突然收到 401必须重新登录体验很差。做 token 续签是必然需求但续签怎么做里面有不少取舍。6.1 前端简单暴力的方案静默刷新最简单的方案是前端在请求时判断 token 剩余有效时间如果快过期了先调一个固定接口换新 token。这个方案要求换新 token的接口本身是匿名的或弱认证的所以后来我放弃了这种实现——安全上不太踏实任何拿到过期 token 的人都可以去换新 token等于延长了 token 的生命周期。6.2 主流方案Refresh Token刷新令牌更可靠的方式是引入 Refresh Token。这是一种长期凭证通常 7 天专门用来换新的 Access Token。登录成功后服务端返回两个值Access Token短期有效用于访问受保护资源Refresh Token长期有效只用于调用刷新接口刷新令牌的核心逻辑是[HttpPost(refresh)] [AllowAnonymous] public async TaskIActionResult Refresh([FromBody] RefreshRequest request) { var storedToken await _cache.GetStringAsync($refresh_token:{request.RefreshToken}); if (storedToken null) { return Unauthorized(new { message 刷新令牌无效或已过期 }); } // 解析旧的 access token但不校验签名只取里面的用户身份 var handler new JwtSecurityTokenHandler(); var jwtToken handler.ReadJwtToken(request.AccessToken); var userId jwtToken.Claims.FirstOrDefault(c c.Type JwtRegisteredClaimNames.Sub)?.Value; if (userId null) { return Unauthorized(); } // 生成新的 access token 和 refresh token var user await _userService.GetUserById(int.Parse(userId)); var newAccessToken _tokenService.GenerateToken(user); var newRefreshToken Guid.NewGuid().ToString(N); await _cache.RemoveAsync($refresh_token:{request.RefreshToken}); await _cache.SetStringAsync($refresh_token:{newRefreshToken}, userId, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow TimeSpan.FromDays(7) }); return Ok(new { accessToken newAccessToken, refreshToken newRefreshToken }); }Refresh Token 有几个关键设计原则必须轮换每次刷新都生成新的 Refresh Token旧的立即作废。必须可撤销服务端要存 Refresh Token 的状态用缓存或数据库这样才能在用户修改密码、被踢下线时立即失效。必须绑定用户刷新时要重新确认 Refresh Token 对应用户仍然存在且状态正常。设置合理过期时间Access Token 120 分钟、Refresh Token 7 天是我在内部系统常用的组合面向 C 端的系统可考虑 Access Token 15 分钟、Refresh Token 30 天。不要在客户端持久化存储 Access Token而 Refresh Token 可以存在本地存储但需要做好安全策略比如绑定指纹信息。6.3 双 Token 方案的痛点与改进双 Token 虽然比单 Token 安全不少但它不是银弹。最大的痛点是刷新接口本身需要极高级别的防护——因为它接收一个长期有效的凭证只要拿到 Refresh Token基本等于拿到了账号的长期控制权。所以我做了两件事第一给 Refresh Token 增加设备信息绑定。生成时把客户端的 User-Agent、IP 等信息做哈希后存入缓存刷新时对比当前请求是否来自同一设备。一旦发现异常直接拒绝刷新并作废该用户的全部 Refresh Token。第二加入令牌族Token Family概念。每次刷新生成的新 Refresh Token 同时记录自己继承自哪个旧 Token的父 ID。如果系统发现同一个父令牌对应的子令牌被不同设备使用立刻标记整个令牌族为异常并全部吊销。这是防御刷新令牌盗用比较有效的手段实现不复杂一张表就能搞定。从这里也能看出来JWT 的无状态其实是相对的。一旦引入 Refresh Token你依然需要服务端存储配合。但这是值得的——认证状态这个东西完全无状态并不现实关键是让无状态的部分Access Token 校验足够快让有状态的部分Refresh Token 管理足够安全。7. 安全加固清单密钥管理、kid 处理与常见攻击面看到搜索热词里有jwt漏洞总结和jwt kid这是很多人忽略但其实非常重要的部分。JWT 用的库是成熟的开源库但配置不当照样漏洞百出。7.1 密钥安全生产环境不要用同一个密钥不管项目规模多大我强烈建议主密钥和 token 签名密钥分离。主密钥用于加密用户密码之类的敏感数据token 签名密钥只用于 JWT 签名两把钥匙不要共用一把。泄露任何一个影响面可控。密钥轮换也是必须考虑的。我的做法是在配置里支持多个密钥通过kid标识当前使用哪一个{ Jwt: { SigningKeys: [ { Kid: 2025-key-v1, Value: first-secret-key-... }, { Kid: 2026-key-v2, Value: second-secret-key-... } ], CurrentKeyId: 2026-key-v2 } }TokenValidationParameters 里配置一个 KeyResolver 来根据kid加载对应密钥options.TokenValidationParameters.IssuerSigningKeyResolver (token, securityToken, kid, parameters) { // 根据 kid 从配置或密钥管理服务中查找对应密钥 return new[] { GetSigningKey(kid) }; };这样旧 token 在密钥轮换后依然能验签成功因为kid能指到旧密钥新 token 则用新密钥签发用户可以平滑过渡不需要强制重新登录。这个细节很多项目不做等到真的要轮换密钥时才发现所有线上 token 一夜之间全部失效。7.2 kid 注入一个隐蔽的高危点kid是 JWT Header 里的可选字段一些 JWT 库在解析时会把kid直接当文件路径或 SQL 参数使用从而引发注入。虽然 ASP.NET Core 的 JwtBearer 包不会拿kid去查数据库但如果你自己写了IssuerSigningKeyResolver就要留意kid的外部输入属性——永远不要用kid去拼接文件路径、拼接 SQL、直接作为命令参数。安全做法是先校验kid是否在白名单内不是就拒绝。7.3 算法混淆攻击不要盲目信任 alg 字段算法混淆攻击Algorithm Confusion Attack是 JWT 领域一个经典的攻击手法。攻击者把 cookie 里的 token 篡改为用alg: none的 token如果后端没有对alg做白名单限制某些旧版本库会直接跳过验签攻击者就能伪造任意身份。在 ASP.NET Core 里JwtBearer默认会校验签名算法但为了万无一失建议显式指定允许的算法集合。同时要注意alg: HS256和alg: RS256在思路上有本质区别HS256 是对称算法签发和验证用同一个密钥RS256 是非对称算法公钥公开、私钥保密。如果后端用的是 RS256但攻击者把alg改成 HS256把公钥当对称密钥来签名就有可能绕过验证。这一点在团队用了多个签名算法时尤其要小心。最简单的防御就是固定只允许白名单里的算法options.TokenValidationParameters.ValidAlgorithms new[] { SecurityAlgorithms.HmacSha256 };7.4 其他几个容易被忽视的点端口和域名校验ValidAudience应该设置为 API 的实际域名或标识防止 token 被用在其他服务上。日志脱敏不要在日志里打印完整 token。排查问题时打前几位和后几位就够了不然日志系统一泄露所有用户的登录态全暴露。注销实现JWT 没有服务端 session无法真正注销。如果业务上需要强制下线比如用户点退出登录、修改密码、账号被封就需要引入token 黑名单机制把注销的 token 的 jtiJWT ID存到缓存直到过期。这也是无状态的代价该交给存储的还是要交。HTTPS所有 token 传输都应该是 HTTPS。虽然这是基础中的基础但我见过不少内网系统用 HTTP 裸跑token 在局域网里被嗅探的风险依然存在。8. 回到项目本身我踩过的最痛的一个坑这节算是彩蛋讲一个我在集成过程中真实踩过、排查了整整半天的坑希望能帮你避开。现象是后端所有代码都按文档写好了[Authorize]也加了登录接口也能拿到 token但带 token 请求受保护接口时始终返回 401。用 Postman 测试也是同样的结果。排查链路是这样的先确认 token 能不能正常解析。我写了一个测试接口把 token 解码出来看 claims发现exp时间正常、签名算法是 HS256、claims 都在。再确认TokenValidationParameters的配置。打印出来看Issuer、Audience、SecretKey 都对得上。然后怀疑是ClockSkew的问题——服务器时间和本地时间差太多但查了日志时间偏差只有几秒不至于。最后才发现问题出在claim type 映射上。我在生成 token 时用了JwtRegisteredClaimNames.Sub标准的sub默认的DefaultInboundClaimTypeMap会把sub映射成ClaimTypes.NameIdentifier。而在授权 Handler 里我用ClaimTypes.Name去取用户名这个声明根本不存在导致授权断言失败。说实话这个坑很隐蔽因为从字符串上看sub和name都在 payload 里但代码里用的是映射前的 raw claim type还是映射后的 .NET claim type两者的取值 API 完全不同。最终我的解决方案是全局统一生成 token 时直接用ClaimTypes定义的常量作为 claim type这样整个处理链路上就不需要关心映射转换了。这个教训也让我养成了一个习惯JWT 相关的代码先从解析后的 claims 入手做一次全量输出再写授权逻辑。调试成本高不高往往就差这一步。9. 一些小工具和调试技巧最后分享几个我常用的辅助手段。第一调试 token 内容用小工具。JWT 的官网jwt.io可以粘贴 token 直接解码 Header 和 Payload但要留意不要把生产环境的真实密钥粘贴进去它只是个解码器本地用可以线上调试要慎重。我自己更喜欢在本地写一个临时调试接口或在 watch 窗口里直接看变量尽量减少 token 内容在第三方网站的留存。第二Postman 的自动化测试脚本。在 Postman 的 Tests 标签里可以用pm.environment.set(token, jsonData.token)把登录拿到的 token 存到环境变量然后所有请求的 Authorization 头都写成Bearer {{token}}这样联调效率会高很多。第三关于依赖注入作用域。JwtTokenService这种无状态服务注册成 Singleton 即可它只依赖IConfiguration没有数据库上下文不需要每次请求都 new 一个。第四单元测试。至少应该覆盖三块逻辑token 生成后能被TokenValidationParameters成功验签、过期 token 会被拒绝、claims 和生成时一致。这些测试很快就能写但在改配置、升版本的时候能帮你兜底。10. 最后说点个人体会这轮集成做下来我对 JWT 的感受是它本身只是一套自包含令牌的规范真正决定项目安全性和体验的是你在它外面搭的那层机制——密钥怎么管、过期怎么续、权限怎么控、异常怎么拦。ASP.NET Core 8 在认证授权的框架设计上已经相当成熟Authentication和Authorization两级中间件让 JWT 的接入成本降到了很低的水平但框架给的是能跑的保障跑得好还得自己动手。如果你所在的项目还没有对外的接口层认证从今天开始把 JWT 加上并不晚如果你已经做好了不妨对照这篇文章检查一遍密钥轮换、Refresh Token 轮换、算法白名单这些细节。这些都是真实项目里绕不开的部分早处理早安心。
返回列表