概念
JSON Web Token (JWT)是一个开放标准(RFC 7519) ,它定义了一种紧凑和自包含的方式,用于作为 JSON 对象在各方之间安全地传输信息。此信息可以进行验证和信任,因为它是经过数字签名的。JWT 可以使用机密(使用 HMAC 算法)或使用 RSA 或 ECDSA 的公钥/私钥对进行签名。
虽然可以对 JWT 进行加密,以便在各方之间提供保密性,但是我们将关注已签名的Token。签名Token可以验证其中包含的声明的完整性,而加密Token可以向其他方隐藏这些声明。当使用公钥/私钥对对令牌进行签名时,该签名还证明只有持有私钥的一方才是对其进行签名的一方( 签名技术是保证传输的信息不可抵赖,并不能保证信息传输的安全 )
官网地址:https://jwt.io
JWT 原理
JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样。
{
"姓名": "八戒",
"角色": "管理员",
"到期时间": "2028年12月11日0点0分"
}
以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名(详见后文)。
服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。
JWT 数据结构
编码后的数据结构
它是一个很长的字符串,中间用点(.
)分隔成三个部分。注意,JWT 内部是没有换行的,这里只是为了便于展示,将它写成了几行。
JWT 的三个部分依次如下
Header(头部)
Payload(负载)
Signature(签名)
写成一行,就是下面的样子。
Header.Payload.Signature
JWT 认证流程
认证流程流程说明:
-
浏览器发起请求登陆,用户携带用户名和密码等了
-
服务端验证身份,根据算法,将用户标识符打包生成 Token,
-
服务器返回JWT信息给浏览器,JWT不包含敏感信息
-
浏览器发起请求获取用户资料,把刚刚拿到的 Token一起发送给服务器
-
服务器发现数据中有 Token,验证身份是否合法
-
服务器根据当前Token解析返回该用户的用户资料
双令牌解决方案
在前后端分离的开发模式下,前端用户登录成功后后端服务会给用户颁发一个JWT的access_token
。前端在接收到JWT的access_token
后会将access_token
存储到浏览器LocalStorage
中。
后续每次请求都会将此access_token
放在请求头中传递到后端服务,后端服务会有一个过滤器对access_token
进行拦截校验,校验access_token
是否过期,如果access_token
过期则会让前端跳转到登录页面重新登录。
因为JWT的access_token
中一般会包含用户的基础信息,为了保证JWT的access_token
的安全性,一般会将JWT的access_token
的过期时间设置的比较短。
但是这样又会导致前端用户需要频繁登录(access_token
过期),甚至有的表单比较复杂,前端用户在填写表单时需要思考较长时间,等真正提交表单时后端校验发现access_token
过期失效了不得不跳转到登录页面。
如果真发生了这种情况前端用户肯定是要吐槽的,对用户体验非常不友好。例如:access_token
有效期是2h
,用户一直在使用客户端考试,使用的过程中,access_token
到期跳转到登录页面邀请重新登录。心里想说什么垃圾系统,过了2个小时又要重新登录!我他妈想骂人了,一万个....
本篇内容就是在前端用户无感知的情况下实现access_token
的自动续期,避免频繁登录、表单填写内容丢失情况的发生。以及access_token
和refresh_token
很巧妙的实效设置,达到双令牌刷新、续期。
AccessToken和RefreshToken
什么是 Access Token ?
Access Token 用于基于 Token 的认证模式,允许应用访问一个资源 API。用户认证授权成功后,服务端会签发 Access Token 给应用。应用需要携带 Access Token 访问资源 API,资源服务 API 会通过拦截器查验 Access Token 中的 scope 字段是否包含特定的权限项目,从而决定是否返回资源。
什么是 Refresh Token ?
通常Access Token
有效时间通常较短。通常用户在获取资源的时候需要携带 Access Token
,当 Access Token
过期后,用户需要获取一个新的 AccessToken。这时候就需要Refresh Token
了。Refresh Token
用于获取新的 AccessToken
。这样可以缩短 AccessToken
的过期时间保证安全,同时又不会因为频繁过期重新要求用户登录。
用户在初次认证时,Refresh Token
会和AccessToken
一起返回。应用必须安全地存储 Refresh Token
,它的重要性和密码是一样的,因为 Refresh Token
能够一直让用户保持登录。
{
"code": 0,
"msg": "success",
"data": {
"token_type": "Bearer",
"expires_in": 7200,
"access_token": "eyJ0eXA1NiJ9.eyJpc3MiOikifX0._kwtyMsMI0ML0o",
"refresh_token": "eyJ0eXiJIUzI1NiJ9.eyJpc3MiOifX0.mYSXrpoNpU"
}
}
}
客户端应用携带 Refresh Token
向服务端点发起请求时,服务端每次都会返回相同的Refresh Token
和新的 AccessToken
,直到 Refresh Token
过期。
{
"code": 0,
"msg": "success",
"data": {
"token_type": "Bearer",
"expires_in": 7200,
"access_token": "eyJ0eXA1NiJ9.eyJpc3MiOikifX0._kwtyMsMI0ML0o",
"refresh_token": "eyJ0eXiJIUzI1NiJ9.eyJpc3MiOifX0.mYSXrpoNpU"
}
}
}
代码实现
实现配置参数说明。access_token
设置为2小时
过期,而refresh_token
设置7天
过期。
这样7天内,如果access_token
过期了,那就可以用refresh_token
来刷新拿到新的access_token
。只要不超过7天
内未访问系统,那就可以一直是登录状态,可以无限续签,不需要登录。如果超过7天
未访问系统,那么refresh_token
也就过期了,这时候需要重新登录了。
安装插件
composer require tinywan/jwt
插件地址:
https://www.workerman.net/plugin/10
插件配置
配置文件
config/plugin/tinywan/jwt
return [
'enable' => true,
'jwt' => [
// 算法类型 HS256、HS384、HS512、RS256、RS384、RS512、ES256、ES384、Ed25519
'algorithms' => 'HS256',
// access令牌秘钥
'access_secret_key' => '2024d3d3LmJq',
// access令牌过期时间,单位:秒。默认 2 小时
'access_exp' => 7200,
// refresh令牌秘钥
'refresh_secret_key' => '2022KTxigxc9o50c',
// refresh令牌过期时间,单位:秒。默认 7 天
'refresh_exp' => 604800,
// refresh 令牌是否禁用,默认不禁用 false
'refresh_disable' => false,
// 令牌签发者
'iss' => 'webman.tinywan.cn',
...
];
-
access_token
设置access_exp
为2小时
过期 -
refresh_token
设置refresh_exp
为7天
过期
生成令牌
$user = [
'id' => 2024,
'name' => 'Tinywan',
'email' => 'Tinywan@163.com'
];
$token = Tinywan\Jwt\JwtToken::generateToken($user);
var_dump(json_encode($token));
输出(json格式)
{
"token_type": "Bearer",
"expires_in": 36000,
"access_token": "eyJ0eXAiOiJAUR-Gqtnk9LUPO8IDrLK7tjCwQZ7CI...",
"refresh_token": "eyJ0eXAiOiJIEGkKprvcccccQvsTJaOyNy8yweZc..."
}
参数描述
参数 | 类型 | 描述 | 示例值 |
---|---|---|---|
token_type | string | Token 类型 | Bearer |
expires_in | int | 凭证有效时间,单位:秒 | 36000 |
access_token | string | 访问凭证 | XXXXXXXXXXXXXXXXXXXX |
refresh_token | string | 刷新凭证(访问凭证过期使用 ) | XXXXXXXXXXXXXXXXXXXX |
中间件拦截器
/**
* @desc 中间件拦截器
* @author Tinywan(ShaoBo Wan)
*/
declare(strict_types=1);
namespace app\middleware;
use Tinywan\ExceptionHandler\Exception\ForbiddenHttpException;
use Tinywan\ExceptionHandler\Exception\UnauthorizedHttpException;
use Tinywan\Jwt\JwtToken;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
class AuthorizationMiddleware implements MiddlewareInterface
{
/**
* @param Request $request
* @param callable $handler
* @return Response
* @throws ForbiddenHttpException|UnauthorizedHttpException
*/
public function process(Request $request, callable $handler): Response
{
$request->userId = JwtToken::getCurrentId();
if (0 === $request->userId) {
throw new UnauthorizedHttpException();
}
return $handler($request);
}
}
中间件拦截器中是对 access_token
进行请求拦截校验,判断access_token
是否有效。如果当前用户access_token
无效,则直接拦截请求并返回UnauthorizedHttpException
认证失败异常类响应。
令牌验证 无效 响应参考示例
HTTP/1.1 401 Unauthorized
Content-Type: application/json;charset=UTF-8
{
"code": 0,
"msg": "令牌会话已过期,请再次登录!",
"data": {}
}
令牌验证 通过 响应参考示例
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"code": 0,
"msg": "success",
"data": {
"id": 202801,
"username": "Tinywan"
},
}
刷新令牌通
通过以上可以看出我们设置的access_token
为2小时
过期后,服务端会返回一个401
的HTTP状态码HTTP/1.1 401 Unauthorized
,参考如下所示:
HTTP/1.1 401 Unauthorized
Content-Type: application/json;charset=UTF-8
{
"code": 0,
"msg": "身份验证会话已过期,请重新登录!",
"data": {}
}
现在access_token
是2小时
已过期了,2小时
之后就需要重新登录了。也就是前端需要跳转到登录页面。这样显然体验不好,接下来实现用refresh_token
来刷新获取新的访问令牌access_token
通过调用刷新令牌refreshToken()
方法来获取最新的访问令牌access_token
刷新令牌伪代码参考
/**
* @desc: 刷新令牌
* @return Response
* @author Tinywan(ShaoBo Wan)
*/
public function refreshToken(): Response
{
$res = \Tinywan\Jwt\JwtToken::refreshToken();
return response_json(0,'success',$res);
}
CUL 模拟请求
curl --request GET \
--url http://127.0.0.1:8888/oauth/refresh-token \
--header 'Accept: */*' \
--header 'Accept-Encoding: gzip, deflate, br' \
--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ3ZWJtYW4udGlueXdhbi5jbiIsImF1ZCI6IndlYm1hbi50aW55d2FuLmNuIiwiaWF0IjoxNzI0MTM3MzQzLCJuYmYiOjE3MjQxMzczNDMsImV4cCI6MTcyNDc0MjE0MywiZXh0ZW5kIjp7ImlkIjoyMDIyMDAwMSwidXNlcm5hbWUiOiJ3ZWJtYW4iLCJtb2JpbGUiOiIxMzY2OTM2MTE5MiIsImVtYWlsIjoiVGlueXdhbkAxNjMuY29tIiwiYXZhdGFyIjoiaHR0cHM6Ly9saXZlLW9zcy5iYWlkdS5jb20vYXNzZXRzL2ltYWdlcy9hdmF0YXJzLzZhdmF0YXIuanBnIiwicGFzc3dvcmQiOiIkMnkkMTAkRm1Ka0RJV2JWN2hDTEl0VWV1amhpT0dibDEuVHYwUjRXNEJnaFhZWWNkcThQTGJVNm5lTGUiLCJpc19lbmFibGVkIjoxLCJjcmVhdGVfdGltZSI6IjIwMjEtMTEtMTIgMTA6NDg6NTkifX0.3Ii4Og8N6M7rk9GDxT_RydX12FdioGJUXvJU4wm5AwA' \
--header 'Connection: keep-alive' \
--header 'User-Agent: PostmanRuntime-ApipostRuntime/1.1.0'
注意:这时候请求认证Header的
Authorization: Bearer
传的值是refresh_token
令牌,而不是access_token
令牌.
通过以上请求带上有效的refresh_token
,拿到新的access_token
和refresh_token
HTTP/1.1 402 Unauthorized
Content-Type: application/json;charset=UTF-8
{
"code": 0,
"msg": "刷新令牌会话已过期,请重新登录!",
"data": {}
}
注意:这里返回的HTTP状态码是
402
,当然了该状态码可以通过配置文件进行配置。
可以看出我们设置的refresh_token
超过7天
也就过期了,这时候需要前端跳转到登录页面让用户重新登录了。
前端伪代码
async function refreshToken() {
const res = await axios.get("http://127.0.0.1:8888/oauth/refresh-token", {
params: { refresh_token: localStorage.getItem("refresh_token") },
});
localStorage.setItem("access_token", res.data.access_token || "");
localStorage.setItem("refresh_token", res.data.refresh_token || "");
return res;
}
axios.interceptors.response.use(
(response) => response,
async (err) => {
let { data, config } = err.response;
if (data.statusCode === 401 && config.url.includes("/oauth/refresh-token")) {
const res = await refreshToken();
if (res.status === 200) {
return axios(config);
} else {
alert("登录过期,请重新登录");
return Promise.reject(res.data);
}
} else {
return err.response;
}
}
);