diff --git a/backend/src/auth.ts b/backend/src/auth.ts index e0252fa..c3701a2 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -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; diff --git a/backend/src/config.ts b/backend/src/config.ts index 733adf6..1b0bedf 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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 diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 5e9a3a7..83da311 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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 }); }), ); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index cc42562..9eccc69 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -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( diff --git a/backend/src/services/settingsService.ts b/backend/src/services/settingsService.ts index f3edce6..7953999 100644 --- a/backend/src/services/settingsService.ts +++ b/backend/src/services/settingsService.ts @@ -130,6 +130,33 @@ export async function setSmtpSettings(patch: Partial): Promise { + 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 { + 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. diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 7bcfe70..fb6c862 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -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 => { + const res = await fetch(`${apiBase()}/settings/auth`, { headers: authHeaders() }); + assertOk(res, "Failed to load session settings"); + return res.json(); + }, + + updateAuth: async (tokenTtlSeconds: number): Promise => { + 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 => { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e64fd29..4bd6a55 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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" }, diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 870bea9..275562b 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -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" }, diff --git a/frontend/src/pages/AdminSettingsPage/SessionSection.tsx b/frontend/src/pages/AdminSettingsPage/SessionSection.tsx new file mode 100644 index 0000000..d025fdc --- /dev/null +++ b/frontend/src/pages/AdminSettingsPage/SessionSection.tsx @@ -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 = { + 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(null); + const [error, setError] = React.useState(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 ( + + + + + + {t("adminSettings.session.infoTitle")} + {t("adminSettings.session.infoBody")} + + + 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 }} + /> + + + + {saving && } + {status && {status}} + + + {error && setError(null)}>{error}} + + + + ); +} diff --git a/frontend/src/pages/AdminSettingsPage/index.tsx b/frontend/src/pages/AdminSettingsPage/index.tsx index d3c4be7..a7801f9 100644 --- a/frontend/src/pages/AdminSettingsPage/index.tsx +++ b/frontend/src/pages/AdminSettingsPage/index.tsx @@ -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 + ); }