# 8.6 Token 使用说明 :::tip 概述 系统采用 JWT 双令牌认证,access_token 用于接口认证(2 小时),refresh_token 用于刷新令牌(7 天)。refresh_token 存储于 Redis(jti 有状态),支持**一次性刷新、登出吊销、踢下线**。所有需认证的接口必须在请求头中携带 access_token。 ::: ## Token 完整生命周期 ### 登录获取 Token ```text 用户输入账号密码 │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 前端: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 ```text 用户操作(如点击"用户列表") │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 前端:请求拦截器 │ │ 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 换取新令牌对: ```text 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 失效(重新登录) ```text access_token 过期 / refresh_token 刷新失败(过期/已吊销/网络异常) │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 前端:清除登录态,跳转登录页 │ │ storage.clear() │ │ window.location.href = '/login' │ └─────────────────────────────────────────────────────────────┘ ``` ### 两种 Token 的区别 ```text ┌─────────────────────────────────────────────────────────────┐ │ access_token │ │ 用途:接口认证(每个 API 请求携带) │ │ 有效期:2 小时(JWT_ACCESS_TTL=7200 秒) │ │ payload:type=access,含 jti │ │ 传递方式:Authorization: Bearer │ │ 校验方:Authenticate 中间件 │ │ 特点:短期有效、无状态,泄露影响范围有限 │ ├─────────────────────────────────────────────────────────────┤ │ refresh_token │ │ 用途:刷新令牌(仅在 access_token 过期时使用) │ │ 有效期:7 天(JWT_REFRESH_TTL=604800 秒) │ │ payload:type=refresh,含与 access 相同的 jti │ │ 传递方式:POST /api/refresh 请求体 │ │ 校验方:JwtService::refreshToken()(Redis jti 一次性校验) │ │ 特点:有状态(Redis)、可吊销、支持踢下线 │ └─────────────────────────────────────────────────────────────┘ ``` ## 请求头格式 ```text Authorization: Bearer ``` ### 示例 ```bash # curl curl http://localhost:8000/api/user/page \ -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." # curl(带分页参数) curl "http://localhost:8000/api/user/page?pageNo=1&pageSize=10" \ -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." ``` ### 请求头配置 | 配置项 | 默认值 | 说明 | |--------|--------|------| | `jwt.header_prefix` | `Bearer ` | 请求头前缀(注意有尾部空格,`config/jwt.php`) | ## Token 获取 ### 登录获取(主入口) ```bash POST /api/login Content-Type: application/json { "username": "admin", "password": "123456", "code": "a3Bx", "key": "captcha_key_xxx" } ``` **成功响应:** ```json { "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 密码模式获取 ```bash 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` | ### 请求示例 ```bash # 推荐:/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... ``` ### 成功响应 ```json { "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` | | 权限 | 无需认证(公开接口) | ### 请求示例 ```bash # 通过请求头传递 GET /api/oauth2/check/token Authorization: Bearer # 通过 URL 参数传递 GET /api/oauth2/check/token?token= ``` ### 成功响应(有效) ```json { "code": 0, "ok": true, "msg": "操作成功", "data": { "valid": true, "uid": 1, "exp": 1727007200 } } ``` ### 失败响应(无效) ```json { "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 | 认证组 | 吊销指定令牌 | ```bash # 登出(携带 access_token) POST /api/logout Authorization: Bearer ``` :::tip 真实吊销(Redis 有状态) 本项目 refresh_token 以 `refresh:{uid}:{jti}` 存于 Redis——登出即删除该键,旧 access_token 到期后**无法续签**;`JwtService::revokeAllTokens($userId)` 可一次性吊销用户全部令牌(踢下线、改密全端失效场景)。 ::: ## URL 参数传递(SSO 场景) 前端路由守卫支持通过 URL 参数传递 Token,适用于 SSO 单点登录场景: ```text http://localhost:8001/?token= ``` **前端处理逻辑:** ```text 1. 路由守卫检测 URL 中的 token 参数 2. 将 token 存入 localStorage 3. 清除 URL 中的 token 参数(防止泄露) 4. 后续请求自动携带该 token ``` ## 前端存储与使用 ### 存储(摘自 `ui/src/store/modules/user.ts` 实际代码) ```typescript // 登录成功后存储(仅 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`) ```typescript const token = storage.get('ACCESS-TOKEN', ''); config.headers['Authorization'] = `Bearer ${token}`; ``` ### 响应拦截器(401 处理,摘自 `ui/src/utils/http/axios/index.ts`) ```typescript // 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; ``` ### SSO 场景(URL 参数传递 Token) 路由守卫检测 `?token=` 参数并写入存储(摘自 `ui/src/router/router-guards.ts` 实际代码): ```typescript 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 ```json { "uid": 1, "username": "admin", "type": "access", "jti": "550e8400-e29b-41d4-a716-446655440000", "iss": "rxthinkcmf", "iat": 1727000000, "exp": 1727007200 } ``` ### refresh_token ```json { "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 字符的强随机密钥(**不满足应用拒绝启动**): ```bash openssl rand -base64 32 # 或 php -r "echo bin2hex(random_bytes(32));" ``` :::