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

15 KiB
Raw Blame History

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/codebody { phone, tntkey }sso-portal 的 app/api/getVerifyCode/route.ts 不改
  • 飞书扫码UI 已暂时隐藏,后端未提供对应 SSO 端点,不在本流程内。

2. 接口契约(字段级)

① 账号密码登录 — POST /api/v1/basis/sso/account

// 请求体
{
  "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

// 请求体
{
  "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

// 请求体
{
  "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_tokencodeStore.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

代码示意:

// ... 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.tsxpostLogin() 已携带 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。

代码示意:

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:5501prod: 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:6610prod: 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(决定解析方式)
  • tenant 取值已确认cowarobotsso-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.tsapp/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_TENANTNEXT_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_tokenhttpOnly Cookie 存储,前端不可读。
  • 授权 code 一次性、短时效由后端签发与校验sso-portal 不再自造、不缓存。
  • client_redirect_uri 必须与 SSO 注册的回调地址一致,防止开放重定向。

9. 前端侧现状(无需改动)

登录组件已满足新契约的调用要求:

  • components/login/index.tsx postLogin():账号 / 手机登录已携带 client_id / client_redirect_urihandleLoginSuccess 已按 { code } 重定向回 external-app。
  • 飞书扫码 tab 已暂时隐藏(Tabs.Tab value="feishu" 注释 + Tabs.Panel{false && ...} 屏蔽),恢复时取消对应注释即可。