☰
.NET6+SqlServer+JWT:WebAPI增删改查与Token鉴权实战指南
2026/10/6 9:08:36 网站建设 项目流程

简介:这是面向.NET开发者的Web API实战项目,基于.NET6与ASP.NET Core构建,演示SQL Server下的CRUD操作及JWT身份验证流程,适合初学或进阶掌握RESTful API、Swagger文档、ORM与安全认证的开发者。包内含665个文件,压缩包34.81MB,主干为cs源码、csproj工程文件与sln解决方案,同时包含dll/pdb依赖程序集、json配置文件、editorconfig规则及数据库备份(.bak),涵盖DapperCore、SqlSugar等多种数据访问方式。已有1060人学习查看。通过该项目可完整了解分层架构(BLL、Service、Model)下的接口设计、JWT令牌签发与鉴权、数据库迁移与异常处理思路,还可参考Swagger测试API、网络安全防护及CI/CD相关配置,适合作为快速上手的完整示例。

1. 为什么是.NET6+Sqlserver+JWT:这套CRUD组合到底解决了什么

年初给公司做内部工单系统,第一版就三个接口:登录、查工单、提交工单。技术选型没犹豫,直接选了.NET6 WebAPI + Sqlserver + JWT,一干就是大半年。今天回头聊这个组合,不是为了推荐什么新框架,而是这套"增删改查+登录令牌"几乎是每个业务系统的地基,里面的配置坑远比想象中多。适合谁:从传统ASP.NET转过来的人、要在老项目里新开接口的人、以及想把"能跑但不敢维护"的接口重写一遍的团队。这套方案不花哨,但胜在每条路都有人踩过,出了错你能查到答案。

2. 搭建项目骨架:从空目录到第一个接口跑通的四步

2.1 开发环境与版本选型:SDK、数据库与SSMS该装哪一版

先统一环境,版本不一致是第一个翻车点。我用的是.NET 6 SDK(LTS版本),数据库用Sqlserver 2019 Developer,图形工具用SSMS 18/19都可以。有人问为什么不直接上.NET 8——如果你的服务器是Windows Server 2019、运维只给装了.NET 6 Runtime,那你写.NET 8代码根本发布不上去。选LTS版本不是保守,是给部署留余地。

Sqlserver安装这块,新手最容易漏的是"选择功能"那一步:一定要勾上数据库引擎服务和客户端工具连接。只装SSMS不等于装了数据库引擎,这是最常见的误会。装完开发版后,我用SQL Server 配置管理器确认一下TCP/IP协议是否启用,这一步网上教程很少提,但几乎决定了后面所有连接是否顺畅。

2.2 创建WebAPI项目:dotnet CLI一行命令搞定

我习惯用命令行创建项目,干净而且可重复:

dotnet new webapi -n Demo.Api --framework net6.0 cd Demo.Api code .

-n指定项目名称,--framework锁定目标框架为net6.0。如果你用的是Visual Studio 2022,创建时选ASP.NET Core Web API模板也能得到同样的骨架,但注意模板里默认带了一个WeatherForecastController,后面要删掉,不然发布后会多出几个无用接口。

项目骨架里真正需要关注的只有三个文件:Program.cs(程序入口和依赖注入配置)、appsettings.json(连接字符串、JWT密钥等配置)、Controllers目录(放接口)。其余都是编译产物和无关模板,不用管。

2.3 引入NuGet包:EF Core、Sqlserver驱动与JWT Bearer

单靠默认模板不能连数据库、也不能发JWT令牌,需要手动加三个包。我直接敲命令:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 6.0.28 dotnet add package Microsoft.EntityFrameworkCore.Tools --version 6.0.28 dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 6.0.28

EntityFrameworkCore.SqlServer是EF Core操作Sqlserver的驱动,Tools提供迁移命令(后面执行dotnet ef指令必须靠它),JwtBearer是.NET官方对JWT认证的服务端实现。版本号必须锁定在6.0.x,不能顺手装8.x,否则程序集加载会直接报错。

2.4 配置文件里的连接字符串与JWT密钥设计

appsettings.json是最容易随手乱写、然后又花两个小时排查的配置文件。我一般这么写:

{ "ConnectionStrings": { "Default": "Server=localhost\\SQLEXPRESS;Database=DemoDb;User Id=sa;Password=你的密码;TrustServerCertificate=True;" }, "Jwt": { "Key": "P@ssw0rdDemoKey-2024-HasAtLeast32Characters!", "Issuer": "Demo.Api", "Audience": "Demo.Client", "ExpireMinutes": 120 }, "Logging": { "LogLevel": { "Default": "Information" } } }

连接字符串里几个参数容易踩坑:Server=localhost\SQLEXPRESS表示本机默认实例名,如果安装时改过实例名,这里必须跟着改;User Id=sa用的是Sqlserver账号密码登录,比Windows身份验证省去IIS权限问题;TrustServerCertificate=True必须加,否则新版驱动访问自签名证书会中断连接。

JWT的Key是签名密钥,至少要32个字符。如果你的环境不允许明文存密码,可以用环境变量覆盖,但开发阶段直接写在配置里最省事。Issuer和Audience分别代表令牌签发方和接收方,后面配置鉴权时要用。

3. 数据层与增删改查:一张用户表从建表到入库的完整链路

3.1 设计实体类与DbContext:先写模型再生成表

先写实体类,再让EF Core帮你生成数据库表,这是EF Core的工作方式,和传统"先建表再写ADO.NET"正好反过来。以用户表为例:

using System.ComponentModel.DataAnnotations; namespace Demo.Api.Models { public class User { [Key] public int Id { get; set; } [Required, MaxLength(50)] public string Username { get; set; } = string.Empty; [Required, MaxLength(200)] public string PasswordHash { get; set; } = string.Empty; [MaxLength(50)] public string Nickname { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } = DateTime.Now; } }

[Key]标注主键,[Required]映射成非空列,[MaxLength(50)]控制字符串列长度。PasswordHash字段设计为200位是因为哈希字符串通常很长,不要用50位然后发现入库报错。

接下来写DbContext:

using Microsoft.EntityFrameworkCore; using Demo.Api.Models; namespace Demo.Api.Data { public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<User> Users { get; set; } } }

DbSet<User>是EF Core对用户表的操作入口,后续增删改查都从这里走。如果你还有订单、工单等表,每个实体类加一个DbSet即可。

3.2 执行迁移:EF Core从实体类生成Sqlserver表结构的命令

实体类和DbContext写完,还不能直接连数据库,需要先执行迁移。打开终端,在项目根目录运行:

dotnet ef migrations add InitUserTable dotnet ef database update

第一条命令在项目里生成一个Migrations目录,记录表结构的变更历史;第二条命令真正把表创建到Sqlserver里。如果你运行第一条命令时报错No database provider has been configured,说明Program.cs里还没注册DbContext,回到2.4补上:

builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

这段代码把连接字符串传给EF Core,UseSqlServer是驱动入口。执行完database update后,用SSMS刷新数据库列表,会看到DemoDb库里出现Users表,同时EF Core还会自动创建__EFMigrationsHistory表,它是版本的记录表,千万不要手动删。

3.3 泛型仓储基类:把增删改查封装成一套模板

每个实体都要写五个增删改查方法太重复了,我用泛型基类解决。先定义接口:

public interface IRepository<T> where T : class { Task<List<T>> GetAllAsync(); Task<T?> GetByIdAsync(int id); Task<T> AddAsync(T entity); Task<T> UpdateAsync(T entity); Task<bool> DeleteAsync(int id); }

接口约束where T : class保证泛型参数是引用类型,T?表示返回值可能为空。实现类:

public class Repository<T> : IRepository<T> where T : class { private readonly AppDbContext _db; public Repository(AppDbContext db) { _db = db; } public async Task<List<T>> GetAllAsync() { return await _db.Set<T>().ToListAsync(); } public async Task<T?> GetByIdAsync(int id) { return await _db.Set<T>().FindAsync(id); } public async Task<T> AddAsync(T entity) { _db.Set<T>().Add(entity); await _db.SaveChangesAsync(); return entity; } public async Task<T> UpdateAsync(T entity) { _db.Set<T>().Update(entity); await _db.SaveChangesAsync(); return entity; } public async Task<bool> DeleteAsync(int id) { var entity = await _db.Set<T>().FindAsync(id); if (entity == null) return false; _db.Set<T>().Remove(entity); await _db.SaveChangesAsync(); return true; } }

这里的关键是_db.Set<T>(),EF Core会根据泛型类型自动找到对应的DbSet。SaveChangesAsync是EF Core将内存修改同步到数据库的唯一入口,忘记调用会导致所有操作看似成功、实际没入库。

在Program.cs注册泛型仓储:

builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));

AddScoped表示每个请求作用域内复用同一个实例,避免并发时DbContext冲突。

3.4 控制器落地:增删改查的五个HTTP端点怎么组织

有了仓储基类,控制器就清爽了。以用户管理接口为例:

[ApiController] [Route("api/users")] public class UsersController : ControllerBase { private readonly IRepository<User> _repo; public UsersController(IRepository<User> repo) { _repo = repo; } [HttpGet] public async Task<IActionResult> GetAll() { return Ok(await _repo.GetAllAsync()); } [HttpGet("{id}")] public async Task<IActionResult> GetById(int id) { var user = await _repo.GetByIdAsync(id); return user == null ? NotFound() : Ok(user); } [HttpPost] public async Task<IActionResult> Create(CreateUserDto dto) { var entity = new User { Username = dto.Username, PasswordHash = dto.Password, Nickname = dto.Nickname }; var created = await _repo.AddAsync(entity); return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); } [HttpPut("{id}")] public async Task<IActionResult> Update(int id, UpdateUserDto dto) { var user = await _repo.GetByIdAsync(id); if (user == null) return NotFound(); user.Nickname = dto.Nickname; await _repo.UpdateAsync(user); return NoContent(); } [HttpDelete("{id}")] public async Task<IActionResult> Delete(int id) { var deleted = await _repo.DeleteAsync(id); return deleted ? NoContent() : NotFound(); } }

[Route("api/users")]定义路由前缀,[HttpGet("{id}")]这种写法把URL参数绑定到方法参数。Ok()、NotFound()、NoContent()是ControllerBase内置的响应包装方法,分别对应200、404、204状态码。目前这个接口还没有任何权限控制,任何人调一下GET /api/users就能看到全部用户数据,下一章就把它锁起来。

4. JWT登录取证:登录一次,后续请求全部免认证

4.1 JWT的结构与签发逻辑:Header、Payload、Signature不再玄学

JWT(JSON Web Token)是一串由三个点分隔的字符串,形如xxxxx.yyyyy.zzzzz。第一部分Header声明加密算法,第二部分Payload携带用户声明(Claims),第三部分Signature是前两段内容的签名。服务端用同一个密钥对令牌签名,客户端每次请求带上令牌,服务端验证签名即可确认令牌未被篡改,这就是无状态认证的核心:服务端不用存Session,扩展时天然适合多实例部署。

签名算法我用HS256,这类对称加密算法加密和解密用同一个密钥,实现最简单。你只要保证密钥不泄露,安全性就掌握在自己手里。关于JWT漏洞的讨论网上很多,但绝大多数漏洞来自密钥太短、算法降级、过期时间太长这三个错误,而不是JWT本身有问题。

4.2 登录接口实现:查用户表、验密码、发令牌

先写一个独立的TokenService:

using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; using Demo.Api.Models; namespace Demo.Api.Services { public class TokenService { private readonly IConfiguration _config; public TokenService(IConfiguration config) { _config = config; } public string CreateToken(User user) { var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.Username), new Claim(ClaimTypes.Role, "User") }; var key = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(_config["Jwt:Key"])); var creds = new SigningCredentials( key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: _config["Jwt:Issuer"], audience: _config["Jwt:Audience"], claims: claims, expires: DateTime.Now.AddMinutes( Convert.ToDouble(_config["Jwt:ExpireMinutes"])), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } } }

Claim是JWT对"用户身份信息"的抽象,把用户Id、用户名、角色放进令牌里,接口层就能直接读出来。SymmetricSecurityKey的入参是字节数组,所以用Encoding.UTF8.GetBytes把配置里的字符串转成字节。expires控制令牌过期时间,这里从配置读取,便于后续调整。

登录接口写在AuthController里:

[ApiController] [Route("api/auth")] public class AuthController : ControllerBase { private readonly IRepository<User> _userRepo; private readonly TokenService _tokenService; public AuthController(IRepository<User> userRepo, TokenService tokenService) { _userRepo = userRepo; _tokenService = tokenService; } [HttpPost("login")] public async Task<IActionResult> Login(LoginDto dto) { var users = await _userRepo.GetAllAsync(); var user = users.FirstOrDefault(x => x.Username == dto.Username); if (user == null || !VerifyPassword(dto.Password, user.PasswordHash)) return Unauthorized(new { message = "用户名或密码错误" }); var token = _tokenService.CreateToken(user); return Ok(new { token, user.Nickname }); } private bool VerifyPassword(string input, string hash) { // 演示用简单Hash对比,生产环境建议用BCrypt或ASP.NET Core PasswordHasher var sha = System.Security.Cryptography.SHA256.Create(); var bytes = System.Text.Encoding.UTF8.GetBytes(input); var computed = Convert.ToHexString(sha.ComputeHash(bytes)); return computed.Equals(hash, StringComparison.OrdinalIgnoreCase); } }

这里GetAllAsync查出所有用户再匹配是偷懒写法,数据量大了应该用表达式树查询,后面进阶章再提。Unauthorized返回401状态码,前端可以根据这个状态跳转登录页。

4.3 鉴权三件套配置:AddAuthentication、UseAuthentication、[Authorize]

生成令牌只是第一步,关键是让每个受保护的接口自动验证令牌。在Program.cs里补上如下配置:

var key = Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]); 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"], ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(key), ValidateLifetime = true, ClockSkew = TimeSpan.FromMinutes(1) }; }); builder.Services.AddAuthorization();

AddAuthentication注册认证服务,JwtBearerDefaults.AuthenticationScheme指明使用Bearer Token方式;AddJwtBearer内部配置验证参数,六个Validate开关分别校验Issuer、Audience、签名密钥、签发时间、过期时间和时钟偏移。ClockSkew是服务端和客户端时钟差的容忍值,默认5分钟,我习惯压到1分钟减少令牌实际有效期被拉长的问题。

然后修改Program.cs的管道顺序:

var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();

注意UseAuthentication必须在UseAuthorization之前,顺序反了会得到404而不是401。最后在UsersController类上加上[Authorize]特性,没有令牌的请求统统被挡在门外。

4.4 从令牌里读当前用户:自定义Claims的三种取法

接口需要知道"当前登录的人是谁",用HttpContext.User获取:

[Authorize] [HttpGet("me")] public IActionResult GetMe() { var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; var username = User.Identity?.Name; var role = User.FindFirst(ClaimTypes.Role)?.Value; return Ok(new { userId, username, role }); }

FindFirst(ClaimTypes.NameIdentifier)取用户Id,User.Identity.Name取用户名,虽然Data里存的是"用户Id在NameIdentifier"这种映射关系,但EF Core和JWT标准已经帮你把字符串和类型对齐了。实际项目中,我还会把用户Id封装成一个扩展方法:

public static class ClaimsPrincipalExtensions { public static int GetUserId(this ClaimsPrincipal user) { var value = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; return int.TryParse(value, out var id) ? id : 0; } }

这样控制器里直接写User.GetUserId(),读到的就是当前登录用户的整型ID。

5. 避坑指南:Sqlserver连接失败、JWT解析翻车与EF Core迁移报错的排查

5.1 连接失败:Sqlserver的TCP/IP协议没启用

现象:项目启动后运行到第一个数据库操作,报错A network-related or instance-specific error occurred while establishing a connection to SQL Server。不是密码错,不是数据库名错,是压根没连上。

原因:Sqlserver安装时默认可能只开了Named Pipes协议,而客户端驱动的默认传输方式是TCP/IP。这种情况在开发机装Sqlserver时尤其常见,安装向导不提示,配置文件也不报错,像个黑匣子。

解决:打开SQL Server 配置管理器,找到SQL Server网络配置→ 实例名的协议,右键TCP/IP选择启用,然后到SQL Server服务里重启对应的Sqlserver服务。重启后回到项目,不用改任何代码,连接就通了。注意操作过程需要管理员权限。

5.2 签名报错:JWT密钥长度不足32字节

现象:登录接口生成令牌正常,但调用受保护接口时返回401,服务端日志显示IDX10603: Signature validation failed或IDX10653: key is too small。

原因:我用的是HS256算法,该算法要求签名密钥至少256位,即32字节。配置里随便写了一个"mysecret"只有9字节,SymmetricSecurityKey会直接拒绝或生成弱签名,令牌验签永远失败。

解决:把Jwt:Key替换成至少32位的随机字符串。你可以用PowerShell生成:

$bytes = New-Object byte[] 32 [Security.Cryptography.RandomNumberGenerator]::Fill($bytes) [Convert]::ToBase64String($bytes)

生成的Base64字符串贴到配置文件里。如果字符串含特殊字符,记得用JSON转义。

5.3 迁移报错:排序规则冲突与生成表名不一致

现象:执行dotnet ef database update时报错,提示Cannot resolve the collation conflict between "Chinese_PRC_CI_AS" and "SQL_Latin1_General_CP1_CI_AS",或者迁移后SSMS里看到表名变成了Users带复数,和预期不一致。

原因:前者是Sqlserver实例默认排序规则与数据库排序规则不一致,常见于服务器上既有中文实例、又有英文实例的情况。后者是EF Core默认会把实体名复数化,User变成Users。本身没问题,但如果你在连接字符串里指定了某个已有库,表名不一致会导致运行时找不到表。

解决:排序规则冲突可以在DbContext的OnModelCreating里固定排序规则:

protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity<User>() .Property(x => x.Username) .UseCollation("Chinese_PRC_CI_AS"); modelBuilder.Entity<User>().ToTable("User"); }

UseCollation指定列级别排序规则,ToTable显式指定表名单数或你自己想要的表名,避免EF Core的复数化和你手动建的表名不一致。这个配置要在迁移前写好,已经迁移过的项目得先删掉旧迁移重建。

5.4 参数校验不生效:漏了[ApiController]

现象:CreateUserDto上标了[Required],但前端不传Username时接口仍然进入方法体,返回200而不是400,新增了空的用户记录。

原因:ASP.NET Core的模型自动校验依赖[ApiController]特性,只有加上它,控制器才会在参数绑定失败时自动返回400。如果继承了ControllerBase但没标注,[Required]就成了摆设。

解决:控制器类上补上加粗代码。还不行就手动校验:

if (!ModelState.IsValid) return BadRequest(ModelState);

放在方法体第一行。前者自动、后者手动,两者组合起来覆盖所有场景。

5.5 部署翻车:IIS进程身份连不上Sqlserver

现象:本地调试一切正常,发布到Windows服务器IIS后接口全部报500,日志显示Sqlserver登录失败。

原因:IIS应用程序池默认身份是ApplicationPoolIdentity,它属于IIS_IUSRS组,在Sqlserver里没有登录权限。如果你用的是Windows身份验证,数据库会拒绝这个匿名进程访问。

解决:三个方案任选其一。第一种,在IIS应用程序池高级设置里把进程模型-标识改成NetworkService,然后在Sqlserver里给NT AUTHORITY\NETWORK SERVICE加登录名并授权;第二种,连接字符串改用Sqlserver账号密码(本地那种User Id=sa方式);第三种,在Sqlserver里创建专用登录账号,连接字符串里指定它。我推荐第三种,权限最小、出问题好查审计。

6. 进阶:Token续签、统一响应与发布后的验证清单

6.1 Token续签:滑动过期与双Token两种方案

JWT令牌过期后,不能让用户重新输密码,常见做法是滑动过期或者双Token。滑动过期是每次请求时,如果令牌剩余时间不足一半,就用RefreshToken换一个新令牌;双Token则是签一个短命AccessToken和一个长命RefreshToken,AccessToken过期后用RefreshToken去换。我常用双Token方案,RefreshToken存数据库,过期时可以吊销。

实现要点是给TokenService加一个CreateRefreshToken方法,生成一个GUID字符串存入数据库的RefreshTokens表,登录接口同时返回AccessToken和RefreshToken;新增一个refresh端点,验证RefreshToken有效后签发新令牌。这个方案要额外建一张表,但逻辑直白、撤销方便。如果不想建表,滑动过期用JwtSecurityTokenHandler自带的expires重签也能凑合,只是没办法让令牌立即失效。

6.2 统一响应与全局异常处理

目前接口成功时返回裸数据,失败时返回各种状态码,前端对接时很痛苦。我一般把响应包一层:

public class ApiResult<T> { public int Code { get; set; } public string Message { get; set; } public T Data { get; set; } }

再配合一个全局异常过滤器,捕获未处理异常并统一返回ApiResult<object>,让500状态码带上可读的错误信息。这个过滤器继承IExceptionFilter,约二十行代码就能覆盖所有接口,收益不小。

6.3 发布后验证:用Swagger把增删改查完整跑一遍

发布到IIS后,不要急着联调前端,先用Swagger走一遍完整链路。启动网站,访问/swagger,按这个顺序验证:先调POST /api/auth/login拿token,点右上角Authorize按钮把token粘贴进去;然后GET /api/users应该返回200,不带token应该返回401;接着POST /api/users新增一条记录,再用PUT /api/users/{id}改昵称,最后DELETE /api/users/{id}删掉,全链路无报错才算发布成功。这一步能筛掉八成部署问题。我做项目这些年,最深刻的教训就是"发布不是结束,验证才算数"——每次部署完都亲手过一遍这五个接口,认证、读写、权限就都有了底。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询