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

761 lines
39 KiB
JavaScript

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