diff --git a/README.md b/README.md new file mode 100644 index 0000000..7553099 --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +# Oikos — Selbstgehosteter Familienplaner + +Oikos ist eine self-hosted Progressive Web App für Familien. Sie läuft vollständig auf deinem eigenen Server — keine Cloud-Abhängigkeiten, keine Datenweitergabe. + +## Features + +- **Dashboard** — Begrüßung, Wetter-Widget, anstehende Termine, dringende Aufgaben, Essen, Pinnwand +- **Aufgaben** — Listenansicht (Kategorie/Fälligkeit), Kanban-Board, Teilaufgaben, Swipe-Gesten, wiederkehrende Aufgaben +- **Einkaufslisten** — Mehrere Listen, Kategorien, Essensplan-Integration +- **Essensplan** — Wochenansicht, Zutatenverwaltung, Übertrag auf Einkaufsliste +- **Kalender** — Monats-/Wochen-/Tages-/Agenda-Ansicht, Familienfarben, wiederkehrende Termine +- **Pinnwand** — Farbige Sticky Notes mit Markdown-Light +- **Kontakte** — Wichtige Kontakte mit Kategorie-Filter, tel:/mailto:/Maps-Links +- **Budget** — Einnahmen/Ausgaben, Monatsauswertung, CSV-Export +- **PWA** — Offline-fähig, installierbar auf iOS/Android/Desktop + +## Voraussetzungen + +- **Docker & Docker Compose** (empfohlen) oder **Node.js ≥ 20** +- Ein Linux-Server hinter Nginx Reverse Proxy mit SSL (empfohlen) + +--- + +## Schnellstart mit Docker (empfohlen) + +### 1. Repository klonen + +```bash +git clone https://github.com/ulsklyc/oikos.git +cd oikos +``` + +### 2. Umgebungsvariablen konfigurieren + +```bash +cp .env.example .env +``` + +Pflichtfelder in `.env` anpassen: + +```env +SESSION_SECRET=ein-langer-zufaelliger-string-min-32-zeichen +DB_ENCRYPTION_KEY=ein-starkes-passwort-fuer-die-datenbank +``` + +Optional: Wetter-Widget aktivieren + +```env +OPENWEATHER_API_KEY=dein-api-key-von-openweathermap.org +OPENWEATHER_CITY=Berlin +``` + +### 3. Starten + +```bash +docker compose up -d +``` + +Die App ist unter `http://localhost:3000` erreichbar. + +### 4. Erster Login + +Beim ersten Start wird automatisch ein Admin-Account erstellt: + +``` +Benutzername: admin +Passwort: admin +``` + +**Passwort sofort ändern!** → Einstellungen → Passwort ändern + +--- + +## Ohne Docker (direkt mit Node.js) + +```bash +npm install +cp .env.example .env +# .env anpassen (siehe oben) +npm start +``` + +Entwicklungsmodus (Auto-Reload): + +```bash +npm run dev +``` + +--- + +## Nginx Reverse Proxy + +Beispiel-Konfiguration für Nginx Proxy Manager oder direktes Nginx: + +```nginx +server { + listen 443 ssl; + server_name oikos.deine-domain.de; + + location / { + proxy_pass http://localhost:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +Die Datei `nginx.conf.example` im Repository enthält eine vollständige Konfiguration. + +--- + +## Familienmitglieder verwalten + +Neue Mitglieder können nur Admins anlegen: + +1. **Einstellungen** → **Familienmitglieder** → **+ Neu** +2. Benutzername, Anzeigename, Passwort, Avatarfarbe und Rolle festlegen +3. Login-Daten dem Familienmitglied mitteilen + +--- + +## Umgebungsvariablen — Referenz + +| Variable | Pflicht | Standard | Beschreibung | +|---|---|---|---| +| `PORT` | — | `3000` | Server-Port | +| `NODE_ENV` | — | `development` | `production` für Deployment | +| `SESSION_SECRET` | ✓ | — | Langer Zufalls-String (≥ 32 Zeichen) | +| `DB_PATH` | — | `./oikos.db` | Pfad zur SQLite-Datenbankdatei | +| `DB_ENCRYPTION_KEY` | — | — | SQLCipher-Schlüssel (leer = keine Verschlüsselung) | +| `OPENWEATHER_API_KEY` | — | — | API-Key von openweathermap.org | +| `OPENWEATHER_CITY` | — | `Berlin` | Stadtname für Wetter-Abfrage | +| `OPENWEATHER_UNITS` | — | `metric` | `metric` = °C, `imperial` = °F | +| `OPENWEATHER_LANG` | — | `de` | Sprache der Wetterbeschreibungen | +| `RATE_LIMIT_WINDOW_MS` | — | `60000` | Login Rate-Limit Fenster (ms) | +| `RATE_LIMIT_MAX_ATTEMPTS` | — | `5` | Max. Login-Versuche pro Fenster | + +--- + +## Sicherheit + +- Sessions sind `httpOnly`, `SameSite=Strict` und in Produktion `Secure` +- CSRF-Schutz via Double Submit Cookie auf allen zustandsändernden Requests +- Passwörter mit bcrypt (Cost Factor 12) gehasht +- Globaler Rate-Limiter auf allen API-Endpoints (300 req/min) +- Strikter Login-Rate-Limiter (5 Versuche/Minute) +- Content Security Policy via Helmet +- Datenbank optional mit SQLCipher verschlüsselt + +--- + +## Datensicherung + +Die gesamte Datenbank liegt in einer einzigen Datei: + +```bash +# Backup +cp /data/oikos.db /backup/oikos-$(date +%Y%m%d).db + +# Docker-Volume sichern +docker run --rm -v oikos_data:/data -v $(pwd):/backup \ + alpine tar czf /backup/oikos-backup.tar.gz /data +``` + +--- + +## Tests ausführen + +```bash +npm test +``` + +Die Tests verwenden In-Memory-SQLite und benötigen keine laufende App-Instanz. + +--- + +## Lizenz + +Privates Projekt — nicht für den öffentlichen Einsatz lizenziert. diff --git a/public/api.js b/public/api.js index 83f3144..aef11fe 100644 --- a/public/api.js +++ b/public/api.js @@ -6,6 +6,14 @@ const API_BASE = '/api/v1'; +/** Liest den CSRF-Token aus dem Cookie (gesetzt vom Server nach Login). */ +function getCsrfToken() { + return document.cookie.split(';') + .map((c) => c.trim()) + .find((c) => c.startsWith('csrf-token=')) + ?.slice('csrf-token='.length) ?? ''; +} + /** * Zentraler Fetch-Wrapper. * Setzt Content-Type, handhabt 401-Redirects und parsed JSON-Fehler. @@ -17,10 +25,14 @@ const API_BASE = '/api/v1'; async function apiFetch(path, options = {}) { const url = `${API_BASE}${path}`; + const method = options.method ?? 'GET'; + const stateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method); + const response = await fetch(url, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json', + ...(stateChanging ? { 'X-CSRF-Token': getCsrfToken() } : {}), ...options.headers, }, ...options, diff --git a/public/router.js b/public/router.js index 7597707..0cb5b36 100644 --- a/public/router.js +++ b/public/router.js @@ -217,6 +217,26 @@ function showToast(message, type = 'default', duration = 3000) { // Event-Listener // -------------------------------------------------------- +// -------------------------------------------------------- +// Globale Fehler-Handler (Error Boundary) +// -------------------------------------------------------- + +window.addEventListener('error', (e) => { + // Ressource-Ladefehler (z.B. fehlgeschlagenes Bild): ignorieren + if (e.target && e.target !== window) return; + console.error('[Oikos] Unbehandelter Fehler:', e.error ?? e.message); + showToast('Ein unerwarteter Fehler ist aufgetreten.', 'danger'); +}); + +window.addEventListener('unhandledrejection', (e) => { + // Auth-Fehler werden bereits von auth:expired behandelt + if (e.reason?.status === 401) return; + console.error('[Oikos] Unbehandeltes Promise-Rejection:', e.reason); + const msg = e.reason?.message || 'Ein Fehler ist aufgetreten.'; + showToast(msg, 'danger'); + e.preventDefault(); // Konsolenfehler unterdrücken (bereits geloggt) +}); + // Browser zurück/vor window.addEventListener('popstate', (e) => { navigate(e.state?.path || location.pathname, false); diff --git a/server/auth.js b/server/auth.js index fa70ab4..0472685 100644 --- a/server/auth.js +++ b/server/auth.js @@ -13,6 +13,7 @@ const session = require('express-session'); const rateLimit = require('express-rate-limit'); const db = require('./db'); +const { generateToken } = require('./middleware/csrf'); const router = express.Router(); // -------------------------------------------------------- @@ -117,8 +118,17 @@ router.post('/login', loginLimiter, async (req, res) => { return res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); } - req.session.userId = user.id; - req.session.role = user.role; + req.session.userId = user.id; + req.session.role = user.role; + req.session.csrfToken = generateToken(); + + // CSRF-Token als Cookie setzen (nicht httpOnly → lesbar für JS) + res.cookie('csrf-token', req.session.csrfToken, { + httpOnly: false, + sameSite: 'strict', + secure: process.env.NODE_ENV === 'production', + maxAge: 1000 * 60 * 60 * 24 * 7, + }); res.json({ user: { diff --git a/server/index.js b/server/index.js index 1baa72f..aac4969 100644 --- a/server/index.js +++ b/server/index.js @@ -7,11 +7,13 @@ 'use strict'; require('dotenv').config(); -const express = require('express'); -const helmet = require('helmet'); -const path = require('path'); -const db = require('./db'); +const express = require('express'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); +const path = require('path'); +const db = require('./db'); const { router: authRouter, sessionMiddleware, requireAuth } = require('./auth'); +const { csrfMiddleware } = require('./middleware/csrf'); const app = express(); const PORT = process.env.PORT || 3000; @@ -72,13 +74,29 @@ app.use(express.static(path.join(__dirname, '..', 'public'), { etag: true, })); +// -------------------------------------------------------- +// Globaler API-Rate-Limiter (Schritt 29) +// Verhindert Brute-Force und DoS auf allen API-Endpunkten. +// Login hat einen eigenen, strengeren Limiter (auth.js). +// -------------------------------------------------------- +const apiLimiter = rateLimit({ + windowMs: 60_000, // 1 Minute + max: 300, // 300 Requests/Minute pro IP (großzügig für Familien-App) + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Zu viele Anfragen. Bitte warte kurz.', code: 429 }, + skip: (req) => req.path === '/health', // Health-Check ausgenommen +}); +app.use('/api/', apiLimiter); + // -------------------------------------------------------- // API-Routen // -------------------------------------------------------- app.use('/api/v1/auth', authRouter); -// Alle weiteren API-Routen erfordern Authentifizierung +// Alle weiteren API-Routen erfordern Authentifizierung + CSRF-Schutz app.use('/api/v1', requireAuth); +app.use('/api/v1', csrfMiddleware); app.use('/api/v1/dashboard', require('./routes/dashboard')); app.use('/api/v1/tasks', require('./routes/tasks')); app.use('/api/v1/shopping', require('./routes/shopping')); diff --git a/server/middleware/csrf.js b/server/middleware/csrf.js new file mode 100644 index 0000000..2860748 --- /dev/null +++ b/server/middleware/csrf.js @@ -0,0 +1,71 @@ +/** + * Modul: CSRF-Schutz (Double Submit Cookie Pattern) + * Zweck: Schützt state-ändernde API-Endpunkte vor Cross-Site Request Forgery + * Abhängigkeiten: node:crypto + * + * Funktionsweise: + * 1. Beim ersten authentifizierten Request wird ein 32-Byte-Hex-Token in der Session gespeichert. + * 2. Das Token wird als nicht-httpOnly-Cookie gesetzt (lesbar durch JavaScript). + * 3. Das Frontend liest das Cookie und sendet es als X-CSRF-Token-Header. + * 4. Der Server vergleicht Header und Session-Token per timingSafeEqual. + * 5. GET/HEAD/OPTIONS sind ausgenommen (safe methods). + */ + +'use strict'; + +const crypto = require('node:crypto'); + +const TOKEN_LENGTH = 32; // Bytes → 64 Hex-Zeichen + +/** + * Generiert einen kryptographisch sicheren CSRF-Token. + * @returns {string} 64-stelliger Hex-String + */ +function generateToken() { + return crypto.randomBytes(TOKEN_LENGTH).toString('hex'); +} + +/** + * CSRF-Middleware für authentifizierte API-Routen. + * Muss NACH requireAuth eingebunden werden. + */ +function csrfMiddleware(req, res, next) { + // Token generieren falls noch nicht vorhanden (erste Request nach Login) + if (!req.session.csrfToken) { + req.session.csrfToken = generateToken(); + } + + // Cookie bei jedem Request erneuern (SameSite=Strict, nicht httpOnly → JS-lesbar) + res.cookie('csrf-token', req.session.csrfToken, { + httpOnly: false, + sameSite: 'strict', + secure: process.env.NODE_ENV === 'production', + maxAge: 1000 * 60 * 60 * 24 * 7, // 7 Tage (gleich wie Session) + }); + + // Safe Methods benötigen keine Validierung + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { + return next(); + } + + // CSRF-Token aus Header prüfen + const headerToken = req.headers['x-csrf-token'] ?? ''; + const sessionToken = req.session.csrfToken; + const expectedLen = TOKEN_LENGTH * 2; // 64 Hex-Zeichen + + const tokenValid = + headerToken.length === expectedLen && + sessionToken.length === expectedLen && + crypto.timingSafeEqual( + Buffer.from(headerToken, 'hex'), + Buffer.from(sessionToken, 'hex') + ); + + if (!tokenValid) { + return res.status(403).json({ error: 'Ungültiges CSRF-Token.', code: 403 }); + } + + next(); +} + +module.exports = { csrfMiddleware, generateToken }; diff --git a/server/middleware/validate.js b/server/middleware/validate.js new file mode 100644 index 0000000..0ea37e0 --- /dev/null +++ b/server/middleware/validate.js @@ -0,0 +1,106 @@ +/** + * Modul: Eingabe-Validierung (Validate) + * Zweck: Wiederverwendbare Validierungs-Helfer für alle API-Routen + * Abhängigkeiten: keine + */ + +'use strict'; + +// Globale Längengrenzen +const MAX_TITLE = 200; +const MAX_TEXT = 5000; +const MAX_SHORT = 100; + +/** + * Bereinigt und validiert einen Pflicht-String. + * @param {any} val - Eingabewert + * @param {string} field - Feldname (für Fehlermeldung) + * @param {object} opts + * @param {number} [opts.max=200] - Maximale Länge + * @param {boolean}[opts.required=true]- Ob das Feld Pflicht ist + * @returns {{ value: string|null, error: string|null }} + */ +function str(val, field, { max = MAX_TITLE, required = true } = {}) { + if (val === undefined || val === null || val === '') { + if (required) return { value: null, error: `${field} ist erforderlich.` }; + return { value: null, error: null }; + } + const s = String(val).trim(); + if (required && !s) return { value: null, error: `${field} darf nicht leer sein.` }; + if (s.length > max) return { value: null, error: `${field} darf maximal ${max} Zeichen haben.` }; + return { value: s || null, error: null }; +} + +/** + * Validiert einen Enum-Wert. + * @param {any} val + * @param {string[]} allowed + * @param {string} field + * @returns {{ value: string|null, error: string|null }} + */ +function oneOf(val, allowed, field) { + if (val === undefined || val === null || val === '') return { value: null, error: null }; + if (!allowed.includes(val)) + return { value: null, error: `${field} muss eines von: ${allowed.join(', ')} sein.` }; + return { value: val, error: null }; +} + +/** + * Validiert ein Datumsformat YYYY-MM-DD. + * @param {any} val + * @param {string} field + * @param {boolean} required + */ +function date(val, field, required = false) { + if (!val) { + if (required) return { value: null, error: `${field} ist erforderlich.` }; + return { value: null, error: null }; + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(String(val))) + return { value: null, error: `${field} muss im Format YYYY-MM-DD sein.` }; + return { value: String(val), error: null }; +} + +/** + * Validiert ein Zeit-Format HH:MM. + */ +function time(val, field) { + if (!val) return { value: null, error: null }; + if (!/^\d{2}:\d{2}$/.test(String(val))) + return { value: null, error: `${field} muss im Format HH:MM sein.` }; + return { value: String(val), error: null }; +} + +/** + * Validiert eine Zahl (positiv oder negativ). + */ +function num(val, field, { required = false } = {}) { + if (val === undefined || val === null || val === '') { + if (required) return { value: null, error: `${field} ist erforderlich.` }; + return { value: null, error: null }; + } + const n = Number(val); + if (!isFinite(n)) return { value: null, error: `${field} muss eine gültige Zahl sein.` }; + return { value: n, error: null }; +} + +/** + * Validiert eine Hex-Farbe (#RRGGBB). + */ +function color(val, field) { + if (!val) return { value: null, error: null }; + if (!/^#[0-9A-Fa-f]{6}$/.test(String(val))) + return { value: null, error: `${field} muss ein gültiger HEX-Farbwert sein (#RRGGBB).` }; + return { value: String(val), error: null }; +} + +/** + * Sammelt alle Fehler aus einem Array von Validierungsergebnissen. + * @param {Array<{ error: string|null }>} results + * @returns {string[]} Fehlerliste + */ +function collectErrors(results) { + return results.map((r) => r.error).filter(Boolean); +} + +module.exports = { str, oneOf, date, time, num, color, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT }; diff --git a/server/routes/tasks.js b/server/routes/tasks.js index 8504f51..dd6752d 100644 --- a/server/routes/tasks.js +++ b/server/routes/tasks.js @@ -9,7 +9,8 @@ const express = require('express'); const router = express.Router(); const db = require('../db'); -const { nextOccurrence } = require('../services/recurrence'); +const { nextOccurrence } = require('../services/recurrence'); +const v = require('../middleware/validate'); // -------------------------------------------------------- // Konstanten @@ -47,22 +48,17 @@ function subtaskProgress(taskId) { return { total: row.total ?? 0, done: row.done ?? 0 }; } -/** Eingabe-Validierung für Task-Felder. */ +/** Eingabe-Validierung für Task-Felder (zentralisiert über validate.js). */ function validateTaskInput(body, isCreate = true) { - const errors = []; - if (isCreate && !body.title?.trim()) errors.push('title ist erforderlich.'); - if (body.title !== undefined && !body.title?.trim()) errors.push('title darf nicht leer sein.'); - if (body.priority && !VALID_PRIORITIES.includes(body.priority)) - errors.push(`priority muss eines von: ${VALID_PRIORITIES.join(', ')} sein.`); - if (body.status && !VALID_STATUSES.includes(body.status)) - errors.push(`status muss eines von: ${VALID_STATUSES.join(', ')} sein.`); - if (body.category && !VALID_CATEGORIES.includes(body.category)) - errors.push(`Ungültige Kategorie.`); - if (body.due_date && !/^\d{4}-\d{2}-\d{2}$/.test(body.due_date)) - errors.push('due_date muss im Format YYYY-MM-DD sein.'); - if (body.due_time && !/^\d{2}:\d{2}$/.test(body.due_time)) - errors.push('due_time muss im Format HH:MM sein.'); - return errors; + return v.collectErrors([ + v.str(body.title, 'title', { required: isCreate }), + v.str(body.description, 'description', { required: false, max: v.MAX_TEXT }), + v.oneOf(body.priority, VALID_PRIORITIES, 'priority'), + v.oneOf(body.status, VALID_STATUSES, 'status'), + v.oneOf(body.category, VALID_CATEGORIES, 'category'), + v.date(body.due_date, 'due_date'), + v.time(body.due_time, 'due_time'), + ]); } // --------------------------------------------------------