Files
sso-portal/INTEGRATION_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

278 lines
11 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 接入指南
> 适用:任何需要接入本 SSO 体系的前端应用Next.js / React / Vue / 其他均可参考)。
> 参考实现:[`external-app/`](../external-app/)Next.js 16完整可运行示例
> 协议:标准 **OAuth2 授权码流程**。
---
## 1. 前置准备
向 SSO 管理员申请以下信息,**全部必填**
| 字段 | 说明 | 示例 |
|------|------|------|
| `client_id` | OAuth2 应用标识(同时作为跳转 URL 的 `app_id` | `6wds5qua3b76hu748zrjmall` |
| `client_secret` | 服务端密钥,**⚠️ 仅服务端持有,绝不下发浏览器** | `oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F` |
| `tenant` | 租户标识 | `cowarobot` |
| `callback_url` | 你的应用回调地址,**需与 SSO 注册完全一致** | `https://your-app.example.com/callback` |
还需确认两个地址:
| 地址 | 用途 | 示例 |
|------|------|------|
| `SSO_URL` | SSO 登录页地址(用于跳转 `/login` | `https://sso.softtest.cowarobot.com` |
| `BACKEND_API` | 后端 SSO 宿主(服务端调 `access_token` | `http://basis-app-svc:6610` |
---
## 2. 整体流程OAuth2 授权码)
```
[你的应用] [sso-portal] [后端 SSO]
│ │ │
│ ① 未登录302 跳转 SSO │ │
├──────── /login?app_id=...&app_url=... ────────────▶│ │
│ │ │
│ │ ② 用户在 SSO 页登录 │
│ │ (账号/手机) │
│ │ │
│ ③ SSO 302 回跳,带一次性 sso_code │ │
│◀──────── /callback?sso_code=xxx ───────────────────┤ │
│ │ │
│ ④ 服务端用 sso_code + client_secret 换 token │ │
├──────────── POST /api/v1/basis/sso/access_token ───────────────────────────▶
│◀─────────── { access_token, refresh_token, ... } ──────────────────────────┤
│ │ │
│ ⑤ 写 httpOnly Cookie重定向首页 │ │
│ → 已登录 ✓ │ │
```
**关键点**
- **步骤 ④ 必须在你的服务端**完成——`client_secret` 不能出现在前端代码/网络请求中。
- `sso_code` **一次性、短时效**,由后端保证,无需额外处理(不要重试同一 code
- 步骤 ⑤ 写完 cookie 后即可视为登录成功。
---
## 3. 步骤详解
### 3.1 构造 SSO 登录跳转 URL
未登录时,跳转到 SSO
```
{SSO_URL}/login?app_id={client_id}&app_url={encodeURIComponent(callback_url)}
```
> `app_url` 必须 `encodeURIComponent`。
### 3.2 接收回调
`callback_url` 对应的页面/路由接收 `sso_code`URL query 参数)。
校验:
- `sso_code` 缺失 → 提示错误。
- 存在 → 进入 3.3。
### 3.3 服务端换 token核心敏感操作
**服务端**调用后端:
```http
POST {BACKEND_API}/api/v1/basis/sso/access_token
Content-Type: application/json
{
"client_id": "<client_id>",
"client_secret": "<client_secret>",
"code": "<sso_code>",
"grant_type": "authorization_code",
"tenant": "<tenant>"
}
```
成功响应(当前契约,不含用户信息):
```json
{
"code": 200,
"data": {
"access_token": "zfa1eIUz0cDUTHA3RVvjjWIL3SbvBu2g",
"expires_in": "7200",
"refresh_token": "Fpczl1EreLWw3BKhw6ejqa93SchN1rBF"
},
"msg": ""
}
```
> **关于用户信息**:标准 OAuth2 的 `access_token` 端点**只返回 token不返回 user**。如需展示用户信息,需让后端补一个「用 access_token 换用户信息」的 userinfo 端点。当前 `external-app` 的用户信息卡片会显示「后端未返回用户详情」占位。
### 3.4 建立会话(写 Cookie
| Cookie 名 | 内容 | 属性 |
|-----------|------|------|
| `token` | `access_token` | `httpOnly`, `path=/`, `maxAge=86400`(或与 `expires_in` 对齐) |
| `refresh_token` | `refresh_token`(若有) | `httpOnly`, `path=/`, `maxAge=86400*7` |
| `user` | JSON.stringify(user)(若有) | 非 `httpOnly`(用于客户端展示) |
### 3.5 登录态判定
**以 `token` cookie 为准**(拿到 `access_token` 即登录成功)。
- **服务端组件 / SSR**:直接读 `token` cookie 判定。
- **客户端组件**:让服务端把 `authenticated: boolean` 标志传下来(**不要**在前端读 `httpOnly` cookie——读不到
- 避免依赖 `user` cookie 判定登录(后端不返回 user 就会误判为未登录)。
### 3.6 登出
清空 `token` / `refresh_token` / `user` cookie`maxAge=0`),然后跳转回 `{SSO_URL}/login?app_id=...&app_url=...`
---
## 4. 关键配置项(环境变量)
下表是 **Next.js 命名约定**`NEXT_PUBLIC_` 前缀 = 客户端可见;无前缀 = 仅服务端)。其他框架按各自约定映射(例如 React + 独立后端CLIENT_ID 可能由后端转发或注入 HTML
| 变量Next.js | 端 | 说明 |
|----------------|----|------|
| `NEXT_PUBLIC_SSO_URL` | 客户端 | SSO 登录页地址,构造跳转 URL 用 |
| `NEXT_PUBLIC_APP_URL` | 客户端 | 本应用地址,作为 `client_redirect_uri` |
| `NEXT_PUBLIC_SSO_APP_ID` | 两端 | OAuth2 `client_id`,同时作为 URL `app_id` |
| `SSO_CLIENT_SECRET` | **仅服务端** | `client_secret`**绝不**下发前端 |
| `SSO_BACKEND_API` | 服务端 | 后端 SSO 宿主(调 `access_token` |
| `SSO_TENANT` | 服务端 | 租户标识(如 `cowarobot` |
> ⚠️ `SSO_CLIENT_SECRET` 含敏感信息,生产建议通过部署平台密钥注入,**不要**入库。`.env*.local` 已被 `.gitignore` 忽略,本地私有覆盖请写入 `.env*.local`。
---
## 5. 安全要求(必读)
1. **`client_secret` 绝不**出现在前端代码、URL、浏览器可读 cookie 中。
2. `access_token` / `refresh_token`**`httpOnly` Cookie**,路径 `/`
3. `redirect_uri` 必须与 SSO 注册**完全一致**(协议、域名、端口、路径都一致),否则换 token 失败。
4. 授权 `sso_code` **一次性、短时效**,由后端保证,前端不要重试。
5. 生产环境 Cookie 应设置 `Secure`HTTPS和合理的 `SameSite`Lax/Strict
6. 敏感日志(如 `access_token` 响应)生产环境应脱敏或关闭。
---
## 6. 参考实现external-appNext.js
完整可运行示例位于 **sibling 仓库** [`../external-app/`](../external-app/)
```
external-app/
├── app/
│ ├── page.tsx # 首页:服务端读 token cookie → authenticated → DashboardPage
│ ├── DashboardPage.tsx # 登录后页面client 组件user 可为 null
│ ├── callback/
│ │ └── page.tsx # 回调页:收 sso_code → POST /api/callback → 跳首页
│ └── api/
│ ├── callback/route.ts # 服务端:调 access_token → 写 httpOnly cookie
│ └── logout/route.ts # 清 cookie
├── .env.development
├── .env.staging
├── .env.production
└── README.md
```
**关键代码片段**(可直接对照阅读完整文件):
`app/page.tsx`(首页登录态判定):
```ts
const token = cookieStore.get('token')?.value
const authenticated = !!token
return <DashboardPage user={user} authenticated={authenticated} />
```
`app/callback/page.tsx`(回调):
```ts
const code = searchParams.get('sso_code')
await fetch('/api/callback', {
method: 'POST',
body: JSON.stringify({ code }),
})
window.location.href = '/' // 成功 → 首页
```
`app/api/callback/route.ts`(服务端换 token + 写 cookie
```ts
const res = await fetch(`${BACKEND}/api/v1/basis/sso/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id, client_secret, code,
grant_type: 'authorization_code',
tenant: SSO_TENANT,
}),
})
const { access_token, refresh_token } = await res.json()
response.cookies.set('token', access_token, { httpOnly: true, path: '/', maxAge: 86400 })
response.cookies.set('refresh_token', refresh_token, { httpOnly: true, path: '/', maxAge: 86400*7 })
```
---
## 7. 其他框架适配要点
### React非 Next.js需独立 Node 后端)
- 跳转/回调与 Next.js 同理,但 `cookieStore` 换后端读取(如 Express `req.cookies`)。
- 客户端组件判定登录 → 通过后端 SSR 渲染时注入 `authenticated`,或客户端用 `/api/me`(轻量探活)查。
- 跨域时 Cookie 需 `SameSite=Lax/None; Secure`
### Vue / 静态前端 + 独立后端
- 静态前端**不能**直接调 `access_token`(会暴露 `client_secret`),必须经后端中转。
- 跳转/回调 HTML 端处理即可;后端提供换 token + 写 cookie 的接口。
### 跨域注意
- SSO 与你的应用若不同域:后端换 token 后写 Cookie 需正确 `Domain`/`Path`/`SameSite`
- `sso-portal` 与应用不同源时,跳转 URL 的 `app_url` 必须为**完整可公网访问的 URL**。
---
## 8. 常见问题
| 现象 | 可能原因 / 排查 |
|------|----------------|
| 拿到 `sso_code` 后**又跳回 SSO** | token cookie 未写入;服务端没读到 token`authenticated` 未正确传给客户端 |
| `access_token` 返回 401 | `client_secret` 错;`redirect_uri` 与注册不一致;`code` 已用过 / 过期 |
| `redirect_uri` 报错 | 与 SSO 注册的回调地址完全一致(含 `http`/`https`、端口、尾部斜杠) |
| `tenant` 报错 | `SSO_TENANT` 与后端约定不符(当前约定 `cowarobot` |
| 用户信息不展示 | 标准行为——`access_token` 不返回 user。后端补 userinfo 接口即可 |
| 同一 code 报「已使用」 | 一次性,由后端保证;不要在客户端重试 |
| `code !== 200` 误判 | 切到 OAuth2 流程后,`code` 字段含义变了(业务码 vs 授权码),**不要**再用 `code === 200` 判断成功 |
---
## 9. 后端接口参考(速查)
| # | 用途 | 接口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` | **你的应用服务端** |
③ 请求体:
```json
{
"client_id": "...",
"client_secret": "...",
"code": "...",
"grant_type": "authorization_code",
"tenant": "cowarobot"
}
```
---
## 10. 一句话总结
> 跳转 `{SSO_URL}/login?app_id=...&app_url=...` → 接 `sso_code` → **服务端**用 `client_secret` 换 `access_token` → 写 `httpOnly` cookie → 完成。
照此即可接入。有问题对照 [`external-app/`](../external-app/) 实现,或查本仓库的 [`ADAPTATION_GUIDE.md`](./ADAPTATION_GUIDE.md)。