Files
rxthinkcmf-wiki/RXThinkCMF_Laravel13_EleVue/zh/api-spec/token.md
T
2026-10-08 17:50:29 +08:00

493 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <token> │
│ 校验方: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 <access_token>
```
### 示例
```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 <access_token>
# 通过 URL 参数传递
GET /api/oauth2/check/token?token=<access_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 <access_token>
```
:::tip 真实吊销(Redis 有状态)
本项目 refresh_token 以 `refresh:{uid}:{jti}` 存于 Redis——登出即删除该键,旧 access_token 到期后**无法续签**;`JwtService::revokeAllTokens($userId)` 可一次性吊销用户全部令牌(踢下线、改密全端失效场景)。
:::
## URL 参数传递(SSO 场景)
前端路由守卫支持通过 URL 参数传递 Token,适用于 SSO 单点登录场景:
```text
http://localhost:8001/?token=<access_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));"
```
:::