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
@@ -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 { 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,
|
||||
|
||||
Reference in new issue
Block a user