chore(sso-portal): 整合近期重构、UX 改进、部署脚本与文档
项目改名 sso-mock → sso-portal: - 重命名仓库目录 sso-mock/ → sso-portal/ - package.json name 改 sso-portal - app/layout.tsx title/description 改 SSO Portal - 源码 [sso-mock] console.log 前缀、注释全部 → [sso-portal] - 文档(README、ADAPTATION_GUIDE)、env 同步 - 生产 svc 名 sso-mock-svc → sso-portal-svc(external-app 引用同步) SSO tenant 改环境变量驱动: - 新增 SSO_TENANT env(默认 cowarobot) - login/route.ts 顶部 const SSO_TENANT,ssoBody 显式使用 - callSsoApi 改回纯透传(不再硬编码覆盖 tenant) - external-app 同步用 SSO_TENANT,命名对齐 external-app 登录态改为基于 token: - page.tsx 读 token cookie 判定 authenticated(不再依赖 user cookie) - DashboardPage 用 authenticated 决定跳转,user 为 null 时优雅兜底展示 - 解决「200 后又跳回 SSO」bug(access_token 接口不返回 user) sso-portal 缺 SSO 参数体验优化: - 缺 app_id/app_url 时 Alert 提示 + 禁用所有输入和登录按钮 - components/login/index.tsx 加 missingSsoParams 检测 - Alert 样式两排完整显示 部署脚本(参考 uirefbase): - 新增 build.sh(git → pnpm install → build → docker build → tag → push) - 新增 .gitlab-ci.yml(main 分支触发 build.sh prod) - 新增 Dockerfile(standalone 模式,端口 5501) - package.json 加 env-cmd 依赖 + build:stage/build 脚本 - next.config.ts 保持 output: 'standalone' 代码清理: - 删除 utils/code-store.ts - 删除 app/api/auth/verify/(连同目录) - 移除旧的 codeStore / generateCode 逻辑 文档: - ADAPTATION_GUIDE.md:完整重写对齐真实后端三个 SSO 接口(/sso/account、/sso/phone/smscode、/sso/access_token) - 新增 INTEGRATION_GUIDE.md(从 external-app 迁移过来作为通用接入指南) - README.md 同步更新(sso-portal 仓库门面 + 文档索引) - external-app/README.md 加交叉链接指向 sso-portal 的接入文档
This commit is contained in:
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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 || "登录失败" });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user