# 5.1 用户认证(JWT) :::tip 概述 JWT(JSON Web Token)是系统的核心认证机制。采用双令牌设计:access_token 用于接口认证(2小时),refresh_token 用于令牌刷新(7天)。无状态、可水平扩展,前后端分离场景下的标准方案。 ::: ## 双令牌机制 ```text ┌──────────┐ ┌──────────┐ │ 用户登录 │──── username ────►│ │ │ │──── password ────►│JwtService│ │ │──── code/key ────►│ .login()│ └──────────┘ └────┬─────┘ │ ┌─────────────────┼─────────────────┐ ▼ ▼ ▼ access_token refresh_token user info (2小时有效) (7天有效) (基础信息) ``` ### 两种令牌的区别 | 特性 | access_token | refresh_token | |------|-------------|---------------| | 用途 | 接口认证(每个 API 请求携带) | 刷新令牌(仅在 access_token 过期时使用) | | 有效期 | 2 小时(7200 秒) | 7 天(604800 秒) | | payload.type | `access` | `refresh` | | 传递方式 | `Authorization: Bearer ` | POST `/oauth2/token` 请求体 | | 校验方 | AuthMiddleware | JwtService::refreshToken | ### 令牌流转全生命周期 ```text 1. 登录 POST /api/login {username, password, code, key} │ ▼ JwtService::login() │ 签发 access_token + refresh_token ▼ 前端存储到 localStorage 2. 请求 API GET /api/user/page Authorization: Bearer │ ▼ AuthMiddleware::handle() │ 解析 → 校验签名 → 校验有效期 → 校验 type=access │ 注入 $request->userInfo ▼ Controller → Logic → Model → 响应 3. Token 过期 前端响应拦截器检测 code=401 │ ▼ POST /api/oauth2/token {grant_type: refresh_token, refresh_token: } │ ▼ JwtService::refreshToken() │ 校验 type=refresh → 校验用户状态 → 签发新令牌对 ▼ 前端更新 localStorage 中的令牌 4. 登出 前端清除 localStorage 中的令牌 (JWT 无状态,服务端无需处理) ``` ## 配置项 | 配置键 | 环境变量 | 默认值 | 说明 | |--------|---------|--------|------| | `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 ` | 请求头前缀 | :::warning 密钥安全 生产环境务必通过 `.env` 设置 `JWT.SECRET` 为强随机密钥: ```bash php -r "echo bin2hex(random_bytes(32));" ``` ::: ## 核心类说明 ### Jwt 工具类(extend/jwt/Jwt.php) 对 `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 | **异常处理:** `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` | ## Token Payload 结构 ### access_token payload ```json { "uid": 1, "username": "admin", "type": "access", "iss": "rxthinkcmf", "iat": 1727000000, "exp": 1727007200 } ``` ### refresh_token payload ```json { "uid": 1, "username": "admin", "type": "refresh", "iss": "rxthinkcmf", "iat": 1727000000, "exp": 1727604800 } ``` ## 登录流程详解 ```text POST /api/login { "username": "admin", "password": "123456", "code": "a3Bx", "key": "xxx" } │ ▼ 1. CaptchaService::check($code, $key) │ 验证码错误 → {"code":1, "msg":"验证码错误"} ▼ 2. User::withoutGlobalScope(['soft_delete'])->where('username', $username)->find() │ 不存在 → {"code":1, "msg":"用户名或密码错误"} ▼ 3. $user->status != 1 │ 禁用 → {"code":1, "msg":"账号已被禁用"} ▼ 4. PasswordService::verify($password, $user->password, $user->salt) │ 不匹配 → {"code":1, "msg":"用户名或密码错误"} ▼ 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": "..." } } } ``` :::tip 安全设计 - 用户不存在和密码错误统一提示"用户名或密码错误",避免暴露账号是否存在 - 验证码校验在密码校验之前,防止暴力破解 - 已删除用户通过 `withoutGlobalScope` 查询但不返回,统一走"用户名或密码错误" ::: ## Token 解析流程(AuthMiddleware) ```text 请求进入 AuthMiddleware │ ▼ 1. 检查排除路由(login/captcha/oauth2 等直接放行) │ ▼ 2. Jwt::getTokenFromHeader() │ 从 Authorization 头提取 Token │ 空 → {"code":401, "msg":"未提供认证令牌"} ▼ 3. Jwt::decode($token) │ 过期 → {"code":401, "msg":"token已过期"} │ 签名无效 → {"code":401, "msg":"token签名无效"} ▼ 4. 校验 $decoded->type === 'access' │ 非 access → {"code":401, "msg":"无效的认证令牌"} ▼ 5. $request->userInfo = $decoded │ 注入用户信息(uid、username 等) ▼ 6. 读取 #[Permission] 注解 → 校验权限 │ 无权限 → {"code":403, "msg":"无访问权限"} ▼ 7. 放行 → $next($request) ``` ## Token 刷新流程 ```text POST /api/oauth2/token { "grant_type": "refresh_token", "refresh_token": "eyJ..." } │ ▼ 1. Jwt::decode($refreshToken) │ 过期/无效 → {"code":1, "msg":"无效的refresh_token"} ▼ 2. 校验 $decoded->type === 'refresh' │ 非 refresh → {"code":1, "msg":"无效的refresh_token"} ▼ 3. User::find($decoded->uid) │ 不存在或禁用 → {"code":1, "msg":"用户不存在或已被禁用"} ▼ 4. 签发新 access_token + 新 refresh_token │ ▼ 5. 返回新令牌对 ``` :::tip "刷新即换新"策略 刷新时旧 refresh_token 不显式作废,新旧令牌在各自有效期内都可用。如需一次性刷新(旧令牌刷新后立即失效),应结合 Redis 黑名单机制。 ::: ## 前端集成 ### 登录与存储 ```typescript // 登录 const response = await login({ username, password, code, key }); localStorage.setItem('access_token', response.data.accessToken); localStorage.setItem('refresh_token', response.data.refreshToken); ``` ### 请求拦截器(自动携带 Token) ```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; }); ``` ### 响应拦截器(自动刷新 Token) ```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); } ); ``` ## 安全建议 | 建议 | 说明 | |------|------| | 生产环境修改密钥 | `JWT.SECRET` 使用 32 字节以上随机字符串 | | HTTPS 传输 | Token 明文传输,必须使用 HTTPS | | 合理设置有效期 | access_token 不宜过长(2小时),refresh_token 按需调整 | | 敏感操作二次验证 | 修改密码、删除数据等操作建议额外校验 | | Token 黑名单 | 生产环境建议用 Redis 实现 Token 吊销(当前为简化实现) | | 前端安全存储 | 避免将 Token 存入 Cookie(防 CSRF),推荐 localStorage | ## 错误响应汇总 | 场景 | 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 | 用户不存在或已被禁用 |