feat: make ttl configurable in admin

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-19 18:11:06 +02:00
1 parent 4f408031b4
commit 6c66e7b615
10 files changed
+315 -14

No files matched your search

+14 -3
View File
@@ -1,17 +1,28 @@
import type { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
import type { Role } from "@prisma/client";
import { AUTH_ALGO, AUTH_SECRET, AUTH_TOKEN_TTL } from "./config";
import { AUTH_ALGO, AUTH_SECRET } from "./config";
import { getAuthTokenTtl } from "./services/settingsService";
export type TokenPayload = { sub: string; role: Role };
export function createToken(userId: string, role: Role): string {
function createToken(userId: string, role: Role, ttlSeconds: number): string {
return jwt.sign({ sub: userId, role }, AUTH_SECRET, {
algorithm: AUTH_ALGO,
expiresIn: AUTH_TOKEN_TTL,
expiresIn: ttlSeconds,
});
}
/** Signs a fresh JWT for `userId`, using the instance's current session length (admin-configurable
* -- see settingsService.ts's getAuthTokenTtl / AdminSettingsPage's Session section) -- and
* returns that same length alongside it so the caller can echo it back as the login response's
* `expires_in`. Every route that issues a token (register/login/verify-email/refresh) goes
* through this instead of calling createToken directly. */
export async function issueToken(userId: string, role: Role): Promise<{ token: string; expiresIn: number }> {
const expiresIn = await getAuthTokenTtl();
return { token: createToken(userId, role, expiresIn), expiresIn };
}
export function verifyToken(token: string): TokenPayload | null {
try {
return jwt.verify(token, AUTH_SECRET, { algorithms: [AUTH_ALGO] }) as TokenPayload;
+4 -1
View File
@@ -37,8 +37,11 @@ export const MODEL_PREVIEWS = path.join(STORAGE, "model-previews");
export const PUBLIC_URL = (process.env.PUBLIC_URL || "").trim().replace(/\/+$/, "");
export const AUTH_SECRET = process.env.AUTH_SECRET || "changeme-secret";
export const AUTH_TOKEN_TTL = envInt("AUTH_TOKEN_TTL", 43200);
export const AUTH_ALGO = "HS256" as const;
// The AUTH_TOKEN_TTL env var itself is read in services/settingsService.ts (getAuthTokenTtl),
// not here -- it's only ever consulted as the seed value for a fresh instance's first read, same
// as SMTP_HOST/SMTP_PORT/etc. are read directly in that file rather than exported from this one.
// Once an admin saves a session length via the Admin Settings page, the DB row wins from then on.
export const IMPORT_ALLOWED_EXTS = new Set([".stl", ".3mf", ".step", ".stp", ".obj", ".lbrn", ".lbrn2", ".zip"]);
// Extensions the 3D viewer can actually render as a Plate -- mirrors frontend's MODEL_EXTS
+9 -10
View File
@@ -2,8 +2,7 @@ import crypto from "node:crypto";
import { Router } from "express";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { AUTH_TOKEN_TTL } from "../config";
import { createToken, requireAuth } from "../auth";
import { issueToken, requireAuth } from "../auth";
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { parseBody } from "../utils/validate";
@@ -94,8 +93,8 @@ router.post(
return;
}
const token = createToken(user.id, user.role);
res.json({ token, expires_in: AUTH_TOKEN_TTL, user: toUserOut(user) });
const { token, expiresIn } = await issueToken(user.id, user.role);
res.json({ token, expires_in: expiresIn, user: toUserOut(user) });
}),
);
@@ -115,8 +114,8 @@ router.post(
throw new HttpError(403, "Please verify your email before signing in.", "EMAIL_NOT_VERIFIED");
}
const token = createToken(user.id, user.role);
res.json({ token, expires_in: AUTH_TOKEN_TTL, user: toUserOut(user) });
const { token, expiresIn } = await issueToken(user.id, user.role);
res.json({ token, expires_in: expiresIn, user: toUserOut(user) });
void createLog({ userId: user.id, action: "user_logged_in", details: { email: user.email } });
}),
);
@@ -160,8 +159,8 @@ router.post(
// Verifying doubles as signing in -- the user just proved control of the mailbox, and
// making them turn around and log in again with a password they only just typed is friction
// with no security benefit.
const token = createToken(verified.id, verified.role);
res.json({ token, expires_in: AUTH_TOKEN_TTL, user: toUserOut(verified) });
const { token, expiresIn } = await issueToken(verified.id, verified.role);
res.json({ token, expires_in: expiresIn, user: toUserOut(verified) });
void createLog({ userId: verified.id, action: "user_logged_in", details: { email: verified.email } });
}),
);
@@ -282,8 +281,8 @@ router.post(
asyncHandler(async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.userId! } });
if (!user) throw new HttpError(401, "Invalid or expired token");
const token = createToken(user.id, user.role);
res.json({ token, expires_in: AUTH_TOKEN_TTL });
const { token, expiresIn } = await issueToken(user.id, user.role);
res.json({ token, expires_in: expiresIn });
}),
);
+30
View File
@@ -6,10 +6,12 @@ import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import {
getAllowRegistrations,
getAuthTokenTtl,
getPreviewMode,
getSmtpSettings,
getThingiverseAccessToken,
setAllowRegistrations,
setAuthTokenTtl,
setPreviewMode,
setSmtpSettings,
setThingiverseAccessToken,
@@ -105,6 +107,34 @@ router.post(
}),
);
// Admin-only both ways, like Storage above -- this affects every session on the instance
// (including whoever's editing it), not just the caller's own login.
router.get(
"/settings/auth",
requireAdmin,
asyncHandler(async (_req, res) => {
res.json({ token_ttl_seconds: await getAuthTokenTtl() });
}),
);
// 5 minutes to 1 year -- wide enough to cover any real deployment, narrow enough that a typo
// (e.g. an extra zero) still lands somewhere sane instead of "never expires" or "expires
// immediately". Only affects tokens issued *after* saving -- see auth.ts's issueToken.
const MIN_AUTH_TOKEN_TTL_SECONDS = 5 * 60;
const MAX_AUTH_TOKEN_TTL_SECONDS = 365 * 24 * 60 * 60;
const authSettingsSchema = z.object({
token_ttl_seconds: z.number().int().min(MIN_AUTH_TOKEN_TTL_SECONDS).max(MAX_AUTH_TOKEN_TTL_SECONDS),
});
router.patch(
"/settings/auth",
requireAdmin,
asyncHandler(async (req, res) => {
const body = parseBody(authSettingsSchema, req.body);
await setAuthTokenTtl(body.token_ttl_seconds);
res.json({ token_ttl_seconds: body.token_ttl_seconds });
}),
);
// Read is open to every user -- LibraryPage needs the current mode to know whether to generate
// previews at all. Only admins can change it (see getPreviewMode's instance-wide rationale).
router.get(
+27
View File
@@ -130,6 +130,33 @@ export async function setSmtpSettings(patch: Partial<SmtpSettings>): Promise<Smt
return next;
}
const AUTH_TOKEN_TTL_KEY = "auth_token_ttl_seconds";
// Matches the old AUTH_TOKEN_TTL env default (12 hours) -- used only when neither a DB row nor
// the env var itself is set.
const DEFAULT_AUTH_TOKEN_TTL_SECONDS = 43200;
// How long a signed-in session's token stays valid before requiring another login (see
// auth.ts's issueToken, which every login/register/verify route goes through). Seeds from the
// AUTH_TOKEN_TTL env var the first time this is read on a fresh instance -- once an admin saves
// a value via PATCH /settings/auth (AdminSettingsPage's Session section), the DB row becomes the
// sole source of truth, same pattern as SMTP/preview mode/Thingiverse token above. Changing this
// only affects tokens issued *after* the change; anyone already signed in keeps whatever TTL was
// active when their token was issued, since that's baked into the JWT itself.
export async function getAuthTokenTtl(): Promise<number> {
const row = await prisma.setting.findUnique({ where: { key: AUTH_TOKEN_TTL_KEY } });
const value = row?.value;
if (typeof value === "number" && Number.isFinite(value) && value > 0) return Math.trunc(value);
return envInt("AUTH_TOKEN_TTL", DEFAULT_AUTH_TOKEN_TTL_SECONDS);
}
export async function setAuthTokenTtl(seconds: number): Promise<void> {
await prisma.setting.upsert({
where: { key: AUTH_TOKEN_TTL_KEY },
create: { key: AUTH_TOKEN_TTL_KEY, value: seconds },
update: { value: seconds },
});
}
// Database connection info + the live "Test & Save" switch live in databaseSettingsService.ts,
// not here -- unlike everything else in this file, it isn't just a Setting-table row; it needs
// db.ts's client-swap primitive and its own Postgres-specific connection testing.
+26
View File
@@ -13,6 +13,10 @@ export type StorageSettings = {
export type PreviewMode = "automatic" | "on-demand" | "disabled";
export type AuthSettings = {
token_ttl_seconds: number;
};
export type SmtpSettings = {
host: string | null;
port: number;
@@ -132,6 +136,28 @@ export const settingsApi = {
return res.json();
},
// Instance-wide: how long a signed-in session's token stays valid before requiring another
// login (see backend's auth.ts issueToken). Admin-only both ways, like Storage above -- this
// affects every session on the instance, not just the caller's own.
getAuth: async (): Promise<AuthSettings> => {
const res = await fetch(`${apiBase()}/settings/auth`, { headers: authHeaders() });
assertOk(res, "Failed to load session settings");
return res.json();
},
updateAuth: async (tokenTtlSeconds: number): Promise<AuthSettings> => {
const res = await fetch(`${apiBase()}/settings/auth`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ token_ttl_seconds: tokenTtlSeconds }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) {
throw new Error(await readErrorMessage(res, "Failed to update session settings"));
}
return res.json();
},
// SMTP is used to send the account-verification email on registration (see backend's
// routes/auth.ts) -- write-only for the password like the Thingiverse token above.
getSmtp: async (): Promise<SmtpSettings> => {
+26
View File
@@ -317,6 +317,32 @@
"cleared": "Access Token removed.",
"failed": "Failed to save Thingiverse settings."
},
"session": {
"heading": "Session Length",
"subtitle": "Control how long a signed-in session stays valid for the whole instance.",
"infoTitle": "What this controls",
"infoBody": "How long a freshly issued sign-in token stays valid before that user is asked to log in again. Applies instance-wide to every new login, registration, and email verification. Changing this only affects tokens issued from now on -- anyone already signed in keeps whatever length was active when they logged in.",
"tokenTtlLabel": "Session length (seconds)",
"tokenTtlHelp": "Between 5 minutes and 1 year.",
"invalid": "Enter a number between 300 (5 minutes) and 31536000 (1 year) seconds.",
"approx": "≈ {{value}}",
"units": {
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"hours_one": "{{count}} hour",
"hours_other": "{{count}} hours",
"days_one": "{{count}} day",
"days_other": "{{count}} days",
"weeks_one": "{{count}} week",
"weeks_other": "{{count}} weeks",
"months_one": "{{count}} month",
"months_other": "{{count}} months"
},
"save": "Save",
"saving": "Saving…",
"saved": "Saved.",
"failed": "Failed to save session settings."
},
"connections": {
"heading": "Connections"
},
+26
View File
@@ -317,6 +317,32 @@
"cleared": "Prieigos raktas pašalintas.",
"failed": "Nepavyko išsaugoti Thingiverse nustatymų."
},
"session": {
"heading": "Sesijos trukmė",
"subtitle": "Valdykite, kiek laiko galioja prisijungimo sesija visai sistemai.",
"infoTitle": "Ką tai valdo",
"infoBody": "Kiek laiko galioja naujai išduotas prisijungimo žetonas, kol naudotojo bus paprašyta prisijungti iš naujo. Taikoma visai sistemai -- kiekvienam naujam prisijungimui, registracijai ir el. pašto patvirtinimui. Pakeitimas paveikia tik nuo šiol išduodamus žetonus -- jau prisijungę naudotojai išlaiko tą trukmę, kuri galiojo jiems prisijungiant.",
"tokenTtlLabel": "Sesijos trukmė (sekundėmis)",
"tokenTtlHelp": "Nuo 5 minučių iki 1 metų.",
"invalid": "Įveskite skaičių nuo 300 (5 minutės) iki 31536000 (1 metai) sekundžių.",
"approx": "≈ {{value}}",
"units": {
"minutes_one": "{{count}} minutė",
"minutes_other": "{{count}} minučių",
"hours_one": "{{count}} valanda",
"hours_other": "{{count}} valandų",
"days_one": "{{count}} diena",
"days_other": "{{count}} dienų",
"weeks_one": "{{count}} savaitė",
"weeks_other": "{{count}} savaičių",
"months_one": "{{count}} mėnuo",
"months_other": "{{count}} mėnesių"
},
"save": "Išsaugoti",
"saving": "Saugoma…",
"saved": "Išsaugota.",
"failed": "Nepavyko išsaugoti sesijos nustatymų."
},
"connections": {
"heading": "Ryšiai"
},
@@ -0,0 +1,151 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
import Alert from "@mui/material/Alert";
import AlertTitle from "@mui/material/AlertTitle";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../api/client";
import { settingsApi } from "../../api/settings";
import SectionHeader from "../../components/SectionHeader";
type Props = {
onUnauthorized?: () => void;
};
// Mirrors the backend's own bounds (routes/settings.ts's MIN/MAX_AUTH_TOKEN_TTL_SECONDS) so a
// bad value is rejected client-side before it ever reaches the server.
const MIN_SECONDS = 5 * 60;
const MAX_SECONDS = 365 * 24 * 60 * 60;
type DurationUnit = "minutes" | "hours" | "days" | "weeks" | "months";
const UNIT_SECONDS: Record<DurationUnit, number> = {
minutes: 60,
hours: 3600,
days: 86400,
weeks: 604800,
months: 2629800, // 30.44 days, i.e. a year / 12 -- close enough for a rough conversion hint
};
/** Picks the largest unit `totalSeconds` amounts to at least one of, e.g. 90000 -> "1 day", not
* "1500 minutes" -- so a large TTL always reads as a plain "X hours/days/weeks/months" instead
* of forcing the admin to do that arithmetic themselves. Returns null under a minute, where the
* raw seconds the admin already typed are conversion enough. */
function bestFitDuration(totalSeconds: number): { unit: DurationUnit; value: number } | null {
const units: DurationUnit[] = ["months", "weeks", "days", "hours", "minutes"];
for (const unit of units) {
const size = UNIT_SECONDS[unit];
if (totalSeconds >= size) {
return { unit, value: Math.round((totalSeconds / size) * 10) / 10 };
}
}
return null;
}
/** Instance-wide: how long a freshly issued sign-in token stays valid before that user has to
* log in again (see backend's auth.ts issueToken and getAuthTokenTtl) -- admin-configured like
* Storage Structure and Model Previews above it. Seeded from the AUTH_TOKEN_TTL env var the
* first time this is ever read on a fresh instance; once saved here, the DB value takes over
* for good. Only affects tokens issued after saving -- anyone already signed in keeps whatever
* length was active at their own login. */
export default function SessionSection({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [seconds, setSeconds] = React.useState(43200);
const [draft, setDraft] = React.useState("43200");
const [loading, setLoading] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [status, setStatus] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let active = true;
setLoading(true);
void (async () => {
try {
const data = await settingsApi.getAuth();
if (active) {
setSeconds(data.token_ttl_seconds);
setDraft(String(data.token_ttl_seconds));
}
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
} finally {
if (active) setLoading(false);
}
})();
return () => { active = false; };
}, [onUnauthorized]);
const parsed = Number.parseInt(draft, 10);
const isValid = Number.isFinite(parsed) && parsed >= MIN_SECONDS && parsed <= MAX_SECONDS;
const preview = isValid ? bestFitDuration(parsed) : null;
const save = async () => {
if (!isValid || saving) return;
setSaving(true);
setStatus(null);
setError(null);
try {
const data = await settingsApi.updateAuth(parsed);
setSeconds(data.token_ttl_seconds);
setDraft(String(data.token_ttl_seconds));
setStatus(t("adminSettings.session.saved"));
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
} else {
setError(err instanceof Error ? err.message : t("adminSettings.session.failed"));
}
} finally {
setSaving(false);
}
};
return (
<Stack spacing={3}>
<SectionHeader
title={t("adminSettings.session.heading")}
subtitle={t("adminSettings.session.subtitle")}
/>
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={2}>
<Alert severity="info">
<AlertTitle>{t("adminSettings.session.infoTitle")}</AlertTitle>
{t("adminSettings.session.infoBody")}
</Alert>
<TextField
type="number"
label={t("adminSettings.session.tokenTtlLabel")}
value={draft}
onChange={e => setDraft(e.target.value)}
error={draft !== "" && !isValid}
helperText={
draft !== "" && !isValid
? t("adminSettings.session.invalid")
: preview
? t("adminSettings.session.approx", { value: t(`adminSettings.session.units.${preview.unit}`, { count: preview.value }) })
: t("adminSettings.session.tokenTtlHelp")
}
disabled={loading || saving}
slotProps={{ htmlInput: { min: MIN_SECONDS, max: MAX_SECONDS } }}
sx={{ maxWidth: 320 }}
/>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
<Button variant="contained" onClick={save} disabled={loading || saving || !isValid || parsed === seconds}>
{saving ? t("adminSettings.session.saving") : t("adminSettings.session.save")}
</Button>
{saving && <CircularProgress size={14} />}
{status && <Typography variant="caption" color="text.secondary">{status}</Typography>}
</Stack>
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
</Stack>
</Paper>
</Stack>
);
}
@@ -4,6 +4,7 @@ import type { PreviewMode } from "../../api/settings";
import StorageSection from "./StorageSection";
import PreviewsSection from "./PreviewsSection";
import ThingiverseSection from "./ThingiverseSection";
import SessionSection from "./SessionSection";
type Props = {
onUnauthorized?: () => void;
@@ -21,6 +22,7 @@ export default function AdminSettingsPage({ onUnauthorized, onPreviewModeChanged
<ThingiverseSection onUnauthorized={onUnauthorized} />
<StorageSection onUnauthorized={onUnauthorized} />
<PreviewsSection onUnauthorized={onUnauthorized} onSaved={onPreviewModeChanged} />
<SessionSection onUnauthorized={onUnauthorized} />
</Stack>
);
}