Auth & Self-Service — OAuth, Passkey, TOTP, Better-Auth, Employee-Self-Service
Feinkonzept · §3.9 Auth & Self-Service — OAuth, Passkey/WebAuthn, TOTP, Better-Auth, ESS
Abschnitt betitelt „Feinkonzept · §3.9 Auth & Self-Service — OAuth, Passkey/WebAuthn, TOTP, Better-Auth, ESS“Einordnung. Dieses Feinkonzept setzt FUNKTIONSUMFANG §3.9 um. Es ist die Eintrittsschleuse der Plattform: jede Zeit-Stempelung, jede Stundennachweis-Signatur, jede Datenfreigabe an DATEV hängt an einer bewiesenen Identität und an einem Tenant-Setting, das Row-Level-Security überhaupt erst wirksam macht. Wir folgen LESSONS-LEARNED §3 („JWT + bcrypt waren Schwächen“): keine handgerollten Tokens, kein Passwort-only, keine selbstgebauten Sessions — stattdessen Better Auth mit Passkeys (BSI TR-03116-4), TOTP, OAuth und einer Recovery-Strategie, die auch funktioniert, wenn der Monteur sein Handy auf der Baustelle verliert.
1. Header & Metadaten
Abschnitt betitelt „1. Header & Metadaten“feature_id: kern/09-auth-self-servicetitle: Auth & Self-Service — OAuth, Passkey, TOTP, Better-Auth, Employee-Self-Servicefunktionsumfang_ref: §3.9roadmap_horizont: MVPplattformen: mobile: vollständig # Passkey-First-Login, TOTP-Fallback, Urlaub/eAU-ESS web: vollständig # Admin-MFA-Pflicht, OAuth-Provider-Konfig, ESS-Web desktop: ab V1.5 bei Bedarfowner_rolle: Admin # MFA-Policies, OAuth-Domains, Session-TTLmodul_gate_flag: kern.identity # Kern, nicht modul-gated — ohne Auth keine Appcompliance_flags: gobd: false # Auth selbst ist nicht Buchführung, aber liefert arbzg: false # Actor-Identität für GoBD-Audit-Chain vob: false dsgvo: true # Login-Daten, biometr. Authentifikator-Hash bfsg: true # ESS für Mitarbeiter muss barrierefrei sein betrvg: true # Mitbestimmung bei „Login-Überwachung" stvg: false weitere: - "BSI TR-03116-4 (Authentifikatoren im E-Government)" - "eIDAS 910/2014 (elektronische Identifizierung, LoA substantial/high)" - "NIS2 (Art. 21: MFA-Pflicht für kritische Systeme)" - "Hinweisgeberschutzgesetz §§13–15 (anonymer Kanal, hier on-demand)"referenzkunde: status: TBD name: "zu klären — Zielprofil: Betrieb mit AD/Entra ID (Office 365) für Büro und Passkey-only für Feld" quelle: "Pattern aus TECH-STACK §5.1"estimate_eng_tage: 18 # Better Auth Integration, Passkey-Flow, TOTP, OAuth, ESS-Screens, Recoveryabhängigkeiten: - kern/11-compliance-audit # Login-/Logout-/MFA-Events fließen in Audit-Chain - kern/04-projekte-kunden-baustellen # Scope-Modell („own"/Team/Tenant) wird hier definiert - infra/rls-foundation # current_setting('app.tenant_id') wird im Session-Hook gesetzt2. Kontext & Problem
Abschnitt betitelt „2. Kontext & Problem“Marktrealität. Im Malerbetrieb bedient der 56-jährige Meister das Handy mit zwei Fingern, der 19-jährige Azubi hat TikTok schneller offen als der Büroleiter Outlook. Im SHK-Betrieb gibt es schon eine Entra-ID-Identität (Office 365 Business), die Kundendienst-Disposition läuft dort — dort ist OAuth-Single-Sign-On nicht Luxus, sondern Pflicht. Auf der Baustelle dagegen ist das Passwort-Feld ein 48 × 48 Pixel-Minenfeld mit nassen Handschuhen und auf dem Firmentablet eine geteilte Identität („Werkstatt-Tablet“), die aber rechtlich niemandem gehört. LESSONS-LEARNED §3 dokumentiert den Schmerz der Alt-App: JWT im LocalStorage (XSS-anfällig), bcrypt mit cost=10 (2025 angreifbar), eigene „Passwort vergessen“-Route mit Race-Condition-Bug, der einen Tenant-Leak ermöglichte.
Rechtlicher Rahmen. DSGVO Art. 32 fordert „Stand der Technik“, BSI TR-03116-4 benennt Passkeys und TOTP namentlich als aktuelle Authentifikator-Klassen; NIS2 schreibt MFA für „wichtige Einrichtungen“ vor — ein Handwerksbetrieb fällt zwar unterhalb der Schwelle, aber seine Abrechnungsdaten (DATEV, XRechnung an Kommunen) sind schutzwürdig. Für Mitarbeiter-Monitoring gilt BetrVG §87(1)6: Mitbestimmung bei „technischen Einrichtungen, die geeignet sind, Verhalten oder Leistung zu überwachen“ — Login-Events in einem Audit-Log sind überwachungsfähig, also brauchen wir eine dokumentierte Zweckbindung und eine Betriebsvereinbarungs-Vorlage (ausgelagert an §3.11). Für Self-Service (Urlaubsantrag, eAU-Upload, DSGVO-Art.-15-Auskunft) trifft BFSG — der ESS muss WCAG 2.2 AA erfüllen.
Schmerzpunkt der Alt-App. Die Alt-App hatte 70 Routes und eine selbstgeschriebene Session-Middleware, die auf einem fehlenden Secure-Flag hing und bei einem Phishing-Versuch (Mail „Bitte Zeit-Konto prüfen“) zwei Monate lang Cookies abgriff, bevor es jemand merkte. Die Admin-UX kannte kein Konzept „MFA-Pflicht für Rollen“; wer Admin wurde, behielt seinen 8-stelligen Kennwort-Reset vom Onboarding. Recovery war ein Zettel am Schreibtisch des Büroleiters.
Erwarteter Outcome (SMART).
- Spezifisch: Werkszeit liefert (a) OAuth-Login (Google/Microsoft) mit Tenant-Domain-Mapping, (b) Passkey/WebAuthn als Default-Faktor für Feld-User, (c) TOTP als Zweit-Faktor für Admins, (d) Recovery-Codes (8×10 Zeichen, Argon2id), (e) ESS-Flows Urlaub/eAU/Profilpflege/DSGVO-Auskunft barrierefrei, (f) Hinweisgeber-Kanal on-demand (Modul-Flag, nicht MVP-Default).
- Messbar: 95 % der Logins erfolgen per Passkey oder OAuth (Passwort-only ≤ 5 % nach 90 Tagen); durchschnittliche Login-Zeit ≤ 2,5 s; 100 % der Admin-Accounts haben mindestens 2 Faktoren; 0 % Cross-Tenant-Session (harte Test-Invariante, siehe §9).
- Achievable: Better Auth ist produktionsreif (siehe TECH-STACK §5.1) mit einer Fallback-Strategie auf Lucia-Auth falls Better-Auth-Updates stocken; Passkey-Browser-Support ist 98 % (caniuse, 2026-Q1).
- Relevant: Ohne Auth kein RLS-Setting, ohne RLS keine Tenant-Isolation, ohne Tenant-Isolation ist die gesamte Architektur unverkäuflich.
- Time-bound: MVP-Block §3.9 binnen 18 Eng-Tagen, beginnend mit Sprint-Start T+0 (Auftakt 2026-05-04).
3. Stakeholder, Rollen, Personas
Abschnitt betitelt „3. Stakeholder, Rollen, Personas“| Rolle | Persona | Primär-Screen | Pain-Point |
|---|---|---|---|
| Monteur | Hannes Krüger (38, SHK) | Mobile · Passkey-Login | „Ich will nicht jeden Morgen ein Passwort tippen.“ |
| Bauleiter | Thomas Schmidt (52, SHK) | Mobile + Web | „Auf der Baustelle kein Empfang, trotzdem einloggen.“ |
| Manager/Admin | Sabine Maier (45, Maler) | Web · MFA-Dashboard | „Wer darf was? Ich brauche Rollen-Übersicht, keine YAML.“ |
| Buchhaltung | Frau Müller (56, SHK) | Web · OAuth-SSO | „Office 365 ist mein Leben — bitte nicht zweimal Login.“ |
| Azubi | Jonas Brehm (17, Maler) | Mobile + NFC-Kiosk | „Ich hab kein Dienst-Handy.“ |
| Betriebsrat | Herr Gärtner (SHK) | Admin-Report | „Was wird in Login-Events genau protokolliert?“ |
| Auditor (DATEV) | Externe Prüfung | Web · Audit-Export | „Zeigen Sie mir lückenlose Login-Historie für QA 2025-Q4“ |
| Schaden-Bote | Hinweisgeber (anonym) | Web · Hinweis-Formular | „Ich will anonym bleiben, auch vor IT.“ |
Haupt-Conflict. Convenience (Passkey-only, SSO) ↔ Compliance (MFA-Pflicht für Admins). Lösungsweg: Rollen-basierte Policy — Monteur/Azubi Passkey reicht, Manager/Admin/Buchhaltung erzwungene zweite Klasse (TOTP oder Hardware-Security-Key).
4. Ziele & Nicht-Ziele
Abschnitt betitelt „4. Ziele & Nicht-Ziele“Ziele (MVP)
Abschnitt betitelt „Ziele (MVP)“- Passkey/WebAuthn-First für alle nativen Apps (iOS Face ID, Android BiometricPrompt, Windows Hello, macOS Touch ID).
- TOTP als zweiter Faktor für Admins (Pflicht) und alle anderen (opt-in); mindestens zwei Verfahren pro Admin-Account.
- OAuth 2.1 + PKCE mit Google Workspace und Microsoft Entra ID als Identity Provider; Tenant-Domain-Mapping (
@musterbetrieb-maler.de → Tenant T-001). - Passwort als Legacy-Faktor, Argon2id (m=64 MiB, t=3, p=4), Konfiguration: Mindestlänge 12, keine Komplexitätsregeln, Passphrase empfohlen; „haveibeenpwned“-Check per k-Anonymity-API beim Setzen.
- Session-Management via Better Auth: Cookie
SameSite=Strict,Secure,HttpOnly, TTL 15 min (rolling), Refresh-Token TTL 30 Tage, Tenant-Binding im Cookie-Claim. - Recovery-Codes 8×10 Zeichen, Argon2id-gehasht, Einmal-Verwendung, bei Verbrauch < 3 → Push-Warnung.
- Employee Self-Service (ESS): Profil-Pflege, Passwort/Passkey-Rotation, Urlaubsantrag, eAU-Upload, DSGVO-Art.-15-Self-Service-Export (JSON + PDF), Löschantrag mit GoBD-Aufklärung.
- Hinweisgeber-Kanal on-demand (Modul-Gate
modul.hinweisgeber): anonymer Zugang über separaten Reverse-Proxy-Pfad, keine Login-Pflicht, keine IP-Logs. - BFSG-Konformität: alle ESS-Flows WCAG 2.2 AA, 48 × 48 Touch-Targets, Kontrast ≥ 4.5 : 1,
aria-livefür asynchrone Flows (OAuth-Redirect). - RLS-Foundation: nach Login setzt die Auth-Middleware
SET LOCAL app.tenant_id = :tundapp.user_id = :uin jeder Transaktion; ohne diese Settings liefern alle Queries0 rows.
Nicht-Ziele (Phase 1)
Abschnitt betitelt „Nicht-Ziele (Phase 1)“- SAML 2.0 (Enterprise erst ab Tier „Ziegel“). Postponed nach V1.4.
- FIDO2 ohne Passkey (separate Sicherheitsschlüssel) — funktioniert technisch über WebAuthn, aber keine eigene UX.
- Custom IdP (z.B. Keycloak self-hosted beim Kunden) — kein MVP-Szenario.
- SCIM 2.0 User-Provisioning aus Entra — Nachrüstbar, aber Phase-2.
- Anonyme Hinweisgeber-Chat-Konversation (nur Einweg-Einreichung im MVP; Dialog via Ticket-Nummer + neu generiertem Code).
5. User Stories + Akzeptanzkriterien
Abschnitt betitelt „5. User Stories + Akzeptanzkriterien“US-9.1 · Passkey-First-Login (Monteur)
Abschnitt betitelt „US-9.1 · Passkey-First-Login (Monteur)“Als Monteur Hannes K. möchte ich mich auf meinem iPhone 13 per Face ID in Werkszeit einloggen, damit ich in unter 2 Sekunden stempeln kann, ohne Passwort zu tippen.
Akzeptanz.
- App startet → „Mit Face ID fortfahren“ ist Default-CTA, Passwort nur unter „weitere Optionen“.
- Passkey wird nach Erst-Login als Device-bound Credential via
navigator.credentials.create({publicKey})erzeugt und an Server-Side Registration gebunden. - Wiederholungs-Login < 2 s (von Tap bis Tab „Zeit“).
- Bei Passkey-Fehler (z.B. Biometrie dreimal fehlgeschlagen) Fallback auf Passwort + TOTP in demselben Flow, keine Navigation.
US-9.2 · OAuth-SSO (Buchhaltung)
Abschnitt betitelt „US-9.2 · OAuth-SSO (Buchhaltung)“Als Frau Müller möchte ich mich mit meinem Office-365-Konto (
[email protected]) bei Werkszeit anmelden, damit ich kein zweites Passwort führen muss.
Akzeptanz.
- Eingabe der E-Mail-Adresse auf Login-Screen → System erkennt Domain
@shk-gebruder-schmidt.de→ Entra-ID-Redirect (PKCE). - Callback prüft
hd-Claim (Hosted-Domain) undemail_verified; bei Mismatch Abbruch mit Fehlercode AUTH-403. - Beim ersten SSO-Login wird lokaler User automatisch angelegt (JIT-Provisioning) mit Rolle aus Entra-Gruppe
werkszeit-rolle-buchhaltung(Mapping in Admin-Settings). - Re-Auth jede 12 h (rolling), SSO-Silent-Refresh wenn Entra-Session gültig.
US-9.3 · Admin-MFA-Pflicht (Manager)
Abschnitt betitelt „US-9.3 · Admin-MFA-Pflicht (Manager)“Als Manager Sabine M. möchte ich, dass meine Admin-Rolle zwingend zwei Faktoren erfordert, damit niemand mit gestohlenem Passwort Lohndaten exportiert.
Akzeptanz.
- Beim Rollen-Upgrade „Manager“ → „Admin“ wird TOTP-Enrollment erzwungen (kein Skip möglich).
- Ohne aktiven 2. Faktor ist die Rolle administrativ „inactive“; Scope wird auf „Manager“ gedowngradet.
- Admin-Settings-Screen zeigt MFA-Coverage („14 von 14 Admins abgedeckt ✓“) als KPI.
US-9.4 · Recovery bei Geräteverlust (Monteur)
Abschnitt betitelt „US-9.4 · Recovery bei Geräteverlust (Monteur)“Als Monteur, der sein Handy auf der Baustelle verloren hat, möchte ich mich mit Recovery-Code + Passwort wieder einloggen, damit ich am nächsten Morgen wieder stempeln kann.
Akzeptanz.
- Am Ersatzgerät: „Ich habe keinen Passkey mehr“ → E-Mail-Verifikation + Recovery-Code-Prompt.
- Alte Passkey-Credentials werden serverseitig zurückgezogen (
passkeys.revoked_atgesetzt; der Grund „device_loss“ ist konzeptionell — diepasskeys-Tabelle hat keine eigene reason-Spalte, ein Grund-Enum existiert nur beimfa_factors) — die Revocation selbst ist nicht Teil der BetrVG-Whitelist aus ADR-0003 und landet nicht als eigenerlogin_events-Eintrag (siehe Grenzfall 2 unten). - Neuer Passkey wird am Ersatzgerät registriert.
- Admin erhält E-Mail-Benachrichtigung (BetrVG §87(1)6: nur Event, kein Klartext).
US-9.5 · ESS · Urlaubsantrag (Mitarbeiter, BFSG-konform)
Abschnitt betitelt „US-9.5 · ESS · Urlaubsantrag (Mitarbeiter, BFSG-konform)“Als Mitarbeiter möchte ich Urlaub für 24.12.–05.01.2027 beantragen, damit mein Manager freigeben und der Kalender konsistent ist.
Akzeptanz.
- Formular mit Tastatur-only bedienbar,
aria-requiredan Pflichtfeldern, Fehlermeldungenrole="alert". - Vertretungsregelung: Kollege-Auswahl mit Typeahead, Default-Vorschlag aus Team.
- Bestätigungs-Screen zeigt Resturlaub-Saldo nach Antrag (rechnerisch, noch nicht genehmigt).
US-9.6 · DSGVO Art. 15 Self-Service (Mitarbeiter)
Abschnitt betitelt „US-9.6 · DSGVO Art. 15 Self-Service (Mitarbeiter)“Als Mitarbeiter möchte ich meine Daten als JSON + PDF exportieren können, damit ich Art. 15 DSGVO ohne E-Mail-Dialog nutzen kann.
Akzeptanz.
- Ein Tap/Klick startet asynchronen Job, E-Mail-Link nach 5 min mit Download (S3 Presigned URL, 24 h TTL, EU-Bucket).
- Inhalt: alle User-Stamm-, Zeit-, Urlaub-, eAU-, Login-Event-Daten der letzten 3 Jahre.
- PDF-Index + JSON-Schema dokumentieren; Schema-Version in Dateinamen.
6. Scope-Matrix (Mobile / Web / Desktop)
Abschnitt betitelt „6. Scope-Matrix (Mobile / Web / Desktop)“| Feature | Mobile | Web | Desktop (V1.5) |
|---|---|---|---|
| Passkey-Registrierung | ✓ (Biometrie) | ✓ (Windows Hello / Touch ID) | ✓ |
| TOTP-Enrollment | ✓ (QR scannen) | ✓ (QR anzeigen) | ✓ |
| OAuth-Login (Google/Microsoft) | ✓ (ASWebAuthSession / Custom Tab) | ✓ | — |
| Passwort-Fallback | ✓ (versteckt) | ✓ (versteckt) | — |
| Recovery-Codes generieren/anzeigen | Nur anzeigen | Generieren (Admin) + anzeigen | — |
| ESS · Profil-Pflege | Stammdaten | Vollformular | — |
| ESS · Urlaubsantrag | ✓ | ✓ | — |
| ESS · eAU-Upload | ✓ (Kamera) | ✓ (Drag & Drop) | — |
| ESS · DSGVO-Art.-15-Export | „Starten“-Button | „Starten“+Download | — |
| Admin · MFA-Policies | — | ✓ ausschließlich | — |
| Admin · OAuth-Provider-Konfig | — | ✓ ausschließlich | — |
| Hinweisgeber-Kanal (Modul) | Link in Footer | Eigener Pfad /hinweis |
— |
| NFC-Kiosk-Sonder-Login | ✓ (Ausweis-Tag) | — | — |
7. UX / UI-Spezifikation
Abschnitt betitelt „7. UX / UI-Spezifikation“Designsystem. Ausschließlich wz-*-Klassen aus assets/werkszeit-design-system.css. Keine Inline-Tailwind, keine externen Komponenten-Libs (außer FontAwesome-Glyphen in <svg>-Form inline).
ASCII-Wireframes.
7.1 Mobile · Passkey-First-Login (Cold-Start)
Abschnitt betitelt „7.1 Mobile · Passkey-First-Login (Cold-Start)“┌────────────────────────────────┐ ← 36 chars breit, Mobile-Frame│ 📱 9:12 5G ●●●● 87%│├────────────────────────────────┤│ W ││ Werkszeit ││ Für Handwerk & Bau ││ ││ ┌──────────────────────────┐ ││ │ 🔐 Mit Face ID anmelden│ │ ← Primary-CTA, 56px hoch│ └──────────────────────────┘ ││ ││ ┌──────────────────────────┐ ││ │ 🗝 Mit Google fortfahren│ │ ← OAuth│ └──────────────────────────┘ ││ ┌──────────────────────────┐ ││ │ Ⓜ Microsoft-Konto │ ││ └──────────────────────────┘ ││ ││ — weitere Optionen — ││ [ E-Mail + Passwort ] ← Text ││ ││ ‹ Hilfe · Impressum › │├────────────────────────────────┤│ 🛡 GoBD · DSGVO · BFSG │└────────────────────────────────┘7.2 Mobile · TOTP-Enrollment (Admin erstes Login)
Abschnitt betitelt „7.2 Mobile · TOTP-Enrollment (Admin erstes Login)“┌────────────────────────────────┐│ ← Admin-Anmeldung sichern │├────────────────────────────────┤│ Schritt 2 von 3 ││ ▓▓▓▓░░░░░ ││ ││ Zweite Sicherheit einrichten ││ Pflicht für Admins (NIS2/BSI) ││ ││ 1. Authenticator-App öffnen ││ (Google Auth · 1Password) ││ ││ ┌──────────────────────────┐ ││ │ ████ QR-Code ████ │ ││ │ ████ ████ │ ││ │ ████ ████ │ ││ │ ████ ████████ ████ │ ││ └──────────────────────────┘ ││ ││ Schlüssel manuell: ││ JBSW Y3DP EHPK 3PXP ││ ││ 2. Code eingeben: ││ ┌────┬────┬────┬────┬────┬────┐││ │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │││ └────┴────┴────┴────┴────┴────┘││ ││ ┌──────────────────────────┐ ││ │ Weiter │ ││ └──────────────────────────┘ │└────────────────────────────────┘7.3 Mobile · Recovery-Codes anzeigen (nach Enrollment)
Abschnitt betitelt „7.3 Mobile · Recovery-Codes anzeigen (nach Enrollment)“┌────────────────────────────────┐│ ← Recovery-Codes · 1 von 1 │├────────────────────────────────┤│ Notfall-Zugänge ││ Drucken · speichern · sicher ││ aufbewahren. Jeder Code nur ││ einmal nutzbar. ││ ││ ┌──────────────────────────┐ ││ │ 1. 7K3L-M8NQ-2WYX │ ││ │ 2. 9HRZ-F4PD-TBC1 │ ││ │ 3. 5GME-X9VQ-LD8N │ ││ │ 4. 3CPB-KR7W-H2YF │ ││ │ 5. 8DXA-J4NT-QMP9 │ ││ │ 6. 2SYK-W3LG-V7BQ │ ││ │ 7. 6FRD-T8KP-X4ML │ ││ │ 8. 4BGN-QH9C-PJV2 │ ││ └──────────────────────────┘ ││ ││ ⓘ Argon2id-gehasht (Server). ││ Klartext nur hier einmalig. ││ ││ [ ⤓ PDF ] [ ✉ Als Mail ] ││ [ ✓ Ich hab sie notiert ] │└────────────────────────────────┘7.4 Web · Admin · MFA-Policies (Manager-Screen ~80 chars)
Abschnitt betitelt „7.4 Web · Admin · MFA-Policies (Manager-Screen ~80 chars)“┌──────────────────────────────────────────────────────────────────────────────┐│ Werkszeit · musterbetrieb-maler Sabine M. ▾ │├────────────┬─────────────────────────────────────────────────────────────────┤│ Stamm │ 🛡 GoBD · DSGVO · BFSG · NIS2 ││ Zeit │ Identität & Zugang / MFA-Policies ││ Finanz │ ││ System │ ┌───────────────────────────────────────────────────────────┐ ││ · Audit │ │ KPI-Row │ ││ · Auth ← │ │ 14/14 2/26 92% 0 1 offen │ ││ · Einstell│ │ Admins Passwd Passkey Locked Recovery-Codes <3 │ ││ │ │ MFA ✓ only Adopt. Acc. (Hannes K.) │ ││ │ └───────────────────────────────────────────────────────────┘ ││ │ ││ │ Rolle │ MFA-Pflicht │ Methoden │ Grace-TTL ││ │ Admin │ Ja (Hart) │ TOTP + Passkey │ 0 Tage ││ │ Manager │ Ja │ TOTP o. Passkey │ 7 Tage ││ │ Bauleiter │ Empfohlen │ Passkey Default │ 30 Tage ││ │ Monteur │ Optional │ Passkey │ — ││ │ Azubi │ Optional │ Passkey / NFC-Kiosk│ — ││ │ Buchhaltung │ Ja │ SSO + TOTP-Backup │ 0 Tage ││ │ ││ │ OAuth-Provider: [Google ✓ wsp.musterbetrieb-maler.de] ││ │ [Microsoft ✓ musterbetrieb-maler.de] ││ │ [+ Provider hinzufügen] ││ │ ││ │ [ ⤓ Report · MFA-Coverage ] [ Policy übernehmen ] │└────────────┴─────────────────────────────────────────────────────────────────┘7.5 Web · ESS · DSGVO-Art.-15-Selfservice (Mitarbeiter-Profil)
Abschnitt betitelt „7.5 Web · ESS · DSGVO-Art.-15-Selfservice (Mitarbeiter-Profil)“┌──────────────────────────────────────────────────────────────────────────────┐│ Werkszeit · Mein Konto Hannes K. ▾ │├──────────────────────────────────────────────────────────────────────────────┤│ Profil · Sicherheit · Abwesenheiten · eAU · Daten exportieren ││ ││ Meine Daten (DSGVO Art. 15, 20) ││ ││ Sie haben das Recht, eine Kopie Ihrer bei Werkszeit gespeicherten ││ personenbezogenen Daten zu erhalten. Der Export enthält: ││ ││ ● Stammdaten (Name, Rolle, Gerätezuordnung) ││ ● Zeiterfassungen der letzten 3 Jahre (~ 750 Datensätze) ││ ● Urlaubsanträge, Krankmeldungen (eAU) ││ ● Login-Events (Datum, Gerät — keine IP nach 30 d) ││ ││ NICHT enthalten: ││ ● Stundennachweise gegenüber Kunden (gehören dem Tenant, §147 AO) ││ ● Audit-Log-Einträge über Dritt-Aktionen ││ ││ Format: [ ⦿ JSON + PDF-Index ○ Nur PDF ○ Nur JSON ] ││ Zeitraum: [ Letzte 3 Jahre ▾ ] ││ ││ [ Export anfordern → ] ││ ││ Letzter Export: 2025-11-14, 14:08 Uhr · Download-Link 24 h gültig │└──────────────────────────────────────────────────────────────────────────────┘7.6 Mobile · Hinweisgeber-Einreichung (on-demand Modul, anonymer Kanal)
Abschnitt betitelt „7.6 Mobile · Hinweisgeber-Einreichung (on-demand Modul, anonymer Kanal)“┌────────────────────────────────┐│ ← Hinweis einreichen │├────────────────────────────────┤│ 🕊 Anonymer Kanal ││ Keine Anmeldung · Keine IP ││ ││ Nach §§13–15 HinSchG. ││ ││ Betrifft: ││ [ Arbeitsschutz ▾ ] ││ ││ Ihre Beobachtung (2000 Z.): ││ ┌──────────────────────────┐ ││ │ │ ││ │ │ ││ │ │ ││ └──────────────────────────┘ ││ ││ Anhang (optional, max 3): ││ [ + Foto ] [ + PDF ] ││ ││ ⓘ Anhänge werden EXIF-bereinigt││ und Rück-identifikation ││ unterbunden. ││ ││ ┌──────────────────────────┐ ││ │ Einreichen │ ││ └──────────────────────────┘ ││ ││ Sie erhalten ein Token zur ││ anonymen Rück-Kommunikation. │└────────────────────────────────┘Interaktionsregeln.
- Alle Buttons haben
min-height: 48px(Token--wz-touch), auch unter Arbeitshandschuh bedienbar. - Kontrast Text auf Hintergrund ≥ 4.5 : 1 (WCAG 2.2 AA); Fokus-Ring
outline: 2px solid var(--wz-stahl); outline-offset: 2px;. - OAuth-Redirect nutzt
ASWebAuthenticationSession(iOS) und Custom Tabs (Android) — niemals<WebView>(CVE-Anfälligkeit). - Passkey-Fallback-Reihenfolge: Passkey → OAuth → Passwort+TOTP → Recovery-Code. Jeder Schritt erzeugt eigenen Audit-Event.
aria-live="polite"für OAuth-Redirect-Status („Mit Microsoft authentifizieren…“).
8. Datenmodell & RLS-Policies
Abschnitt betitelt „8. Datenmodell & RLS-Policies“Auth-Tabellen (Better Auth Erweiterung). Drizzle-Pseudo-Schema in TypeScript-Notation:
export const usersTable = pgTable('users', { id: uuid('id').primaryKey().defaultRandom(), // UUID v7 tenantId: uuid('tenant_id').notNull().references(() => tenantsTable.id), email: text('email').notNull(), emailVerifiedAt: timestamp('email_verified_at'), displayName: text('display_name').notNull(), role: text('role', { enum: ['admin','manager','bauleiter','buchhaltung','monteur','azubi'] }).notNull(), status: text('status', { enum: ['active','locked','disabled'] }).notNull().default('active'), passwordHash: text('password_hash'), // Argon2id, optional (OAuth-only → null) passwordSetAt: timestamp('password_set_at'), mfaRequired: boolean('mfa_required').notNull().default(false), deletedAt: timestamp('deleted_at'), // Soft-Delete — GoBD-Retention redactedAt: timestamp('redacted_at'), // DSGVO Art. 17 Maskierung createdAt: timestamp('created_at').notNull().defaultNow(), hashPrev: bytea('hash_prev'), // Append-only Chain hashSelf: bytea('hash_self').notNull(),}, (t) => ({ uqTenantEmail: uniqueIndex('users_tenant_email_uq').on(t.tenantId, t.email),}));
export const sessionsTable = pgTable('sessions', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').notNull().references(() => usersTable.id), tenantId: uuid('tenant_id').notNull(), deviceName: text('device_name'), // „iPhone 13 Hannes" userAgentHash: bytea('user_agent_hash').notNull(), // SHA-256, keine rohen UA-Strings createdAt: timestamp('created_at').notNull().defaultNow(), lastSeenAt: timestamp('last_seen_at').notNull().defaultNow(), expiresAt: timestamp('expires_at').notNull(), revokedAt: timestamp('revoked_at'), revokeReason: text('revoke_reason'),});
export const passkeysTable = pgTable('passkeys', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').notNull().references(() => usersTable.id), credentialId: bytea('credential_id').notNull().unique(), publicKey: bytea('public_key').notNull(), signCounter: bigint('sign_counter', { mode: 'number' }).notNull().default(0), transports: text('transports').array(), // ['internal','hybrid','usb'] deviceLabel: text('device_label'), createdAt: timestamp('created_at').notNull().defaultNow(), lastUsedAt: timestamp('last_used_at'), revokedAt: timestamp('revoked_at'),});
export const totpSecretsTable = pgTable('totp_secrets', { userId: uuid('user_id').primaryKey().references(() => usersTable.id), secretEnc: bytea('secret_enc').notNull(), // AES-256-GCM, Key in KMS issuer: text('issuer').notNull().default('Werkszeit'), algo: text('algo').notNull().default('SHA1'), // RFC 6238 digits: smallint('digits').notNull().default(6), period: smallint('period').notNull().default(30), createdAt: timestamp('created_at').notNull().defaultNow(),});
export const recoveryCodesTable = pgTable('recovery_codes', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').notNull().references(() => usersTable.id), codeHash: text('code_hash').notNull(), // Argon2id, not in scope of DB text-search usedAt: timestamp('used_at'), createdAt: timestamp('created_at').notNull().defaultNow(),});
export const oauthIdentitiesTable = pgTable('oauth_identities', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').notNull().references(() => usersTable.id), provider: text('provider', { enum: ['google','microsoft'] }).notNull(), subject: text('subject').notNull(), // stable subject-id domain: text('domain'), // hd-claim for Google createdAt: timestamp('created_at').notNull().defaultNow(),}, (t) => ({ uqProviderSubject: uniqueIndex('oauth_prov_sub_uq').on(t.provider, t.subject),}));
export const loginEventsTable = pgTable('login_events', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').references(() => usersTable.id), // nullable für Fehl-Login tenantId: uuid('tenant_id'), emailAttempted: text('email_attempted'), // redactable // BetrVG §87 (1) Nr. 6 Whitelist (ADR-0003), Stand nach #758-Enum-Bereinigung — // deckungsgleich mit dem 0002-CHECK-Constraint `login_events_event_type_whitelist`. eventType: text('event_type', { enum: [ 'login.succeeded','login.failed', 'passkey.registered','passkey.used', 'totp.enrolled','totp.used', 'recovery.used','mfa.enforced'] }).notNull(), factor: text('factor'), // 'passkey'|'totp'|'password'|'oauth.google'|... ipRedacted: text('ip_redacted'), // /24 für v4, /48 für v6 — nach 30 Tagen NULL deviceName: text('device_name'), occurredAt: timestamp('occurred_at').notNull().defaultNow(), hashPrev: bytea('hash_prev'), hashSelf: bytea('hash_self').notNull(),});RLS-Policies (PostgreSQL 17). Die Auth-Middleware setzt nach erfolgreichem Session-Resume:
SET LOCAL app.tenant_id = '018e9c4f-...'; -- UUID v7SET LOCAL app.user_id = 'b3a1...';SET LOCAL app.role = 'monteur';SET LOCAL app.session_id = 'ff01...';Pflicht-Policies (Ausschnitte):
ALTER TABLE users ENABLE ROW LEVEL SECURITY;ALTER TABLE users FORCE ROW LEVEL SECURITY; -- auch für Owner
-- (a) Tenant-Hopping unmöglich machenCREATE POLICY users_tenant_isolation ON users USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- (b) Jeder sieht sich selbst, Manager sieht Team, Admin sieht TenantCREATE POLICY users_scope_read ON users FOR SELECT USING ( id = current_setting('app.user_id')::uuid OR (current_setting('app.role') IN ('manager','admin') AND tenant_id = current_setting('app.tenant_id')::uuid) );
-- (c) Profilpflege: nur self oder adminCREATE POLICY users_self_update ON users FOR UPDATE USING ( id = current_setting('app.user_id')::uuid OR current_setting('app.role') = 'admin' );
-- (d) Sessions: nur Owner + Admin sehenALTER TABLE sessions ENABLE ROW LEVEL SECURITY;ALTER TABLE sessions FORCE ROW LEVEL SECURITY;CREATE POLICY sessions_owner ON sessions USING ( tenant_id = current_setting('app.tenant_id')::uuid AND (user_id = current_setting('app.user_id')::uuid OR current_setting('app.role') = 'admin') );
-- (e) Login-Events: User sieht sich, Admin sieht Tenant, niemand sieht fremde TenantsALTER TABLE login_events ENABLE ROW LEVEL SECURITY;ALTER TABLE login_events FORCE ROW LEVEL SECURITY;CREATE POLICY login_events_scope ON login_events FOR SELECT USING ( tenant_id = current_setting('app.tenant_id')::uuid AND (user_id = current_setting('app.user_id')::uuid OR current_setting('app.role') = 'admin') );
-- (f) Insert immer append-onlyCREATE POLICY login_events_insert ON login_events FOR INSERT WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);REVOKE UPDATE, DELETE ON login_events FROM PUBLIC; -- Chain-IntegritätTenant-Binding-Hook. Jeder Hono-Request durchläuft:
- Cookie-Parse (Better Auth Session-Cookie,
SameSite=Strict). - Session-Lookup in Redis (Cache) / Postgres (Wahrheit).
BEGIN; SET LOCAL app.*; …; COMMIT;— ohne erfolgreichesSET LOCALbricht die Transaktion ab.- Fehlendes
app.tenant_id→ Query liefert 0 Rows (RLS USING= NULList FALSE).
Hashchain. hash_self = sha256(hash_prev || canonical_json(row_without_hashes)). Erzeugung per Postgres-Trigger (pg_crypto) + ed25519-Signatur-Job (offline) — Details in §3.11.
9. API & Verträge
Abschnitt betitelt „9. API & Verträge“OpenAPI-Fragmente (Auszug, URIs relativ /v1):
| Method | Path | Payload (req) | Response (200/201) | Fehler (4xx/5xx) |
|---|---|---|---|---|
| POST | /auth/login/password |
{email, password} |
{requiresMfa:bool, challenge?} |
401 AUTH-INVALID, 429 AUTH-THROTTLE |
| POST | /auth/login/passkey/begin |
{email?} |
{challenge, rpId, allowCredentials} |
404 AUTH-USER-UNKNOWN (nur bei bekanntem Tenant) |
| POST | /auth/login/passkey/finish |
WebAuthn-Assertion | {sessionToken, user} |
401 AUTH-PASSKEY-INVALID |
| POST | /auth/login/oauth/start |
{provider, redirectUri} |
{authUrl, state, codeChallenge} |
400 AUTH-UNKNOWN-PROVIDER |
| POST | /auth/login/oauth/callback |
{code, state} |
{sessionToken, user, isJit} |
403 AUTH-DOMAIN-MISMATCH |
| POST | /auth/mfa/totp/verify |
{code} |
{sessionToken} |
401 AUTH-MFA-INVALID |
| POST | /auth/mfa/totp/enroll/begin |
— | {secret, otpauthUri, backupCodes} |
409 AUTH-TOTP-ALREADY |
| POST | /auth/mfa/totp/enroll/finish |
{code} |
{enrolled:true} |
401 AUTH-MFA-INVALID |
| POST | /auth/recovery/use |
{code} |
{sessionToken, remaining:number} |
401 AUTH-RECOVERY-INVALID |
| POST | /auth/logout |
— | 204 | — |
| GET | /auth/me |
— | {user, sessions[], passkeys[], mfa} |
401 |
| POST | /auth/passkey/register/begin |
— | {challenge, rpId, user} |
401 |
| POST | /auth/passkey/register/finish |
WebAuthn-Attestation | {passkey} |
400 AUTH-PASSKEY-ATTEST-INVALID |
| DELETE | /auth/passkey/{id} |
— | 204 | 403 AUTH-FORBIDDEN |
| POST | /ess/vacation-requests |
{from, to, substituteUserId, note} |
{id, status:'pending'} |
409 ESS-CONFLICT (Überlappung) |
| POST | /ess/eau |
multipart/form-data (PDF + Meta) |
{id, status:'uploaded'} |
413 FILE-TOO-LARGE |
| POST | /ess/data-export |
{format:'json+pdf', range} |
{jobId, estimatedAt} |
429 ESS-EXPORT-THROTTLE |
| POST | /ess/deletion-request |
{reason} |
{id, effectiveAt, retained:string[]} |
400 ESS-DELETION-BLOCKED-GOBD |
| POST | /hinweis/submit (anonym) |
{category, text, attachments[]} |
{ticket, replyToken} |
429 HINWEIS-RATE-LIMIT |
Idempotenz. Alle mutierenden Endpunkte akzeptieren Header Idempotency-Key: <UUID v7>, deduplizieren 24 h per Redis.
Rate-Limits.
/auth/login/*: 5/min pro Source-IP+Email-Kombo, dann exponentielles Backoff./auth/mfa/*: 10/min pro User, danach 15 min Cool-down./hinweis/submit: 3/h pro Source-IP.
Fehler-Format (RFC 9457 Problem Details):
{ "type": "https://werkszeit.de/errors/auth-mfa-invalid", "title": "MFA ungültig", "status": 401, "code": "AUTH-MFA-INVALID", "hint": "Bitte neu generieren", "retryAfter": 30 }Cross-Tenant-Test-Invariante (Pflicht). Jeder Integration-Test öffnet zwei Tenants (musterbetrieb-maler, shk-gebruder-schmidt), authentifiziert einen User von A und versucht jeden Endpunkt mit einer ID von B — Erwartung 404, niemals 200/403 (404 offenbart keine Existenz).
10. Gherkin — Happy Path & Grenzfälle
Abschnitt betitelt „10. Gherkin — Happy Path & Grenzfälle“Happy Path · US-9.1 Passkey-First
Abschnitt betitelt „Happy Path · US-9.1 Passkey-First“Funktionalität: Passkey-Login auf iOS Szenario: Hannes K. startet morgens stempeln Gegeben Hannes K. hat auf iPhone 13 einen Werkszeit-Passkey registriert (credentialId c8...) Und der Tenant "musterbetrieb-maler" ist aktiv Wenn Hannes die App startet Und auf "Mit Face ID anmelden" tippt Und Face ID erfolgreich den Private-Key freigibt Dann sendet der Client eine WebAuthn-Assertion an POST /v1/auth/login/passkey/finish Und der Server verifiziert Signatur, counter-Monotonie, rpId, origin Und erstellt eine Session mit TTL 15 min (rolling 30 Tage) Und setzt Cookie "wz_sid" mit SameSite=Strict, Secure, HttpOnly Und schreibt login_events.login.succeeded mit factor=passkey, hash_prev/hash_self verkettet Und die App lädt den Stempel-Screen in < 2 sGrenzfall 1 · Tenant-Hopping-Versuch
Abschnitt betitelt „Grenzfall 1 · Tenant-Hopping-Versuch“ Szenario: User von Tenant A versucht Session-Token für Tenant B Gegeben Hannes K. gehört Tenant "musterbetrieb-maler" (T-001) Und besitzt eine gültige Session s1 mit tenant_id=T-001 Wenn Hannes einen gekaperten User-Identifier u9 aus Tenant "shk-gebruder-schmidt" (T-002) auf GET /v1/users/u9 ausprobiert Dann liefert das API 404 Not Found Und kein login_events-Eintrag mit Klartext-User-ID wird erzeugt Und ein Audit-Event "suspicious.cross_tenant_probe" wird geschrieben Und nach 3 solchen Events innerhalb von 10 min wird Session s1 revokedGrenzfall 2 · Passkey abgelaufen / Gerät verloren
Abschnitt betitelt „Grenzfall 2 · Passkey abgelaufen / Gerät verloren“ Szenario: Hannes verliert sein Handy auf der Baustelle, Ersatzgerät Gegeben Hannes' Passkey ist auf iPhone 13 (serverseitig aktiv, lastUsedAt Fr 17.04.) Und Hannes hat 8 Recovery-Codes am 04.03.2026 gedruckt, 1 davon verbraucht Wenn Hannes am Ersatz-iPhone am Mo 19.04.2026 09:15 die App startet Und "Ich habe keinen Passkey mehr" tippt Und seine E-Mail eingibt Dann sendet der Server eine Magic-Link an hannes@... (15 min TTL) Und nach Magic-Link-Klick wird er nach einem Recovery-Code gefragt Und nach gültigem Code wird der alte Passkey als revoked markiert (reason=device_loss) Und der Client startet WebAuthn-Registration auf dem neuen Gerät Und login_events.passkey.registered wird für den neuen Passkey geschrieben (die Revocation selbst ist nicht Teil der BetrVG-Whitelist aus ADR-0003 und landet nicht als eigener login_events-Eintrag) Und der Manager erhält eine Info-Mail (kein Klartext zum Vorfall, nur "Gerätetausch")Grenzfall 3 · OAuth-Domain-Mismatch
Abschnitt betitelt „Grenzfall 3 · OAuth-Domain-Mismatch“ Szenario: Frau Müller versucht mit privatem Gmail-Konto bei SHK-Tenant einzuloggen Gegeben Tenant "shk-gebruder-schmidt" erlaubt nur Microsoft-OAuth mit Domain "shk-gebruder-schmidt.de" Dann prüft der Server den hd-Claim (fehlt bei Gmail-Privat) Und antwortet 403 AUTH-DOMAIN-MISMATCH mit Problem-Details Und zeigt im UI einen erklärenden Hinweis („Nur Firmen-Konto zulässig") Und kein User wird JIT-provisioniert Und ein login_events.login.failed-Eintrag mit reason="oauth_domain_mismatch" und maskiertem Email-Prefix wird geschriebenGrenzfall 4 · Admin ohne MFA wird gedowngradet
Abschnitt betitelt „Grenzfall 4 · Admin ohne MFA wird gedowngradet“ Szenario: Legacy-Admin ohne TOTP versucht Lohn-Export Gegeben Ein Admin-Account "oldadmin@..." hat noch kein TOTP enrolled (Migration) Wenn der Admin auf /v1/finance/payroll-export zugreift Dann prüft Auth-Middleware mfaRequired und findet kein aktives totp_secrets Und degradiert die Request-Rolle auf "manager" für dieser Session Und protokolliert den Vorfall (Zukunfts-Spec — `/v1/finance/payroll-export` existiert noch nicht im Code; ein request-zeitiges MFA-Gate ist kein Login-Vorgang und passt nicht in die login_events-Whitelist aus ADR-0003. Event-Typ/-Ziel bei Umsetzung neu entscheiden, ggf. Delta-ADR falls doch login_events verwendet werden soll) Und liefert 403 AUTH-MFA-REQUIRED mit einem hint zum Enrollment-Link Und zeigt im UI den TOTP-Enrollment-Screen als BlockerGrenzfall 5 · Brute-Force gegen Passwort-Login
Abschnitt betitelt „Grenzfall 5 · Brute-Force gegen Passwort-Login“ Szenario: 20 fehlgeschlagene Passwort-Versuche innerhalb 3 Minuten Gegeben Ein Angreifer probiert hannes@... mit 20 Passwörtern Wenn der 6. Versuch erfolgt Dann antwortet der Server 429 AUTH-THROTTLE mit Retry-After: 120 Und nach 10 weiteren Fehlversuchen wird users.status = 'locked' gesetzt Und ein login_events.login.failed-Event mit reason="account_locked" wird geschrieben Und der User erhält eine Mail mit Entsperr-Link (Token einmalig, 24 h TTL) Und eine 2. Mail an den Admin ("Account-Sperre hannes@…") Und keine Timing-Seitenkanal-Information wird preisgegeben (fixed 600ms response)11. Compliance-Matrix
Abschnitt betitelt „11. Compliance-Matrix“| Norm / § | Maßnahme |
|---|---|
| DSGVO Art. 5 (Datenminimierung) | IP nur /24 bzw. /48 truncated; nach 30 Tagen NULL |
| DSGVO Art. 6 (Rechtsgrundlage) | Auth-Events = Art. 6(1)(b) Vertragserfüllung; ESS = (1)(a)/(b) |
| DSGVO Art. 17 (Löschung) | Soft-Delete users.redactedAt + Klartext-Überschreibung; Login-Events anonymized |
| DSGVO Art. 20 (Portabilität) | ESS-Export JSON+PDF, Schema versioniert |
| DSGVO Art. 25 (PbD) | Passkey-First = kein Klartext-Passwort im Normalflow |
| DSGVO Art. 30 (Verz.-Verarb.) | Generierter Verfahrensdoku-Baustein (aus §3.11 erzeugt) |
| DSGVO Art. 32 (Stand d. Technik) | Argon2id, WebAuthn, TLS 1.3, Cookie-Flags; BSI TR-03116-4-konform |
| BSI TR-03116-4 | Passkeys (Klasse: FIDO2 mit Resident-Key), TOTP (RFC 6238) |
| eIDAS 910/2014 | LoA substantial via Passkey+Gerätebindung; LoA high via Hardware-Security-Key |
| NIS2 Art. 21 | MFA-Pflicht für Admin-Rollen (hart erzwungen) |
| BetrVG §87(1)6 | Login-Events als „Verhaltens-Überwachung“ → BV-Vorlage liegt bei, siehe §3.11 |
| BFSG + BITV | ESS-Screens WCAG 2.2 AA, kein Blocker |
| HinSchG §§13–15 | Hinweisgeber-Pfad ohne Auth, ohne IP, EXIF-Stripping |
| §147 AO | Auth-Logs gehören zur Verfahrensdoku, 10 Jahre S3 Object Lock |
12. Sicherheits- & Bedrohungsmodell
Abschnitt betitelt „12. Sicherheits- & Bedrohungsmodell“STRIDE-Matrix (Auszug).
| Bedrohung | Vektor | Mitigation |
|---|---|---|
| Spoofing · Phishing | Fake-Login-Seite | Passkey-Origin-Binding, OAuth-Pro-Tenant-Allowlist, DMARC/DKIM |
| Tampering · Session-Hijack | Cookie-Theft via XSS | HttpOnly, SameSite=Strict, CSP default-src 'self', keine Inline-Scripts |
| Repudiation · „War ich nicht“ | Nachträgliche Abstreitbarkeit | Hash-Chain der login_events + ed25519 täglich + S3 Object Lock |
| Information Disclosure · Tenant-Leak | WHERE-Bug |
RLS FORCE + 404 statt 403 + Cross-Tenant-Test-Invariante |
| Denial of Service · Credential Stuffing | Botnet auf Login | CAPTCHA nach 3 Fehlern, Rate-Limit, IP-Reputation |
| Elevation of Privilege · Role Grab | Manipulation der Session-Claim | Server-seitige Rollen-Prüfung pro Request, Session-Claims sind Cache, Wahrheit in DB |
Krypto-Agilität. Passwort-Hashing via PHC-String ($argon2id$v=19$m=65536,t=3,p=4$...) — Parameter im String, Upgrade beim nächsten Login. TOTP-Secrets in KMS-wrapped AES-256-GCM, Rotation Key-Material jährlich (inklusive Re-Encrypt). ed25519-Signaturen für Hash-Chain; Key-Rotation dokumentiert in §3.11.
Fallback-Plan. Wenn Better Auth binnen 60 Tagen keine kritische Security-Response liefert, Migration auf Lucia-Auth ([email protected]) — Schema kompatibel gehalten (eigene Tables, kein Vendor-Lock-in). Ohne kritisches Event: Better Auth mit quartalsweisem Security-Review.
13. Observability, Tests, Telemetrie
Abschnitt betitelt „13. Observability, Tests, Telemetrie“Logs. Struktur-Logs (OpenTelemetry + Pino): jede Auth-Operation erzeugt {trace_id, tenant_id, user_id?, event_type, factor, duration_ms, outcome}. Log-Pii-Scrubber entfernt E-Mail-Domains auf Request-Ebene aus dem Error-Stack.
Metrics (Prom/OTEL):
auth_login_total{factor,outcome}(Counter)auth_login_duration_seconds{factor}(Histogram, SLO p95 ≤ 1 s für Passkey)auth_mfa_required_gauge{role}(Gauge)auth_session_active(Gauge)auth_passkey_adoption_ratio(Gauge · Zielkorridor ≥ 0.9 nach 90 d)
Tracing. Frontend startet Span auth.login.passkey, Server-Span auth.verify.assertion, DB-Span auth.query.credential. End-to-End-p95 SLO 1 s für Passkey, 2 s für OAuth.
Tests.
- Unit: Argon2id-Verify-Invarianz, TOTP-RFC6238-Vektor-Tests.
- Integration: Better Auth Happy Path + 11 Grenzfälle (Tenant-Hopping, Cookie-Mutation, Passkey-Replay, Counter-Rollback, clock-skew TOTP).
- E2E: Playwright-Szenario „Passkey-Registrierung auf Windows Hello + Wiederverwendung“ + Appium-iOS-Flow.
- Security: OWASP ZAP Baseline pro PR,
npm audit, Semgrep-Ruleset „webauthn-secure“, SBOM via CycloneDX. - Chaos: Redis-Ausfall → Sessions fallen auf DB-Lookup zurück, Latenz steigt ≤ 150 ms p95.
- Cross-Tenant-Invariante: verpflichtend für jeden neuen
@Controller-Pfad (Test-Template intests/invariants/cross_tenant.spec.ts).
14. Migration & Rollout
Abschnitt betitelt „14. Migration & Rollout“Phase 1 (T+0 bis T+10). Better Auth + Passkey + Passwort-Fallback + OAuth Google/Microsoft. Admins werden zwangs-MFA-enrolled (Feature-Flag auth.mfa.enforce.admin=true).
Phase 2 (T+10 bis T+18). ESS-Flows (Urlaub/eAU/Export/Deletion-Request), Audit-Chain-Integration, BFSG-Audit der Screens (externer Audit-Partner).
Daten-Migration. Kein Legacy-Import in MVP (greenfield). Test-Tenants werden aus seed/*.ts erzeugt (siehe TECH-STACK §8.4): 2 User pro Tenant mit Passkey + TOTP, 4 User Passwort+TOTP, 2 OAuth-User.
Feature-Flags.
auth.mfa.enforce.admin(default true)auth.passkey.allow(default true)auth.oauth.google.allow(per Tenant)auth.oauth.microsoft.allow(per Tenant)auth.hinweisgeber.enabled(per Tenant, default false)
Rollback. Feature-Flag-Rollback via LaunchDarkly-Kompatibles Interface; notfalls Dumbed-Down-Modus „Passwort + TOTP only“, keine User-seitige Auswirkung außer fehlender Passkey-Option.
15. Offene Fragen & Annahmen
Abschnitt betitelt „15. Offene Fragen & Annahmen“Offene Fragen.
- Gemeinsame Tablet-Identität (Werkstatt-Kiosk) — rechtlich „persönlich“? Diskussion mit Betriebsrat erforderlich; Annahme: Kiosk-Sessions sind technische Nutzer (
service-account), individuelle Zuordnung per NFC-Ausweis. - Microsoft Entra B2B (Gast-Konten): Unterstützen wir externe Sub-Unternehmer-User direkt? Annahme MVP: nein, Sub-Zeiten via §3.8-Sub-Erfassung.
- Passkey-Sync vs. Device-Bound: Nutzen wir iCloud-Keychain-Sync oder erzwingen wir Device-Bound? Annahme: Sync erlaubt (UX-Gewinn), aber Admin-Passkeys müssen Device-Bound (Security-Policy).
- Hardware-Security-Key (YubiKey) als Admin-Pflicht: Phase-1 optional, ab V1.4 wahrscheinlich Pflicht für „Ziegel“-Tier.
Annahmen.
- Browser-Support für WebAuthn ≥ 98 % (caniuse Q1-2026) — bestätigt.
- Entra ID OIDC-Endpunkte stabil — bestätigt, Watch via Azure Health Dashboard.
- Better Auth-Releases ≥ 2x/Quartal — überwacht; Fallback Lucia dokumentiert.
16. Abhängigkeiten & Schnittstellen
Abschnitt betitelt „16. Abhängigkeiten & Schnittstellen“- ↔ §3.11 Compliance-Audit: Login-Events fließen in die globale Hash-Chain; ed25519-Signatur-Batch liest
login_eventszusammen mit anderen audit-relevanten Tabellen. - ↔ §3.4 Projekte/Kunden/Baustellen: Scope-Definition („own“ = Baustellen im Team + eigene Time-Entries) nutzt
app.user_idaus Session-Hook. - → infra/rls-foundation: Session-Hook setzt
SET LOCAL app.*— Fehlendes Setting = 0 Rows (harte Test-Invariante). - → kern/08-dienstplan: Rollen-Modell steuert sichtbare Planänderungen (Manager sieht Team, Monteur sieht eigenen Plan).
- → ops/incident-response:
login.failedmitreason='account_locked'löst Alert-Webhook in Slack/Teams aus (konfigurierbar, nicht MVP-Pflicht).
Outbox-Pattern. Login-Events werden transaktional mit DB-Mutation geschrieben und asynchron an den Audit-Signer weitergegeben (UUID-v7-Idempotency-Key).
17. Kosten / ROI-Skizze
Abschnitt betitelt „17. Kosten / ROI-Skizze“Build. 18 Eng-Tage × 900 € = 16.200 € (intern), + 2 Tage BFSG-Audit extern (1.800 €). Gesamt 18.000 €.
Run. Better Auth Community (MIT, kostenfrei); KMS-Keys 1 €/Monat/Key × 4 = 4 €/Monat/Tenant; SMS/Magic-Link-Provider (für Recovery) 0,05 €/Mail × geschätzt 2 Mails/Monat/Tenant = 0,10 €. Rundum ~5 €/Monat/Tenant.
Risiko. Passkey-UX-Wiederstand bei älteren Usern — Mitigation: Video-Onboarding + Bauch-Button „Passwort reicht mir fürs Erste“.
Plan-Gate. Alle Auth-Features sind in Phase 1 in jedem Plan (Blau/Grün/Gelb/Ziegel) enthalten, da Pflicht. OAuth-Multi-Tenant-Self-Service erst ab „Gelb“.
18. Edge Cases (detailliert)
Abschnitt betitelt „18. Edge Cases (detailliert)“- Uhr-Drift bei TOTP: Server akzeptiert ±1 Step (±30 s) Toleranz. Bei > 60 s Drift → UI-Hint „Geräteuhrzeit prüfen“.
- Passkey-Counter-Rollback (Device-Reset): Erkannt, Credential als suspekt markiert, zweiter Faktor nachgefordert.
- OAuth-Refresh-Fehler während Session: Silent-Refresh schlägt fehl → Re-Auth-Prompt mit
return_to-URL. - Magic-Link mehrfach geklickt: Token einmalig (DB-Constraint
used_atUNIQUE NULL); zweiter Klick liefert 410 Gone. - Benutzer ändert E-Mail während aktiver Session: Session bleibt, aber alle anderen Sessions werden revoked (
email_changed); User muss andere Geräte neu anmelden. - DSGVO-Löschantrag bei GoBD-gebundenen Daten: User wird auf Konflikt hingewiesen (GoBD §147 AO vs. DSGVO Art. 17); Self-Service erzeugt einen Ticket, der von Admin bestätigt werden muss (Maskierung, nicht Löschung — Details §3.11).
- Hinweisgeber-Meldung von intern (eingeloggter User findet den Pfad): Proxy-Pfad rendert anonyme Ansicht, ignoriert Session-Cookie aktiv (
Clear-Site-Data: cookies, storage), schreibt keinen User-ID-Bezug. - Azubi unter 16 ohne eigenes Handy: NFC-Kiosk-Login, User-Type
azubi-kiosk, Passkey-Enrollment auf Wunsch des Sorgeberechtigten. - Parallele MFA-Enrollment-Flows auf zwei Browsern: nur die zuletzt abgeschlossene Enrollment-Transaktion gewinnt (DB-UNIQUE constraint auf
totp_secrets.user_id), zweite liefert 409. - Session während Rollen-Downgrade aktiv: RLS-Settings werden bei jedem Request neu gesetzt; Downgrade wird sofort wirksam (nicht erst beim nächsten Login).
- User-Löschung bei aktiven Zeiterfassungen: blockiert mit 400 ESS-DELETION-BLOCKED-GOBD, Verweis auf Maskierungs-Pfad.
- Better Auth Version Bump bricht Cookie-Format: Migration-Script dreht bestehende Sessions auf (zwingt Re-Login), im Voraus per Mail 48 h kommuniziert.
Ende Feinkonzept §3.9 · v0.1 · 2026-04-19
19. Admin-Konfiguration
Abschnitt betitelt „19. Admin-Konfiguration“Die zugehörigen Manager-Screens sind in §7.4 (MFA-Policies) und §7.5 (DSGVO-Art-15-ESS) als UI-Skizzen ausgearbeitet. Diese Sektion fasst die F-A-* Feature-IDs konsolidiert zusammen — Admin-Modul-Stub-URL: /admin/kern/auth-self-service/.
- F-A-01 — MFA-Policy pro Rolle. Pflicht-Matrix (
Ownerimmer Pflicht,AdminPflicht,ManagerPflicht,Mitarbeiteroptional/empfohlen). Konfigurierbar pro Tenant; Default-Werte ausweb/01-admin-portal-shell §6.3. Senken einer Pflicht-Stufe erzeugt Audit-Log-Eintrag mit Begründungs-Pflicht. - F-A-02 — OAuth-Identity-Provider-Konfiguration. Pro Tenant aktivierbar: Google, Microsoft Entra ID, Sign in with Apple. Client-IDs/-Secrets liegen in AWS Secrets Manager — Admin sieht nur den Aktivierungs-Schalter und das Provider-Logo, nicht die Credentials selbst. Test-Login-Button validiert die Konfiguration.
- F-A-03 — Recovery-Code-Defaults. Anzahl Codes pro Generierung (Default 10), TTL bis Zwangs-Regenerierung (Default 12 Monate), erzwungene Speicher-Bestätigungs-Checkbox (siehe §7.3).
- F-A-04 — Session-Limits. Max parallele Sessions pro User (Default 5), Idle-Timeout (Default 30 Tage Mobile, 12 h Web Admin), Bulk-Revoke-Aktion durch Admin („Logout aller Geräte für User X“).
- F-A-05 — DSGVO-Art-15-Antragsbearbeitung. Eingangs-Postfach für Mitarbeiter-Anträge (über §7.5 ESS-Form ausgelöst), SLA-Counter (30 Tage), Self-Service-Antwort-Templates, Eskalations-Schalter an externen DSB.
- F-A-06 — Hinweisgeber-Modul (HinSchG). Aktivierung pro Tenant (default aus), Konfiguration des anonymen Kanals (siehe §7.6), Routing an Compliance-/HR-Verantwortliche (zwei vertrauenswürdige Personen Pflicht).
- F-A-07 — Auth-Audit-Log. Filterbarer View aller Auth-Events (Login-Success/-Failure, MFA-Reset, Passkey-Enrollment, Permission-Change, Tenant-Switch). CSV-Export, Aufbewahrungs-Frist 6 Jahre (BetrVG-Mitbestimmung).
- F-A-08 — Permissions-Override. Bei aktiver Rolle einzelne Permissions zuschalten/entziehen (z.B. Mitarbeiter mit Sonderrolle “Lohnbüro-Vertretung”). Nur durch Owner möglich, mit Audit-Eintrag und 2-Augen-Prinzip-Hinweis.
Für Entwickler — API-Endpoints35
| Methode | Pfad | Auth | Zweck |
|---|---|---|---|
| GET | /v1/auth/login/oauth/callback | public | OAuth-Callback (Browser-Redirect-Variante). |
| POST | /v1/auth/login/oauth/callback | public | OAuth-Callback (SPA/PKCE-Variante). |
| POST | /v1/auth/login/oauth/start | public | OAuth-Login starten (PKCE-Authorize-URL). |
| POST | /v1/auth/login/passkey/begin | public | Passkey-Login starten (WebAuthn-Challenge). |
| POST | /v1/auth/login/passkey/finish | public | Passkey-Login abschließen (WebAuthn-Assertion prüfen). |
| POST | /v1/auth/login/password | public | Login mit E-Mail + Passwort. |
| POST | /v1/auth/logout | bearerAuth | Aktuelle Session beenden. |
| GET | /v1/auth/me | bearerAuth | Aktuelle Session-Identität. |
| PATCH | /v1/auth/me/active-role | bearerAuth | Aktive Rolle wechseln. |
| PATCH | /v1/auth/me/active-tenant | bearerAuth | Aktiven Tenant wechseln. |
| GET | /v1/auth/me/dashboard-layout | bearerAuth | Dashboard-Layout der aktuellen Identität. |
| PUT | /v1/auth/me/dashboard-layout | bearerAuth | Dashboard-Layout setzen. |
| GET | /v1/auth/me/mfa/factors | bearerAuth | MFA-Faktoren der aktuellen Identität. |
| GET | /v1/auth/me/roles | bearerAuth | Rollen der aktuellen Identität. |
| POST | /v1/auth/mfa/recovery/regenerate | bearerAuth | Recovery-Codes neu erzeugen. |
| POST | /v1/auth/mfa/recovery/use | bearerAuth | Recovery-Code einlösen. |
| POST | /v1/auth/mfa/totp/disable | bearerAuth | TOTP deaktivieren (Code bestätigen). |
| POST | /v1/auth/mfa/totp/enroll/begin | bearerAuth | TOTP-Enrollment starten (Secret + otpauth-URI). |
| POST | /v1/auth/mfa/totp/enroll/finish | bearerAuth | TOTP-Enrollment abschließen (Code bestätigen). |
| POST | /v1/auth/mfa/totp/verify | bearerAuth | TOTP-Code verifizieren (Step-up). |
| DELETE | /v1/auth/passkey/{credentialId} | bearerAuth | Passkey entfernen. |
| PATCH | /v1/auth/passkey/{credentialId} | bearerAuth | Passkey-Gerätename ändern. |
| POST | /v1/auth/passkey/register/begin | bearerAuth | Passkey-Registrierung starten (WebAuthn-Attestation-Optionen). |
| POST | /v1/auth/passkey/register/finish | bearerAuth | Passkey-Registrierung abschließen. |
| POST | /v1/auth/password/change | bearerAuth | Passwort ändern (re-auth mit aktuellem Passwort). |
| POST | /v1/auth/password/reset/confirm | public | Passwort-Reset bestätigen (Token + neues Passwort). |
| POST | /v1/auth/password/reset/request | public | Passwort-Reset anfordern. |
| POST | /v1/auth/platform-login | public | Plattform-Admin-Login (Operator-Konsole). |
| POST | /v1/auth/platform-logout | bearerAuth | Plattform-Admin-Logout. |
| DELETE | /v1/auth/sessions | bearerAuth | Alle anderen Sessions widerrufen (bulk revoke-others). |
| GET | /v1/auth/sessions | bearerAuth | Aktive Sessions der aktuellen Identität. |
| DELETE | /v1/auth/sessions/{sessionId} | bearerAuth | Session widerrufen. |
| POST | /v1/auth/sign-up | public | Registrierung (neuer Tenant + Customer-Account). |
| POST | /v1/auth/step-up/passkey/begin | bearerAuth | Step-up-Passkey-Challenge (z. B. DSGVO-Antrag). |
| POST | /v1/auth/test-impersonate/{userId} | bearerAuth | Test-Impersonation (nur Nicht-Prod, Admin-Rolle). |