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>
81 lines
2.8 KiB
JavaScript
81 lines
2.8 KiB
JavaScript
// 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('; ');
|
|
}
|