dcd6d44960
- Added new documentation for the Feishu user authorization flow, detailing the backend protocol, authorization initiation, callback handling, and token management. - Updated frontend index to link to the new authorization flow documentation for better accessibility and guidance on UI design consistency.
183 lines
9.9 KiB
Markdown
183 lines
9.9 KiB
Markdown
|
||
后端提供**协议**,前端提供**组件**。任何用户态数据接口(DataApi)在令牌缺失时返回统一错误码 `FS_AUTH_REQUIRED`,前端拦截器识别后驱动授权流程,授权完成自动重放原请求。业务方感知为零。
|
||
|
||
**授权发起方式变更**:废弃「后端发送飞书卡片消息引导授权」的交互(`POST /fs/auth/sendAuthCard` 不再用于本流程)。改为后端提供纯数据接口「准备授权」,前端拿到授权 URL 后自行决定打开方式与 UI 呈现。IM 卡片仅保留为后台任务(如公共用户令牌失效)的兜底通知手段。
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as 用户
|
||
participant FE as 前端组件
|
||
participant GW as appcenter 网关
|
||
participant FS as 飞书开放平台
|
||
|
||
U->>FE: 触发功能(如搜索云文档)
|
||
FE->>GW: POST /fs/docs/search
|
||
GW->>GW: mainUserId -> openId -> Redis 取 user_access_token
|
||
alt 令牌缺失/失效
|
||
GW-->>FE: code=FS_AUTH_REQUIRED { corpNo, appId, scopes }
|
||
FE->>FE: 弹授权引导层(用户点击触发,防浏览器拦截)
|
||
FE->>GW: POST /fs/auth/prepare { corpNo, appId, scopes }
|
||
GW->>GW: 生成 authId,授权会话预存 Redis(绑定 mainUserId)
|
||
GW-->>FE: { authId, authUrl }(state=authId)
|
||
FE->>FS: window.open / applink 打开授权页
|
||
U->>FS: 点击授权
|
||
FS->>GW: 回调 /anonymous/fs/auth/callback.cl?code&state
|
||
GW->>FS: code 换 token(authen/v2/oauth/token)
|
||
GW->>GW: 缓存 per-user token(+refresh_token) + 授权结果
|
||
GW-->>FS: 返回自动 window.close() 页面
|
||
loop 轮询(2s 间隔,120s 超时)
|
||
FE->>GW: GET /fs/auth/authResult?messageId=authId
|
||
end
|
||
GW-->>FE: FSUserInfoCO(授权成功)
|
||
end
|
||
FE->>GW: 重放 POST /fs/docs/search
|
||
GW-->>FE: 搜索结果
|
||
```
|
||
|
||
## 2. 后端协议设计
|
||
|
||
### 2.1 统一未授权信号(改造点 A)
|
||
|
||
新增响应码 `FS_AUTH_REQUIRED`(`AppCenterResponseCode`)。所有用户态 DataApi 在取不到有效 `user_access_token` 时返回:
|
||
|
||
```json
|
||
{
|
||
"success": false,
|
||
"code": "FS_AUTH_REQUIRED",
|
||
"message": "需要飞书授权",
|
||
"data": {
|
||
"corpNo": "FEBG3X19R5J",
|
||
"appId": "cli_xxx",
|
||
"scopes": ["search:docs:read", "drive:drive:readonly"]
|
||
}
|
||
}
|
||
```
|
||
|
||
- 此响应只携带授权上下文;`authId` 由前端随后调用 `/fs/auth/prepare`(见 2.2)时生成,充当授权流程的 `state` 与结果查询的 `messageId`。
|
||
- 落地方式:在 `BaseFeishuController` 增加 `requireUserToken(corpNo, appId, openId, scopes)` 帮助方法,DataApi 统一调用,避免每个 Controller 重复判断。
|
||
- 首个改造对象:`FSDocsController.search`(顺带落实 `FSDocSearchQry.corpNo` 的 fixme,由后端根据绑定租户推导)。
|
||
|
||
### 2.2 授权发起接口(新增,替代卡片模式)
|
||
|
||
新增 `POST /fs/auth/prepare`(需登录态),**一步完成**「创建授权会话 + 构造授权 URL」,前端无需再分别调两个接口:
|
||
|
||
请求体:
|
||
|
||
```json
|
||
{
|
||
"corpNo": "FEBG3X19R5J",
|
||
"appId": "cli_xxx",
|
||
"scopes": ["search:docs:read", "drive:drive:readonly"]
|
||
}
|
||
```
|
||
|
||
响应 `data`:
|
||
|
||
```json
|
||
{
|
||
"authId": "uuid",
|
||
"authUrl": "https://accounts.feishu.cn/open-apis/authen/v1/authorize?...&state=<authId>",
|
||
"expireSeconds": 600
|
||
}
|
||
```
|
||
|
||
服务端内部逻辑:
|
||
|
||
1. 从登录态取 `mainUserId`,生成 `authId`(UUID);
|
||
2. 授权会话预存 Redis:`COMMON_FS_SELF_BUILT_APP_AUTH_CARD + authId` -> `{ corpNo, appId, businessId=authId, mainUserId, scopes }`,TTL 10 分钟;
|
||
3. scope 拼接:`业务 scopes + offline_access`(为 refresh_token 做准备),空格分隔;
|
||
4. `redirectUri` 固定为 `{gatewayHost}/appcenter/anonymous/fs/auth/callback.cl`(必须已在开发者后台「重定向 URL」白名单中,否则换 token 时报 20071),复用 `FSService.oauth2buildAuthorizationUrl` 构造 URL。
|
||
|
||
说明:
|
||
|
||
- 前端可以在收到 `FS_AUTH_REQUIRED` 后带着响应里的上下文直接调本接口;也可以在进入功能页时**预检**(可选调用,scopes 由前端声明),提前完成授权再使用功能。
|
||
- 旧的 `POST /fs/auth/sendAuthCard` 与 `POST /anonymous/fs/auth/authorizationUrl` 保留不动(兼容存量调用方),新流程不使用。
|
||
|
||
### 2.3 授权回调(复用现有 callback.cl,改造点 B)
|
||
|
||
现有 `FSBridgeController.callback` 已完成:state 取会话 -> `fsService.userInfo()` 换 token -> 写 `AUTH_CARD_RESULT + businessId` -> 返回自闭合页面。需要补两点:
|
||
|
||
1. **回写 per-user token 缓存**:callback 拿到 `FSUserInfoCO` 后,调用新增的 token 存储(见 2.4),把 `access_token + refresh_token + expires_in` 持久化。这是当前链路缺失的一环。
|
||
2. **会话校验**:比对会话中的 `mainUserId` 与授权返回的用户是否一致(防授权串号),不一致则结果标记失败。
|
||
|
||
### 2.4 per-user 令牌存储与刷新(改造点 C,核心新增)
|
||
|
||
现状 `FSService.setUserAccessToken` 只存 token 字符串,无法支撑刷新。设计:
|
||
|
||
- **缓存结构升级**:key 不变(`COMMON_FS_SELF_BUILT_APP_USER_ACCESS_TOKEN + corpNo:appId:openId`),value 从 `String` 升级为对象 `FSUserTokenCO{ accessToken, refreshToken, expiresIn, refreshTokenExpiresIn }`。读侧做兼容(旧值为 String 时视为仅 accessToken)。
|
||
- **新增 `FSUserAuthService`**(或扩展 `FSService`)提供 `getValidUserAccessToken(corpNo, appId, openId)`:
|
||
1. 缓存命中且剩余有效期 >= 360s -> 直接返回 accessToken;
|
||
2. 临期且有 refresh_token -> 调 `/open-apis/authen/v2/oauth/token`(grant_type=refresh_token)刷新后回写(refresh_token 一次性,必须整体覆盖缓存);
|
||
3. 刷新失败且错误码为 20037/20064/20073(refresh_token 失效)-> 清缓存,返回 null,由 DataApi 抛 `FS_AUTH_REQUIRED` 引导重新授权。
|
||
- 刷新逻辑从 `FSCommonUserAuthService.refreshUserAccessToken` 抽取共用方法,公共用户与 per-user 两条线复用同一段换 token 代码。
|
||
|
||
### 2.5 授权结果查询(复用)
|
||
|
||
复用 `GET /fs/auth/authResult?messageId=authId`。建议响应增加失败态(当前只有 null/有值两种),`FSUserInfoCO` 增加可选 `error` 字段,前端据此区分「还在等」与「授权失败」。
|
||
|
||
## 3. Redis key 一览
|
||
|
||
| Key | Value | TTL |
|
||
|-----|-------|-----|
|
||
| `COMMON_FS_SELF_BUILT_APP_AUTH_CARD + authId` | 授权会话 { corpNo, appId, mainUserId, businessId, scopes } | 600s |
|
||
| `COMMON_FS_SELF_BUILT_APP_AUTH_CARD_RESULT + authId` | `FSUserInfoCO`(含 error 可选) | 600s |
|
||
| `COMMON_FS_SELF_BUILT_APP_USER_ACCESS_TOKEN + corpNo:appId:openId` | `FSUserTokenCO` | expiresIn(用 Redis TTL 表达) |
|
||
|
||
## 4. 前端组件行为规格
|
||
|
||
> 本仓库不含前端代码,以下为前端实现须遵守的规格(接口协议 + 状态机)。
|
||
|
||
### 4.1 封装入口
|
||
|
||
提供一个高阶封装(如 axios 响应拦截器或 `callFsUserApi(fn)`):
|
||
|
||
1. 调用 DataApi;
|
||
2. 响应 `code === 'FS_AUTH_REQUIRED'` -> 进入授权流程(4.2),成功后**自动重放**原请求(最多重放 1 次,防循环);
|
||
3. 其他错误原样抛出。
|
||
|
||
### 4.2 授权流程状态机
|
||
|
||
```
|
||
idle -> preparing(调 /fs/auth/prepare) -> authorizing(打开授权窗口 + 轮询) -> success -> 重放原请求
|
||
\-> failed(超时120s / 用户关窗 / 结果含 error / prepare 失败) -> 提示重试
|
||
```
|
||
|
||
- **环境判断**:飞书客户端内(`window.h5sdk` 存在)用 applink `mode=sidebar-semi` 打开;浏览器用 `window.open(url, '_blank', 'width=600,height=700')`。
|
||
- **去重**:同一 `appId + scopes` 已有进行中的授权流程时,复用该流程的 Promise,不重复调 prepare、不重复开窗。
|
||
- **轮询**:每 2s 调 `/fs/auth/authResult?messageId=authId`,拿到 `FSUserInfoCO` 即成功;120s 超时判失败。
|
||
- **成功展示**:可用返回的 `name`/`avatar` 提示「已授权:张三」。
|
||
- **UI 自主**:授权引导层(文案、按钮、弹窗样式)完全由前端实现,后端只提供 prepare/authResult 两个数据接口,不发卡片、不推消息。
|
||
|
||
### 4.3 使用方契约
|
||
|
||
```ts
|
||
// 伪代码,前端仓库实现
|
||
const result = await callFsUserApi(() =>
|
||
post('/appcenter/fs/docs/search', { searchKey, count: 20, offset: 0 })
|
||
);
|
||
// 未授权时自动弹授权,授权后自动返回搜索结果
|
||
```
|
||
|
||
## 5. 边界与风险
|
||
|
||
| 风险 | 对策 |
|
||
|------|------|
|
||
| 授权码 5 分钟过期、一次性 | callback 立即换 token;20003/20004/20065 记日志并在结果中标记失败 |
|
||
| redirect_uri 不一致(20071) | 构造 URL 与 callback 换 token 使用同一 `gatewayHost + 固定路径`,不从前端传 |
|
||
| refresh_token 一次性 | 刷新成功后必须整体覆盖缓存;刷新与读取加用户级锁(Redis 分布式锁,key 含 openId)防并发刷新互相覆盖 |
|
||
| 用户更换飞书账号授权 | 会话绑定 mainUserId,callback 校验 openId 与 UC 绑定关系,不一致判失败 |
|
||
| 弹窗被浏览器拦截 | 授权引导层用「点击按钮打开」而非自动 window.open |
|
||
| scope 裁剪 | 以 token 接口返回的 `scope` 字段为准,授权结果中落库/缓存实际授予范围 |
|
||
|
||
## 6. 落地拆分建议(实现阶段子任务)
|
||
|
||
1. **后端-令牌层**:`FSUserTokenCO` 缓存结构 + 刷新共用抽取 + `getValidUserAccessToken`。
|
||
2. **后端-协议层**:`FS_AUTH_REQUIRED` + 新增 `POST /fs/auth/prepare`(authId 会话预存 + 授权 URL)+ callback 回写 per-user 缓存与校验 + authResult 失败态。
|
||
3. **后端-首个 DataApi 改造**:`FSDocsController.search` 接入新协议(含 corpNo fixme)。
|
||
4. **前端组件**:拦截器 + 授权引导 UI + 轮询重放(前端仓库实施)。
|
||
|
||
## 7. 验证方式
|
||
|
||
- 单测:令牌刷新分支(命中/临期刷新/refresh 失效);callback 串号校验。
|
||
- 联调:UAT 环境用真实飞书账号走通「未授权 -> 弹窗授权 -> 自动重放搜索 -> 返回文档列表」全链路;构造 refresh_token 过期场景验证二次授权引导。
|