# 5.2 认证流程实战 :::tip 概述 本节通过完整的请求链路,展示从登录到接口调用的认证全流程,包括登录、Token 刷新、接口认证、权限校验、租户隔离五个核心环节。 ::: ## 认证全景图 ```text ┌─────────────────────────────────────────────────────────────────────┐ │ 认证授权全景 │ │ │ │ ① 登录 POST /api/login(公开接口) │ │ 用户名+密码+验证码(key/code) → 校验 → 签发双令牌 + 写登录日志 │ │ │ │ ② 请求 API GET/POST /api/xxx(auth.jwt 组内) │ │ Bearer token → Authenticate → 权限校验 → 注入 userInfo │ │ │ │ ③ Token 过期 响应 code=401(HTTP 200) │ │ refresh_token → POST /api/refresh → 新令牌对(旧 jti 一次性作废) │ │ │ │ ④ 租户隔离 ResolveTenant │ │ 解析租户 → 注入 tenantId → Service 层自动过滤 │ │ │ │ ⑤ 登出 POST /api/logout → revokeToken(refresh 即时失效) │ └─────────────────────────────────────────────────────────────────────┘ ``` ## 一、登录流程 ### 完整流程图 ```text POST /api/login { "username": "admin", "password": "123456", "key": "captcha-uuid", "code": "a3Bx" } │ ▼ ┌─────────────────────────────────────────────────────────┐ │ AuthController::login(LoginRequest $request) │ │ │ │ 1. LoginRequest 类型注入 → rules() 格式校验 │ │ │ 失败 → {"code":1, "msg":"参数验证失败"} │ │ ▼ │ │ 2. 读取 key / code 参数(与 captcha 响应字段命名一致) │ │ CaptchaService::verify($key, $code) │ │ │ 仅当 config('captcha.length') > 0 时启用 │ │ │ 失败 → fail("请输入验证码" / "验证码错误") │ │ ▼ │ │ 3. JwtService::login($username, $password) │ └─────────────────────────┬───────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ JwtService::login() │ │ │ │ 1. User::where('username', $username)->first() │ │ │ 不存在 → BizException("用户名或密码错误") │ │ ▼ │ │ 2. $user->status 禁用 │ │ │ → BizException("账号已被禁用") │ │ ▼ │ │ 3. PasswordService::verify($password, $hashed, $salt) │ │ │ 不匹配 → BizException("用户名或密码错误") │ │ ▼ │ │ 4. createTokenPair($user) │ │ │ 生成 jti(UUID) → 签发 access + refresh │ │ │ Redis::setex("refresh:{uid}:{jti}", 7天, token) │ │ │ loadPermissions() → 缓存 perms:{uid} │ │ ▼ │ │ 5. 返回 { access_token, refresh_token, token_type, │ │ expires_in, user } │ └─────────────────────────┬───────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ AuthController 续 │ │ │ │ 成功 → writeLoginLog(成功) → success($result,'登录成功') │ │ 失败 → writeLoginLog(失败) → fail($e->getMessage()) │ │ (登录日志成功/失败均记录,含 IP 归属地、浏览器) │ └─────────────────────────────────────────────────────────┘ ``` ### 登录接口详情 **请求:** ```bash POST /api/login Content-Type: application/json { "username": "admin", "password": "123456", "key": "5c62cae9-3bdb-4356-bc4a-c4c61b7c3bea", "code": "a3Bx" } ``` **成功响应:** ```json { "code": 0, "ok": true, "msg": "登录成功", "data": { "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", "token_type": "Bearer", "expires_in": 7200, "user": { "id": 1, "username": "admin", "realname": "管理员", "avatar": "user/avatar/20260923/100429_e8FAKf9r.png" } } } ``` :::tip 登录/刷新接口 raw 返回 服务端原始数组键为 `access_token` 等 snake_case;登录、刷新接口由 `AuthController` 以 `success(..., true)` raw 返回,**跳过**驼峰转换,与前端 `data.access_token` 读取方式一致(一般接口仍经驼峰转换,如 `create_time` → `createTime`)。 ::: **失败响应:** | 场景 | code | msg | |------|------|-----| | 参数格式错误 | 1 | 参数验证失败(data 为字段明细) | | 验证码缺失 | 1 | 请输入验证码 | | 验证码错误 | 1 | 验证码错误 | | 用户不存在 | 1 | 用户名或密码错误 | | 密码错误 | 1 | 用户名或密码错误 | | 账号禁用 | 1 | 账号已被禁用 | ## 二、接口认证流程 ### 中间件链执行顺序 ```text 请求 GET /api/user/page Authorization: Bearer eyJ... │ ▼ ┌─────────────────────────────────────────────────────────┐ │ ProxyMiddleware(全局 api 组) │ │ 受信代理校验 → 还原真实 REMOTE_ADDR │ └─────────────────────────┬───────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────┐ │ Cors(全局 api 组) │ │ OPTIONS 预检 → 204 + CORS 头;普通请求 → 追加 CORS 头 │ └─────────────────────────┬───────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────┐ │ ForceJsonResponse(全局 api 组) │ │ Accept 强制 application/json │ └─────────────────────────┬───────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────┐ │ Authenticate(auth.jwt,路由组) │ │ │ │ 1. 提取 Bearer token(公开接口在组外,不经过此处) │ │ │ 缺失 → HTTP 200 {"code":401, "msg":"未提供认证令牌"}│ │ 2. JwtService::parseToken()(type 必须为 access) │ │ │ 过期/签名错 → HTTP 200 │ │ 3. 注入 attributes['userInfo'] │ │ 4. 反射 #[Permission] 注解 → checkPermission() │ │ │ uid=1 跳过 → Redis perms:{uid} → Cache → 查库 │ │ │ 无权限 → HTTP 403 {"code":403, "msg":"无访问权限…"} │ └─────────────────────────┬───────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────┐ │ ResolveTenant(tenant,路由组) │ │ │ │ 1. 无 userInfo → 放行(未认证请求不注入租户) │ │ 2. uid=1 → tenantId=0, isSuperAdmin=true │ │ 3. 非超管 → 查库 tenant_id │ │ │ 用户不存在 → HTTP 200 │ │ │ 租户不存在/禁用/过期 → HTTP 403 │ │ 4. 注入 attributes['tenantId' / 'tenantInfo'] │ └─────────────────────────┬───────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────┐ │ LogOperation(log.operation,路由组) │ │ handle:收集上下文、读 #[OperationLog]、判断是否记录 │ │ terminate:响应后写入操作日志(不阻塞业务) │ └─────────────────────────┬───────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────┐ │ UserController::page() │ │ return $this->_page($this->service); │ │ → Service::pageList() → Model → Database │ └─────────────────────────┬───────────────────────────────┘ ▼ JSON 响应返回 ``` (各中间件源码级解析见 [4.2 请求处理链路](/zh/architecture/request-flow.html)) ### 权限校验详解 ```text Authenticate::checkPermission($uid, $permissionCode) │ ▼ 1. $uid === 1(超级管理员) │ 是 → return true(跳过校验) ▼ 2. Redis::get("perms:{$uid}") ← 一级缓存 │ 命中 → in_array('*:*:*') 或 in_array(code) → 通过 │ Redis 不可用 → 降级 ▼ 3. Cache::remember("perms_fallback:{$uid}", 300) ← 二级缓存(5 分钟) │ 命中 → 匹配判断 ▼ 4. loadPermissionsFromDb($uid) ← 三级:查库并回填缓存 UserRole(用户角色)→ RoleMenu join Menu → 条件:menu 未删除、type=1、permission 非空 │ in_array($permissionCode, $permissions) │ 包含 → return true(放行) │ 不包含 → return false(HTTP 403) ``` **权限码格式:** `sys:{模块名}:{操作}` ```text sys:user:add 添加用户 sys:user:update 修改用户 sys:user:delete 删除用户 sys:user:page 用户分页 sys:article:add 添加文章 sys:article:detail 文章详情 ``` 菜单/角色菜单数据变更时 `MenuObserver` / `RoleMenuObserver` 自动清除相关缓存,权限调整即时生效。 ## 三、Token 刷新流程 ### 流程图 ```text 客户端持有 refresh_token,需要换取新令牌对 (第三方接入调用;管理后台前端当前未内置自动续签,见 8.6) │ ▼ ┌─────────────────────────────────────────────────────────┐ │ 客户端请求 │ │ POST /api/refresh { refresh_token: "..." } │ └─────────────────────────┬───────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────┐ │ AuthController::refresh() │ │ 1. 读取 refresh_token(缺失 → fail("缺少refresh_token")│ │ 2. JwtService::refreshToken($refreshToken) │ └─────────────────────────┬───────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────┐ │ JwtService::refreshToken() │ │ 1. 解析令牌 → 失败 → BizException("无效的refresh_token") │ │ 2. type === 'refresh' → 否则同上 │ │ 3. Lua 原子脚本:GETDEL refresh:{uid}:{jti} │ │ │ 不存在(已用过/已吊销)→ "refresh_token已失效" │ │ 4. User 状态校验 → "用户不存在或已被禁用" │ │ 5. createTokenPair() → 新 jti 签发新令牌对写入 Redis │ └─────────────────────────┬───────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────┐ │ 客户端处理结果 │ │ 1. 保存返回的新 access_token / refresh_token(均为新值) │ │ 2. 后续请求携带新 access_token │ └─────────────────────────────────────────────────────────┘ ``` ### 刷新接口详情 **请求(主入口):** ```bash POST /api/refresh Content-Type: application/json { "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." } ``` **成功响应:** ```json { "code": 0, "ok": true, "msg": "刷新成功", "data": { "access_token": "eyJ...(新access_token)", "refresh_token": "eyJ...(新refresh_token)", "token_type": "Bearer", "expires_in": 7200 } } ``` **失败响应:** | 场景 | code | msg | |------|------|-----| | 缺少 refresh_token | 1 | 缺少refresh_token | | refresh_token 无效/类型错 | 1 | 无效的refresh_token | | 旧令牌已用过(重放) | 1 | refresh_token已失效 | | 用户不存在/禁用 | 1 | 用户不存在或已被禁用 | :::warning 一次性刷新(防重放) 旧 refresh_token 刷新后立即从 Redis 删除(Lua 原子操作),重放旧令牌会返回"refresh_token已失效"。客户端必须保存返回的**新** refresh_token,否则下次无法续签。 ::: ## 四、完整请求示例 以"查询用户分页"为例,展示一次认证请求的完整链路: ```text ① 前端发起请求 GET /api/user/page?pageNo=1&pageSize=20 Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... │ ▼ ② ProxyMiddleware / Cors / ForceJsonResponse → 依次放行 │ ▼ ③ Authenticate │ 提取 token → parseToken() → { uid:1, username:"admin", type:"access", jti, exp } │ type === 'access' ✓ │ 注入 attributes['userInfo'] │ checkPermission(1, 'sys:user:page') → uid=1 超管跳过 ✓ │ ▼ ④ ResolveTenant → uid=1 → tenantId=0, isSuperAdmin=true │ ▼ ⑤ LogOperation → GET 普通查询不记录(非导入导出类型),放行 │ ▼ ⑥ UserController::page() │ return $this->_page($this->service) │ ▼ ⑦ UserService::pageList($params) │ buildQuery:LIKE/精确匹配 + 软删除全局范围 │ → SELECT ... FROM laravel_user WHERE is_delete = 0 ... │ ▼ ⑧ 返回 JSON(驼峰转换后) { "code": 0, "ok": true, "msg": "操作成功", "data": { "records": [...], "total": 42, "current": 1, "size": 20, "pages": 3 } } ``` ## 五、前端 401 处理(实际实现) `ui/` 管理后台当前**未内置自动续签**:access_token 过期(code=401)时直接提示重新登录(摘自 `ui/src/utils/http/axios/index.ts` 实际代码): ```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; ``` 自动续签所需的后端能力已就绪([三、Token 刷新流程](#三-token-刷新流程) 的 `POST /api/refresh`),后续前端升级时可在此处接入刷新与请求重试逻辑。 ## 六、登出与 OAuth2 兼容接口 ### 登出(令牌即时失效) ```bash # 携带 access_token,吊销对应 refresh_token POST /api/logout Authorization: Bearer ``` `JwtService::revokeToken()` 解析 `uid + jti` 后删除 Redis 中的 `refresh:{uid}:{jti}`——旧 access_token 到期后将无法续签。改密、封号等场景调用 `revokeAllTokens($userId)` 一次性吊销该用户**全部**令牌并清除权限缓存(踢下线)。 ### OAuth2 兼容接口 系统同时提供 OAuth2 风格的 Token 接口,兼容标准 OAuth2 客户端: | 接口 | 方法 | 认证 | 说明 | |------|------|------|------| | `/api/oauth2/token` | POST | 公开 | 获取令牌(grant_type=password 或 refresh_token) | | `/api/oauth2/check/token` | GET | 公开 | 检测令牌有效性 | | `/api/oauth2/remove/{token}` | DELETE | 认证组 | 吊销令牌 | #### 密码模式获取令牌 ```bash POST /api/oauth2/token Content-Type: application/x-www-form-urlencoded grant_type=password&username=admin&password=123456 ``` #### 刷新模式获取令牌 ```bash POST /api/oauth2/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token&refresh_token=eyJ... ```