hi,这里是桑小榆。上篇我们一起探讨了jwt的特性,以及各个部分的组成原理。本篇我们以代码实操去打造一个授权体系,为进一步探讨并理解jwt。
细心的伙伴会发现,我们无需界定语言来使用jwt,任一语言,比如 C#,Java,node.js 等都是可以按照规则使用jwt的。
现如今,我们的系统大多以多服务划分的方式搭建,并支持接轨微服务架构。我们通常会建立一个独立的授权服务,使得所有与资源服务打交道的请求端,都需要先请求授权服务获取token令牌,再带着令牌去获取相应的资源服务。
我们抛开现存封装好的授权框架,我们直接使用原始的方式去使用token授权。
颁发token令牌
第一步,以授权服务颁发token令牌作为权威机构。
//token颁发
public class JwtTokenHandler
{
//颁发token
public static string IssueJwt(TokenModelJwt tokenModel)
{
string iss ="c2FuZ3hpYW95dXlhc2FuZ3hpYW95dXlh";//通常为独立授权颁发服务名称,最好base64加密
string aud = "sangxiaoyuya";
string secret = "c2FuZ3hpYW95dXlhc2FuZ3hpYW95dXlh";//加签密钥通常需要16+;
//此处根据jwt的payload的七个官方字段声明
var claims = new List<Claim>
{
//uid通常为用户唯一标识
new Claim(JwtRegisteredClaimNames.Jti, tokenModel.Uid.ToString()),
new Claim(JwtRegisteredClaimNames.Iat, $"{new DateTimeOffset(DateTime.Now).ToUnixTimeSeconds()}"),
new Claim(JwtRegisteredClaimNames.Nbf,$"{new DateTimeOffset(DateTime.Now).ToUnixTimeSeconds()}") ,
//这个就是过期时间,目前是过期1000秒,可自定义,注意JWT有自己的缓冲过期时间
new Claim (JwtRegisteredClaimNames.Exp,$"{new DateTimeOffset(DateTime.Now.AddSeconds(1000)).ToUnixTimeSeconds()}"),
new Claim(JwtRegisteredClaimNames.Iss,iss),
new Claim(JwtRegisteredClaimNames.Aud,aud)
}
// 支持一个用户多个角色全部赋予;
claims.AddRange(tokenModel.Role.Split(',').Select(s => new Claim(ClaimTypes.Role, s)));
//秘钥 (SymmetricSecurityKey 对安全性的要求,密钥的长度一般256位以上)
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);//jwt的加密部分,采用hs256
//这里是jwt的payload载体部分,以及加密方式
var jwt = new JwtSecurityToken(
issuer: iss,
claims: claims,
signingCredentials: creds);
var jwtHandler = new JwtSecurityTokenHandler();
//这个系统内部会自动加入jwt的头部{"alg": "HS256","typ": "JWT"} 加入进行生成
var encodedJwt = jwtHandler.WriteToken(jwt);
return encodedJwt;
}
上述是一个根据jwt的规则生成的一个token令牌,严格按照jwt的组成部分以及配合加签。
Header 头部
Payload 载荷
Signature 签名
以上组成jwt格式为:Header.Payload.Signature
虽然在生成token 的源码中并没有声明header头部,在jwtHandler.WriteToken(jwt) 方法里,内部会自动加上头部信息进行生成,我们可以看源码生成方式。
//...省略符为空判断以及长度限制,目前仅截取核心代码
//这里是指定jwt的头部信息
JwtHeader jwtHeader = ((jwtSecurityToken.EncryptingCredentials == null) ? jwtSecurityToken.Header : new JwtHeader(jwtSecurityToken.SigningCredentials));
//此处对头部进行了base64url加密
empty = jwtHeader.Base64UrlEncode();
//这里根据我们指定的hs256加签方式,对header头部和payload载荷进行加签.
if (jwtSecurityToken.SigningCredentials != null)
{
text = JwtTokenUtilities.CreateEncodedSignature(empty + "." + encodedPayload, jwtSecurityToken.SigningCredentials);
}
//此处为生产环境环境中,使用加密凭据进行加密
if (jwtSecurityToken.EncryptingCredentials != null)
{
return EncryptToken(new JwtSecurityToken(jwtHeader, jwtSecurityToken.Payload, empty, encodedPayload, text), jwtSecurityToken.EncryptingCredentials).RawData;
}
//最终返回规定格式{Header.Payload.Signature}
return empty + "." + encodedPayload + "." + text;
token令牌鉴权
我们的授权服务已经能够颁发权威的token了,颁发之后,我们还需要专门的鉴定机制,不然会出现颁发了token,用户带着token过来我们不认识。
//颁发了token令牌,需要对应的去识别令牌
public static TokenModelJwt Serialize(string jwt)
{
var jwtHandler = new JwtSecurityTokenHandler();
JwtSecurityToken securityToken = jwtHandler.ReadJwtToken(jwt);
object role;
try
{
securityToken.Payload.TryGetValue(ClaimTypes.Role, out role);
}
catch (Exception error)
{
Console.WriteLine(error);
throw;
}
var tm = new TokenModelJwt
{
Uid = (securityToken.Id).ObjToInt(),
Role = role?.ObjToString() ?? String.Empty,
};
return tm;
}
我们可以查看源码的 ReadJwtToken(jwt) 方法。分成三部分解析,头部和载体都需要base64url 进行解码。
//jwt拆分成三部分
public JwtSecurityToken ReadJwtToken(string token)
{
//... 此处为空判已省略
//截取核心代码,我们可以看到也是通过{.}分成三部分解析的。
JwtSecurityToken jwtSecurityToken = new JwtSecurityToken();
//Decode(string[] tokenParts, string rawData)方法解析jwt三部分.
jwtSecurityToken.Decode(token.Split(new char[1] { '.' }), token);
return jwtSecurityToken;
}
//此处会有一个根据长度来判断是使用jwe解析还是jws解析。
//当然,jws和jwe都是jwt的表现形式。
void Decode(string[] tokenParts, string rawData)
{
//..此处去除为空判断和异常捕获,只截取核心源码
Header = JwtHeader.Base64UrlDeserialize(tokenParts[0]);
if (tokenParts.Length == 5)
{
DecodeJwe(tokenParts);
}
else
{
DecodeJws(tokenParts);
}
RawData = rawData;
}
private void DecodeJws(string[] tokenParts)
{
//..此处去除为空判断和异常捕获代码,只截图核心源码
Payload = JwtPayload.Base64UrlDeserialize(tokenParts[1]);
RawHeader = tokenParts[0];
RawPayload = tokenParts[1];
RawSignature = tokenParts[2];
}
授权认证
在我们已经完成了jwt的颁发和鉴定。我们编写接口的时候,往往会在控制器的上方标记 [Authorize] 即为需要认证。然后在服务端配置jwt的认证信息,其实也是在我们颁发token时把相关的secret密钥,发行人,受众人信息配置好即可。
中间件管道开启认证app.UseAuthentication() 和app.UseAuthorization() 授权这两个中间件,这是官方封装的授权框架。
//此处使用jwt 认证方式
services.AddAuthentication(x =>
{
//这里声明scheme其实就是使用{bearer} 认证
// 也可以直接写字符串,AddAuthentication("Bearer")
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(o =>
{
//以下受众和密钥需要配置和颁发token时保持一致,
var audienceConfig = "c2FuZ3hpYW95dXlhc2FuZ3hpYW95dXlh";
var symmetricKeyAsBase64 = "c2FuZ3hpYW95dXlhc2FuZ3hpYW95dXlh";
var keyByteArray = Encoding.ASCII.GetBytes(symmetricKeyAsBase64.Value);
var signingKey = new SymmetricSecurityKey(keyByteArray);
var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey,//参数配置在下边
ValidateIssuer = true,
ValidIssuer = symmetricKeyAsBase64.Value,//发行人
ValidateAudience = true,
ValidAudience = audienceConfig.Value,//订阅人
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,//这个是缓冲过期时间,也就是说,即使我们配置了过期时间,这里也要考虑进去,过期时间+缓冲,默认好像是7分钟,你可以直接设置为0
RequireExpirationTime = true,
};
});
这样我们就已经完成了颁发token,和鉴权的一套流程。
当然,为了更加了解底层运作原理,我们还是抛开封装框架手写一套鉴定token。
我们编写接口的时候,会在控制器或者方法中标记 [Authorize] ,然后中间件开启鉴权就可以授权了,它们是怎样实现的?
重点在于中间件app.UseAuthorization(),中间件的作用通常是处理管道请求和响应的,说白了就是在http请求过程中,中间会进行拦截,来验证你的请求信息是否符合要求。
这像极了我们看抗日剧时,前往平安县城的路上,会有敌军层层关卡,需要你掏出良民证,证明你是不是良民,来判断允不允许你通过。
那么我们也做一个认证token用的中间件。
/// <summary>
/// 鉴权中间件
/// </summary>
public class JwtTokenAuthMiddleware
{
//请求管道链
private readonly RequestDelegate _next;
public JwtTokenAuthMiddleware(RequestDelegate next)
{
_next = next;
}
private void PreProceed(HttpContext next)
{
//..todo 处理请求前逻辑
}
private void PostProceed(HttpContext next)
{
//..todo 请求处理中逻辑
}
public Task Invoke(HttpContext httpContext)
{
PreProceed(httpContext);
//检测是否包含'Authorization'请求头
if (!httpContext.Request.Headers.ContainsKey("Authorization"))
{
//如果不包含,则跳过进入下一个中间件
PostProceed(httpContext);
return _next(httpContext);
}
try
{
//解析token时,不需要Bearer字符
var tokenHeader = httpContext.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
if (tokenHeader.Length >= 128)
{
//这里为我们先前写好的解析token方法。
TokenModelJwt tm = JwtTokenHandler.Serialize(tokenHeader);
//授权
//var claimList = new List<Claim>();
//var claim = new Claim(ClaimTypes.Role, tm.Role);
//claimList.Add(claim);
//var identity = new ClaimsIdentity(claimList);
//var principal = new ClaimsPrincipal(identity);
//httpContext.User = principal;
}
}
catch (Exception e)
{
Console.WriteLine($"{DateTime.Now} middleware wrong:{e.Message}");
}
PostProceed(httpContext);
return _next(httpContext);
}
}
使用时候我们只需在管道中注入中间件即可app.UseMiddleware<JwtTokenAuthMiddleware>() 。我们看到中间件请求进来之后,去除Bearer 部分,将原始token调用我们上述写好的token解析方法SerializeJwt(tokenHeader) 。
当然,我们生成的token为了安全性,有效时长不宜过长,我们也需要一个刷新令牌的功能。我们依然可以回归到生活中,如果我聘请某个程序员做商城开发。
通常会签订一个合同给到程序员,但如果某一天想终止合同,或者想开猿节流,使得这份合同无效,通常会有以下几种方式:
1.当前这份作废,按需重新签订一份合同;
2.更换颁发机构和认证机构(也就是换公司或者换部门,然后重新签订合同)。
那么token刷新,也基本以上两种,但是第二种一旦调整,前面颁发的token都会失效,一般不会这么干。使用第一种方式,直接作废原token并刷新令牌更为简便。
但是,jwt是一种无状态的信息包,一旦颁发之后便不可更改。
那我们会怎么去强制失效呢?对了,就是赋予jwt状态。
我这里有两种方案:
第一种,是颁发token的时候有一个受众人aud ,也就是颁发给谁 ,我们颁发token的时候,每个用户根据 username+pwd+datetime.now() 的规则作为颁发受众人。
这样如果更改了用户名或者密码则受众人就会变更,此时的token已经无效了需要刷新token。另外datetime.now()也可以存在缓存或者用户表,当用户退出登录,或者管理员取消其token有效,则只需更改用户的datetime时间即可,受众人发生变更,token无效。后台需配置ValidateAudience = true,进行认证受众人是否一致。
第二种,上篇文章提到过payload载体是可以添加自己的逻辑的,那么我们可以赋予一个 version 作为token的版本信息。例如默认正常产生的token版,也可以是当前时间的时间戳,并记录到用户表或者用户缓存。当token被用户强制失效,或者管理员强制失效,则只需更改这个版本信息即可。认证的时候如果发现版本不一致,则token失效。
以上两种作为举一反三的思考,如有需要可以自行代码操作实现。
当然,以上案例是帮助其理解jwt的使用,并不适合使用在生产环境下。生产环境下必然需要一套关联用户库表的操作,并搭建一套标准的授权体系,不仅支持内部系统授权还支持第三方授权。这部分将在后续一起探讨。
以上内容或许出于基础层面不同,理解起来不同层面有不同程度的见解,也可能会迷惑一时不好理解。
这是在所难免的,不要为难自己,任何一个知识领域都不必强迫自己看一两遍就能够融会贯通。
这需要反复接收,思考,反馈,思考。
就如功夫巨星李小龙,一生中打败过无数强者,他自认为不是天下第一,有一种对手令他畏惧。
李小龙的妻子琳达在《我眼中的布鲁斯》回忆里写道,她问丈夫:“作为世界第一,是不是不畏惧所有的对手?”。
李小龙否认:“我不是世界第一,我也有害怕的对手。”
妻子听到十分惊讶,追问:“什么样的对手让你害怕?”
李小龙说:“我不怕会一万种招式的人,我只怕把一种招式练了一万遍的对手。”
好了,以上jwt的代码实操内容了。接下来,我们将继续探讨微服务架构,本着大道至简的思想能够让更多的人看懂,如果你喜欢,一个赞便是最大的鼓励。
文中案例源码地址:
https://github.com/ElicaKing/Auth/tree/master/src/IdentityServer