JWT&tymon/jwt-auth
简介
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')
);
});
}
接下来同时打开 LcobucciJWTBuilder 和 TymonJWTAuthProvidersJWTLcobucci
TymonJWTAuthProvidersJWTLcobucci
先看Lcobucci::encode()
,该方法主要负责创建token。
encode()方法上游不做详细追踪,大概就是先从TymonJWTAuthJWTAuth或TymonJWTAuthJWTGuard类中的一些login()、tokenById()、attempt()等方法调用
然后再根据配置文件创建Header、Payload等数据
该方法先将调用方传进来的参数、加密类、秘钥往Builder的成员属性填充
最后调用了Builder的getToken()方法
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, '-_', '+/'));
}