7.3 KiB
Architektur — Dienstplan-Pro Team-Release v1.0
BMAD Phase 3 (Solutioning). Die „lean spine": Invarianten, aus denen alles Konsistente folgt. Leitet sich aus prd-dienstplan-pro-2026-07-07/prd.md + addendum.md ab. Brownfield — baut auf project-context.md.
1. Architektur-Invarianten (nicht verhandelbar)
- Datentrennung ist server-erzwungen. Jede Datenoperation ist auf
req.user.idgefiltert.user_idstammt ausschließlich aus dem Session-Kontext, nie aus dem Client. Auch der Admin liest nur seine eigene Datenbasis. - Kein Klartext-Geheimnis in der DB. Login-Tokens und Session-IDs liegen nur als SHA-256-Hash vor; der Rohwert existiert nur im Link (einmalig) bzw. im httpOnly-Cookie.
foreign_keys = ON— sonst wirkt kein CASCADE. Löschen eines Nutzers räumt Tokens+Sessions atomar.- Seed vor Migration.
seedAdmin(ADMIN_EMAIL)liefertadminUserId, dannmigrateToMultiUser(adminUserId). Fehlt/ungültigADMIN_EMAIL→ Fail-Fast, keine Migration. - Migration ist eine einzige SQLite-Transaktion (documents-Rebuild + history), idempotent (Guard:
documents.user_idexistiert schon?). Kein halb-migrierter Zustand. - Auth ist additiv, nie im kritischen Offline-Pfad.
401(ungültige Session) → Login-Overlay; Netzwerkfehler (offline) → App läuft lokal weiter. Die beiden werden nie verwechselt. - Same-origin. Ein Node/Express-Prozess serviert Frontend + API. Kein CORS.
- better-sqlite3 ist synchron. Niemals
db.run(); immerdb.prepare().run()/db.exec(). KeineasyncDB-Funktionen.
2. Komponenten
Backend (server/)
| Datei | Verantwortung | Status |
|---|---|---|
db.js |
Verbindung, Pragmas (WAL, foreign_keys=ON), Schema, getDoc(userId,key)/putDoc(userId,key,…) |
ändern |
auth.js |
normalizeEmail, hashToken, createLoginToken/consumeLoginToken, createSession/validateSession/deleteSession/deleteUserSessions, seedAdmin, migrateToMultiUser, authMiddleware, adminMiddleware, listUsers/addUser/removeUser (Last-Admin-Guard) |
neu |
mailer.js |
sendMagicLink(email,url) via nodemailer; ohne SMTP → console.log (Dev) |
neu |
ratelimit.js |
In-Memory hit(key,limit,windowMin); E-Mail primär, IP großzügig |
neu |
audit.js |
append-only Log (ts,event,user_id,ip_hash), keine PII |
neu |
index.js |
Routen + Middleware + Cookie-Helfer; Startup-Sequenz (seed→migrate→listen) | ändern |
backup.js |
unverändert (tägliches Online-Backup) | — |
Frontend
| Datei | Verantwortung | Status |
|---|---|---|
index.html |
Login-Overlay (Vollbild, initial sichtbar) + Bestätigungsseite-Route + Admin-Sektion/Logout in #tab-settings |
ändern |
app.js |
Bootstrap: GET /api/auth/me vor App-Start; Offline≠401; Nutzerwechsel; Admin-UI-Logik |
ändern |
sync.js |
credentials:'include'; 401→Overlay, Netzfehler→lokal; pending-Schutz |
ändern |
storage.js |
clearUserData() (Daten-Keys + OpenRouter-Key), dienstplan_current_user |
ändern |
auth-ui.js |
schlanke Login-/Bestätigungs-/Admin-Interaktion (neu, hält app.js fokussiert) |
neu |
sw.js |
Cache-Version bump | ändern |
3. Datenmodell
users(id PK, email UNIQUE [normalisiert], is_admin, created_at)
login_tokens(token_hash PK, user_id FK→users ON DELETE CASCADE, expires_at, used_at)
sessions(id_hash PK, user_id FK→users ON DELETE CASCADE, expires_at, created_at, last_seen_at)
documents(user_id, key, value, updated_at, PRIMARY KEY(user_id,key)) -- Rebuild
history(…, user_id) -- ADD COLUMN
audit_log(id PK, ts, event, user_id, ip_hash) -- neu, keine PII
last_seen_at trägt den Idle-Timeout (FR-3): bei jeder Auth-geschützten Anfrage aktualisiert; validateSession prüft now-last_seen_at < SESSION_IDLE_HOURS und now < expires_at.
4. Kern-Flows
- Login-Request:
POST /api/auth/request→ratelimit(email)→normalizeEmail→ user in Allowlist? → alte offene Tokens invalidieren →createLoginToken→sendMagicLink→ immer neutrale 200. - Einlösung (scanner-sicher):
GET /auth?token=→ Bestätigungsseite (kein Verbrauch).POST /auth/confirm→consumeLoginToken(Hash-Vergleich, unused, <30min,used_atsetzen) →createSession→ Cookie → 302/. - Session-Check:
authMiddlewareliest Cookie →validateSession(Hash→sessions JOIN users, expires + idle) →req.user; sonst 401 + Cookie clear.last_seen_atbump. - Migration (Startup): Fail-Fast
ADMIN_EMAIL→seedAdmin→migrateToMultiUser(adminId)(eine Transaktion) →listen. - Nutzerwechsel (Frontend):
me.email≠dienstplan_current_user(oder keiner) →clearUserData()→DataSync.boot(). - Admin entfernt Nutzer:
DELETE /api/admin/users/:id→ Last-Admin-Guard → delete user → CASCADE räumt tokens/sessions → audit.
5. Sicherheits-Architektur
Hashing (Token+Session) · httpOnly/Secure/SameSite=Lax · Einmal-Token 30min + neutrale Antwort + Rate-Limit (E-Mail primär) · scanner-sichere POST-Einlösung · Idle-Timeout · Token in Access-Logs maskieren (Caddy) · Audit-Log ohne PII · user_id nur aus Session (Anti-IDOR).
6. Technologie-Entscheidungen
- nodemailer (einzige neue Dependency) für SMTP; Konsolen-Fallback.
- Kein cookie-parser —
res.cookie()nativ, Lesen via Mini-Helfer. - Server-Sessions (widerrufbar) statt JWT.
- Rate-Limit in-memory (per-Prozess; für einen Container ok — Ceiling dokumentiert).
7. Test-Architektur
Node---test: auth.test.js (hash/consume/session/migration/last-admin/normalizeEmail/anti-IDOR), ratelimit.test.js, state.test.js (2-Nutzer-Trennung + 401/403). Browser-E2E (wie Stufe 1): kompletter Login/Trennung/Logout/Nutzerwechsel-Durchlauf mit Konsolen-Magic-Link.
8. Deployment-Architektur
Docker node:20-slim hinter Caddy, Volume dienstplan-data:/data. Neue Env: ADMIN_EMAIL, APP_BASE_URL, SESSION_TTL_DAYS=30, SESSION_IDLE_HOURS=8, RATE_LIMIT_EMAIL=5, RATE_LIMIT_WINDOW_MIN=15, RATE_LIMIT_IP=…, SMTP_*. SPF/DKIM Voraussetzung. Deploy-Guard prüft ADMIN_EMAIL vor Live.
9. Verfeinerungen (qwen-Architektur-Gate)
Übernommen:
cookie-parserstatt handgerolltem Parsing (2. Dependency nebennodemailer) — vermeidet Encoding-/Flag-Fehler. Setzen weiterhin viares.cookie().- Gedrosselter
last_seen_at-Write: nur aktualisieren, wenn älter als ~5 Min → kein Write pro Request, keine Lock-Contention. - FK-Indizes:
CREATE INDEXauflogin_tokens.user_idundsessions.user_id(schnelles CASCADE/Cleanup). - CSRF + Fixierungsschutz: Die Bestätigungs-POST (
/auth/confirm) trägt den Token aus der Seite (CSRF-Angreifer hat ihn nicht) + ein Form-CSRF-Token; die Session wird neu erst bei erfolgreichem Consume erzeugt (kein Cookie vor Confirm → keine Session-Fixation).
Bewusst akzeptiert (Team-Skala, Ceiling dokumentiert):
- better-sqlite3 synchron blockiert den Event-Loop minimal — bei einstelliger Nutzerzahl irrelevant; kein Web-Scale-Locking-Thema.
- Rate-Limit in-memory (resettet bei Restart) — bei Magic-Link ohne Passwort kaum Brute-Force-Fläche; Persistenz = YAGNI.
- Migration auf kleinem Datenbestand (hunderte Dienste) — kein OOM/Tx-Log-Thema.
- Login-Token-Race bei Parallel-Klick: neuster Link gewinnt, tolerierbar.