【ASP.NET Core】分布式场景下ASP.NET Core中JWT应用教程
文章目录
- 前言
- 一、JWT(JSON Web Token)
- 1.1 什么是JWT
- 1.2 JWT的验证流程
- 1.3 JWT令牌的优点
- 二、ASP.NET Core中应用JWT
- 2.1 配置文件
- 2.2 封装JWT Service 生成Token
- 2.3 注册JWT验证
- 2.4 Controller调用生成令牌服务
- 2.5 基于JWT的身份验证和授权验证
- 总结
前言
这些年来随着前端的不断工程化,JWT的应用是愈加广泛。考虑到HTTP本身是无状态的,像传统web开发中主要依赖一种键值对的结构Session来鉴别用户身份。通过请求里传入的cookie,这个Cookie里保存是一个键值对SessionID,并且和服务器上的完整的Session键值对对应,从而找到并获取用户数据。
由于Session本身是保存在服务器内存里的,对于分布式的环境而言,必须有一个中心化的服务器来存储Session本身。当客户端请求被Nginx之类代理服务转发到特定的服务器,该服务器需要去中心服务器通过请求Cooke匹配对应的Session。虽然有通过Redis存Sessiion这种方案,但是整体上从执行逻辑上来看,跨服务器通信本身就存在网络等待时间和性能开销,对中心状态服务器本身是一件要求不低的事情,当然可以把中心状态服务器做成集群,但这样就陷入了水多加面,面多加水的困境了。
有了问题,于是就有了方案。可以这么说,JWT的提出就是为了解决分布式场景下的身份鉴权。
一、JWT(JSON Web Token)
1.1 什么是JWT
JWT全称JSON Web Token, 是一种基于 JSON 的轻量级令牌,用于在网络应用间传递经签名的声明信息(如用户身份、权限等)。JWT本质上是一种标准,用于身份校验,你可以在各种后端框架里手动实现JWT。比如本人曾有一篇关于在WCF里应用JWT的实现 链接: 【WCF】通过AOP实现基于JWT的授权与鉴权的实践
JWT本身是存储在客户端,在一次HTTP请求中被携带传入服务器,进行校验。为了防止客户端的数据造假,保存在客户端的令牌经过了签名处理,而签名的密钥只有服务器端才知道,每次服务器端收到客户端提交过来的令牌的时候都要检查一下生成的签名和客户端传入JWT签名是否一致,从而判断此令牌是否被篡改。
一个JWT其结构分为三部分:
- Header(头部):声明令牌类型(JWT)和签名算法(如 HS256)。
- Payload(载荷):存储核心声明信息(如用户信息、过期时间t等),可自定义字段(如授权角色)。
- Signature(签名):通过头部指定的算法,用密钥(或公钥)对 Header 和 Payload 进行签名,确保令牌未被篡改。
值得注意的是对于一个完整JWT来说,它本身其实是透明的。由于JWT本质是一个公开的身份验证标准,任何人拿到一个JWT都能完整的解析出里面包含的Header,Payload和Signature内容。尤其是Payload载荷信息,切记不要在里面存入手机号、身份证之类的敏感信息。
1.2 JWT的验证流程
- 服务器生成JWT令牌【令牌颁发服务器】
一般是在完成登录成功后,服务器从验证通过后,构建Header和Payload。令牌颁发服务器使用保存的密钥对JWT头部和载荷进行签名,生成完整 JWT,并作为请求结果将JWT返回。 - 客户端获取并在请求时携带JWT Token
在上一步中,客户端完成登录成功获取JWT。之后的每次请求都需要在Authorization头部携带该Token,Authorization: Bearer [JWT Token] - 服务器验证JWT令牌
服务器端验证JWT,从请求的Authorization中提取令牌,将其分割为 Header、Payload、Signature 三部分。使用相同的密钥和Header中的签名算法对Header和Payload再次加密对比验证生成的Signature和请求头里解析出来的Signature ,如果不一致则说明存在被篡改的情况。
之后就是验证 Payload里的信息,如验证Token的颁发者,验证Token的接收者,验证Token的过期时间等。
1.3 JWT令牌的优点
- JWT本身是无状态的,服务器无需存储会话数据,身份信息保存在客户端。一次HTTP请求中仅通过验证令牌签名即可确认身份,降低服务器存储压力。这种无状态的特征天然适合分布式系统。
- JWT的签名保证了客户端数据无法篡改,任何对JWT内容的的篡改(如Header、Payload又或者是Signature本身)都会导致生成的签名和携带的签名不一致
- 由于服务器不需要存储会话数据,使用JWT的方案显然对服务器的性能要求会低一些。这种方案不需要和中心服务器通信,是纯内存的计算。
二、ASP.NET Core中应用JWT
在ASP.NET Core中应用JWT主要分两步:
- 第一步是通过JWT默认的标准生成Token。令牌是自含颁发者和受众的信息。令牌可以由多个颁发者服务器生成,也可以被授予多个受众。由各类客户端访问接口时通过受众信息来判断本Token是否具有权限访问,通过颁发者信息来判断是否是合法途径生成的Token;
- 第二步是验证JWT是否合规,一般是通过判断二次生成的令牌签名是否一致,颁发者和受众者是否匹配,令牌是否过期等。
生成Token的时候我们可以指定令牌的听众Audience,也就是使用这个令牌的客户端允许访问的接口地址,这个时候颁发者是有且唯一的。
验证Token的时候,需要提前传入全部合规的颁发者和受众者。Audience和Issuer可能都是多个。
实际生产环境中,令牌颁发服务器可能是多个,而且一般是是独立于验证服务本身。
2.1 配置文件
我们在appsettings.cs里写入完整的配置,包含签名信息,颁发者信息和验证者信息。需要明确的是生成Token的时候颁发者是唯一的,但是可以由多个颁发者生成Token。也就是验证Token的时候,颁发者可以是多个来源。
{"JwtSettings": {//签名配置"Signing": {"Algorithm": "HS256", //签名算法"SecretKey": "${你的密钥}"},//生成Token:生成令牌时的配置"Generator": {"Issuer": "https://localhost:5200", // 颁发者,也就是当前服务器自身"Audience": [ "https://api.service.com", "https://order.service.com" ], //令牌面向的受众列表"ExpiresInMinutes": 30, //令牌分钟有效期"NotBeforeMinutes": 0 //延迟生效分钟时间},//验证Token:验证令牌时的配置"Validator": {"AllowedIssuers": [ "https://localhost:5200", "https://localhost:5201" ], //允许的签发者列表"AllowedAudiences": [ "https://api.service.com" ], //允许的受众(当前服务自身标识)"ValidateLifetime": true, //是否验证有效期"ClockSkewSeconds": 30 //时钟偏差容忍(秒,避免服务器时间差导致验证失败)}},}
2.2 封装JWT Service 生成Token
先按步骤拆解代码,完整代码在本小节最后
文件架构
Common/
├── JWT/
│ ├── Configurations/
│ │ ├── JwtSettings.cs
│ ├── Service/
│ │ ├── IJWTService.cs
│ │ ├── JWTService.cs
└────────────
添加引用
add package System.IdentityModel.Tokens.Jwt
JwtSetting.cs配置实体
/// <summary>
/// JWT配置
/// </summary>
public class JwtSettings
{/// <summary>/// 签名相关配置/// </summary>public SigningSettings Signing { get; set; } = new SigningSettings();/// <summary>/// 生成令牌的配置/// </summary>public GeneratorSettings Generator { get; set; } = new GeneratorSettings();/// <summary>/// 验证令牌的配置/// </summary>public ValidatorSettings Validator { get; set; } = new ValidatorSettings();
}/// <summary>
/// 签名算法及密钥配置
/// </summary>
public class SigningSettings
{/// <summary>/// 签名算法(如 HS256、RS256)/// </summary>public string Algorithm { get; set; } = "HS256";/// <summary>/// 对称加密密钥(HS系列算法使用)/// </summary>public string SecretKey { get; set; } = string.Empty;
}/// <summary>
/// 生成令牌的配置
/// </summary>
public class GeneratorSettings
{/// <summary>/// 签发者(iss)/// </summary>public string Issuer { get; set; } = string.Empty;/// <summary>/// 受众列表(aud)/// </summary>public List<string> Audience { get; set; } = new List<string>();/// <summary>/// 令牌有效期(分钟)/// </summary>public int ExpiresInMinutes { get; set; } = 30;/// <summary>/// 延迟生效时间(分钟)/// </summary>public int NotBeforeMinutes { get; set; } = 0;
}/// <summary>
/// 验证令牌的配置
/// </summary>
public class ValidatorSettings
{/// <summary>/// 允许的签发者列表(验证iss时使用)/// </summary>public List<string> AllowedIssuers { get; set; } = new List<string>();/// <summary>/// 允许的受众列表(验证aud时使用)/// </summary>public List<string> AllowedAudiences { get; set; } = new List<string>();/// <summary>/// 是否验证令牌有效期(exp和nbf)/// </summary>public bool ValidateLifetime { get; set; } = true;/// <summary>/// 时钟偏差容忍(秒)/// </summary>public int ClockSkewSeconds { get; set; } = 30;
}
Program.cs注册JwtSetting
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("JwtSettings"));
准备claims
JWT中有一些基本的载荷信息,如用户唯一标识,用户名,签发时间。还可以加入一些自定义的Claim信息等。另外就是载荷里的角色信息,它可以命名多个。
List<Claim> claims = new List<Claim>() {new Claim(JwtRegisteredClaimNames.Sub, httpUser.UserId.ToString()), //用户唯一标识new Claim(JwtRegisteredClaimNames.Name, httpUser.Name), //用户名new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.Now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), //签发时间new Claim("NickName",httpUser.NickName ?? ""),new Claim("Description",httpUser.Description),new Claim("Age",httpUser.Age.ToString()),new Claim("JWTVersion",httpUser.JWTVersion.ToString())
};
//添加角色
claims.AddRange(httpUser.RoleList.Select(role => new Claim(ClaimTypes.Role, role.Trim())));
准备加密key
依据服务器里appsetting里存储的配置,获取加密方式,密钥来生成签名凭证
SymmetricSecurityKey securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Signing.SecretKey));
SigningCredentials signingCredentials = new SigningCredentials(securityKey,GetSecurityAlgorithm(_jwtSettings.Signing.Algorithm)
);
生成令牌字符串
生成令牌的时候,颁发者就是本服务器本身,但是受众可能会有多个,通过逗号隔开。
JwtSecurityToken jwtSecurityToken = new JwtSecurityToken(issuer: _jwtSettings.Generator.Issuer,audience: string.Join(",", _jwtSettings.Generator.Audience), //多受众用逗号分隔claims: claims,notBefore: DateTime.Now.AddMinutes(_jwtSettings.Generator.NotBeforeMinutes), //延迟生效expires: DateTime.Now.AddMinutes(_jwtSettings.Generator.ExpiresInMinutes), //过期时间signingCredentials: signingCredentials
);
return new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken);
完整代码
public class JWTService : IJWTService
{private readonly JwtSettings _jwtSettings;public JWTService(IConfiguration configuration, IOptions<JwtSettings> jwtSettings){_jwtSettings = jwtSettings.Value ?? throw new ArgumentNullException(nameof(jwtSettings));ValidateGeneratorSettings();}public string GenerateToken(HttpUser httpUser){//验证httpUserif (httpUser == null){throw new ArgumentNullException(nameof(httpUser), "用户信息不能为空");}//准备claimsList<Claim> claims = new List<Claim>() {new Claim(JwtRegisteredClaimNames.Sub, httpUser.UserId.ToString()), //用户唯一标识new Claim(JwtRegisteredClaimNames.Name, httpUser.Name), //用户名new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.Now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), //签发时间new Claim("NickName",httpUser.NickName ?? ""),new Claim("Description",httpUser.Description),new Claim("Age",httpUser.Age.ToString()),new Claim("JWTVersion",httpUser.JWTVersion.ToString())};//添加角色if (httpUser.RoleList != null && httpUser.RoleList.Any()){claims.AddRange(httpUser.RoleList.Select(role =>new Claim(ClaimTypes.Role, role.Trim())));}//准备加密keySymmetricSecurityKey securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Signing.SecretKey));SigningCredentials signingCredentials = new SigningCredentials(securityKey,GetSecurityAlgorithm(_jwtSettings.Signing.Algorithm));JwtSecurityToken jwtSecurityToken = new JwtSecurityToken(issuer: _jwtSettings.Generator.Issuer,audience: string.Join(",", _jwtSettings.Generator.Audience), //多受众用逗号分隔claims: claims,notBefore: DateTime.Now.AddMinutes(_jwtSettings.Generator.NotBeforeMinutes), //延迟生效expires: DateTime.Now.AddMinutes(_jwtSettings.Generator.ExpiresInMinutes), //过期时间signingCredentials: signingCredentials);//生成令牌字符串try{return new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken);}catch (Exception ex){throw new InvalidOperationException("生成 JWT 令牌失败", ex);}}/// <summary>/// 验证生成配置的有效性(避免运行时错误)/// </summary>private void ValidateGeneratorSettings(){if (string.IsNullOrWhiteSpace(_jwtSettings.Signing.SecretKey))throw new InvalidOperationException("JWT 签名密钥(SecretKey)未配置");if (string.IsNullOrWhiteSpace(_jwtSettings.Generator.Issuer))throw new InvalidOperationException("JWT 签发者(Issuer)未配置");if (_jwtSettings.Generator.Audience == null || !_jwtSettings.Generator.Audience.Any())throw new InvalidOperationException("JWT 受众(Audience)未配置");if (_jwtSettings.Generator.ExpiresInMinutes <= 0)throw new InvalidOperationException("JWT 有效期(ExpiresInMinutes)必须大于 0");}/// <summary>/// 将配置的算法字符串转换为 SecurityAlgorithms 常量/// </summary>private string GetSecurityAlgorithm(string algorithmName){return algorithmName switch{"HS256" => SecurityAlgorithms.HmacSha256,"HS384" => SecurityAlgorithms.HmacSha384,"HS512" => SecurityAlgorithms.HmacSha512,_ => throw new NotSupportedException($"不支持的签名算法:{algorithmName}")};}}
2.3 注册JWT验证
//向IOC容器注册认证服务
builder.Services.AddAuthentication(options =>
{options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{var jwtSettings = builder.Configuration.GetSection("JwtSettings").Get<JwtSettings>();//JWT认证配置options.TokenValidationParameters = new(){//验证Issuer(若启用多 Issuer,需设为 true)ValidateIssuer = true,//验证Audience(若启用多 Audience,需设为 true)ValidateAudience = true,//验证过期时间ValidateLifetime = true,//验证签名密钥ValidateIssuerSigningKey = true,//多Audience配置(对应配置文件中的Audiences数组)ValidAudiences = jwtSettings?.Validator.AllowedAudiences,//多Issuer配置(对应配置文件中的Issuers数组)ValidIssuers = jwtSettings?.Validator.AllowedIssuers,//签名密钥(注意编码为字节数组)IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tokenOptions.SecurityKey ?? throw new ArgumentNullException("SecurityKey 未配置"))),//可选:设置令牌过期后的缓冲时间(例如 5 分钟)ClockSkew = TimeSpan.FromMinutes(5)};
});
2.4 Controller调用生成令牌服务
一般是先认证,授权后颁发JWT。我们给完成登录的请求赋予一个httpUser实体,用于包含登录人员的信息。最后通过_jwtService调用生成token的服务。
[Route("api/[controller]/[action]")]
[ApiController]
public class AuthController : ControllerBase
{private readonly IJWTService _jwtService;public AuthController(IJWTService jwtService, UserManager<User> userManager, RoleManager<Role> roleManager, IWebHostEnvironment webHostEnvironment = null, EmailService emailService = null, IUserJWTVersion userJWTVersion = null){_logger = logger;_jwtService = jwtService;_userManager = userManager;_roleManager = roleManager;_webHostEnvironment = webHostEnvironment;_emailService = emailService;_userJWTVersion = userJWTVersion;}/// <summary>/// 登录/// </summary>/// <param name="loginUser"></param>/// <returns></returns>[HttpPost]public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginUserReq loginUser){if (loginUser.UserName == null && loginUser.Email == null){return Ok("请输入用户名或邮箱");}User? user = await _userManager.FindByNameAsync(loginUser.UserName);if (user == null){user = await _userManager.FindByEmailAsync(loginUser.Email);if (user == null){if (_webHostEnvironment.IsDevelopment()){return Ok("用户不存在");}else{return Ok("账号或密码错误");}}}if (await _userManager.IsLockedOutAsync(user)){return Ok($"用户被锁,{user.LockoutEnd}");}if (!await _userManager.CheckPasswordAsync(user, loginUser.Password)){if (_webHostEnvironment.IsDevelopment()){return Ok("登录密码错误");}else{return BadRequest("账号或密码错误");}}user.JWTVersion = user.JWTVersion != null ? ++user.JWTVersion : 0;await _userJWTVersion.UpdateTokenVersionAsync(user);HttpUser httpUser = new HttpUser(){UserId = user.Id,Name = user.UserName,NickName = user.NickName,JWTVersion = user.JWTVersion ?? 0,RoleList = (await _userManager.GetRolesAsync(user)).ToList(),};var token = _jwtService.GenerateToken(httpUser);return new LoginResponse(){Token = token};}}
2.5 基于JWT的身份验证和授权验证
ASP.NET Core中,认证和授权是通过两个中间件开启的。分别是UseAuthentication和UseAuthorization。
- UseAuthentication是负责验证用户身份(解析请求中的 Token,执行JWT验证逻辑,将验证结果存入HttpContext.User)它会在请求到达控制器之前运行,确保后续的授权中间件能获取到用户身份。
- UseAuthorization是检查用户是否有权限访问资源(根据[Authorize]特性中的策略,判断HttpContext.User是否符合要求)。
在ASP.NET Core中,大部分都是践行约定大于配置的理念,像如何执行JWT的验证,框架是安装约定的规则帮忙实现的,所以我们不需要手动去解析Token本身。
Progran.cs
//负责验证用户身份(解析请求中的 Token,执行JWT验证逻辑,将验证结果存入HttpContext.User)
//它会在请求到达控制器之前运行,确保后续的授权中间件能获取到用户身份。
app.UseAuthentication();
//检查用户是否有权限访问资源(根据[Authorize]特性中的策略,判断HttpContext.User是否符合要求)。
app.UseAuthorization();
如果在Action上应用[Authorize]特性就是表明该Action需要验证权限,如果是Controller上应用[Authorize]特性就是表明该控制器里全部的Action都需要验证权限。
Controller里应用Authorize特性
/// <summary>
/// 登出
/// </summary>
/// <returns></returns>
[HttpGet]
[Authorize]
public async Task<ActionResult> Logout(int userId)
{var user = await _userManager.FindByIdAsync(userId.ToString());if (user == null){return Ok("用户不存在");}else{await _userJWTVersion.RemoveTokenVersionAsync(user.Id.ToString());return Ok("登出成功");}
}
如果要添加角色验证的话,就在Authorize后添加待验证的角色
[Authorize(Roles="admin")]
实际上JWT是放在HTTP请求头中,用自定义报文头Authorization命名JWT字符串。其中Authorization对应的值是“Bearer”和JWT令牌组合拼接,中间一定要通过空格分隔。前后不能多出来额外的空格、换行等
总结
相比传统 Session 在分布式环境中的性能瓶颈,JWT的无状态、防篡改等优势使其天然适合分布式环境。本文以ASP.NET Core为基础,从配置文件设计、JWT 服务封装、Token 生成与验证,到控制器调用及认证授权中间件配置,完整呈现了JWT在实际项目中的落地步骤。