简介

JWT全程Jason Web Token,主要用在移动端登录与权限验证的一种方案。
由于移动端无法使用传统Web的Session会话技术,一般会使用JWT或者其他类似方案。

使用

客户端提供账户密码等信息,后端验证成功后返回token,后续调用Api时需要将token放在请求头上(视为已登录状态)s。 token具有时效性,可以在失效前使用旧token换取新token。

JWT Token拆解分析

这是一段JWT Token

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOlwvXC9hcGkudGVzdFwvbG9naW4iLCJpYXQiOjE1OTMzOTQ0OTAsImV4cCI6MTU5MzYxMDQ5MCwibmJmIjoxNTkzMzk0NDkwLCJqdGkiOiJEVm1zbFJvZ1NENnQ4bFVRIiwic3ViIjoyLCJwcnYiOiJmMmJjNTBlYjk0MTgzNGY3MDcyYzUxNDQ0Njg3ODg3ZWMzNzU0ZDc5In0.sHpM0FTQW1o91FiIfbf0Z-syY8SVnuDEayC5LFw0hMA

该token由三段字符串组成,以"."为分隔符,按先后顺序分为Header、Payload、Signature

Header

声明类型和加密算法,使用base64url编码过的,上面token头信息解码后结果为

{
    "typ":"JWT", // 类型为JWT
    "alg":"HS256" // 签名加密算法为HS256
}

Payload

解码后:

{
    "iss":"http:\/\/api.test\/login",
    "iat":1593394490,
    "exp":1593610490,
    "nbf":1593394490,
    "jti":"DVmslRogSD6t8lUQ",
    "sub":2,
    "prv":"f2bc50eb941834f7072c51444687887ec3754d79"
}

同样也是Jason字符串base64编码后的结果,存放有效信息,对于Payload内的各个元素(声明)有分三个等级:已注册声明、公共声明、私有声明。(rfc7519-4)

已注册声明 Registered Claim Names
已注册声明是有预定义好的key,但不是强制要求全数使用:
iss —— JWT发行者(Issuer)
sub —— JWT的信息、主要内容(Subject),一般放当前登录的用户信息
aud —— Toekn接受者(Audience)
exp —— JWT过期时间(Expiration Time) 时间戳
iat —— JWT签发时间(Issueed At) 时间戳
nbf —— JWT生效时间(Not-Before) 时间戳 一般与iat相同
jti —— JWT ID 唯一标识

这里特意讲讲nbf会出现的问题,首先nbf使用iat值并无太大问题。
主要在后端验证这块会出现问题,比如项目后端有AB两台服务器。
服务器A颁发了JWT jat和nbf都使用了系统时间 2020-01-01 09:00:00转成时间戳
但前端在使用JWT时是提交到服务器B,验证时系统时间为 2020-01-01 08:55:00
那iat 和 nbf > 系统时间,JWT不生效抛出异常。
但是一般后端的JWT验证代码会加上少许的leeway值解决不同服务器之间时间不同步的问题

// 伪代码示范
if ($nbf > time() + $leeway){
    throw new TokenValidationException('nbf is not enable yet.')
}

根据rfc7519-4.1.5中说到leeway一般不超过几分钟,在后端配置leeway之余还需根据实际情况同步服务器时间。

公共声明 Public Claim Names
共声明必须使用IANA "JSON Web Token Claims" registry定义好的,或者采取与其不冲突的名字。
一般保存服务端与用户端共同使用的信息,谨慎使用。

私有声明 Private Claim Names
既不是已注册声明,又不是公有声明即为私有声明,可能会造成冲突,请谨慎使用。

Signature

签名作用是防止数据被篡改
使用 "." 将base64url编码后的Header和Payload字符串进行拼接
再根据Header指定的加密方式,使用服务端保存的秘钥将拼接后的字符串进行加密。
加密后再base64url编码一下,就得出签名。

// 伪代码实现签名
$encryptString = base64_urlencode($headerJsonString).base64_urlencode($payloadJsonString);
$secret = env('JWT_SECRET');
$signature = base64_encode(hs256($encryptString, $secret));

不过不同的JWT组件实现签名的方式会不一样,有些可能会在其中加上其他处理。

tymon/jwt-auth组件

该组件语言为PHP, 可通过composer安装
安装和使用什么的不讨论,直接查看源码,看看这个jwt组件是如何创建token的

入口
先看/config/jwt.php里的providers

/*
|--------------------------------------------------------------------------
| JWT Provider
|--------------------------------------------------------------------------
|
| Specify the provider that is used to create and decode the tokens.
| 指定一个用来创建和加密token的provider
*/

'jwt' => Tymon\JWTAuth\Providers\JWT\Lcobucci::class,

TymonJWTAuthProvidersJWTLcobucci 这个类去查看代码之前,
先全局搜索 " new Lcobucci( "
你会看到在Lcobucci类实例化时传入的参数

use Lcobucci\JWT\Builder as JWTBuilder;
protected function registerLcobucciProvider()
{
    $this->app->singleton('tymon.jwt.provider.jwt.lcobucci', function ($app) {
        return new Lcobucci(
            new JWTBuilder(), // 在Lcobucci::encode()里会用到
            new JWTParser(),
            $this->config('secret'), // 这里已经从.env文件获取到JWT_SECRET项了
            $this->config('algo'),
            $this->config('keys')
        );
    });
}

接下来同时打开 LcobucciJWTBuilderTymonJWTAuthProvidersJWTLcobucci
TymonJWTAuthProvidersJWTLcobucci
先看Lcobucci::encode(),该方法主要负责创建token。

encode()方法上游不做详细追踪,大概就是先从TymonJWTAuthJWTAuth或TymonJWTAuthJWTGuard类中的一些login()、tokenById()、attempt()等方法调用
然后再根据配置文件创建Header、Payload等数据
该方法先将调用方传进来的参数、加密类、秘钥往Builder的成员属性填充
最后调用了BuildergetToken()方法
public function encode(array $payload)
{
    // Remove the signature on the builder instance first.
    // 1.先移除builder的签名
    $this->builder->unsign();

   
    try {
         // 2.往builder填充声明
        foreach ($payload as $key => $value) {
            $this->builder->set($key, $value);
        }
        // 3.往builder填充加密算法类和key
        $this->builder->sign($this->signer, $this->getSigningKey());
    } catch (Exception $e) {
        throw new JWTException('Could not create token: '.$e->getMessage(), $e->getCode(), $e);
    }
    // 4.调用Builder::getToken()
    return (string) $this->builder->getToken();
}

LcobucciJWTBuilder
切换到Builder::getToken()

/**
 * Returns the resultant token
 *
 * @return Token
 */
public function getToken(Signer $signer = null, Key $key = null)
{
    // 1.对参数的判断 没传的话就使用成员属性
    $signer = $signer ?: $this->signer;
    $key = $key ?: $this->key;
    
    // 2. 根据当前加密类 往$this->headers添加对应alg值 参数为引用传递 
    // 比如默认是用HS256的话,为Lcobucci\JWT\Signer\Hmac\Sha256::getAlgorithmId()的返回值 
    if ($signer instanceof Signer) {
        $signer->modifyHeader($this->headers);
    }
    
    // 3.将原为数组的headers和claims先转为json字符串再使用base64url编码
    $payload = [
        $this->encoder->base64UrlEncode($this->encoder->jsonEncode($this->headers)),
        $this->encoder->base64UrlEncode($this->encoder->jsonEncode($this->claims))
    ];
    
    // 4.1跳到下面的方法
    $signature = $this->createSignature($payload, $signer, $key);

    // 5.1 将签名base64url编码后塞进$payload,塞入后$payload有三个元素
    // 按顺序分别是Headers、Claims、Signture
    if ($signature !== null) {
        $payload[] = $this->encoder->base64UrlEncode($signature);
    }
    
    // 5.2最后实例化Lcobucci\JWT\Token类并返回 
    return new Token($this->headers, $this->claims, $signature, $payload);
}


/**
 * @param string[] $payload
 *
 * @return Signature|null
 */
private function createSignature(array $payload, Signer $signer = null, Key $key = null)
{
    if ($signer === null || $key === null) {
        return null;
    }

    // 4.2使用先前Lcobucci::encode()里往Builder填充的加密类的sign()进行加密
    // 比如我用默认的HS256,使用Lcobucci\JWT\Signer\Hmac\Sha256的父类Lcobucci\JWT\Signer\Hmac::createHash()
    return $signer->sign(implode('.', $payload), $key);
}

LcobucciJWTToken
在这之前我们先回去TymonJWTAuthProvidersJWTLcobucci::encode()
看最后一行return的代码 :

return (string) $this->builder->getToken();

可以看到最后LcobucciJWTToken类将会以字符串形式返回给调用方。
如果类以字符串形式被调用的话,将会触发魔术方法__toString()
然后我们直奔LcobucciJWTToken::__toString()

/**
 * Returns an encoded representation of the token
 *
 * @return string
 */
public function __toString()
{
    // 就是将在实例化本类时传入的$payload数组元素以 . 拼接并返回
    $data = implode('.', $this->payload);

    if ($this->signature === null) {
        $data .= '.';
    }

    return $data;
}

最后将返回给调用方一个JWT

Base64url
在常用的base64编码下,得出的字符串可能会出现"+"和"/"。
在某些情况下会造成歧义,所以base64url只是在base64之前将源字符串的"+"和"/"替换成"-"和"_",编码后的"="也去掉,base64url的定义在 rfc4648-5
具体实现可以参考

public function base64UrlEncode($data)
{
    return str_replace('=', '', strtr(base64_encode($data), '+/', '-_'));
}


/**
 * Decodes from base64url
 * Base64url解码
 * @param string $data
 * @return string
 */
public function base64UrlDecode($data)
{
    if ($remainder = strlen($data) % 4) {
        $data .= str_repeat('=', 4 - $remainder);
    }

    return base64_decode(strtr($data, '-_', '+/'));
}

标签: none

添加新评论