简介:本资源是一套基于.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 != "P@ssw0rd123") 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 ActionResult<IEnumerable<string>> 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",类型为 http,scheme 为 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 仍 401
3.1 现象:Postman 调用/api/auth/login成功返回 token,但用该 token 调/api/values却 401
原因:AddJwtBearer的TokenValidationParameters配置与JwtSecurityToken生成时的参数不一致。最常见的是:
ValidIssuer/ValidAudience字符串大小写不匹配(如生成时用"MyApp",验证时写"myapp");IssuerSigningKey使用的密钥字节数组长度不足 32(HMAC-SHA256 要求 256 bit);ValidateLifetime = false(开发时误关,导致过期 token 也被接受)。
解决:
- 在
Login方法中打印生成的 token 字符串(Console.WriteLine(tokenString)),复制到 https://jwt.io 解码,确认iss、aud、exp字段值; - 在
AddJwtBearer的TokenValidationParameters中,用Console.WriteLine输出ValidIssuer和ValidAudience,确保完全一致; - 检查密钥:
key.Length必须为 32(UTF8 编码下,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中检查nbf(Not Before)是否早于该时间。需在Login时将nbf设为LastPasswordChangedAt; - 进阶方案:引入 Redis 存储已注销的 token ID(jti),在
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.Configure<JwtSettings>(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.GetService<IWebHostEnvironment>().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-secrets,Production 用 Azure Key Vault
appsettings.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 Token?
Refresh 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 Task<IActionResult> 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.Deserialize<RefreshTokenData>(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(随机 GUID,Hash 后存 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 有效性
不依赖黑名单,用版本号实现轻量级注销。原理:每次密码修改,UserVersion+1;Token 中携带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 里手动检查更早、更统一。
希望帮到你。
本文还有配套的精品资源,点击获取