☰
ASP.NET Core 集成 JWT 完整指南:认证授权与安全加固实战
2026/10/1 17:44:38 网站建设 项目流程

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)。标准声明里最常用的是这几个:

  • sub(Subject):用户主体标识,通常放用户 ID
  • exp(Expiration Time):过期时间,时间戳格式
  • iat(Issued At):签发时间
  • nbf(Not Before):生效时间,早于该时间不可用
  • iss(Issuer):签发者
  • aud(Audience):受众,标识 token 给谁用

自定义声明则根据需要自由添加,比如用户角色、权限点、租户 ID、昵称、头像 URL 等。ASP.NET Core 里通过Claim对象来构造这些数据,后面代码部分会具体演示。

2.4 Signature:整个 token 的可信基础

签名是用 Header 里声明的算法,把Base64Url(Header) + "." + Base64Url(Payload)拼起来,再用密钥做哈希计算得到的。拿 HS256 来说,它的验证本质就是后端用同一个密钥重新计算一遍签名,再跟前端传来的签名比对——能对上,说明 token 在传输过程中没有被篡改;对不上,直接拒绝请求。

这里有个非常重要的认知:JWT 的签名保护的是"完整性",不是"机密性"。如果你需要让某些数据在 token 里不可被客户端直接看到,应该考虑加密方案,比如 JWE(JSON 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 List<Claim> { 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 service:

using 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 : AuthorizationHandler<TenantRequirement> { 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.AddSingleton<IAuthorizationHandler, 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.NameIdentifier,role会被映射成ClaimTypes.Role,name会被映射成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 src="data:image/png;base64,...">展示,登录时把captchaId和用户输入的验证码一起提交。后端校验签名时同时验证验证码:

[HttpPost("login")] [AllowAnonymous] public async Task<IActionResult> 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 Task<IActionResult> 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 有几个关键设计原则:

  1. 必须轮换:每次刷新都生成新的 Refresh Token,旧的立即作废。
  2. 必须可撤销:服务端要存 Refresh Token 的状态(用缓存或数据库),这样才能在用户修改密码、被踢下线时立即失效。
  3. 必须绑定用户:刷新时要重新确认 Refresh Token 对应用户仍然存在且状态正常。
  4. 设置合理过期时间:Access Token 120 分钟、Refresh Token 7 天是我在内部系统常用的组合;面向 C 端的系统可考虑 Access Token 15 分钟、Refresh Token 30 天。
  5. 不要在客户端持久化存储 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 的 jti(JWT ID)存到缓存直到过期。这也是"无状态"的代价,该交给存储的还是要交。
  • HTTPS:所有 token 传输都应该是 HTTPS。虽然这是基础中的基础,但我见过不少内网系统用 HTTP 裸跑,token 在局域网里被嗅探的风险依然存在。

8. 回到项目本身:我踩过的最痛的一个坑

这节算是彩蛋,讲一个我在集成过程中真实踩过、排查了整整半天的坑,希望能帮你避开。

现象是:后端所有代码都按文档写好了,[Authorize]也加了,登录接口也能拿到 token,但带 token 请求受保护接口时,始终返回 401。用 Postman 测试也是同样的结果。

排查链路是这样的:

  1. 先确认 token 能不能正常解析。我写了一个测试接口,把 token 解码出来看 claims,发现exp时间正常、签名算法是 HS256、claims 都在。
  2. 再确认TokenValidationParameters的配置。打印出来看,Issuer、Audience、SecretKey 都对得上。
  3. 然后怀疑是ClockSkew的问题——服务器时间和本地时间差太多?但查了日志,时间偏差只有几秒,不至于。
  4. 最后才发现,问题出在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 轮换、算法白名单这些细节。这些都是真实项目里绕不开的部分,早处理早安心。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询