签入版本

This commit is contained in:
小蚂蚁云
2026-10-25 17:24:27 +08:00
parent 48911cc08f
commit fdbeae092a
1010 changed files with 30682 additions and 95274 deletions
+50 -464
View File
@@ -1,488 +1,74 @@
# 5.2 认证流程实战
# 5.2 认证与授权流程
:::tip 概述
本节通过完整的请求链路,展示从登录到接口调用的认证全流程,包括登录、Token 刷新、接口认证、权限校验、租户隔离五个核心环节。
登录是项目里最完整的业务链路:验证码 → 定位用户 → 状态校验 → 密码比对 → 签发双令牌 → 登计日志与在线会话。本页拆解每个环节与授权侧的角色权限装配。
:::
## 认证全景图
## 登录七步(service.Auth.Login)
```text
┌─────────────────────────────────────────────────────────────────────┐
│ 认证授权全景 │
│ │
│ ① 登录 POST /api/login │
│ 用户名+密码+验证码 → 校验 → 签发 access_token + refresh_token │
│ │
│ ② 请求 API GET/POST /api/xxx │
│ Bearer token → AuthMiddleware → 权限校验 → 注入 userInfo │
│ │
│ ③ Token 过期 响应 code=401 │
│ refresh_token → POST /api/oauth2/token → 新令牌对 │
│ │
│ ④ 租户隔离 TenantMiddleware │
│ 解析租户 → 注入 tenantId → Logic 层自动过滤 │
│ │
│ ⑤ 登出 前端清除 localStorage │
│ (JWT 无状态,服务端无需处理) │
└─────────────────────────────────────────────────────────────────────┘
1. 图形验证码 提供 key 时强制校验,一码一用(防重放爆破);未提供 key 跳过
2. 定位用户 用户名在租户内唯一;req.TenantCode 留空时全局定位(优先平台租户)
3. 账号状态 status 非 1 一律视为禁用
4. 租户校验 租户摘要(缓存 60 秒):禁用拒绝、过期拒绝;tenantID=0 平台级直通
5. 密码校验 外部 salt + bcrypt(password+salt);salt 为空直接比对
6. 签发双令牌 access/refresh 各带独立 jti
7. 登记 登录日志(gin_login_log)+ 在线会话缓存(允许多端并存)
```
## 一、登录流程
任一环节失败返回对应业务错误码(CodeCaptchaError / CodeUserNotFound / CodeTenantNotFound / CodeUserDisabled / CodeTenantDisabled / CodeTenantExpired / CodePasswordWrong),除令牌签发失败直接上抛外,其余失败均写 status=1 的失败日志。
### 完整流程图
登录链路**无事务**:各环节是独立读/写,失败不产生半状态。
## 中间件接线
auth 模块在 init 中完成两处接线,供中间件回调:
```go
middleware.SetIdentityLoader(...) // JWT 中间件拉取角色/权限(缓存 300 秒)
middleware.SetTenantLoader(...) // 租户中间件校验状态/过期(摘要缓存 60 秒)
```
未接线由 WireCheck 告警,中间件降级(身份扩展为空 / 租户校验跳过)。
## 授权:从菜单到接口
```text
POST /api/login
{ "username": "admin", "password": "123456", "code": "a3Bx", "key": "xxx" }
│
▼
┌─────────────────────────────────────────────────────────┐
│ LoginController::login() │
│ │
│ 1. getParams() 获取请求参数 │
│ 2. validate($params, LoginValidate::class . '.login') │
│ │ 失败 → {"code":422, "msg":"参数验证失败"} │
│ ▼ │
│ 3. LoginLogic::login($username, $password, $code, $key) │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ LoginLogic::login() │
│ │
│ 1. CaptchaService::check($code, $key) │
│ │ 失败 → throw "验证码错误" │
│ ▼ │
│ 2. JwtService::login($username, $password) │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ JwtService::login() │
│ │
│ 1. User::withoutGlobalScope(['soft_delete']) │
│ ->where('username', $username)->find() │
│ │ 不存在 → throw "用户名或密码错误" │
│ ▼ │
│ 2. $user->status != 1 │
│ │ 禁用 → throw "账号已被禁用" │
│ ▼ │
│ 3. PasswordService::verify($password, $hashed, $salt) │
│ │ 不匹配 → throw "用户名或密码错误" │
│ ▼ │
│ 4. Jwt::createAccessToken($uid, $username) │
│ Jwt::createRefreshToken($uid, $username) │
│ ▼ │
│ 5. 返回 { access_token, refresh_token, user } │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ LoginController 续 │
│ │
│ 成功 → LoginLogic::recordLoginLog(成功) │
│ → $this->success($result, '登录成功', true) │
│ │
│ 失败 → LoginLogic::recordLoginLog(失败) │
│ → $this->fail($e->getMessage()) │
└─────────────────────────────────────────────────────────┘
gin_menu(permission 节点)──勾选──▶ gin_role_menu
gin_role(data_scope 档位)──▶ gin_user_role ──▶ 用户
│
▼ LoadIdentity(登录后装配,缓存 300 秒)
角色编码集合 + 权限节点集合 + 数据范围(多角色取最宽松)
│
▼ 请求期
Authz:HasPerm(路由 Permission) → 超管直通 / 集合命中放行
DataScope:按档位在 applyFilters 链追加行级条件
```
### 登录接口详情
**请求:**
```bash
POST /api/login
Content-Type: application/json
{
"username": "admin",
"password": "123456",
"code": "a3Bx",
"key": "captcha_xxx"
}
```
**成功响应:**
```json
{
"code": 0,
"ok": true,
"msg": "登录成功",
"data": {
"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"tokenType": "Bearer",
"expiresIn": 7200,
"user": {
"id": 1,
"username": "admin",
"realname": "管理员",
"avatar": "/uploads/user/avatar/avatar.jpg"
}
}
}
```
**失败响应:**
| 场景 | code | msg |
|------|------|-----|
| 验证码错误 | 1 | 验证码错误 |
| 用户不存在 | 1 | 用户名或密码错误 |
| 密码错误 | 1 | 用户名或密码错误 |
| 账号禁用 | 1 | 账号已被禁用 |
| 参数缺失 | 422 | 用户名不能为空 / 密码不能为空 |
## 二、接口认证流程
### 中间件链执行顺序
## 令牌刷新(POST /api/oauth2/token,公开接口)
```text
请求 GET /api/user/page
Authorization: Bearer eyJ...
│
▼
┌─────────────────────────────────────────────────────────┐
│ CorsMiddleware(全局) │
│ OPTIONS 预检 → 返回 204 + CORS 头 │
│ 普通请求 → 设置跨域头,放行 │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ AuthMiddleware │
│ │
│ 1. 检查排除列表(login/captcha/oauth2 → 直接放行) │
│ 2. Jwt::getTokenFromHeader() │
│ │ 空 → {"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] 注解 │
│ │ uid=1 → 超级管理员,跳过校验 │
│ │ 查询用户权限列表 → 匹配权限码 │
│ │ 无权限 → {"code":403, "msg":"无访问权限"} │
│ ▼ │
│ 7. 放行 │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ TenantMiddleware │
│ │
│ 1. 从 JWT payload 获取 uid │
│ 2. 查询用户所属租户 │
│ 3. $request->tenantId = 租户ID │
│ 4. $request->tenantInfo = 租户信息 │
│ │ 租户禁用/过期 → {"code":403, "msg":"租户异常"} │
│ ▼ │
│ 5. 放行 │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ DemoMiddleware │
│ │
│ 1. 检查 DEMO 配置(env('app_demo')) │
│ 2. 非演示模式 → 放行 │
│ 3. 演示模式 + 写操作(POST/PUT/DELETE) │
│ │ 未标注 #[DemoAllow] → {"code":403, "msg":"演示环境"} │
│ ▼ │
│ 4. 放行 │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ LogMiddleware │
│ │
│ 1. 读取 #[Log] 注解 │
│ 2. 记录操作日志(异步/同步) │
│ 3. 放行 │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ UserController::page() │
│ │
│ return parent::_index($this->logic); │
│ → Logic::pageList() → Model → Database │
└─────────────────────────┬───────────────────────────────┘
│
▼
JSON 响应返回
携带 refresh_token 兑换 → 校验类型与黑名单 → 签发新令牌对
旧 refresh 拉黑(防重放)→ 返回新 access/refresh
```
### 权限校验详解
## 强制下线(DELETE /api/oauth2/remove/:token)
```text
AuthMiddleware::checkPermission($uid, $permissionCode)
│
▼
1. $uid === 1(超级管理员)
│ 是 → return true(跳过校验)
▼
2. UserRole::where('user_id', $uid)->column('role_id')
│ 无角色 → return false
▼
3. RoleMenu → Menu::whereIn('role_id', $roleIds)
.where('type', 1) // type=1 为权限节点
.column('permission')
▼
4. in_array($permissionCode, $permissions)
│ 包含 → return true(放行)
│ 不包含 → return false(返回 403)
```
管理员在在线用户页对指定会话强制下线:拉黑该会话 access + refresh、移除在线登记。受保护接口,需要相应权限节点。
**权限码格式:** `sys:{模块名}:{操作}`
## 登出(GET /api/logout)
```text
sys:user:add 添加用户
sys:user:update 修改用户
sys:user:delete 删除用户
sys:user:page 用户分页
sys:article:add 添加文章
sys:article:list 文章列表
```
按在线登记复用同一拉黑逻辑(同时拉黑双令牌),移除在线登记并写登出日志。上下文缺 jti 时仅记日志,不视为错误。
## 三、Token 刷新流程
## 排查建议
### 流程图
| 现象 | 检查 |
|---|---|
| 登录一直验证码错误 | 验证码 300 秒过期且一码一用;开发可配 bypassCode |
| Token 合法但 401 | 是否在黑名单(刚登出/被强退);jti 缓存键 |
| 有 Token 但接口 403 | 角色是否授权该权限码(见 [5.4 权限管理](/zh/core/rbac.html)) |
| 新授权不生效 | 权限缓存 300 秒,重新登录立即生效 |
```text
前端检测到 code=401
│
▼
┌─────────────────────────────────────────────────────────┐
│ 前端响应拦截器 │
│ │
│ 1. 从 localStorage 读取 refresh_token │
│ 2. POST /api/oauth2/token │
│ { grant_type: "refresh_token", refresh_token: "..." } │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ LoginController::token() │
│ │
│ 1. 判断 grant_type === 'refresh_token' │
│ 2. LoginLogic::refreshToken($refreshToken) │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ JwtService::refreshToken() │
│ │
│ 1. Jwt::decode($refreshToken) │
│ │ 无效 → throw "无效的refresh_token" │
│ ▼ │
│ 2. $decoded->type === 'refresh' │
│ │ 非 refresh → throw "无效的refresh_token" │
│ ▼ │
│ 3. User::find($decoded->uid) │
│ │ 不存在或禁用 → throw "用户不存在或已被禁用" │
│ ▼ │
│ 4. 签发新 access_token + 新 refresh_token │
│ ▼ │
│ 5. 返回新令牌对 │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 前端响应拦截器(续) │
│ │
│ 1. 更新 localStorage 中的 access_token │
│ 2. 更新 localStorage 中的 refresh_token │
│ 3. 用新 access_token 重试原失败请求 │
└─────────────────────────────────────────────────────────┘
```
## 下一步
### 刷新接口详情
**请求:**
```bash
POST /api/oauth2/token
Content-Type: application/json
{
"grant_type": "refresh_token",
"refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
}
```
**成功响应:**
```json
{
"code": 0,
"ok": true,
"msg": "success",
"data": {
"accessToken": "eyJ...(新access_token)",
"refreshToken": "eyJ...(新refresh_token)",
"tokenType": "Bearer",
"expiresIn": 7200
}
}
```
**失败响应:**
| 场景 | code | msg |
|------|------|-----|
| 缺少 refresh_token | 1 | 缺少refresh_token参数 |
| refresh_token 无效/过期 | 1 | 无效的refresh_token |
| 用户不存在/禁用 | 1 | 用户不存在或已被禁用 |
## 四、完整请求示例
以"查询用户分页"为例,展示一次认证请求的完整链路:
```text
① 前端发起请求
GET /api/user/page?pageNo=1&pageSize=20
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
│
▼
② CorsMiddleware → 设置跨域头,放行
│
▼
③ AuthMiddleware
│ getTokenFromHeader() → "eyJ..."
│ Jwt::decode() → { uid:1, username:"admin", type:"access", exp:... }
│ type === 'access' ✓
│ $request->userInfo = $decoded
│ checkPermission(1, 'sys:user:page')
│ uid=1 → 超级管理员,跳过
▼
④ TenantMiddleware
│ 查询用户租户 → tenantId=1
│ $request->tenantId = 1
▼
⑤ DemoMiddleware → 非演示模式,放行
│
▼
⑥ LogMiddleware → GET 查询不记录日志,放行
│
▼
⑦ UserController::page()
│ return parent::_index($this->logic)
▼
⑧ UserLogic::pageList($params)
│ applyTenantScope() → WHERE tenant_id = 1
│ applyDataScope() → 按角色 data_scope 过滤
│ → SELECT * FROM think_user WHERE is_delete=0 AND tenant_id=1 ...
▼
⑨ 返回 JSON
{
"code": 0,
"ok": true,
"msg": "操作成功",
"data": {
"records": [...],
"total": 42,
"size": 20,
"current": 1,
"pages": 3
}
}
```
## 五、前端自动刷新 Token 实现
```typescript
// src/utils/http/axios/index.ts
let isRefreshing = false;
let pendingRequests: Function[] = [];
axios.interceptors.response.use(
response => response,
async error => {
const { config, response } = error;
// 非 401 错误直接抛出
if (response?.status !== 401) {
return Promise.reject(error);
}
// 已在刷新中,将请求加入队列等待
if (isRefreshing) {
return new Promise(resolve => {
pendingRequests.push((newToken: string) => {
config.headers['Authorization'] = `Bearer ${newToken}`;
resolve(axios(config));
});
});
}
isRefreshing = true;
config._retry = true;
try {
const refreshToken = localStorage.getItem('refresh_token');
const res = await refreshTokenApi(refreshToken);
const { accessToken, refreshToken: newRefreshToken } = res.data;
// 更新存储
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', newRefreshToken);
// 重试当前请求
config.headers['Authorization'] = `Bearer ${accessToken}`;
// 执行队列中的等待请求
pendingRequests.forEach(cb => cb(accessToken));
pendingRequests = [];
return axios(config);
} catch (e) {
// 刷新失败,清除登录态,跳转登录页
localStorage.clear();
window.location.href = '/login';
return Promise.reject(e);
} finally {
isRefreshing = false;
}
}
);
```
## 六、OAuth2 兼容接口
系统同时提供 OAuth2 风格的 Token 接口,兼容标准 OAuth2 客户端:
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/oauth2/token` | POST | 获取令牌(grant_type=password 或 refresh_token) |
| `/api/oauth2/remove/{token}` | DELETE | 吊销令牌 |
| `/api/oauth2/check/token` | GET | 检测令牌有效性 |
### 密码模式获取令牌
```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...
```
→ [5.3 验证码机制](/zh/core/captcha.html)