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);
}