password based service info auth (better than the first one we had)

This commit is contained in:
Nopm
2025-06-03 21:44:43 -03:00
parent 7b3cf409e4
commit c066a7d46b
3 changed files with 64 additions and 14 deletions
+9
View File
@@ -25,6 +25,15 @@ NODE_ENV=production
# Defaults to true. Set to false to disable login and make the info page public. # Defaults to true. Set to false to disable login and make the info page public.
# ENABLE_INFO_PAGE_LOGIN=true # ENABLE_INFO_PAGE_LOGIN=true
# Authentication mode for the service info page. (token | password)
# If 'token', any valid user token (via `getUser()`) is used (requires GATEKEEPER='user_token' mode).
# If 'password', SERVICE_INFO_PASSWORD is used.
# Defaults to 'token' if ENABLE_INFO_PAGE_LOGIN is true.
# SERVICE_INFO_AUTH_MODE=token
# Password for the service info page if SERVICE_INFO_AUTH_MODE is 'password'.
# SERVICE_INFO_PASSWORD=your-service-info-password
# The route name used to proxy requests to APIs, relative to the Web site root. # The route name used to proxy requests to APIs, relative to the Web site root.
# PROXY_ENDPOINT_ROUTE=/proxy # PROXY_ENDPOINT_ROUTE=/proxy
+26
View File
@@ -435,6 +435,10 @@ type Config = {
loginImageUrl?: string; loginImageUrl?: string;
/** Whether to enable the token-based login page for the service info page. Defaults to true. */ /** Whether to enable the token-based login page for the service info page. Defaults to true. */
enableInfoPageLogin?: boolean; enableInfoPageLogin?: boolean;
/** Authentication mode for the service info page. (token | password) */
serviceInfoAuthMode: "token" | "password";
/** Password for the service info page if serviceInfoAuthMode is 'password'. */
serviceInfoPassword?: string;
}; };
// To change configs, create a file called .env in the root directory. // To change configs, create a file called .env in the root directory.
@@ -554,6 +558,8 @@ export const config: Config = {
}, },
loginImageUrl: getEnvWithDefault("LOGIN_IMAGE_URL", ""), loginImageUrl: getEnvWithDefault("LOGIN_IMAGE_URL", ""),
enableInfoPageLogin: getEnvWithDefault("ENABLE_INFO_PAGE_LOGIN", true), enableInfoPageLogin: getEnvWithDefault("ENABLE_INFO_PAGE_LOGIN", true),
serviceInfoAuthMode: getEnvWithDefault("SERVICE_INFO_AUTH_MODE", "token") as Config["serviceInfoAuthMode"],
serviceInfoPassword: getEnvWithDefault("SERVICE_INFO_PASSWORD", undefined),
} as const; } as const;
function generateSigningKey() { function generateSigningKey() {
@@ -691,6 +697,25 @@ export async function assertConfigIsValid() {
} }
} }
if (config.enableInfoPageLogin) {
if (!["token", "password"].includes(config.serviceInfoAuthMode)) {
throw new Error(
`Invalid SERVICE_INFO_AUTH_MODE: ${config.serviceInfoAuthMode}. Must be 'token' or 'password'.`
);
}
if (config.serviceInfoAuthMode === "password" && !config.serviceInfoPassword) {
throw new Error(
"SERVICE_INFO_AUTH_MODE is 'password' but SERVICE_INFO_PASSWORD is not set."
);
}
// If service info login is token-based, gatekeeper must be 'user_token' mode for getUser() to be effective.
if (config.serviceInfoAuthMode === "token" && config.gatekeeper !== "user_token") {
throw new Error(
"SERVICE_INFO_AUTH_MODE is 'token' for info page login, but GATEKEEPER is not 'user_token'. User token authentication will not work."
);
}
}
// Ensure forks which add new secret-like config keys don't unwittingly expose // Ensure forks which add new secret-like config keys don't unwittingly expose
// them to users. // them to users.
for (const key of getKeys(config)) { for (const key of getKeys(config)) {
@@ -765,6 +790,7 @@ export const OMITTED_KEYS = [
"powTokenPurgeHours", "powTokenPurgeHours",
"loginImageUrl", "loginImageUrl",
"enableInfoPageLogin", "enableInfoPageLogin",
"serviceInfoPassword",
] satisfies (keyof Config)[]; ] satisfies (keyof Config)[];
type OmitKeys = (typeof OMITTED_KEYS)[number]; type OmitKeys = (typeof OMITTED_KEYS)[number];
+29 -14
View File
@@ -110,7 +110,7 @@ function renderLoginPage(csrf: string, error?: string) {
padding:30px;width:100%;max-width:400px;text-align:center;} padding:30px;width:100%;max-width:400px;text-align:center;}
.logo-image{max-width:200px;margin-bottom:20px;} .logo-image{max-width:200px;margin-bottom:20px;}
.form-group{margin-bottom:20px;} .form-group{margin-bottom:20px;}
input[type=text]{width:100%;padding:10px;border:1px solid #ddd;border-radius:4px; input[type=text], input[type=password]{width:100%;padding:10px;border:1px solid #ddd;border-radius:4px;
box-sizing:border-box;font-size:16px;} box-sizing:border-box;font-size:16px;}
button{background:#4caf50;color:#fff;border:none;padding:12px 20px;border-radius:4px; button{background:#4caf50;color:#fff;border:none;padding:12px 20px;border-radius:4px;
cursor:pointer;font-size:16px;width:100%;} cursor:pointer;font-size:16px;width:100%;}
@@ -120,8 +120,8 @@ function renderLoginPage(csrf: string, error?: string) {
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { background: #2c2c2c; color: #e0e0e0; } body { background: #2c2c2c; color: #e0e0e0; }
.login-container { background: #383838; box-shadow: 0 4px 12px rgba(0,0,0,0.4); border: 1px solid #4a4a4a; } .login-container { background: #383838; box-shadow: 0 4px 12px rgba(0,0,0,0.4); border: 1px solid #4a4a4a; }
input[type=text] { background: #4a4a4a; color: #e0e0e0; border: 1px solid #5a5a5a; } input[type=text], input[type=password] { background: #4a4a4a; color: #e0e0e0; border: 1px solid #5a5a5a; }
input[type=text]::placeholder { color: #999; } input[type=text]::placeholder, input[type=password]::placeholder { color: #999; }
button { background: #007bff; } /* Using a blue for dark mode button */ button { background: #007bff; } /* Using a blue for dark mode button */
button:hover { background: #0056b3; } button:hover { background: #0056b3; }
.error-message { color: #ff8a80; } /* Lighter red for errors in dark mode */ .error-message { color: #ff8a80; } /* Lighter red for errors in dark mode */
@@ -134,7 +134,9 @@ function renderLoginPage(csrf: string, error?: string) {
${errBlock} ${errBlock}
<form method="POST" action="${LOGIN_ROUTE}"> <form method="POST" action="${LOGIN_ROUTE}">
<div class="form-group"> <div class="form-group">
<input type="text" id="token" name="token" required placeholder="Your token"> ${config.serviceInfoAuthMode === "password"
? `<input type="password" id="password" name="password" required placeholder="Service Password">`
: `<input type="text" id="token" name="token" required placeholder="Your token">`}
<input type="hidden" name="_csrf" value="${csrf}"> <input type="hidden" name="_csrf" value="${csrf}">
</div> </div>
<button type="submit">Access Dashboard</button> <button type="submit">Access Dashboard</button>
@@ -343,17 +345,30 @@ infoPageRouter.use(
/* login attempt */ /* login attempt */
infoPageRouter.post(LOGIN_ROUTE, (req, res) => { infoPageRouter.post(LOGIN_ROUTE, (req, res) => {
const token = (req.body.token || "").trim(); if (config.serviceInfoAuthMode === "password") {
const password = (req.body.password || "").trim();
const user = getUser(token); // returns undefined if invalid // Simple string comparison; for production, consider a timing-safe comparison library
if (!user) { if (config.serviceInfoPassword && password === config.serviceInfoPassword) {
return res req.session!.infoPageAuthed = true;
.status(401) return res.redirect("/");
.send(renderLoginPage(res.locals.csrfToken, "Invalid token. Please try again.")); } else {
return res
.status(401)
.send(renderLoginPage(res.locals.csrfToken, "Invalid password. Please try again."));
}
} else {
// Token-based authentication (using any valid user token)
const token = (req.body.token || "").trim();
const user = getUser(token); // returns undefined if invalid
if (user) {
req.session!.infoPageAuthed = true;
return res.redirect("/");
} else {
return res
.status(401)
.send(renderLoginPage(res.locals.csrfToken, "Invalid token. Please try again."));
}
} }
req.session!.infoPageAuthed = true;
res.redirect("/");
}); });
/* GET / either login form or info page */ /* GET / either login form or info page */