一、JWT简介
JWT(Json Web Token)是一种可以跨域的认证方案。
构成:
- 头部Header:头部包含了两部分,token 类型和采用的加密算法(可为none,后端应限制加密算法,不以这里为准)。
- 载荷Payload:这部分才是重要的,可以自定义信息保存在此。
- 签名Signature:使用编码后的header和payload以及我们提供的一个密钥,然后使用header中指定的签名算法进行签名,签名的作用是保证JWT没有被篡改过,如果有人对头部以及负载的内容解码之后进行修改,再进行编码,最后加上之前的签名组合形成新的JWT的话,那么服务器端会判断出新的头部和负载形成的签名和JWT附带上的签名是不一样的。如果要对新的头部和负载进行签名,在不知道服务器加密时用的密钥的话,得出来的签名也是不一样的。
这三部分均用base64进行编码,并使用 . 进行分隔。一个典型的 JWT 格式的 token 类似xxxxx.yyyyy.zzzzz。
JWT对三个部分都使用的是Base64进行编码,但是Base64是可逆的,所以注意在Payload部分不要携带敏感信息。
特点:
- 简洁(Compact) : 可以通过URL,POST参数或者在HTTP header发送,因为数据量小,传输速度也很快。
- 自包含(Self-contained) :负载中包含了所有用户所需要的信息,避免了多次查询数据库。
核心优势:无状态
- 省事,解析完token把信息放在处理链路的上下文,不用去redis或者database去取了。
- 省时,用每个请求验证token慢一点点的代价,换来不用去存储取的优势。
二、JWT优缺点
JWT优点:
- 因为json的通用性,JWT是可以跨语言支持的,像JAVA,JavaScript,NodeJS,PHP等语言都能使用。
- 因为有了payload部分,所以JWT可以在自身存储一些其他业务逻辑所必要的非敏感信息。
- 便于传输。JWT的构成非常简单,字节占用很小,所以它是非常便于传输的。
- 它不需要在服务端保存会话信息, 所以它易于应用的扩展。
JWT缺点:
- 占带宽: 正常情况下要比 session_id 更大,需要消耗更多流量,挤占更多带宽,假如你的网站每月有 10 万次的浏览器,就意味着要多开销几十兆的流量。听起来并不多,但日积月累也是不小一笔开销。实际上,许多人会在 JWT 中存储的信息会更多。
- 无法在服务端注销。用户主动注销时一般会让前端清理token,后端不理会,很难解决劫持问题。其他解决办法都是有状态的,例如通过Redis存储token副本、Redis存储token版本号、Redis存储过期时间等。
- 性能问题: JWT 的卖点之一就是加密签名,由于这个特性,接收方得以验证 JWT 是否有效且被信任。但是大多数 Web 身份认证应用中,JWT 都会被存储到 Cookie 中,这就是说你有了两个层面的签名。听着似乎很牛逼,但是没有任何优势,为此,你需要花费两倍的 CPU 开销来验证签名。对于有着严格性能要求的 Web 应用,这并不理想,尤其对于单线程环境。
安全建议:
- 保证密钥的保密性
- 签名算法固定在后端,不以JWT里的算法为标准
- 避免敏感信息保存在JWT中
- 尽量让JWT的有效时间足够短
三、JWT使用流程
- 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口。这一过程一般是一个HTTP POST请求。建议的方式是通过SSL加密的传输(https协议),从而避免敏感信息被嗅探。
- 后端核对用户名和密码成功后,将用户的id等其他信息作为 JWT-Payload(负载),将其与头部分别进行Base64编码拼接后签名形成一个JWT。形成的JWT就是一个形同111.zzz.xxx的字符串。
- 后端将JWT字符串作为登录成功的返回结果返回给前端。前端可以将返回的结果保存在localStorage或sessionStorage上,退出登录时前端删除保存的JWT即可。
- 前端在每次请求时将JWT放入HTTP Header中的Authorization位。(解决XSS和XSRF问题)
- 后端检查是否存在,如存在验证JWT的有效性。例如,检查签名是否正确;检查Token是否过期;检查Token的接收方是否是自己(可选)。
- 验证通过后后端使用JWT中包含的用户信息进行其他逻辑操作,返回相应结果。
四、.NET6使用JWT
开启JWT身份认证,开发登录接口,在需要身份认证的接口上增加 [Authorize] 特性,若不需要身份认证,添加 [AllowAnonymous] 特性即可。
代码示例:
appsettings.json
"JwtConfig": {
"SecretKey": "5610703d-d774-2b4b-836e-996ada2bb75b", // 密钥
"Issuer": "wuyun", // 颁发者
"Audience": "user", // 接收者
"Expired": 1 // 过期时间(30min)
},
program.cs(.NET6与老版本略有差异)
#region JWT验证
builder.Services.AddScoped<JwtService>();
builder.Services.Configure<JwtConfig>(builder.Configuration.GetSection("JwtConfig"));
var jwtConfig = new JwtConfig();
builder.Configuration.Bind("JwtConfig", jwtConfig);
//认证(Authentication)中间件配置
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false;
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,//是否验证Issuer
ValidateAudience = false,//是否验证Audience
ValidateIssuerSigningKey = true,//是否验证SigningKey
ValidateLifetime = true,//是否验证Token有效期(使用当前时间与Token的Claims中的NotBefore和Expires对比)
ValidIssuer = jwtConfig.Issuer,//Issuer(Token颁发机构)
ValidAudience = jwtConfig.Audience,//Audience(颁发给谁)
IssuerSigningKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(jwtConfig.SecretKey)),//这里的key要进行加密
ClockSkew = TimeSpan.FromSeconds(0),//缓冲时间:不设置的话默认为5分钟。注意:有效时长 = 过期时间 + 缓冲时间
RequireExpirationTime = true,//是否必须设置过期时间
};
});
#endregion
var app = builder.Build(); // 构建App实例
app.UseCors("CorsPolicy"); // 启用跨域策略
app.UseAuthentication(); // 启用认证(先认证后授权,否则带了token也认证不通过)
app.UseAuthorization(); // 启用授权
app.Run(); // 运行App并阻塞调用线程直到宿主关闭
JwtConfig.cs
/// <summary>
/// jwt配置
/// </summary>
public class JwtConfig : IOptions<JwtConfig>
{
public JwtConfig Value => this;
public string SecretKey { get; set; }
public string Issuer { get; set; }
public string Audience { get; set; }
public int Expired { get; set; }
public DateTime NotBefore => DateTime.UtcNow;
public DateTime IssuedAt => DateTime.UtcNow;
public DateTime Expiration => IssuedAt.AddMinutes(Expired);
private SecurityKey SigningKey => new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
public SigningCredentials SigningCredentials => new SigningCredentials(SigningKey, SecurityAlgorithms.HmacSha256);
}
JwtService.cs
/// <summary>
/// 创建JWT工具类
/// </summary>
public class JwtService
{
private readonly JwtConfig _jwtConfig;
public JwtService(IOptions<JwtConfig> jwtConfig)
{
_jwtConfig = jwtConfig.Value;
}
/// <summary>
/// 生成token
/// </summary>
/// <param name="sub">token主题</param>
/// <param name="customClaims">携带的用户信息</param>
/// <returns></returns>
public string GetToken(string sub, UserInfoBriefDto customClaims)
{
// C1aim部分包含了一些跟这个token有关的重要信息。JWT标准规定了一些字段,下面节选一些字段:
// iss: The issuer of the token.(token是给谁的)
// sub: The subject of the token.(token主题)
// exp: Expiration time.(token过期时间,Unix时间戳格式)
// iat: Issued at.(token建时间,Unix时间盛格式)
// jti: JWT ID.(针对当前token的唯一标识)
// 除了规定的字段外,可以包含其他任何兼容的字段。
//创建用户身份标识,可按需要添加更多信息
var claims = new[]
{
new Claim("userid", customClaims.UserId),
//new Claim("username", customClaims.UserName),
//new Claim("roles", string.Join(";",customClaims.Roles)),
new Claim(JwtRegisteredClaimNames.Sub, sub),
};
//创建令牌
var jwt = new JwtSecurityToken(
issuer: _jwtConfig.Issuer,
audience: _jwtConfig.Audience,
claims: claims,
notBefore: _jwtConfig.NotBefore,
expires: _jwtConfig.Expiration,
signingCredentials: _jwtConfig.SigningCredentials);
return new JwtSecurityTokenHandler().WriteToken(jwt);
}
public LoginResult GetResultWithToken(string sub, UserInfoBriefDto customClaims)
{
return new LoginResult()
{
access_token = GetToken(sub, customClaims),
//refresh_token = Guid.NewGuid().ToString(), // 配合Redis使用
expires_in = _jwtConfig.Expired * 60,
token_type = JwtBearerDefaults.AuthenticationScheme,
//user = customClaims
};
}
}
UserController.cs
/// <summary>
/// 用户登录
/// </summary>
[HttpPost(Name = "Login")]
[AllowAnonymous]//不需要身份验证
public IActionResult Login(UserLoginReq request)
{
//判断用户名和密码是否正确(仅为示例)
if (request.UserName != "admin" || request.Password != "111111")
{
return BadRequest("用户名或密码不正确!");
}
UserInfoBriefDto user = new UserInfoBriefDto() { UserId = "100001", UserName = request.UserName, Roles = request.UserName };
LoginResult loginResult = _jwtService.GetResultWithToken("user_login", user);//签发token并整合登录返回信息
return Ok(loginResult);
}
五、JWT和Session对比
存储id的差异:
Session方式存储用户id的最大弊病在于Session是存储在服务器端的,所以需要占用大量服务器内存,对于较大型应用而言可能还要保存许多的状态。一般而言,大型应用还需要借助一些KV数据库和一系列缓存机制来实现Session的存储。
而JWT方式将用户状态分散到了客户端中,可以明显减轻服务端的内存压力。除了用户id之外,还可以存储其他的和用户相关的信息,例如该用户是否是管理员、用户所在的分组等。虽说JWT方式让服务器有一些计算压力(例如加密、编码和解码),但是这些压力相比磁盘存储而言可能就不算什么了。具体是否采用,需要在不同场景下用数据说话。
Session方式来存储用户id,一开始用户的Session只会存储在一台服务器上。对于有多个子域名的站点,每个子域名至少会对应一台不同的服务器,例如:www.taobao.com,nv.taobao.com,nz.taobao.com,login.taobao.com。所以如果要实现在login.taobao.com登录后,在其他的子域名下依然可以取到Session,这要求我们在多台服务器上同步Session。使用JWT的方式则没有这个问题的存在,因为用户的状态已经被传送到了客户端。
使用Session的缺点:
- 占用服务端内存;
- 扩展性差(跨域难解决,不利于分布式);
- 基于Cookie,容易受到CSRF(跨站请求伪造)攻击。