ASP.NET Core Web API 实现 JWT 身份验证
在ASP.NET Core WebApi中使用标识框架(Identity)-CSDN博客
因为一般需要和标识框架一起使用,建议先查看标识框架用法
一.为什么需要JWT
我们的系统需要实现认证,即服务端需要知道登录进来的客户端的身份,管理员有管理员的权限,普通用户有普通用户的权限.
但服务端是基于HTTP协议的,该协议本质上是无状态,两次请求本质上是独立的,也就是说该协议无法帮我们实现认证.
1、传统的 Session 认证机制
早期认证的实现方式是Session,流程大概如下:
(1)用户登录,服务端验证用户名密码成功后,生成一个唯一的 SessionId。
(2)这个 SessionId 存在服务端内存(或者数据库、Redis)里,对应一个用户状态。
(3)服务端通过 Set-Cookie
把 SessionId 写入浏览器 Cookie。
(4)浏览器后续请求自动携带 Cookie,服务端用这个 SessionId 找到用户信息。
Session 的缺点:
问题 | 说明 |
---|---|
服务器内存压力 | 每登录一个用户,服务器都要保存一份 Session 数据,用户多了就容易撑爆内存 |
不适合分布式 | 多台服务器集群部署时,Session 要么共享存储(如 Redis),要么做 Session 粘性路由,增加系统复杂度 |
跨域难处理 | 前后端分离、跨域 API 调用时,Cookie 不好用或者需要复杂的 CORS 配置 |
状态管理复杂 | 如果 Session 丢失、超时、清理,用户体验会很差,需要额外处理 |
2、JWT 的出现:无状态化认证
随着微服务、云原生、前后端分离等架构兴起,开发者开始追求一种 「无状态」且「轻量级」 的认证方案,JWT 应运而生。
3、JWT 对比 Session 的核心区别
对比点 | Session | JWT |
---|---|---|
状态管理 | 服务端有状态,需要存储每个用户 Session | 完全无状态,Token 自包含用户信息 |
存储位置 | 服务端内存/数据库 | 客户端自行保存(通常存在本地存储或 Cookie) |
跨服务 | 需要共享 Session 或做负载均衡粘性 | 天然支持多服务,无需 Session 同步 |
扩展性 | 横向扩展困难 | 服务端可任意扩容 |
性能 | 每次请求都查找 Session | 不需要查 Session,Token 自解密验证 |
4、JWT 的工作流程概览(无状态认证)
(1)用户登录,后端生成 JWT 返回给前端。
(2)前端保存好 JWT
(3)每次 API 请求,前端把 JWT 放到 Authorization: Bearer
头里。
(4)后端中间件解析 JWT,验签,通过后即可认为该用户已登录。
服务端只负责「验签 + 解密」,不保存任何 Session 状态。
5、JWT 的优点
优点 | 说明 |
---|---|
跨服务、跨平台 | 多服务架构天然支持,移动 App、Web 前端、第三方系统都可以用同一个 Token |
减少服务器压力 | 服务端无需保存登录状态 |
性能高 | 每次只需做一次 Token 验证,无需 Session 查询 |
易与 CDN、API 网关等集成 | 请求携带 Token,网关层即可完成鉴权 |
标准化 | 基于开放标准 RFC7519,广泛支持,工具链成熟 |
6、为什么现在很多新项目都选择 JWT?
-
适合微服务
-
适合前后端分离
-
适合跨平台 App
-
适合无状态、弹性伸缩的云架构
7、JWT 取代 Session,不是因为它绝对更好,而是因为它更「适应当代架构」
JWT 并不是完美无缺,它也有一些缺点,比如:
缺点 | 说明 |
---|---|
Token 无法主动失效 | 如果用户登出或者权限变更,老 Token 依然有效(可通过 Token 黑名单、Token 版本号等方式绕过) |
容易被盗用 | 如果 Token 泄露,别人拿到 Token 就可以冒充用户 |
Token 较长 | JWT 体积大,不适合非常高频短连接场景 |
二.什么是 JWT?
JWT,全称 JSON Web Token,是一种开放标准(RFC 7519),用于在不同系统之间安全地传输信息。它是一种基于 JSON 格式、经过数字签名的数据令牌,主要应用于 身份认证 和 信息交换 场景。
1.JWT 的核心用途
-
身份认证(Authentication)
用户登录成功后,服务器生成一个包含用户身份信息的 JWT,返回给客户端。客户端后续每次请求,都带上这个 Token,服务器通过验证 Token,确认用户身份,无需重复登录。 -
信息交换(Information Exchange)
系统之间可以通过 JWT 安全地交换一些加密或不可篡改的声明信息。
2.JWT 的结构:三段式组成
一个典型的 JWT 长这样(这是被算法处理过的):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyTmFtZSI6ImFkbWluIiwicm9sZSI6IkFkbWluIn0.NhJzHfJZKIo0FPWqGk92OukUjD0YPgXVyknzZoAW_2Y
被点号分隔成三段:
(1) Header(头部)
指定 Token 的类型(通常是 JWT)以及签名所用的算法,比如 HS256
。
{"alg": "HS256","typ": "JWT"
}
(2) Payload(负载)
放具体的声明信息(Claims),比如用户 ID、用户名、角色、过期时间等。
{"userName": "admin","role": "Admin","exp": 1719820800
}
(3) Signature(签名)
防止篡改。由 Header、Payload 和一个 Secret 密钥(只有服务端知道),通过指定算法生成。
签名生成方式(以 HMAC-SHA256 为例):
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret
)
三.控制台使用
1.环境搭建
先创建一个控制台程序生成JWT,需要安装JWT读写的NuGet包
System.IdentityModel.Tokens.Jwt
2.生成JWT
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
static void Main(string[] args)
{// 创建用户的 Claims 列表// Claim就代表一条用户信息。Claim有两个主要的属性:Type和Value,它们都是string类型的,Type代表用户信息的类型,Value代表用户信息的值。// Type属性可以是预定义的类型,如ClaimTypes.Name、ClaimTypes.Role等,也可以是自定义的类型。var claims = new List<Claim>();// 用户唯一标识,比如用户ID,这里用"6"做示例claims.Add(new Claim(ClaimTypes.NameIdentifier, "6"));// 用户姓名,这里是 "ZhangSan"claims.Add(new Claim(ClaimTypes.Name, "ZhangSan"));// 用户角色,注意:可以有多个角色声明claims.Add(new Claim(ClaimTypes.Role, "User"));claims.Add(new Claim(ClaimTypes.Role, "Admin"));// 自定义 Claim,比如扩展字段,这里自定义了一个 "jz" 字段claims.Add(new Claim("jz", "112233"));// 定义密钥字符串,生产环境一般放在配置文件,不要硬编码string key = "kjdfsjffd^kjfkfkds#dsffdsdsfd@fdsufdsfo33300";// 设置 Token 的过期时间,这里是 1 天后过期DateTime expires = DateTime.Now.AddDays(1);// 把密钥字符串转成字节数组byte[] secBytes = Encoding.UTF8.GetBytes(key);// 根据密钥生成对称加密安全密钥对象var secKey = new SymmetricSecurityKey(secBytes);// 指定签名算法,这里使用 HMAC-SHA256var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature);// 创建 JWT Token 对象,包括:claims、过期时间、签名凭据var tokenDescriptor = new JwtSecurityToken(claims: claims, // 载荷:用户身份信息expires: expires, // 有效期signingCredentials: credentials // 签名信息);// 把 JwtSecurityToken 对象序列化成最终的 Token 字符串string jwt = new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);// 输出 TokenConsole.WriteLine(jwt);
}
eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjYiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiWmhhbmdTYW4iLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOlsiVXNlciIsIkFkbWluIl0sImp6IjoiMTEyMjMzIiwiZXhwIjoxNzUwOTg5ODMwfQ.7sl9Y18uxGU-Xd9Ly3rfXKnKidBJ_ZZjPyZOwnTR_0c
3.JWT解析
Header 和 Payload 是明文的,只是做了 Base64Url 编码,不是加密
比如一个原始 Payload:
{"userName": "admin","role": "Admin","exp": 1719820800
}
Base64Url 编码后就是一串字母数字:
eyJ1c2VyTmFtZSI6ImFkbWluIiwicm9sZSI6IkFkbWluIiwiZXhwIjoxNzE5ODIwODAwfQ
JWT 三部分都是明文(Header 和 Payload 可直接 Base64Url 解码),JWT 的重点是防篡改而不是保密
所以不难理解,别人拿到你的JWT就可以冒充你.
jwt在线解密/加密 - JSON中文网json中文网致力于在中国推广json,json Web Tokens 是目前流行的跨域认证解决方案,json中文网提供jwt解密/加密工具,提供HS256、HS384和HS512等签名算法的编码和校验。https://www.json.cn/jwt 可以将得到的JWT直接放到jwt解析网站上就能解析出前两部分的信息.
或者使用下面这个方法
string jwt = Console.ReadLine()!;
string[] segments = jwt.Split('.');
string head = JwtDecode(segments[0]); // 头部
string payload = JwtDecode(segments[1]); // 负载
Console.WriteLine("--------head--------");
Console.WriteLine(head);
Console.WriteLine("--------payload--------");
Console.WriteLine(payload);string JwtDecode(string s)
{s = s.Replace('-', '+').Replace('_', '/');switch (s.Length % 4){case 2:s += "==";break;case 3:s += "=";break;}var bytes = Convert.FromBase64String(s); // 解码return Encoding.UTF8.GetString(bytes);
}
可以看到信息被解析出来了,由于JWT会被发送到客户端,而负载中的内容是以明文形式保存的,因此一定不要把不能被客户端知道的信息放到负载中。
JWT的编码和解码规则都是公开的,而且负载部分的Claim信息也是明文的,因此恶意攻击者可以对负载部分中的用户ID等信息进行修改,从而冒充其他用户的身份来访问服务器上的资源。因此,服务器端需要对签名部分进行校验,从而检查JWT是否被篡改了。
// 从控制台读取用户输入的 JWT 字符串
string jwt = Console.ReadLine()!; // 注意:加了 "!" 是为了告诉编译器:这里不会是 null// 定义密钥字符串(要与生成 JWT 时用的密钥保持一致,否则验证会失败)
string secKey = "kjdfsjffd^kjfkfkds#dsffdsdsfd@fdsufdsfo33300";// 创建一个 JWT Token 解析器
JwtSecurityTokenHandler tokenHandler = new();// 定义 Token 验证参数
TokenValidationParameters valParam = new();// 设置签名验证的密钥,必须和生成时一致
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secKey));
valParam.IssuerSigningKey = securityKey;// 不验证签发者 (Issuer),这里简化处理(生产环境可以启用校验)
valParam.ValidateIssuer = false;// 不验证接收者 (Audience),同样为了简化
valParam.ValidateAudience = false;// 开始验证 Token
// ValidateToken 方法会做:
// 1. 验证签名
// 2. 验证 Token 是否过期
// 3. 返回解析后的 ClaimsPrincipal 对象(包含用户身份信息)
// out 参数会返回原始的 SecurityToken 对象
ClaimsPrincipal claimsPrincipal = tokenHandler.ValidateToken(jwt, valParam, out SecurityToken secToken);// 遍历解析出来的 Claim 列表,并输出每个 Claim 的类型和值
foreach (var claim in claimsPrincipal.Claims)
{Console.WriteLine($"{claim.Type}={claim.Value}");
}
如果篡改JWT,程序运行时就会抛出内容为“Signature validation failed”的异常。exp值是过期时间,如果收到过期的JWT,即使签名校验成功,ValidateToken方法也会抛出异常
四.WebApi中使用
1.环境准备
"JWT": {"SigningKey": "kjdfsjffd^kjfkfkds#dsffdsdsfd@fdsufdsfo33300EXTRA","ExpireSeconds": "3600"}
public class JwtSetting{public string SigningKey { get; set; }public int ExpireSeconds { get; set; }}
我们先在配置系统appsettings.json
中配置一个名字为JWT的节点,并在节点下创建SigningKey、ExpireSeconds两个配置项,分别代表JWT的密钥和过期时间(单位为秒)。
我们再创建一个对应JWT节点的配置类JwtSetting,类中包含SigningKey、ExpireSeconds这两个属性。
安装Microsoft.AspNetCore.Authentication.JwtBearer
包,这个包封装了简化ASP.NET Core中使用JWT的操作
2.注册服务
// 将配置文件中的 JWT 部分绑定到 JwtSetting 配置类
builder.Services.Configure<JwtSetting>(builder.Configuration.GetSection("JWT"));// 注册 JWT Bearer 身份认证服务
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(x =>{// 从配置中读取 JWT 设置对象(比如密钥等信息)var jwtOpt = builder.Configuration.GetSection("JWT").Get<JwtSetting>();// 把密钥字符串转为字节数组byte[] keyBytes = Encoding.UTF8.GetBytes(jwtOpt.SigningKey);// 用密钥生成对称安全密钥对象var secKey = new SymmetricSecurityKey(keyBytes);// 配置 Token 验证参数x.TokenValidationParameters = new TokenValidationParameters(){// 是否验证 Token 的签发者(Issuer),这里关闭ValidateIssuer = false,// 是否验证 Token 的接收方(Audience),这里关闭ValidateAudience = false,// 是否验证 Token 的过期时间,生产环境一般要打开,这里关闭是为了开发方便ValidateLifetime = false,// 是否验证 Token 的签名,生产环境一定要开ValidateIssuerSigningKey = true,// 用来验证签名的密钥IssuerSigningKey = secKey};});
本质上就是中间件,别忘了使用.
3.给登录用户发JWT
// 控制器:负责处理用户登录请求,并生成 JWT Token
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{private readonly IOptions<JwtSetting> _jwtSetting; // JWT 配置信息private readonly ILogger<AuthController > _logger;private readonly UserManager<User> _userManager;private readonly RoleManager<Role> _roleManager;public AuthController(ILogger<AuthController > logger, UserManager<User> userManager,RoleManager<Role> roleManager, IOptions<JwtSetting> jwtSetting){_logger = logger;_userManager = userManager;_roleManager = roleManager;_jwtSetting = jwtSetting;}// 登录接口,接收用户名密码,验证成功后生成 JWT[HttpPost]public async Task<IActionResult> Login(LoginRequest loginRequest){string userName = loginRequest.UserName;string password = loginRequest.Password;// 使用 Identity 框架查找用户var user = await _userManager.FindByNameAsync(userName);if (user == null){return BadRequest("用户不存在");}// 判断用户是否被锁定(连续登录失败导致)var islocked = await _userManager.IsLockedOutAsync(user);if (islocked){// 用户锁定,返回 400,提示锁定信息return BadRequest("用户已锁定!");}// 校验密码var success = await _userManager.CheckPasswordAsync(user, password);if (!success){// 密码错误,记录一次失败尝试(用于锁定机制)var r = await _userManager.AccessFailedAsync(user);if (!r.Succeeded){// 记录失败信息失败,返回错误return BadRequest("访问失败信息写入错误!");}else{// 普通密码错误返回 400return BadRequest("失败!");} }//重置访问失败计数await _userManager.ResetAccessFailedCountAsync(user);// 构建 JWT Claims(载荷里的用户信息)var claims = new List<Claim>{// 用户唯一标识new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),// 用户名new Claim(ClaimTypes.Name, user.UserName)};// 查询用户角色,并把每个角色加入到 Claimsvar roles = await _userManager.GetRolesAsync(user);foreach (var role in roles){claims.Add(new Claim(ClaimTypes.Role, role));}// 调用封装好的 Token 构建方法,生成 JWT 字符串string jwtToken = BuildToken(claims, _jwtSetting.Value);// 把 Token 返回给前端return Ok(jwtToken);}/// <summary>/// 根据用户 Claims 和 JWT 配置,生成 JWT Token 字符串/// </summary>private static string BuildToken(IEnumerable<Claim> claims, JwtSetting _jwtSetting){// 设置 Token 过期时间DateTime expires = DateTime.Now.AddSeconds(_jwtSetting.ExpireSeconds);// 根据配置的密钥生成安全密钥对象byte[] keyBytes = Encoding.UTF8.GetBytes(_jwtSetting.SigningKey);var secKey = new SymmetricSecurityKey(keyBytes);// 指定签名算法,这里用 HMAC-SHA256var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature);// 创建 Token 对象,包括过期时间、签名凭据、Claimsvar tokenDescriptor = new JwtSecurityToken(expires: expires,signingCredentials: credentials,claims: claims);// 序列化成最终 Token 字符串return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);}
}
4.接口校验JWT
[Route("[controller]/[action]")][ApiController][Authorize] // 表示:访问此控制器下的所有 Action,都必须登录并携带有效 JWTpublic class UserInfoController : ControllerBase{/// <summary>/// 测试用接口:返回当前登录用户的身份信息(从 JWT Claims 解析)/// </summary>[HttpGet]public IActionResult Hello(){// 从 Claims 中获取用户IDstring id = this.User.FindFirst(ClaimTypes.NameIdentifier)!.Value;// 获取用户名string userName = this.User.FindFirst(ClaimTypes.Name)!.Value;// 获取用户拥有的所有角色IEnumerable<Claim> roleClaims = this.User.FindAll(ClaimTypes.Role);// 把角色列表拼接成逗号分隔的字符串string roleNames = string.Join(',', roleClaims.Select(c => c.Value));// 返回身份信息return Ok($"id={id}, userName={userName}, roleNames={roleNames}");}}
添加的[Authorize]
表示这个控制器类下所有的操作方法都需要登录后才能访问。
ControllerBase中定义的ClaimsPrincipal
类型的User属性代表当前登录用户的身份信息,我们可以通过ClaimsPrincipal的Claims属性获得当前登录用户的所有Claim信息,不过我们一般通过FindFirst
方法根据Claim的类型来查找需要的Claim,如果用户身份信息中含有多个同类型的Claim,我们则可以通过FindAll
方法来找到所有Claim。
5.swagger调试
直接访问401无权限 ,我们需要传入jwt才能访问该接口.
ASP.NET Core要求(这也是HTTP的规范)JWT放到名字为Authorization的HTTP请求报文头中,报文头的值为“Bearer JWT”。
Swagger中默认没有提供设置自定义HTTP请求报文头的方式,因此对于需要传递Authorization报文头的接口,调试起来很麻烦。我们可以通过对OpenAPI进行配置,从而让Swagger中可以发送Authorization报文头。
// 注册 Swagger 服务,同时配置 JWT 认证支持builder.Services.AddSwaggerGen(c =>{// 定义一个 OpenApiSecurityScheme:告诉 Swagger,这里有一个全局的 Header 参数叫 Authorizationvar scheme = new OpenApiSecurityScheme(){// Swagger UI 上显示的描述信息,告诉开发者怎么填写 TokenDescription = "在请求头中加入 Authorization 字段,例如:'Bearer 12345abcdef'",// 给这个 SecurityScheme 起一个引用ID,后面配置用Reference = new OpenApiReference{Type = ReferenceType.SecurityScheme,Id = "Authorization"},// Scheme 字段,这里用 "oauth2" 字符串(其实可以写任何字符串,Swagger 不校验这个)Scheme = "oauth2",// 参数名,Swagger UI 会自动生成这个 Header 字段Name = "Authorization",// 参数的位置:在 HTTP Header 中In = ParameterLocation.Header,// 声明类型是 API Key(Swagger 把 "Authorization" 这种 Header 参数用 ApiKey 类型)Type = SecuritySchemeType.ApiKey,};// 添加这个 Security 定义,名称叫 "Authorization",Swagger UI 会显示一个输入框c.AddSecurityDefinition("Authorization", scheme);// 创建一个全局安全要求:告诉 Swagger,每个接口默认都要带这个 SecuritySchemevar requirement = new OpenApiSecurityRequirement();// 给这个 requirement 加上刚才定义的 scheme,值是空列表(Swagger 需要这么写)requirement[scheme] = new List<string>();// 把这个全局安全要求加到 Swagger 配置里c.AddSecurityRequirement(requirement);});
首先我们要先利用前面的登录接口获取一个JWT,然后通过这个按钮将JWT传入,此时你就可以访问那些需要认证的接口了.