Files
sso-portal/ADAPTATION_GUIDE.md
zhangheng 775b4f6044 chore(sso-portal): 整合近期重构、UX 改进、部署脚本与文档
项目改名 sso-mock → sso-portal:
- 重命名仓库目录 sso-mock/ → sso-portal/
- package.json name 改 sso-portal
- app/layout.tsx title/description 改 SSO Portal
- 源码 [sso-mock] console.log 前缀、注释全部 → [sso-portal]
- 文档(README、ADAPTATION_GUIDE)、env 同步
- 生产 svc 名 sso-mock-svc → sso-portal-svc(external-app 引用同步)

SSO tenant 改环境变量驱动:
- 新增 SSO_TENANT env(默认 cowarobot)
- login/route.ts 顶部 const SSO_TENANT,ssoBody 显式使用
- callSsoApi 改回纯透传(不再硬编码覆盖 tenant)
- external-app 同步用 SSO_TENANT,命名对齐

external-app 登录态改为基于 token:
- page.tsx 读 token cookie 判定 authenticated(不再依赖 user cookie)
- DashboardPage 用 authenticated 决定跳转,user 为 null 时优雅兜底展示
- 解决「200 后又跳回 SSO」bug(access_token 接口不返回 user)

sso-portal 缺 SSO 参数体验优化:
- 缺 app_id/app_url 时 Alert 提示 + 禁用所有输入和登录按钮
- components/login/index.tsx 加 missingSsoParams 检测
- Alert 样式两排完整显示

部署脚本(参考 uirefbase):
- 新增 build.sh(git → pnpm install → build → docker build → tag → push)
- 新增 .gitlab-ci.yml(main 分支触发 build.sh prod)
- 新增 Dockerfile(standalone 模式,端口 5501)
- package.json 加 env-cmd 依赖 + build:stage/build 脚本
- next.config.ts 保持 output: 'standalone'

代码清理:
- 删除 utils/code-store.ts
- 删除 app/api/auth/verify/(连同目录)
- 移除旧的 codeStore / generateCode 逻辑

文档:
- ADAPTATION_GUIDE.md:完整重写对齐真实后端三个 SSO 接口(/sso/account、/sso/phone/smscode、/sso/access_token)
- 新增 INTEGRATION_GUIDE.md(从 external-app 迁移过来作为通用接入指南)
- README.md 同步更新(sso-portal 仓库门面 + 文档索引)
- external-app/README.md 加交叉链接指向 sso-portal 的接入文档
2026-07-08 20:53:51 +08:00

317 lines
15 KiB
Markdown
Raw Permalink 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-Portal → 真实后端 适配指南
> 目标:后端已落地真实 SSO 接口。把 sso-portal 从「自造一次性 code + 内存 `codeStore`」切换为「纯透传真实后端」external-app 改用**标准 OAuth2 授权码流程**换 token。
> 本文以 **2026-07 后端提供的三个真实接口**为准。
> ⚠️ 与早期设想不同:真实后端**没有**采用「单端点 `auth/index` + `login_type` 区分」的设计,而是**按登录方式拆成独立端点**`/sso/account`、`/sso/phone/smscode`),再用一个标准 OAuth2 端点(`/sso/access_token`)把授权 code 换成 access_token。
---
## 1. 后端提供的三个真实接口
| # | 用途 | 接口POST | 调用方 | 必须 |
| --- | ---------------------------- | --------------------------------------- | -------------------- | ---- |
| ① | 账号密码登录 → 返回授权 code | `/api/v1/basis/sso/account` | **sso-portal** | ✅ |
| ② | 手机验证码登录 → 返回授权 code | `/api/v1/basis/sso/phone/smscode` | **sso-portal** | ✅ |
| ③ | 授权 code → access_token | `/api/v1/basis/sso/access_token` | **external-app服务端** | ✅ |
**宿主**`http://172.16.115.31:6610`(即 `NEXT_PUBLIC_SHARED_API_PATH`
**固定凭据SSO 应用注册值)**
| 字段 | 值 | 持有方 |
| --------------- | ---------------------------------- | ------------------------------- |
| `client_id` | `6wds5qua3b76hu748zrjmall` | sso-portal随 URL `app_id` 传入)+ external-app |
| `client_secret` | `oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F` | **仅 external-app 服务端** env绝不下发浏览器 |
**不变项**
- 发送手机验证码:依旧走 `POST /api/v1/basis/user/phone/code`body `{ phone, tntkey }`sso-portal 的 `app/api/getVerifyCode/route.ts` **不改**
- 飞书扫码UI 已暂时隐藏,后端**未提供**对应 SSO 端点,不在本流程内。
---
## 2. 接口契约(字段级)
### ① 账号密码登录 — `POST /api/v1/basis/sso/account`
```jsonc
// 请求体
{
"account": "string",
"password": "string",
"tenant": "string", // 租户标识,取自 SSO_TENANT默认 cowarobot
"client_id": "6wds5qua3b76hu748zrjmall",
"client_redirect_uri": "<external-app>/callback"
}
// 响应
{ "code": "<授权 code一次性、短时效>" }
```
### ② 手机验证码登录 — `POST /api/v1/basis/sso/phone/smscode`
```jsonc
// 请求体
{
"phone": "string", // 如 "+8613800000000"
"smscode": "string", // 6 位短信验证码
"tenant": "string",
"client_id": "6wds5qua3b76hu748zrjmall",
"client_redirect_uri": "<external-app>/callback"
}
// 响应
{ "code": "<授权 code一次性、短时效>" }
```
> 验证码**发送**仍走 `/api/v1/basis/user/phone/code`(不变),与登录接口 `/sso/phone/smscode` 是两个不同端点,别混淆。
### ③ 授权 code 换 token — `POST /api/v1/basis/sso/access_token`
```jsonc
// 请求体
{
"client_id": "6wds5qua3b76hu748zrjmall",
"client_secret": "oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F", // ⚠️ 仅服务端
"code": "<①或②返回的授权 code>",
"grant_type": "authorization_code", // 标准 OAuth2 取值
"tenant": "string"
}
// 响应(按 OAuth2 习惯,以后端实测为准)
{ "access_token": "...", "refresh_token": "...", "expires_in": 7200, "token_type": "Bearer", "user": { ... } }
```
> ⚠️ **`code` 语义冲突**basis 旧接口通常包一层业务外壳 `{ code: 200, data: {...}, message }`(此处 `code` 是**业务状态码**);而 ①②③ 里 `code` 是**授权码字符串**。落地时请以后端实测响应为准:若 ①② 返回的是平铺 `{ code: "<授权码>" }`,则直接取;若包了外壳 `{ code:200, data:{ code:"..." } }`,则取 `data.code`。下文代码示意两种都兼容。
---
## 3. 标准流程OAuth2 授权码流程)
```
external-app
→ 跳转 sso-portal: /login?app_id=6wds5qua3b76hu748zrjmall&app_url=<external-app>/callback
↓ 用户在 sso-portal 登录(账号 / 手机)
→ sso-portal ① 或 ② 调 /sso/account | /sso/phone/smscode ⇒ 授权 code
→ sso-portal 重定向: <app_url>?sso_code=<code>
→ external-app /api/callback服务端③ POST /sso/access_token
{ client_id, client_secret, code, grant_type, tenant } ⇒ access_token
→ external-app 写 httpOnly Cookie建立本地会话
```
要点:
- `client_secret` 只在 **external-app 服务端**出现sso-portal 全程不持有。
- 授权 `code` 一次性、短时效,**由后端保证**sso-portal 不再自造、不再存内存。
---
## 4. sso-portal 需要改哪些文件
### 4.1 `app/api/login/route.ts` —— 改为透传 `/sso/*`,删除 `codeStore`
**现状**:调 basis 原始登录端点 → 拿 `user + access_token``codeStore.set()` 自造 code。
**改为**:按 `type` 调对应 `/sso/*` 端点,把后端返回的授权 code 原样透传,**删除** `codeStore` / `generateCode`
字段映射(前端解密后 body → 后端请求体):
| type | 前端字段 | 后端字段(`/sso/*` |
| -------- | -------------------------------------------- | --------------------------------------------------------------------------------- |
| account | `account`, `password`, `abbr`, `client_id`, `client_redirect_uri` | `account`, `password`, `tenant`(来自 `SSO_TENANT` env), `client_id`, `client_redirect_uri` |
| phone | `phone`, `verify_code`, `tntkey`, `client_id`, `client_redirect_uri` | `phone`, `smscode`(=verify_code), `tenant`(来自 `SSO_TENANT` env), `client_id`, `client_redirect_uri` |
代码示意:
```ts
// ... decrypt() 不变,删除 import { codeStore } / generateCode ...
async function callSsoApi(path: string, body: object) {
const res = await fetch(`${SHARED_API}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const text = await res.text()
try {
return JSON.parse(text)
} catch {
throw new Error(`后端返回非 JSON [${res.status}]: ${text.slice(0, 200)}`)
}
}
export async function POST(req: Request) {
const decryptData = await req.json()
const body = JSON.parse(decrypt(decryptData))
const type = body?.type
// tenant 由环境变量 SSO_TENANT 驱动(默认 cowarobotssoBody 显式使用、callSsoApi 纯透传
const tenant = process.env.SSO_TENANT || 'cowarobot'
let ssoRes: any
if (type === 'account') {
ssoRes = await callSsoApi('/api/v1/basis/sso/account', {
account: body.account,
password: body.password,
tenant,
client_id: body.client_id,
client_redirect_uri: body.client_redirect_uri,
})
} else if (type === 'phone') {
ssoRes = await callSsoApi('/api/v1/basis/sso/phone/smscode', {
phone: body.phone,
smscode: body.verify_code,
tenant,
client_id: body.client_id,
client_redirect_uri: body.client_redirect_uri,
})
} else {
return NextResponse.json({ error: `不支持的登录方式: ${type}` })
}
// ⚠️ 删除旧的 `if (res?.code !== 200)` 判断 —— 现在 code 是授权码字符串,不是业务状态码。
// 兼容平铺 / 外壳两种响应;失败时回显后端 message。
const authCode = ssoRes?.code ?? ssoRes?.data?.code
if (!authCode || typeof authCode !== 'string') {
return NextResponse.json({
error: ssoRes?.message || ssoRes?.msg || '登录失败,后端未返回授权 code',
})
}
return NextResponse.json({ code: authCode })
}
```
> 前端 `components/login/index.tsx` 的 `postLogin()` 已携带 `client_id` / `client_redirect_uri`**无需改动**。`handleLoginSuccess` 已按 `{ code }` 跳转,与新透传结构一致。
### 4.2 `utils/code-store.ts` —— 删除
授权 code 的签发 / 校验 / 一次性 / 过期全部由后端负责,内存 store 不再需要。同步移除 `login/route.ts` 里所有 `import { codeStore }`
### 4.3 `app/api/auth/verify/route.ts` —— 删除
external-app 改为直连后端 `/sso/access_token`(见 §5sso-portal 不再提供 code 换用户信息的代理。
### 4.4 `app/api/getVerifyCode/route.ts` —— 不变
继续代理 `POST /api/v1/basis/user/phone/code`(验证码**发送**逻辑沿用)。
---
## 5. external-app 需要改哪些文件
### 5.1 `app/api/callback/route.ts` —— 改为直连后端 `/sso/access_token`
**现状**:把 `sso_code` POST 给 sso-portal `/api/auth/verify` 换用户信息。
**改为**:服务端直接调后端 `/api/v1/basis/sso/access_token`,带 `client_secret`,拿 token 写 httpOnly Cookie。
代码示意:
```ts
const BACKEND = (process.env.SSO_BACKEND_API || 'http://172.16.115.31:6610').replace(/\/+$/, '')
export async function POST(req: Request) {
const { code } = await req.json()
if (!code) {
return NextResponse.json({ error: '缺少 code 参数' }, { status: 400 })
}
const res = await fetch(`${BACKEND}/api/v1/basis/sso/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.NEXT_PUBLIC_SSO_APP_ID, // 6wds5qua3b76hu748zrjmall
client_secret: process.env.SSO_CLIENT_SECRET, // ⚠️ 仅服务端 env不下发浏览器
code,
grant_type: 'authorization_code',
tenant: process.env.SSO_TENANT || 'cowarobot',
}),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
return NextResponse.json(
{ error: data?.message || data?.msg || '换 token 失败' },
{ status: 401 },
)
}
const token = data?.access_token ?? data?.data?.access_token
const refreshToken = data?.refresh_token ?? data?.data?.refresh_token
const user = data?.user ?? data?.data?.user
const response = NextResponse.json({ success: true })
if (token) {
response.cookies.set('token', token, { httpOnly: true, path: '/', maxAge: 86400 })
}
if (refreshToken) {
response.cookies.set('refresh_token', refreshToken, {
httpOnly: true, path: '/', maxAge: 86400 * 7,
})
}
if (user) {
response.cookies.set('user', JSON.stringify(user), { path: '/', maxAge: 86400 })
}
return response
}
```
### 5.2 跳转链接 —— `app_id` 改为真实 client_id
`app/DashboardPage.tsx` 跳 sso-portal 时 `app_id` 即 OAuth2 `client_id`,需改为 `6wds5qua3b76hu748zrjmall`,并把硬编码的 `localhost` 地址改为环境变量 `NEXT_PUBLIC_SSO_APP_ID` / `NEXT_PUBLIC_SSO_URL` / `NEXT_PUBLIC_APP_URL`(见 §6
---
## 6. 环境变量
### sso-portal`.env.development` / `.env.staging` / `.env.production`
| 变量 | 说明 | dev / staging | production |
| --------------------------------- | --------------------------------------------- | --------------------- | --------------------- |
| `NEXT_PUBLIC_SHARED_API_PATH` | `/sso/*` 宿主 | `http://172.16.115.31:6610/` | `http://basis-app-svc:6610` |
| `NEXT_PUBLIC_BASE_PATH` | 部署子路径,无则留空 | `""` | `""` |
| `SSO_TENANT` | SSO 接口的租户标识account / phone-smscode / access_token 共用) | `cowarobot` | `cowarobot` |
> sso-portal **不持有** `client_secret`(仅 external-app 服务端有);`SSO_TENANT` 驱动所有 SSO 接口的 `tenant` 字段。
### external-app
| 变量 | 端 | 说明 | 值 / 示例 |
| -------------------------- | ------ | ----------------------------------------------------- | ---------------------------------- |
| `NEXT_PUBLIC_SSO_APP_ID` | 两端 | OAuth2 `client_id`,作为 URL `app_id` 传给 sso-portal | `6wds5qua3b76hu748zrjmall` |
| `NEXT_PUBLIC_SSO_URL` | 客户端 | sso-portal 登录页地址,客户端跳转 `/login` 用 | dev: `http://localhost:5501`prod: `http://sso-portal-svc:5501` |
| `NEXT_PUBLIC_APP_URL` | 客户端 | 本应用地址,作为 `client_redirect_uri` | dev: `http://localhost:4000` |
| `SSO_CLIENT_SECRET` | 服务端 | `/sso/access_token` 鉴权用,**仅服务端** | `oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F` |
| `SSO_BACKEND_API` | 服务端 | `/sso/access_token` 宿主 | dev: `http://172.16.115.31:6610`prod: `http://basis-app-svc:6610` |
| `SSO_TENANT` | 服务端 | 租户标识(与 sso-portal 的 `SSO_TENANT` 一致) | `cowarobot` |
> `SSO_CLIENT_SECRET` 含敏感信息,建议生产环境通过部署平台密钥注入,不入库;本地私有覆盖写入 `.env*.local`(已被 `.gitignore` 忽略)。`NEXT_PUBLIC_SSO_URL`(指向 sso-portal用于客户端跳转 `/login`,故带 `NEXT_PUBLIC_` 前缀。
---
## 7. 迁移检查清单
- [ ] 后端就绪:`/sso/account` 返回授权 code
- [ ] 后端就绪:`/sso/phone/smscode` 返回授权 code
- [ ] 后端就绪:`/sso/access_token` 返回 access_token
- [ ] **确认 ①②③ 响应外壳**`code` 是平铺授权码还是包在 `data.code`(决定解析方式)
- [x] **`tenant` 取值已确认**`cowarobot`sso-portal 与 external-app 均用此值);**`grant_type`** 仍待确认(暂定 `authorization_code`
- [ ] sso-portal `login/route.ts`:改为透传 `/sso/account` / `/sso/phone/smscode`,删 `codeStore` / `generateCode`,去掉 `code !== 200` 判断
- [ ] sso-portal 删除 `utils/code-store.ts``app/api/auth/verify/route.ts` 及相关 import
- [ ] external-app `app/api/callback/route.ts`:改为服务端直连 `/sso/access_token`
- [ ] external-app env新增 `NEXT_PUBLIC_SSO_URL` / `SSO_CLIENT_SECRET` / `SSO_BACKEND_API` / `SSO_TENANT``NEXT_PUBLIC_SSO_APP_ID` 改为 `6wds5qua3b76hu748zrjmall`
- [ ] `client_secret` 仅在 external-app 服务端 env前端代码 / 网络请求中无泄露
- [ ] 端到端:账号登录 → 拿 code → external-app 换 token → 建会话;手机登录同路径;验证码发送正常
---
## 8. 安全注意
- `client_secret` **绝不下发浏览器**`/sso/access_token` 一律由 external-app 的服务端 route handler 调用。
- `access_token` / `refresh_token`**httpOnly Cookie** 存储,前端不可读。
- 授权 `code` 一次性、短时效由后端签发与校验sso-portal 不再自造、不缓存。
- `client_redirect_uri` 必须与 SSO 注册的回调地址一致,防止开放重定向。
---
## 9. 前端侧现状(无需改动)
登录组件已满足新契约的调用要求:
- `components/login/index.tsx` `postLogin()`:账号 / 手机登录已携带 `client_id` / `client_redirect_uri``handleLoginSuccess` 已按 `{ code }` 重定向回 external-app。
- 飞书扫码 tab 已暂时隐藏(`Tabs.Tab value="feishu"` 注释 + `Tabs.Panel``{false && ...}` 屏蔽),恢复时取消对应注释即可。