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:
parent
f539514fad
commit
9a21c72f08
4 changed files with 54 additions and 12 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue