Zum Inhalt springen

Auth & Self-Service — OAuth, Passkey, TOTP, Better-Auth, Employee-Self-Service

Live in Produktion

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.


feature_id: kern/09-auth-self-service
title: Auth & Self-Service — OAuth, Passkey, TOTP, Better-Auth, Employee-Self-Service
funktionsumfang_ref: §3.9
roadmap_horizont: MVP
plattformen:
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 Bedarf
owner_rolle: Admin # MFA-Policies, OAuth-Domains, Session-TTL
modul_gate_flag: kern.identity # Kern, nicht modul-gated — ohne Auth keine App
compliance_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, Recovery
abhä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 gesetzt

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).

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).


  1. Passkey/WebAuthn-First für alle nativen Apps (iOS Face ID, Android BiometricPrompt, Windows Hello, macOS Touch ID).
  2. TOTP als zweiter Faktor für Admins (Pflicht) und alle anderen (opt-in); mindestens zwei Verfahren pro Admin-Account.
  3. OAuth 2.1 + PKCE mit Google Workspace und Microsoft Entra ID als Identity Provider; Tenant-Domain-Mapping (@musterbetrieb-maler.de → Tenant T-001).
  4. 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.
  5. Session-Management via Better Auth: Cookie SameSite=Strict, Secure, HttpOnly, TTL 15 min (rolling), Refresh-Token TTL 30 Tage, Tenant-Binding im Cookie-Claim.
  6. Recovery-Codes 8×10 Zeichen, Argon2id-gehasht, Einmal-Verwendung, bei Verbrauch < 3 → Push-Warnung.
  7. 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.
  8. Hinweisgeber-Kanal on-demand (Modul-Gate modul.hinweisgeber): anonymer Zugang über separaten Reverse-Proxy-Pfad, keine Login-Pflicht, keine IP-Logs.
  9. BFSG-Konformität: alle ESS-Flows WCAG 2.2 AA, 48 × 48 Touch-Targets, Kontrast ≥ 4.5 : 1, aria-live für asynchrone Flows (OAuth-Redirect).
  10. RLS-Foundation: nach Login setzt die Auth-Middleware SET LOCAL app.tenant_id = :t und app.user_id = :u in jeder Transaktion; ohne diese Settings liefern alle Queries 0 rows.
  • 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).

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.

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) und email_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.

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.

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_at gesetzt; der Grund „device_loss“ ist konzeptionell — die passkeys-Tabelle hat keine eigene reason-Spalte, ein Grund-Enum existiert nur bei mfa_factors) — die Revocation selbst ist nicht Teil der BetrVG-Whitelist aus ADR-0003 und landet nicht als eigener login_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-required an Pflichtfeldern, Fehlermeldungen role="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.

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)

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.

┌────────────────────────────────┐ ← 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…“).

Auth-Tabellen (Better Auth Erweiterung). Drizzle-Pseudo-Schema in TypeScript-Notation:

schema/auth.ts
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 v7
SET 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 machen
CREATE 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 Tenant
CREATE 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 admin
CREATE 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 sehen
ALTER 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 Tenants
ALTER 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-only
CREATE 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ät

Tenant-Binding-Hook. Jeder Hono-Request durchläuft:

  1. Cookie-Parse (Better Auth Session-Cookie, SameSite=Strict).
  2. Session-Lookup in Redis (Cache) / Postgres (Wahrheit).
  3. BEGIN; SET LOCAL app.*; …; COMMIT; — ohne erfolgreiches SET LOCAL bricht die Transaktion ab.
  4. Fehlendes app.tenant_id → Query liefert 0 Rows (RLS USING = NULL ist 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.


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).


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 s
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 revoked

Grenzfall 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")
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"
Wenn Frau Müller auf Login "Mit Google" klickt und ihr Gmail-Konto [email protected] auswählt
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 geschrieben
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 Blocker
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)

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

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.


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 in tests/invariants/cross_tenant.spec.ts).

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.


Offene Fragen.

  1. 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.
  2. Microsoft Entra B2B (Gast-Konten): Unterstützen wir externe Sub-Unternehmer-User direkt? Annahme MVP: nein, Sub-Zeiten via §3.8-Sub-Erfassung.
  3. 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).
  4. 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.

  • ↔ §3.11 Compliance-Audit: Login-Events fließen in die globale Hash-Chain; ed25519-Signatur-Batch liest login_events zusammen mit anderen audit-relevanten Tabellen.
  • ↔ §3.4 Projekte/Kunden/Baustellen: Scope-Definition („own“ = Baustellen im Team + eigene Time-Entries) nutzt app.user_id aus 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.failed mit reason='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).


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“.


  1. Uhr-Drift bei TOTP: Server akzeptiert ±1 Step (±30 s) Toleranz. Bei > 60 s Drift → UI-Hint „Geräteuhrzeit prüfen“.
  2. Passkey-Counter-Rollback (Device-Reset): Erkannt, Credential als suspekt markiert, zweiter Faktor nachgefordert.
  3. OAuth-Refresh-Fehler während Session: Silent-Refresh schlägt fehl → Re-Auth-Prompt mit return_to-URL.
  4. Magic-Link mehrfach geklickt: Token einmalig (DB-Constraint used_at UNIQUE NULL); zweiter Klick liefert 410 Gone.
  5. 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.
  6. 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).
  7. 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.
  8. Azubi unter 16 ohne eigenes Handy: NFC-Kiosk-Login, User-Type azubi-kiosk, Passkey-Enrollment auf Wunsch des Sorgeberechtigten.
  9. 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.
  10. Session während Rollen-Downgrade aktiv: RLS-Settings werden bei jedem Request neu gesetzt; Downgrade wird sofort wirksam (nicht erst beim nächsten Login).
  11. User-Löschung bei aktiven Zeiterfassungen: blockiert mit 400 ESS-DELETION-BLOCKED-GOBD, Verweis auf Maskierungs-Pfad.
  12. 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


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-01MFA-Policy pro Rolle. Pflicht-Matrix (Owner immer Pflicht, Admin Pflicht, Manager Pflicht, Mitarbeiter optional/empfohlen). Konfigurierbar pro Tenant; Default-Werte aus web/01-admin-portal-shell §6.3. Senken einer Pflicht-Stufe erzeugt Audit-Log-Eintrag mit Begründungs-Pflicht.
  • F-A-02OAuth-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-03Recovery-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-04Session-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-05DSGVO-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-06Hinweisgeber-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-07Auth-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-08Permissions-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
MethodePfadAuthZweck
GET/v1/auth/login/oauth/callbackpublicOAuth-Callback (Browser-Redirect-Variante).
POST/v1/auth/login/oauth/callbackpublicOAuth-Callback (SPA/PKCE-Variante).
POST/v1/auth/login/oauth/startpublicOAuth-Login starten (PKCE-Authorize-URL).
POST/v1/auth/login/passkey/beginpublicPasskey-Login starten (WebAuthn-Challenge).
POST/v1/auth/login/passkey/finishpublicPasskey-Login abschließen (WebAuthn-Assertion prüfen).
POST/v1/auth/login/passwordpublicLogin mit E-Mail + Passwort.
POST/v1/auth/logoutbearerAuthAktuelle Session beenden.
GET/v1/auth/mebearerAuthAktuelle Session-Identität.
PATCH/v1/auth/me/active-rolebearerAuthAktive Rolle wechseln.
PATCH/v1/auth/me/active-tenantbearerAuthAktiven Tenant wechseln.
GET/v1/auth/me/dashboard-layoutbearerAuthDashboard-Layout der aktuellen Identität.
PUT/v1/auth/me/dashboard-layoutbearerAuthDashboard-Layout setzen.
GET/v1/auth/me/mfa/factorsbearerAuthMFA-Faktoren der aktuellen Identität.
GET/v1/auth/me/rolesbearerAuthRollen der aktuellen Identität.
POST/v1/auth/mfa/recovery/regeneratebearerAuthRecovery-Codes neu erzeugen.
POST/v1/auth/mfa/recovery/usebearerAuthRecovery-Code einlösen.
POST/v1/auth/mfa/totp/disablebearerAuthTOTP deaktivieren (Code bestätigen).
POST/v1/auth/mfa/totp/enroll/beginbearerAuthTOTP-Enrollment starten (Secret + otpauth-URI).
POST/v1/auth/mfa/totp/enroll/finishbearerAuthTOTP-Enrollment abschließen (Code bestätigen).
POST/v1/auth/mfa/totp/verifybearerAuthTOTP-Code verifizieren (Step-up).
DELETE/v1/auth/passkey/{credentialId}bearerAuthPasskey entfernen.
PATCH/v1/auth/passkey/{credentialId}bearerAuthPasskey-Gerätename ändern.
POST/v1/auth/passkey/register/beginbearerAuthPasskey-Registrierung starten (WebAuthn-Attestation-Optionen).
POST/v1/auth/passkey/register/finishbearerAuthPasskey-Registrierung abschließen.
POST/v1/auth/password/changebearerAuthPasswort ändern (re-auth mit aktuellem Passwort).
POST/v1/auth/password/reset/confirmpublicPasswort-Reset bestätigen (Token + neues Passwort).
POST/v1/auth/password/reset/requestpublicPasswort-Reset anfordern.
POST/v1/auth/platform-loginpublicPlattform-Admin-Login (Operator-Konsole).
POST/v1/auth/platform-logoutbearerAuthPlattform-Admin-Logout.
DELETE/v1/auth/sessionsbearerAuthAlle anderen Sessions widerrufen (bulk revoke-others).
GET/v1/auth/sessionsbearerAuthAktive Sessions der aktuellen Identität.
DELETE/v1/auth/sessions/{sessionId}bearerAuthSession widerrufen.
POST/v1/auth/sign-uppublicRegistrierung (neuer Tenant + Customer-Account).
POST/v1/auth/step-up/passkey/beginbearerAuthStep-up-Passkey-Challenge (z. B. DSGVO-Antrag).
POST/v1/auth/test-impersonate/{userId}bearerAuthTest-Impersonation (nur Nicht-Prod, Admin-Rolle).