diff --git a/components/login/index.tsx b/components/login/index.tsx index 349d11a..caa6226 100644 --- a/components/login/index.tsx +++ b/components/login/index.tsx @@ -35,6 +35,7 @@ import { encrypt, getQueryVariable, validatePhoneNumber } from "./util"; const HeaderIcon = DigitalIcon; const headerTitle = "酷哇数字化平台"; const fixedTenant = "coowa"; +const ssoStateStorageKey = (appId: string) => `sso_state:${appId}`; export default function LoginPage() { const colorScheme = useComputedColorScheme(); @@ -59,20 +60,24 @@ export default function LoginPage() { // SSO 跳转参数 const isClient = typeof window !== "undefined"; const appId = isClient ? (getQueryVariable("app_id") as string) || "" : ""; - const appUrl = isClient - ? decodeURIComponent((getQueryVariable("app_url") as string) || "") - : ""; + const appUrl = isClient ? (getQueryVariable("app_url") as string) || "" : ""; + const state = isClient ? (getQueryVariable("state") as string) || "" : ""; // 缺少 app_id 或 app_url 时不允许登录(sso-portal 自身不需要登录) const missingSsoParams = !appId || !appUrl; - // 持久化 SSO 参数:飞书 OAuth 跳转后会丢失 URL 参数,存 localStorage + // 持久化 SSO 参数:飞书 OAuth 跳转后会丢失 URL 参数。 useEffect(() => { if (appId && appUrl) { localStorage.setItem(appId, appUrl); // app_id -> app_url localStorage.setItem("sso_app_id", appId); // 记住当前 app_id + if (state) { + sessionStorage.setItem(ssoStateStorageKey(appId), state); + } else { + sessionStorage.removeItem(ssoStateStorageKey(appId)); + } } - }, [appId, appUrl]); + }, [appId, appUrl, state]); // 获取租户简称列表(与 uirefbase 一致) useEffect(() => { @@ -118,17 +123,33 @@ export default function LoginPage() { const storedAppId = appId || localStorage.getItem("sso_app_id") || ""; const targetUrl = appUrl || (storedAppId ? localStorage.getItem(storedAppId) || "" : ""); + const callbackState = + (appId && appUrl ? state : "") || + (storedAppId + ? sessionStorage.getItem(ssoStateStorageKey(storedAppId)) || "" + : ""); if (res?.code && targetUrl) { - const separator = targetUrl.includes("?") ? "&" : "?"; - window.location.href = `${targetUrl}${separator}sso_code=${res.code}`; + try { + const callbackUrl = new URL(targetUrl); + callbackUrl.searchParams.set("sso_code", res.code); + if (callbackState) { + callbackUrl.searchParams.set("state", callbackState); + } + if (storedAppId) { + sessionStorage.removeItem(ssoStateStorageKey(storedAppId)); + } + window.location.href = callbackUrl.toString(); + } catch { + setError("外部应用回调地址(app_url)格式无效,无法完成 SSO 跳转"); + } return; } // 无外部应用地址 — sso-portal 不提供本地登录 setError("缺少外部应用回调地址(app_url),无法完成 SSO 跳转"); }, - [appId, appUrl], + [appId, appUrl, state], ); // ============ 统一调用 /api/login ============ diff --git a/components/login/util.ts b/components/login/util.ts index de44a0b..5956321 100644 --- a/components/login/util.ts +++ b/components/login/util.ts @@ -13,15 +13,7 @@ export const validatePhoneNumber = (str: string) => { /** 获取 url 中的某个参数值 */ export const getQueryVariable = (variable: string) => { if (typeof window === 'undefined') return false - const query = window.location.search.substring(1) - const vars = query.split('&') - for (let i = 0; i < vars.length; i++) { - const pair = vars[i].split('=') - if (pair[0] === variable) { - return pair[1] - } - } - return false + return new URLSearchParams(window.location.search).get(variable) || false } /** 加载飞书二维码 SDK */ diff --git a/export-sso-pdf.sh b/export-sso-pdf.sh new file mode 100755 index 0000000..09f8178 --- /dev/null +++ b/export-sso-pdf.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +DEFAULT_INPUT="$SCRIPT_DIR/酷哇商城网页登录接入SSO方案文档.md" + +usage() { + cat <<'EOF' +Usage: + ./export-sso-pdf.sh [input.md] [output.pdf] + +Examples: + ./export-sso-pdf.sh + ./export-sso-pdf.sh notes.md notes.pdf + +The script renders Mermaid diagrams and prints the Markdown document as A4 PDF. +EOF +} + +if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then + usage + exit 0 +fi + +INPUT=${1:-$DEFAULT_INPUT} +OUTPUT=${2:-"${INPUT%.md}.pdf"} + +if [[ ! -f "$INPUT" ]]; then + echo "Markdown file not found: $INPUT" >&2 + exit 1 +fi + +if [[ ${INPUT##*.} != "md" ]]; then + echo "Input must be a .md file: $INPUT" >&2 + exit 1 +fi + +if ! command -v node >/dev/null 2>&1; then + echo "Node.js is required. Install Node.js 18 or newer and retry." >&2 + exit 1 +fi + +for candidate in google-chrome chromium chromium-browser; do + if command -v "$candidate" >/dev/null 2>&1; then + CHROME=$(command -v "$candidate") + break + fi +done + +if [[ -z ${CHROME:-} ]]; then + echo "Chrome or Chromium is required to create PDF." >&2 + exit 1 +fi + +INPUT_DIR=$(cd "$(dirname "$INPUT")" && pwd) +INPUT="$INPUT_DIR/$(basename "$INPUT")" +OUTPUT_DIR=$(cd "$(dirname "$OUTPUT")" && pwd) +OUTPUT="$OUTPUT_DIR/$(basename "$OUTPUT")" +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/markdown-pdf.XXXXXX") + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +render_mermaid() { + if command -v mmdc >/dev/null 2>&1; then + PUPPETEER_EXECUTABLE_PATH="$CHROME" mmdc "$@" + return + fi + + PUPPETEER_EXECUTABLE_PATH="$CHROME" npm exec --yes \ + --package=@mermaid-js/mermaid-cli -- mmdc "$@" +} + +node "$SCRIPT_DIR/scripts/render-markdown-pdf.mjs" prepare "$INPUT" "$WORK_DIR" + +shopt -s nullglob +for diagram in "$WORK_DIR"/diagram-*.mmd; do + render_mermaid -i "$diagram" -o "${diagram%.mmd}.svg" +done +shopt -u nullglob + +HTML="$WORK_DIR/document.html" +node "$SCRIPT_DIR/scripts/render-markdown-pdf.mjs" render "$INPUT" "$WORK_DIR" "$HTML" + +"$CHROME" --headless=new --no-sandbox --disable-gpu --no-pdf-header-footer \ + --print-to-pdf="$OUTPUT" "file://$HTML" >/dev/null 2>&1 + +echo "PDF created: $OUTPUT" diff --git a/scripts/render-markdown-pdf.mjs b/scripts/render-markdown-pdf.mjs new file mode 100644 index 0000000..33d0657 --- /dev/null +++ b/scripts/render-markdown-pdf.mjs @@ -0,0 +1,205 @@ +import { readFile, readdir, writeFile } from 'node:fs/promises' +import path from 'node:path' + +const [mode, inputPath, workDir, outputPath] = process.argv.slice(2) + +if (!['prepare', 'render'].includes(mode) || !inputPath || !workDir) { + console.error('Usage: render-markdown-pdf.mjs [output.html]') + process.exit(1) +} + +const markdown = await readFile(inputPath, 'utf8') + +function escapeHtml(value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function inline(text) { + const tokens = [] + let value = escapeHtml(text) + + value = value.replace(/`([^`]+)`/g, (_, code) => { + tokens.push(`${code}`) + return `\u0000${tokens.length - 1}\u0000` + }) + value = value.replace(/\*\*(.+?)\*\*/g, '$1') + value = value.replace(/\[(.+?)\]\((https?:[^)]+)\)/g, '$1') + + return value.replace(/\u0000(\d+)\u0000/g, (_, index) => tokens[Number(index)]) +} + +function isTableDivider(line) { + return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line) +} + +function cells(line) { + return line + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((cell) => inline(cell.trim())) +} + +function renderMarkdown(source, diagrams) { + const diagramPattern = /```mermaid\n[\s\S]*?\n```/g + let index = 0 + const prepared = source.replace(diagramPattern, () => `@@MERMAID_${index++}@@`) + const lines = prepared.replace(/\r\n/g, '\n').split('\n') + const output = [] + + for (let i = 0; i < lines.length;) { + const line = lines[i] + + if (!line.trim()) { + i += 1 + continue + } + + const diagram = line.match(/^@@MERMAID_(\d+)@@$/) + if (diagram) { + const svg = diagrams[Number(diagram[1])] || '' + output.push(`
${svg}
`) + i += 1 + continue + } + + const fence = line.match(/^```([^`]*)$/) + if (fence) { + const language = fence[1].trim() + const code = [] + i += 1 + while (i < lines.length && !/^```\s*$/.test(lines[i])) { + code.push(lines[i]) + i += 1 + } + if (i < lines.length) i += 1 + output.push(`
${escapeHtml(code.join('\n'))}
`) + continue + } + + const heading = line.match(/^(#{1,6})\s+(.+)$/) + if (heading) { + const level = heading[1].length + output.push(`${inline(heading[2])}`) + i += 1 + continue + } + + if (line.startsWith('> ')) { + const quote = [] + while (i < lines.length && lines[i].startsWith('> ')) { + quote.push(lines[i].slice(2)) + i += 1 + } + output.push(`
${inline(quote.join('
'))}
`) + continue + } + + if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1])) { + const header = cells(line) + const rows = [] + i += 2 + while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { + rows.push(cells(lines[i])) + i += 1 + } + output.push(`${header.map((cell) => ``).join('')}${rows.map((row) => `${row.map((cell) => ``).join('')}`).join('')}
${cell}
${cell}
`) + continue + } + + const unordered = line.match(/^[-*]\s+(.*)$/) + const ordered = line.match(/^\d+\.\s+(.*)$/) + if (unordered || ordered) { + const tag = ordered ? 'ol' : 'ul' + const items = [] + while (i < lines.length) { + const match = tag === 'ol' + ? lines[i].match(/^\d+\.\s+(.*)$/) + : lines[i].match(/^[-*]\s+(.*)$/) + if (!match) break + const task = match[1].match(/^\[([ xX])\]\s+(.*)$/) + const content = task + ? `${task[1].trim() ? '☑' : '☐'} ${inline(task[2])}` + : inline(match[1]) + items.push(`
  • ${content}
  • `) + i += 1 + } + output.push(`<${tag}>${items.join('')}`) + continue + } + + const paragraph = [line.trim()] + i += 1 + while ( + i < lines.length && + lines[i].trim() && + !/^(#{1,6})\s+/.test(lines[i]) && + !/^```/.test(lines[i]) && + !/^@@MERMAID_\d+@@$/.test(lines[i]) && + !/^[-*]\s+/.test(lines[i]) && + !/^\d+\.\s+/.test(lines[i]) && + !lines[i].startsWith('> ') && + !(lines[i].includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1])) + ) { + paragraph.push(lines[i].trim()) + i += 1 + } + output.push(`

    ${inline(paragraph.join(' '))}

    `) + } + + return output.join('\n') +} + +if (mode === 'prepare') { + const diagrams = [...markdown.matchAll(/```mermaid\n([\s\S]*?)\n```/g)].map((match) => match[1]) + await Promise.all(diagrams.map((diagram, index) => writeFile(path.join(workDir, `diagram-${index}.mmd`), diagram))) + process.exit(0) +} + +if (!outputPath) { + console.error('An HTML output path is required for render mode.') + process.exit(1) +} + +const svgFiles = (await readdir(workDir)) + .filter((file) => /^diagram-\d+\.svg$/.test(file)) + .sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0])) +const diagrams = await Promise.all(svgFiles.map((file) => readFile(path.join(workDir, file), 'utf8'))) +const body = renderMarkdown(markdown, diagrams) + +const html = ` + + + + Markdown PDF + + +${body} +` + +await writeFile(outputPath, html) diff --git a/酷哇商城网页登录接入SSO方案文档.md b/酷哇商城网页登录接入SSO方案文档.md new file mode 100644 index 0000000..b014ba3 --- /dev/null +++ b/酷哇商城网页登录接入SSO方案文档.md @@ -0,0 +1,175 @@ +# 酷哇商城网页登录接入 SSO + +> 适用范围:酷哇商城网页端登录、回调和本地会话建立。 +> 接入模式:OAuth2 授权码流程。 + +## 1. 接入前准备 + +向 SSO/Basis 管理员申请并确认以下配置: + +| 配置 | 说明 | +| --------------- | ------------------------------------------------------------------ | +| `client_id` | 商城专用应用 ID,同时作为登录地址中的 `app_id` | +| `client_secret` | 商城服务端密钥,绝不能下发浏览器 | +| `tenant` | 当前联调值为 `cowarobot` | +| `callback_url` | 商城固定回调地址,例如 `https://mall.example.com/sso/callback` | +| `SSO_URL` | SSO 登录页地址,例如 `https://sso.softtest.cowarobot.com` | +| `BACKEND_API` | Basis SSO 后端地址,例如 `https://internal.softtest.cowarobot.com` | + +`callback_url` 必须提前登记,且协议、域名、端口和路径必须完全一致。生产环境使用 HTTPS;`client_secret` 只通过部署平台或密钥管理系统注入商城服务端。 + +## 2. 登录流程 + +```mermaid +sequenceDiagram + participant B as 商城浏览器 + participant M as 商城服务端 + participant P as SSO 登录页 + participant S as Basis SSO 后端 + + B->>M: 访问受保护页面 + M-->>B: 未登录,跳转 SSO + B->>P: /login?app_id=...&app_url=...&state=... + B->>P: 完成账号或手机登录 + P->>S: 校验登录并签发一次性授权码 + P-->>B: 回跳 callback_url?sso_code=...&state=... + B->>M: 请求商城回调地址 + M->>S: 用 sso_code 换 access_token + S-->>M: access_token、refresh_token、expires_in + M->>S: 用 access_token 获取用户信息 + S-->>M: 用户资料 + M-->>B: 写商城会话,跳转原页面 +``` + +商城只需要完成四件事:生成并保存 `state`、构造登录跳转地址、在服务端用 `sso_code` 换令牌、建立商城自己的会话。 + +## 3. 实现步骤 + +### 3.1 跳转到 SSO + +用户未登录时,浏览器跳转到: + +```text +{SSO_URL}/login?app_id={client_id}&app_url={encodeURIComponent(callback_url)}&state={state} +``` + +示例: + +```text +https://sso.softtest.cowarobot.com/login?app_id=&app_url=https%3A%2F%2Fmall.example.com%2Fsso%2Fcallback&state= +``` + +`app_url` 必须来自商城固定配置,不能直接使用用户传入的跳转地址。每次登录都要使用浏览器安全随机数生成新的 `state`,保存到 `sessionStorage` 或服务端会话;`state` 不要包含用户信息、令牌或跳转地址。 + +### 3.2 接收回调 + +登录成功后,SSO 会回跳: + +```text +https://mall.example.com/sso/callback?sso_code=&state= +``` + +回调处理器必须先校验 `state`:它必须存在、与本次登录保存的值完全一致,并在通过后立即删除。缺失、不一致或已使用时停止登录并让用户重新发起。校验通过后,立即在**商城服务端**用 `sso_code` 换取令牌;同一个 `sso_code` 只能使用一次,失败后不要重试同一授权码。 + +### 3.3 服务端换取令牌 + +```http +POST {BACKEND_API}/api/v1/basis/sso/access_token +Content-Type: application/json + +{ + "client_id": "", + "client_secret": "", + "code": "", + "grant_type": "authorization_code", + "tenant": "cowarobot" +} +``` + +成功响应形态: + +```json +{ + "code": 200, + "data": { + "access_token": "", + "refresh_token": "", + "expires_in": "7200" + } +} +``` + +商城应同时校验 HTTP 状态、业务状态和 `data.access_token`。响应外层 `code: 200` 是业务状态码;回调参数中的 `sso_code` 是授权码,二者不能混用。 + +### 3.4 获取用户信息 + +换取令牌成功后,由**商城服务端**调用: + +```text +{BACKEND_API}/api/v1/basis/sso/user_info?access_token={access_token} +``` + +`access_token` 为 SSO 颁发的访问令牌,必须由商城服务端传递,不得下发到浏览器或记录到日志。接口返回示例: + +```typescript +interface UserInfoResponse { + code: number; + data: { + account: string; + email: string; + id: number; + job_title: string; + key: string; + phone: string; + status: string; + tntkey: string; + user_name: string; + }; + msg: string; +} +``` + +商城应校验业务状态和 `data.key`。`data.key` 是用户绑定的稳定标识;`account`、`user_name`、`email`、`phone`、`job_title`、`status`、`tntkey` 与 `key` 可按商城业务需要同步或展示。不要仅按姓名或手机号创建绑定关系。 + +### 3.5 建立商城会话 + +推荐做法是:令牌只保存在商城服务端会话存储,浏览器只保存随机会话 ID。登录成功后使用 `303` 跳回登录前页面或商城首页。 + +如果现有架构必须把令牌放入 Cookie,至少设置: + +```text +HttpOnly; Secure; SameSite=Lax; Path=/ +``` + +Cookie 有效期不应长于 `expires_in`。不要把 `access_token`、`refresh_token` 或 `client_secret` 放入 localStorage、sessionStorage、URL 或浏览器可读 Cookie。 + +## 4. 商城需要补齐的能力 + +| 项目 | 接入要求 | +| ------------ | ----------------------------------------------------------------------------------------------- | +| 用户绑定 | 使用 `user_info` 返回的 `data.key` 绑定会员或员工账号,不能只按姓名、手机号或账号匹配 | +| 用户信息 | 已提供 `/api/v1/basis/sso/user_info`;商城服务端用 SSO 颁发的 `access_token` 获取资料 | +| 令牌刷新 | 确认 refresh token 的请求字段、轮换和失效规则后,由商城服务端实现 | +| 退出登录 | 至少清除商城本地会话;全局退出或令牌吊销需以 Basis 正式接口为准 | +| `state` 防护 | 商城每次登录生成高随机值并保存;SSO 会原样回传,商城在换令牌前完成一次性校验,用于防御登录 CSRF | + +## 5. 联调检查清单 + +- [ ] 已登记测试和生产 `callback_url`,并取得商城专用 `client_id` / `client_secret`。 +- [ ] 未登录访问商城受保护页面时,能正确跳转到 SSO。 +- [ ] 账号登录、手机登录都能回跳商城,并同时携带一次性 `sso_code` 与原始 `state`。 +- [ ] 回调 `state` 缺失、不一致或重复使用时,商城拒绝换令牌并要求重新登录。 +- [ ] 商城服务端能换取 `access_token`,浏览器网络请求和前端代码中没有 `client_secret`。 +- [ ] 商城服务端能用 `access_token` 调用 `user_info`,并以 `data.key` 完成用户绑定。 +- [ ] 刷新页面后登录态正常;令牌或会话过期后会重新登录,不出现循环跳转。 +- [ ] 退出后旧会话不能继续访问商城。 +- [ ] 日志中不记录授权码、密钥、访问令牌、刷新令牌或手机号等敏感明文。 + +## 6. 常见问题 + +| 现象 | 优先排查 | +| ------------------------ | ----------------------------------------------------------------------------------- | +| 回调缺少 `sso_code` | `callback_url` 是否正确登记,登录跳转地址是否完整编码 | +| 换令牌返回 401 | `client_id`、`client_secret`、`tenant`、回调地址是否匹配;授权码是否已使用或过期 | +| 换令牌成功但商城仍未登录 | Cookie 的 `Secure` / `SameSite` / 域名设置,以及商城服务端会话读取逻辑 | +| 无法展示用户信息 | 检查 `access_token` 是否有效,以及 `user_info` 的业务状态和 `data.key` 是否正确返回 | diff --git a/酷哇商城网页登录接入SSO方案文档.pdf b/酷哇商城网页登录接入SSO方案文档.pdf new file mode 100644 index 0000000..82ab1bf Binary files /dev/null and b/酷哇商城网页登录接入SSO方案文档.pdf differ