chore: 현재 작업 중간 커밋
This commit is contained in:
269
src/dashboardAuth.js
Normal file
269
src/dashboardAuth.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user