签入版本

This commit is contained in:
2026-10-08 17:50:29 +08:00
parent 809750b47e
commit e213a9b31d
444 changed files with 39366 additions and 37533 deletions
+158 -167
View File
@@ -1,7 +1,7 @@
# 5.1 用户认证(JWT)
:::tip 概述
JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:access_token 用于接口认证(2小时),refresh_token 用于令牌刷新(7天)。无状态、可水平扩展,前后端分离场景下的标准方案。
JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:access_token 用于接口认证(2 小时),refresh_token 用于令牌刷新(7 天)。refresh_token 存储于 Redis(有状态),支持**一次性刷新、主动吊销、踢下线、改密后全端失效**;access_token 无状态、可水平扩展。
:::
## 双令牌机制
@@ -10,13 +10,14 @@ JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:
┌──────────┐ ┌──────────┐
│ 用户登录 │──── username ────►│ │
│ │──── password ────►│JwtService│
│ │──── code/key ────►│ .login()│
│ │──── key/code ────►│ .login()│
└──────────┘ └────┬─────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
access_token refresh_token user info
(2小时有效) (7天有效) (基础信息)
存 Redis(jti 一次性)
```
### 两种令牌的区别
@@ -24,20 +25,22 @@ JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:
| 特性 | access_token | refresh_token |
|------|-------------|---------------|
| 用途 | 接口认证(每个 API 请求携带) | 刷新令牌(仅在 access_token 过期时使用) |
| 有效期 | 2 小时(7200 秒) | 7 天(604800 秒) |
| 有效期 | 2 小时(`JWT_ACCESS_TTL=7200`) | 7 天(`JWT_REFRESH_TTL=604800`) |
| payload.type | `access` | `refresh` |
| 传递方式 | `Authorization: Bearer <token>` | POST `/oauth2/token` 请求体 |
| 校验方 | AuthMiddleware | JwtService::refreshToken |
| 传递方式 | `Authorization: Bearer <token>` | POST `/api/refresh` 请求体 |
| 服务端状态 | 无状态(不落库) | **有状态**:`refresh:{uid}:{jti}` 存 Redis |
| 校验方 | `Authenticate` 中间件 | `JwtService::refreshToken()` |
### 令牌流转全生命周期
```text
1. 登录
POST /api/login {username, password, code, key}
POST /api/login {username, password, key, code}
│
▼
JwtService::login()
│ 签发 access_token + refresh_token
│ 同一 jti(UUID),refresh_token 存入 Redis(TTL 7 天)
▼
前端存储到 localStorage
@@ -46,81 +49,84 @@ JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:
Authorization: Bearer <access_token>
│
▼
AuthMiddleware::handle()
Authenticate::handle()
│ 解析 → 校验签名 → 校验有效期 → 校验 type=access
│ 注入 $request->userInfo
│ 注入 attributes['userInfo']
▼
Controller → Logic → Model → 响应
Controller → Service → Model → 响应
3. Token 过期
前端响应拦截器检测 code=401
3. Token 过期(响应 code=401)
│
▼
POST /api/oauth2/token {grant_type: refresh_token, refresh_token: <refresh_token>}
POST /api/refresh {refresh_token: <refresh_token>}
│
▼
JwtService::refreshToken()
│ 校验 type=refresh → 校验用户状态 → 签发新令牌对
│ 校验 type=refresh → Lua 原子校验并删除 Redis 中旧 jti(一次性)
│ → 校验用户状态 → 签发新令牌对(新 jti)
▼
前端更新 localStorage 中的令牌
4. 登出
前端清除 localStorage 中的令牌
(JWT 无状态,服务端无需处理)
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 签名密钥,**生产环境务必修改** |
| `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 ` | 请求头前缀 |
| `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` 启动校验直接抛异常,**应用拒绝启动**——弱密钥无法上线。生成方式:
:::warning 密钥安全
生产环境务必通过 `.env` 设置 `JWT.SECRET` 为强随机密钥:
```bash
openssl rand -base64 32
# 或
php -r "echo bin2hex(random_bytes(32));"
```
:::
## 核心类说明
### Jwt 工具类(extend/jwt/Jwt.php)
### JwtService 服务类(`app/Services/JwtService.php`)
对 `firebase/php-jwt` 的薄封装,只做令牌的编解码与提取,不涉及业务逻辑。
基于 `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 |
| `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}` | 权限数组 |
**异常处理:** `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` |
**降级策略**:Redis 不可用时不阻塞登录(access_token 照常签发),但刷新的 jti 校验、踢下线能力失效——设计上"可用性优先、安全能力降级"。
## Token Payload 结构
@@ -131,6 +137,7 @@ php -r "echo bin2hex(random_bytes(32));"
"uid": 1,
"username": "admin",
"type": "access",
"jti": "550e8400-e29b-41d4-a716-446655440000",
"iss": "rxthinkcmf",
"iat": 1727000000,
"exp": 1727007200
@@ -144,187 +151,171 @@ php -r "echo bin2hex(random_bytes(32));"
"uid": 1,
"username": "admin",
"type": "refresh",
"jti": "550e8400-e29b-41d4-a716-446655440000",
"iss": "rxthinkcmf",
"iat": 1727000000,
"exp": 1727604800
}
```
同一登录签发的两个令牌共享同一个 `jti`(UUID),Redis 键为 `refresh:{uid}:{jti}`。
## 登录流程详解
```text
POST /api/login
{ "username": "admin", "password": "123456", "code": "a3Bx", "key": "xxx" }
{ "username": "admin", "password": "123456", "key": "captcha-uuid", "code": "a3Bx" }
│
▼
1. CaptchaService::check($code, $key)
│ 验证码错误 → {"code":1, "msg":"验证码错误"}
1. LoginRequest(FormRequest)参数格式校验
│ 失败 → {"code":1, "msg":"参数验证失败", "data":{字段=>错误}}
▼
2. User::withoutGlobalScope(['soft_delete'])->where('username', $username)->find()
│ 不存在 → {"code":1, "msg":"用户名或密码错误"}
2. CaptchaService::verify($key, $code) ← 仅当 CAPTCHA_LENGTH > 0 时启用
│ 失败 → {"code":1, "msg":"验证码错误"}
▼
3. $user->status != 1
│ 禁用 → {"code":1, "msg":"账号已被禁用"}
3. JwtService::login($username, $password)
├─ User 查询(软删除范围内)
│ 不存在 → BizException("用户名或密码错误") ← 统一提示,不暴露账号存在性
├─ $user->status 禁用 → BizException("账号已被禁用")
├─ PasswordService::verify(密码, hash, salt)
│ 不匹配 → BizException("用户名或密码错误")
└─ createTokenPair():签发双令牌 + refresh 存 Redis + 加载权限缓存
▼
4. PasswordService::verify($password, $user->password, $user->salt)
│ 不匹配 → {"code":1, "msg":"用户名或密码错误"}
4. AuthController 写登录日志(成功/失败均记录)
▼
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": "..." }
}
}
5. 返回 Result::success($result, '登录成功', true) ← raw,跳过驼峰转换
data = { access_token, refresh_token, token_type, expires_in, user }
(snake_case,与前端 store 的 data.access_token 读取方式一致)
```
:::tip 安全设计
- 用户不存在和密码错误统一提示"用户名或密码错误",避免暴露账号是否存在
- 验证码校验在密码校验之前,防止暴力破解
- 已删除用户通过 `withoutGlobalScope` 查询但不返回,统一走"用户名或密码错误"
- 验证码校验在密码校验之前,防止暴力破解(配合 IP 限流)
- `key` / `code` 参数名与 `/api/captcha` 响应字段一致
:::
## Token 解析流程(AuthMiddleware)
## Token 解析流程(Authenticate 中间件)
```text
请求进入 AuthMiddleware
请求进入 Authenticate(auth.jwt)
│
▼
1. 检查排除路由(login/captcha/oauth2 等直接放行)
│
▼
2. Jwt::getTokenFromHeader()
│ 从 Authorization 头提取 Token
│ 空 → {"code":401, "msg":"未提供认证令牌"}
1. 提取 Authorization 头(带 Bearer 前缀则剥离,裸 token 亦可)
│ 头缺失 → HTTP 200 {"code":401, "msg":"未提供认证令牌"}
▼
3. Jwt::decode($token)
│ 过期 → {"code":401, "msg":"token已过期"}
│ 签名无效 → {"code":401, "msg":"token签名无效"}
2. JwtService::parseToken($token)
│ 过期/签名无效 → HTTP 200(消息取自异常,如"Expired token")
│ type !== 'access' → BizException("无效的access_token") → HTTP 200
▼
4. 校验 $decoded->type === 'access'
│ 非 access → {"code":401, "msg":"无效的认证令牌"}
3. 注入 attributes['userInfo'] = {uid, username, jti, ...}
▼
5. $request->userInfo = $decoded
│ 注入用户信息(uid、username 等)
4. 读取控制器方法的 #[Permission] 注解 → checkPermission()
│ 无注解 → 跳过权限校验
│ uid=1 → 超级管理员直接放行
│ 无权限 → HTTP 403 {"code":403, "msg":"无访问权限:sys:xxx:xxx"}
▼
6. 读取 #[Permission] 注解 → 校验权限
│ 无权限 → {"code":403, "msg":"无访问权限"}
▼
7. 放行 → $next($request)
5. 放行 → $next($request)
```
(完整中间件链见 [4.2 请求处理链路](/zh/architecture/request-flow.html))
## Token 刷新流程
```text
POST /api/oauth2/token
{ "grant_type": "refresh_token", "refresh_token": "eyJ..." }
POST /api/refresh
{ "refresh_token": "eyJ..." }
│
▼
1. Jwt::decode($refreshToken)
│ 过期/无效 → {"code":1, "msg":"无效的refresh_token"}
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. 校验 $decoded->type === 'refresh'
│ 非 refresh → {"code":1, "msg":"无效的refresh_token"}
▼
3. User::find($decoded->uid)
│ 不存在或禁用 → {"code":1, "msg":"用户不存在或已被禁用"}
▼
4. 签发新 access_token + 新 refresh_token
│
▼
5. 返回新令牌对
2. 返回 Result::success($result, '刷新成功', true) ← raw,snake_case
data = { access_token, refresh_token, token_type, expires_in }
```
:::tip "刷新即换新"策略
刷新时旧 refresh_token 不显式作废,新旧令牌在各自有效期内都可用。如需一次性刷新(旧令牌刷新后立即失效),应结合 Redis 黑名单机制。
:::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` 实际代码)
```typescript
// 登录
const response = await login({ username, password, code, key });
localStorage.setItem('access_token', response.data.accessToken);
localStorage.setItem('refresh_token', response.data.refreshToken);
// 登录(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)
### 请求拦截器(自动携带 Token,摘自 `ui/src/utils/http/axios/index.ts`)
```typescript
// 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;
});
const token = storage.get('ACCESS-TOKEN', '');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
```
### 响应拦截器(自动刷新 Token)
### 响应拦截器(401 处理,摘自 `ui/src/utils/http/axios/index.ts`)
:::info 本项目未内置自动续签
401 时直接提示重新登录(`POST /api/refresh` 能力保留给第三方客户端与后续升级)。
:::
```typescript
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);
}
);
// 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 字节以上随机字符串 |
| 生产环境修改密钥 | `JWT_SECRET` ≥ 32 字符随机值,**不满足应用拒绝启动** |
| HTTPS 传输 | Token 明文传输,必须使用 HTTPS |
| 合理设置有效期 | access_token 不宜过长(2小时),refresh_token 按需调整 |
| 合理设置有效期 | access_token 不宜过长(2 小时),refresh_token 按需调整 |
| 敏感操作二次验证 | 修改密码、删除数据等操作建议额外校验 |
| Token 黑名单 | 生产环境建议用 Redis 实现 Token 吊销(当前为简化实现) |
| 依赖 Redis 有状态能力 | 登出吊销、踢下线、一次性刷新均依赖 Redis,生产保证可用 |
| 前端安全存储 | 避免将 Token 存入 Cookie(防 CSRF),推荐 localStorage |
| 改密后全端失效 | 修改密码时调用 `revokeAllTokens($userId)` 清除全部令牌 |
## 错误响应汇总
| 场景 | 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 | 用户不存在或已被禁用 |
| 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 统一响应与异常抛出](/zh/core/exception.html)。
:::