feat: bootstrap commercial AI drama platform
This commit is contained in:
+205
@@ -0,0 +1,205 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { SAML } from "@node-saml/node-saml";
|
||||
import { dbGet, dbRun } from "./db.mjs";
|
||||
import { resolveOidcUser } from "./oidc.mjs";
|
||||
|
||||
const LOGIN_STATE_TTL_MS = 10 * 60 * 1000;
|
||||
const CLOCK_SKEW_MS = 5000;
|
||||
const DEFAULT_NAME_ID_FORMAT = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
|
||||
|
||||
function samlError(status, code, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.status = status;
|
||||
error.code = code;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function hashValue(value) {
|
||||
return createHash("sha256").update(String(value || "")).digest("hex");
|
||||
}
|
||||
|
||||
function base64url(buffer) {
|
||||
return Buffer.from(buffer).toString("base64url");
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try { return JSON.parse(value); } catch { return fallback; }
|
||||
}
|
||||
|
||||
function ensureSsoEnabled() {
|
||||
const policy = dbGet("SELECT sso_enabled FROM identity_policies WHERE id = 'default'");
|
||||
if (!policy?.sso_enabled) throw samlError(403, "sso_disabled", "平台当前未启用企业 SSO");
|
||||
}
|
||||
|
||||
function providerRow(providerId, { requireSso = true, allowDisabled = false } = {}) {
|
||||
if (requireSso) ensureSsoEnabled();
|
||||
const provider = dbGet(`SELECT * FROM identity_providers WHERE id = ?${allowDisabled ? "" : " AND enabled = 1"}`, [providerId]);
|
||||
if (!provider) throw samlError(404, "sso_provider_not_found", "企业身份提供商不存在或未启用");
|
||||
if (provider.kind !== "saml") throw samlError(400, "sso_provider_kind_unsupported", "当前登录链路不是 SAML 提供商");
|
||||
if (requireSso && (!provider.entry_point || !provider.idp_cert_ref || !provider.sp_issuer)) {
|
||||
throw samlError(400, "saml_provider_incomplete", "SAML 提供商缺少 IdP Entry Point、证书环境变量名或 SP Issuer");
|
||||
}
|
||||
if (!["never", "ifPresent", "always"].includes(provider.validate_in_response_to || "ifPresent")) {
|
||||
throw samlError(400, "saml_validate_in_response_to_invalid", "SAML InResponseTo 校验策略无效");
|
||||
}
|
||||
if (!allowDisabled && !["configured", "ready"].includes(provider.status)) {
|
||||
throw samlError(400, "saml_provider_not_ready", "SAML 提供商尚未完成探测或已停用");
|
||||
}
|
||||
if (allowDisabled && !["disabled", "configured", "ready"].includes(provider.status)) {
|
||||
throw samlError(400, "saml_provider_not_ready", "SAML 提供商尚未完成探测或已停用");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
function envCertificate(ref) {
|
||||
const key = String(ref || "").trim();
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
||||
throw samlError(400, "saml_certificate_ref_invalid", "SAML IdP 证书环境变量名无效");
|
||||
}
|
||||
const value = String(process.env[key] || "").trim().replace(/\\n/g, "\n");
|
||||
if (!value) throw samlError(503, "saml_certificate_missing", `服务端环境变量 ${key} 未配置 IdP 证书`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeValidateInResponseTo(value) {
|
||||
return ["never", "ifPresent", "always"].includes(value) ? value : "ifPresent";
|
||||
}
|
||||
|
||||
function providerOptions(provider, { callbackUrl, cacheProvider, requireIdpCert = true }) {
|
||||
const issuer = String(provider.sp_issuer || process.env.AI_DRAMA_SAML_SP_ISSUER || `${process.env.AI_DRAMA_API_ORIGIN || "http://127.0.0.1:8787"}/api/auth/sso/saml/metadata`).trim();
|
||||
return {
|
||||
entryPoint: provider.entry_point,
|
||||
idpCert: requireIdpCert ? envCertificate(provider.idp_cert_ref) : "AA==",
|
||||
issuer,
|
||||
callbackUrl,
|
||||
audience: provider.audience || issuer,
|
||||
idpIssuer: provider.issuer_url || undefined,
|
||||
identifierFormat: provider.saml_name_id_format || DEFAULT_NAME_ID_FORMAT,
|
||||
wantAssertionsSigned: provider.want_assertions_signed !== 0,
|
||||
wantAuthnResponseSigned: provider.want_authn_response_signed !== 0,
|
||||
validateInResponseTo: normalizeValidateInResponseTo(provider.validate_in_response_to),
|
||||
acceptedClockSkewMs: CLOCK_SKEW_MS,
|
||||
requestIdExpirationPeriodMs: LOGIN_STATE_TTL_MS,
|
||||
cacheProvider,
|
||||
disableRequestedAuthnContext: true,
|
||||
signatureAlgorithm: "sha256",
|
||||
providerName: "AI 短剧生产平台"
|
||||
};
|
||||
}
|
||||
|
||||
class DatabaseSamlCacheProvider {
|
||||
constructor(relayStateHash) {
|
||||
this.relayStateHash = relayStateHash;
|
||||
}
|
||||
|
||||
async saveAsync(key, value) {
|
||||
const result = dbRun(
|
||||
"UPDATE saml_login_states SET request_id = ?, request_issue_instant = ? WHERE relay_state_hash = ? AND consumed_at IS NULL AND expires_at > ?",
|
||||
[String(key), String(value), this.relayStateHash, nowIso()]
|
||||
);
|
||||
if (!Number(result.changes || 0)) throw samlError(401, "saml_state_invalid", "SAML 登录状态不存在或已过期");
|
||||
return { value: String(value), createdAt: Date.now() };
|
||||
}
|
||||
|
||||
async getAsync(key) {
|
||||
const row = dbGet(
|
||||
"SELECT request_issue_instant FROM saml_login_states WHERE relay_state_hash = ? AND request_id = ? AND consumed_at IS NULL AND expires_at > ?",
|
||||
[this.relayStateHash, String(key), nowIso()]
|
||||
);
|
||||
return row?.request_issue_instant || null;
|
||||
}
|
||||
|
||||
// node-saml calls removeAsync during assertion processing. Final consumption is
|
||||
// handled atomically after the whole signed response has passed validation.
|
||||
async removeAsync(key) {
|
||||
const row = dbGet("SELECT request_issue_instant FROM saml_login_states WHERE relay_state_hash = ? AND request_id = ?", [this.relayStateHash, String(key)]);
|
||||
return row?.request_issue_instant || null;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupExpiredSamlStates() {
|
||||
const timestamp = nowIso();
|
||||
dbRun("DELETE FROM saml_login_states WHERE expires_at <= ? OR consumed_at IS NOT NULL", [timestamp]);
|
||||
}
|
||||
|
||||
function profileToClaims(profile, provider) {
|
||||
const claims = { ...(profile?.attributes || {}), ...(profile || {}) };
|
||||
const first = (...values) => values.flatMap((value) => Array.isArray(value) ? value : [value]).find((value) => typeof value === "string" && value.trim()) || "";
|
||||
const nameId = first(profile?.nameID, profile?.nameId, profile?.uid, profile?.subject);
|
||||
const email = first(profile?.email, profile?.mail, profile?.userPrincipalName, profile?.preferred_username, claims.email, claims.mail, claims["urn:oid:0.9.2342.19200300.100.1.3"]);
|
||||
const displayName = first(profile?.displayName, profile?.name, profile?.cn, profile?.givenName, claims.displayName, claims.name, email, nameId);
|
||||
claims.sub = first(claims.sub, nameId);
|
||||
claims.email = first(claims.email, email);
|
||||
claims.name = first(claims.name, displayName);
|
||||
claims.iss = first(claims.iss, profile?.issuer, provider.issuer_url);
|
||||
return claims;
|
||||
}
|
||||
|
||||
export function isSamlProvider(providerId) {
|
||||
return Boolean(dbGet("SELECT id FROM identity_providers WHERE id = ? AND kind = 'saml'", [providerId]));
|
||||
}
|
||||
|
||||
export async function startSamlLogin(providerId, { callbackUrl, apiOrigin, returnTo = "/", selection = {}, ipAddress = "", userAgent = "", host = "" } = {}) {
|
||||
cleanupExpiredSamlStates();
|
||||
const provider = providerRow(providerId);
|
||||
const relayState = base64url(randomBytes(32));
|
||||
const timestamp = nowIso();
|
||||
const expiresAt = new Date(Date.now() + LOGIN_STATE_TTL_MS).toISOString();
|
||||
const id = `saml-state-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
||||
dbRun(
|
||||
"INSERT INTO saml_login_states(id, relay_state_hash, provider_id, return_to, selection_json, ip_address, user_agent, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[id, hashValue(relayState), provider.id, String(returnTo || "/"), JSON.stringify(selection || {}), String(ipAddress || ""), String(userAgent || ""), expiresAt, timestamp]
|
||||
);
|
||||
try {
|
||||
const cacheProvider = new DatabaseSamlCacheProvider(hashValue(relayState));
|
||||
const saml = new SAML(providerOptions(provider, { callbackUrl, cacheProvider }));
|
||||
const authorizationUrl = await saml.getAuthorizeUrlAsync(relayState, host || apiOrigin, {});
|
||||
return { authorizationUrl, relayState, expiresAt, provider };
|
||||
} catch (error) {
|
||||
dbRun("DELETE FROM saml_login_states WHERE relay_state_hash = ?", [hashValue(relayState)]);
|
||||
if (error.status) throw error;
|
||||
throw samlError(502, "saml_request_failed", `SAML 登录请求生成失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSamlCallback({ samlResponse, relayState, callbackUrl } = {}) {
|
||||
cleanupExpiredSamlStates();
|
||||
if (!samlResponse || !relayState) throw samlError(401, "saml_response_state_required", "SAML ACS 必须包含断言和 RelayState");
|
||||
const stateRow = dbGet(
|
||||
"SELECT * FROM saml_login_states WHERE relay_state_hash = ? AND consumed_at IS NULL AND expires_at > ?",
|
||||
[hashValue(relayState), nowIso()]
|
||||
);
|
||||
if (!stateRow) throw samlError(401, "saml_state_invalid", "SAML 登录状态无效、已使用或已过期");
|
||||
const provider = providerRow(stateRow.provider_id);
|
||||
const cacheProvider = new DatabaseSamlCacheProvider(hashValue(relayState));
|
||||
const saml = new SAML(providerOptions(provider, { callbackUrl, cacheProvider }));
|
||||
let result;
|
||||
try {
|
||||
result = await saml.validatePostResponseAsync({ SAMLResponse: String(samlResponse) });
|
||||
} catch (error) {
|
||||
throw samlError(401, "saml_response_invalid", `SAML 断言校验失败:${error.message}`);
|
||||
}
|
||||
if (result.loggedOut || !result.profile) throw samlError(401, "saml_profile_missing", "SAML 响应没有可用的登录身份");
|
||||
const consumed = dbRun("UPDATE saml_login_states SET consumed_at = ? WHERE id = ? AND consumed_at IS NULL AND expires_at > ?", [nowIso(), stateRow.id, nowIso()]);
|
||||
if (!Number(consumed.changes || 0)) throw samlError(401, "saml_state_replayed", "SAML 登录状态已被使用");
|
||||
const claims = profileToClaims(result.profile, provider);
|
||||
return {
|
||||
...resolveOidcUser(provider, claims),
|
||||
selection: parseJson(stateRow.selection_json, {}),
|
||||
returnTo: stateRow.return_to,
|
||||
provider,
|
||||
profile: result.profile
|
||||
};
|
||||
}
|
||||
|
||||
export function samlServiceProviderMetadata(providerId, { callbackUrl } = {}) {
|
||||
const provider = providerRow(providerId, { requireSso: false, allowDisabled: true });
|
||||
const cacheProvider = new DatabaseSamlCacheProvider("metadata");
|
||||
const saml = new SAML(providerOptions(provider, { callbackUrl, cacheProvider, requireIdpCert: false }));
|
||||
return saml.generateServiceProviderMetadata(null, null);
|
||||
}
|
||||
Reference in New Issue
Block a user