docs(readme): add doc
This commit is contained in:
@@ -35,6 +35,7 @@ import { encrypt, getQueryVariable, validatePhoneNumber } from "./util";
|
|||||||
const HeaderIcon = DigitalIcon;
|
const HeaderIcon = DigitalIcon;
|
||||||
const headerTitle = "酷哇数字化平台";
|
const headerTitle = "酷哇数字化平台";
|
||||||
const fixedTenant = "coowa";
|
const fixedTenant = "coowa";
|
||||||
|
const ssoStateStorageKey = (appId: string) => `sso_state:${appId}`;
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const colorScheme = useComputedColorScheme();
|
const colorScheme = useComputedColorScheme();
|
||||||
@@ -59,20 +60,24 @@ export default function LoginPage() {
|
|||||||
// SSO 跳转参数
|
// SSO 跳转参数
|
||||||
const isClient = typeof window !== "undefined";
|
const isClient = typeof window !== "undefined";
|
||||||
const appId = isClient ? (getQueryVariable("app_id") as string) || "" : "";
|
const appId = isClient ? (getQueryVariable("app_id") as string) || "" : "";
|
||||||
const appUrl = isClient
|
const appUrl = isClient ? (getQueryVariable("app_url") as string) || "" : "";
|
||||||
? decodeURIComponent((getQueryVariable("app_url") as string) || "")
|
const state = isClient ? (getQueryVariable("state") as string) || "" : "";
|
||||||
: "";
|
|
||||||
|
|
||||||
// 缺少 app_id 或 app_url 时不允许登录(sso-portal 自身不需要登录)
|
// 缺少 app_id 或 app_url 时不允许登录(sso-portal 自身不需要登录)
|
||||||
const missingSsoParams = !appId || !appUrl;
|
const missingSsoParams = !appId || !appUrl;
|
||||||
|
|
||||||
// 持久化 SSO 参数:飞书 OAuth 跳转后会丢失 URL 参数,存 localStorage
|
// 持久化 SSO 参数:飞书 OAuth 跳转后会丢失 URL 参数。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (appId && appUrl) {
|
if (appId && appUrl) {
|
||||||
localStorage.setItem(appId, appUrl); // app_id -> app_url
|
localStorage.setItem(appId, appUrl); // app_id -> app_url
|
||||||
localStorage.setItem("sso_app_id", appId); // 记住当前 app_id
|
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 一致)
|
// 获取租户简称列表(与 uirefbase 一致)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -118,17 +123,33 @@ export default function LoginPage() {
|
|||||||
const storedAppId = appId || localStorage.getItem("sso_app_id") || "";
|
const storedAppId = appId || localStorage.getItem("sso_app_id") || "";
|
||||||
const targetUrl =
|
const targetUrl =
|
||||||
appUrl || (storedAppId ? localStorage.getItem(storedAppId) || "" : "");
|
appUrl || (storedAppId ? localStorage.getItem(storedAppId) || "" : "");
|
||||||
|
const callbackState =
|
||||||
|
(appId && appUrl ? state : "") ||
|
||||||
|
(storedAppId
|
||||||
|
? sessionStorage.getItem(ssoStateStorageKey(storedAppId)) || ""
|
||||||
|
: "");
|
||||||
|
|
||||||
if (res?.code && targetUrl) {
|
if (res?.code && targetUrl) {
|
||||||
const separator = targetUrl.includes("?") ? "&" : "?";
|
try {
|
||||||
window.location.href = `${targetUrl}${separator}sso_code=${res.code}`;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无外部应用地址 — sso-portal 不提供本地登录
|
// 无外部应用地址 — sso-portal 不提供本地登录
|
||||||
setError("缺少外部应用回调地址(app_url),无法完成 SSO 跳转");
|
setError("缺少外部应用回调地址(app_url),无法完成 SSO 跳转");
|
||||||
},
|
},
|
||||||
[appId, appUrl],
|
[appId, appUrl, state],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============ 统一调用 /api/login ============
|
// ============ 统一调用 /api/login ============
|
||||||
|
|||||||
@@ -13,15 +13,7 @@ export const validatePhoneNumber = (str: string) => {
|
|||||||
/** 获取 url 中的某个参数值 */
|
/** 获取 url 中的某个参数值 */
|
||||||
export const getQueryVariable = (variable: string) => {
|
export const getQueryVariable = (variable: string) => {
|
||||||
if (typeof window === 'undefined') return false
|
if (typeof window === 'undefined') return false
|
||||||
const query = window.location.search.substring(1)
|
return new URLSearchParams(window.location.search).get(variable) || false
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 加载飞书二维码 SDK */
|
/** 加载飞书二维码 SDK */
|
||||||
|
|||||||
91
export-sso-pdf.sh
Executable file
91
export-sso-pdf.sh
Executable file
@@ -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"
|
||||||
205
scripts/render-markdown-pdf.mjs
Normal file
205
scripts/render-markdown-pdf.mjs
Normal file
@@ -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 <prepare|render> <input.md> <work-dir> [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>${code}</code>`)
|
||||||
|
return `\u0000${tokens.length - 1}\u0000`
|
||||||
|
})
|
||||||
|
value = value.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
value = value.replace(/\[(.+?)\]\((https?:[^)]+)\)/g, '<a href="$2">$1</a>')
|
||||||
|
|
||||||
|
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(`<div class="mermaid-diagram">${svg}</div>`)
|
||||||
|
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(`<pre><code class="language-${escapeHtml(language)}">${escapeHtml(code.join('\n'))}</code></pre>`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = line.match(/^(#{1,6})\s+(.+)$/)
|
||||||
|
if (heading) {
|
||||||
|
const level = heading[1].length
|
||||||
|
output.push(`<h${level}>${inline(heading[2])}</h${level}>`)
|
||||||
|
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(`<blockquote>${inline(quote.join('<br>'))}</blockquote>`)
|
||||||
|
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(`<table><thead><tr>${header.map((cell) => `<th>${cell}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table>`)
|
||||||
|
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
|
||||||
|
? `<span class="checkbox">${task[1].trim() ? '☑' : '☐'}</span> ${inline(task[2])}`
|
||||||
|
: inline(match[1])
|
||||||
|
items.push(`<li>${content}</li>`)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
output.push(`<${tag}>${items.join('')}</${tag}>`)
|
||||||
|
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(`<p>${inline(paragraph.join(' '))}</p>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Markdown PDF</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4; margin: 18mm 15mm; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { color: #1f2937; font: 10.5pt/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; }
|
||||||
|
h1 { margin: 0 0 8px; color: #0f4c81; font-size: 24pt; }
|
||||||
|
h2 { margin-top: 26px; border-bottom: 1px solid #dbe4ef; padding-bottom: 5px; color: #123b62; font-size: 16pt; }
|
||||||
|
h3 { margin-top: 18px; color: #174f84; font-size: 12pt; }
|
||||||
|
p, li, table, .mermaid-diagram { break-inside: avoid; }
|
||||||
|
table { width: 100%; margin: 12px 0; border-collapse: collapse; font-size: 9.5pt; }
|
||||||
|
th { background: #eaf3fb; color: #123b62; font-weight: 600; }
|
||||||
|
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; text-align: left; vertical-align: top; }
|
||||||
|
code { border-radius: 3px; background: #f1f5f9; padding: 1px 4px; color: #a33d10; font-family: "SFMono-Regular", Consolas, monospace; }
|
||||||
|
pre { overflow-wrap: anywhere; border: 1px solid #dbe4ef; border-radius: 4px; background: #f8fafc; padding: 10px; white-space: pre-wrap; font-size: 8.8pt; }
|
||||||
|
pre code { background: transparent; padding: 0; color: #1f2937; }
|
||||||
|
blockquote { margin: 10px 0; border-left: 3px solid #67a6d8; padding-left: 12px; color: #475569; }
|
||||||
|
.mermaid-diagram { margin: 14px 0; text-align: center; }
|
||||||
|
.mermaid-diagram svg { max-width: 100%; height: auto; }
|
||||||
|
.checkbox { color: #0f4c81; }
|
||||||
|
a { color: #0f4c81; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>${body}</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
await writeFile(outputPath, html)
|
||||||
175
酷哇商城网页登录接入SSO方案文档.md
Normal file
175
酷哇商城网页登录接入SSO方案文档.md
Normal file
@@ -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=<client_id>&app_url=https%3A%2F%2Fmall.example.com%2Fsso%2Fcallback&state=<random_value>
|
||||||
|
```
|
||||||
|
|
||||||
|
`app_url` 必须来自商城固定配置,不能直接使用用户传入的跳转地址。每次登录都要使用浏览器安全随机数生成新的 `state`,保存到 `sessionStorage` 或服务端会话;`state` 不要包含用户信息、令牌或跳转地址。
|
||||||
|
|
||||||
|
### 3.2 接收回调
|
||||||
|
|
||||||
|
登录成功后,SSO 会回跳:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://mall.example.com/sso/callback?sso_code=<one_time_code>&state=<random_value>
|
||||||
|
```
|
||||||
|
|
||||||
|
回调处理器必须先校验 `state`:它必须存在、与本次登录保存的值完全一致,并在通过后立即删除。缺失、不一致或已使用时停止登录并让用户重新发起。校验通过后,立即在**商城服务端**用 `sso_code` 换取令牌;同一个 `sso_code` 只能使用一次,失败后不要重试同一授权码。
|
||||||
|
|
||||||
|
### 3.3 服务端换取令牌
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST {BACKEND_API}/api/v1/basis/sso/access_token
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"client_id": "<client_id>",
|
||||||
|
"client_secret": "<server_only_secret>",
|
||||||
|
"code": "<sso_code>",
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"tenant": "cowarobot"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应形态:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"access_token": "<access_token>",
|
||||||
|
"refresh_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` 是否正确返回 |
|
||||||
BIN
酷哇商城网页登录接入SSO方案文档.pdf
Normal file
BIN
酷哇商城网页登录接入SSO方案文档.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user