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).
This commit is contained in:
Pascal Scholz 2026-09-21 22:24:52 +02:00
parent f539514fad
commit 9a21c72f08
4 changed files with 54 additions and 12 deletions

View file

@ -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. nach jedem „Veröffentlichen" neu befüllt. Kein Neustart von `website` nötig.
- Bearbeitete Inhalte (`content/site.json`, `content/theme.json`) und hochgeladene Bilder - Bearbeitete Inhalte (`content/site.json`, `content/theme.json`) und hochgeladene Bilder
(`public/uploads/`) liegen auf eigenen Volumes, damit sie ein Image-Update überleben. (`public/uploads/`) liegen auf eigenen Volumes, damit sie ein Image-Update überleben.
- Login: fester Benutzername + Passwort (bcrypt-Hash), Session-Cookie (12h, httpOnly, - Login: ein oder mehrere feste Benutzername/Passwort-Paare (bcrypt-Hash), Session-Cookie
Secure, SameSite=Strict), Rate-Limit (5 Fehlversuche → 15 Min. Sperre pro IP). (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. 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 ### B7. `~/stack/admin.env` mit echten Werten befüllen
```bash ```bash
cat > ~/stack/admin.env <<'EOF' cat > ~/stack/admin.env <<'EOF'
@ -164,6 +171,12 @@ SESSION_SECRET=<Hex-String aus B6 hier einfügen>
EOF EOF
chmod 600 ~/stack/admin.env 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=<zweiter Hash aus B6 hier einfügen>
```
### B8. DNS bei IONOS ### B8. DNS bei IONOS
- Neuer A-Eintrag: Hostname `admin` → Server-IPv4 (gleiche IP wie `@`). - 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 ## Bekannte Grenzen
- Nur ein Login (kein Mehrbenutzer-System, keine Passwort-Reset-Funktion in der - Mehrere feste Logins möglich (`ADMIN_USERNAME`/`ADMIN_PASSWORD_HASH`, `_2`, `_3`, ...),
Oberfläche – neues Passwort: `docker compose run --rm admin node admin/hash-password.js "..."` aber kein echtes Mehrbenutzer-System – alle Logins haben dieselben Rechte, es gibt keine
ausführen (wie B6), neuen Hash in `admin.env` eintragen, dann `docker compose up -d admin`). 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. - 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 - 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 Server und kann bei Bedarf manuell gesichert werden (z. B. regelmäßiges Backup der

View file

@ -11,6 +11,11 @@ ADMIN_PASSWORD_HASH=
# Zufälligen Wert erzeugen z. B. mit: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # Zufälligen Wert erzeugen z. B. mit: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET= 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) # Pfade (im Container i.d.R. nicht ändern, siehe DEPLOYMENT_ADMIN.md)
PROJECT_DIR=/app PROJECT_DIR=/app
CONTENT_PATH=/app/content/site.json CONTENT_PATH=/app/content/site.json

View file

@ -83,14 +83,35 @@ function recordSuccessfulLogin(req) {
loginAttempts.delete(requestIp(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) { function verifyPassword(username, password) {
const expectedUser = process.env.ADMIN_USERNAME || ''; const match = loadUsers().find((u) => u.username === username);
const expectedHash = process.env.ADMIN_PASSWORD_HASH || '';
if (!expectedUser || !expectedHash) return false;
// Always run bcrypt so response timing doesn't reveal whether the username was correct. // Always run bcrypt so response timing doesn't reveal whether the username was correct.
const passwordOk = bcrypt.compareSync(password || '', expectedHash); const passwordOk = bcrypt.compareSync(password || '', match ? match.hash : DUMMY_HASH);
return username === expectedUser && passwordOk; return Boolean(match) && passwordOk;
} }
function getCookies(req) { function getCookies(req) {
@ -139,6 +160,7 @@ module.exports = {
isLockedOut, isLockedOut,
recordFailedLogin, recordFailedLogin,
recordSuccessfulLogin, recordSuccessfulLogin,
hasConfiguredUsers,
verifyPassword, verifyPassword,
getCookies, getCookies,
requireAuth, requireAuth,

View file

@ -22,9 +22,9 @@ const ALLOWED_IMAGE_TYPES = {
}; };
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; 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( 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); process.exit(1);
} }