Files
sso-portal/ADAPTATION_GUIDE.md
zhangheng 37f10118d3 chore: initial commit of sso-mock SSO login service
Next.js + Mantine mock SSO service providing account/password, phone
verify-code, and Feishu QR login. Issues a one-time sso_code that the
external app exchanges for user info.

Includes:
- Feishu callback fix: guard against duplicate/concurrent /api/login
  submissions of the same single-use authorization code (feishuLogin
  once-guard + memoized onLoginSuccess to stop effect re-fire).
- ADAPTATION_GUIDE.md: how to switch from the in-memory code store to
  the real backend auth/index + inner_get_user_info endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 18:09:30 +08:00

175 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SSO-Mock → 真实后端 适配指南
> 目标:当后端提供真实 SSO 接口后,把 sso-mock 从「自己伪造一次性 code」切换为「纯透传真实后端」。
> 参考实现:生产前端 `ssofront``src/apis/login/index.ts`)已经在用这套契约,本指南以它为准。
---
## 1. 结论:需要几个接口?
核心是 **2 个**(你已想到),完全对齐生产还需 **1 个辅助 + 2 个已有代理**
| # | 用途 | 后端接口(生产实测) | 必须 | 备注 |
|---|------|----------------------|------|------|
| ① | 登录 → 返回一次性 code | `POST /api/v1/auth/index` | ✅ | **单端点**,用 `login_type` 区分账号/手机/飞书 |
| ② | 一次性 code → 用户信息 | `GET /api/v1/auth/inner_get_user_info?code=` | ✅ | 供 external-app 调用 |
| ③ | app_id → 应用登录方式 | `GET /api/v1/application/by_client_id?client_id=` | ⭕ | 决定开放哪些登录 tab + 应用名;前端写死可省 |
| ④ | 发送手机验证码 | `POST /api/v1/verify_code/send` | ⭕ | 手机登录才需要sso-mock 现已代理为 basis 版) |
| ⑤ | 租户简称列表 | `GET /api/v1/basis/tenant/base/listabbr` | ⭕ | 多租户选择(现已代理) |
> **一句话**:后端把 ①② 做出来sso-mock 就能删掉内存 `codeStore` 与假 `/api/auth/verify`,变成纯代理。③ 是"完全对齐生产"的锦上添花。
---
## 2. 接口契约(字段级)
### ① 登录换 code — `POST /api/v1/auth/index`
请求体(三种方式共用,按 `login_type` 带不同字段):
```jsonc
{
"login_type": "password | phone | feishu", // 判别登录方式
"client_id": "<app_id>", // 外部应用标识
"client_redirect_uri": "<app_url>", // 外部应用回调地址
// login_type=password
"phone": "+86138...", "password": "***",
// login_type=phone
"phone": "+86138...", "verify_code": "123456",
// login_type=feishu
"code": "<飞书授权码>", "redirect_uri": "<sso-mock/login 回调>"
}
```
响应:
```json
{ "code": "一次性 sso_code短时效、单次使用" }
```
### ② code 换用户信息 — `GET /api/v1/auth/inner_get_user_info?code=<sso_code>`
响应(`Login.UserInfo`,节选):
```jsonc
{
"token": "...", "refresh_token": "...",
"user": {
"id": 1, "name": "张三", "phone": "...",
"permissions": { "/dashboard": ["view"] },
"feishu": { "avatar_url": "..." }
}
}
```
### ③ 应用信息 — `GET /api/v1/application/by_client_id?client_id=<app_id>`
```json
{ "data": { "providers": ["feishu", "password", "wechat"], "display_name": "XX 系统" } }
```
---
## 3. 标准流程(切到真实后端后)
```
external-app
→ 跳转 sso-mock: /login?app_id=<id>&app_url=<encoded url>
↓ 用户在 sso-mock 登录(账号/手机/飞书)
→ sso-mock ① POST /api/v1/auth/index ⇒ { code }
→ sso-mock 重定向: <app_url>?sso_code=<code>
→ external-app ② GET /api/v1/auth/inner_get_user_info?code=<code> ⇒ { user, token }
→ external-app 建立本地会话
```
---
## 4. sso-mock 需要改哪些文件
### 4.1 `app/api/login/route.ts` —— 从"伪造 code"改为"透传 auth/index"
**现状**:调 basis 原始登录端点拿到 `user+access_token`,再 `codeStore.set()` 自造 code。
**改为**:直接调 `/api/v1/auth/index`,把返回的 `code` 原样透传,**删除 codeStore 相关代码**。
```ts
// 三种方式统一映射到 auth/index
const payloadMap = {
account: { login_type: 'password', phone: body.phone, password: body.password },
phone: { login_type: 'phone', phone: body.phone, verify_code: body.verify_code },
feishu: { login_type: 'feishu', code: body.code, redirect_uri: body.redirect_uri },
}
const res = await callBasisApi('/api/v1/auth/index', {
...payloadMap[type],
client_id: body.client_id, // = app_id
client_redirect_uri: body.client_redirect_uri, // = app_url
})
// 直接透传 code不再 codeStore.set / generateCode
return NextResponse.json({ code: res.code })
```
> 注意:`client_id` / `client_redirect_uri` 现在是**必填**(一次性 code 必须绑定应用)。前端 `postLogin` / `qr-login.tsx` 已经在带这两个字段。
### 4.2 `app/api/auth/verify/route.ts` —— 从"读内存"改为"代理 inner_get_user_info"
**现状**:从 `codeStore` 取 user、自造 mock token。
**改为**
```ts
export async function POST(req: Request) {
const { code } = await req.json()
if (!code) return NextResponse.json({ error: 'code 不能为空' }, { status: 400 })
const res = await fetch(
`${SHARED_API}/api/v1/auth/inner_get_user_info?code=${encodeURIComponent(code)}`
)
return NextResponse.json(await res.json(), { status: res.status })
}
```
> 或者让 external-app 直接调后端 ②sso-mock 连这个代理都不用留。
### 4.3 `utils/code-store.ts` —— 删除
真实后端负责 code 的签发/校验/一次性/过期,内存 store 不再需要。同时移除 `route.ts` 里所有 `import { codeStore }`
### 4.4 `app/api/getVerifyCode/route.ts` —— 换端点
`/api/v1/basis/user/phone/code``/api/v1/verify_code/send`(若后端统一到新契约)。
### 4.5(可选)应用信息驱动登录 tab
新增 `app/api/front/getAppInfo/route.ts` 代理 `③ by_client_id`,前端据 `providers` 动态渲染 tab对齐 `ssofront/CloudLogin.tsx``asyncGetAppInfo`)。
---
## 5. 环境变量
```bash
# .env.local
NEXT_PUBLIC_SHARED_API_PATH=http://<真实后端>:<端口> # auth/index、inner_get_user_info 的宿主
NEXT_PUBLIC_BASE_PATH= # 部署子路径(如有)
```
---
## 6. 迁移检查清单
- [ ] 后端就绪:`POST /api/v1/auth/index` 返回 `{ code }`
- [ ] 后端就绪:`GET /api/v1/auth/inner_get_user_info` 返回用户信息
- [ ] `login/route.ts` 改为调 auth/index 并透传 code删 codeStore 逻辑
- [ ] `auth/verify/route.ts` 改为代理 inner_get_user_info或前端直连后端
- [ ] 删除 `utils/code-store.ts` 及其 import
- [ ] `client_id` / `client_redirect_uri` 缺失时前端给出明确报错
- [ ] (可选)接入 `by_client_id` 驱动登录 tab
- [ ] 端到端验证:飞书回调只发 1 次 auth/index已修复重复提交换 code 成功external-app 用 code 换到用户信息
---
## 7. 前端侧现状(无需改动)
登录组件已满足新契约的调用要求:
- `components/login/index.tsx` `postLogin()`:账号/手机登录已携带 `client_id` / `client_redirect_uri`
- `components/login/components/login/qr-login.tsx`:飞书回调已带 `code / redirect_uri / client_id / client_redirect_uri`,并已加**一次性提交守卫**`feishuLoginTriggered` ref防止同一飞书 code 被并发重复提交。