guanghu-ice-heart/server-tools/lake-lamp-authz/hololake-session.js

260 lines
7.9 KiB
JavaScript
Raw Normal View History

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