"use strict"; const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); class HoloLakeSessionManager { constructor(options = {}) { this.now = options.now || Date.now; this.sendEmail = options.sendEmail || (async () => false); this.pepper = String(options.pepper || ""); if (this.pepper.length < 24) { throw new Error("HOLOLAKE_SESSION_PEPPER must contain at least 24 characters"); } this.stateFile = String(options.stateFile || ""); this.otpTtlMs = Number(options.otpTtlSeconds || 10 * 60) * 1000; this.sessionTtlMs = Number(options.sessionTtlSeconds || 24 * 60 * 60) * 1000; this.maxOtpAttempts = Math.max(1, Number(options.maxOtpAttempts || 5)); this.requestLimit = Math.max(1, Number(options.requestLimit || 6)); this.requestWindowMs = Number(options.requestWindowSeconds || 60 * 60) * 1000; this.registeredEmails = new Map( (options.registeredEmails || []) .map(normalizeEmail) .filter(validEmail) .map(email => [email, this.digest(`account:${email}`)]), ); this.requestEvents = new Map(); this.state = this.loadState(); } async requestOtp({ email, deviceId, networkKey = "unknown" }) { const now = this.now(); this.prune(now); const normalizedDevice = normalizeDeviceId(deviceId); const requestId = crypto.randomUUID(); if (!normalizedDevice) return { accepted: false, error: "invalid_device" }; if (!this.takeRequest(String(networkKey || "unknown"), now)) { return { accepted: false, error: "rate_limited" }; } const normalizedEmail = normalizeEmail(email); const accountId = this.registeredEmails.get(normalizedEmail); if (!accountId) return { accepted: true, request_id: requestId }; const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0"); this.state.challenges[requestId] = { account_id: accountId, device_id: normalizedDevice, code_digest: this.digest( `otp:${requestId}:${normalizedDevice}:${code}`, ), attempts: 0, expires_at: now + this.otpTtlMs, }; this.persist(); const sent = await this.sendEmail({ to: normalizedEmail, subject: "HoloLake 登录验证码", text: [ `你的 HoloLake 登录验证码是:${code}`, `验证码将在 ${Math.ceil(this.otpTtlMs / 60_000)} 分钟后失效。`, "如果不是你本人操作,请忽略这封邮件。", ].join("\n"), }); if (!sent) { delete this.state.challenges[requestId]; this.persist(); } return { accepted: true, request_id: requestId }; } verifyOtp({ requestId, code, deviceId }) { const now = this.now(); this.prune(now); const id = String(requestId || ""); const challenge = this.state.challenges[id]; const normalizedDevice = normalizeDeviceId(deviceId); if ( !challenge || !normalizedDevice || challenge.expires_at <= now || challenge.attempts >= this.maxOtpAttempts ) { if (challenge) { delete this.state.challenges[id]; this.persist(); } return invalidCode(); } challenge.attempts += 1; const candidate = this.digest( `otp:${id}:${normalizedDevice}:${String(code || "")}`, ); const matches = safeEqual(candidate, challenge.code_digest) && safeEqual(normalizedDevice, challenge.device_id); if (!matches) { if (challenge.attempts >= this.maxOtpAttempts) { delete this.state.challenges[id]; } this.persist(); return invalidCode(); } delete this.state.challenges[id]; const token = crypto.randomBytes(48).toString("base64url"); const tokenDigest = this.digest(`session:${token}`); this.state.sessions[tokenDigest] = { account_id: challenge.account_id, device_id: normalizedDevice, created_at: now, expires_at: now + this.sessionTtlMs, }; this.persist(); return { ok: true, session_token: token, expires_at: (now + this.sessionTtlMs) / 1000, expires_in: this.sessionTtlMs / 1000, }; } authenticate(token, deviceId) { const now = this.now(); const normalizedDevice = normalizeDeviceId(deviceId); const tokenDigest = this.digest(`session:${String(token || "")}`); const session = this.state.sessions[tokenDigest]; if (!session) return { ok: false, error: "session_invalid" }; if (session.expires_at <= now) { delete this.state.sessions[tokenDigest]; this.persist(); return { ok: false, error: "session_expired" }; } if (!normalizedDevice || !safeEqual(normalizedDevice, session.device_id)) { return { ok: false, error: "session_device_mismatch" }; } return { ok: true, session: { account_id: session.account_id, device_id: session.device_id, created_at: session.created_at / 1000, expires_at: session.expires_at / 1000, }, }; } revoke(token, deviceId) { const authenticated = this.authenticate(token, deviceId); if (!authenticated.ok) return authenticated; const tokenDigest = this.digest(`session:${String(token || "")}`); delete this.state.sessions[tokenDigest]; this.persist(); return { ok: true }; } inspectState() { return JSON.parse(JSON.stringify(this.state)); } digest(value) { return crypto.createHmac("sha256", this.pepper).update(value).digest("hex"); } takeRequest(key, now) { const previous = (this.requestEvents.get(key) || []) .filter(timestamp => now - timestamp < this.requestWindowMs); if (previous.length >= this.requestLimit) { this.requestEvents.set(key, previous); return false; } previous.push(now); this.requestEvents.set(key, previous); return true; } prune(now) { let changed = false; for (const [id, challenge] of Object.entries(this.state.challenges)) { if (challenge.expires_at <= now) { delete this.state.challenges[id]; changed = true; } } for (const [digest, session] of Object.entries(this.state.sessions)) { if (session.expires_at <= now) { delete this.state.sessions[digest]; changed = true; } } if (changed) this.persist(); } loadState() { if (!this.stateFile || !fs.existsSync(this.stateFile)) { return emptyState(); } const parsed = JSON.parse(fs.readFileSync(this.stateFile, "utf8")); if ( !parsed || parsed.schema !== "guanghu.hololake-session-state/v1" || !isRecord(parsed.challenges) || !isRecord(parsed.sessions) ) { throw new Error("invalid HoloLake session state"); } return parsed; } persist() { if (!this.stateFile) return; fs.mkdirSync(path.dirname(this.stateFile), { recursive: true, mode: 0o700 }); const temporary = `${this.stateFile}.${process.pid}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(this.state, null, 2)}\n`, { encoding: "utf8", mode: 0o600, }); fs.renameSync(temporary, this.stateFile); fs.chmodSync(this.stateFile, 0o600); } } function emptyState() { return { schema: "guanghu.hololake-session-state/v1", challenges: {}, sessions: {}, }; } function invalidCode() { return { ok: false, error: "invalid_or_expired_code" }; } function normalizeEmail(value) { return String(value || "").trim().normalize("NFKC").toLowerCase(); } function normalizeDeviceId(value) { const normalized = String(value || "").trim(); return /^[A-Za-z0-9._:-]{8,120}$/.test(normalized) ? normalized : ""; } function validEmail(value) { return value.length <= 254 && /^[^@\s]+@[^@\s]+$/.test(value); } function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); } function isRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } module.exports = { HoloLakeSessionManager, };