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.
- 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=<Hex-String aus B6 hier einfügen>
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=<zweiter Hash aus B6 hier einfügen>
```
### 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

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'))"
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

View file

@ -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,

View file

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