import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; import { createPasswordRecord, dbAll, dbGet, dbRun, passwordHash } from "./db.mjs"; import { hashApiClientKey } from "./api-client-secrets.mjs"; const SESSION_TTL_MS = 8 * 60 * 60 * 1000; const MAX_FAILED_ATTEMPTS = 5; const LOCKOUT_MS = 15 * 60 * 1000; const MFA_STEP_MS = 30 * 1000; const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000; const MFA_MAX_CHALLENGE_ATTEMPTS = 5; const MFA_STORAGE_KEY = scryptSync(process.env.AI_DRAMA_MFA_ENCRYPTION_KEY || process.env.AI_DRAMA_SESSION_SECRET || "ai-drama-local-mfa-key-v1", "ai-drama-mfa", 32); const DEVICE_HASH_KEY = process.env.AI_DRAMA_DEVICE_HASH_KEY || process.env.AI_DRAMA_SESSION_SECRET || "ai-drama-local-device-key-v1"; const MAX_DEVICE_ID_LENGTH = 256; const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; function authError(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 boundedText(value, maxLength) { return String(value || "").slice(0, maxLength); } function requestMetadata(metadata = {}) { return { ipAddress: boundedText(metadata.ipAddress, 128), userAgent: boundedText(metadata.userAgent, 512), deviceId: boundedText(metadata.deviceId, MAX_DEVICE_ID_LENGTH), deviceLabel: boundedText(metadata.deviceLabel, 120) }; } function hashDeviceValue(value) { return createHmac("sha256", DEVICE_HASH_KEY).update(String(value || "")).digest("hex"); } function normalizedDeviceId(value) { const candidate = String(value || "").trim(); return candidate.length >= 8 && candidate.length <= MAX_DEVICE_ID_LENGTH ? candidate : ""; } function deviceFingerprint(metadata = {}) { const request = requestMetadata(metadata); return hashDeviceValue(request.userAgent); } function riskLevelForScore(score) { if (score >= 75) return "high"; if (score >= 45) return "medium"; return "low"; } function devicePayload(row) { if (!row?.auth_device_id && !row?.device_id) return null; const trusted = Boolean(row.trusted_at); const revoked = Boolean(row.revoked_at); return { id: row.auth_device_id || row.device_id, label: row.device_label || row.label || "浏览器设备", firstSeenAt: row.device_first_seen_at || row.first_seen_at || null, lastSeenAt: row.device_last_seen_at || row.last_seen_at || null, lastIpAddress: row.device_last_ip_address || row.last_ip_address || "", userAgent: row.device_last_user_agent || row.last_user_agent || "", trustedAt: row.trusted_at || null, revokedAt: row.revoked_at || null, status: revoked ? "revoked" : trusted ? "trusted" : "known", activeSessionCount: Number(row.active_session_count || 0), latestRiskLevel: row.latest_risk_level || "medium", latestRiskScore: Number(row.latest_risk_score ?? 50) }; } function registerAuthDevice(userId, metadata = {}) { const request = requestMetadata(metadata); const rawDeviceId = normalizedDeviceId(request.deviceId); if (!rawDeviceId) { return { device: null, riskLevel: "high", riskScore: 85, firstSeen: false, ipChanged: false, fingerprintChanged: false }; } const deviceKeyHash = hashDeviceValue(rawDeviceId); const fingerprintHash = deviceFingerprint(request); const existing = dbGet("SELECT * FROM auth_devices WHERE user_id = ? AND device_key_hash = ?", [userId, deviceKeyHash]); const timestamp = nowIso(); const ipChanged = Boolean(existing?.last_ip_address && request.ipAddress && existing.last_ip_address !== request.ipAddress); const fingerprintChanged = Boolean(existing?.fingerprint_hash && existing.fingerprint_hash !== fingerprintHash); const firstSeen = !existing; let device; if (existing) { dbRun( "UPDATE auth_devices SET label = ?, fingerprint_hash = ?, last_seen_at = ?, last_ip_address = ?, last_user_agent = ?, updated_at = ? WHERE id = ? AND user_id = ?", [request.deviceLabel || existing.label || "浏览器设备", fingerprintHash, timestamp, request.ipAddress, request.userAgent, timestamp, existing.id, userId] ); device = dbGet("SELECT * FROM auth_devices WHERE id = ?", [existing.id]); } else { const id = `device-${Date.now()}-${randomBytes(4).toString("hex")}`; dbRun( "INSERT INTO auth_devices(id, user_id, device_key_hash, fingerprint_hash, label, first_seen_at, last_seen_at, last_ip_address, last_user_agent, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, userId, deviceKeyHash, fingerprintHash, request.deviceLabel || "浏览器设备", timestamp, timestamp, request.ipAddress, request.userAgent, timestamp, timestamp] ); device = dbGet("SELECT * FROM auth_devices WHERE id = ?", [id]); } let riskScore = firstSeen ? 75 : device.trusted_at ? 10 : 50; if (device.revoked_at) riskScore = 90; if (ipChanged) riskScore += 20; if (fingerprintChanged) riskScore += 15; riskScore = Math.min(100, riskScore); if (firstSeen) { recordSecurityEvent({ userId, eventType: "device.first_seen", result: "success", ...request, metadata: { deviceRecordId: device.id, label: device.label, riskLevel: riskLevelForScore(riskScore), riskScore } }); } return { device, riskLevel: riskLevelForScore(riskScore), riskScore, firstSeen, ipChanged, fingerprintChanged }; } function safeSecurityMetadata(metadata = {}) { if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return {}; return Object.fromEntries(Object.entries(metadata).filter(([key]) => !/(password|passcode|otp|code|token|secret|api.?key)/i.test(key))); } export function recordSecurityEvent({ userId = null, eventType, result = "success", ipAddress = "", userAgent = "", metadata = {} } = {}) { if (!eventType) return null; const timestamp = nowIso(); const id = `sec-${Date.now()}-${randomBytes(5).toString("hex")}`; try { dbRun( "INSERT INTO auth_security_events(id, user_id, event_type, result, ip_address, user_agent, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [id, userId || null, String(eventType), String(result || "success"), boundedText(ipAddress, 128), boundedText(userAgent, 512), JSON.stringify(safeSecurityMetadata(metadata)), timestamp] ); return id; } catch { return null; } } function parseSecurityMetadata(value) { try { return JSON.parse(value || "{}"); } catch { return {}; } } export function listSecurityEvents(userId = null, options = {}) { const limit = Math.min(200, Math.max(1, Number(options.limit || 80))); const eventType = String(options.eventType || "").trim(); const clauses = []; const params = []; if (userId) { clauses.push("e.user_id = ?"); params.push(userId); } if (eventType) { clauses.push("e.event_type = ?"); params.push(eventType); } const rows = dbAll( `SELECT e.id, e.user_id, e.event_type, e.result, e.ip_address, e.user_agent, e.metadata_json, e.created_at, u.display_name AS user_display_name, u.email AS user_email FROM auth_security_events e LEFT JOIN users u ON u.id = e.user_id ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY e.created_at DESC LIMIT ?`, [...params, limit] ); return rows.map((row) => ({ id: row.id, userId: row.user_id, userDisplayName: row.user_display_name || "", userEmail: row.user_email || "", eventType: row.event_type, result: row.result, ipAddress: row.ip_address || "", userAgent: row.user_agent || "", metadata: parseSecurityMetadata(row.metadata_json), createdAt: row.created_at })); } function hashToken(token) { return createHash("sha256").update(token).digest("hex"); } function readToken(headers) { const authorization = headers.authorization || headers.Authorization; if (authorization && /^Bearer\s+/i.test(authorization)) return authorization.replace(/^Bearer\s+/i, "").trim(); const sessionToken = headers["x-session-token"]; return Array.isArray(sessionToken) ? sessionToken[0] : sessionToken; } export function safeUser(user) { if (!user) return null; return { id: user.id, display_name: user.display_name, email: user.email, avatar_color: user.avatar_color, status: user.status }; } function encodeBase32(buffer) { let value = 0; let bits = 0; let output = ""; for (const byte of buffer) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { bits -= 5; output += BASE32_ALPHABET[(value >> bits) & 31]; } } if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 31]; return output; } function decodeBase32(value) { const normalized = String(value || "").toUpperCase().replace(/=+$/g, "").replace(/[^A-Z2-7]/g, ""); let bits = 0; let buffer = 0; const output = []; for (const character of normalized) { buffer = (buffer << 5) | BASE32_ALPHABET.indexOf(character); bits += 5; if (bits >= 8) { bits -= 8; output.push((buffer >> bits) & 0xff); } } return Buffer.from(output); } function encryptMfaSecret(secret) { const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", MFA_STORAGE_KEY, iv); const encrypted = Buffer.concat([cipher.update(String(secret), "utf8"), cipher.final()]); return [iv, cipher.getAuthTag(), encrypted].map((part) => part.toString("base64url")).join("."); } function decryptMfaSecret(payload) { const [ivValue, tagValue, encryptedValue] = String(payload || "").split("."); if (!ivValue || !tagValue || !encryptedValue) throw authError(500, "mfa_secret_invalid", "MFA 密钥存储记录无效"); const decipher = createDecipheriv("aes-256-gcm", MFA_STORAGE_KEY, Buffer.from(ivValue, "base64url")); decipher.setAuthTag(Buffer.from(tagValue, "base64url")); return Buffer.concat([decipher.update(Buffer.from(encryptedValue, "base64url")), decipher.final()]).toString("utf8"); } function hotp(secret, counter) { const counterBuffer = Buffer.alloc(8); counterBuffer.writeBigUInt64BE(BigInt(counter)); const digest = createHmac("sha1", decodeBase32(secret)).update(counterBuffer).digest(); const offset = digest[digest.length - 1] & 0x0f; const value = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff); return String(value % 1000000).padStart(6, "0"); } function validTotp(secret, code, timestamp = Date.now()) { const normalizedCode = String(code || "").replace(/\s/g, ""); if (!/^\d{6}$/.test(normalizedCode)) return false; const counter = Math.floor(timestamp / MFA_STEP_MS); return [-1, 0, 1].some((offset) => hotp(secret, counter + offset) === normalizedCode); } function identityPolicy() { return dbGet("SELECT * FROM identity_policies WHERE id = 'default'") || { password_login_enabled: 1, mfa_required_for_admins: 0, mfa_required_for_all: 0, session_ttl_hours: 8, max_sessions_per_user: 10 }; } function userIsPrivileged(userId) { if (dbGet("SELECT user_id FROM system_admins WHERE user_id = ? AND status = 'active'", [userId])) return true; return Boolean(dbGet("SELECT user_id FROM organization_members WHERE user_id = ? AND role_key IN ('org_owner', 'org_admin') AND status = 'active' LIMIT 1", [userId])); } export function mfaRequiredForUser(userId) { const policy = identityPolicy(); return Boolean(policy.mfa_required_for_all || (policy.mfa_required_for_admins && userIsPrivileged(userId))); } function passwordMatches(userId, password) { const credential = dbGet("SELECT * FROM user_credentials WHERE user_id = ?", [userId]); if (!credential) return false; const expected = Buffer.from(credential.password_hash, "hex"); const actual = Buffer.from(passwordHash(password, credential.password_salt), "hex"); return expected.length === actual.length && timingSafeEqual(expected, actual); } function clientIdentity(token) { const client = dbGet( `SELECT c.*, u.id AS user_id, u.display_name, u.email, u.avatar_color, u.status AS user_status FROM api_clients c JOIN users u ON u.id = c.created_by WHERE c.client_key_hash = ? AND c.status = 'active'`, [hashApiClientKey(token)] ); if (!client) return null; if (client.user_status !== "active") throw authError(403, "api_client_owner_not_active", "API 客户端所属用户已停用"); dbRun("UPDATE api_clients SET last_used_at = ?, updated_at = ? WHERE id = ?", [nowIso(), nowIso(), client.id]); let scopes = []; try { scopes = JSON.parse(client.scopes_json || "[]"); } catch { scopes = []; } return { userId: client.user_id, sessionId: null, apiClientId: client.id, apiClient: { id: client.id, organizationId: client.organization_id, workspaceId: client.workspace_id, scopes }, user: safeUser({ ...client, id: client.user_id, status: client.user_status }), expiresAt: null }; } export function sessionIdentity(headers) { const token = readToken(headers); if (!token) return null; const tokenHash = hashToken(token); const session = dbGet( `SELECT s.*, u.id AS user_id, u.display_name, u.email, u.avatar_color, u.status AS user_status, d.id AS auth_device_id, d.label AS device_label, d.first_seen_at AS device_first_seen_at, d.last_seen_at AS device_last_seen_at, d.last_ip_address AS device_last_ip_address, d.last_user_agent AS device_last_user_agent, d.trusted_at, d.revoked_at FROM auth_sessions s JOIN users u ON u.id = s.user_id LEFT JOIN auth_devices d ON d.id = s.device_id WHERE s.token_hash = ? AND s.revoked_at IS NULL`, [tokenHash] ); if (!session) { const apiClient = clientIdentity(token); if (apiClient) return apiClient; throw authError(401, "session_invalid", "登录会话无效,请重新登录"); } if (session.user_status !== "active") throw authError(403, "user_not_active", "当前用户已被停用"); if (new Date(session.expires_at).getTime() <= Date.now()) { dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE id = ?", [nowIso(), nowIso(), session.id]); throw authError(401, "session_expired", "登录会话已过期,请重新登录"); } dbRun("UPDATE auth_sessions SET last_seen_at = ? WHERE id = ?", [nowIso(), session.id]); return { userId: session.user_id, sessionId: session.id, user: safeUser({ ...session, id: session.user_id, status: session.user_status }), expiresAt: session.expires_at, riskLevel: session.risk_level || "medium", riskScore: Number(session.risk_score ?? 50), device: devicePayload(session) }; } export function authenticate(email, password, metadata = {}) { const normalizedEmail = String(email || "").trim().toLowerCase(); const request = requestMetadata(metadata); const user = dbGet("SELECT * FROM users WHERE lower(email) = ?", [normalizedEmail]); if (!user) { recordSecurityEvent({ eventType: "login.failure", result: "failure", ...request, metadata: { email: normalizedEmail, reason: "unknown_email" } }); throw authError(401, "invalid_credentials", "邮箱或密码不正确"); } const credential = dbGet("SELECT * FROM user_credentials WHERE user_id = ?", [user.id]); if (!credential) { recordSecurityEvent({ userId: user.id, eventType: "login.failure", result: "failure", ...request, metadata: { email: normalizedEmail, reason: "credential_missing" } }); throw authError(401, "invalid_credentials", "邮箱或密码不正确"); } if (credential.locked_until && new Date(credential.locked_until).getTime() > Date.now()) { recordSecurityEvent({ userId: user.id, eventType: "account.locked", result: "blocked", ...request, metadata: { email: normalizedEmail, reason: "lockout_active", lockedUntil: credential.locked_until } }); throw authError(429, "account_locked", "登录失败次数过多,请稍后再试"); } const valid = passwordMatches(user.id, password); const timestamp = nowIso(); if (!valid) { const failedAttempts = Number(credential.failed_attempts || 0) + 1; const lockedUntil = failedAttempts >= MAX_FAILED_ATTEMPTS ? new Date(Date.now() + LOCKOUT_MS).toISOString() : null; dbRun("UPDATE user_credentials SET failed_attempts = ?, locked_until = ?, updated_at = ? WHERE user_id = ?", [failedAttempts, lockedUntil, timestamp, user.id]); recordSecurityEvent({ userId: user.id, eventType: "login.failure", result: "failure", ...request, metadata: { email: normalizedEmail, reason: "invalid_credentials", failedAttempts, lockoutTriggered: Boolean(lockedUntil) } }); if (lockedUntil) recordSecurityEvent({ userId: user.id, eventType: "account.locked", result: "blocked", ...request, metadata: { email: normalizedEmail, reason: "failed_login_threshold", failedAttempts, lockedUntil } }); throw authError(401, "invalid_credentials", "邮箱或密码不正确"); } if (user.status !== "active") { recordSecurityEvent({ userId: user.id, eventType: "login.blocked", result: "blocked", ...request, metadata: { reason: "user_not_active", status: user.status } }); throw authError(403, "user_not_active", "当前用户未激活或已停用"); } if (!identityPolicy().password_login_enabled) { recordSecurityEvent({ userId: user.id, eventType: "login.blocked", result: "blocked", ...request, metadata: { reason: "password_login_disabled" } }); throw authError(403, "password_login_disabled", "平台已关闭本地密码登录,请使用企业身份登录"); } dbRun("UPDATE user_credentials SET failed_attempts = 0, locked_until = NULL, last_login_at = ?, updated_at = ? WHERE user_id = ?", [timestamp, timestamp, user.id]); return user; } export function mfaStatus(userId) { const method = dbGet("SELECT id, method_type, label, enabled, setup_expires_at, last_used_at, created_at FROM user_mfa_methods WHERE user_id = ?", [userId]); return { enabled: Boolean(method?.enabled), method: method ? { id: method.id, type: method.method_type, label: method.label, enabled: Boolean(method.enabled), setupExpiresAt: method.setup_expires_at, lastUsedAt: method.last_used_at, createdAt: method.created_at } : null }; } export function startMfaSetup(userId, metadata = {}) { const current = dbGet("SELECT id, enabled FROM user_mfa_methods WHERE user_id = ?", [userId]); if (current?.enabled) throw authError(409, "mfa_already_enabled", "MFA 已经启用"); const user = dbGet("SELECT email FROM users WHERE id = ?", [userId]); if (!user) throw authError(404, "user_not_found", "当前用户不存在"); const id = current?.id || `mfa-${Date.now()}-${randomBytes(4).toString("hex")}`; const secret = encodeBase32(randomBytes(20)); const timestamp = nowIso(); const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); if (current) { dbRun("UPDATE user_mfa_methods SET secret_ciphertext = ?, label = ?, enabled = 0, setup_expires_at = ?, updated_at = ? WHERE id = ? AND user_id = ?", [encryptMfaSecret(secret), "身份验证器", expiresAt, timestamp, id, userId]); } else { dbRun("INSERT INTO user_mfa_methods(id, user_id, method_type, label, secret_ciphertext, enabled, setup_expires_at, created_at, updated_at) VALUES (?, ?, 'totp', ?, ?, 0, ?, ?, ?)", [id, userId, "身份验证器", encryptMfaSecret(secret), expiresAt, timestamp, timestamp]); } const request = requestMetadata(metadata); recordSecurityEvent({ userId, eventType: "mfa.setup.started", result: "success", ...request, metadata: { methodId: id, expiresAt } }); return { methodId: id, type: "totp", label: "身份验证器", secret, otpauthUrl: `otpauth://totp/${encodeURIComponent(user.email)}?secret=${secret}&issuer=${encodeURIComponent("AI短剧生产平台")}`, expiresAt }; } export function enableMfa(userId, methodId, code, metadata = {}) { const request = requestMetadata(metadata); const method = dbGet("SELECT * FROM user_mfa_methods WHERE id = ? AND user_id = ?", [methodId, userId]); if (!method) throw authError(404, "mfa_method_not_found", "MFA 初始化记录不存在"); if (method.enabled) return mfaStatus(userId); if (method.setup_expires_at && new Date(method.setup_expires_at).getTime() <= Date.now()) { recordSecurityEvent({ userId, eventType: "mfa.failure", result: "failure", ...request, metadata: { reason: "setup_expired", methodId } }); throw authError(410, "mfa_setup_expired", "MFA 初始化已过期,请重新生成密钥"); } if (!validTotp(decryptMfaSecret(method.secret_ciphertext), code)) { recordSecurityEvent({ userId, eventType: "mfa.failure", result: "failure", ...request, metadata: { reason: "invalid_setup_code", methodId } }); throw authError(400, "mfa_code_invalid", "验证码不正确"); } const timestamp = nowIso(); dbRun("UPDATE user_mfa_methods SET enabled = 1, setup_expires_at = NULL, last_used_at = ?, updated_at = ? WHERE id = ? AND user_id = ?", [timestamp, timestamp, methodId, userId]); recordSecurityEvent({ userId, eventType: "mfa.enabled", result: "success", ...request, metadata: { methodId, methodType: "totp" } }); return mfaStatus(userId); } export function cancelMfaSetup(userId, methodId, metadata = {}) { const method = dbGet("SELECT id, enabled FROM user_mfa_methods WHERE id = ? AND user_id = ?", [methodId, userId]); if (!method) throw authError(404, "mfa_method_not_found", "MFA 初始化记录不存在"); if (method.enabled) throw authError(409, "mfa_already_enabled", "已启用的 MFA 不能通过取消初始化关闭"); dbRun("DELETE FROM user_mfa_methods WHERE id = ? AND user_id = ? AND enabled = 0", [methodId, userId]); const request = requestMetadata(metadata); recordSecurityEvent({ userId, eventType: "mfa.setup.cancelled", result: "success", ...request, metadata: { methodId } }); return mfaStatus(userId); } export function disableMfa(userId, currentPassword, code, metadata = {}) { const request = requestMetadata(metadata); const method = dbGet("SELECT * FROM user_mfa_methods WHERE user_id = ? AND enabled = 1", [userId]); if (!method) return mfaStatus(userId); if (!passwordMatches(userId, currentPassword)) { recordSecurityEvent({ userId, eventType: "mfa.failure", result: "failure", ...request, metadata: { reason: "invalid_current_password", methodId: method.id } }); throw authError(400, "current_password_invalid", "当前密码不正确"); } if (!validTotp(decryptMfaSecret(method.secret_ciphertext), code)) { recordSecurityEvent({ userId, eventType: "mfa.failure", result: "failure", ...request, metadata: { reason: "invalid_disable_code", methodId: method.id } }); throw authError(400, "mfa_code_invalid", "验证码不正确"); } dbRun("DELETE FROM user_mfa_methods WHERE id = ? AND user_id = ?", [method.id, userId]); recordSecurityEvent({ userId, eventType: "mfa.disabled", result: "success", ...request, metadata: { methodId: method.id, methodType: "totp" } }); return mfaStatus(userId); } export function createMfaChallenge(userId, metadata = {}) { const token = randomBytes(32).toString("base64url"); const timestamp = nowIso(); const expiresAt = new Date(Date.now() + MFA_CHALLENGE_TTL_MS).toISOString(); dbRun("INSERT INTO auth_mfa_challenges(id, challenge_hash, user_id, expires_at, attempts, created_at) VALUES (?, ?, ?, ?, 0, ?)", [`mfa-challenge-${Date.now()}-${randomBytes(4).toString("hex")}`, hashToken(token), userId, expiresAt, timestamp]); const request = requestMetadata(metadata); recordSecurityEvent({ userId, eventType: "mfa.challenge.created", result: "challenge", ...request, metadata: { expiresAt } }); return { token, expiresAt }; } export function createMfaEnrollmentChallenge(userId, metadata = {}) { const token = randomBytes(32).toString("base64url"); const timestamp = nowIso(); const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); dbRun("INSERT INTO auth_mfa_enrollment_challenges(id, challenge_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", [`mfa-enroll-${Date.now()}-${randomBytes(4).toString("hex")}`, hashToken(token), userId, expiresAt, timestamp]); const request = requestMetadata(metadata); recordSecurityEvent({ userId, eventType: "mfa.enrollment.challenge.created", result: "challenge", ...request, metadata: { expiresAt } }); return { token, expiresAt }; } function enrollmentChallenge(token) { const challenge = dbGet("SELECT c.*, u.email, u.display_name, u.avatar_color, u.status FROM auth_mfa_enrollment_challenges c JOIN users u ON u.id = c.user_id WHERE c.challenge_hash = ?", [hashToken(token)]); if (!challenge || challenge.consumed_at) throw authError(401, "mfa_enrollment_invalid", "MFA 绑定挑战无效,请重新登录"); if (new Date(challenge.expires_at).getTime() <= Date.now()) throw authError(401, "mfa_enrollment_expired", "MFA 绑定挑战已过期,请重新登录"); if (challenge.status !== "active") throw authError(403, "user_not_active", "当前账号已停用"); return challenge; } export function startMfaEnrollment(token, metadata = {}) { const challenge = enrollmentChallenge(token); return { setup: startMfaSetup(challenge.user_id, metadata), user: safeUser(challenge) }; } export function completeMfaEnrollment(token, methodId, code, metadata = {}) { const challenge = enrollmentChallenge(token); const status = enableMfa(challenge.user_id, methodId, code, metadata); const timestamp = nowIso(); dbRun("UPDATE auth_mfa_enrollment_challenges SET consumed_at = ? WHERE id = ?", [timestamp, challenge.id]); const request = requestMetadata(metadata); recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.enrollment.completed", result: "success", ...request, metadata: { methodId } }); return { session: createSession(challenge.user_id, metadata), status }; } export function completeMfaChallenge(challengeToken, code, metadata = {}) { const request = requestMetadata(metadata); const challenge = dbGet("SELECT c.*, m.id AS method_id, m.secret_ciphertext FROM auth_mfa_challenges c JOIN user_mfa_methods m ON m.user_id = c.user_id AND m.enabled = 1 WHERE c.challenge_hash = ?", [hashToken(challengeToken)]); if (!challenge || challenge.consumed_at) { recordSecurityEvent({ eventType: "mfa.challenge.failure", result: "failure", ...request, metadata: { reason: "challenge_invalid" } }); throw authError(401, "mfa_challenge_invalid", "MFA 验证挑战无效,请重新登录"); } if (new Date(challenge.expires_at).getTime() <= Date.now()) { recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.challenge.failure", result: "failure", ...request, metadata: { reason: "challenge_expired" } }); throw authError(401, "mfa_challenge_expired", "MFA 验证挑战已过期,请重新登录"); } if (Number(challenge.attempts || 0) >= MFA_MAX_CHALLENGE_ATTEMPTS) { recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.challenge.failure", result: "blocked", ...request, metadata: { reason: "challenge_locked", attempts: challenge.attempts } }); throw authError(429, "mfa_challenge_locked", "MFA 验证失败次数过多,请重新登录"); } if (!validTotp(decryptMfaSecret(challenge.secret_ciphertext), code)) { const attempts = Number(challenge.attempts || 0) + 1; dbRun("UPDATE auth_mfa_challenges SET attempts = ?, consumed_at = CASE WHEN ? >= ? THEN ? ELSE consumed_at END WHERE id = ?", [attempts, attempts, MFA_MAX_CHALLENGE_ATTEMPTS, nowIso(), challenge.id]); recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.challenge.failure", result: attempts >= MFA_MAX_CHALLENGE_ATTEMPTS ? "blocked" : "failure", ...request, metadata: { reason: "invalid_code", attempts } }); throw authError(attempts >= MFA_MAX_CHALLENGE_ATTEMPTS ? 429 : 401, "mfa_code_invalid", "验证码不正确"); } const timestamp = nowIso(); dbRun("UPDATE auth_mfa_challenges SET consumed_at = ? WHERE id = ?", [timestamp, challenge.id]); dbRun("UPDATE user_mfa_methods SET last_used_at = ?, updated_at = ? WHERE id = ?", [timestamp, timestamp, challenge.method_id]); recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.challenge.success", result: "success", ...request, metadata: { methodId: challenge.method_id } }); return createSession(challenge.user_id, metadata); } export function createSession(userId, metadata = {}) { const token = randomBytes(32).toString("base64url"); const timestamp = nowIso(); const policy = identityPolicy(); const ttlMs = Math.max(1, Number(policy.session_ttl_hours || SESSION_TTL_MS / 3600000)) * 60 * 60 * 1000; const expiresAt = new Date(Date.now() + ttlMs).toISOString(); const id = `session-${Date.now()}-${randomBytes(4).toString("hex")}`; const device = registerAuthDevice(userId, metadata); dbRun("INSERT INTO auth_sessions(id, token_hash, user_id, expires_at, ip_address, user_agent, created_at, last_seen_at, device_id, risk_level, risk_score) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ id, hashToken(token), userId, expiresAt, String(metadata.ipAddress || ""), String(metadata.userAgent || ""), timestamp, timestamp, device.device?.id || null, device.riskLevel, device.riskScore ]); const maxSessions = Math.max(1, Number(policy.max_sessions_per_user || 10)); const staleSessions = dbAll("SELECT id FROM auth_sessions WHERE user_id = ? AND revoked_at IS NULL ORDER BY last_seen_at DESC", [userId]).slice(maxSessions); if (staleSessions.length) dbRun(`UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE id IN (${staleSessions.map(() => "?").join(",")})`, [timestamp, timestamp, ...staleSessions.map((row) => row.id)]); const request = requestMetadata(metadata); recordSecurityEvent({ userId, eventType: "session.created", result: "success", ...request, metadata: { sessionId: id, expiresAt, authMethod: metadata.authMethod || "session", deviceRecordId: device.device?.id || null, riskLevel: device.riskLevel, riskScore: device.riskScore, evictedSessionCount: staleSessions.length } }); if (staleSessions.length) recordSecurityEvent({ userId, eventType: "session.revoked", result: "success", ...request, metadata: { reason: "session_limit", revokedSessionCount: staleSessions.length } }); return { id, token, expiresAt, deviceId: device.device?.id || null, riskLevel: device.riskLevel, riskScore: device.riskScore }; } export function revokeSession(headers, metadata = {}) { const token = readToken(headers); if (!token) return false; const timestamp = nowIso(); const session = dbGet("SELECT id, user_id FROM auth_sessions WHERE token_hash = ? AND revoked_at IS NULL", [hashToken(token)]); const result = dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE token_hash = ? AND revoked_at IS NULL", [timestamp, timestamp, hashToken(token)]); const revoked = Number(result.changes || 0) > 0; if (revoked && session) recordSecurityEvent({ userId: session.user_id, eventType: "session.revoked", result: "success", ...requestMetadata(metadata), metadata: { sessionId: session.id, reason: metadata.reason || "logout" } }); return revoked; } function sessionPayload(row, currentSessionId) { const expired = new Date(row.expires_at).getTime() <= Date.now(); return { id: row.id, createdAt: row.created_at, lastSeenAt: row.last_seen_at, expiresAt: row.expires_at, ipAddress: row.ip_address || "", userAgent: row.user_agent || "", revokedAt: row.revoked_at || null, deviceId: row.device_id || null, device: devicePayload(row), riskLevel: row.risk_level || "medium", riskScore: Number(row.risk_score ?? 50), current: row.id === currentSessionId, status: row.revoked_at ? "revoked" : expired ? "expired" : row.id === currentSessionId ? "current" : "active" }; } export function listUserSessions(userId, currentSessionId) { const rows = dbAll( `SELECT s.id, s.created_at, s.last_seen_at, s.expires_at, s.ip_address, s.user_agent, s.revoked_at, s.device_id, s.risk_level, s.risk_score, d.id AS auth_device_id, d.label AS device_label, d.first_seen_at AS device_first_seen_at, d.last_seen_at AS device_last_seen_at, d.last_ip_address AS device_last_ip_address, d.last_user_agent AS device_last_user_agent, d.trusted_at, d.revoked_at FROM auth_sessions s LEFT JOIN auth_devices d ON d.id = s.device_id WHERE s.user_id = ? ORDER BY s.last_seen_at DESC LIMIT 50`, [userId] ); return rows.map((row) => sessionPayload(row, currentSessionId)); } export function listUserDevices(userId) { const timestamp = nowIso(); const rows = dbAll( `SELECT d.*, (SELECT COUNT(*) FROM auth_sessions s WHERE s.device_id = d.id AND s.revoked_at IS NULL AND s.expires_at > ?) AS active_session_count, (SELECT s.risk_level FROM auth_sessions s WHERE s.device_id = d.id ORDER BY s.created_at DESC LIMIT 1) AS latest_risk_level, (SELECT s.risk_score FROM auth_sessions s WHERE s.device_id = d.id ORDER BY s.created_at DESC LIMIT 1) AS latest_risk_score FROM auth_devices d WHERE d.user_id = ? ORDER BY d.last_seen_at DESC`, [timestamp, userId] ); return rows.map((row) => devicePayload({ ...row, auth_device_id: row.id })); } function getUserDevice(userId, deviceId) { return dbGet("SELECT * FROM auth_devices WHERE id = ? AND user_id = ?", [deviceId, userId]); } export function trustUserDevice(userId, deviceId, metadata = {}) { const device = getUserDevice(userId, deviceId); if (!device) throw authError(404, "device_not_found", "设备记录不存在"); if (device.revoked_at) throw authError(409, "device_revoked", "已撤销的设备不能标记为信任"); const timestamp = nowIso(); dbRun("UPDATE auth_devices SET trusted_at = ?, updated_at = ? WHERE id = ? AND user_id = ?", [timestamp, timestamp, deviceId, userId]); recordSecurityEvent({ userId, eventType: "device.trusted", result: "success", ...requestMetadata(metadata), metadata: { deviceRecordId: deviceId } }); return listUserDevices(userId).find((item) => item.id === deviceId) || null; } export function untrustUserDevice(userId, deviceId, metadata = {}) { const device = getUserDevice(userId, deviceId); if (!device) throw authError(404, "device_not_found", "设备记录不存在"); const timestamp = nowIso(); dbRun("UPDATE auth_devices SET trusted_at = NULL, updated_at = ? WHERE id = ? AND user_id = ?", [timestamp, deviceId, userId]); recordSecurityEvent({ userId, eventType: "device.untrusted", result: "success", ...requestMetadata(metadata), metadata: { deviceRecordId: deviceId } }); return listUserDevices(userId).find((item) => item.id === deviceId) || null; } export function revokeUserSession(userId, sessionId, metadata = {}) { const result = dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL", [nowIso(), nowIso(), sessionId, userId]); const revoked = Number(result.changes || 0) > 0; if (revoked) recordSecurityEvent({ userId, eventType: "session.revoked", result: "success", ...requestMetadata(metadata), metadata: { sessionId, reason: metadata.reason || "self_service" } }); return revoked; } export function revokeOtherUserSessions(userId, currentSessionId, metadata = {}) { const timestamp = nowIso(); const result = dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE user_id = ? AND id <> ? AND revoked_at IS NULL", [timestamp, timestamp, userId, currentSessionId]); const revokedCount = Number(result.changes || 0); recordSecurityEvent({ userId, eventType: "session.revoked", result: "success", ...requestMetadata(metadata), metadata: { reason: "revoke_others", revokedSessionCount: revokedCount } }); return revokedCount; } export function revokeAllUserSessions(userId, metadata = {}) { const timestamp = nowIso(); const result = dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE user_id = ? AND revoked_at IS NULL", [timestamp, timestamp, userId]); const revokedCount = Number(result.changes || 0); recordSecurityEvent({ userId, eventType: "session.revoked", result: "success", ...requestMetadata(metadata), metadata: { reason: metadata.reason || "revoke_all", revokedSessionCount: revokedCount, actorUserId: metadata.actorUserId || null } }); return revokedCount; } export function changePassword(userId, currentPassword, nextPassword, metadata = {}) { const request = requestMetadata(metadata); const user = dbGet("SELECT * FROM users WHERE id = ?", [userId]); const credential = dbGet("SELECT * FROM user_credentials WHERE user_id = ?", [userId]); if (!user || !credential) throw authError(404, "user_not_found", "当前用户不存在"); const expected = Buffer.from(credential.password_hash, "hex"); const actual = Buffer.from(passwordHash(currentPassword, credential.password_salt), "hex"); if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { recordSecurityEvent({ userId, eventType: "password.failure", result: "failure", ...request, metadata: { reason: "current_password_invalid" } }); throw authError(400, "current_password_invalid", "当前密码不正确"); } if (String(nextPassword || "").length < 12) throw authError(400, "password_too_short", "新密码至少需要 12 个字符"); const record = createPasswordRecord(nextPassword); const changedAt = nowIso(); dbRun("UPDATE user_credentials SET password_salt = ?, password_hash = ?, failed_attempts = 0, locked_until = NULL, updated_at = ? WHERE user_id = ?", [record.salt, record.hash, changedAt, userId]); recordSecurityEvent({ userId, eventType: "password.changed", result: "success", ...request, metadata: { changedAt } }); return { changedAt }; } export function resetPassword(userId, nextPassword, metadata = {}) { const user = dbGet("SELECT id FROM users WHERE id = ?", [userId]); if (!user) throw authError(404, "user_not_found", "目标用户不存在"); if (String(nextPassword || "").length < 12) throw authError(400, "password_too_short", "新密码至少需要 12 个字符"); const record = createPasswordRecord(nextPassword); const timestamp = nowIso(); dbRun("INSERT INTO user_credentials(user_id, password_salt, password_hash, failed_attempts, locked_until, created_at, updated_at) VALUES (?, ?, ?, 0, NULL, ?, ?) ON CONFLICT(user_id) DO UPDATE SET password_salt = excluded.password_salt, password_hash = excluded.password_hash, failed_attempts = 0, locked_until = NULL, updated_at = excluded.updated_at", [userId, record.salt, record.hash, timestamp, timestamp]); const revokedSessionCount = revokeAllUserSessions(userId, metadata); recordSecurityEvent({ userId, eventType: "password.reset", result: "success", ...requestMetadata(metadata), metadata: { revokedSessionCount, actorUserId: metadata.actorUserId || null } }); return { resetAt: timestamp, revokedSessionCount }; } export function resetMfa(userId, metadata = {}) { const user = dbGet("SELECT id FROM users WHERE id = ?", [userId]); if (!user) throw authError(404, "user_not_found", "目标用户不存在"); dbRun("DELETE FROM user_mfa_methods WHERE user_id = ?", [userId]); dbRun("DELETE FROM auth_mfa_challenges WHERE user_id = ?", [userId]); dbRun("DELETE FROM auth_mfa_enrollment_challenges WHERE user_id = ?", [userId]); const revokedSessionCount = revokeAllUserSessions(userId, metadata); recordSecurityEvent({ userId, eventType: "mfa.reset", result: "success", ...requestMetadata(metadata), metadata: { revokedSessionCount, actorUserId: metadata.actorUserId || null } }); return { resetAt: nowIso(), revokedSessionCount }; } export function authMode() { return process.env.AI_DRAMA_ALLOW_DEV_CONTEXT === "1" ? "session-plus-scoped-api-client-plus-local-dev-context" : "session-or-scoped-api-client"; }