JWT:谁创造了我,我听命于谁

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