JWT双令牌认证实现无感Token自动续约

2024-08-21 15:05:43 浏览数 (3)

JWT

概念

JSON Web Token (JWT)是一个开放标准(RFC 7519) ,它定义了一种紧凑和自包含的方式,用于作为 JSON 对象在各方之间安全地传输信息。此信息可以进行验证和信任,因为它是经过数字签名的。JWT 可以使用机密(使用 HMAC 算法)或使用 RSA 或 ECDSA 的公钥/私钥对进行签名。

虽然可以对 JWT 进行加密,以便在各方之间提供保密性,但是我们将关注已签名的Token。签名Token可以验证其中包含的声明的完整性,而加密Token可以向其他方隐藏这些声明。当使用公钥/私钥对对令牌进行签名时,该签名还证明只有持有私钥的一方才是对其进行签名的一方( 签名技术是保证传输的信息不可抵赖,并不能保证信息传输的安全 )

官网地址:https://jwt.io

JWT 原理

JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样。

代码语言:javascript复制
{
  "姓名": "开源技术小栈",
  "角色": "管理员",
  "到期时间": "2028年12月11日0点0分"
}

以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名(详见后文)。

服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。

JWT 数据结构

编码后的数据结构

它是一个很长的字符串,中间用点(.)分隔成三个部分。注意,JWT 内部是没有换行的,这里只是为了便于展示,将它写成了几行。

JWT 的三个部分依次如下

代码语言:javascript复制
Header(头部)
Payload(负载)
Signature(签名)

写成一行,就是下面的样子。

代码语言:javascript复制
Header.Payload.Signature

JWT 认证流程

认证流程流程说明:

  1. 浏览器发起请求登陆,用户携带用户名和密码等了
  2. 服务端验证身份,根据算法,将用户标识符打包生成 Token,
  3. 服务器返回JWT信息给浏览器,JWT不包含敏感信息
  4. 浏览器发起请求获取用户资料,把刚刚拿到的 Token一起发送给服务器
  5. 服务器发现数据中有 Token,验证身份是否合法
  6. 服务器根据当前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_tokenrefresh_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 能够一直让用户保持登录。

代码语言:javascript复制
{
 "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 过期。

代码语言:javascript复制
{
 "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也就过期了,这时候需要重新登录了。

安装插件

代码语言:javascript复制
composer require tinywan/jwt

插件地址:https://www.workerman.net/plugin/10

插件配置

配置文件config/plugin/tinywan/jwt

代码语言:javascript复制
<?php

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_exp2小时过期
  • refresh_token设置refresh_exp7天过期

生成令牌

代码语言:javascript复制
$user = [
    'id'  => 2024, 
    'name'  => 'Tinywan',
    'email' => 'Tinywan@163.com'
];
$token = TinywanJwtJwtToken::generateToken($user);
var_dump(json_encode($token));

输出(json格式)

代码语言:javascript复制
{
    "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

中间件拦截器

代码语言:javascript复制
<?php
/**
 * @desc 中间件拦截器
 * @author Tinywan(ShaoBo Wan)
 */
declare(strict_types=1);

namespace appmiddleware;

use TinywanExceptionHandlerExceptionForbiddenHttpException;
use TinywanExceptionHandlerExceptionUnauthorizedHttpException;
use TinywanJwtJwtToken;
use WebmanHttpRequest;
use WebmanHttpResponse;
use WebmanMiddlewareInterface;

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认证失败异常类响应。

令牌验证 无效 响应参考示例

代码语言:javascript复制
HTTP/1.1 401 Unauthorized
Content-Type: application/json;charset=UTF-8

{
    "code": 0,
    "msg": "令牌会话已过期,请再次登录!",
    "data": {}
}

令牌验证 通过 响应参考示例

代码语言:javascript复制
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8

{
    "code": 0,
    "msg": "success",
    "data": {
        "id": 202801,
        "username": "Tinywan"
    },
}

刷新令牌通

通过以上可以看出我们设置的access_token2小时过期后,服务端会返回一个401的HTTP状态码HTTP/1.1 401 Unauthorized,参考如下所示:

代码语言:javascript复制
HTTP/1.1 401 Unauthorized
Content-Type: application/json;charset=UTF-8

{
 "code": 0,
 "msg": "身份验证会话已过期,请重新登录!",
 "data": {}
}

现在access_token2小时已过期了,2小时之后就需要重新登录了。也就是前端需要跳转到登录页面。这样显然体验不好,接下来实现用refresh_token来刷新获取新的访问令牌access_token

通过调用刷新令牌refreshToken()方法来获取最新的访问令牌access_token

刷新令牌伪代码参考

代码语言:javascript复制
/**
 * @desc: 刷新令牌
 * @return Response
 * @author Tinywan(ShaoBo Wan)
 */
public function refreshToken(): Response
{
    $res = TinywanJwtJwtToken::refreshToken();
    return response_json(0,'success',$res);
}

CUL 模拟请求

代码语言:javascript复制
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_tokenrefresh_token

代码语言:javascript复制
HTTP/1.1 402 Unauthorized
Content-Type: application/json;charset=UTF-8

{
 "code": 0,
 "msg": "刷新令牌会话已过期,请重新登录!",
 "data": {}
}

注意:这里返回的HTTP状态码是402,当然了该状态码可以通过配置文件进行配置。

可以看出我们设置的refresh_token超过7天也就过期了,这时候需要前端跳转到登录页面让用户重新登录了。

前端伪代码

代码语言:javascript复制
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;
    }
  }
);

1 人点赞