password based service info auth (better than the first one we had)
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user