Files
rxthinkcmf-wiki/RXThinkCMF_Laravel13_NaiveVue/zh/api-spec/token.md
T
2026-10-11 19:06:33 +08:00

19 KiB
Raw Blame History

8.6 Token 使用说明

:::tip 概述 系统采用 JWT 双令牌认证,access_token 用于接口认证(2 小时),refresh_token 用于刷新令牌(7 天)。refresh_token 存储于 Redis(jti 有状态),支持一次性刷新、登出吊销、踢下线。所有需认证的接口必须在请求头中携带 access_token。 :::

Token 完整生命周期

登录获取 Token

用户输入账号密码
    │
    ▼
┌─────────────────────────────────────────────────────────────┐
│ 前端:POST /api/login                                       │
│ { username, password, code, key }                           │
└─────────────────────────┬───────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│ 后端:AuthController → JwtService                           │
│                                                             │
│ 1. LoginRequest            参数格式校验                      │
│ 2. CaptchaService::verify() 验证码校验(key/code)            │
│ 3. User::where('username') 查询用户                          │
│ 4. PasswordService::verify() 密码校验(双重加盐)             │
│ 5. createTokenPair()       签发双令牌 + refresh 存 Redis      │
│    (同一 jti,Redis 键 refresh:{uid}:{jti},TTL 7 天)        │
└─────────────────────────┬───────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│ 响应(登录/刷新接口 raw 返回,保持 snake_case):             │
│ {                                                           │
│   "access_token": "eyJ...",      ← 用于接口认证             │
│   "refresh_token": "eyJ...",     ← 用于刷新令牌             │
│   "token_type": "Bearer",                                   │
│   "expires_in": 7200,            ← 2小时 = 7200秒           │
│   "user": { "id":1, "username":"admin", ... }                │
│ }                                                           │
│ (AuthController 调用 success(..., true) 跳过驼峰转换,      │
│   与前端 store 的 data.access_token 读取方式保持一致)        │
└─────────────────────────┬───────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│ 前端:存储 Token(仅存 access_token)                        │
│                                                             │
│ storage.set('ACCESS-TOKEN', data.access_token)              │
└─────────────────────────────────────────────────────────────┘

使用 Token 请求 API

用户操作(如点击"用户列表")
    │
    ▼
┌─────────────────────────────────────────────────────────────┐
│ 前端:请求拦截器                                            │
│ const token = storage.get('ACCESS-TOKEN')                   │
│ config.headers['Authorization'] = `Bearer ${token}`         │
└─────────────────────────┬───────────────────────────────────┘
                          ▼
┌─────────────────────────────────────────────────────────────┐
│ GET /api/user/page?pageNo=1&pageSize=10                     │
│ Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 │
└─────────────────────────┬───────────────────────────────────┘
                          ▼
┌─────────────────────────────────────────────────────────────┐
│ 后端:Authenticate 中间件(auth.jwt)                        │
│                                                             │
│ 1. 提取 Authorization 头(带 Bearer 前缀则剥离,裸 token 亦可)│
│    │ 头缺失 → HTTP 200 "未提供认证令牌"                        │
│ 2. JwtService::parseToken()(校验签名+有效期+type=access)    │
│    │ 过期/签名错/类型错 → HTTP 200                           │
│ 3. 注入 attributes['userInfo']                              │
│ 4. 反射 #[Permission] → checkPermission()                   │
│    │ 无权限 → HTTP 403 "无访问权限:{权限码}"                 │
│ 5. 放行 → Controller → Service → Model → 响应               │
└─────────────────────────┬───────────────────────────────────┘
                          ▼
┌─────────────────────────────────────────────────────────────┐
│ 前端:响应拦截器                                            │
│ code === 0   → 返回 data(成功)                            │
│ code/status 401 → 弹窗"登录身份已失效" → 清存储 → 跳登录页    │
│ code/status 403 → 提示"无访问权限"                           │
│ 其他         → 提示 msg                                     │
└─────────────────────────────────────────────────────────────┘

Token 刷新(后端能力)

access_token 过期后,客户端可用 refresh_token 换取新令牌对:

POST /api/refresh
{ "refresh_token": "eyJ..." }          ← 主入口(公开接口)
   (或 POST /api/oauth2/token,grant_type=refresh_token)
    │
    ▼
JwtService::refreshToken()
    │ 1. 解析 refresh_token → 失败 → "无效的refresh_token"
    │ 2. type 必须为 refresh
    │ 3. Lua 原子校验并删除 Redis refresh:{uid}:{jti}(一次性)
    │    已用过/已吊销 → "refresh_token已失效"
    │ 4. 用户状态校验 → "用户不存在或已被禁用"
    │ 5. createTokenPair() → 新 jti 签发新令牌对
    ▼
返回新令牌对(access_token / refresh_token 均为新值)

:::warning 一次性刷新(防重放) 旧 refresh_token 刷新后立即从 Redis 删除——必须保存返回的新 refresh_token,重放旧令牌会得到 refresh_token已失效。 :::

:::info 管理后台前端的现状 ui/ 管理后台当前未内置自动续签:access_token 过期(401)时直接弹窗提示重新登录(见下文「响应拦截器」)。POST /api/refresh 面向第三方接入客户端与后续前端升级使用。 :::

Token 失效(重新登录)

access_token 过期 / refresh_token 刷新失败(过期/已吊销/网络异常)
    │
    ▼
┌─────────────────────────────────────────────────────────────┐
│ 前端:清除登录态,跳转登录页                                 │
│ storage.clear()                                             │
│ window.location.href = '/login'                             │
└─────────────────────────────────────────────────────────────┘

两种 Token 的区别

┌─────────────────────────────────────────────────────────────┐
│                    access_token                             │
│  用途:接口认证(每个 API 请求携带)                         │
│  有效期:2 小时(JWT_ACCESS_TTL=7200 秒)                    │
│  payload:type=access,含 jti                               │
│  传递方式:Authorization: Bearer <token>                    │
│  校验方:Authenticate 中间件                                 │
│  特点:短期有效、无状态,泄露影响范围有限                    │
├─────────────────────────────────────────────────────────────┤
│                    refresh_token                             │
│  用途:刷新令牌(仅在 access_token 过期时使用)              │
│  有效期:7 天(JWT_REFRESH_TTL=604800 秒)                   │
│  payload:type=refresh,含与 access 相同的 jti               │
│  传递方式:POST /api/refresh 请求体                          │
│  校验方:JwtService::refreshToken()(Redis jti 一次性校验)  │
│  特点:有状态(Redis)、可吊销、支持踢下线                   │
└─────────────────────────────────────────────────────────────┘

请求头格式

Authorization: Bearer <access_token>

示例

# curl
curl http://admin.laravel13.naivevue/api/user/page \
  -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."

# curl(带分页参数)
curl "http://admin.laravel13.naivevue/api/user/page?pageNo=1&pageSize=10" \
  -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."

请求头配置

配置项 默认值 说明
jwt.header_prefix Bearer 请求头前缀(注意有尾部空格,config/jwt.php)

Token 获取

登录获取(主入口)

POST /api/login
Content-Type: application/json

{
    "username": "admin",
    "password": "123456",
    "code": "a3Bx",
    "key": "captcha_key_xxx"
}

成功响应:

{
    "code": 0,
    "ok": true,
    "msg": "登录成功",
    "data": {
        "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
        "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
        "token_type": "Bearer",
        "expires_in": 7200,
        "user": {
            "id": 1,
            "username": "admin",
            "realname": "管理员",
            "avatar": "user/avatar/20260923/100429_e8FAKf9r.png"
        }
    }
}

OAuth2 密码模式获取

POST /api/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=password&username=admin&password=123456

响应字段

字段 类型 说明
access_token string 访问令牌(用于接口认证)
refresh_token string 刷新令牌(用于换取新令牌)
token_type string 令牌类型(固定 Bearer)
expires_in number access_token 有效期(秒,默认 7200)
user object 用户基础信息(id、username、realname、avatar)

:::tip 登录/刷新接口为 snake_case 特例 一般接口响应经驼峰自动转换(create_time → createTime);登录、刷新等令牌接口由 AuthController 以 success(..., true) raw 返回,键名保持 snake_case,与前端 data.access_token 读取方式一致。 :::

Token 刷新

access_token 过期后(code=401),使用 refresh_token 换取新令牌对:

接口信息

项目 说明
URL(主入口) POST /api/refresh(公开接口)
URL(OAuth2 兼容) POST /api/oauth2/token(grant_type=refresh_token)
Content-Type application/json 或 application/x-www-form-urlencoded

请求示例

# 推荐:/api/refresh(JSON)
POST /api/refresh
Content-Type: application/json

{ "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." }

# OAuth2 兼容(表单)
POST /api/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=eyJ0eXAiOi...

成功响应

{
    "code": 0,
    "ok": true,
    "msg": "刷新成功",
    "data": {
        "access_token": "eyJ...(新access_token)",
        "refresh_token": "eyJ...(新refresh_token)",
        "token_type": "Bearer",
        "expires_in": 7200
    }
}

失败响应

场景 code msg
缺少 refresh_token 1 缺少refresh_token
refresh_token 无效/类型错 1 无效的refresh_token
旧令牌已用过(重放)/ 已吊销 1 refresh_token已失效
用户不存在/禁用 1 用户不存在或已被禁用

Token 有效性检查

接口信息

项目 说明
URL GET /api/oauth2/check/token
权限 无需认证(公开接口)

请求示例

# 通过请求头传递
GET /api/oauth2/check/token
Authorization: Bearer <access_token>

# 通过 URL 参数传递
GET /api/oauth2/check/token?token=<access_token>

成功响应(有效)

{
    "code": 0,
    "ok": true,
    "msg": "操作成功",
    "data": {
        "valid": true,
        "uid": 1,
        "exp": 1727007200
    }
}

失败响应(无效)

{
    "code": 0,
    "ok": true,
    "msg": "操作成功",
    "data": {
        "valid": false,
        "msg": "Expired token"
    }
}

Token 吊销

接口 方法 认证 说明
/api/logout POST 认证组 登出:吊销当前 refresh_token(revokeToken 删除 Redis jti)
/api/oauth2/remove/{token} DELETE 认证组 吊销指定令牌
# 登出(携带 access_token)
POST /api/logout
Authorization: Bearer <access_token>

:::tip 真实吊销(Redis 有状态) 本项目 refresh_token 以 refresh:{uid}:{jti} 存于 Redis——登出即删除该键,旧 access_token 到期后无法续签;JwtService::revokeAllTokens($userId) 可一次性吊销用户全部令牌(踢下线、改密全端失效场景)。 :::

URL 参数传递(SSO 场景)

前端路由守卫支持通过 URL 参数传递 Token,适用于 SSO 单点登录场景:

http://localhost:8001/?token=<access_token>

前端处理逻辑:

1. 路由守卫检测 URL 中的 token 参数
2. 将 token 存入 localStorage
3. 清除 URL 中的 token 参数(防止泄露)
4. 后续请求自动携带该 token

前端存储与使用

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

// 登录成功后存储(仅 access_token)
const ex = 7 * 24 * 60 * 60 * 1000;
storage.set(ACCESS_TOKEN, data.access_token, ex);   // 键名 'ACCESS-TOKEN'

// 登出时清除(userStore.logout)
storage.remove(ACCESS_TOKEN);
storage.remove(CURRENT_USER);
storage.clear();

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

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

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

import { useDialog } from '@/hooks/web/useDialog';

const $dialog = useDialog();

// code === 401(ResultEnum.TIMEOUT)
case ResultEnum.TIMEOUT:
  if (router.currentRoute.value?.name === PageEnum.BASE_LOGIN_NAME) return;
  $dialog.warning({
    title: '提示',
    content: '登录身份已失效,请重新登录!',
    positiveText: '确定',
    //negativeText: '取消',
    closable: false,
    maskClosable: false,
    onPositiveClick: () => {
      storage.clear();
      window.location.href = PageEnum.BASE_LOGIN;   // '/login'
    },
    onNegativeClick: () => {},
  });
  break;

SSO 场景(URL 参数传递 Token)

路由守卫检测 ?token= 参数并写入存储(摘自 ui/src/router/router-guards.ts 实际代码):

if (to.query && 'token' in to.query) {
  const tokenVal = String(to.query.token);
  storage.set(ACCESS_TOKEN, tokenVal);
  userStore.setToken(tokenVal);
}

Token Payload 结构

access_token

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

refresh_token

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

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

错误响应汇总

场景 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已失效

配置项

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

:::warning 生产环境安全 生产环境务必通过 .env 设置 JWT_SECRET 为 ≥32 字符的强随机密钥(不满足应用拒绝启动):

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

:::