Files
easygoadmin-wiki/EasyGoAdmin_GoFrame_Layui/zh/core/jwt.md
T
2026-10-20 21:19:44 +08:00

11 KiB
Raw Blame History

5.1 用户认证(JWT)

:::tip 概述 JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:access_token 用于接口认证(2小时),refresh_token 用于令牌刷新(7天)。无状态、可水平扩展,前后端分离场景下的标准方案。 :::

双令牌机制

┌──────────┐                    ┌──────────┐
│ 用户登录  │──── username ────►│          │
│          │──── password ────►│JwtService│
│          │──── code/key ────►│  .login()│
└──────────┘                    └────┬─────┘
                                     │
                   ┌─────────────────┼─────────────────┐
                   ▼                 ▼                 ▼
            access_token      refresh_token      user info
            (2小时有效)        (7天有效)         (基础信息)

两种令牌的区别

特性 access_token refresh_token
用途 接口认证(每个 API 请求携带) 刷新令牌(仅在 access_token 过期时使用)
有效期 2 小时(7200 秒) 7 天(604800 秒)
payload.type access refresh
传递方式 Authorization: Bearer <token> POST /oauth2/token 请求体
校验方 AuthMiddleware JwtService::refreshToken

令牌流转全生命周期

1. 登录
   POST /api/login {username, password, code, key}
       │
       ▼
   JwtService::login()
       │ 签发 access_token + refresh_token
       ▼
   前端存储到 localStorage

2. 请求 API
   GET /api/user/page
   Authorization: Bearer <access_token>
       │
       ▼
   AuthMiddleware::handle()
       │ 解析 → 校验签名 → 校验有效期 → 校验 type=access
       │ 注入 $request->userInfo
       ▼
   Controller → Logic → Model → 响应

3. Token 过期
   前端响应拦截器检测 code=401
       │
       ▼
   POST /api/oauth2/token {grant_type: refresh_token, refresh_token: <refresh_token>}
       │
       ▼
   JwtService::refreshToken()
       │ 校验 type=refresh → 校验用户状态 → 签发新令牌对
       ▼
   前端更新 localStorage 中的令牌

4. 登出
   前端清除 localStorage 中的令牌
   (JWT 无状态,服务端无需处理)

配置项

配置键 环境变量 默认值 说明
jwt.secret JWT.SECRET 内置默认密钥 JWT 签名密钥,生产环境务必修改
jwt.algo — HS256 签名算法
jwt.access_ttl JWT.ACCESS_TTL 7200 access_token 有效期(秒),2 小时
jwt.refresh_ttl JWT.REFRESH_TTL 604800 refresh_token 有效期(秒),7 天
jwt.issuer — rxthinkcmf 签发者标识(iss 字段)
jwt.header_name — Authorization 请求头名称
jwt.header_prefix — Bearer 请求头前缀

:::warning 密钥安全 生产环境务必通过 .env 设置 JWT.SECRET 为强随机密钥:

php -r "echo bin2hex(random_bytes(32));"

:::

核心类说明

Jwt 工具类(extend/jwt/Jwt.php)

对 firebase/php-jwt 的薄封装,只做令牌的编解码与提取,不涉及业务逻辑。

方法 说明 调用方
encode($payload, $ttl) 签发 Token(自动补齐 iss/iat/exp) 内部方法
decode($token) 解码 Token(校验签名+有效期) AuthMiddleware、JwtService
getTokenFromHeader() 从请求头提取 Token AuthMiddleware
createAccessToken($uid, $username) 签发 access_token JwtService
createRefreshToken($uid, $username) 签发 refresh_token JwtService

异常处理: decode() 将 firebase 的具体异常统一转为 RuntimeException(code=401):

原始异常 转换后消息
ExpiredException token已过期
SignatureInvalidException token签名无效
其他异常 token无效: 原始信息

JwtService 服务类(app/service/JwtService.php)

负责登录业务流程,调用 Jwt 工具类签发与刷新令牌。

方法 说明 返回值
login($username, $password) 用户登录,签发令牌对 ['access_token', 'refresh_token', 'token_type', 'expires_in', 'user']
refreshToken($refreshToken) 用 refresh_token 换新令牌对 ['access_token', 'refresh_token', 'token_type', 'expires_in']
parseToken($token) 解析 access_token 获取 payload object(含 uid、username、exp)
checkToken($token) 检测令牌有效性(不抛异常) ['valid' => true/false, ...]
revokeToken($token) 吊销令牌(当前简化实现) bool

Token Payload 结构

access_token payload

{
    "uid": 1,
    "username": "admin",
    "type": "access",
    "iss": "rxthinkcmf",
    "iat": 1727000000,
    "exp": 1727007200
}

refresh_token payload

{
    "uid": 1,
    "username": "admin",
    "type": "refresh",
    "iss": "rxthinkcmf",
    "iat": 1727000000,
    "exp": 1727604800
}

登录流程详解

POST /api/login
{ "username": "admin", "password": "123456", "code": "a3Bx", "key": "xxx" }
    │
    ▼
1. CaptchaService::check($code, $key)
   │ 验证码错误 → {"code":1, "msg":"验证码错误"}
   ▼
2. User::withoutGlobalScope(['soft_delete'])->where('username', $username)->find()
   │ 不存在 → {"code":1, "msg":"用户名或密码错误"}
   ▼
3. $user->status != 1
   │ 禁用 → {"code":1, "msg":"账号已被禁用"}
   ▼
4. PasswordService::verify($password, $user->password, $user->salt)
   │ 不匹配 → {"code":1, "msg":"用户名或密码错误"}
   ▼
5. Jwt::createAccessToken($uid, $username) + Jwt::createRefreshToken(...)
   │
   ▼
6. 返回
   {
     "code": 0,
     "msg": "登录成功",
     "data": {
       "accessToken": "eyJ...",
       "refreshToken": "eyJ...",
       "tokenType": "Bearer",
       "expiresIn": 7200,
       "user": { "id": 1, "username": "admin", "realname": "管理员", "avatar": "..." }
     }
   }

:::tip 安全设计

  • 用户不存在和密码错误统一提示"用户名或密码错误",避免暴露账号是否存在
  • 验证码校验在密码校验之前,防止暴力破解
  • 已删除用户通过 withoutGlobalScope 查询但不返回,统一走"用户名或密码错误" :::

Token 解析流程(AuthMiddleware)

请求进入 AuthMiddleware
    │
    ▼
1. 检查排除路由(login/captcha/oauth2 等直接放行)
    │
    ▼
2. Jwt::getTokenFromHeader()
   │ 从 Authorization 头提取 Token
   │ 空 → {"code":401, "msg":"未提供认证令牌"}
   ▼
3. Jwt::decode($token)
   │ 过期 → {"code":401, "msg":"token已过期"}
   │ 签名无效 → {"code":401, "msg":"token签名无效"}
   ▼
4. 校验 $decoded->type === 'access'
   │ 非 access → {"code":401, "msg":"无效的认证令牌"}
   ▼
5. $request->userInfo = $decoded
   │ 注入用户信息(uid、username 等)
   ▼
6. 读取 #[Permission] 注解 → 校验权限
   │ 无权限 → {"code":403, "msg":"无访问权限"}
   ▼
7. 放行 → $next($request)

Token 刷新流程

POST /api/oauth2/token
{ "grant_type": "refresh_token", "refresh_token": "eyJ..." }
    │
    ▼
1. Jwt::decode($refreshToken)
   │ 过期/无效 → {"code":1, "msg":"无效的refresh_token"}
   ▼
2. 校验 $decoded->type === 'refresh'
   │ 非 refresh → {"code":1, "msg":"无效的refresh_token"}
   ▼
3. User::find($decoded->uid)
   │ 不存在或禁用 → {"code":1, "msg":"用户不存在或已被禁用"}
   ▼
4. 签发新 access_token + 新 refresh_token
   │
   ▼
5. 返回新令牌对

:::tip "刷新即换新"策略 刷新时旧 refresh_token 不显式作废,新旧令牌在各自有效期内都可用。如需一次性刷新(旧令牌刷新后立即失效),应结合 Redis 黑名单机制。 :::

前端集成

登录与存储

// 登录
const response = await login({ username, password, code, key });
localStorage.setItem('access_token', response.data.accessToken);
localStorage.setItem('refresh_token', response.data.refreshToken);

请求拦截器(自动携带 Token)

// src/utils/http/axios/index.ts
axios.interceptors.request.use(config => {
    const token = localStorage.getItem('access_token');
    if (token) {
        config.headers['Authorization'] = `Bearer ${token}`;
    }
    return config;
});

响应拦截器(自动刷新 Token)

axios.interceptors.response.use(
    response => response,
    async error => {
        const { config, response } = error;

        if (response?.status === 401 && !config._retry) {
            config._retry = true;

            // 尝试用 refresh_token 刷新
            const refreshToken = localStorage.getItem('refresh_token');
            if (refreshToken) {
                try {
                    const res = await refreshTokenApi(refreshToken);
                    localStorage.setItem('access_token', res.data.accessToken);
                    localStorage.setItem('refresh_token', res.data.refreshToken);

                    // 用新 Token 重试原请求
                    config.headers['Authorization'] = `Bearer ${res.data.accessToken}`;
                    return axios(config);
                } catch (e) {
                    // 刷新失败,跳转登录
                    localStorage.clear();
                    router.push('/login');
                }
            }
        }

        return Promise.reject(error);
    }
);

安全建议

建议 说明
生产环境修改密钥 JWT.SECRET 使用 32 字节以上随机字符串
HTTPS 传输 Token 明文传输,必须使用 HTTPS
合理设置有效期 access_token 不宜过长(2小时),refresh_token 按需调整
敏感操作二次验证 修改密码、删除数据等操作建议额外校验
Token 黑名单 生产环境建议用 Redis 实现 Token 吊销(当前为简化实现)
前端安全存储 避免将 Token 存入 Cookie(防 CSRF),推荐 localStorage

错误响应汇总

场景 HTTP 状态 code msg
未提供 Token 200 401 未提供认证令牌
Token 过期 200 401 token已过期
Token 签名无效 200 401 token签名无效
Token 类型错误 200 401 无效的认证令牌
无权限 200 403 无访问权限
refresh_token 无效 200 1 无效的refresh_token
用户被禁用 200 1 用户不存在或已被禁用