feat: bootstrap commercial AI drama platform
This commit is contained in:
+760
@@ -0,0 +1,760 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user