实现用户登录功能的经典做法是用Session,但是在前后端分离、分布式环境下已经不适应了,而现在我们倾向于采用JWT代替Session实现登录。
JWT全称是JSON web token,它是使用JSON格式来保存令牌信息的。JWT机制不是把用户的登录信息保存在服务器端,而是把登录信息(也叫作令牌)保存在客户端。为了防止客户端的数据造假,保存在客户端的令牌经过了签名处理,而签名的密钥只有服务器端才知道,每次服务器端收到客户端提交的令牌的时候都要检查一下签名,如果发现数据被篡改,则拒绝接收客户端提交的令牌。
- 头部(header)中保存的是加密算法的说明。
- 负载(payload)中保存的是用户的ID、用户名、角色等信息。
- 签名(signature)是根据头部和负载一起算出来的值。
JWT实现登录的流程如下。
- 客户端向服务器端发送用户名、密码等请求登录。
- 服务器端校验用户名、密码,如果校验成功,则从数据库中取出这个用户的ID、角色等用户相关信息。
- 服务器端采用只有服务器端才知道的密钥来对用户信息的JSON字符串进行签名,形成签名数据。
- 服务器端把用户信息的JSON字符串和签名拼接到一起形成JWT,然后发送给客户端。
- 客户端保存服务器端返回的JWT,并且在客户端每次向服务器端发送请求的时候都带上这个JWT。
- 每次服务器端收到浏览器请求中携带的JWT后,服务器端用密钥对JWT的签名进行校验,如果校验成功,服务器端则从JWT中的JSON字符串中读取出用户的信息。
这样服务器端就知道这个请求对应的用户了,也就实现了登录的功能。
由此可以看出,在JWT机制下,登录用户的信息保存在客户端,服务器端不需要保存数据,这样我们的程序就天然地适合分布式的集群环境,而且服务器端从客户端请求中就可以获取当前登录用户的信息,不需要再去状态服务器中获取,因此程序的运行效率更高。虽然用户信息保存在客户端,但是由于有签名的存在,客户端无法篡改这些用户信息,因此可以保证客户端提交的JWT的可信度。
JWT的基本使用
生成JWT令牌
我们先创建一个控制台程序生成JWT,需要安装JWT读写的NuGet包System.IdentityModel.Tokens.Jwt
。
var claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.NameIdentifier, "6"));
claims.Add(new Claim(ClaimTypes.Name, "路飞"));
claims.Add(new Claim(ClaimTypes.Role, "User"));
claims.Add(new Claim(ClaimTypes.Role, "Admin"));
claims.Add(new Claim("jz", "112233"));
string key = "kjdfsjffd^kjfkfkds#dsffdsdsfd@fdsufdsfo33300";// 秘钥
DateTime expires = DateTime.Now.AddDays(1); // 过期时间
// 下面进行加密生成JWT
byte[] secBytes = Encoding.UTF8.GetBytes(key);
var secKey = new SymmetricSecurityKey(secBytes);
var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature);
var tokenDescriptor = new JwtSecurityToken
(claims: claims,expires: expires, signingCredentials: credentials);
string jwt = new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
Console.WriteLine(jwt);
一个用户的信息可能会包含多项内容,比如身份证号、生日、邮箱、地址等,在.NET中Claim就代表一条用户信息。Claim有两个主要的属性:Type和Value,它们都是string类型的,Type代表用户信息的类型,Value代表用户信息的值。由于Type是string类型的,因此可以取任何值,也可以自定义。
我们根据多个Claim对象、过期时间、密钥来生成JWT。
运行程序生成如下:eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjYiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoi6Lev6aOeIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbIlVzZXIiLCJBZG1pbiJdLCJqeiI6IjExMjIzMyIsImV4cCI6MTY2Mzg1ODgwMX0._hH5ohaJatky2Nk_SH0XenuaWpYhYslwcy94NPjWz_g
JWT被句点分隔成了3部分,分别是头部、负载和签名。
JWT看起来很乱,好像是加密过的,其实它们都是明文存储的,只不过进行了简单的编码而已。JWT中使用Base64URL算法对字符串进行编码,这个算法跟Base64算法基本相同,考虑到JWT可能会被放到URL中,而Base64有3个特殊字符+、/和=,它们在URL里面有特殊含义,因此我们需要从Base64中删除=,并且把+替换成-、把/替换成_。
解码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);
}
调用自定义的JwtDecode方法对头部和负载部分进行解码。JwtDecode方法除了做简单的字符串替换之外,还对替换后的字符串进行Base64解码。
这段对JWT进行解码的代码中没有用到任何密钥,只是简单地解码,因为JWT的头部和负载部分都没有加密,实质上都是以明文的形式保存的。
运行结果如下:--------head--------
{"alg":"http://www.w3.org/2001/04/xmldsig-more#hmac-sha256","typ":"JWT"}
--------payload--------
{"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier":"6","http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name":"路飞","http://schemas.microsoft.com/ws/2008/06/identity/claims/role":["User","Admin"],"jz":"112233","exp":1663858801}
从程序运行结果可以看出,JWT的头部中本质上以明文的形式记录了JWT的签名使用的哈希算法,负载中本质上也以明文的形式记录了我们设置的多条Claim信息。由于JWT会被发送到客户端,而负载中的内容是以明文形式保存的,因此一定不要把不能被客户端知道的信息放到负载中。
JWT的编码和解码规则都是公开的,而且负载部分的Claim信息也是明文的,因此恶意攻击者可以对负载部分中的用户ID等信息进行修改,从而冒充其他用户的身份来访问服务器上的资源。因此,服务器端需要对签名部分进行校验,从而检查JWT是否被篡改了。
我们可以调用JwtSecurityTokenHandler类对JWT进行解码,因为它会在对JWT解码前对签名进行校验。
string jwt = Console.ReadLine()!;
string secKey = "kjdfsjffd^kjfkfkds#dsffdsdsfd@fdsufdsfo33300";
JwtSecurityTokenHandler tokenHandler = new();
TokenValidationParameters valParam = new();
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secKey));
valParam.IssuerSigningKey = securityKey;
valParam.ValidateIssuer = false;
valParam.ValidateAudience = false;
// 调用ValidateToken方法对JWT进行解密
ClaimsPrincipal claimsPrincipal = tokenHandler.ValidateToken(jwt,
valParam, out SecurityToken secToken);
foreach (var claim in claimsPrincipal.Claims)
{
Console.WriteLine($"{claim.Type}={claim.Value}");
}
如果我们输入的是服务器端返回的JWT,上面的代码能够正常运行。
运行结果如下:http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier=6
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name=路飞
http://schemas.microsoft.com/ws/2008/06/identity/claims/role=User
http://schemas.microsoft.com/ws/2008/06/identity/claims/role=Admin
jz=112233
exp=1663858801
但是如果我们篡改JWT,程序运行时就会抛出内容为“Signature validation failed”的异常。exp值是过期时间,如果收到过期的JWT,即使签名校验成功,ValidateToken方法也会抛出异常。
总之,JWT机制让我们可以把用户的信息保存到客户端,每次客户端向服务器端发送请求的时候,客户端只要把JWT发送到服务器端,服务器端就可以得知当前请求用户的信息,而通过签名的机制则可以避免JWT内容被篡改。
本文学习参考自:ASP.NET Core技术内幕与项目实战