From 33a8adef0e65a67da56ad19feecaf4ac7cb02f24 Mon Sep 17 00:00:00 2001 From: ferdi2go Date: Wed, 16 Sep 2026 12:36:23 +0000 Subject: [PATCH] =?UTF-8?q?Bearbeitung=20hinter=20ein=20Passwort=20legen,?= =?UTF-8?q?=20Ansehen=20f=C3=BCr=20das=20Team=20freigeben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 5 ++- README.md | 12 ++++++- public/app.js | 73 +++++++++++++++++++++++++++++++++++------- public/index.html | 16 ++++++++++ public/style.css | 7 +++++ server/auth.js | 80 +++++++++++++++++++++++++++++++++++++++++++++++ server/index.js | 57 +++++++++++++++++++++++++++++++++ test/auth.test.js | 51 ++++++++++++++++++++++++++++++ 8 files changed, 288 insertions(+), 13 deletions(-) create mode 100644 server/auth.js create mode 100644 test/auth.test.js diff --git a/.env.example b/.env.example index cc6820d..0993bad 100644 --- a/.env.example +++ b/.env.example @@ -6,5 +6,8 @@ NEXTCLOUD_APP_PASSWORD= # optional App-Passwort aus Nextcloud → Einstellung NEXTCLOUD_CALENDAR=morning-meeting # Nur setzen, wenn die Nextcloud ein selbstsigniertes Zertifikat hat 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="" diff --git a/README.md b/README.md index 8a83389..0b207a9 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,21 @@ Alle Variablen sind optional, siehe `.env.example`: | `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_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 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 1. Umgebungsvariablen setzen und App neu starten. diff --git a/public/app.js b/public/app.js index 66bb51b..7089d7f 100644 --- a/public/app.js +++ b/public/app.js @@ -43,10 +43,51 @@ async function api(path, { method = 'GET', body, raw } = {}) { const res = await fetch(path, opts); if (res.status === 204) return null; 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; } +// ---------- 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() { state.data = await api('api/state'); renderAll(); @@ -100,6 +141,7 @@ function avatarEl(p, cls = '') { function showView(name) { const known = ['kalender', 'personen', 'abwesenheiten', 'einstellungen']; if (!known.includes(name)) name = 'kalender'; + if (!isEditor() && EDITOR_VIEWS.includes(name)) name = 'kalender'; if (name === 'abwesenheiten' && state.data) prepareAbsenceImport(); for (const v of $$('.view')) v.hidden = v.id !== `view-${name}`; for (const a of $$('.topbar nav a')) { @@ -128,9 +170,10 @@ function renderCalendar() { if (cur.getDay() === 0 || cur.getDay() === 6) continue; const date = iso(cur); 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'; - 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 (date === today) cell.classList.add('today'); 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 document.addEventListener('paste', (e) => { - if ($('#view-personen').hidden || !state.data) return; + if ($('#view-personen').hidden || !state.data || !isEditor()) return; const file = imageFromClipboard(e.clipboardData); if (!file) return; e.preventDefault(); @@ -920,13 +963,16 @@ function renderSettings() { list.appendChild(li); } + const protectedHint = state.data.auth?.protected + ? '

Bearbeitung ist durch das Passwort aus EDIT_PASSWORD geschützt. Das Team sieht nur den Kalender.

' + : '

Kein Bearbeitungspasswort gesetzt: Jeder, der die Seite öffnet, kann auch bearbeiten. Zum Schützen EDIT_PASSWORD als Umgebungsvariable setzen.

'; const nc = state.data.nextcloud; const box = $('#nextcloud-status'); - box.innerHTML = nc.configured + box.innerHTML = protectedHint + (nc.configured ? `

Konfiguriert: ${escapeHtml(nc.baseUrl)}, angemeldet als ${escapeHtml(nc.user)}.

` + `

Zielkalender: ${s.targetCalendarHref ? escapeHtml(s.targetCalendarName || s.targetCalendarHref) : `neuer Kalender „${escapeHtml(s.calendarDisplayName)}" (${escapeHtml(nc.calendar)})`} – wählbar im Dialog „Nextcloud …" im Kalender.

` + `

Quellkalender für Abwesenheiten: ${s.absenceCalendarHref ? escapeHtml(s.absenceCalendarName || s.absenceCalendarHref) : 'noch keiner'} – wählbar unter „Abwesenheiten".

` - : `

Nicht konfiguriert.

Umgebungsvariablen setzen: NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_APP_PASSWORD (App-Passwort aus Nextcloud → Einstellungen → Sicherheit), optional NEXTCLOUD_CALENDAR. Bis dahin steht der ICS-Download zur Verfügung.

`; + : `

Nicht konfiguriert.

Umgebungsvariablen setzen: NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_APP_PASSWORD (App-Passwort aus Nextcloud → Einstellungen → Sicherheit), optional NEXTCLOUD_CALENDAR. Bis dahin steht der ICS-Download zur Verfügung.

`); } function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } @@ -962,12 +1008,17 @@ $('#holiday-add').addEventListener('submit', async (e) => { // ---------- Start ---------- function renderAll() { + applyAuthUI(); renderCalendar(); - renderPersons(); - renderPhotoField(); - renderAbsences(); - renderSettings(); - if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport(); + if (isEditor()) { + renderPersons(); + renderPhotoField(); + renderAbsences(); + renderSettings(); + if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport(); + } else { + showView(location.hash.slice(1)); + } } showView(location.hash.slice(1)); load().catch((err) => toast(`Laden fehlgeschlagen: ${err.message}`, true)); diff --git a/public/index.html b/public/index.html index 9409f31..c279435 100644 --- a/public/index.html +++ b/public/index.html @@ -18,6 +18,7 @@ Abwesenheiten Einstellungen +
@@ -255,6 +256,21 @@ + + +
+

Zum Bearbeiten anmelden

+

Ansehen kann jeder. Auslosen, Personen, Abwesenheiten und Nextcloud erfordern das Bearbeitungspasswort.

+ + +
+ + + +
+
+
+ diff --git a/public/style.css b/public/style.css index 7339be9..f59bf79 100644 --- a/public/style.css +++ b/public/style.css @@ -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[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-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) { .topbar { padding: 0 16px; gap: 0 16px; } .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::-webkit-scrollbar { display: none; } .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 { outline: 2px solid var(--tradeo-blue); outline-offset: 2px; diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..9846c8e --- /dev/null +++ b/server/auth.js @@ -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('; '); +} diff --git a/server/index.js b/server/index.js index 97bbc40..c4adb4d 100644 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import { loadEnvFile } from './env.js'; import { createStore, imageMime } from './store.js'; import { looksLikePlaceholder } from './image.js'; +import { createAuth, readCookie, cookieHeader } from './auth.js'; import { workingDays, generateSchedule, isValidDate, countAssignments, addDays } from './scheduler.js'; import { germanHolidays, BUNDESLAENDER } from './holidays.js'; import { buildEventLines, buildCalendar, eventUid } from './ics.js'; @@ -46,6 +47,45 @@ if (appPassword) { app.use(express.json({ limit: '1mb' })); 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 clean = (s, max = 120) => String(s ?? '').trim().slice(0, max); const bad = (res, msg) => res.status(400).json({ error: msg }); @@ -85,7 +125,24 @@ function publicPerson(p) { // ---------- Gesamtzustand ---------- app.get('/api/state', (req, res) => { 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({ + auth: authInfo, persons: st.persons.map(publicPerson), absences: st.absences, holidays: st.holidays, diff --git a/test/auth.test.js b/test/auth.test.js new file mode 100644 index 0000000..75a7100 --- /dev/null +++ b/test/auth.test.js @@ -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); +});