Files
rxthinkcmf-wiki/RXThinkCMF_Laravel13_AntdVue/zh/core/jwt.md
T
2026-10-08 17:50:29 +08:00

13 KiB
Raw Blame History

5.1 用户认证(JWT)

:::tip 概述 JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:access_token 用于接口认证(2 小时),refresh_token 用于令牌刷新(7 天)。refresh_token 存储于 Redis(有状态),支持一次性刷新、主动吊销、踢下线、改密后全端失效;access_token 无状态、可水平扩展。 :::

双令牌机制

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

两种令牌的区别

特性 access_token refresh_token
用途 接口认证(每个 API 请求携带) 刷新令牌(仅在 access_token 过期时使用)
有效期 2 小时(JWT_ACCESS_TTL=7200) 7 天(JWT_REFRESH_TTL=604800)
payload.type access refresh
传递方式 Authorization: Bearer <token> POST /api/refresh 请求体
服务端状态 无状态(不落库) 有状态:refresh:{uid}:{jti} 存 Redis
校验方 Authenticate 中间件 JwtService::refreshToken()

令牌流转全生命周期

1. 登录
   POST /api/login {username, password, key, code}
       │
       ▼
   JwtService::login()
       │ 签发 access_token + refresh_token
       │ 同一 jti(UUID),refresh_token 存入 Redis(TTL 7 天)
       ▼
   前端存储到 localStorage

2. 请求 API
   GET /api/user/page
   Authorization: Bearer <access_token>
       │
       ▼
   Authenticate::handle()
       │ 解析 → 校验签名 → 校验有效期 → 校验 type=access
       │ 注入 attributes['userInfo']
       ▼
   Controller → Service → Model → 响应

3. Token 过期(响应 code=401)
       │
       ▼
   POST /api/refresh {refresh_token: <refresh_token>}
       │
       ▼
   JwtService::refreshToken()
       │ 校验 type=refresh → Lua 原子校验并删除 Redis 中旧 jti(一次性)
       │ → 校验用户状态 → 签发新令牌对(新 jti)
       ▼
   前端更新 localStorage 中的令牌

4. 登出
   POST /api/logout(携带 access_token)
       │
       ▼
   JwtService::revokeToken()
       │ 解析 uid + jti → 删除 Redis refresh:{uid}:{jti}
       ▼
   refresh_token 即时失效(旧 access_token 到期后无法续签)

5. 踢下线 / 改密失效
   JwtService::revokeAllTokens($userId)
       │ SCAN 删除 refresh:{uid}:* 全部令牌
       │ 删除 perms:{uid} 权限缓存
       ▼
   该用户所有设备立即掉线

:::tip 为什么 refresh 要有状态? 纯无状态 JWT 无法在"过期前"作废。refresh_token 落 Redis 后,登出、改密、封号、踢下线都能即时生效;配合 Lua 脚本原子删除实现"一次性刷新"(旧 refresh_token 用过即废,防止重放)。 :::

配置项

配置键(config/jwt.php) 环境变量 默认值 说明
jwt.secret JWT_SECRET (必填) JWT 签名密钥,必须 ≥ 32 字符,否则拒绝启动
jwt.access_ttl JWT_ACCESS_TTL 7200 access_token 有效期(秒),2 小时
jwt.refresh_ttl JWT_REFRESH_TTL 604800 refresh_token 有效期(秒),7 天
jwt.perms_ttl JWT_PERMS_TTL 7200 权限缓存 TTL(秒)
jwt.issuer JWT_ISSUER rxthinkcmf 签发者标识(iss 字段)
jwt.header_prefix — Bearer 请求头前缀(含尾部空格)

:::warning 密钥安全(fail-close) JWT_SECRET 为空或不足 32 字符时,AppServiceProvider 启动校验直接抛异常,应用拒绝启动——弱密钥无法上线。生成方式:

openssl rand -base64 32
# 或
php -r "echo bin2hex(random_bytes(32));"

:::

核心类说明

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

基于 firebase/php-jwt 封装登录、刷新、解析、吊销全流程:

方法 说明 返回值 / 异常
login($username, $password) 用户登录,签发令牌对 ['access_token', 'refresh_token', 'token_type', 'expires_in', 'user'];失败抛 BizException
refreshToken($refreshToken) 用 refresh_token 换新令牌对(旧 jti 一次性删除) ['access_token', 'refresh_token', 'token_type', 'expires_in']
parseToken($token) 解析 access_token(仅接受 type=access) object(含 uid、username、jti、exp)
checkToken($token) 检测令牌有效性(不抛异常) ['valid' => true/false, ...]
revokeToken($token) 吊销单个 refresh_token(登出) bool
revokeAllTokens($userId) 吊销用户全部令牌 + 权限缓存(踢下线/改密) bool
createTokenPair($user) 生成 jti 并签发新令牌对(内部) 令牌对数组
loadPermissions($userId) 加载用户权限并缓存 perms:{uid} 权限数组

降级策略:Redis 不可用时不阻塞登录(access_token 照常签发),但刷新的 jti 校验、踢下线能力失效——设计上"可用性优先、安全能力降级"。

Token Payload 结构

access_token payload

{
    "uid": 1,
    "username": "admin",
    "type": "access",
    "jti": "550e8400-e29b-41d4-a716-446655440000",
    "iss": "rxthinkcmf",
    "iat": 1727000000,
    "exp": 1727007200
}

refresh_token payload

{
    "uid": 1,
    "username": "admin",
    "type": "refresh",
    "jti": "550e8400-e29b-41d4-a716-446655440000",
    "iss": "rxthinkcmf",
    "iat": 1727000000,
    "exp": 1727604800
}

同一登录签发的两个令牌共享同一个 jti(UUID),Redis 键为 refresh:{uid}:{jti}。

登录流程详解

POST /api/login
{ "username": "admin", "password": "123456", "key": "captcha-uuid", "code": "a3Bx" }
    │
    ▼
1. LoginRequest(FormRequest)参数格式校验
   │ 失败 → {"code":1, "msg":"参数验证失败", "data":{字段=>错误}}
   ▼
2. CaptchaService::verify($key, $code)        ← 仅当 CAPTCHA_LENGTH > 0 时启用
   │ 失败 → {"code":1, "msg":"验证码错误"}
   ▼
3. JwtService::login($username, $password)
   ├─ User 查询(软删除范围内)
   │   不存在 → BizException("用户名或密码错误")     ← 统一提示,不暴露账号存在性
   ├─ $user->status 禁用 → BizException("账号已被禁用")
   ├─ PasswordService::verify(密码, hash, salt)
   │   不匹配 → BizException("用户名或密码错误")
   └─ createTokenPair():签发双令牌 + refresh 存 Redis + 加载权限缓存
   ▼
4. AuthController 写登录日志(成功/失败均记录)
   ▼
5. 返回 Result::success($result, '登录成功', true)   ← raw,跳过驼峰转换
   data = { access_token, refresh_token, token_type, expires_in, user }
   (snake_case,与前端 store 的 data.access_token 读取方式一致)

:::tip 安全设计

  • 用户不存在和密码错误统一提示"用户名或密码错误",避免暴露账号是否存在
  • 验证码校验在密码校验之前,防止暴力破解(配合 IP 限流)
  • key / code 参数名与 /api/captcha 响应字段一致 :::

Token 解析流程(Authenticate 中间件)

请求进入 Authenticate(auth.jwt)
    │
    ▼
1. 提取 Authorization 头(带 Bearer 前缀则剥离,裸 token 亦可)
   │ 头缺失 → HTTP 200 {"code":401, "msg":"未提供认证令牌"}
   ▼
2. JwtService::parseToken($token)
   │ 过期/签名无效 → HTTP 200(消息取自异常,如"Expired token")
   │ type !== 'access' → BizException("无效的access_token") → HTTP 200
   ▼
3. 注入 attributes['userInfo'] = {uid, username, jti, ...}
   ▼
4. 读取控制器方法的 #[Permission] 注解 → checkPermission()
   │ 无注解 → 跳过权限校验
   │ uid=1 → 超级管理员直接放行
   │ 无权限 → HTTP 403 {"code":403, "msg":"无访问权限:sys:xxx:xxx"}
   ▼
5. 放行 → $next($request)

(完整中间件链见 4.2 请求处理链路)

Token 刷新流程

POST /api/refresh
{ "refresh_token": "eyJ..." }
    │
    ▼
1. JwtService::refreshToken($refreshToken)
   ├─ 解析失败 → BizException("无效的refresh_token")
   ├─ type !== 'refresh' → BizException("无效的refresh_token")
   ├─ Lua 脚本原子校验 Redis 中 refresh:{uid}:{jti} 并删除(一次性)
   │   不存在/已用 → BizException("refresh_token已失效")
   ├─ 用户不存在或禁用 → BizException("用户不存在或已被禁用")
   └─ createTokenPair():新 jti 签发新令牌对,写入 Redis
   ▼
2. 返回 Result::success($result, '刷新成功', true)   ← raw,snake_case
   data = { access_token, refresh_token, token_type, expires_in }

:::tip 两种刷新入口

  • POST /api/refresh(推荐):公开接口,请求体 {refresh_token};
  • POST /api/oauth2/token:OAuth2 兼容,grant_type=refresh_token。 两者最终都调用 JwtService::refreshToken(),旧 refresh_token 一次性作废。 :::

:::warning Redis 降级行为 Redis 不可用时刷新接口跳过 jti 校验(降级放行),保证可用性;此时踢下线/一次性刷新能力失效。生产环境应保证 Redis 高可用。 :::

前端集成

登录与存储(摘自 ui/src/store/modules/user.ts 实际代码)

// 登录(key/code 为验证码参数),响应 data 为 snake_case
const response = await login({ username, password, key, code });
const { code, data } = response;
if (code === ResultEnum.SUCCESS) {
    const ex = 7 * 24 * 60 * 60 * 1000;
    storage.set(ACCESS_TOKEN, data.access_token, ex);   // 仅存 access_token
}

请求拦截器(自动携带 Token,摘自 ui/src/utils/http/axios/index.ts)

const token = storage.get('ACCESS-TOKEN', '');
if (token) {
    config.headers['Authorization'] = `Bearer ${token}`;
}

响应拦截器(401 处理,摘自 ui/src/utils/http/axios/index.ts)

:::info 本项目未内置自动续签 401 时直接提示重新登录(POST /api/refresh 能力保留给第三方客户端与后续升级)。 :::

// code === 401(ResultEnum.TIMEOUT)
case ResultEnum.TIMEOUT:
  if (router.currentRoute.value?.name === PageEnum.BASE_LOGIN_NAME) return;
  ElMessageBox.confirm('登录身份已失效,请重新登录!', '提示', { type: 'warning' })
    .then(() => {
      storage.clear();
      window.location.href = PageEnum.BASE_LOGIN;   // '/login'
    })
    .catch(() => {});
  break;

安全建议

建议 说明
生产环境修改密钥 JWT_SECRET ≥ 32 字符随机值,不满足应用拒绝启动
HTTPS 传输 Token 明文传输,必须使用 HTTPS
合理设置有效期 access_token 不宜过长(2 小时),refresh_token 按需调整
敏感操作二次验证 修改密码、删除数据等操作建议额外校验
依赖 Redis 有状态能力 登出吊销、踢下线、一次性刷新均依赖 Redis,生产保证可用
前端安全存储 避免将 Token 存入 Cookie(防 CSRF),推荐 localStorage
改密后全端失效 修改密码时调用 revokeAllTokens($userId) 清除全部令牌

错误响应汇总

场景 HTTP 状态 code msg
未提供 Token 200 401 未提供认证令牌
Token 过期 200 401 Expired token(消息取自异常)
Token 签名无效 200 401 Signature verification failed
Token 类型错误(refresh 当 access 用) 200 401 无效的access_token
无权限 403 403 无访问权限:{权限码}
refresh_token 无效/类型错 200 1 无效的refresh_token
refresh_token 已用/已吊销 200 1 refresh_token已失效
用户被禁用(刷新时) 200 1 用户不存在或已被禁用
登录密码错误 200 1 用户名或密码错误

:::tip 状态码约定 Authenticate 中间件的 401 以 HTTP 200 + body code 表达(前端据 code=401 跳转登录),403 同时设置 HTTP 状态码(便于网关处理),业务层异常走 HTTP 200 + body code。详见 5.16 统一响应与异常抛出。 :::