签入版本
This commit is contained in:
@@ -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)。
|
||||
:::
|
||||
|
||||
Reference in New Issue
Block a user