13 KiB
5.1 用户认证(JWT)
:::tip 概述 JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:access_token 用于接口认证(2 小时),refresh_token 用于令牌刷新(7 天)。refresh_token 存储于 Redis(有状态),支持一次性刷新、主动吊销、踢下线、改密后全端失效;access_token 无状态、可水平扩展。 :::
双令牌机制
┌──────────┐ ┌──────────┐
│ 用户登录 │──── username ────►│ │
│ │──── password ────►│JwtService│
│ │──── key/code ────►│ .login()│
└──────────┘ └────┬─────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
access_token refresh_token user info
(2小时有效) (7天有效) (基础信息)
存 Redis(jti 一次性)
两种令牌的区别
| 特性 | access_token | refresh_token |
|---|---|---|
| 用途 | 接口认证(每个 API 请求携带) | 刷新令牌(仅在 access_token 过期时使用) |
| 有效期 | 2 小时(JWT_ACCESS_TTL=7200) |
7 天(JWT_REFRESH_TTL=604800) |
| payload.type | access |
refresh |
| 传递方式 | Authorization: Bearer <token> |
POST /api/refresh 请求体 |
| 服务端状态 | 无状态(不落库) | 有状态:refresh:{uid}:{jti} 存 Redis |
| 校验方 | Authenticate 中间件 |
JwtService::refreshToken() |
令牌流转全生命周期
1. 登录
POST /api/login {username, password, key, code}
│
▼
JwtService::login()
│ 签发 access_token + refresh_token
│ 同一 jti(UUID),refresh_token 存入 Redis(TTL 7 天)
▼
前端存储到 localStorage
2. 请求 API
GET /api/user/page
Authorization: Bearer <access_token>
│
▼
Authenticate::handle()
│ 解析 → 校验签名 → 校验有效期 → 校验 type=access
│ 注入 attributes['userInfo']
▼
Controller → Service → Model → 响应
3. Token 过期(响应 code=401)
│
▼
POST /api/refresh {refresh_token: <refresh_token>}
│
▼
JwtService::refreshToken()
│ 校验 type=refresh → Lua 原子校验并删除 Redis 中旧 jti(一次性)
│ → 校验用户状态 → 签发新令牌对(新 jti)
▼
前端更新 localStorage 中的令牌
4. 登出
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 签名密钥,必须 ≥ 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 启动校验直接抛异常,应用拒绝启动——弱密钥无法上线。生成方式:
openssl rand -base64 32
# 或
php -r "echo bin2hex(random_bytes(32));"
:::
核心类说明
JwtService 服务类(app/Services/JwtService.php)
基于 firebase/php-jwt 封装登录、刷新、解析、吊销全流程:
| 方法 | 说明 | 返回值 / 异常 |
|---|---|---|
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} |
权限数组 |
降级策略:Redis 不可用时不阻塞登录(access_token 照常签发),但刷新的 jti 校验、踢下线能力失效——设计上"可用性优先、安全能力降级"。
Token Payload 结构
access_token payload
{
"uid": 1,
"username": "admin",
"type": "access",
"jti": "550e8400-e29b-41d4-a716-446655440000",
"iss": "rxthinkcmf",
"iat": 1727000000,
"exp": 1727007200
}
refresh_token payload
{
"uid": 1,
"username": "admin",
"type": "refresh",
"jti": "550e8400-e29b-41d4-a716-446655440000",
"iss": "rxthinkcmf",
"iat": 1727000000,
"exp": 1727604800
}
同一登录签发的两个令牌共享同一个 jti(UUID),Redis 键为 refresh:{uid}:{jti}。
登录流程详解
POST /api/login
{ "username": "admin", "password": "123456", "key": "captcha-uuid", "code": "a3Bx" }
│
▼
1. LoginRequest(FormRequest)参数格式校验
│ 失败 → {"code":1, "msg":"参数验证失败", "data":{字段=>错误}}
▼
2. CaptchaService::verify($key, $code) ← 仅当 CAPTCHA_LENGTH > 0 时启用
│ 失败 → {"code":1, "msg":"验证码错误"}
▼
3. JwtService::login($username, $password)
├─ User 查询(软删除范围内)
│ 不存在 → BizException("用户名或密码错误") ← 统一提示,不暴露账号存在性
├─ $user->status 禁用 → BizException("账号已被禁用")
├─ PasswordService::verify(密码, hash, salt)
│ 不匹配 → BizException("用户名或密码错误")
└─ createTokenPair():签发双令牌 + refresh 存 Redis + 加载权限缓存
▼
4. AuthController 写登录日志(成功/失败均记录)
▼
5. 返回 Result::success($result, '登录成功', true) ← raw,跳过驼峰转换
data = { access_token, refresh_token, token_type, expires_in, user }
(snake_case,与前端 store 的 data.access_token 读取方式一致)
:::tip 安全设计
- 用户不存在和密码错误统一提示"用户名或密码错误",避免暴露账号是否存在
- 验证码校验在密码校验之前,防止暴力破解(配合 IP 限流)
key/code参数名与/api/captcha响应字段一致 :::
Token 解析流程(Authenticate 中间件)
请求进入 Authenticate(auth.jwt)
│
▼
1. 提取 Authorization 头(带 Bearer 前缀则剥离,裸 token 亦可)
│ 头缺失 → HTTP 200 {"code":401, "msg":"未提供认证令牌"}
▼
2. JwtService::parseToken($token)
│ 过期/签名无效 → HTTP 200(消息取自异常,如"Expired token")
│ type !== 'access' → BizException("无效的access_token") → HTTP 200
▼
3. 注入 attributes['userInfo'] = {uid, username, jti, ...}
▼
4. 读取控制器方法的 #[Permission] 注解 → checkPermission()
│ 无注解 → 跳过权限校验
│ uid=1 → 超级管理员直接放行
│ 无权限 → HTTP 403 {"code":403, "msg":"无访问权限:sys:xxx:xxx"}
▼
5. 放行 → $next($request)
(完整中间件链见 4.2 请求处理链路)
Token 刷新流程
POST /api/refresh
{ "refresh_token": "eyJ..." }
│
▼
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. 返回 Result::success($result, '刷新成功', true) ← raw,snake_case
data = { access_token, refresh_token, token_type, expires_in }
:::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 实际代码)
// 登录(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,摘自 ui/src/utils/http/axios/index.ts)
const token = storage.get('ACCESS-TOKEN', '');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
响应拦截器(401 处理,摘自 ui/src/utils/http/axios/index.ts)
:::info 本项目未内置自动续签
401 时直接提示重新登录(POST /api/refresh 能力保留给第三方客户端与后续升级)。
:::
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: '确定',
closable: false,
maskClosable: false,
onPositiveClick: () => {
storage.clear();
window.location.href = PageEnum.BASE_LOGIN; // '/login'
},
});
break;
安全建议
| 建议 | 说明 |
|---|---|
| 生产环境修改密钥 | JWT_SECRET ≥ 32 字符随机值,不满足应用拒绝启动 |
| HTTPS 传输 | Token 明文传输,必须使用 HTTPS |
| 合理设置有效期 | access_token 不宜过长(2 小时),refresh_token 按需调整 |
| 敏感操作二次验证 | 修改密码、删除数据等操作建议额外校验 |
| 依赖 Redis 有状态能力 | 登出吊销、踢下线、一次性刷新均依赖 Redis,生产保证可用 |
| 前端安全存储 | 避免将 Token 存入 Cookie(防 CSRF),推荐 localStorage |
| 改密后全端失效 | 修改密码时调用 revokeAllTokens($userId) 清除全部令牌 |
错误响应汇总
| 场景 | 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已失效 |
| 用户被禁用(刷新时) | 200 | 1 | 用户不存在或已被禁用 |
| 登录密码错误 | 200 | 1 | 用户名或密码错误 |
:::tip 状态码约定
Authenticate 中间件的 401 以 HTTP 200 + body code 表达(前端据 code=401 跳转登录),403 同时设置 HTTP 状态码(便于网关处理),业务层异常走 HTTP 200 + body code。详见 5.16 统一响应与异常抛出。
:::