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

概念

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 的三个部分依次如下

复制代码
Header(头部)
Payload(负载)
Signature(签名)

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

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

安装插件

复制代码
composer require tinywan/jwt

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

插件配置

配置文件config/plugin/tinywan/jwt

javascript 复制代码
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' => '[email protected]'
];
$token = Tinywan\Jwt\JwtToken::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 复制代码
/**
 * @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认证失败异常类响应。

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

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 = \Tinywan\Jwt\JwtToken::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;
    }
  }
);
相关推荐
可爱的霸王龙5 天前
SpringBoot整合JWT
java·后端·jwt
csdn_aspnet13 天前
在 .NET 9.0 Web API 中实现 Scalar 接口文档及JWT集成
jwt·.net9·scalar
图图图图爱睡觉1 个月前
大白话解释认证JWT是什么 有什么用 怎么用
jwt
lixww.cn2 个月前
ASP.NET Core对JWT的封装
asp.net core·jwt·authorize
lixww.cn2 个月前
ASP.NET Core JWT Version
asp.net core·jwt·filter·identity
lixww.cn2 个月前
ASP.NET Core JWT
asp.net core·jwt
kong79069282 个月前
电商系统-用户认证(三)基于公钥解析JWT令牌
jwt·用户认证·解析jwt令牌
一只淡水鱼662 个月前
【spring】集成JWT实现登录验证
java·spring·jwt
梦幻加菲猫3 个月前
JWT在线解密/解码 - 加菲工具
jwt·jwt 在线解密/解码·jwt在线解密·jwt在线解码
脸红ฅฅ*的思春期3 个月前
JAVA安全—JWT攻防&Swagger自动化&Druid泄露
java·安全·自动化·jwt