# 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": "/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": "/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=/callback ↓ 用户在 sso-portal 登录(账号 / 手机) → sso-portal ① 或 ② 调 /sso/account | /sso/phone/smscode ⇒ 授权 code → sso-portal 重定向: ?sso_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 驱动(默认 cowarobot),ssoBody 显式使用、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`(见 §5),sso-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 && ...}` 屏蔽),恢复时取消对应注释即可。