ARTICLE DETAIL

资讯详情

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

.NET6 WebAPI + Sqlserver + JWT 实现增删改查与登录认证

.NET6 WebAPI + Sqlserver + JWT 实现增删改查与登录认证 简介这是一份面向.NET6学习者的Web API实战项目演示如何结合SQL Server与JWT实现增删改查接口覆盖ASP.NET Core Web API构建、Swagger接口文档、ORM数据访问、身份认证与权限控制等核心技能也适合作为毕业设计或企业级接口开发的参考模板。压缩包共665个文件包含大量cs源码、csproj工程文件、dll程序集、pdb调试信息、json配置及数据库备份等整体约34.81MB其中也包含若干跨平台库文件与构建脚本便于直接打开解决方案进行编译调试。已有1060人学习下载。通过该项目可掌握从数据库迁移、仓储模式到JWT签发与校验的完整链路同时理解SQL注入防护、全局异常处理、RESTful接口设计等工程实践源码分层清晰可快速改造成支持SqlSugar、Dapper等多种ORM的通用基础框架。1. .NET6 WebAPI Sqlserver JWT 这套组合解决的是业务系统最通用的那道坎C# 开发者想跟上 .NET 迭代或者一个全栈工程师要快速给公司搭内部管理系统大概率会被这个标题戳中.NET6 WebAPI 做接口层Sqlserver 做数据落点JWT 做登录认证最终产出一套完整增删改查。这套组合解决的是业务系统最通用的骨架问题——用户登录拿到 token之后每个受保护接口凭 token 访问数据落到 SQL Server增删改查是业务的最小单元。为什么值得搭因为它能把“身份认证”和“数据操作”两件事一次性打通而不是像很多教程那样只演示一个能跑通但上不了生产的玩具接口。真正动起手来翻车点往往集中在连接串加密参数、JWT 密钥长度和中间件配置三处。这篇把从建项目到发布验证的完整链路讲清楚也把坑的位置提前标出来。2. 从空白目录到第一个能查数的接口.NET6 WebAPI 项目骨架与 Sqlserver 访问层2.1 创建项目为什么单跑 dotnet new webapi 不够我一般不会直接用默认模板因为 .NET6 的dotnet new webapi默认生成的是 Minimal API 风格只有一条Get示例没有 Controller 目录。做标准增删改查接口Controller 的分层方式更直观于是加一个-controllers参数把传统控制器模板拉回来。本地开发期我还会关掉 HTTPS 证书避免“证书不受信任”这类和业务无关的干扰但这个参数只限开发环境。# 创建项目使用 Controller 模板关闭开发期证书以减少本地干扰 dotnet new webapi -n Shop.Api -controllers --no-https cd Shop.Api # 数据访问层两个必装包Sqlserver 驱动 EF Core 迁移工具 dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Tools第一次跑dotnet new webapi时要等 NuGet 还原完成公司内网环境经常卡在这一步如果代理或私有源没配好会出现大量NU1101找不到包的错误。dotnet add package不加版本号时默认取最新稳定版.NET6 项目装 EF Core 8.x 也能工作但我更建议装 6.x 这一代版本与运行时同代依赖冲突最少。项目创建完后先删掉模板自带的WeatherForecastController或最小 API 示例免得发布后被当成正式接口暴露出去。这个动作很多人会忘等安全扫描报告出来才回头处理属于白捡的教训。2.2 数据访问层选 EF Core 还是 Dapper先定调子再动手标题里的“增删改查”决定了这是典型的业务 CRUD 场景这种场景我首选 EF Core。原因很实际EF Core 的 ChangeTracker 会自动追踪实体状态新增、修改、删除只要操作对象再SaveChangesAsync()写进仓库的代码量比手拼 SQL 少一半以上。配合迁移工具表结构变成代码里的版本记录多人协作时不需要互相传.sql脚本。Dapper 不是不能用它的性能确实比 EF 好执行单条 SQL 的耗时大约只有 EF 的几分之一但代价是每个增删改查方法都要自己维护 SQL 字符串和参数映射。项目里有复杂报表或大结果集查询时我通常的做法是 EF Core 管业务写路径Dapper 单独管统计读路径俩库共存并不冲突。对一个小型管理系统只上 EF Core 就够了别一上来就引入两套 ORM。2.3 Sqlserver 连接串新版驱动默认加密老库最容易在这里翻车连接串是本项目第一个“玄学”高发区。新版 Microsoft.Data.SqlClient 从 4.0 开始默认EncryptTrue意思是客户端要求服务端提供有效证书并验证。开发机上的 SQL Server 往往没配证书结果就是连接时报“证书链是由不受信任的颁发机构颁发的”。网上搜到的老连接串大多没带Encrypt和TrustServerCertificate直接抄过来必炸。{ ConnectionStrings: { Default: Serverlocalhost,1433;DatabaseShopDb;User Idsa;PasswordYourPassword;TrustServerCertificatetrue;Encrypttrue;MultipleActiveResultSetstrue }, Logging: { LogLevel: { Default: Information, Microsoft.AspNetCore: Warning } }, AllowedHosts: * }Serverlocalhost,1433指定的是本机默认实例和端口如果你用的是命名实例这里要写成localhost\\SQLEXPRESS这种带反斜杠的形式。TrustServerCertificatetrue的意思是在本机开发阶段不校验服务器证书只对开发环境用生产环境要把Encrypt保持为true且TrustServerCertificate改为false否则等于裸奔。MultipleActiveResultSets一般打开它让你在同一条连接上同时跑多个查询避免某些列表接口并发读时出现“连接已被占用”的异常。2.4 建第一张 User 表实体、上下文、迁移三步走做登录认证第一张表自然是用户表。字段设计上有几条硬规矩密码绝不存明文存哈希主键用Guid而不是自增int外部接口不暴露自增 ID减少被遍历抓取的风险时间字段统一存 UTC展示时再转本地时间不然多环境部署后时间对不上。public class User { public Guid UserId { get; set; } public string UserName { get; set; } string.Empty; public string PasswordHash { get; set; } string.Empty; public string Role { get; set; } User; public int Status { get; set; } 1; // 1 正常0 禁用 public DateTime CreatedAt { get; set; } }public class AppDbContext : DbContext { public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { } public DbSetUser Users SetUser(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityUser(e { e.HasKey(x x.UserId); e.Property(x x.UserName).HasMaxLength(50).IsRequired(); e.HasIndex(x x.UserName).IsUnique(); }); } }HasIndex.IsUnique()会在 Sqlserver 上生成唯一索引防止并发了两个同名账号。User表的Role字段先给默认值“User”管理员账号建好后再手工改成“Admin”后面[Authorize(Roles Admin)]要依赖这个值做权限控制。在Program.cs注册上下文这一步的代码顺序有讲究AddDbContext必须在builder.Build()之前因为后续中间件要用依赖注入拿这个服务。builder.Services.AddDbContextAppDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(Default)));然后打开终端跑两条命令# 生成迁移文件 dotnet ef migrations add InitUserTable # 把表结构同步到数据库 dotnet ef database update如果提示找不到dotnet ef说明Microsoft.EntityFrameworkCore.Tools包没装上或者没在项目目录下执行。迁移文件生成后会出现在Migrations目录里打开看看 Up 方法里的 SQL 是否符合预期再执行database update。这一步很多人省略直接用EnsureCreated()小项目确实能跑但表结构一变就只能删库重建数据全丢。既然要做增删改查就要对数据负责迁移是唯一靠谱的路。3. 把 JWT 写进登录流程签发、校验、授权三步走以及最容易忽略的 ClockSkew3.1 JWT 的三个组成部分和它在认证体系里的定位JWT 全称是 JSON Web Token由 Header、Payload、Signature 三段 base64 编码的字符串拼成中间用点号分隔。Header 里声明签名算法Payload 里放用户相关信息Signature 是对前两段做签名后的结果防止内容被篡改。它在无状态认证体系里的定位是服务端不保存会话收到请求时只验证 token 签名和过期时间就能确认请求者身份。明白这个定位就理解了两个关键结论第一token 里的信息谁都能解包所以不要往 Payload 里放密码、身份证号这类敏感数据第二token 遗失等于把通行证交给别人所以过期时间不能设太长这是后续做刷新令牌的根本原因。实际项目中我会把UserId、UserName、Role、过期时间四样东西放进去够用且不越界。3.2 TokenService 签发 JWTclaims 设计、密钥和过期时间签发 token 的代码核心是JwtSecurityToken构造和WriteToken序列化。密钥从IConfiguration读不要硬编码在代码里这是后面避坑章节要反复强调的点。public class TokenService : ITokenService { private readonly IConfiguration _config; public TokenService(IConfiguration config) _config config; public string GenerateToken(User user) { var key new SymmetricSecurityKey( Encoding.UTF8.GetBytes(_config[Jwt:Key])); var creds new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims new ListClaim { new(ClaimTypes.NameIdentifier, user.UserId.ToString()), new(ClaimTypes.Name, user.UserName), new(ClaimTypes.Role, user.Role) }; var token new JwtSecurityToken( issuer: _config[Jwt:Issuer], audience: _config[Jwt:Audience], claims: claims, expires: DateTime.UtcNow.AddMinutes(15), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } }ClaimTypes.NameIdentifier对应的是 JWT 里的nameidClaimTypes.Role对应role这些 map 关系由 JwtSecurityTokenHandler 自动处理。expires我统一用DateTime.UtcNow计算避免服务端本地时间差八小时导致 token 提前过期或迟迟不过期。Jwt:Key的长度必须至少 32 字节因为HmacSha256算法要求密钥长度不低于 256 位网上很多教程用your-very-secret-key这种短字符串运行时就抛异常。3.3 登录接口密码校验通过后才发 token用户表里的密码存的是 BCrypt 哈希登录时把用户传入的明文密码与库里的哈希做校验而不是把库里密码取出来对比。用 BCrypt 的原因很简单它是自适应哈希计算成本可以随时间调整能有效对抗暴力破解。具体包是BCrypt.Net-Next接口实现如下。[AllowAnonymous] [HttpPost(login)] public async TaskIActionResult Login(LoginRequest req) { var user await _db.Users .FirstOrDefaultAsync(u u.UserName req.UserName); if (user null || user.Status ! 1 || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) { return Unauthorized(new { message 用户名或密码错误 }); } var token _tokenService.GenerateToken(user); return Ok(new { token, expiresIn 900, userName user.UserName, role user.Role }); }注意这里把user null、Status ! 1、密码错误三种情况合并返回同一个“用户名或密码错误”避免通过响应差异枚举出哪些用户名存在。expiresIn单位是秒900 秒就是 15 分钟前端拿到这个值可以做自动续签的倒计时。密码校验本身很吃 CPUBCrypt 的 cost 因子默认 11在生产环境要注意数据库连接池是否够用高并发登录时这是第一个瓶颈。3.4 JwtBearer 中间件配置参数这里每一项都是踩坑点中间件配置决定了 token 怎么被信任参数写错的表现不是启动报错而是“token 验证不过”或者“过期 token 还能用”。我在Program.cs里通常是这么配的。builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer builder.Configuration[Jwt:Issuer], ValidateAudience true, ValidAudience builder.Configuration[Jwt:Audience], ValidateLifetime true, ClockSkew TimeSpan.FromSeconds(30), ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration[Jwt:Key])) }; });最容易被忽略的是ClockSkew。框架默认留了 5 分钟的时钟偏差容忍意思是 token 过期后 5 分钟内仍然被认为是有效的。这在分布式环境里是合理的但放在单机内部系统上会出现“用户退出登录后 token 还能用 5 分钟”的诡异现象。我把它缩到 30 秒既容忍微小的时钟差又不至于让过期 token 长期生效。ValidateLifetime必须开否则过期校验直接跳过token 变成永久通行证。配置完认证还要在管道里加app.UseAuthentication()放在app.UseAuthorization()之前。很多人只写了UseAuthorization忘了认证中间件结果是接口能访问但[Authorize]完全不生效。管道的顺序是先认证身份再鉴权放行。3.5 jwt 发包格式前端怎么带 token401 时看什么JWT 在 HTTP 请求里的标准传递方式是请求头Authorization: Bearer token中间有一个空格这是最容易写错的地方。很多人把 Bearer 拼成 BearerToken或者把小写 bearer 放在前面导致鉴权失败。用 curl 验证最直观# 第一步登录拿 token curl -X POST http://localhost:5000/api/user/login \ -H Content-Type: application/json \ -d {userName:admin,password:123456} # 第二步带 token 访问受保护接口 curl http://localhost:5000/api/user?page1pageSize10 \ -H Authorization: Bearer 上一步返回的token如果 token 过期或签名不对接口返回 401此时要看响应头里的WWW-Authenticate它里面有框架写的具体失败原因常见的有Bearer errorinvalid_token提示过期时间、签名不匹配或 issuer 不符合。排查 401 时不要只盯着业务代码先看这个头能省半小时。4. 把增删改查写成可以上生产的接口参数校验、分页、状态码和事务边界4.1 DTO 与 ApiController 校验联动别让脏数据进数据库.NET6 的控制器默认带有[ApiController]特性它会自动执行模型验证验证失败时返回 400 和 ProblemDetails 格式的错误信息。前提是接口入参要用 DTO 而非实体类。直接拿User实体接收前端请求是个常见坏习惯因为前端可以传入RoleAdmin、Status1这类不该由客户端决定的字段而你完全拦不住。给每类请求单独建 DTO只暴露该让客户端传的属性。public class CreateUserRequest { [Required(ErrorMessage 用户名不能为空)] [StringLength(50, MinimumLength 3, ErrorMessage 用户名长度需在3到50之间)] public string UserName { get; set; } string.Empty; [Required(ErrorMessage 密码不能为空)] [StringLength(20, MinimumLength 6, ErrorMessage 密码长度需在6到20之间)] public string Password { get; set; } string.Empty; public string? Role { get; set; } }Role设为可空字符串后端判断为空默认“User”非空时校验只能取User或Admin。校验规则写死在 DataAnnotations 里改规则就要改代码重新发布所以规则只放基本约束业务逻辑校验比如用户名是否已被占用仍然在接口方法里做。4.2 列表查询与分页Skip/Take 翻页的边界参数列表接口是所有增删改查里最容易写成“全表查询”的地方。接口返回全部数据前端再自己分页一次请求拉几千行数据库压力全压在 Sqlserver 上。标准的做法是后端分页查询参数带page和pageSize。[Authorize(Roles Admin,User)] [HttpGet] public async TaskIActionResult GetUsers(int page 1, int pageSize 10) { var query _db.Users.AsNoTracking(); var total await query.CountAsync(); var items await query .OrderBy(u u.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(u new UserListItem { UserId u.UserId, UserName u.UserName, Role u.Role, Status u.Status, CreatedAt u.CreatedAt }) .ToListAsync(); return Ok(new { total, page, pageSize, items }); }AsNoTracking()告诉 EF Core 这条查询结果不用跟踪状态内存占用小一半性能明显更好。Skip和Take会被翻译成 Sqlserver 的OFFSET和FETCH NEXT不是先把所有数据拉进内存再截断这个不用担心。分页查询必须配合OrderBy否则 EF Core 会直接抛异常原因是 Sqlserver 的FETCH子句要求有ORDER BY。pageSize建议在上层做个最大值限制超过 100 直接截断防止有人传pageSize1000000把数据库拖垮。返回体里用total items的组合前端需要total才能渲染分页条。这个接口的[Authorize(Roles Admin,User)]意思是登录用户都能看后面的更新、删除接口则只允许 Admin 操作权限粒度由 Roles 参数控制。4.3 新增和更新201、204 状态码背后的语义新增用户接口返回 201 Created 而不是 200 OK这是很多人不理解的细节。201 表示资源创建成功同时响应头Location指向新资源的地址客户端拿到后可以直接用它发起 GET。更新接口如果返回的是整个更新后的实体用 200 OK如果返回空用 204 NoContent表示“操作成功但无响应体”。[HttpPost] public async TaskIActionResult CreateUser(CreateUserRequest req) { var exists await _db.Users .AnyAsync(u u.UserName req.UserName); if (exists) return Conflict(new { message 用户名已存在 }); var user new User { UserId Guid.NewGuid(), UserName req.UserName, PasswordHash BCrypt.Net.BCrypt.HashPassword(req.Password), Role string.IsNullOrEmpty(req.Role) ? User : req.Role, Status 1, CreatedAt DateTime.UtcNow }; _db.Users.Add(user); await _db.SaveChangesAsync(); return CreatedAtAction(nameof(GetUsers), new { id user.UserId }, user); }新增前先查“用户名是否已占用”这里有个并发边界两个请求同时查都不存在然后同时插入唯一索引会拦住后提交的那个并抛出异常。所以代码里的提前检查是优化体验用的真正兜底的是数据库的唯一索引。PasswordHash用 BCrypt 哈希再入库明文密码永远不出现在数据库里。更新接口要用 PUT 而不是 POSTHTTP 语义上 PUT 是整体替换。按主键查出实体后修改属性再SaveChangesAsync()EF Core 的 ChangeTracker 只更新变化了的列。[HttpPut({id:guid})] public async TaskIActionResult UpdateUser(Guid id, UpdateUserRequest req) { var user await _db.Users.FindAsync(id); if (user null) return NotFound(); user.UserName req.UserName; user.Role req.Role; if (!string.IsNullOrWhiteSpace(req.Password)) user.PasswordHash BCrypt.Net.BCrypt.HashPassword(req.Password); await _db.SaveChangesAsync(); return NoContent(); }FindAsync会先查本地缓存再查数据库是我在这里用它的原因。req.Password允许为空表示不修改密码这是“编辑用户”功能的常见交互表单里密码框留空就不动它填了才重新哈希。4.4 删除为什么选软删除给“后悔药”留一条后路删除接口有两种实现方式硬删除直接从数据库删行软删除是改状态位。小表且确认无外键引用的场景硬删除没问题但用户这类数据几乎一定被订单表、日志表引用硬删除会触发外键约束异常或者把历史数据变成一堆 orphan。所以我更推荐软删除把Status从 1 改成 0列表查询只查Status 1登录校验也拦截Status ! 1。[HttpDelete({id:guid})] public async TaskIActionResult DeleteUser(Guid id) { var user await _db.Users.FindAsync(id); if (user null) return NotFound(); user.Status 0; await _db.SaveChangesAsync(); return NoContent(); }软删除的真实价值在于“后悔药”。误删一个用户硬删除后数据可能被后续新增记录覆盖恢复成本高到不想恢复软删除只需要把Status改回去用户数据完好无损。代价是所有查询接口都要记得带上Status 1的过滤条件这一点写代码时容易漏我会在仓储层或查询表达式里封装一个统一的ActiveUsers查询视图避免各处散落的过滤条件不一致。事务边界上单表操作不需要手动开事务SaveChangesAsync()自带事务多条写入要么全部成功要么全部回滚。跨表写入时才显式使用IDbContextTransaction用using包裹确保异常时自动回滚。5. 避坑清单Sqlserver、JWT、CRUD 最常见的翻车现场与排查路径5.1 连接串被新版驱动加密策略拦腰截断现象项目在开发机上一跑就报“证书链是由不受信任的颁发机构颁发的”有的直接抛A connection was successfully established with the server, but then an error occurred during the login process。换老版本驱动也报错但报错位置不一样。去网上搜答案五花八门有说改Server的有说重装 Sqlserver 的实际都治标不治本。原因Microsoft.Data.SqlClient 4.0 之后默认开启EncryptTrue而本地开发库几乎都没有配置可信证书。老教程连接串没带这俩参数新驱动按默认值执行于是握手失败。解决连接串显式加EncryptTrue和TrustServerCertificateTrue开发环境用TrustServerCertificate跳过证书校验。如果内网库是 SQL Server 2016 之前的老版本还需要在连接串里加EncryptFalse因为老库对 TLS 1.2 支持不完整。生产环境则相反EncryptTrue保持TrustServerCertificate去掉数据库服务器必须配上证书。5.2 Sqlserver 字符串转数字和 STRING_SPLIT 报 Invalid object name现象C# 代码里int.Parse(data[age])直接把字符串转整数前端传了个空字符串或“12a”程序 500 了。或者 Sqlserver 上执行SELECT * FROM t WHERE id IN (SELECT value FROM STRING_SPLIT(ids, ,))直接报Invalid object name STRING_SPLIT。原因字符串转数字翻车是数据问题SQL Server 的STRING_SPLIT报找不到对象是版本问题——这个函数是 SQL Server 2016兼容级别 130才引入的数据库实例是 2012 或旧库兼容级别没升自然找不到。解决C# 里所有字符串转数字统一用int.TryParse解析失败走默认值或返回 400。数据库升级兼容级别用下面的 SQL 确认SELECT name, compatibility_level FROM sys.databases WHERE name DB_NAME();如果值低于 130用ALTER DATABASE [库名] SET COMPATIBILITY_LEVEL 130或者放弃STRING_SPLIT改用XML拆分。上线前的数据字典检查里我会专门加一条“禁止用字符串拼接传递多值参数”转表格参数DataTable才是 Sqlserver 场景下的正解。5.3 JWT 密钥踩坑过短、过期、换机器校验失败现象启动一个不报错一调用登录接口就抛IDX10603异常或者同一个 token 在自己机器上验证通过发布到测试服务器后一直被 401 拒绝。还有一种情况是网上教程用了一模一样的密钥结果安全扫描报告直接标记为高风险。原因HmacSha256要求密钥至少 32 字节密钥写在appsettings.json里并在代码里硬编码读取。换机器后配置文件没同步或者密钥在某个环境里被环境变量覆盖了签发和校验用的就不是同一把钥匙。至于默认密钥风险公开仓库里的固定密钥早就被扫描工具收录拿默认密钥签发的 token 任何人都能伪造。解决密钥生成用随机 32 字节以上的安全随机数开发和生产分开。生产密钥放环境变量或密钥管理服务不写进代码仓库。签发和校验两端必须读同一个配置源这个可以在启动时打印一段密钥哈希做核对。另外注意Jwt:Key不要带 BOM从环境变量读取时字符串可能多出不可见字符导致签名意外不匹配。5.4 kid 参数与默认密钥漏洞的关联风险现象JWT 验证逻辑里加了kid头部处理想支持多密钥轮换结果某个来源的 token 验证通过但内容是完全伪造的。网上还有关于“在 nacos 默认密钥身份认证绕过漏洞”这类公开案例的讨论问题的本质是 JWT 实现里的密钥信任被绕过了。原因kid是 JWT Headers 里的一个可选字段用来告诉验证方“我是用哪把钥匙签的”。如果验证方没有维护 kid 到密钥的白名单攻击者可以把kid指向一个自己控制的密钥或者指向空值让签名验证变成空验证。解决只接受白名单内的 kid查询密钥时不允许动态从文件路径或 URL 注入。对没有 kid 的 token 直接拒绝。另外所有 JWT 密钥必须每次发布时随机生成禁止复用网上示例代码里的默认值。安全这个方向没有捷径默认密钥就是你系统里最显眼的门。5.5 事务嵌套与 EF Core 并发冲突现象写接口时为了“保证一致性”把多个SaveChangesAsync()分别包在事务里结果运行时报Transaction already in progress或者两个人同时编辑同一条记录后提交的覆盖了先提交的数据丢得悄无声息。原因EF Core 的SaveChangesAsync本身就是事务性的外层再开一个事务就会遇到事务嵌套。并发丢失是因为没有乐观锁控制后提交的事务直接覆盖数据库里的新值。解决单表多条写操作直接调用一次SaveChangesAsync让 EF 自己开事务。跨表操作才用IDbContextTransaction并且用using包裹。并发控制给实体加一个byte[] RowVersion字段Sqlserver 对应rowversion类型更新时 EF 自动把它放进WHERE条件受影响行数为 0 时抛出DbUpdateConcurrencyException捕获后返回 409 Conflict 让客户端重试。6. 让 JWT 会“续命”滑动过期与刷新令牌的最小落地以及发布后的验证访问令牌过期时间越短越安全但用户体验也越差。15 分钟过期用户操作到一半被踢出去这是内部系统最招骂的交互。解决思路是双令牌access_token保持短生命周期refresh_token给一个较长的生命周期前端在 access_token 过期前用 refresh_token 换新的,这其实就是热词里常说的“jwt 实现 token 续签”。刷新令牌要落库不能放在客户端自己签。数据库加一张RefreshToken表字段包含Token、UserId、ExpiresAtUtc、Revoked、CreatedAtUtc。刷新接口的实现逻辑是收到 refresh_token查到且未被吊销且未过期则签发新的 access_token并把旧 refresh_token 标记为已吊销同时生成一条新的 refresh_token 返回。参数生命周期存放位置续签策略access_token15 分钟前端内存过期前自动刷新refresh_token7 天数据库 前端存储每次刷新轮换旧令牌作废[AllowAnonymous] [HttpPost(refresh)] public async TaskIActionResult Refresh(RefreshRequest req) { var oldToken await _db.RefreshTokens .FirstOrDefaultAsync(x x.Token req.RefreshToken); if (oldToken null || oldToken.Revoked || oldToken.ExpiresAtUtc DateTime.UtcNow) { return Unauthorized(new { message 登录状态已过期请重新登录 }); } var user await _db.Users.FindAsync(oldToken.UserId); if (user null || user.Status ! 1) return Unauthorized(); oldToken.Revoked true; var newRefresh new RefreshToken { Token Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)), UserId user.UserId, ExpiresAtUtc DateTime.UtcNow.AddDays(7), CreatedAtUtc DateTime.UtcNow }; _db.RefreshTokens.Add(newRefresh); await _db.SaveChangesAsync(); return Ok(new { accessToken _tokenService.GenerateToken(user), refreshToken newRefresh.Token }); }RandomNumberGenerator.GetBytes(64)生成的是 64 字节安全随机数refresh_token 必须足够随机才能防猜测。每次刷新都轮换令牌旧令牌吊销即使某一次令牌泄露攻击者也只有一次使用机会。滑动过期要做的是每次续签时检查剩余到期时间如果离 7 天还有一半以上就保持原到期时间如果剩余不到一半就再往后延 7 天。否则一个活跃用户永远不重新登录账号实际变成永久有效。最后提醒发布环节的常见坑。dotnet publish -c Release -o ./publish发布后部署到 IIS 的应用池要设成“无托管代码”WebAPI 是独立进程不依赖 IIS 的托管管道。连接串要从开发库改成生产库Program.cs里的配置从 appsettings.Production.json 读发布前跑一遍全链路验证登录拿 token、访问受保护接口、改一条数据、删一条数据、等 access_token 过期后刷新。我做认证配置的教训是曾经把 ClockSkew 调得太大用户退出后 token 还活了五分钟从此所有认证参数改完都先跑一遍“退出-再验证”的返测。这套链路不复杂但每一步的参数都有讲究照着走一遍希望帮到你。本文还有配套的精品资源点击获取
返回列表