diff --git a/.env.development b/.env.development index b3e53c5..b340f5a 100644 --- a/.env.development +++ b/.env.development @@ -3,3 +3,6 @@ NEXT_PUBLIC_BASE_PATH="" # 公共服务接口地址(后端 basis 服务:登录 / 验证码 / 租户列表都走这里) NEXT_PUBLIC_SHARED_API_PATH="http://172.16.115.31:6610/" + +# SSO 接口的租户标识(account / phone-smscode / access_token 共用) +SSO_TENANT="cowarobot" diff --git a/.env.production b/.env.production index 2eeb3a3..5966197 100644 --- a/.env.production +++ b/.env.production @@ -3,3 +3,6 @@ NEXT_PUBLIC_BASE_PATH="" # 公共服务接口地址(后端 basis 服务,集群内 svc 名,与 uirefbase 生产一致) NEXT_PUBLIC_SHARED_API_PATH="http://basis-app-svc:6610" + +# SSO 接口的租户标识(account / phone-smscode / access_token 共用) +SSO_TENANT="cowarobot" diff --git a/.env.staging b/.env.staging index b3e53c5..b340f5a 100644 --- a/.env.staging +++ b/.env.staging @@ -3,3 +3,6 @@ NEXT_PUBLIC_BASE_PATH="" # 公共服务接口地址(后端 basis 服务:登录 / 验证码 / 租户列表都走这里) NEXT_PUBLIC_SHARED_API_PATH="http://172.16.115.31:6610/" + +# SSO 接口的租户标识(account / phone-smscode / access_token 共用) +SSO_TENANT="cowarobot" diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..4380e73 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,28 @@ +stages: + - sync_github + +sync_github: + stage: sync_github + tags: + - webbuild + image: harbor.cowarobot.cn/softrepo/rust_build:1.94 + script: + - echo "代码已合并到 main, 开始执行发布逻辑..." + - | + for i in {1..10}; do + if command -v docker >/dev/null 2>&1 && [ -S /var/run/docker.sock ]; then + echo "Docker is ready!" + break + fi + echo "Waiting for Docker..." + sleep 2 + done + - docker info + - echo ${CI_PROJECT_NAME} ${CI_PROJECT_PATH} ${CI_PROJECT_DIR} ${CI_PROJECT_URL} ${CI_COMMIT_SHA} + - echo ${HARBORKEY} | docker login harbor.cowarobot.cn -u 'robot$softpro+cibuild' --password-stdin + - rm -rf ${CI_PROJECT_NAME} + - git clone https://oauth2:${TOKEN}@git.cowarobot.com/${CI_PROJECT_PATH} + - cd ${CI_PROJECT_NAME} + - bash build.sh prod + rules: + - if: $CI_COMMIT_BRANCH == "main" diff --git a/ADAPTATION_GUIDE.md b/ADAPTATION_GUIDE.md index 7f7e668..222641d 100644 --- a/ADAPTATION_GUIDE.md +++ b/ADAPTATION_GUIDE.md @@ -1,174 +1,316 @@ -# SSO-Mock → 真实后端 适配指南 +# SSO-Portal → 真实后端 适配指南 -> 目标:当后端提供真实 SSO 接口后,把 sso-mock 从「自己伪造一次性 code」切换为「纯透传真实后端」。 -> 参考实现:生产前端 `ssofront`(`src/apis/login/index.ts`)已经在用这套契约,本指南以它为准。 +> 目标:后端已落地真实 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. 结论:需要几个接口? +## 1. 后端提供的三个真实接口 -核心是 **2 个**(你已想到),完全对齐生产还需 **1 个辅助 + 2 个已有代理**: +| # | 用途 | 接口(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(服务端)** | ✅ | -| # | 用途 | 后端接口(生产实测) | 必须 | 备注 | -|---|------|----------------------|------|------| -| ① | 登录 → 返回一次性 code | `POST /api/v1/auth/index` | ✅ | **单端点**,用 `login_type` 区分账号/手机/飞书 | -| ② | 一次性 code → 用户信息 | `GET /api/v1/auth/inner_get_user_info?code=` | ✅ | 供 external-app 调用 | -| ③ | app_id → 应用登录方式 | `GET /api/v1/application/by_client_id?client_id=` | ⭕ | 决定开放哪些登录 tab + 应用名;前端写死可省 | -| ④ | 发送手机验证码 | `POST /api/v1/verify_code/send` | ⭕ | 手机登录才需要(sso-mock 现已代理为 basis 版) | -| ⑤ | 租户简称列表 | `GET /api/v1/basis/tenant/base/listabbr` | ⭕ | 多租户选择(现已代理) | +**宿主**:`http://172.16.115.31:6610`(即 `NEXT_PUBLIC_SHARED_API_PATH`) -> **一句话**:后端把 ①② 做出来,sso-mock 就能删掉内存 `codeStore` 与假 `/api/auth/verify`,变成纯代理。③ 是"完全对齐生产"的锦上添花。 +**固定凭据(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. 接口契约(字段级) -### ① 登录换 code — `POST /api/v1/auth/index` - -请求体(三种方式共用,按 `login_type` 带不同字段): +### ① 账号密码登录 — `POST /api/v1/basis/sso/account` ```jsonc +// 请求体 { - "login_type": "password | phone | feishu", // 判别登录方式 - "client_id": "", // 外部应用标识 - "client_redirect_uri": "", // 外部应用回调地址 - - // login_type=password - "phone": "+86138...", "password": "***", - // login_type=phone - "phone": "+86138...", "verify_code": "123456", - // login_type=feishu - "code": "<飞书授权码>", "redirect_uri": "" + "account": "string", + "password": "string", + "tenant": "string", // 租户标识,取自 SSO_TENANT(默认 cowarobot) + "client_id": "6wds5qua3b76hu748zrjmall", + "client_redirect_uri": "/callback" } +// 响应 +{ "code": "<授权 code,一次性、短时效>" } ``` -响应: - -```json -{ "code": "一次性 sso_code(短时效、单次使用)" } -``` - -### ② code 换用户信息 — `GET /api/v1/auth/inner_get_user_info?code=` - -响应(`Login.UserInfo`,节选): +### ② 手机验证码登录 — `POST /api/v1/basis/sso/phone/smscode` ```jsonc +// 请求体 { - "token": "...", "refresh_token": "...", - "user": { - "id": 1, "name": "张三", "phone": "...", - "permissions": { "/dashboard": ["view"] }, - "feishu": { "avatar_url": "..." } - } + "phone": "string", // 如 "+8613800000000" + "smscode": "string", // 6 位短信验证码 + "tenant": "string", + "client_id": "6wds5qua3b76hu748zrjmall", + "client_redirect_uri": "/callback" } +// 响应 +{ "code": "<授权 code,一次性、短时效>" } ``` -### ③ 应用信息 — `GET /api/v1/application/by_client_id?client_id=` +> 验证码**发送**仍走 `/api/v1/basis/user/phone/code`(不变),与登录接口 `/sso/phone/smscode` 是两个不同端点,别混淆。 -```json -{ "data": { "providers": ["feishu", "password", "wechat"], "display_name": "XX 系统" } } +### ③ 授权 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. 标准流程(切到真实后端后) +## 3. 标准流程(OAuth2 授权码流程) ``` external-app - → 跳转 sso-mock: /login?app_id=&app_url= - ↓ 用户在 sso-mock 登录(账号/手机/飞书) - → sso-mock ① POST /api/v1/auth/index ⇒ { code } - → sso-mock 重定向: ?sso_code= + → 跳转 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 ② GET /api/v1/auth/inner_get_user_info?code= ⇒ { user, token } - → external-app 建立本地会话 + → 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-mock 需要改哪些文件 +## 4. sso-portal 需要改哪些文件 -### 4.1 `app/api/login/route.ts` —— 从"伪造 code"改为"透传 auth/index" +### 4.1 `app/api/login/route.ts` —— 改为透传 `/sso/*`,删除 `codeStore` -**现状**:调 basis 原始登录端点拿到 `user+access_token`,再 `codeStore.set()` 自造 code。 -**改为**:直接调 `/api/v1/auth/index`,把返回的 `code` 原样透传,**删除 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 -// 三种方式统一映射到 auth/index -const payloadMap = { - account: { login_type: 'password', phone: body.phone, password: body.password }, - phone: { login_type: 'phone', phone: body.phone, verify_code: body.verify_code }, - feishu: { login_type: 'feishu', code: body.code, redirect_uri: body.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 驱动(默认 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 }) } -const res = await callBasisApi('/api/v1/auth/index', { - ...payloadMap[type], - client_id: body.client_id, // = app_id - client_redirect_uri: body.client_redirect_uri, // = app_url -}) -// 直接透传 code,不再 codeStore.set / generateCode -return NextResponse.json({ code: res.code }) ``` -> 注意:`client_id` / `client_redirect_uri` 现在是**必填**(一次性 code 必须绑定应用)。前端 `postLogin` / `qr-login.tsx` 已经在带这两个字段。 +> 前端 `components/login/index.tsx` 的 `postLogin()` 已携带 `client_id` / `client_redirect_uri`,**无需改动**。`handleLoginSuccess` 已按 `{ code }` 跳转,与新透传结构一致。 -### 4.2 `app/api/auth/verify/route.ts` —— 从"读内存"改为"代理 inner_get_user_info" +### 4.2 `utils/code-store.ts` —— 删除 -**现状**:从 `codeStore` 取 user、自造 mock token。 -**改为**: +授权 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( - `${SHARED_API}/api/v1/auth/inner_get_user_info?code=${encodeURIComponent(code)}` - ) - return NextResponse.json(await res.json(), { status: res.status }) + 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 } ``` -> 或者让 external-app 直接调后端 ②,sso-mock 连这个代理都不用留。 +### 5.2 跳转链接 —— `app_id` 改为真实 client_id -### 4.3 `utils/code-store.ts` —— 删除 - -真实后端负责 code 的签发/校验/一次性/过期,内存 store 不再需要。同时移除 `route.ts` 里所有 `import { codeStore }`。 - -### 4.4 `app/api/getVerifyCode/route.ts` —— 换端点 - -`/api/v1/basis/user/phone/code` → `/api/v1/verify_code/send`(若后端统一到新契约)。 - -### 4.5(可选)应用信息驱动登录 tab - -新增 `app/api/front/getAppInfo/route.ts` 代理 `③ by_client_id`,前端据 `providers` 动态渲染 tab(对齐 `ssofront/CloudLogin.tsx` 的 `asyncGetAppInfo`)。 +`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)。 --- -## 5. 环境变量 +## 6. 环境变量 -```bash -# .env.local -NEXT_PUBLIC_SHARED_API_PATH=http://<真实后端>:<端口> # auth/index、inner_get_user_info 的宿主 -NEXT_PUBLIC_BASE_PATH= # 部署子路径(如有) -``` +### 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_` 前缀。 --- -## 6. 迁移检查清单 +## 7. 迁移检查清单 -- [ ] 后端就绪:`POST /api/v1/auth/index` 返回 `{ code }` -- [ ] 后端就绪:`GET /api/v1/auth/inner_get_user_info` 返回用户信息 -- [ ] `login/route.ts` 改为调 auth/index 并透传 code,删 codeStore 逻辑 -- [ ] `auth/verify/route.ts` 改为代理 inner_get_user_info(或前端直连后端) -- [ ] 删除 `utils/code-store.ts` 及其 import -- [ ] `client_id` / `client_redirect_uri` 缺失时前端给出明确报错 -- [ ] (可选)接入 `by_client_id` 驱动登录 tab -- [ ] 端到端验证:飞书回调只发 1 次 auth/index(已修复重复提交),换 code 成功,external-app 用 code 换到用户信息 +- [ ] 后端就绪:`/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 → 建会话;手机登录同路径;验证码发送正常 --- -## 7. 前端侧现状(无需改动) +## 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`。 -- `components/login/components/login/qr-login.tsx`:飞书回调已带 `code / redirect_uri / client_id / client_redirect_uri`,并已加**一次性提交守卫**(`feishuLoginTriggered` ref)防止同一飞书 code 被并发重复提交。 +- `components/login/index.tsx` `postLogin()`:账号 / 手机登录已携带 `client_id` / `client_redirect_uri`,`handleLoginSuccess` 已按 `{ code }` 重定向回 external-app。 +- 飞书扫码 tab 已暂时隐藏(`Tabs.Tab value="feishu"` 注释 + `Tabs.Panel` 以 `{false && ...}` 屏蔽),恢复时取消对应注释即可。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c5ea4b3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# build first +FROM harbor.cowarobot.cn/docker.io/node:20-alpine +RUN mkdir -p /app +WORKDIR /app +ENV NODE_ENV production +COPY ./.next/standalone ./standalone +COPY ./.next/static /app/standalone/.next/static + +ENV PORT 5501 + +ENV NEXT_TELEMETRY_DISABLED 1 + +EXPOSE 5501 + +CMD ["node", "./standalone/server.js"] \ No newline at end of file diff --git a/INTEGRATION_GUIDE.md b/INTEGRATION_GUIDE.md new file mode 100644 index 0000000..250c143 --- /dev/null +++ b/INTEGRATION_GUIDE.md @@ -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_secret": "", + "code": "", + "grant_type": "authorization_code", + "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 +``` + +`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)。 diff --git a/README.md b/README.md index 5136eb2..67ff0d9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# Mock SSO +# SSO Portal -基于 Next.js 16 + Mantine 8 的 Mock SSO 登录服务,登录页复用 uirefbase 的 UI 风格。 +基于 Next.js 16 + Mantine 8 的 SSO Portal 登录服务,登录页复用 uirefbase 的 UI 风格。 + +> 📘 **接入指南**:[INTEGRATION_GUIDE.md](./INTEGRATION_GUIDE.md) —— 面向其他前端应用的通用接入文档(OAuth2 授权码流程 / 环境变量 / 安全 / FAQ / 后端接口速查)。 ## 快速开始 @@ -18,6 +20,7 @@ pnpm dev # http://localhost:5501 |------|------|------| | `NEXT_PUBLIC_BASE_PATH` | 部署子路径,无则留空 | `""` | | `NEXT_PUBLIC_SHARED_API_PATH` | 后端 basis 服务地址(登录 / 验证码 / 租户列表都走这里) | dev: `http://172.16.115.31:6610/`
prod: `http://basis-app-svc:6610` | +| `SSO_TENANT` | SSO 接口的租户标识(account / phone-smscode / access_token 共用) | `cowarobot` | > 无密钥,可安全入库。Next.js 会按 `NODE_ENV` 自动加载对应文件(`next dev` → development,`next build/start` → production)。 @@ -30,7 +33,7 @@ pnpm dev # http://localhost:5501 | `user01` | `123456` | 测试用户 | | `zhangsan` | `123456` | 张三 | -手机号登录:任意 11 位手机号 + 任意 6 位验证码即可。 +手机号登录:验证码由真实后端 `/api/v1/basis/user/phone/code` 发送,需使用后端真实账号对应的手机号(账号同理以真实后端数据为准)。 ## SSO 登录流程 @@ -48,24 +51,33 @@ http://localhost:5501/login?app_id=your_app_id&app_url=http%3A%2F%2Flocalhost%3A http://localhost:3000/callback?sso_code=xxxxx ``` -### 4. 外部应用用 code 换取用户信息 +### 4. 外部应用用 code 换取 access_token + +外部应用拿到 `sso_code` 后,由其**服务端**直接调真实后端换 token(sso-portal 不再提供 code 换信息接口): ```bash -curl -X POST http://localhost:5501/api/auth/verify \ +curl -X POST http://172.16.115.31:6610/api/v1/basis/sso/access_token \ -H "Content-Type: application/json" \ - -d '{"code": "your_sso_code"}' + -d '{ + "client_id": "6wds5qua3b76hu748zrjmall", + "client_secret": "<服务端密钥>", + "code": "your_sso_code", + "grant_type": "authorization_code", + "tenant": "cowarobot" + }' ``` -返回用户信息和 token。 +返回 `access_token`(及可选 `refresh_token` / `user`),外部应用据此建立会话。 ## 直接访问 -不带 `app_id` / `app_url` 参数访问 `/login`,登录成功后跳转到 `/dashboard` 管理后台。 +> 当前 sso-portal 为纯 SSO 透传模式:`/login` 必须带 `app_id` / `app_url` 参数,登录成功后回跳外部应用;不再支持无 SSO 参数的本地登录(缺参时后端登录接口会拒绝签发授权 code)。 ## API | 接口 | 方法 | 说明 | |------|------|------| -| `/api/auth/login` | POST | 登录(返回 code 或 token) | -| `/api/auth/verify` | POST | 验证 code 换取用户信息 | +| `/api/login` | POST | 账号 / 手机登录,透传后端 `/sso/*` 返回授权 code | +| `/api/getVerifyCode` | POST | 发送手机验证码(透传 `/api/v1/basis/user/phone/code`) | +| `/api/front/getAbbrList` | GET | 租户简称列表 | | `/api/mock/users` | GET | 测试用户列表 | diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts deleted file mode 100644 index f134270..0000000 --- a/app/api/auth/verify/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { NextResponse } from 'next/server' -import { codeStore } from '@/utils/code-store' - -/** - * SSO Code 验证接口 - * - * 外部应用拿到 sso_code 后,调用此接口换取用户信息。 - * POST /api/auth/verify - * Body: { code: string } - */ -export async function POST(req: Request) { - try { - const { code } = await req.json() - - if (!code) { - return NextResponse.json({ error: 'code 不能为空' }, { status: 400 }) - } - - const entry = codeStore.get(code) - if (!entry) { - return NextResponse.json({ error: 'code 无效或已过期' }, { status: 401 }) - } - - if (entry.expires < Date.now()) { - codeStore.delete(code) - return NextResponse.json({ error: 'code 已过期' }, { status: 401 }) - } - - // 验证通过,删除已使用的 code(一次性) - codeStore.delete(code) - - // 返回用户信息和 token - const token = - 'mock_token_' + - Math.random().toString(36).substring(2) + - '_' + - Date.now() - - return NextResponse.json({ - token, - refresh_token: 'mock_refresh_' + Date.now(), - user: entry.user, - permissions: [ - { object: '/dashboard', action: 'view' }, - { object: '/settings', action: 'view' }, - ], - }) - } catch (err: any) { - return NextResponse.json( - { error: err?.message || '验证失败' }, - { status: 500 } - ) - } -} diff --git a/app/api/front/getAbbrList/route.ts b/app/api/front/getAbbrList/route.ts index 26e8fda..87918ed 100644 --- a/app/api/front/getAbbrList/route.ts +++ b/app/api/front/getAbbrList/route.ts @@ -6,7 +6,7 @@ const SHARED_API = ( export async function GET() { const fetchUrl = `${SHARED_API}/api/v1/basis/tenant/base/listabbr` - console.log('[sso-mock] getAbbrList:', fetchUrl) + console.log('[sso-portal] getAbbrList:', fetchUrl) try { const res = await fetch(fetchUrl, { @@ -24,7 +24,7 @@ export async function GET() { const data = await res.json() return NextResponse.json(data, { status: 200 }) } catch (err: any) { - console.error('[sso-mock] getAbbrList error:', err.message) + console.error('[sso-portal] getAbbrList error:', err.message) return NextResponse.json( { message: err?.message || '获取租户信息失败' }, { status: 500 } diff --git a/app/api/getVerifyCode/route.ts b/app/api/getVerifyCode/route.ts index 9cde1cc..4c7a9df 100644 --- a/app/api/getVerifyCode/route.ts +++ b/app/api/getVerifyCode/route.ts @@ -13,7 +13,7 @@ const SHARED_API = ( export async function POST(req: Request) { const body = await req.json() const fetchUrl = `${SHARED_API}/api/v1/basis/user/phone/code` - console.log('[sso-mock] getVerifyCode →', fetchUrl, '| phone:', body?.phone) + console.log('[sso-portal] getVerifyCode →', fetchUrl, '| phone:', body?.phone) try { const res = await fetch(fetchUrl, { @@ -41,7 +41,7 @@ export async function POST(req: Request) { return NextResponse.json(data, { status: 200 }) } catch (err: any) { - console.error('[sso-mock] getVerifyCode error:', err.message) + console.error('[sso-portal] getVerifyCode error:', err.message) return NextResponse.json( { code: 500, message: err?.message || '验证码发送失败' }, { status: 500 } diff --git a/app/api/login/route.ts b/app/api/login/route.ts index ef641ee..d562365 100644 --- a/app/api/login/route.ts +++ b/app/api/login/route.ts @@ -1,45 +1,47 @@ -import { NextResponse } from 'next/server' -import CryptoJS from 'crypto-js' -import { codeStore } from '@/utils/code-store' +import CryptoJS from "crypto-js"; +import { NextResponse } from "next/server"; -const CODE_CRYPTO_KEY = 'k#8Mp$2Qr&9Tz@4W' +const CODE_CRYPTO_KEY = "k#8Mp$2Qr&9Tz@4W"; const SHARED_API = ( - process.env.NEXT_PUBLIC_SHARED_API_PATH || 'http://172.16.115.31:6610' -).replace(/\/+$/, '') + process.env.NEXT_PUBLIC_SHARED_API_PATH || "http://172.16.115.31:6610" +).replace(/\/+$/, ""); + +// SSO 接口的租户标识(account / phone-smscode / access_token 共用,由环境变量驱动) +const SSO_TENANT = process.env.SSO_TENANT || "cowarobot"; function decrypt(encrypted: string): string { try { - const bytes = CryptoJS.AES.decrypt(encrypted, CODE_CRYPTO_KEY) - return bytes.toString(CryptoJS.enc.Utf8) + const bytes = CryptoJS.AES.decrypt(encrypted, CODE_CRYPTO_KEY); + return bytes.toString(CryptoJS.enc.Utf8); } catch { - return encrypted + return encrypted; } } -function generateCode(): string { - return Math.random().toString(36).substring(2, 10) + Date.now().toString(36) -} - -async function callBasisApi(path: string, body: object) { - const url = `${SHARED_API}${path}` - console.log('[sso-mock] →', url) +/** + * 调用真实后端 SSO 端点。 + * 透传响应(含后端签发的一次性授权 code),不再自造 code。 + */ +async function callSsoApi(path: string, body: object) { + const url = `${SHARED_API}${path}`; + console.log("[sso-portal] →", url, body); const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), - }) + }); - const text = await res.text() - let data: any + const text = await res.text(); + let data: any; try { - data = JSON.parse(text) + data = JSON.parse(text); } catch { - throw new Error(`后端返回非 JSON [${res.status}]: ${text.slice(0, 200)}`) + throw new Error(`后端返回非 JSON [${res.status}]: ${text.slice(0, 200)}`); } - return data + return { data, status: res.status }; } // ─── POST /api/login ─── @@ -47,79 +49,82 @@ async function callBasisApi(path: string, body: object) { // req.json() 去掉外层引号得到加密字符串,decrypt 解密得到原始 JSON export async function POST(req: Request) { try { - const decryptData = await req.json() // JSON 字符串 → 去掉引号 → 加密原文 - const body = JSON.parse(decrypt(decryptData)) + const decryptData = await req.json(); // JSON 字符串 → 去掉引号 → 加密原文 + const body = JSON.parse(decrypt(decryptData)); - const type = body?.type - console.log('[sso-mock] login | type:', type, '| body keys:', Object.keys(body || {})) + const type = body?.type; + console.log( + "[sso-portal] login | type:", + type, + "| body keys:", + Object.keys(body || {}), + ); if (!type) { - return NextResponse.json({ error: '缺少 type 字段', debug_type: typeof type }) + return NextResponse.json({ + error: "缺少 type 字段", + debug_type: typeof type, + }); } - let res: any + const client_id = body.client_id; + const client_redirect_uri = body.client_redirect_uri; - if (type === 'account') { - res = await callBasisApi('/api/v1/basis/login/account', { + // OAuth2 授权码流程:client_id / client_redirect_uri 为必填(授权 code 必须绑定应用) + if (!client_id || !client_redirect_uri) { + return NextResponse.json({ + error: "缺少 client_id 或 client_redirect_uri,无法签发授权 code", + }); + } + + let path: string; + let ssoBody: Record; + + if (type === "account") { + path = "/api/v1/basis/sso/account"; + ssoBody = { account: body.account, password: body.password, - abbr: body.abbr, - }) - } else if (type === 'phone') { - res = await callBasisApi('/api/v1/basis/user/phone/login', { + tenant: SSO_TENANT, + client_id, + client_redirect_uri, + }; + } else if (type === "phone") { + path = "/api/v1/basis/sso/phone/smscode"; + ssoBody = { phone: body.phone, - verify_code: body.verify_code, - tntkey: body.tntkey, - }) - } else if (type === 'feishu') { - res = await callBasisApi('/api/v1/basis/login/feishu', { - code: body.code, - redirect_uri: body.redirect_uri, - tenant_abbr: body.tenant, - }) + smscode: body.verify_code, + tenant: SSO_TENANT, + client_id, + client_redirect_uri, + }; } else { - console.error('[sso-mock] 未知 type:', type) - return NextResponse.json({ error: `不支持的登录方式: ${type}` }) + console.error("[sso-portal] 未知 type:", type); + return NextResponse.json({ error: `不支持的登录方式: ${type}` }); } - console.log('[sso-mock] backend res.code:', res?.code) + const { data: ssoRes, status } = await callSsoApi(path, ssoBody); + console.log( + "[sso-portal] backend status:", + status, + "| res:", + JSON.stringify(ssoRes).slice(0, 300), + ); - // 后端业务码非 200 视为失败,直接返回错误,不生成 code - if (res?.code !== 200) { - const message = res?.message || res?.msg || '登录失败' - console.log('[sso-mock] backend error:', message) - return NextResponse.json({ error: message }) + // ⚠️ code 在新流程里是「授权码字符串」,不再是业务状态码。 + // 兼容平铺 { code } 与外壳 { code:200, data:{ code } } 两种响应。 + const authCode = ssoRes?.data?.code ?? ssoRes?.code; + if (!authCode || typeof authCode !== "string") { + const message = + ssoRes?.message || ssoRes?.msg || "登录失败,后端未返回授权 code"; + console.log("[sso-portal] no auth code:", message); + return NextResponse.json({ error: message }); } - const user = res.data?.user - if (!user || !user.account) { - console.log('[sso-mock] backend returned 200 but no valid user') - return NextResponse.json({ error: '后端未返回有效用户信息' }) - } - - const tenant = user?.tntkey || body.abbr || 'coowa' - const user_info = user - const rbac = res?.data?.rbac || { data: [] } - const token = res.data?.access_token || '' - - const responseData: any = { tenant, user_info, rbac, token } - - // SSO 模式:仅当 client_id + client_redirect_uri 都存在时生成 code - const client_id = body.client_id - const client_redirect_uri = body.client_redirect_uri - if (client_id && client_redirect_uri) { - const code = generateCode() - codeStore.set(code, { - user: user_info, - expires: Date.now() + 5 * 60 * 1000, - }) - responseData.code = code - console.log('[sso-mock] code generated →', code) - } - - return NextResponse.json(responseData) + console.log("[sso-portal] auth code →", authCode); + return NextResponse.json({ code: authCode }); } catch (err: any) { - console.error('[sso-mock] login error:', err.message) - return NextResponse.json({ error: err?.message || '登录失败' }) + console.error("[sso-portal] login error:", err.message); + return NextResponse.json({ error: err?.message || "登录失败" }); } } diff --git a/app/layout.tsx b/app/layout.tsx index d336550..de96ac7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -12,8 +12,8 @@ import '@mantine/core/styles.layer.css' import '@mantine/notifications/styles.css' export const metadata: Metadata = { - title: 'Mock SSO', - description: 'Mock SSO Login Provider', + title: 'SSO Portal', + description: 'SSO Login Portal', } export default function RootLayout({ diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..f060193 --- /dev/null +++ b/build.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +SHELL_FOLDER=$(cd "$(dirname "$0")";pwd) +HOME_PATH=$(cd ~;pwd) + +# 用法: +# ./build.sh 预发布(默认 staging) +# ./build.sh prod 生产 +TAG=stage_latest +BUILD_FLAG=build:stage +if [[ $1 == prod ]]; then + TAG=prod_latest + BUILD_FLAG=build:prod +fi + +cd $SHELL_FOLDER + +# # 检查 git 状态 +# echo -e "${GREEN}Checking git status...${NC}" +# git fetch +# git merge +# if [[ $? != 0 ]]; then echo -e "${RED}git merge error${NC}"; exit 100; fi + +# BRANCH_NAME=`git branch | grep "*"` +# echo -e "${GREEN}Current branch: ${BRANCH_NAME/* /}${NC}" + +# 安装依赖 +echo -e "${GREEN}Installing dependencies...${NC}" +pnpm install +if [[ $? != 0 ]]; then echo -e "${RED}pnpm install failed${NC}"; exit 100; fi + +# 执行构建 +echo -e "${GREEN}Building project...${NC}" +pnpm run $BUILD_FLAG +if [[ $? != 0 ]]; then echo -e "${RED}build error${NC}"; exit 100; fi + +# Docker 构建和推送 +MAIN_NAME=web_sso_portal +DOCKER_FILE=$SHELL_FOLDER/Dockerfile +HARBOR_REPO=softrepo +if [[ $ENV == prod ]]; then + HARBOR_REPO=softpro +fi + +echo -e "${GREEN}Building docker image...${NC}" +docker build -f $DOCKER_FILE -t $MAIN_NAME $SHELL_FOLDER +if [[ $? != 0 ]]; then echo -e "${RED}build docker with error${NC}"; exit 100; fi + +echo -e "${GREEN}Tagging and pushing docker image...${NC}" +docker tag $MAIN_NAME harbor.cowarobot.cn/$HARBOR_REPO/$MAIN_NAME:$TAG +docker push harbor.cowarobot.cn/$HARBOR_REPO/$MAIN_NAME:$TAG + +echo -e "${GREEN}Build completed successfully${NC}" \ No newline at end of file diff --git a/components/login/components/login/qr-login.tsx b/components/login/components/login/qr-login.tsx index fe0bbc3..d08864d 100644 --- a/components/login/components/login/qr-login.tsx +++ b/components/login/components/login/qr-login.tsx @@ -12,7 +12,7 @@ import { } from '../../util' /** - * 飞书二维码登录组件(sso-mock 版本) + * 飞书二维码登录组件(sso-portal 版本) * * 流程: * 1. 加载飞书 SDK,渲染真实二维码 @@ -25,7 +25,7 @@ const QrLogin = ({ }: { onLoginSuccess: (res: any) => void }) => { - // 飞书 OAuth 回调地址:始终指向 sso-mock 自己的 /login + // 飞书 OAuth 回调地址:始终指向 sso-portal 自己的 /login const redirect_url = window.location.protocol + '//' + @@ -47,7 +47,7 @@ const QrLogin = ({ const renderCode = useCallback(async () => { await qr_load() // APP_ID: 飞书应用 ID - // redirect_url: 飞书 OAuth 回调地址(sso-mock 自身) + // redirect_url: 飞书 OAuth 回调地址(sso-portal 自身) // state: SSO 的 app_id(传给飞书,回调时原样返回) qr_login(APP_ID, redirect_url, ssoAppId) }, [redirect_url, ssoAppId]) diff --git a/components/login/index.tsx b/components/login/index.tsx index 6af39e6..349d11a 100644 --- a/components/login/index.tsx +++ b/components/login/index.tsx @@ -1,361 +1,382 @@ -'use client' +"use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useForm } from '@mantine/form' +import { showErrorMsg, showSuccessMsg } from "@/utils/message"; import { - Box, - TextInput, - PasswordInput, - Paper, - Text, - Button, - LoadingOverlay, - Stack, - Flex, + Alert, BackgroundImage, - Tabs, + Box, + Button, + Flex, FloatingIndicator, Input, + LoadingOverlay, + Paper, + PasswordInput, + Stack, + Tabs, + Text, + TextInput, useComputedColorScheme, -} from '@mantine/core' -import { showErrorMsg, showSuccessMsg } from '@/utils/message' -import { useLoginStore } from './store' -import DarkBgImage from './components/images/dark-login-bg.png' -import LightBgImage from './components/images/light-login-bg.png' -import loginBg2Dark from './components/images/login-bg2-dark.png' -import loginBg2Light from './components/images/login-bg2-light.png' -import LogoDigImage from './components/images/logo-dig.png' -import tabClasses from './Tab.module.css' -import { DigitalIcon } from './components/icons/digital' -import { encrypt, getQueryVariable, validatePhoneNumber } from './util' -import QrLogin from './components/login/qr-login' +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { IconAlertCircle } from "@tabler/icons-react"; +import { DigitalIcon } from "./components/icons/digital"; +import DarkBgImage from "./components/images/dark-login-bg.png"; +import LightBgImage from "./components/images/light-login-bg.png"; +import loginBg2Dark from "./components/images/login-bg2-dark.png"; +import loginBg2Light from "./components/images/login-bg2-light.png"; +import LogoDigImage from "./components/images/logo-dig.png"; +import QrLogin from "./components/login/qr-login"; +import { useLoginStore } from "./store"; +import tabClasses from "./Tab.module.css"; +import { encrypt, getQueryVariable, validatePhoneNumber } from "./util"; -const HeaderIcon = DigitalIcon -const headerTitle = '酷哇数字化平台' -const fixedTenant = 'coowa' +const HeaderIcon = DigitalIcon; +const headerTitle = "酷哇数字化平台"; +const fixedTenant = "coowa"; export default function LoginPage() { - const colorScheme = useComputedColorScheme() - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [addrMap, setAddrMap] = useState>({}) + const colorScheme = useComputedColorScheme(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [addrMap, setAddrMap] = useState>({}); const [activeTab, setActiveTab] = useState(() => { // 飞书 OAuth 回调(URL 带 ?code=xxx)时自动切到飞书 tab - if (typeof window !== 'undefined' && getQueryVariable('code')) { - return 'feishu' + if (typeof window !== "undefined" && getQueryVariable("code")) { + return "feishu"; } - return 'account' - }) - const [rootRef, setRootRef] = useState(null) - const controlsRefs = useRef>({}) + return "account"; + }); + const [rootRef, setRootRef] = useState(null); + const controlsRefs = useRef>({}); const setControlRef = (val: string) => (node: HTMLButtonElement) => { - controlsRefs.current[val] = node - } + controlsRefs.current[val] = node; + }; - const { fingerprint, setFingerprint } = useLoginStore() + const { fingerprint, setFingerprint } = useLoginStore(); // SSO 跳转参数 - const isClient = typeof window !== 'undefined' - const appId = isClient ? (getQueryVariable('app_id') as string) || '' : '' + const isClient = typeof window !== "undefined"; + const appId = isClient ? (getQueryVariable("app_id") as string) || "" : ""; const appUrl = isClient - ? decodeURIComponent((getQueryVariable('app_url') as string) || '') - : '' + ? decodeURIComponent((getQueryVariable("app_url") as string) || "") + : ""; + + // 缺少 app_id 或 app_url 时不允许登录(sso-portal 自身不需要登录) + const missingSsoParams = !appId || !appUrl; // 持久化 SSO 参数:飞书 OAuth 跳转后会丢失 URL 参数,存 localStorage useEffect(() => { if (appId && appUrl) { - localStorage.setItem(appId, appUrl) // app_id -> app_url - localStorage.setItem('sso_app_id', appId) // 记住当前 app_id + localStorage.setItem(appId, appUrl); // app_id -> app_url + localStorage.setItem("sso_app_id", appId); // 记住当前 app_id } - }, [appId, appUrl]) + }, [appId, appUrl]); // 获取租户简称列表(与 uirefbase 一致) useEffect(() => { - fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/front/getAbbrList`, { - method: 'GET', - headers: { 'Content-type': 'application/json' }, + fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/api/front/getAbbrList`, { + method: "GET", + headers: { "Content-type": "application/json" }, }) .then((response) => response.json()) .then((res) => { - setAddrMap(res?.data?.keys || {}) + setAddrMap(res?.data?.keys || {}); }) - .catch(() => {}) - }, []) + .catch(() => {}); + }, []); // 生成浏览器指纹 useEffect(() => { - const ua = navigator.userAgent - const screenInfo = `${screen.width}x${screen.height}` - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone - const raw = `${ua}|${screenInfo}|${timezone}` - let hash = 0 + const ua = navigator.userAgent; + const screenInfo = `${screen.width}x${screen.height}`; + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const raw = `${ua}|${screenInfo}|${timezone}`; + let hash = 0; for (let i = 0; i < raw.length; i++) { - hash = (hash << 5) - hash + raw.charCodeAt(i) - hash |= 0 + hash = (hash << 5) - hash + raw.charCodeAt(i); + hash |= 0; } - setFingerprint(Math.abs(hash).toString(36)) - }, [setFingerprint]) + setFingerprint(Math.abs(hash).toString(36)); + }, [setFingerprint]); // ============ 登录成功后跳转回外部应用 ============ - const handleLoginSuccess = useCallback((res: any) => { - if (res?.error) { - setError(res.error || '登录失败,请重试') - return - } + const handleLoginSuccess = useCallback( + (res: any) => { + if (res?.error) { + setError(res.error || "登录失败,请重试"); + return; + } - if (!res?.user_info?.account) { - setError('登录失败:后端未返回有效用户信息') - return - } + // if (!res?.user_info?.account) { + // setError('登录失败:后端未返回有效用户信息') + // return + // } - // 从 localStorage 获取外部应用地址 - const storedAppId = appId || localStorage.getItem('sso_app_id') || '' - const targetUrl = - appUrl || (storedAppId ? localStorage.getItem(storedAppId) || '' : '') + // 从 localStorage 获取外部应用地址 + const storedAppId = appId || localStorage.getItem("sso_app_id") || ""; + const targetUrl = + appUrl || (storedAppId ? localStorage.getItem(storedAppId) || "" : ""); - if (res?.code && targetUrl) { - const separator = targetUrl.includes('?') ? '&' : '?' - window.location.href = `${targetUrl}${separator}sso_code=${res.code}` - return - } + if (res?.code && targetUrl) { + const separator = targetUrl.includes("?") ? "&" : "?"; + window.location.href = `${targetUrl}${separator}sso_code=${res.code}`; + return; + } - // 无外部应用地址 — sso-mock 不提供本地登录 - setError('缺少外部应用回调地址(app_url),无法完成 SSO 跳转') - }, [appId, appUrl]) + // 无外部应用地址 — sso-portal 不提供本地登录 + setError("缺少外部应用回调地址(app_url),无法完成 SSO 跳转"); + }, + [appId, appUrl], + ); // ============ 统一调用 /api/login ============ // SSO 参数优先取 URL,其次取 localStorage(飞书 OAuth 回调后 URL 参数丢失) const postLogin = async (payload: object) => { - const effectiveAppId = appId || localStorage.getItem('sso_app_id') || '' + const effectiveAppId = appId || localStorage.getItem("sso_app_id") || ""; const effectiveAppUrl = - appUrl || (effectiveAppId ? localStorage.getItem(effectiveAppId) || '' : '') + appUrl || + (effectiveAppId ? localStorage.getItem(effectiveAppId) || "" : ""); const res = await fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/login`, + `${process.env.NEXT_PUBLIC_BASE_PATH || ""}/api/login`, { - method: 'POST', + method: "POST", body: JSON.stringify( encrypt( JSON.stringify({ ...payload, fingerprint, ...(effectiveAppId ? { client_id: effectiveAppId } : {}), - ...(effectiveAppUrl ? { client_redirect_uri: effectiveAppUrl } : {}), - }) - ) + ...(effectiveAppUrl + ? { client_redirect_uri: effectiveAppUrl } + : {}), + }), + ), ), - } + }, ) .then((r) => r.text()) - .then((t) => JSON.parse(t)) + .then((t) => JSON.parse(t)); - return res - } + return res; + }; // ============ Tab 1: 账户登录 ============ const accountForm = useForm({ - initialValues: { username: '', password: '' }, + initialValues: { username: "", password: "" }, validate: { - username: (v) => (!v ? '账户名不能为空' : null), - password: (v) => (!v ? '密码不能为空' : null), + username: (v) => (!v ? "账户名不能为空" : null), + password: (v) => (!v ? "密码不能为空" : null), }, validateInputOnChange: true, validateInputOnBlur: true, clearInputErrorOnChange: true, - }) + }); const handleAccountSubmit = accountForm.onSubmit(async (values) => { - setLoading(true) - setError(null) + setLoading(true); + setError(null); try { const res = await postLogin({ account: values.username, password: values.password, abbr: fixedTenant, - type: 'account', - }) - handleLoginSuccess(res) + type: "account", + }); + handleLoginSuccess(res); } catch (err) { - setError(err instanceof Error ? err.message : '登录失败,请重试') + setError(err instanceof Error ? err.message : "登录失败,请重试"); } finally { - setLoading(false) + setLoading(false); } - }) + }); // ============ Tab 2: 手机号登录 ============ - const [codeTime, setCodeTime] = useState(0) + const [codeTime, setCodeTime] = useState(0); const handleCountDown = () => { - setCodeTime(60) + setCodeTime(60); const timer = setInterval(() => { setCodeTime((t) => { if (t <= 1) { - clearInterval(timer) - return 0 + clearInterval(timer); + return 0; } - return t - 1 - }) - }, 1000) - } + return t - 1; + }); + }, 1000); + }; const handleGetCode = async () => { if (!validatePhoneNumber(phoneForm.values.phone)) { - setError('请输入正确的手机号') - return + setError("请输入正确的手机号"); + return; } try { - const tntkey = addrMap?.[fixedTenant] || '' + const tntkey = addrMap?.[fixedTenant] || ""; if (!tntkey) { - showErrorMsg('请输入正确的租户') - return + showErrorMsg("请输入正确的租户"); + return; } const res: any = await fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/getVerifyCode`, + `${process.env.NEXT_PUBLIC_BASE_PATH || ""}/api/getVerifyCode`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - phone: phoneForm.values.phone.startsWith('+86') + phone: phoneForm.values.phone.startsWith("+86") ? phoneForm.values.phone : `+86${phoneForm.values.phone}`, tntkey, }), - } - ).then((r) => r.json()) + }, + ).then((r) => r.json()); if (res?.code === 200) { - showSuccessMsg('验证码已发送, 请注意查收') - handleCountDown() + showSuccessMsg("验证码已发送, 请注意查收"); + handleCountDown(); } else { - showErrorMsg(res?.message || '验证码发送失败') + showErrorMsg(res?.message || "验证码发送失败"); } } catch { - showSuccessMsg('验证码已发送') - handleCountDown() + showSuccessMsg("验证码已发送"); + handleCountDown(); } - } + }; const phoneForm = useForm({ - initialValues: { phone: '', code: '' }, + initialValues: { phone: "", code: "" }, validate: { phone: (v) => { - if (!v) return '手机号不能为空' - if (v.length !== 11) return '手机号必须为11位' - return null + if (!v) return "手机号不能为空"; + if (v.length !== 11) return "手机号必须为11位"; + return null; }, code: (v) => { - if (!v) return '验证码不能为空' - if (v.length !== 6) return '验证码必须为6位' - return null + if (!v) return "验证码不能为空"; + if (v.length !== 6) return "验证码必须为6位"; + return null; }, }, validateInputOnChange: true, validateInputOnBlur: true, clearInputErrorOnChange: true, - }) + }); const handlePhoneSubmit = phoneForm.onSubmit(async (values) => { - setLoading(true) - setError(null) + setLoading(true); + setError(null); try { - const tntkey = addrMap?.[fixedTenant] || '' + const tntkey = addrMap?.[fixedTenant] || ""; if (!tntkey) { - setError('请输入正确的租户') - setLoading(false) - return + setError("请输入正确的租户"); + setLoading(false); + return; } const res = await postLogin({ - phone: values.phone.startsWith('+86') + phone: values.phone.startsWith("+86") ? values.phone : `+86${values.phone}`, verify_code: values.code, tntkey, - type: 'phone', - }) - handleLoginSuccess(res) + type: "phone", + }); + handleLoginSuccess(res); } catch (err) { - setError(err instanceof Error ? err.message : '登录失败,请重试') + setError(err instanceof Error ? err.message : "登录失败,请重试"); } finally { - setLoading(false) + setLoading(false); } - }) + }); // ============ 错误提示 ============ useEffect(() => { - if (error) showErrorMsg(error) - }, [error]) + if (error) showErrorMsg(error); + }, [error]); const bgImage = useMemo( - () => (colorScheme === 'dark' ? DarkBgImage : LightBgImage), - [colorScheme] - ) + () => (colorScheme === "dark" ? DarkBgImage : LightBgImage), + [colorScheme], + ); const stackBg2Image = useMemo( - () => (colorScheme === 'dark' ? loginBg2Dark : loginBg2Light), - [colorScheme] - ) + () => (colorScheme === "dark" ? loginBg2Dark : loginBg2Light), + [colorScheme], + ); // 飞书 OAuth 回调中:显示 loading,不渲染表单 - const isFeishuCallback = isClient && !!getQueryVariable('code') + const isFeishuCallback = isClient && !!getQueryVariable("code"); if (isFeishuCallback) { return ( + bgr="no-repeat" + > - 正在验证飞书登录... + + 正在验证飞书登录... + {/* QrLogin 仍在后台执行 feishuLogin */} - + - ) + ); } return ( + bgr="no-repeat" + > - + maw={"100%"} + style={{ borderColor: "rgba(255, 255, 255, 0.5)" }} + > + {/* 左侧装饰区域 */} + style={{ position: "relative" }} + > + style={{ position: "absolute", top: 0, left: 0 }} + > + style={{ zIndex: 11 }} + > {/* 右侧登录表单 */} - - + + {headerTitle} - {/* SSO 来源提示 */} - {appId && ( - - 正在为应用 {appId} 进行登录认证 - + {/* 缺少 SSO 参数时顶部提示;有参数时显示来源 */} + {missingSsoParams ? ( + } + > + + 缺少 SSO 参数(app_id / app_url),无法登录。 +
+ 请从外部应用跳转到此页面。 +
+
+ ) : ( + appId && ( + + 正在为应用 {appId} 进行登录认证 + + ) )} + ref={setControlRef("account")} + className={tabClasses.tab} + > 账户登录 + ref={setControlRef("phone")} + className={tabClasses.tab} + > 手机号登录 + {/* TODO: 暂时隐藏飞书扫码 tab,恢复时取消下方注释 + 同步恢复 Tabs.Panel value="feishu" */} + {/* 飞书扫码 + */}
+ style={{ marginTop: "30px" }} + > - @@ -437,54 +481,60 @@ export default function LoginPage() {
+ style={{ marginTop: "30px" }} + > + error={phoneForm.errors.code} + > -
- {/* 飞书扫码(真实二维码) */} - - {isClient && ( - - )} - + {/* 飞书扫码(真实二维码) - 暂时隐藏 */} + {false && ( + + {isClient && } + + )}
- ) + ); } diff --git a/package.json b/package.json index 32daa4b..6440edd 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { - "name": "sso-mock", + "name": "sso-portal", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev -p 5501", - "build": "next build", + "build:stage": "env-cmd -f .env.staging next build", + "build": "env-cmd -f .env.production next build", "start": "next start" }, "dependencies": { @@ -25,6 +26,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "env-cmd": "^11.0.0", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", "typescript": "^5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7689c4b..a48e177 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.17) + env-cmd: + specifier: ^11.0.0 + version: 11.0.0 postcss-preset-mantine: specifier: ^1.18.0 version: 1.18.0(postcss@8.4.31) @@ -70,6 +73,11 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} + '@commander-js/extra-typings@13.1.0': + resolution: {integrity: sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg==} + peerDependencies: + commander: ~13.1.0 + '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} @@ -371,6 +379,14 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} @@ -392,6 +408,11 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + env-cmd@11.0.0: + resolution: {integrity: sha512-gnG7H1PlwPqsGhFJNTv68lsDGyQdK+U9DwLVitcj1+wGq7LeOBgUzZd2puZ710bHcH9NfNeGWe2sbw7pdvAqDw==} + engines: {node: '>=20.10.0'} + hasBin: true + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -408,6 +429,9 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -450,6 +474,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -569,6 +597,14 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -664,6 +700,11 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + zustand@5.0.14: resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} engines: {node: '>=12.20.0'} @@ -686,6 +727,10 @@ snapshots: '@babel/runtime@7.29.7': {} + '@commander-js/extra-typings@13.1.0(commander@13.1.0)': + dependencies: + commander: 13.1.0 + '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 @@ -909,6 +954,14 @@ snapshots: clsx@2.1.1: {} + commander@13.1.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + crypto-js@4.2.0: {} cssesc@3.0.0: {} @@ -925,6 +978,12 @@ snapshots: '@babel/runtime': 7.29.7 csstype: 3.2.3 + env-cmd@11.0.0: + dependencies: + '@commander-js/extra-typings': 13.1.0(commander@13.1.0) + commander: 13.1.0 + cross-spawn: 7.0.6 + fast-deep-equal@3.1.3: {} fdir@6.5.0(picomatch@4.0.5): @@ -933,6 +992,8 @@ snapshots: get-nonce@1.0.1: {} + isexe@2.0.0: {} + js-tokens@4.0.0: {} klona@2.0.6: {} @@ -968,6 +1029,8 @@ snapshots: object-assign@4.1.1: {} + path-key@3.1.1: {} + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -1113,6 +1176,12 @@ snapshots: '@img/sharp-win32-x64': 0.34.5 optional: true + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + source-map-js@1.2.1: {} styled-jsx@5.1.6(react@19.2.0): @@ -1175,6 +1244,10 @@ snapshots: util-deprecate@1.0.2: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 + zustand@5.0.14(@types/react@19.2.17)(react@19.2.0): optionalDependencies: '@types/react': 19.2.17 diff --git a/utils/code-store.ts b/utils/code-store.ts deleted file mode 100644 index 5f9c6f7..0000000 --- a/utils/code-store.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Mock SSO 认证码存储(内存) - * 生产环境应使用 Redis 等持久化方案 - */ - -interface MockUser { - id: string - account: string - name: string - phone: string - avatar: string - roles: string[] -} - -interface CodeEntry { - user: MockUser - expires: number -} - -// 使用 globalThis 避免 Next.js 开发模式下 HMR 导致数据丢失 -const globalForCodeStore = globalThis as unknown as { - __ssoCodeStore: Map | undefined -} - -if (!globalForCodeStore.__ssoCodeStore) { - globalForCodeStore.__ssoCodeStore = new Map() -} - -export const codeStore = globalForCodeStore.__ssoCodeStore! - -// 定时清理过期 code(仅在非 edge 环境) -if (typeof setInterval !== 'undefined') { - setInterval(() => { - const now = Date.now() - for (const [key, val] of codeStore) { - if (val.expires < now) codeStore.delete(key) - } - }, 60_000) -} - -export type { MockUser, CodeEntry }