260 lines
7.9 KiB
JavaScript
260 lines
7.9 KiB
JavaScript
"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,
|
|
};
|