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 的接入文档
This commit is contained in:
277
INTEGRATION_GUIDE.md
Normal file
277
INTEGRATION_GUIDE.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# 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-app(Next.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)。
|
||||
Reference in New Issue
Block a user