Bearbeitung hinter ein Passwort legen, Ansehen für das Team freigeben
Mit EDIT_PASSWORD sehen Besucher nur den Kalender: keine E-Mails, Notizen, Abwesenheiten oder Nextcloud-Details, keine schreibenden Aktionen. Anmelden in der Kopfzeile setzt ein signiertes Cookie für 30 Tage; nach fünf Fehlversuchen wartet der Server eine Minute. Ohne EDIT_PASSWORD bleibt alles offen wie bisher. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
9ed59bcf56
commit
33a8adef0e
8 files changed
+288
-13
No files matched your search
+4
-1
@@ -6,5 +6,8 @@ NEXTCLOUD_APP_PASSWORD= # optional App-Passwort aus Nextcloud → Einstellung
|
|||||||
NEXTCLOUD_CALENDAR=morning-meeting
|
NEXTCLOUD_CALENDAR=morning-meeting
|
||||||
# Nur setzen, wenn die Nextcloud ein selbstsigniertes Zertifikat hat
|
# Nur setzen, wenn die Nextcloud ein selbstsigniertes Zertifikat hat
|
||||||
NEXTCLOUD_INSECURE_TLS=false
|
NEXTCLOUD_INSECURE_TLS=false
|
||||||
# Optionaler Zugriffsschutz der App (HTTP Basic Auth). Leer = kein Passwort.
|
# Bearbeitungspasswort: Betrachter sehen nur den Kalender, Auslosen und Verwalten erfordern die Anmeldung.
|
||||||
|
# Leer = jeder darf alles bearbeiten.
|
||||||
|
EDIT_PASSWORD=""
|
||||||
|
# Optionaler Zugriffsschutz der ganzen App (HTTP Basic Auth, auch fürs Ansehen). Leer = kein Passwort.
|
||||||
APP_PASSWORD=""
|
APP_PASSWORD=""
|
||||||
@@ -63,11 +63,21 @@ Alle Variablen sind optional, siehe `.env.example`:
|
|||||||
| `NEXTCLOUD_APP_PASSWORD` | App-Passwort (Nextcloud → Einstellungen → Sicherheit → Geräte & Sitzungen) |
|
| `NEXTCLOUD_APP_PASSWORD` | App-Passwort (Nextcloud → Einstellungen → Sicherheit → Geräte & Sitzungen) |
|
||||||
| `NEXTCLOUD_CALENDAR` | ID des eigenen Kalenders, falls kein vorhandener Zielkalender gewählt wird (Vorgabe `morning-meeting`) |
|
| `NEXTCLOUD_CALENDAR` | ID des eigenen Kalenders, falls kein vorhandener Zielkalender gewählt wird (Vorgabe `morning-meeting`) |
|
||||||
| `NEXTCLOUD_INSECURE_TLS` | `true`, wenn die Nextcloud ein selbstsigniertes Zertifikat nutzt |
|
| `NEXTCLOUD_INSECURE_TLS` | `true`, wenn die Nextcloud ein selbstsigniertes Zertifikat nutzt |
|
||||||
| `APP_PASSWORD` | Wenn gesetzt, schützt HTTP Basic Auth die ganze App (Benutzername beliebig) |
|
| `EDIT_PASSWORD` | Bearbeitungspasswort. Wenn gesetzt, sehen alle nur den Kalender; Auslosen, Personen, Abwesenheiten, Einstellungen und Nextcloud erfordern die Anmeldung über „Anmelden" in der Kopfzeile |
|
||||||
|
| `APP_PASSWORD` | Wenn gesetzt, schützt HTTP Basic Auth die ganze App inklusive Ansehen (Benutzername beliebig) |
|
||||||
|
|
||||||
Ohne Nextcloud-Variablen läuft die App vollständig, nur der Button „Go – schreiben" ist
|
Ohne Nextcloud-Variablen läuft die App vollständig, nur der Button „Go – schreiben" ist
|
||||||
deaktiviert; der ICS-Download bleibt verfügbar.
|
deaktiviert; der ICS-Download bleibt verfügbar.
|
||||||
|
|
||||||
|
## Teilen im Team
|
||||||
|
|
||||||
|
Die Adresse der App kann an alle weitergegeben werden. Mit gesetztem `EDIT_PASSWORD` sehen
|
||||||
|
Besucher den Monatskalender mit Fotos und die Einsatzstatistik, aber keine E-Mail-Adressen,
|
||||||
|
Notizen oder Abwesenheiten, und sie können nichts ändern. Wer bearbeiten will, klickt oben rechts
|
||||||
|
auf „Anmelden". Die Anmeldung gilt 30 Tage im jeweiligen Browser; nach fünf falschen Versuchen
|
||||||
|
wartet der Server eine Minute. Ohne `EDIT_PASSWORD` darf jeder alles, wie bei einem Werkzeug
|
||||||
|
für eine einzelne Person.
|
||||||
|
|
||||||
## Nextcloud
|
## Nextcloud
|
||||||
|
|
||||||
1. Umgebungsvariablen setzen und App neu starten.
|
1. Umgebungsvariablen setzen und App neu starten.
|
||||||
|
|||||||
+62
-11
@@ -43,10 +43,51 @@ async function api(path, { method = 'GET', body, raw } = {}) {
|
|||||||
const res = await fetch(path, opts);
|
const res = await fetch(path, opts);
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
if (!res.ok) {
|
||||||
|
if (data.authRequired) { openLogin(); throw new Error('Zum Bearbeiten bitte anmelden.'); }
|
||||||
|
throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Anmeldung zum Bearbeiten ----------
|
||||||
|
const EDITOR_VIEWS = ['personen', 'abwesenheiten', 'einstellungen'];
|
||||||
|
function isEditor() { return !state.data || state.data.auth?.editor !== false; }
|
||||||
|
function applyAuthUI() {
|
||||||
|
const a = state.data?.auth || { protected: false, editor: true };
|
||||||
|
document.body.classList.toggle('viewer', !a.editor);
|
||||||
|
const btn = $('#auth-button');
|
||||||
|
btn.hidden = !a.protected;
|
||||||
|
btn.textContent = a.editor ? 'Abmelden' : 'Anmelden';
|
||||||
|
for (const link of $$('.topbar nav a')) link.hidden = !a.editor && EDITOR_VIEWS.includes(link.dataset.view);
|
||||||
|
$('#open-plan').hidden = !a.editor;
|
||||||
|
$('#open-nextcloud').hidden = !a.editor;
|
||||||
|
if (!a.editor && EDITOR_VIEWS.includes(location.hash.slice(1))) location.hash = '#kalender';
|
||||||
|
}
|
||||||
|
function openLogin() {
|
||||||
|
const dlg = $('#login-dialog');
|
||||||
|
if (dlg.open) return;
|
||||||
|
$('#login-result').hidden = true;
|
||||||
|
$('#login-password').value = '';
|
||||||
|
dlg.showModal();
|
||||||
|
}
|
||||||
|
$('#auth-button').addEventListener('click', async () => {
|
||||||
|
if (isEditor()) {
|
||||||
|
try { await api('api/auth/logout', { method: 'POST' }); await load(); toast('Abgemeldet. Du siehst jetzt die Ansicht für das Team.'); } catch (err) { toast(err.message, true); }
|
||||||
|
} else openLogin();
|
||||||
|
});
|
||||||
|
$('#login-close').addEventListener('click', () => $('#login-dialog').close());
|
||||||
|
$('#login-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const out = $('#login-result');
|
||||||
|
try {
|
||||||
|
await api('api/auth/login', { method: 'POST', body: { password: $('#login-password').value } });
|
||||||
|
$('#login-dialog').close();
|
||||||
|
await load();
|
||||||
|
toast('Angemeldet. Bearbeiten ist freigeschaltet.');
|
||||||
|
} catch (err) { out.textContent = err.message; out.hidden = false; }
|
||||||
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
state.data = await api('api/state');
|
state.data = await api('api/state');
|
||||||
renderAll();
|
renderAll();
|
||||||
@@ -100,6 +141,7 @@ function avatarEl(p, cls = '') {
|
|||||||
function showView(name) {
|
function showView(name) {
|
||||||
const known = ['kalender', 'personen', 'abwesenheiten', 'einstellungen'];
|
const known = ['kalender', 'personen', 'abwesenheiten', 'einstellungen'];
|
||||||
if (!known.includes(name)) name = 'kalender';
|
if (!known.includes(name)) name = 'kalender';
|
||||||
|
if (!isEditor() && EDITOR_VIEWS.includes(name)) name = 'kalender';
|
||||||
if (name === 'abwesenheiten' && state.data) prepareAbsenceImport();
|
if (name === 'abwesenheiten' && state.data) prepareAbsenceImport();
|
||||||
for (const v of $$('.view')) v.hidden = v.id !== `view-${name}`;
|
for (const v of $$('.view')) v.hidden = v.id !== `view-${name}`;
|
||||||
for (const a of $$('.topbar nav a')) {
|
for (const a of $$('.topbar nav a')) {
|
||||||
@@ -128,9 +170,10 @@ function renderCalendar() {
|
|||||||
if (cur.getDay() === 0 || cur.getDay() === 6) continue;
|
if (cur.getDay() === 0 || cur.getDay() === 6) continue;
|
||||||
const date = iso(cur);
|
const date = iso(cur);
|
||||||
const outside = cur.getMonth() !== d.getMonth();
|
const outside = cur.getMonth() !== d.getMonth();
|
||||||
const cell = document.createElement(outside ? 'div' : 'button');
|
const editable = !outside && isEditor();
|
||||||
|
const cell = document.createElement(editable ? 'button' : 'div');
|
||||||
cell.className = 'day';
|
cell.className = 'day';
|
||||||
if (!outside) { cell.type = 'button'; cell.dataset.date = date; }
|
if (editable) { cell.type = 'button'; cell.dataset.date = date; }
|
||||||
if (outside) cell.classList.add('outside');
|
if (outside) cell.classList.add('outside');
|
||||||
if (date === today) cell.classList.add('today');
|
if (date === today) cell.classList.add('today');
|
||||||
if (date < today) cell.classList.add('past');
|
if (date < today) cell.classList.add('past');
|
||||||
@@ -584,7 +627,7 @@ function imageFromClipboard(data) {
|
|||||||
}
|
}
|
||||||
// Strg+V überall in der Personenansicht: Bild aus der Zwischenablage landet im Formular
|
// Strg+V überall in der Personenansicht: Bild aus der Zwischenablage landet im Formular
|
||||||
document.addEventListener('paste', (e) => {
|
document.addEventListener('paste', (e) => {
|
||||||
if ($('#view-personen').hidden || !state.data) return;
|
if ($('#view-personen').hidden || !state.data || !isEditor()) return;
|
||||||
const file = imageFromClipboard(e.clipboardData);
|
const file = imageFromClipboard(e.clipboardData);
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -920,13 +963,16 @@ function renderSettings() {
|
|||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const protectedHint = state.data.auth?.protected
|
||||||
|
? '<p class="status-ok">Bearbeitung ist durch das Passwort aus <code>EDIT_PASSWORD</code> geschützt. Das Team sieht nur den Kalender.</p>'
|
||||||
|
: '<p class="status-warn">Kein Bearbeitungspasswort gesetzt: Jeder, der die Seite öffnet, kann auch bearbeiten. Zum Schützen <code>EDIT_PASSWORD</code> als Umgebungsvariable setzen.</p>';
|
||||||
const nc = state.data.nextcloud;
|
const nc = state.data.nextcloud;
|
||||||
const box = $('#nextcloud-status');
|
const box = $('#nextcloud-status');
|
||||||
box.innerHTML = nc.configured
|
box.innerHTML = protectedHint + (nc.configured
|
||||||
? `<p class="status-ok">Konfiguriert: <code>${escapeHtml(nc.baseUrl)}</code>, angemeldet als <code>${escapeHtml(nc.user)}</code>.</p>`
|
? `<p class="status-ok">Konfiguriert: <code>${escapeHtml(nc.baseUrl)}</code>, angemeldet als <code>${escapeHtml(nc.user)}</code>.</p>`
|
||||||
+ `<p>Zielkalender: <strong>${s.targetCalendarHref ? escapeHtml(s.targetCalendarName || s.targetCalendarHref) : `neuer Kalender „${escapeHtml(s.calendarDisplayName)}" (${escapeHtml(nc.calendar)})`}</strong> – wählbar im Dialog „Nextcloud …" im Kalender.</p>`
|
+ `<p>Zielkalender: <strong>${s.targetCalendarHref ? escapeHtml(s.targetCalendarName || s.targetCalendarHref) : `neuer Kalender „${escapeHtml(s.calendarDisplayName)}" (${escapeHtml(nc.calendar)})`}</strong> – wählbar im Dialog „Nextcloud …" im Kalender.</p>`
|
||||||
+ `<p>Quellkalender für Abwesenheiten: <strong>${s.absenceCalendarHref ? escapeHtml(s.absenceCalendarName || s.absenceCalendarHref) : 'noch keiner'}</strong> – wählbar unter „Abwesenheiten".</p>`
|
+ `<p>Quellkalender für Abwesenheiten: <strong>${s.absenceCalendarHref ? escapeHtml(s.absenceCalendarName || s.absenceCalendarHref) : 'noch keiner'}</strong> – wählbar unter „Abwesenheiten".</p>`
|
||||||
: `<p class="status-warn">Nicht konfiguriert.</p><p class="hint">Umgebungsvariablen setzen: <code>NEXTCLOUD_URL</code>, <code>NEXTCLOUD_USER</code>, <code>NEXTCLOUD_APP_PASSWORD</code> (App-Passwort aus Nextcloud → Einstellungen → Sicherheit), optional <code>NEXTCLOUD_CALENDAR</code>. Bis dahin steht der ICS-Download zur Verfügung.</p>`;
|
: `<p class="status-warn">Nicht konfiguriert.</p><p class="hint">Umgebungsvariablen setzen: <code>NEXTCLOUD_URL</code>, <code>NEXTCLOUD_USER</code>, <code>NEXTCLOUD_APP_PASSWORD</code> (App-Passwort aus Nextcloud → Einstellungen → Sicherheit), optional <code>NEXTCLOUD_CALENDAR</code>. Bis dahin steht der ICS-Download zur Verfügung.</p>`);
|
||||||
}
|
}
|
||||||
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||||
|
|
||||||
@@ -962,12 +1008,17 @@ $('#holiday-add').addEventListener('submit', async (e) => {
|
|||||||
|
|
||||||
// ---------- Start ----------
|
// ---------- Start ----------
|
||||||
function renderAll() {
|
function renderAll() {
|
||||||
|
applyAuthUI();
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
renderPersons();
|
if (isEditor()) {
|
||||||
renderPhotoField();
|
renderPersons();
|
||||||
renderAbsences();
|
renderPhotoField();
|
||||||
renderSettings();
|
renderAbsences();
|
||||||
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport();
|
renderSettings();
|
||||||
|
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport();
|
||||||
|
} else {
|
||||||
|
showView(location.hash.slice(1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
showView(location.hash.slice(1));
|
showView(location.hash.slice(1));
|
||||||
load().catch((err) => toast(`Laden fehlgeschlagen: ${err.message}`, true));
|
load().catch((err) => toast(`Laden fehlgeschlagen: ${err.message}`, true));
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<a href="#abwesenheiten" data-view="abwesenheiten">Abwesenheiten</a>
|
<a href="#abwesenheiten" data-view="abwesenheiten">Abwesenheiten</a>
|
||||||
<a href="#einstellungen" data-view="einstellungen">Einstellungen</a>
|
<a href="#einstellungen" data-view="einstellungen">Einstellungen</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
<button type="button" id="auth-button" class="topbar-action" hidden>Anmelden</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="main">
|
<main id="main">
|
||||||
@@ -255,6 +256,21 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Dialog: Anmelden zum Bearbeiten -->
|
||||||
|
<dialog id="login-dialog">
|
||||||
|
<form method="dialog" class="dialog-form" id="login-form">
|
||||||
|
<h2>Zum Bearbeiten anmelden</h2>
|
||||||
|
<p class="hint">Ansehen kann jeder. Auslosen, Personen, Abwesenheiten und Nextcloud erfordern das Bearbeitungspasswort.</p>
|
||||||
|
<label>Passwort <input type="password" name="password" id="login-password" autocomplete="current-password" required></label>
|
||||||
|
<p id="login-result" class="result error" hidden></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button type="button" class="ghost" id="login-close">Abbrechen</button>
|
||||||
|
<button type="submit" class="primary">Anmelden</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
|
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
|
||||||
<script type="module" src="app.js"></script>
|
<script type="module" src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -59,12 +59,19 @@ svg.icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-w
|
|||||||
.topbar nav a:hover { color: var(--white); }
|
.topbar nav a:hover { color: var(--white); }
|
||||||
.topbar nav a[aria-current="page"] { color: var(--white); border-bottom-color: var(--white); }
|
.topbar nav a[aria-current="page"] { color: var(--white); border-bottom-color: var(--white); }
|
||||||
.topbar nav a:focus-visible { outline: 2px solid var(--white); outline-offset: -4px; }
|
.topbar nav a:focus-visible { outline: 2px solid var(--white); outline-offset: -4px; }
|
||||||
|
.topbar-action { margin-left: auto; background: transparent; color: var(--white); border-color: rgba(255, 255, 255, .4); min-height: 34px; padding: 0 12px; font-size: 13px; }
|
||||||
|
.topbar-action:hover { background: rgba(255, 255, 255, .1); }
|
||||||
|
.topbar-action:focus-visible { outline: 2px solid var(--white); outline-offset: 2px; }
|
||||||
|
/* Betrachtermodus: Kalender ansehen, nichts bearbeiten */
|
||||||
|
.viewer .day { cursor: default; }
|
||||||
|
.viewer .day:hover:not(.outside) { background: var(--white); }
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.topbar { padding: 0 16px; gap: 0 16px; }
|
.topbar { padding: 0 16px; gap: 0 16px; }
|
||||||
.topbar h1 { padding: 12px 0; }
|
.topbar h1 { padding: 12px 0; }
|
||||||
.topbar nav { flex-wrap: nowrap; overflow-x: auto; width: 100%; margin: 0 -16px; padding: 0 8px; scrollbar-width: none; }
|
.topbar nav { flex-wrap: nowrap; overflow-x: auto; width: 100%; margin: 0 -16px; padding: 0 8px; scrollbar-width: none; }
|
||||||
.topbar nav::-webkit-scrollbar { display: none; }
|
.topbar nav::-webkit-scrollbar { display: none; }
|
||||||
.topbar nav a { min-height: 44px; padding: 0 10px; font-size: 13px; white-space: nowrap; }
|
.topbar nav a { min-height: 44px; padding: 0 10px; font-size: 13px; white-space: nowrap; }
|
||||||
|
.topbar-action { margin-left: auto; margin-block: 8px; }
|
||||||
}
|
}
|
||||||
button:focus-visible, input:focus-visible, select:focus-visible, .button:focus-visible, .day:focus-visible, .avatar[tabindex]:focus-visible {
|
button:focus-visible, input:focus-visible, select:focus-visible, .button:focus-visible, .day:focus-visible, .avatar[tabindex]:focus-visible {
|
||||||
outline: 2px solid var(--tradeo-blue); outline-offset: 2px;
|
outline: 2px solid var(--tradeo-blue); outline-offset: 2px;
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Bearbeitungsschutz: Betrachter sehen den Kalender, Bearbeiten erfordert das Passwort aus EDIT_PASSWORD.
|
||||||
|
// Nach erfolgreicher Anmeldung bekommt der Browser ein signiertes Cookie (HMAC, 30 Tage).
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
export const COOKIE_NAME = 'rotatory_edit';
|
||||||
|
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function safeEqual(a, b) {
|
||||||
|
const ba = Buffer.from(String(a));
|
||||||
|
const bb = Buffer.from(String(b));
|
||||||
|
return ba.length === bb.length && timingSafeEqual(ba, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuth({ password = '', secretFile = null, secret = null, now = () => Date.now() } = {}) {
|
||||||
|
const pw = String(password || '').trim();
|
||||||
|
let key = secret;
|
||||||
|
if (!key && secretFile) {
|
||||||
|
if (fs.existsSync(secretFile)) key = fs.readFileSync(secretFile, 'utf8').trim();
|
||||||
|
if (!key) { key = randomBytes(32).toString('hex'); fs.writeFileSync(secretFile, key, { mode: 0o600 }); }
|
||||||
|
}
|
||||||
|
if (!key) key = randomBytes(32).toString('hex');
|
||||||
|
const sign = (payload) => createHmac('sha256', key).update(payload).digest('base64url');
|
||||||
|
|
||||||
|
// Bremse gegen Raten: nach 5 Fehlversuchen je Adresse 60 Sekunden Sperre
|
||||||
|
const failures = new Map();
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: pw.length > 0,
|
||||||
|
ttlMs: TTL_MS,
|
||||||
|
|
||||||
|
checkPassword(given) {
|
||||||
|
return this.enabled && safeEqual(given ?? '', pw);
|
||||||
|
},
|
||||||
|
|
||||||
|
issue() {
|
||||||
|
const exp = String(now() + TTL_MS);
|
||||||
|
return `${exp}.${sign(exp)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
verify(token) {
|
||||||
|
if (!token || typeof token !== 'string') return false;
|
||||||
|
const dot = token.indexOf('.');
|
||||||
|
if (dot < 0) return false;
|
||||||
|
const exp = token.slice(0, dot);
|
||||||
|
const sig = token.slice(dot + 1);
|
||||||
|
if (!/^\d+$/.test(exp) || Number(exp) < now()) return false;
|
||||||
|
return safeEqual(sig, sign(exp));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Liefert die Restsperre in Sekunden (0 = Versuch erlaubt). */
|
||||||
|
blockedFor(ip) {
|
||||||
|
const f = failures.get(ip);
|
||||||
|
if (!f) return 0;
|
||||||
|
if (f.until && f.until > now()) return Math.ceil((f.until - now()) / 1000);
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
noteFailure(ip) {
|
||||||
|
const f = failures.get(ip) || { count: 0, until: 0 };
|
||||||
|
f.count += 1;
|
||||||
|
if (f.count >= 5) { f.until = now() + 60_000; f.count = 0; }
|
||||||
|
failures.set(ip, f);
|
||||||
|
},
|
||||||
|
noteSuccess(ip) { failures.delete(ip); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCookie(header, name = COOKIE_NAME) {
|
||||||
|
for (const part of String(header || '').split(';')) {
|
||||||
|
const [k, ...rest] = part.trim().split('=');
|
||||||
|
if (k === name) return decodeURIComponent(rest.join('='));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cookieHeader(token, { secure = false, maxAgeMs = TTL_MS } = {}) {
|
||||||
|
const parts = [`${COOKIE_NAME}=${encodeURIComponent(token)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}`];
|
||||||
|
if (secure) parts.push('Secure');
|
||||||
|
return parts.join('; ');
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { loadEnvFile } from './env.js';
|
import { loadEnvFile } from './env.js';
|
||||||
import { createStore, imageMime } from './store.js';
|
import { createStore, imageMime } from './store.js';
|
||||||
import { looksLikePlaceholder } from './image.js';
|
import { looksLikePlaceholder } from './image.js';
|
||||||
|
import { createAuth, readCookie, cookieHeader } from './auth.js';
|
||||||
import { workingDays, generateSchedule, isValidDate, countAssignments, addDays } from './scheduler.js';
|
import { workingDays, generateSchedule, isValidDate, countAssignments, addDays } from './scheduler.js';
|
||||||
import { germanHolidays, BUNDESLAENDER } from './holidays.js';
|
import { germanHolidays, BUNDESLAENDER } from './holidays.js';
|
||||||
import { buildEventLines, buildCalendar, eventUid } from './ics.js';
|
import { buildEventLines, buildCalendar, eventUid } from './ics.js';
|
||||||
@@ -46,6 +47,45 @@ if (appPassword) {
|
|||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
|
|
||||||
|
// ---------- Bearbeitungsschutz ----------
|
||||||
|
// Ohne EDIT_PASSWORD darf jeder alles (wie bisher). Mit Passwort sehen Betrachter nur den Kalender.
|
||||||
|
const auth = createAuth({ password: process.env.EDIT_PASSWORD, secretFile: path.join(dataDir, 'session-secret') });
|
||||||
|
const clientIp = (req) => (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || '?';
|
||||||
|
const isSecure = (req) => req.secure || req.headers['x-forwarded-proto'] === 'https';
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.isEditor = !auth.enabled || auth.verify(readCookie(req.headers.cookie));
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
const needsEditor = (req) => {
|
||||||
|
if (!req.path.startsWith('/api/')) return false;
|
||||||
|
if (req.path.startsWith('/api/auth')) return false;
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') return true;
|
||||||
|
return req.path.startsWith('/api/nextcloud/') || req.path.startsWith('/api/persons/placeholder');
|
||||||
|
};
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (needsEditor(req) && !req.isEditor) return res.status(403).json({ error: 'Anmeldung erforderlich.', authRequired: true });
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/auth', (req, res) => res.json({ protected: auth.enabled, editor: req.isEditor }));
|
||||||
|
app.post('/api/auth/login', (req, res) => {
|
||||||
|
if (!auth.enabled) return res.json({ protected: false, editor: true });
|
||||||
|
const ip = clientIp(req);
|
||||||
|
const wait = auth.blockedFor(ip);
|
||||||
|
if (wait > 0) return res.status(429).json({ error: `Zu viele Fehlversuche. Bitte ${wait} Sekunden warten.` });
|
||||||
|
if (!auth.checkPassword(req.body?.password)) {
|
||||||
|
auth.noteFailure(ip);
|
||||||
|
return res.status(401).json({ error: 'Passwort stimmt nicht.' });
|
||||||
|
}
|
||||||
|
auth.noteSuccess(ip);
|
||||||
|
res.set('Set-Cookie', cookieHeader(auth.issue(), { secure: isSecure(req) }));
|
||||||
|
res.json({ protected: true, editor: true });
|
||||||
|
});
|
||||||
|
app.post('/api/auth/logout', (req, res) => {
|
||||||
|
res.set('Set-Cookie', cookieHeader('', { secure: isSecure(req), maxAgeMs: 0 }));
|
||||||
|
res.json({ protected: auth.enabled, editor: !auth.enabled });
|
||||||
|
});
|
||||||
|
|
||||||
const S = () => store.state;
|
const S = () => store.state;
|
||||||
const clean = (s, max = 120) => String(s ?? '').trim().slice(0, max);
|
const clean = (s, max = 120) => String(s ?? '').trim().slice(0, max);
|
||||||
const bad = (res, msg) => res.status(400).json({ error: msg });
|
const bad = (res, msg) => res.status(400).json({ error: msg });
|
||||||
@@ -85,7 +125,24 @@ function publicPerson(p) {
|
|||||||
// ---------- Gesamtzustand ----------
|
// ---------- Gesamtzustand ----------
|
||||||
app.get('/api/state', (req, res) => {
|
app.get('/api/state', (req, res) => {
|
||||||
const st = S();
|
const st = S();
|
||||||
|
const authInfo = { protected: auth.enabled, editor: req.isEditor };
|
||||||
|
if (!req.isEditor) {
|
||||||
|
// Betrachter: nur, was der Kalender braucht – keine E-Mails, Notizen, Abwesenheiten oder Zugangsdetails
|
||||||
|
return res.json({
|
||||||
|
auth: authInfo,
|
||||||
|
persons: st.persons.map((p) => ({ id: p.id, firstName: p.firstName, lastName: p.lastName, canMain: p.canMain, canCo: p.canCo, active: p.active, photoVersion: p.photoVersion, hasPhoto: store.hasPhoto(p.id) })),
|
||||||
|
absences: [],
|
||||||
|
holidays: st.holidays,
|
||||||
|
assignments: st.assignments,
|
||||||
|
exported: {},
|
||||||
|
settings: { meetingTime: st.settings.meetingTime, durationMinutes: st.settings.durationMinutes },
|
||||||
|
bundeslaender: BUNDESLAENDER,
|
||||||
|
absenceTypes: ABSENCE_TYPES,
|
||||||
|
nextcloud: { configured: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json({
|
res.json({
|
||||||
|
auth: authInfo,
|
||||||
persons: st.persons.map(publicPerson),
|
persons: st.persons.map(publicPerson),
|
||||||
absences: st.absences,
|
absences: st.absences,
|
||||||
holidays: st.holidays,
|
holidays: st.holidays,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createAuth, readCookie, cookieHeader, COOKIE_NAME } from '../server/auth.js';
|
||||||
|
|
||||||
|
test('ohne Passwort ist der Schutz aus', () => {
|
||||||
|
const a = createAuth({ password: '', secret: 's' });
|
||||||
|
assert.equal(a.enabled, false);
|
||||||
|
assert.equal(a.checkPassword(''), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Passwortprüfung und signierte Token', () => {
|
||||||
|
let t = 1_000_000;
|
||||||
|
const a = createAuth({ password: 'geheim', secret: 'schluessel', now: () => t });
|
||||||
|
assert.ok(a.enabled);
|
||||||
|
assert.ok(a.checkPassword('geheim'));
|
||||||
|
assert.ok(!a.checkPassword('Geheim'));
|
||||||
|
assert.ok(!a.checkPassword(undefined));
|
||||||
|
const token = a.issue();
|
||||||
|
assert.ok(a.verify(token));
|
||||||
|
assert.ok(!a.verify(token + 'x'), 'manipulierte Signatur');
|
||||||
|
assert.ok(!a.verify('123.abc'));
|
||||||
|
assert.ok(!a.verify(''));
|
||||||
|
const other = createAuth({ password: 'geheim', secret: 'anderer', now: () => t });
|
||||||
|
assert.ok(!other.verify(token), 'anderer Schlüssel');
|
||||||
|
t += a.ttlMs + 1;
|
||||||
|
assert.ok(!a.verify(token), 'abgelaufen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Bremse nach fünf Fehlversuchen', () => {
|
||||||
|
let t = 0;
|
||||||
|
const a = createAuth({ password: 'x', secret: 's', now: () => t });
|
||||||
|
for (let i = 0; i < 4; i += 1) a.noteFailure('1.2.3.4');
|
||||||
|
assert.equal(a.blockedFor('1.2.3.4'), 0);
|
||||||
|
a.noteFailure('1.2.3.4');
|
||||||
|
assert.ok(a.blockedFor('1.2.3.4') > 0);
|
||||||
|
assert.equal(a.blockedFor('5.6.7.8'), 0);
|
||||||
|
t += 61_000;
|
||||||
|
assert.equal(a.blockedFor('1.2.3.4'), 0);
|
||||||
|
a.noteFailure('1.2.3.4');
|
||||||
|
a.noteSuccess('1.2.3.4');
|
||||||
|
assert.equal(a.blockedFor('1.2.3.4'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cookie lesen und schreiben', () => {
|
||||||
|
const header = cookieHeader('abc.def', { secure: true });
|
||||||
|
assert.ok(header.startsWith(`${COOKIE_NAME}=abc.def; Path=/; HttpOnly; SameSite=Lax; Max-Age=`));
|
||||||
|
assert.ok(header.endsWith('; Secure'));
|
||||||
|
assert.equal(readCookie(`foo=1; ${COOKIE_NAME}=abc.def; bar=2`), 'abc.def');
|
||||||
|
assert.equal(readCookie('foo=1'), null);
|
||||||
|
assert.equal(readCookie(undefined), null);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user