Files
ai-drama-platform/server/saml.mjs
T

206 lines
9.9 KiB
JavaScript

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);
}