# 5.1 用户认证(JWT) :::tip 概述 JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:access_token 用于接口认证(2 小时),refresh_token 用于令牌刷新(7 天)。refresh_token 存储于 Redis(有状态),支持**一次性刷新、主动吊销、踢下线、改密后全端失效**;access_token 无状态、可水平扩展。 ::: ## 双令牌机制 ```text ┌──────────┐ ┌──────────┐ │ 用户登录 │──── 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 ` | POST `/api/refresh` 请求体 | | 服务端状态 | 无状态(不落库) | **有状态**:`refresh:{uid}:{jti}` 存 Redis | | 校验方 | `Authenticate` 中间件 | `JwtService::refreshToken()` | ### 令牌流转全生命周期 ```text 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 │ ▼ Authenticate::handle() │ 解析 → 校验签名 → 校验有效期 → 校验 type=access │ 注入 attributes['userInfo'] ▼ Controller → Service → Model → 响应 3. Token 过期(响应 code=401) │ ▼ POST /api/refresh {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` 启动校验直接抛异常,**应用拒绝启动**——弱密钥无法上线。生成方式: ```bash 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 ```json { "uid": 1, "username": "admin", "type": "access", "jti": "550e8400-e29b-41d4-a716-446655440000", "iss": "rxthinkcmf", "iat": 1727000000, "exp": 1727007200 } ``` ### refresh_token payload ```json { "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", "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 中间件) ```text 请求进入 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 请求处理链路](/zh/architecture/request-flow.html)) ## Token 刷新流程 ```text 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` 实际代码) ```typescript // 登录(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`) ```typescript 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` 能力保留给第三方客户端与后续升级)。 ::: ```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; ``` ## 安全建议 | 建议 | 说明 | |------|------| | 生产环境修改密钥 | `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 统一响应与异常抛出](/zh/core/exception.html)。 :::