chore: 현재 작업 중간 커밋

This commit is contained in:
chungyeong
2026-03-05 11:00:45 +09:00
parent 02970df6af
commit be88b4fcec
43 changed files with 6837 additions and 466 deletions

269
src/dashboardAuth.js Normal file
View File

@@ -0,0 +1,269 @@
"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,
};