From 9a21c72f08ca81d04aa066585566e9c9c171fc07 Mon Sep 17 00:00:00 2001 From: Pascal Scholz Date: Mon, 21 Sep 2026 22:24:52 +0200 Subject: [PATCH] Support multiple admin logins ADMIN_USERNAME/ADMIN_PASSWORD_HASH now optionally extends to _2, _3, ... for additional accounts (e.g. a second login for Lara), all with equal access - no roles, still no self-service password reset. Falls back to a dummy bcrypt compare on unknown usernames to keep login timing consistent. Verified locally: both users log in, wrong password and unknown usernames are rejected, and single-user setups still work unchanged (backward compatible). Also documents the optional second-user setup in DEPLOYMENT_ADMIN.md (B6/B7). --- DEPLOYMENT_ADMIN.md | 25 ++++++++++++++++++++----- admin/.env.example | 5 +++++ admin/lib/auth.js | 32 +++++++++++++++++++++++++++----- admin/server.js | 4 ++-- 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/DEPLOYMENT_ADMIN.md b/DEPLOYMENT_ADMIN.md index dd75f9b..c0567cf 100644 --- a/DEPLOYMENT_ADMIN.md +++ b/DEPLOYMENT_ADMIN.md @@ -35,8 +35,8 @@ Zeitpunkt Zugriff auf Benutzername/Passwort/Secrets oder den Server selbst. nach jedem „Veröffentlichen" neu befüllt. Kein Neustart von `website` nötig. - Bearbeitete Inhalte (`content/site.json`, `content/theme.json`) und hochgeladene Bilder (`public/uploads/`) liegen auf eigenen Volumes, damit sie ein Image-Update überleben. -- Login: fester Benutzername + Passwort (bcrypt-Hash), Session-Cookie (12h, httpOnly, - Secure, SameSite=Strict), Rate-Limit (5 Fehlversuche → 15 Min. Sperre pro IP). +- Login: ein oder mehrere feste Benutzername/Passwort-Paare (bcrypt-Hash), Session-Cookie + (12h, httpOnly, Secure, SameSite=Strict), Rate-Limit (5 Fehlversuche → 15 Min. Sperre pro IP). --- @@ -154,6 +154,13 @@ docker compose run --rm admin node -e "console.log(require('crypto').randomBytes ``` Auch kopieren. +**Optional – zweiten Benutzer anlegen** (z. B. für Lara): denselben Hash-Befehl nochmal +mit ihrem Passwort ausführen und den Hash ebenfalls kopieren. Ein Session-Secret reicht +für alle Benutzer, das muss nicht nochmal erzeugt werden. +```bash +docker compose run --rm admin node admin/hash-password.js "ihr-neues-passwort" +``` + ### B7. `~/stack/admin.env` mit echten Werten befüllen ```bash cat > ~/stack/admin.env <<'EOF' @@ -164,6 +171,12 @@ SESSION_SECRET= EOF chmod 600 ~/stack/admin.env ``` +**Nur falls du einen zweiten Benutzer angelegt hast:** vor dem `EOF` noch zwei weitere +Zeilen einfügen (Muster lässt sich mit `_3`, `_4`, ... beliebig fortsetzen): +``` +ADMIN_USERNAME_2=lara +ADMIN_PASSWORD_HASH_2= +``` ### B8. DNS bei IONOS - Neuer A-Eintrag: Hostname `admin` → Server-IPv4 (gleiche IP wie `@`). @@ -207,9 +220,11 @@ cd ~/stack && docker compose build admin && docker compose up -d admin ## Bekannte Grenzen -- Nur ein Login (kein Mehrbenutzer-System, keine Passwort-Reset-Funktion in der - Oberfläche – neues Passwort: `docker compose run --rm admin node admin/hash-password.js "..."` - ausführen (wie B6), neuen Hash in `admin.env` eintragen, dann `docker compose up -d admin`). +- Mehrere feste Logins möglich (`ADMIN_USERNAME`/`ADMIN_PASSWORD_HASH`, `_2`, `_3`, ...), + aber kein echtes Mehrbenutzer-System – alle Logins haben dieselben Rechte, es gibt keine + Rollen und keine Passwort-Reset-Funktion in der Oberfläche. Neues/geändertes Passwort: + `docker compose run --rm admin node admin/hash-password.js "..."` ausführen (wie B6), + Hash in `admin.env` eintragen, dann `docker compose up -d admin`. - Sessions sind In-Memory: Ein Neustart des `admin`-Containers meldet alle ab. - Es gibt keine Versionshistorie der Inhalte oder des Designs – beides liegt aber auf dem Server und kann bei Bedarf manuell gesichert werden (z. B. regelmäßiges Backup der diff --git a/admin/.env.example b/admin/.env.example index d60662c..cd25daa 100644 --- a/admin/.env.example +++ b/admin/.env.example @@ -11,6 +11,11 @@ ADMIN_PASSWORD_HASH= # Zufälligen Wert erzeugen z. B. mit: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" SESSION_SECRET= +# Weitere Benutzer sind optional: _2, _3, ... anhängen (gleiches Muster, gleicher +# hash-password.js-Befehl). Ohne diese Zeilen bleibt es bei einem Login. +#ADMIN_USERNAME_2=lara +#ADMIN_PASSWORD_HASH_2= + # Pfade (im Container i.d.R. nicht ändern, siehe DEPLOYMENT_ADMIN.md) PROJECT_DIR=/app CONTENT_PATH=/app/content/site.json diff --git a/admin/lib/auth.js b/admin/lib/auth.js index b79e5a2..1796e8c 100644 --- a/admin/lib/auth.js +++ b/admin/lib/auth.js @@ -83,14 +83,35 @@ function recordSuccessfulLogin(req) { loginAttempts.delete(requestIp(req)); } +// Used only to keep bcrypt.compareSync's timing consistent when no username matches - +// its own hash never accepts any real password. +const DUMMY_HASH = '$2a$12$7KBJVb9hH7yljA9gzWTf9ux2WGVmZTPXJkB0ClK6SQETG/wDxwRSW'; + +/** Loads configured admin users from env vars: ADMIN_USERNAME/ADMIN_PASSWORD_HASH for the + * first, then ADMIN_USERNAME_2/ADMIN_PASSWORD_HASH_2, _3, ... for additional accounts. */ +function loadUsers() { + const users = []; + if (process.env.ADMIN_USERNAME && process.env.ADMIN_PASSWORD_HASH) { + users.push({ username: process.env.ADMIN_USERNAME, hash: process.env.ADMIN_PASSWORD_HASH }); + } + let i = 2; + while (process.env[`ADMIN_USERNAME_${i}`] && process.env[`ADMIN_PASSWORD_HASH_${i}`]) { + users.push({ username: process.env[`ADMIN_USERNAME_${i}`], hash: process.env[`ADMIN_PASSWORD_HASH_${i}`] }); + i += 1; + } + return users; +} + +function hasConfiguredUsers() { + return loadUsers().length > 0; +} + function verifyPassword(username, password) { - const expectedUser = process.env.ADMIN_USERNAME || ''; - const expectedHash = process.env.ADMIN_PASSWORD_HASH || ''; - if (!expectedUser || !expectedHash) return false; + const match = loadUsers().find((u) => u.username === username); // Always run bcrypt so response timing doesn't reveal whether the username was correct. - const passwordOk = bcrypt.compareSync(password || '', expectedHash); - return username === expectedUser && passwordOk; + const passwordOk = bcrypt.compareSync(password || '', match ? match.hash : DUMMY_HASH); + return Boolean(match) && passwordOk; } function getCookies(req) { @@ -139,6 +160,7 @@ module.exports = { isLockedOut, recordFailedLogin, recordSuccessfulLogin, + hasConfiguredUsers, verifyPassword, getCookies, requireAuth, diff --git a/admin/server.js b/admin/server.js index c37bee1..64ceacf 100644 --- a/admin/server.js +++ b/admin/server.js @@ -22,9 +22,9 @@ const ALLOWED_IMAGE_TYPES = { }; const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; -if (!process.env.ADMIN_USERNAME || !process.env.ADMIN_PASSWORD_HASH || !process.env.SESSION_SECRET) { +if (!auth.hasConfiguredUsers() || !process.env.SESSION_SECRET) { console.error( - 'ADMIN_USERNAME, ADMIN_PASSWORD_HASH und SESSION_SECRET müssen gesetzt sein (siehe admin/.env.example).' + 'Mindestens ein Benutzer (ADMIN_USERNAME/ADMIN_PASSWORD_HASH) und SESSION_SECRET müssen gesetzt sein (siehe admin/.env.example).' ); process.exit(1); }