270 lines
7.2 KiB
JavaScript
270 lines
7.2 KiB
JavaScript
"use strict";
|
|
|
|
const crypto = require("node:crypto");
|
|
|
|
const SESSION_COOKIE_NAME = "airwatcher_sid";
|
|
const DEFAULT_SESSION_TTL_SEC = 60 * 60 * 24 * 7;
|
|
|
|
function normalizeUsername(value) {
|
|
if (typeof value !== "string") return "";
|
|
return value.trim().toLowerCase();
|
|
}
|
|
|
|
function normalizePassword(value) {
|
|
if (typeof value !== "string") return "";
|
|
return value.trim();
|
|
}
|
|
|
|
function secureEqual(left, right) {
|
|
const leftBuffer = Buffer.from(String(left));
|
|
const rightBuffer = Buffer.from(String(right));
|
|
if (leftBuffer.length !== rightBuffer.length) return false;
|
|
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
|
}
|
|
|
|
function parseCookieHeader(cookieHeader) {
|
|
if (typeof cookieHeader !== "string" || cookieHeader.trim() === "") return {};
|
|
|
|
return cookieHeader.split(";").reduce((acc, pair) => {
|
|
const index = pair.indexOf("=");
|
|
if (index <= 0) return acc;
|
|
|
|
const key = pair.slice(0, index).trim();
|
|
const value = pair.slice(index + 1).trim();
|
|
if (!key) return acc;
|
|
try {
|
|
acc[key] = decodeURIComponent(value);
|
|
} catch (_error) {
|
|
acc[key] = value;
|
|
}
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
function parseBasicAuthorization(headerValue) {
|
|
if (typeof headerValue !== "string") return null;
|
|
const matched = headerValue.trim().match(/^Basic\s+(.+)$/i);
|
|
if (!matched) return null;
|
|
|
|
try {
|
|
const decoded = Buffer.from(matched[1], "base64").toString("utf8");
|
|
const separator = decoded.indexOf(":");
|
|
if (separator < 0) return null;
|
|
|
|
return {
|
|
username: decoded.slice(0, separator),
|
|
password: decoded.slice(separator + 1),
|
|
};
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parsePositiveInt(value, fallback) {
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) return fallback;
|
|
return parsed;
|
|
}
|
|
|
|
function parseUsers(rawUsers) {
|
|
if (typeof rawUsers !== "string" || rawUsers.trim() === "") {
|
|
return new Map();
|
|
}
|
|
|
|
const users = new Map();
|
|
const entries = rawUsers
|
|
.split(/[\n,]/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
|
|
for (const entry of entries) {
|
|
const separator = entry.indexOf(":");
|
|
if (separator <= 0) continue;
|
|
|
|
const username = normalizeUsername(entry.slice(0, separator));
|
|
const password = normalizePassword(entry.slice(separator + 1));
|
|
if (!username || !password) continue;
|
|
users.set(username, password);
|
|
}
|
|
|
|
return users;
|
|
}
|
|
|
|
function parseAdminUsers(rawAdminUsers, knownUsers) {
|
|
const admins = new Set();
|
|
if (typeof rawAdminUsers === "string" && rawAdminUsers.trim() !== "") {
|
|
for (const part of rawAdminUsers.split(/[\n,]/)) {
|
|
const username = normalizeUsername(part);
|
|
if (username) admins.add(username);
|
|
}
|
|
}
|
|
|
|
if (admins.size === 0 && knownUsers.size > 0) {
|
|
const firstUser = knownUsers.keys().next().value;
|
|
if (firstUser) admins.add(firstUser);
|
|
}
|
|
|
|
return admins;
|
|
}
|
|
|
|
function createDashboardAuth(options = {}) {
|
|
const users = parseUsers(
|
|
options.users !== undefined
|
|
? options.users
|
|
: process.env.DASHBOARD_USERS || process.env.DASHBOARD_BASIC_AUTH_USERS || ""
|
|
);
|
|
const admins = parseAdminUsers(
|
|
options.adminUsers !== undefined ? options.adminUsers : process.env.DASHBOARD_ADMIN_USERS,
|
|
users
|
|
);
|
|
const sessionTtlSec = parsePositiveInt(
|
|
options.sessionTtlSec !== undefined
|
|
? options.sessionTtlSec
|
|
: process.env.DASHBOARD_SESSION_TTL_SEC,
|
|
DEFAULT_SESSION_TTL_SEC
|
|
);
|
|
|
|
const sessions = new Map();
|
|
|
|
function isEnabled() {
|
|
return users.size > 0;
|
|
}
|
|
|
|
function isAdmin(username) {
|
|
return admins.has(normalizeUsername(username));
|
|
}
|
|
|
|
function toUser(username, authType) {
|
|
const normalized = normalizeUsername(username);
|
|
return {
|
|
username: normalized,
|
|
isAdmin: isAdmin(normalized),
|
|
authType,
|
|
};
|
|
}
|
|
|
|
function cleanupExpiredSessions() {
|
|
const now = Date.now();
|
|
for (const [sessionId, session] of sessions.entries()) {
|
|
if (!session || session.expiresAt <= now) {
|
|
sessions.delete(sessionId);
|
|
}
|
|
}
|
|
}
|
|
|
|
function verifyCredentials(username, password) {
|
|
const normalizedUsername = normalizeUsername(username);
|
|
const normalizedPassword = normalizePassword(password);
|
|
if (!normalizedUsername || !normalizedPassword) return null;
|
|
|
|
const expectedPassword = users.get(normalizedUsername);
|
|
if (!expectedPassword) return null;
|
|
|
|
return secureEqual(expectedPassword, normalizedPassword) ? normalizedUsername : null;
|
|
}
|
|
|
|
function issueSession(username) {
|
|
const sessionId = crypto.randomBytes(32).toString("hex");
|
|
const expiresAt = Date.now() + sessionTtlSec * 1000;
|
|
sessions.set(sessionId, {
|
|
username,
|
|
expiresAt,
|
|
});
|
|
return sessionId;
|
|
}
|
|
|
|
function readSessionIdFromHeaders(headers = {}) {
|
|
const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie[0] : headers.cookie;
|
|
const cookies = parseCookieHeader(cookieHeader);
|
|
const sessionId = cookies[SESSION_COOKIE_NAME];
|
|
return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : null;
|
|
}
|
|
|
|
function buildSessionCookie(sessionId) {
|
|
return `${SESSION_COOKIE_NAME}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${sessionTtlSec}`;
|
|
}
|
|
|
|
function buildSessionClearCookie() {
|
|
return `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
}
|
|
|
|
function login(username, password) {
|
|
if (!isEnabled()) {
|
|
throw new Error("Dashboard account login is not configured. Set DASHBOARD_USERS.");
|
|
}
|
|
|
|
const verifiedUsername = verifyCredentials(username, password);
|
|
if (!verifiedUsername) {
|
|
return null;
|
|
}
|
|
|
|
const sessionId = issueSession(verifiedUsername);
|
|
return {
|
|
user: toUser(verifiedUsername, "session"),
|
|
sessionId,
|
|
setCookie: buildSessionCookie(sessionId),
|
|
};
|
|
}
|
|
|
|
function getUserFromSession(headers = {}) {
|
|
cleanupExpiredSessions();
|
|
const sessionId = readSessionIdFromHeaders(headers);
|
|
if (!sessionId) return null;
|
|
|
|
const session = sessions.get(sessionId);
|
|
if (!session) return null;
|
|
if (session.expiresAt <= Date.now()) {
|
|
sessions.delete(sessionId);
|
|
return null;
|
|
}
|
|
|
|
return toUser(session.username, "session");
|
|
}
|
|
|
|
function getUserFromBasicAuth(headers = {}) {
|
|
const authHeader = Array.isArray(headers.authorization)
|
|
? headers.authorization[0]
|
|
: headers.authorization;
|
|
const parsed = parseBasicAuthorization(authHeader);
|
|
if (!parsed) return null;
|
|
|
|
const verifiedUsername = verifyCredentials(parsed.username, parsed.password);
|
|
if (!verifiedUsername) return null;
|
|
return toUser(verifiedUsername, "basic");
|
|
}
|
|
|
|
function getUserFromRequest(headers = {}) {
|
|
if (!isEnabled()) return null;
|
|
|
|
const fromSession = getUserFromSession(headers);
|
|
if (fromSession) return fromSession;
|
|
|
|
return getUserFromBasicAuth(headers);
|
|
}
|
|
|
|
function logout(headers = {}) {
|
|
const sessionId = readSessionIdFromHeaders(headers);
|
|
if (sessionId) {
|
|
sessions.delete(sessionId);
|
|
}
|
|
return {
|
|
setCookie: buildSessionClearCookie(),
|
|
};
|
|
}
|
|
|
|
return {
|
|
enabled: isEnabled(),
|
|
sessionCookieName: SESSION_COOKIE_NAME,
|
|
listUsers: () => Array.from(users.keys()),
|
|
getUserFromRequest,
|
|
login,
|
|
logout,
|
|
toUser,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
SESSION_COOKIE_NAME,
|
|
createDashboardAuth,
|
|
};
|