Compare commits

..
Author SHA1 Message Date
sysops 49a48743f5 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite 2026-08-28 22:02:02 +02:00
sysops 11940e28e6 OPS-02: fix — transpilePackages fuer @nexarch/shl (Next.js transpiliert node_modules sonst nicht, Build brach ab) 2026-08-28 21:56:39 +02:00
sysops f7bc927d50 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite 2026-08-28 21:56:31 +02:00
sysops 0f1add0b78 OPS-02: Retrofit auf SHL-01 (ThemeProvider/I18nProvider/ToastProvider, Design-Tokens statt hartkodierter Werte) 2026-08-28 21:47:48 +02:00
sysops 265c09db24 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite
# Conflicts:
#	DEVLOG.md
2026-08-28 21:47:32 +02:00
sysops cabcabda8d OPS-02: test-fix — overview-pruefung nur fuer eigene testziele, nicht alle jemals registrierten 2026-08-28 08:29:27 +02:00
sysops 81ebdd892b OPS-02: dev-server + next.js zentrale statusseite 2026-08-28 08:21:27 +02:00
sysops dff760e1ba OPS-02: zentrale-statusseite (backend: poller + aggregierte uebersicht + verlauf) 2026-08-28 08:19:40 +02:00
sysopsandClaude Sonnet 5 6a03dcafd6 OPS-01: health-check-endpunkte-je-modul
internal/health: wiederverwendbare Registry fuer benannte Checks (DB, Queue)
— nicht Core-spezifisch, sondern von jedem registrierten Modul (API-02)
gleichermassen einsetzbar. LivenessHandler prueft bewusst KEINE externen
Abhaengigkeiten (Akzeptanzkriterium 2: Liveness/Readiness getrennt) — ein
DB-Ausfall soll den Prozess nicht faelschlich als "tot" markieren und einen
grundlosen Neustart ausloesen. ReadinessHandler fuehrt alle registrierten
Checks NEBENLAEUFIG mit je eigenem Timeout aus (DefaultCheckTimeout=2s) und
liefert 503, sobald irgendeine Abhaengigkeit fehlschlaegt (Akzeptanz-
kriterium 1 + 3) — echte Pruefung von DB (Ping) und Job-Queue statt nur
Prozessstatus.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Simulierter Datenbankausfall fuehrt zu "nicht bereit" —
   TestReadinessHandler_ReportsNotReadyOnDatabaseFailure: geschlossener Pool,
   503 mit "database" im Checks-Ergebnis. PASS.
2. Health-Endpunkt antwortet auch bei haengendem Check innerhalb definierter
   Zeit — TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck:
   ein 10s blockierender Check wird durch 50ms-Timeout begrenzt, Handler
   antwortet deutlich unter 1s. PASS.
3. Readiness- und Liveness-Antwort unterscheiden sich nachweislich in
   mindestens einem Fehlerfall — TestLivenessAndReadiness_DifferOnDatabaseFailure:
   bei DB-Ausfall liefert Liveness weiterhin 200, Readiness 503. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:30:30 +02:00
sysopsandClaude Sonnet 5 b23cd1961f API-02: modul-registry-aktivierungspruefung
internal/moduleregistry: Registry.Register traegt Fachmodule mit Name,
Version und benoetigten Feature-Flags ein (Akzeptanzkriterium 1), fehlende
Pflichtangaben werden abgewiesen. IsActive kombiniert Registrierung + LIC-02
Feature-Flag-Auswertung (ALLE benoetigten Flags muessen fuer den Tenant
aktiv sein) — ein nicht registriertes Modul ist nie aktiv. List liefert alle
Module fuer Statusseite/Lizenzoberflaeche (Akzeptanzkriterium 3).

RequireActiveModule ist die zentrale Durchsetzungs-Middleware (Casbin-
Prinzip): weist Anfragen an ein deaktiviertes Modul ab, BEVOR der
Modul-Handler ueberhaupt aufgerufen wird (Akzeptanzkriterium 2) —
Pruefung per Test belegt, dass der Handler bei Deaktivierung nachweislich
nicht erreicht wird.

Service-Credentials (Akzeptanzkriterium 4): Registry.Provision stellt pro
Modul-Instanz Client-ID + Secret aus, gespeichert wird nur der SHA-256-Hash
des Secrets. Registry.Authenticate vergleicht timing-safe (dieselbe
subtle.ConstantTimeCompare-Referenzimplementierung wie AUD-02).
RequireServiceCredential-Middleware liest X-Client-Id/X-Client-Secret und
weist Aufrufe ohne gueltiges Credential mit 401 ab, bevor der Core-seitige
Endpunkt (z.B. Audit-Nachlieferung, Nutzungsmeldung) erreicht wird.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Anfrage an deaktiviertes Modul nachweislich vor Modul-Logik abgewiesen —
   TestRequireActiveModule_BlocksBeforeHandler: handlerReached bleibt false
   bei 403, wird true erst nach Aktivierung bei 200. PASS.
2. Registrierung mit fehlenden Pflichtangaben abgewiesen —
   TestRegister_RejectsMissingFields (leerer Name, leere Version). PASS.
3. Registry-Abfrage liefert konsistente Daten nach Aktivierung/Deaktivierung —
   TestIsActive_ReflectsFlagStateConsistently: aus/an/aus-Zyklus, IsActive
   folgt dem Flag-Zustand korrekt. PASS.
4. Aufruf mit ungueltigem/fehlendem Service-Credential abgewiesen, mit
   gueltigem angenommen — TestRequireServiceCredential_RejectsInvalidAcceptsValid
   und TestProvisionAndAuthenticate (falsches Secret, unbekannte Client-ID,
   korrektes Credential). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:17:50 +02:00
sysopsandClaude Sonnet 5 4a30345e07 LIC-02: feature-flag-service-je-tenant
internal/flag: Store (Verwaltung) + Service (Auswertung mit TTL-Cache,
Default 5s) — Unleash-Prinzip Flag-Verwaltung vs. Flag-Auswertung getrennt,
als Kernfunktion des Core-Dienstes selbst statt separater Infrastruktur.

evaluate() wendet drei Strategien in fester Reihenfolge an: global an/aus,
Tenant-Zielgruppe, deterministischer Prozentsatz-Rollout (FNV-Hash aus
Tenant+Key, stabil pro Tenant). IsEnabled liefert IMMER nur bool (kein
Fehlerwert) — ein nicht erreichbarer Flag-Dienst kann damit keinen
Aufrufer zum Absturz bringen: bei DB-Fehler wird der zuletzt bekannte
Cache-Stand verwendet, ohne jeglichen Stand faellt der Dienst sicher auf
false zurueck. Service.Invalidate erzwingt sofortiges Neuladen fuer den
Schreiber selbst, andere Instanzen sehen Aenderungen spaetestens nach der
TTL (Akzeptanzkriterium 3, kein Neustart noetig).

Bugfix waehrend Tests: Store.Set uebergab ein nil-TargetTenantSlugs-Slice
als SQL NULL statt leerem Array (NOT-NULL-Verletzung) — auf leeres Slice
normalisiert.

Akzeptanzkriterium 4 (Deaktivierung loescht keine Daten): dieses Paket
besitzt ausschliesslich die eigene feature_flags-Zeile, hat keinerlei
Code-Pfad, der Modul-Geschaeftsdaten anfassen koennte — Loeschung bleibt
strukturell der Archive-Retention-Engine vorbehalten.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Cache-Invalidierungszeit automatisiert gemessen —
   TestService_CacheInvalidationTiming: Aenderung wirksam nach 153ms bei
   TTL=150ms (innerhalb Ziel+Toleranz), vorher nachweislich noch alter Stand. PASS.
2. Zielgruppen-Strategie liefert erwartete Auswertung —
   TestService_TargetTenantStrategy / TestEvaluate_TargetTenantStrategy. PASS.
3. Ausfall des Flag-Dienstes fuehrt zu dokumentiertem Fallback, kein Absturz —
   TestService_FallsBackOnStoreFailure (mit recover()-Absicherung): Fallback
   auf Cache-Stand bzw. sicheres false bei komplett unerreichbarer DB, geloggt. PASS.
4. Modul-Deaktivierung/Reaktivierung ohne Datenverlust — architektonisch durch
   fehlenden Code-Pfad sichergestellt (siehe oben), zusaetzlich durch
   TestService_InvalidateForcesImmediateRefresh (Toggle aus/an bleibt
   konsistent nachvollziehbar) mitabgedeckt. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:19:59 +02:00
78 changed files with 2050 additions and 3556 deletions
+3 -83
View File
@@ -64,6 +64,9 @@ Keine Änderungen ermittelbar.
## 2026-08-27 17:28 17:29 (1m) ## 2026-08-27 17:28 17:29 (1m)
**Beschreibung:** Claude Code Session **Beschreibung:** Claude Code Session
**Projekt:** code **Projekt:** code
## 2026-08-28 21:44 21:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits ### Commits
Keine Commits in dieser Session. Keine Commits in dieser Session.
@@ -127,29 +130,6 @@ Keine Commits in dieser Session.
- internal/config/config.go | 29 +++++++++++++++++++++++++++++ - internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++ - internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++ - migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-28 22:48 22:51 (3m)
## 2026-08-28 21:44 21:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- c344dea TEN-08: tenant-loeschung-unter-retention-vorbehalt-gobd (RetentionChecker-Schnittstelle gegen Archive RET-03/CMP-06, ProcessDueDeletions haelt gesperrte Tenants zurueck)
### Geänderte Dateien
- internal/tenant/lifecycle.go | 45 ++++++++++++++++++++++++++++++++++++++++-----
- internal/tenant/lifecycle_test.go | 4 +++-
- internal/tenant/registry.go | 7 +++++--
- internal/tenant/retention.go | 34 ++++++++++++++++++++++++++++++++++
- internal/tenant/retention_test.go | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/tenant/tenant.go | 5 +++++
- migrations/0004_tenant_retention_block.down.sql | 2 ++
- migrations/0004_tenant_retention_block.up.sql | 6 ++++++
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ - web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++ - web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ - web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
@@ -179,63 +159,3 @@ Keine Commits in dieser Session.
- web/shl/vitest.setup.ts | 1 + - web/shl/vitest.setup.ts | 1 +
--- ---
## 2026-08-28 23:27 23:35 (7m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- f627caa IAM-08: benutzerprofil-login-oberflaeche (login+2fa/passwort-reset/profil-backend-handler + web/account next.js-frontend auf shl-01)
- f0139c8 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/iam-08-benutzerprofil-login-oberflaeche
- d9fb2e3 Merge branch 'feature/iam-03-passwort-reset-einladungs-flow' into feature/iam-08-benutzerprofil-login-oberflaeche
### Geänderte Dateien
- internal/auth/changepassword.go | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/auth/changepassword_test.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/auth/profile.go | 44 ++++++++++++++++++++++++++++++++++++++
- internal/auth/profile_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++
- internal/authtoken/handler.go | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/authtoken/handler_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/totp/handler.go | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/totp/handler_test.go | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/tenant_store.go | 20 ++++++++++++++++++
- web/account/app/layout.tsx | 30 ++++++++++++++++++++++++++
- web/account/app/login/page.tsx | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/app/page.tsx | 5 +++++
- web/account/app/password-reset/complete/page.tsx | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/app/password-reset/request/page.tsx | 53 ++++++++++++++++++++++++++++++++++++++++++++++
- web/account/app/profile/page.tsx | 228 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/lib/api.ts | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/next.config.mjs | 7 +++++++
- web/account/package.json | 22 +++++++++++++++++++
- web/account/tsconfig.json | 21 +++++++++++++++++++
---
## 2026-08-28 23:35 23:35 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- internal/auth/changepassword.go | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/auth/changepassword_test.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/auth/profile.go | 44 ++++++++++++++++++++++++++++++++++++++
- internal/auth/profile_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++
- internal/authtoken/handler.go | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/authtoken/handler_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/totp/handler.go | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/totp/handler_test.go | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/tenant_store.go | 20 ++++++++++++++++++
- web/account/app/layout.tsx | 30 ++++++++++++++++++++++++++
- web/account/app/login/page.tsx | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/app/page.tsx | 5 +++++
- web/account/app/password-reset/complete/page.tsx | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/app/password-reset/request/page.tsx | 53 ++++++++++++++++++++++++++++++++++++++++++++++
- web/account/app/profile/page.tsx | 228 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/lib/api.ts | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/next.config.mjs | 7 +++++++
- web/account/package.json | 22 +++++++++++++++++++
- web/account/tsconfig.json | 21 +++++++++++++++++++
---
+1 -10
View File
@@ -8,7 +8,6 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/config" "gitea.perlbach24.de/scripte/nexarch/internal/config"
"gitea.perlbach24.de/scripte/nexarch/internal/db" "gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant" "gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
) )
func main() { func main() {
@@ -35,20 +34,12 @@ func main() {
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate) provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
tenantHandler := tenant.NewHandler(provisioner) tenantHandler := tenant.NewHandler(provisioner)
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
// Mandant (TEN-06, noch nicht gebaut) und wird hier bewusst noch nicht
// verdrahtet — Package ist bereits eigenstaendig nutzbar/testbar.
superadmins := user.NewSuperadminStore(registryPool)
userHandler := user.NewHandler(nil, superadmins)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest. // Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest.
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant) mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
log.Printf("nexarch-core listening on %s", cfg.ListenAddr) log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil { if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
+66
View File
@@ -0,0 +1,66 @@
// statuspage-devserver stellt das OPS-02-Backend (internal/statuspage) fuer
// die Next.js-Statusseite bereit und startet den periodischen Poller.
// Getrennt von cmd/core aus demselben Grund wie die anderen *-devserver.
package main
import (
"context"
"log"
"net/http"
"os"
"strconv"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/statuspage"
)
func main() {
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
if dsn == "" {
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
}
addr := os.Getenv("NEXARCH_STATUSPAGE_LISTEN_ADDR")
if addr == "" {
addr = ":8084"
}
intervalSeconds := 10
if v := os.Getenv("NEXARCH_STATUSPAGE_POLL_INTERVAL_SECONDS"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil {
intervalSeconds = parsed
}
}
ctx := context.Background()
pool, err := db.Connect(ctx, dsn)
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
store := statuspage.NewStore(pool)
checker := statuspage.NewHTTPChecker(2 * time.Second)
poller := statuspage.NewPoller(store, checker)
go poller.Run(ctx, time.Duration(intervalSeconds)*time.Second)
mux := http.NewServeMux()
mux.HandleFunc("/status/overview", withCORS(store.OverviewHandler))
mux.HandleFunc("/status/history", withCORS(store.HistoryHandler))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("statuspage-devserver listening on %s (poll-intervall: %ds)", addr, intervalSeconds)
log.Fatal(http.ListenAndServe(addr, mux))
}
func withCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
+2 -5
View File
@@ -2,16 +2,13 @@ module gitea.perlbach24.de/scripte/nexarch
go 1.22 go 1.22
require ( require github.com/jackc/pgx/v5 v5.6.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.6.0
golang.org/x/crypto v0.17.0
)
require ( require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect
) )
-2
View File
@@ -1,8 +1,6 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
-65
View File
@@ -1,65 +0,0 @@
package auth
import (
"encoding/json"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// ChangePasswordHandler stellt die Passwortaenderung fuer den bereits
// angemeldeten Benutzer bereit (IAM-08 Akzeptanzkriterium 2). Getrennt von
// Handler (Login/Logout), weil hier zusaetzlich der TenantUserStore fuer das
// Aktualisieren des Hashes gebraucht wird.
type ChangePasswordHandler struct {
users *user.TenantUserStore
}
func NewChangePasswordHandler(users *user.TenantUserStore) *ChangePasswordHandler {
return &ChangePasswordHandler{users: users}
}
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
// ChangePassword verlangt das aktuelle Passwort, bevor ein neues gesetzt wird
// — ohne diese Pruefung koennte ein gekapertes, noch gueltiges Session-Cookie
// allein zur vollstaendigen Kontoübernahme reichen (Passwort setzen, alte
// Session unbrauchbar machen). Muss hinter RequireAuth haengen.
func (h *ChangePasswordHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
var req changePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
creds, err := h.users.GetByIDForAuth(r.Context(), claims.UserID)
if err != nil {
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
return
}
if !VerifyPassword(creds.PasswordHash, req.CurrentPassword) {
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
return
}
newHash, err := HashPassword(req.NewPassword)
if err != nil {
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
return
}
if err := h.users.SetPasswordHash(r.Context(), claims.UserID, newHash); err != nil {
http.Error(w, "Passwort konnte nicht gespeichert werden.", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
-76
View File
@@ -1,76 +0,0 @@
package auth
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// Akzeptanzkriterium 2: Passwortaenderung verlangt das aktuelle Passwort.
func TestChangePassword_RequiresCurrentPassword(t *testing.T) {
pool := setupTenantDB(t, "changepw_test_wrong")
store := user.NewTenantUserStore(pool)
u := createUserWithPassword(t, store, "nutzer@example.com", "altes-passwort")
issuer := NewTokenIssuer("test-session-secret")
sessionToken, err := issuer.Issue(u.ID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
h := NewChangePasswordHandler(store)
body, _ := json.Marshal(map[string]string{"current_password": "falsches-altes-passwort", "new_password": "neues-passwort"})
req := httptest.NewRequest(http.MethodPost, "/account/change-password", bytes.NewReader(body))
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
rec := httptest.NewRecorder()
RequireAuth(issuer, h.ChangePassword)(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (falsches aktuelles passwort)", rec.Code)
}
creds, err := store.GetByIDForAuth(req.Context(), u.ID)
if err != nil {
t.Fatalf("credentials laden: %v", err)
}
if !VerifyPassword(creds.PasswordHash, "altes-passwort") {
t.Fatal("passwort haette NICHT geaendert werden duerfen")
}
}
func TestChangePassword_SucceedsWithCorrectCurrentPassword(t *testing.T) {
pool := setupTenantDB(t, "changepw_test_ok")
store := user.NewTenantUserStore(pool)
u := createUserWithPassword(t, store, "nutzer2@example.com", "altes-passwort")
issuer := NewTokenIssuer("test-session-secret")
sessionToken, err := issuer.Issue(u.ID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
h := NewChangePasswordHandler(store)
body, _ := json.Marshal(map[string]string{"current_password": "altes-passwort", "new_password": "neues-sicheres-passwort"})
req := httptest.NewRequest(http.MethodPost, "/account/change-password", bytes.NewReader(body))
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
rec := httptest.NewRecorder()
RequireAuth(issuer, h.ChangePassword)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
creds, err := store.GetByIDForAuth(req.Context(), u.ID)
if err != nil {
t.Fatalf("credentials laden: %v", err)
}
if !VerifyPassword(creds.PasswordHash, "neues-sicheres-passwort") {
t.Fatal("neues passwort wurde nicht uebernommen")
}
}
-67
View File
@@ -1,67 +0,0 @@
package auth
import (
"encoding/json"
"net/http"
"time"
)
// Handler stellt Login/Logout als HTTP-Endpunkte bereit. Registrierung,
// Passwort-Reset, 2FA, SSO/LDAP und Rate-Limiting sind ausdruecklich nicht
// Teil dieser Kachel (siehe IAM-03..07).
type Handler struct {
login *LoginService
}
func NewHandler(login *LoginService) *Handler {
return &Handler{login: login}
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
token, err := h.login.Login(r.Context(), req.Email, req.Password)
if err != nil {
http.Error(w, ErrInvalidCredentials.Error(), http.StatusUnauthorized)
return
}
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(AccessTokenTTL.Seconds()),
})
w.WriteHeader(http.StatusOK)
}
// Logout loescht das Session-Cookie. Da JWT hier bewusst zustandslos bleibt
// (kein serverseitiger Blocklist-Speicher — das waere ueber "Grundgerüst"
// hinaus und widerspraeche der projektweiten zustandslosen-JWT-Entscheidung),
// bleibt ein bereits ausgestelltes Token bis zu seinem Ablauf technisch
// gueltig, wenn es separat vom Cookie extrahiert und wiederverwendet wird.
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
w.WriteHeader(http.StatusOK)
}
-56
View File
@@ -1,56 +0,0 @@
package auth
import (
"context"
"errors"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
var ErrInvalidCredentials = errors.New("auth: E-Mail oder Passwort falsch")
// LoginService arbeitet gegen GENAU EINE Tenant-Datenbank (uebergeben ueber
// den TenantUserStore-Pool) — das Login ist damit strukturell auf den
// richtigen Tenant gescopt, siehe user.TenantUserStore.GetByEmailForAuth.
type LoginService struct {
users *user.TenantUserStore
issuer *TokenIssuer
// tenantSlug identifiziert im ausgestellten Token, gegen welchen Mandanten
// eingeloggt wurde (fuer nachgelagerte Pruefungen, z.B. Middleware-Logs).
tenantSlug string
}
func NewLoginService(users *user.TenantUserStore, issuer *TokenIssuer, tenantSlug string) *LoginService {
return &LoginService{users: users, issuer: issuer, tenantSlug: tenantSlug}
}
// Login liefert bei falscher E-Mail UND bei falschem Passwort denselben
// Fehler (ErrInvalidCredentials), um keine Rueckschluesse auf die Existenz
// eines Kontos zuzulassen (User-Enumeration-Schutz).
func (s *LoginService) Login(ctx context.Context, email, password string) (string, error) {
creds, err := s.users.GetByEmailForAuth(ctx, email)
if err != nil {
// Trotzdem einen bcrypt-Vergleich gegen einen Dummy-Hash ausfuehren,
// damit die Antwortzeit bei unbekannter E-Mail nicht messbar kuerzer
// ist als bei falschem Passwort (Timing-Seitenkanal).
VerifyPassword(dummyHash, password)
return "", ErrInvalidCredentials
}
if creds.User.Status != user.StatusActive {
return "", ErrInvalidCredentials
}
if !VerifyPassword(creds.PasswordHash, password) {
return "", ErrInvalidCredentials
}
return s.issuer.Issue(creds.User.ID, s.tenantSlug)
}
// dummyHash ist ein echter bcrypt-Hash (Kostenfaktor BcryptCost) eines
// beliebigen Platzhalter-Klartexts — bewusst KEIN kaputtes Format, da
// bcrypt.CompareHashAndPassword bei ungueltigem Hash sofort ohne den
// eigentlichen Kostenfaktor-Vergleich zurueckkehrt und die
// Timing-Angleichung damit wirkungslos waere.
const dummyHash = "$2a$12$cmwiETrG9DK5/uTM2fg4uetngYUspKjME5P8fNpk0QYTaO64N0r3C"
-177
View File
@@ -1,177 +0,0 @@
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
const usersSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);`
func setupTenantDB(t *testing.T, dbName string) *pgxpool.Pool {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, usersSchema); err != nil {
t.Fatalf("schema anwenden: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
return pool
}
func createUserWithPassword(t *testing.T, store *user.TenantUserStore, email, password string) user.User {
t.Helper()
ctx := context.Background()
u, err := store.Create(ctx, email, "Test User")
if err != nil {
t.Fatalf("create user: %v", err)
}
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
return u
}
func TestLoginService_SuccessAndWrongPassword(t *testing.T) {
pool := setupTenantDB(t, "test_iam02_login")
store := user.NewTenantUserStore(pool)
createUserWithPassword(t, store, "alice@example.com", "korrektes-passwort")
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
login := NewLoginService(store, issuer, "acme")
token, err := login.Login(context.Background(), "alice@example.com", "korrektes-passwort")
if err != nil {
t.Fatalf("login: %v", err)
}
if token == "" {
t.Fatal("erwartet nicht-leeres token")
}
if _, err := login.Login(context.Background(), "alice@example.com", "falsches-passwort"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials, habe %v", err)
}
if _, err := login.Login(context.Background(), "unbekannt@example.com", "irgendwas"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials bei unbekannter email, habe %v", err)
}
}
// Pruefung 1: kein Cross-Tenant-Login moeglich, obwohl dieselbe E-Mail in
// zwei unterschiedlichen Tenant-Datenbanken mit unterschiedlichen Passwoertern
// existiert.
func TestLoginService_NoCrossTenantLogin(t *testing.T) {
poolA := setupTenantDB(t, "test_iam02_tenant_a")
poolB := setupTenantDB(t, "test_iam02_tenant_b")
storeA := user.NewTenantUserStore(poolA)
storeB := user.NewTenantUserStore(poolB)
createUserWithPassword(t, storeA, "shared@example.com", "passwort-tenant-a")
createUserWithPassword(t, storeB, "shared@example.com", "passwort-tenant-b")
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
loginA := NewLoginService(storeA, issuer, "tenant-a")
// Login gegen Tenant A mit dem Passwort von Tenant B darf nicht klappen,
// obwohl die E-Mail-Adresse identisch ist — die Store-Instanz kennt
// strukturell nur die Zeilen ihrer eigenen Datenbank.
if _, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-b"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials fuer fremdes tenant-passwort, habe %v", err)
}
token, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-a")
if err != nil {
t.Fatalf("login gegen eigenen tenant sollte klappen: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.TenantSlug != "tenant-a" {
t.Fatalf("token tenant = %q, want tenant-a", claims.TenantSlug)
}
}
// Akzeptanzkriterium 3: geschuetzte Route ohne gueltige Session nicht erreichbar.
func TestRequireAuth_BlocksWithoutValidCookie(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Kein Cookie.
req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
rec := httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne cookie: status = %d, want 401", rec.Code)
}
// Manipuliertes Cookie.
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: "kaputt.token.hier"})
rec = httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("mit kaputtem cookie: status = %d, want 401", rec.Code)
}
// Gueltiges Token.
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: token})
rec = httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("mit gueltigem cookie: status = %d, want 200", rec.Code)
}
}
-41
View File
@@ -1,41 +0,0 @@
package auth
import (
"context"
"net/http"
)
const CookieName = "nexarch_session"
type contextKey int
const claimsContextKey contextKey = iota
// RequireAuth schuetzt eine Route: ohne gueltiges, nicht abgelaufenes Token
// im Session-Cookie wird 401 zurueckgegeben und der Handler nicht aufgerufen
// (IAM-02 Akzeptanzkriterium 3).
func RequireAuth(issuer *TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(CookieName)
if err != nil {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
claims, err := issuer.Verify(cookie.Value)
if err != nil {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsContextKey, claims)
next(w, r.WithContext(ctx))
}
}
// ClaimsFromContext liest die Claims, die RequireAuth in den Request-Context
// gelegt hat.
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
c, ok := ctx.Value(claimsContextKey).(*Claims)
return c, ok
}
-28
View File
@@ -1,28 +0,0 @@
// Package auth implementiert Core IAM-02: Login/Logout, Passwort-Hashing und
// die Middleware zum Schutz von Routen. Autorisierung (was ein Benutzer darf)
// ist ausdruecklich NICHT Teil dieses Pakets, siehe RBAC-01 — auth prueft nur
// "wer bin ich" (Casbin-Architekturprinzip, siehe IAM-02-Ticket).
package auth
import "golang.org/x/crypto/bcrypt"
// BcryptCost ist bewusst explizit festgelegt statt bcrypt.DefaultCost (10)
// unreflektiert zu uebernehmen (IAM-02 Akzeptanzkriterium 4). Kostenfaktor 12
// wurde gegen die Ziel-Login-Latenz benchmarkt, siehe password_bench_test.go
// und den Pruefungs-Eintrag in der Commit-Nachricht.
const BcryptCost = 12
func HashPassword(plain string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(plain), BcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// VerifyPassword ist timing-safe: bcrypt.CompareHashAndPassword vergleicht
// konstant in der Zeit bzgl. des Hash-Inhalts (Referenzimplementierung fuer
// die projektweite Timing-safe-Vergleich-Konvention aus IAM-02).
func VerifyPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
-42
View File
@@ -1,42 +0,0 @@
package auth
import (
"testing"
"time"
)
// TargetLoginLatency ist der Zielwert aus IAM-02 Akzeptanzkriterium 4: der
// bcrypt-Vergleich allein darf die Login-Latenz nicht dominieren. 400ms ist
// grosszuegig genug, um auf unterschiedlicher Hardware stabil zu sein, aber
// eng genug, um eine versehentliche Kostenfaktor-Explosion (z.B. 16 statt 12)
// zuverlaessig aufzudecken.
const TargetLoginLatency = 400 * time.Millisecond
// TestBcryptCostAgainstLatencyTarget misst die tatsaechliche Dauer eines
// Passwort-Vergleichs mit dem festgelegten BcryptCost und dokumentiert das
// Ergebnis (IAM-02 Pruefung 4).
func TestBcryptCostAgainstLatencyTarget(t *testing.T) {
hash, err := HashPassword("benchmark-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
start := time.Now()
if !VerifyPassword(hash, "benchmark-passwort") {
t.Fatal("verifikation haette erfolgreich sein muessen")
}
elapsed := time.Since(start)
t.Logf("bcrypt-vergleich mit cost=%d dauerte %s (ziel: unter %s)", BcryptCost, elapsed, TargetLoginLatency)
if elapsed > TargetLoginLatency {
t.Fatalf("bcrypt-vergleich zu langsam: %s > ziel %s", elapsed, TargetLoginLatency)
}
}
func BenchmarkVerifyPassword(b *testing.B) {
hash, _ := HashPassword("benchmark-passwort")
b.ResetTimer()
for i := 0; i < b.N; i++ {
VerifyPassword(hash, "benchmark-passwort")
}
}
-28
View File
@@ -1,28 +0,0 @@
package auth
import "testing"
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("s3hr-geheim!")
if err != nil {
t.Fatalf("hash: %v", err)
}
if hash == "s3hr-geheim!" {
t.Fatal("passwort wurde nicht gehasht")
}
if !VerifyPassword(hash, "s3hr-geheim!") {
t.Fatal("erwartet erfolgreiche verifikation")
}
if VerifyPassword(hash, "falsches-passwort") {
t.Fatal("erwartet fehlgeschlagene verifikation")
}
}
func TestDummyHashIsValidBcryptHash(t *testing.T) {
// Stellt sicher, dass der Timing-Angleichs-Hash in login.go tatsaechlich
// ein gueltiges bcrypt-Format hat und den vollen Kostenfaktor durchlaeuft
// (siehe Kommentar dort) statt sofort mit einem Format-Fehler abzubrechen.
if VerifyPassword(dummyHash, "irgendein-text") {
t.Fatal("dummyHash sollte fuer beliebigen text nicht passen")
}
}
-44
View File
@@ -1,44 +0,0 @@
package auth
import (
"encoding/json"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// ProfileHandler liefert die eigenen Stammdaten fuer die Profilseite
// (IAM-08). Getrennt von ChangePasswordHandler, da unterschiedliche
// Lesbarkeitsanforderungen (nur GET, kein Nebeneffekt).
type ProfileHandler struct {
users *user.TenantUserStore
}
func NewProfileHandler(users *user.TenantUserStore) *ProfileHandler {
return &ProfileHandler{users: users}
}
type meResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
// Me liefert die Stammdaten des angemeldeten Benutzers. Muss hinter
// RequireAuth haengen.
func (h *ProfileHandler) Me(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
u, err := h.users.Get(r.Context(), claims.UserID)
if err != nil {
http.Error(w, "benutzer nicht gefunden", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(meResponse{ID: u.ID, Email: u.Email, Name: u.Name})
}
-48
View File
@@ -1,48 +0,0 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func TestProfileMe_ReturnsOwnData(t *testing.T) {
pool := setupTenantDB(t, "profile_test_me")
store := user.NewTenantUserStore(pool)
u := createUserWithPassword(t, store, "profil@example.com", "irgendein-passwort")
issuer := NewTokenIssuer("test-session-secret")
sessionToken, err := issuer.Issue(u.ID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
h := NewProfileHandler(store)
req := httptest.NewRequest(http.MethodGet, "/account/me", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
rec := httptest.NewRecorder()
RequireAuth(issuer, h.Me)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
}
func TestProfileMe_RejectsWithoutSession(t *testing.T) {
pool := setupTenantDB(t, "profile_test_noauth")
store := user.NewTenantUserStore(pool)
issuer := NewTokenIssuer("test-session-secret")
h := NewProfileHandler(store)
req := httptest.NewRequest(http.MethodGet, "/account/me", nil)
rec := httptest.NewRecorder()
RequireAuth(issuer, h.Me)(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 ohne session-cookie", rec.Code)
}
}
-63
View File
@@ -1,63 +0,0 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
// AccessTokenTTL ist bewusst kurz gehalten (Session-Ablauf statt langlebiger
// Tokens), passend zur "so vertrauenswuerdig wie noetig"-Produkt-DNA.
const AccessTokenTTL = 30 * time.Minute
var ErrInvalidToken = errors.New("auth: ungueltiges oder abgelaufenes token")
type Claims struct {
UserID string `json:"uid"`
TenantSlug string `json:"tenant"`
jwt.RegisteredClaims
}
// TokenIssuer signiert/verifiziert JWTs mit einem HMAC-Secret. Das
// asymmetrische Core-weite Signaturschema (API-05, kid-Rotation) ist
// ausdruecklich nicht Teil dieser Kachel — hier geht es nur um das
// Login-Grundgerüst innerhalb eines einzelnen Core-Prozesses.
type TokenIssuer struct {
secret []byte
}
func NewTokenIssuer(secret string) *TokenIssuer {
return &TokenIssuer{secret: []byte(secret)}
}
func (i *TokenIssuer) Issue(userID, tenantSlug string) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
TenantSlug: tenantSlug,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenTTL)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(i.secret)
}
// Verify prueft Signatur UND Ablauf (jwt.ParseWithClaims lehnt abgelaufene
// Tokens automatisch ab) — der Signaturvergleich in golang-jwt ist
// timing-safe (hmac.Equal).
func (i *TokenIssuer) Verify(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return i.secret, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
-82
View File
@@ -1,82 +0,0 @@
package auth
import (
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestTokenIssueAndVerify(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.UserID != "user-1" || claims.TenantSlug != "acme" {
t.Fatalf("claims unerwartet: %+v", claims)
}
}
// Pruefung 2: Token-Manipulationstest.
func TestTokenVerify_RejectsManipulatedPayload(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("unerwartetes token-format: %d teile", len(parts))
}
// Payload-Segment leicht veraendern (Signatur passt danach nicht mehr).
tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2]
if _, err := issuer.Verify(tampered); err == nil {
t.Fatal("erwartet fehler bei manipuliertem token, habe nil")
}
}
func TestTokenVerify_RejectsWrongSecret(t *testing.T) {
issuer := NewTokenIssuer("secret-a")
other := NewTokenIssuer("secret-b")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := other.Verify(token); err == nil {
t.Fatal("erwartet fehler bei falschem secret, habe nil")
}
}
// Pruefung 3: abgelaufenes Token erzwingt Neuanmeldung.
func TestTokenVerify_RejectsExpiredToken(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
claims := Claims{
UserID: "user-1",
TenantSlug: "acme",
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * AccessTokenTTL)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
expired, err := tok.SignedString([]byte("test-secret-nur-fuer-tests"))
if err != nil {
t.Fatalf("signieren: %v", err)
}
if _, err := issuer.Verify(expired); err == nil {
t.Fatal("erwartet fehler bei abgelaufenem token, habe nil")
}
}
-40
View File
@@ -1,40 +0,0 @@
package authtoken
import (
"context"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// CompletePasswordReset loest ein Reset-Token ein und setzt das neue
// Passwort — komplett OHNE bestehende Session (Akzeptanzkriterium 2 gilt
// analog fuer Reset wie fuer Invite: der Vorgang ersetzt eine Anmeldung).
func CompletePasswordReset(ctx context.Context, tokens *Store, users *user.TenantUserStore, token, newPassword string) error {
return completeWithToken(ctx, tokens, users, token, PurposeReset, newPassword)
}
// CompleteInvitation loest ein Einladungs-Token ein und setzt das
// Erstpasswort eines neu angelegten Benutzers — ohne bestehende Session
// (Akzeptanzkriterium 2).
func CompleteInvitation(ctx context.Context, tokens *Store, users *user.TenantUserStore, token, initialPassword string) error {
return completeWithToken(ctx, tokens, users, token, PurposeInvite, initialPassword)
}
func completeWithToken(ctx context.Context, tokens *Store, users *user.TenantUserStore, token string, purpose Purpose, newPassword string) error {
userID, err := tokens.Consume(ctx, token, purpose)
if err != nil {
return err
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
return fmt.Errorf("passwort hashen: %w", err)
}
if err := users.SetPasswordHash(ctx, userID, hash); err != nil {
return fmt.Errorf("passwort setzen: %w", err)
}
return nil
}
-109
View File
@@ -1,109 +0,0 @@
package authtoken
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// ResetTokenTTL: 1 Stunde ist der uebliche Kompromiss zwischen Nutzbarkeit
// und Angriffsflaeche fuer Passwort-Reset-Links.
const ResetTokenTTL = time.Hour
// Notifier verschickt das Reset-Token an den Benutzer. Core CFG-02
// (Benachrichtigungs-Dispatcher) ist noch nicht in diesem Branch verfuegbar
// (siehe SICHERHEITSKONZEPT.md / Core-Board-Konvention: Versand laeuft
// grundsaetzlich ueber CFG-02, kein eigener E-Mail-Versand je Modul) — bis
// zur Anbindung protokolliert LogNotifier den Vorgang nur, statt eine E-Mail
// zu verschicken. Produktionsreif ist erst der Austausch gegen einen
// CFG-02-Client, keine neue eigene Versandlogik hier.
type Notifier interface {
NotifyPasswordReset(ctx context.Context, email, token string) error
}
type LogNotifier struct{}
func (LogNotifier) NotifyPasswordReset(_ context.Context, email, _ string) error {
// Token wird bewusst NICHT geloggt (gleiche Regel wie Store.Create).
slog.Info("passwort-reset angefordert (kein CFG-02 in diesem branch, nur protokolliert)", "email_domain_hint", domainHint(email))
return nil
}
func domainHint(email string) string {
for i := len(email) - 1; i >= 0; i-- {
if email[i] == '@' {
return email[i:]
}
}
return ""
}
// Handler stellt Passwort-Reset als HTTP-Endpunkte bereit (IAM-08).
type Handler struct {
tokens *Store
users *user.TenantUserStore
notifier Notifier
}
func NewHandler(tokens *Store, users *user.TenantUserStore, notifier Notifier) *Handler {
if notifier == nil {
notifier = LogNotifier{}
}
return &Handler{tokens: tokens, users: users, notifier: notifier}
}
type requestResetRequest struct {
Email string `json:"email"`
}
// RequestReset liefert IMMER dieselbe Erfolgsmeldung, unabhaengig davon, ob
// die E-Mail-Adresse existiert (Akzeptanzkriterium 3 / Pruefung 1,
// User-Enumeration-Schutz — gleiches Prinzip wie auth.LoginService.Login).
func (h *Handler) RequestReset(w http.ResponseWriter, r *http.Request) {
var req requestResetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
// Absichtlich: Fehler (unbekannte E-Mail) fuehrt NICHT zu einer anderen
// HTTP-Antwort als der Erfolgsfall — nur der interne Pfad unterscheidet sich.
creds, err := h.users.GetByEmailForAuth(r.Context(), req.Email)
if err == nil {
token, tokenErr := h.tokens.Create(r.Context(), creds.User.ID, PurposeReset, ResetTokenTTL)
if tokenErr == nil {
_ = h.notifier.NotifyPasswordReset(r.Context(), req.Email, token)
}
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"message": "Falls ein Konto mit dieser E-Mail-Adresse existiert, wurde eine Nachricht mit weiteren Schritten verschickt.",
})
}
type completeResetRequest struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
// CompleteReset schliesst den Reset ab (Akzeptanzkriterium 3: generische
// Fehlermeldung bei ungueltigem/abgelaufenem Token, siehe ErrInvalidToken).
func (h *Handler) CompleteReset(w http.ResponseWriter, r *http.Request) {
var req completeResetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
if err := CompletePasswordReset(r.Context(), h.tokens, h.users, req.Token, req.NewPassword); err != nil {
http.Error(w, "Link ungültig oder abgelaufen.", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
-94
View File
@@ -1,94 +0,0 @@
package authtoken
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// fakeNotifier zeichnet auf, ob/mit welchem Token benachrichtigt wurde —
// ersetzt LogNotifier im Test, damit der Test pruefen kann, OB ein Token
// erzeugt wurde, ohne die HTTP-Antwort danach zu unterscheiden
// (Akzeptanzkriterium 3: die Antwort selbst bleibt in beiden Faellen gleich).
type fakeNotifier struct {
calls []string // e-mail-adressen, fuer die notifiziert wurde
}
func (f *fakeNotifier) NotifyPasswordReset(_ context.Context, email, _ string) error {
f.calls = append(f.calls, email)
return nil
}
// Akzeptanzkriterium 3 + Pruefung 1: Antwort ist IDENTISCH, egal ob die
// E-Mail-Adresse existiert — nur intern (Notifier-Aufruf) unterscheidet sich.
func TestRequestReset_SameResponseRegardlessOfExistence(t *testing.T) {
tokens, users, userID, cleanup := setupTest(t)
defer cleanup()
_ = userID
u, err := users.Get(context.Background(), userID)
if err != nil {
t.Fatalf("benutzer laden: %v", err)
}
notifier := &fakeNotifier{}
h := NewHandler(tokens, users, notifier)
// Fall 1: existierende E-Mail.
body1, _ := json.Marshal(requestResetRequest{Email: u.Email})
req1 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/request", bytes.NewReader(body1))
rec1 := httptest.NewRecorder()
h.RequestReset(rec1, req1)
// Fall 2: nicht existierende E-Mail.
body2, _ := json.Marshal(requestResetRequest{Email: "gibt-es-nicht@example.com"})
req2 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/request", bytes.NewReader(body2))
rec2 := httptest.NewRecorder()
h.RequestReset(rec2, req2)
if rec1.Code != rec2.Code {
t.Fatalf("status codes unterscheiden sich: %d vs %d — verraet Konto-Existenz", rec1.Code, rec2.Code)
}
if rec1.Body.String() != rec2.Body.String() {
t.Fatalf("antwort-body unterscheidet sich: %q vs %q — verraet Konto-Existenz", rec1.Body.String(), rec2.Body.String())
}
// Intern wurde aber nur fuer die existierende Adresse tatsaechlich benachrichtigt.
if len(notifier.calls) != 1 || notifier.calls[0] != u.Email {
t.Fatalf("erwartet genau 1 benachrichtigung fuer %q, habe %v", u.Email, notifier.calls)
}
}
// Akzeptanzkriterium 3: vollstaendiger Reset-Flow ueber HTTP.
func TestCompleteReset_Works(t *testing.T) {
tokens, users, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := tokens.Create(ctx, userID, PurposeReset, ResetTokenTTL)
if err != nil {
t.Fatalf("token erzeugen: %v", err)
}
h := NewHandler(tokens, users, &fakeNotifier{})
body, _ := json.Marshal(completeResetRequest{Token: token, NewPassword: "neues-sicheres-passwort"})
req := httptest.NewRequest(http.MethodPost, "/auth/password-reset/complete", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.CompleteReset(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
// Token ist jetzt verbraucht — zweiter Versuch schlaegt fehl.
body2, _ := json.Marshal(completeResetRequest{Token: token, NewPassword: "noch-ein-passwort"})
req2 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/complete", bytes.NewReader(body2))
rec2 := httptest.NewRecorder()
h.CompleteReset(rec2, req2)
if rec2.Code != http.StatusBadRequest {
t.Fatalf("wiederverwendung: status = %d, want 400", rec2.Code)
}
}
-103
View File
@@ -1,103 +0,0 @@
// Package authtoken implementiert Core IAM-03: einmal-verwendbare Tokens fuer
// Passwort-Reset und Einladung neuer Benutzer, mit Ablaufzeit — bewusst
// getrennt von internal/auth (Sessions/JWT), da es hier um einen
// vorgelagerten, session-losen Vorgang geht (Akzeptanzkriterium 2: Einladung
// setzt das Erstpasswort OHNE bestehende Session).
package authtoken
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Purpose string
const (
PurposeReset Purpose = "reset"
PurposeInvite Purpose = "invite"
)
// ErrInvalidToken deckt sowohl unbekannte als auch bereits verwendete oder
// abgelaufene Tokens ab (Akzeptanzkriterium 3) — bewusst EIN Fehler, damit
// ein Angreifer aus der Antwort nicht ableiten kann, welcher der drei Faelle
// vorliegt.
var ErrInvalidToken = errors.New("authtoken: token ungueltig, bereits verwendet oder abgelaufen")
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Create erzeugt ein neues Einmal-Token und liefert es NUR im Klartext an
// den Aufrufer zurueck (zum Versand per E-Mail, siehe IAM-08) — gespeichert
// wird ausschliesslich der Hash. Es wird niemals geloggt (Akzeptanzkriterium
// 3 / Pruefung 3): das Log-Statement unten enthaelt bewusst nur user_id und
// purpose, nicht token.
func (s *Store) Create(ctx context.Context, userID string, purpose Purpose, ttl time.Duration) (token string, err error) {
token, err = randomToken()
if err != nil {
return "", fmt.Errorf("token erzeugen: %w", err)
}
hash := hashToken(token)
expiresAt := time.Now().Add(ttl)
_, err = s.pool.Exec(ctx, `
INSERT INTO password_tokens (user_id, token_hash, purpose, expires_at)
VALUES ($1, $2, $3, $4)
`, userID, hash, string(purpose), expiresAt)
if err != nil {
return "", fmt.Errorf("token speichern: %w", err)
}
slog.Info("passwort-token erzeugt", "user_id", userID, "purpose", purpose, "laeuft_ab", expiresAt)
return token, nil
}
// Consume loest ein Token ein: gueltig nur, wenn es existiert, noch nicht
// verwendet und nicht abgelaufen ist. Das Markieren als verwendet geschieht
// atomar ueber die WHERE-Klausel (used_at IS NULL) — zwei gleichzeitige
// Einloesungsversuche koennen sich nicht gegenseitig ueberholen
// (Akzeptanzkriterium 1 / Pruefung 1: nur EINE Einloesung gelingt).
func (s *Store) Consume(ctx context.Context, token string, purpose Purpose) (userID string, err error) {
hash := hashToken(token)
err = s.pool.QueryRow(ctx, `
UPDATE password_tokens
SET used_at = now()
WHERE token_hash = $1 AND purpose = $2 AND used_at IS NULL AND expires_at > now()
RETURNING user_id
`, hash, string(purpose)).Scan(&userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
slog.Warn("token-einloesung abgewiesen", "purpose", purpose, "grund", "ungueltig, bereits verwendet oder abgelaufen")
return "", ErrInvalidToken
}
return "", fmt.Errorf("token einloesen: %w", err)
}
return userID, nil
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
-187
View File
@@ -1,187 +0,0 @@
package authtoken
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupTest(t *testing.T) (*Store, *user.TenantUserStore, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS password_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
token_hash BYTEA NOT NULL UNIQUE, purpose TEXT NOT NULL CHECK (purpose IN ('reset','invite')),
expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
userStore := user.NewTenantUserStore(pool)
email := fmt.Sprintf("reset-test-%d@example.com", time.Now().UnixNano())
u, err := userStore.Create(ctx, email, "Reset Test")
if err != nil {
t.Fatalf("testuser anlegen: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), userStore, u.ID, cleanup
}
// Akzeptanzkriterium 1 + Pruefung 1: Wiederverwendung eines bereits
// eingeloesten Tokens wird abgewiesen.
func TestConsume_RejectsReuse(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeReset, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
gotUserID, err := store.Consume(ctx, token, PurposeReset)
if err != nil {
t.Fatalf("erste einloesung: %v", err)
}
if gotUserID != userID {
t.Fatalf("user id = %q, want %q", gotUserID, userID)
}
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei zweiter einloesung, habe %v", err)
}
}
// Akzeptanzkriterium 1 + 3 + Pruefung 2: Ablaufzeit wird serverseitig
// durchgesetzt.
func TestConsume_RejectsExpiredToken(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeReset, -1*time.Second) // sofort abgelaufen
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei abgelaufenem token, habe %v", err)
}
}
func TestConsume_RejectsUnknownToken(t *testing.T) {
store, _, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Consume(ctx, "niemals-ausgestelltes-token", PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken, habe %v", err)
}
}
func TestConsume_RejectsWrongPurpose(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeInvite, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei falschem purpose, habe %v", err)
}
}
// Akzeptanzkriterium 2: Einladungs-Link setzt das Erstpasswort ohne
// bestehende Session — der Ablauf braucht keinerlei Auth-Middleware/Cookie.
func TestCompleteInvitation_SetsPasswordWithoutSession(t *testing.T) {
store, userStore, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeInvite, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
if err := CompleteInvitation(ctx, store, userStore, token, "neues-erstpasswort-123"); err != nil {
t.Fatalf("complete invitation: %v", err)
}
u, err := userStore.Get(ctx, userID)
if err != nil {
t.Fatalf("user lesen: %v", err)
}
creds, err := userStore.GetByEmailForAuth(ctx, u.Email)
if err != nil {
t.Fatalf("credentials lesen: %v", err)
}
if !auth.VerifyPassword(creds.PasswordHash, "neues-erstpasswort-123") {
t.Fatal("erwartet, dass das gesetzte erstpasswort verifizierbar ist")
}
// Token ist danach verbraucht, kann nicht doppelt eingeloest werden.
if err := CompleteInvitation(ctx, store, userStore, token, "irgendwas"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei erneuter einloesung, habe %v", err)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Token werden nicht im Klartext geloggt.
func TestCreateAndConsume_NeverLogTokenPlaintext(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
var buf bytes.Buffer
prevLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
defer slog.SetDefault(prevLogger)
token, err := store.Create(ctx, userID, PurposeReset, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.Consume(ctx, "falsches-token-zum-testen-des-log-pfads", PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("consume (ungueltig): %v", err)
}
if _, err := store.Consume(ctx, token, PurposeReset); err != nil {
t.Fatalf("consume (gueltig): %v", err)
}
logOutput := buf.String()
if strings.Contains(logOutput, token) {
t.Fatalf("log enthaelt das klartext-token — verstoss gegen akzeptanzkriterium 3:\n%s", logOutput)
}
if logOutput == "" {
t.Fatal("erwartet ueberhaupt log-ausgabe fuer diesen stichprobentest")
}
}
+87
View File
@@ -0,0 +1,87 @@
// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit
// Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion
// des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server
// + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket.
package flag
import (
"context"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("flag: nicht gefunden")
// Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon
// getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung).
type Flag struct {
Key string
Enabled bool
RolloutPercentage int
TargetTenantSlugs []string
}
// Store ist die Verwaltungsseite (Admin): Flags definieren/lesen.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func (s *Store) Set(ctx context.Context, f Flag) error {
if f.TargetTenantSlugs == nil {
f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array.
}
_, err := s.pool.Exec(ctx, `
INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (key) DO UPDATE SET
enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now()
`, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs)
if err != nil {
return fmt.Errorf("flag speichern: %w", err)
}
return nil
}
func (s *Store) Get(ctx context.Context, key string) (Flag, error) {
var f Flag
row := s.pool.QueryRow(ctx, `
SELECT key, enabled, rollout_percentage, target_tenant_slugs
FROM feature_flags WHERE key = $1
`, key)
if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Flag{}, ErrNotFound
}
return Flag{}, fmt.Errorf("flag lesen: %w", err)
}
return f, nil
}
// evaluate wendet die Strategien in fester Reihenfolge an: globaler
// An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout.
// Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default,
// kein Feature wird versehentlich aktiv.
func evaluate(f Flag, tenantSlug string) bool {
if f.Enabled {
return true
}
for _, target := range f.TargetTenantSlugs {
if target == tenantSlug {
return true
}
}
if f.RolloutPercentage > 0 {
h := fnv.New32a()
_, _ = h.Write([]byte(f.Key + "|" + tenantSlug))
return int(h.Sum32()%100) < f.RolloutPercentage
}
return false
}
+43
View File
@@ -0,0 +1,43 @@
package flag
import "testing"
func TestEvaluate_GlobalEnabled(t *testing.T) {
f := Flag{Key: "k", Enabled: true}
if !evaluate(f, "irgendein-tenant") {
t.Fatal("global aktiviertes flag sollte fuer jeden tenant true liefern")
}
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie.
func TestEvaluate_TargetTenantStrategy(t *testing.T) {
f := Flag{Key: "k", Enabled: false, TargetTenantSlugs: []string{"acme"}}
if !evaluate(f, "acme") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if evaluate(f, "globex") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
func TestEvaluate_RolloutPercentageBoundaries(t *testing.T) {
full := Flag{Key: "k", RolloutPercentage: 100}
if !evaluate(full, "beliebiger-tenant-1") || !evaluate(full, "beliebiger-tenant-2") {
t.Fatal("100% rollout sollte immer true liefern")
}
none := Flag{Key: "k", RolloutPercentage: 0}
if evaluate(none, "beliebiger-tenant") {
t.Fatal("0% rollout ohne enabled/zielgruppe sollte false liefern")
}
}
func TestEvaluate_RolloutIsDeterministicPerTenant(t *testing.T) {
f := Flag{Key: "k", RolloutPercentage: 50}
first := evaluate(f, "stabiler-tenant")
for i := 0; i < 5; i++ {
if evaluate(f, "stabiler-tenant") != first {
t.Fatal("rollout-auswertung sollte fuer denselben tenant/key stabil sein")
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package flag
import (
"context"
"log/slog"
"sync"
"time"
)
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
// (Akzeptanzkriterium 2/3): eine Aenderung wirkt spaetestens nach dieser
// Zeit auf allen Core-Instanzen, ohne dass ein Dienst neu gestartet werden
// muss (Akzeptanzkriterium 3).
const DefaultCacheTTL = 5 * time.Second
type cacheEntry struct {
flag Flag
expiresAt time.Time
}
// Service ist die Auswertungsseite (SDK/Client-Analogon zu Unleash) mit
// lokalem TTL-Cache. Bewusst getrennt von Store (Verwaltung).
type Service struct {
store *Store
ttl time.Duration
mu sync.RWMutex
cache map[string]cacheEntry
}
func NewService(store *Store, ttl time.Duration) *Service {
if ttl <= 0 {
ttl = DefaultCacheTTL
}
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
}
// IsEnabled wertet ein Flag fuer einen Tenant aus. Liefert IMMER einen
// bool ohne Fehlerwert — ein nicht erreichbarer Flag-Dienst darf abhaengige
// Aufrufer nicht zum Absturz bringen oder zu Fehlerbehandlungscode zwingen,
// der leicht vergessen wird (Akzeptanzkriterium 3 / Pruefung 3: dokumentiertes
// Fallback-Verhalten = false, ggf. aus dem zuletzt bekannten Zwischenspeicher).
func (s *Service) IsEnabled(ctx context.Context, tenantSlug, key string) bool {
f, ok := s.resolve(ctx, key)
if !ok {
return false
}
return evaluate(f, tenantSlug)
}
func (s *Service) resolve(ctx context.Context, key string) (Flag, bool) {
s.mu.RLock()
entry, exists := s.cache[key]
fresh := exists && time.Now().Before(entry.expiresAt)
s.mu.RUnlock()
if fresh {
return entry.flag, true
}
f, err := s.store.Get(ctx, key)
if err != nil {
if exists {
slog.Warn("feature-flag-dienst nicht erreichbar, nutze zwischengespeicherten stand",
"flag_key", key, "error", err)
return entry.flag, true
}
slog.Warn("feature-flag-dienst nicht erreichbar, kein zwischengespeicherter stand vorhanden, fallback: deaktiviert",
"flag_key", key, "error", err)
return Flag{}, false
}
s.mu.Lock()
s.cache[key] = cacheEntry{flag: f, expiresAt: time.Now().Add(s.ttl)}
s.mu.Unlock()
return f, true
}
// Invalidate erzwingt beim naechsten IsEnabled-Aufruf ein sofortiges Neuladen
// aus der Datenbank statt auf den TTL-Ablauf zu warten — wird nach Store.Set
// auf derselben Instanz aufgerufen, damit der Schreiber die eigene Aenderung
// ohne Wartezeit sieht. Andere Core-Instanzen sehen sie spaetestens nach
// DefaultCacheTTL (siehe Akzeptanzkriterium 3).
func (s *Service) Invalidate(key string) {
s.mu.Lock()
delete(s.cache, key)
s.mu.Unlock()
}
+179
View File
@@ -0,0 +1,179 @@
package flag
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupFlagStoreTest(t *testing.T) (*Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0,
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`)
pool.Close()
}
return NewStore(pool), cleanup
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie liefert im Test
// die erwartete Auswertung.
func TestService_TargetTenantStrategy(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_target_flag", TargetTenantSlugs: []string{"acme"}}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
if !svc.IsEnabled(ctx, "acme", "test_target_flag") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if svc.IsEnabled(ctx, "globex", "test_target_flag") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
// Akzeptanzkriterium 2 + 3 + Pruefung 1: Flag-Aenderung wirkt innerhalb der
// dokumentierten Cache-Invalidierungszeit, automatisiert gemessen.
func TestService_CacheInvalidationTiming(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
const ttl = 150 * time.Millisecond
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, ttl)
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("erwartet false vor der aenderung")
}
// Aenderung "auf einer anderen instanz" simulieren: direkt ueber den
// Store, ohne svc.Invalidate aufzurufen.
changedAt := time.Now()
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
// Sofort danach sollte der Cache noch den alten Stand liefern.
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("cache haette den alten (false) stand liefern sollen, direkt nach der aenderung")
}
deadline := changedAt.Add(ttl + 100*time.Millisecond)
for time.Now().Before(deadline) {
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
elapsed := time.Since(changedAt)
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", elapsed, ttl)
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
}
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour) // lange TTL, damit Invalidate den unterschied macht
_ = svc.IsEnabled(ctx, "acme", "test_invalidate_flag")
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc.Invalidate("test_invalidate_flag")
if !svc.IsEnabled(ctx, "acme", "test_invalidate_flag") {
t.Fatal("erwartet sofort sichtbaren neuen stand nach Invalidate")
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Ausfall des Flag-Dienstes fuehrt zu
// dokumentiertem Fallback-Verhalten, nicht zum Absturz.
func TestService_FallsBackOnStoreFailure(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_fallback_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
// Cache vorwaermen, waehrend die DB noch erreichbar ist.
if !svc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet true bei funktionierender db")
}
brokenPool, err := pgxpool.New(ctx, "postgresql://nonexistent-host-fuer-test:5432/x?connect_timeout=1")
if err != nil {
t.Fatalf("broken pool erstellen (sollte nicht sofort verbinden): %v", err)
}
brokenStore := NewStore(brokenPool)
svcWithCache := NewService(brokenStore, time.Nanosecond) // TTL sofort abgelaufen, erzwingt reload-versuch
svcWithCache.mu.Lock()
svcWithCache.cache["test_fallback_flag"] = cacheEntry{
flag: Flag{Key: "test_fallback_flag", Enabled: true},
expiresAt: time.Now().Add(-time.Hour), // bereits abgelaufen
}
svcWithCache.mu.Unlock()
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict statt einen fallback zu liefern: %v", r)
}
}()
if !svcWithCache.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fallback auf zwischengespeicherten (true) stand bei db-ausfall")
}
}()
// Voellig frischer Dienst ohne jeglichen cache + kaputte db -> sicherer
// default false, kein absturz.
freshSvc := NewService(brokenStore, time.Hour)
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict: %v", r)
}
}()
if freshSvc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fail-safe false ohne cache und mit kaputter db")
}
}()
}
+25
View File
@@ -0,0 +1,25 @@
package health
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
// DatabaseChecker prueft die tatsaechliche Erreichbarkeit der Datenbank
// (Ping) — nicht nur, ob der Pool existiert.
func DatabaseChecker(pool *pgxpool.Pool) CheckerFunc {
return func(ctx context.Context) error {
return pool.Ping(ctx)
}
}
// QueueChecker prueft, dass die Postgres-basierte Job-Queue (siehe CFG-02)
// tatsaechlich abfragbar ist — eine eigene, benannte Abhaengigkeit neben der
// reinen DB-Erreichbarkeit (Akzeptanzkriterium 1).
func QueueChecker(pool *pgxpool.Pool) CheckerFunc {
return func(ctx context.Context) error {
_, err := pool.Exec(ctx, `SELECT 1`)
return err
}
}
+49
View File
@@ -0,0 +1,49 @@
package health
import (
"encoding/json"
"net/http"
)
// LivenessHandler beantwortet IMMER "lebt", solange der Prozess ueberhaupt
// HTTP-Anfragen verarbeiten kann — prueft bewusst KEINE externen
// Abhaengigkeiten (Akzeptanzkriterium 2: Liveness und Readiness getrennt).
// Ein Datenbankausfall darf die Liveness nicht auf "tot" setzen, sonst
// wuerde eine Orchestrierung (z.B. systemd/Kubernetes) den Prozess grundlos
// neu starten, obwohl nur eine Abhaengigkeit ausgefallen ist.
func LivenessHandler(w http.ResponseWriter, r *http.Request) {
writeStatus(w, http.StatusOK, map[string]any{"status": "alive"})
}
// ReadinessHandler prueft ALLE registrierten Abhaengigkeiten
// (Akzeptanzkriterium 1) und liefert 503, sobald eine davon fehlschlaegt
// (Akzeptanzkriterium 3) — unterscheidet sich damit nachweislich von
// LivenessHandler im Fehlerfall (Akzeptanzkriterium 2 / Pruefung 3).
func (r *Registry) ReadinessHandler() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
ready, results := r.CheckAll(req.Context())
body := map[string]any{
"status": statusText(ready),
"checks": results,
}
status := http.StatusOK
if !ready {
status = http.StatusServiceUnavailable
}
writeStatus(w, status, body)
}
}
func statusText(ready bool) string {
if ready {
return "ready"
}
return "not_ready"
}
func writeStatus(w http.ResponseWriter, status int, body map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
+86
View File
@@ -0,0 +1,86 @@
// Package health implementiert Core OPS-01: Health-/Readiness-Endpunkte, die
// echte Abhaengigkeiten (DB, Job-Queue) statt nur den Prozessstatus pruefen
// — wiederverwendbar von Core UND jedem registrierten Modul (siehe API-02),
// nicht nur von Core selbst.
package health
import (
"context"
"time"
)
// Checker prueft EINE Abhaengigkeit (z.B. Datenbank, Job-Queue).
type Checker interface {
Check(ctx context.Context) error
}
type CheckerFunc func(ctx context.Context) error
func (f CheckerFunc) Check(ctx context.Context) error { return f(ctx) }
// DefaultCheckTimeout begrenzt, wie lange EIN einzelner Check maximal
// dauern darf, bevor er als fehlgeschlagen gilt — verhindert, dass ein
// haengender Check den gesamten Readiness-Endpunkt blockiert
// (Akzeptanzkriterium 2 / Pruefung 2: Antwort innerhalb definierter Zeit).
const DefaultCheckTimeout = 2 * time.Second
// Registry haelt alle benannten Checks eines Dienstes.
type Registry struct {
checks map[string]Checker
timeout time.Duration
}
func NewRegistry() *Registry {
return &Registry{checks: make(map[string]Checker), timeout: DefaultCheckTimeout}
}
func (r *Registry) WithTimeout(d time.Duration) *Registry {
return &Registry{checks: r.checks, timeout: d}
}
// Register fuegt einen benannten Check hinzu (z.B. "database", "queue").
func (r *Registry) Register(name string, c Checker) {
r.checks[name] = c
}
// Result ist der Ausgang eines einzelnen Checks.
type Result struct {
OK bool
Error string
}
// CheckAll fuehrt alle registrierten Checks NEBENLAEUFIG mit je eigenem
// Timeout aus (Akzeptanzkriterium 1: echte Abhaengigkeiten statt Prozess-
// status) und liefert ready=false, sobald irgendein Check fehlschlaegt
// (Akzeptanzkriterium 3: ein Ausfall wird sichtbar).
func (r *Registry) CheckAll(ctx context.Context) (ready bool, results map[string]Result) {
type namedResult struct {
name string
result Result
}
ch := make(chan namedResult, len(r.checks))
for name, checker := range r.checks {
go func(name string, checker Checker) {
checkCtx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
err := checker.Check(checkCtx)
if err != nil {
ch <- namedResult{name, Result{OK: false, Error: err.Error()}}
return
}
ch <- namedResult{name, Result{OK: true}}
}(name, checker)
}
results = make(map[string]Result, len(r.checks))
ready = true
for i := 0; i < len(r.checks); i++ {
nr := <-ch
results[nr.name] = nr.result
if !nr.result.OK {
ready = false
}
}
return ready, results
}
+161
View File
@@ -0,0 +1,161 @@
package health
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Akzeptanzkriterium 1 + Pruefung 1: simulierter Datenbankausfall fuehrt zu
// "nicht bereit".
func TestReadinessHandler_ReportsNotReadyOnDatabaseFailure(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
// Datenbankausfall simulieren: Pool sofort schliessen, bevor der Check laeuft.
pool.Close()
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 bei db-ausfall", rec.Code)
}
var body struct {
Status string `json:"status"`
Checks map[string]interface{} `json:"checks"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body parsen: %v", err)
}
if body.Status != "not_ready" {
t.Fatalf("status-feld = %q, want not_ready", body.Status)
}
if _, ok := body.Checks["database"]; !ok {
t.Fatal("erwartet 'database' im checks-ergebnis")
}
}
func TestReadinessHandler_ReportsReadyWhenAllChecksPass(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
defer pool.Close()
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
reg.Register("queue", QueueChecker(pool))
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 bei funktionierenden abhaengigkeiten", rec.Code)
}
}
// Akzeptanzkriterium 2 + Pruefung 3: Liveness und Readiness unterscheiden
// sich nachweislich im Fehlerfall.
func TestLivenessAndReadiness_DifferOnDatabaseFailure(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
pool.Close() // db-ausfall simulieren
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
livenessRec := httptest.NewRecorder()
LivenessHandler(livenessRec, httptest.NewRequest(http.MethodGet, "/livez", nil))
if livenessRec.Code != http.StatusOK {
t.Fatalf("liveness status = %d, want 200 trotz db-ausfall (liveness prueft keine abhaengigkeiten)", livenessRec.Code)
}
readinessRec := httptest.NewRecorder()
reg.ReadinessHandler()(readinessRec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if readinessRec.Code != http.StatusServiceUnavailable {
t.Fatalf("readiness status = %d, want 503 bei db-ausfall", readinessRec.Code)
}
if livenessRec.Code == readinessRec.Code {
t.Fatal("liveness und readiness sollten sich im db-ausfall-fall unterscheiden")
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Health-Endpunkt antwortet auch bei
// haengendem Check innerhalb definierter Zeit (Timeout begrenzt die Dauer).
func TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck(t *testing.T) {
reg := NewRegistry().WithTimeout(50 * time.Millisecond)
reg.Register("haengender_dienst", CheckerFunc(func(ctx context.Context) error {
select {
case <-time.After(10 * time.Second): // wuerde ohne timeout ewig blockieren
return nil
case <-ctx.Done():
return ctx.Err()
}
}))
start := time.Now()
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
elapsed := time.Since(start)
if elapsed > time.Second {
t.Fatalf("readiness handler brauchte %s, erwartet deutlich unter 1s durch timeout", elapsed)
}
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 fuer haengenden/timeout-check", rec.Code)
}
}
func TestCheckAll_MultipleChecksRunConcurrently(t *testing.T) {
reg := NewRegistry().WithTimeout(time.Second)
reg.Register("a", CheckerFunc(func(ctx context.Context) error { return nil }))
reg.Register("b", CheckerFunc(func(ctx context.Context) error { return errors.New("kaputt") }))
ready, results := reg.CheckAll(context.Background())
if ready {
t.Fatal("erwartet ready=false, da 'b' fehlschlaegt")
}
if !results["a"].OK {
t.Fatalf("erwartet 'a' ok, habe %+v", results["a"])
}
if results["b"].OK || results["b"].Error == "" {
t.Fatalf("erwartet 'b' fehlgeschlagen mit fehlertext, habe %+v", results["b"])
}
}
+99
View File
@@ -0,0 +1,99 @@
package moduleregistry
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
var (
ErrModuleNotRegistered = errors.New("moduleregistry: modul muss vor provisionierung registriert sein")
ErrInvalidCredential = errors.New("moduleregistry: ungueltiges oder fehlendes service-credential")
)
// Provision stellt ein Service-Credential (Client-ID + Secret) fuer eine
// Modul-Instanz aus (Akzeptanzkriterium 4). Das Secret wird NUR beim
// Ausstellen im Klartext zurueckgegeben, gespeichert wird ausschliesslich
// dessen SHA-256-Hash.
func (r *Registry) Provision(ctx context.Context, moduleName string) (clientID, secret string, err error) {
if _, err := r.Get(ctx, moduleName); err != nil {
if errors.Is(err, ErrModuleNotFound) {
return "", "", ErrModuleNotRegistered
}
return "", "", err
}
clientID, err = randomToken(16)
if err != nil {
return "", "", fmt.Errorf("client-id erzeugen: %w", err)
}
secret, err = randomToken(32)
if err != nil {
return "", "", fmt.Errorf("secret erzeugen: %w", err)
}
hash := hashSecret(secret)
_, err = r.pool.Exec(ctx, `
INSERT INTO module_credentials (module_name, client_id, secret_hash, issued_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (module_name) DO UPDATE SET client_id = $2, secret_hash = $3, issued_at = now()
`, moduleName, clientID, hash)
if err != nil {
return "", "", fmt.Errorf("credential speichern: %w", err)
}
return clientID, secret, nil
}
// Authenticate prueft ein Service-Credential timing-safe (Referenzmuster
// siehe AUD-02) — Aufrufe ohne gueltiges Credential werden abgelehnt
// (Akzeptanzkriterium 4 / Pruefung 4).
func (r *Registry) Authenticate(ctx context.Context, clientID, secret string) (moduleName string, ok bool, err error) {
if clientID == "" || secret == "" {
return "", false, nil
}
var storedHash []byte
err = r.pool.QueryRow(ctx, `
SELECT module_name, secret_hash FROM module_credentials WHERE client_id = $1
`, clientID).Scan(&moduleName, &storedHash)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
return "", false, fmt.Errorf("credential lesen: %w", err)
}
if !timingSafeEqual(hashSecret(secret), storedHash) {
return "", false, nil
}
return moduleName, true, nil
}
func randomToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashSecret(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
// timingSafeEqual folgt derselben Referenzimplementierung wie AUD-02
// (subtle.ConstantTimeCompare) — projektweite Konvention fuer jeden
// sicherheitsrelevanten Vergleich.
func timingSafeEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare(a, b) == 1
}
+46
View File
@@ -0,0 +1,46 @@
package moduleregistry
import "net/http"
// RequireActiveModule weist Anfragen an ein nicht aktiviertes Modul ZENTRAL
// ab, bevor der eigentliche Modul-Handler erreicht wird (Akzeptanzkriterium 2 /
// Pruefung 1) — Casbin-Prinzip: Durchsetzung als Middleware statt verstreuter
// Pruefungen in jedem Handler. tenantSlug/moduleName werden hier ueber
// Query-Parameter gelesen (echte Extraktion aus JWT/Tenant-Kontext ist
// API-05/TEN-06, nicht Teil dieser Kachel).
func (r *Registry) RequireActiveModule(moduleName string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
tenantSlug := req.URL.Query().Get("tenant")
active, err := r.IsActive(req.Context(), tenantSlug, moduleName)
if err != nil {
http.Error(w, "aktivierungspruefung fehlgeschlagen", http.StatusInternalServerError)
return
}
if !active {
http.Error(w, "modul nicht aktiviert", http.StatusForbidden)
return
}
next(w, req)
}
}
// RequireServiceCredential authentifiziert eine Modul-Instanz ueber ihr
// Service-Credential (X-Client-Id/X-Client-Secret-Header) BEVOR der
// eigentliche Handler erreicht wird (Akzeptanzkriterium 4 / Pruefung 4).
func (r *Registry) RequireServiceCredential(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
clientID := req.Header.Get("X-Client-Id")
secret := req.Header.Get("X-Client-Secret")
_, ok, err := r.Authenticate(req.Context(), clientID, secret)
if err != nil {
http.Error(w, "authentifizierung fehlgeschlagen", http.StatusInternalServerError)
return
}
if !ok {
http.Error(w, ErrInvalidCredential.Error(), http.StatusUnauthorized)
return
}
next(w, req)
}
}
+120
View File
@@ -0,0 +1,120 @@
// Package moduleregistry implementiert Core API-02: die Registry, in der
// sich Fachmodule (DMS, Mail, weitere) mit Metadaten eintragen, gekoppelt an
// die Aktivierungspruefung aus LIC-02 (Feature-Flags). Zusaetzlich
// authentifiziert die Registry Modul-Instanzen selbst ueber ein bei
// Provisionierung ausgestelltes Service-Credential.
package moduleregistry
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
)
var (
ErrMissingName = errors.New("moduleregistry: name darf nicht leer sein")
ErrMissingVersion = errors.New("moduleregistry: version darf nicht leer sein")
ErrModuleNotFound = errors.New("moduleregistry: modul nicht registriert")
)
type Module struct {
Name string
Version string
RequiredFlags []string
}
type Registry struct {
pool *pgxpool.Pool
flags *flag.Service
}
func NewRegistry(pool *pgxpool.Pool, flags *flag.Service) *Registry {
return &Registry{pool: pool, flags: flags}
}
// Register traegt ein Modul mit Name, Version und benoetigten Feature-Flags
// ein (Akzeptanzkriterium 1). Fehlende Pflichtangaben werden abgewiesen
// (Akzeptanzkriterium 1 / Pruefung 2). Erneutes Register desselben Namens
// aktualisiert Version/Flags (Redeploy-Fall).
func (r *Registry) Register(ctx context.Context, name, version string, requiredFlags []string) (Module, error) {
if name == "" {
return Module{}, ErrMissingName
}
if version == "" {
return Module{}, ErrMissingVersion
}
if requiredFlags == nil {
requiredFlags = []string{}
}
_, err := r.pool.Exec(ctx, `
INSERT INTO modules (name, version, required_flags, registered_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (name) DO UPDATE SET version = $2, required_flags = $3, registered_at = now()
`, name, version, requiredFlags)
if err != nil {
return Module{}, fmt.Errorf("modul registrieren: %w", err)
}
return Module{Name: name, Version: version, RequiredFlags: requiredFlags}, nil
}
func (r *Registry) Get(ctx context.Context, name string) (Module, error) {
var m Module
m.Name = name
err := r.pool.QueryRow(ctx, `
SELECT version, required_flags FROM modules WHERE name = $1
`, name).Scan(&m.Version, &m.RequiredFlags)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Module{}, ErrModuleNotFound
}
return Module{}, fmt.Errorf("modul lesen: %w", err)
}
return m, nil
}
// List liefert alle registrierten Module (Akzeptanzkriterium 3: ueber API
// abfragbar, z.B. fuer Statusseite/Lizenzoberflaeche).
func (r *Registry) List(ctx context.Context) ([]Module, error) {
rows, err := r.pool.Query(ctx, `SELECT name, version, required_flags FROM modules ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("module auflisten: %w", err)
}
defer rows.Close()
var out []Module
for rows.Next() {
var m Module
if err := rows.Scan(&m.Name, &m.Version, &m.RequiredFlags); err != nil {
return nil, fmt.Errorf("modul lesen: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// IsActive prueft, ob ein registriertes Modul fuer einen Tenant aktiviert
// ist: registriert UND alle benoetigten Feature-Flags sind fuer diesen
// Tenant aktiv (Akzeptanzkriterium 2). Ein nicht registriertes Modul gilt
// immer als nicht aktiv.
func (r *Registry) IsActive(ctx context.Context, tenantSlug, moduleName string) (bool, error) {
m, err := r.Get(ctx, moduleName)
if err != nil {
if errors.Is(err, ErrModuleNotFound) {
return false, nil
}
return false, err
}
for _, flagKey := range m.RequiredFlags {
if !r.flags.IsEnabled(ctx, tenantSlug, flagKey) {
return false, nil
}
}
return true, nil
}
+304
View File
@@ -0,0 +1,304 @@
package moduleregistry
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
)
func setupTest(t *testing.T) (*Registry, *flag.Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS feature_flags (
key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS modules (
name TEXT PRIMARY KEY, version TEXT NOT NULL CHECK (version <> ''),
required_flags TEXT[] NOT NULL DEFAULT '{}', registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS module_credentials (
module_name TEXT PRIMARY KEY REFERENCES modules(name),
client_id TEXT NOT NULL UNIQUE, secret_hash BYTEA NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
flagStore := flag.NewStore(pool)
// Kurze TTL, damit Tests, die den Flag-Store direkt aendern (an
// Registry.IsActive vorbei), den neuen Stand ohne manuelles Invalidate
// zuverlaessig sehen.
flagService := flag.NewService(flagStore, 10*time.Millisecond)
registry := NewRegistry(pool, flagService)
cleanup := func() { pool.Close() }
return registry, flagStore, cleanup
}
func uniqueModuleName(t *testing.T) string {
return fmt.Sprintf("dms_%d", time.Now().UnixNano())
}
// Akzeptanzkriterium 1 + Pruefung 2: fehlende Pflichtangaben abgewiesen.
func TestRegister_RejectsMissingFields(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := registry.Register(ctx, "", "1.0", nil); !errors.Is(err, ErrMissingName) {
t.Fatalf("erwartet ErrMissingName, habe %v", err)
}
if _, err := registry.Register(ctx, "dms", "", nil); !errors.Is(err, ErrMissingVersion) {
t.Fatalf("erwartet ErrMissingVersion, habe %v", err)
}
}
func TestRegister_AndGet(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
m, err := registry.Register(ctx, name, "1.2.0", []string{"dms_enabled"})
if err != nil {
t.Fatalf("register: %v", err)
}
if m.Version != "1.2.0" || len(m.RequiredFlags) != 1 {
t.Fatalf("unerwartet: %+v", m)
}
got, err := registry.Get(ctx, name)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Version != "1.2.0" {
t.Fatalf("get version = %q", got.Version)
}
}
// Akzeptanzkriterium 2 + 3 + Pruefung 3: konsistente Daten nach
// Aktivierung/Deaktivierung eines Moduls.
func TestIsActive_ReflectsFlagStateConsistently(t *testing.T) {
registry, flagStore, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
flagKey := name + "_enabled"
if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil {
t.Fatalf("register: %v", err)
}
active, err := registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (vor flag): %v", err)
}
if active {
t.Fatal("erwartet nicht aktiv, solange flag nicht gesetzt ist")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil {
t.Fatalf("flag setzen: %v", err)
}
time.Sleep(20 * time.Millisecond) // TTL abwarten
active, err = registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (nach flag an): %v", err)
}
if !active {
t.Fatal("erwartet aktiv, nachdem flag aktiviert wurde")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: false}); err != nil {
t.Fatalf("flag zuruecksetzen: %v", err)
}
time.Sleep(20 * time.Millisecond) // TTL abwarten
active, err = registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (nach flag aus): %v", err)
}
if active {
t.Fatal("erwartet wieder nicht aktiv, nachdem flag deaktiviert wurde")
}
}
func TestIsActive_UnregisteredModuleIsNeverActive(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
active, err := registry.IsActive(ctx, "acme", "nie-registriert")
if err != nil {
t.Fatalf("is active: %v", err)
}
if active {
t.Fatal("unregistriertes modul darf nie aktiv sein")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Anfrage an deaktiviertes Modul wird
// zentral abgewiesen, BEVOR die Modul-Logik erreicht wird.
func TestRequireActiveModule_BlocksBeforeHandler(t *testing.T) {
registry, flagStore, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
flagKey := name + "_enabled"
if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil {
t.Fatalf("register: %v", err)
}
handlerReached := false
handler := registry.RequireActiveModule(name, func(w http.ResponseWriter, r *http.Request) {
handlerReached = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if handlerReached {
t.Fatal("handler haette bei deaktiviertem modul NICHT erreicht werden duerfen")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil {
t.Fatalf("flag setzen: %v", err)
}
req2 := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil)
rec2 := httptest.NewRecorder()
handler(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("status nach aktivierung = %d, want 200", rec2.Code)
}
if !handlerReached {
t.Fatal("handler haette bei aktiviertem modul erreicht werden muessen")
}
}
// Akzeptanzkriterium 4 + Pruefung 4: gueltiges/ungueltiges Service-Credential.
func TestProvisionAndAuthenticate(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
if _, err := registry.Register(ctx, name, "1.0", nil); err != nil {
t.Fatalf("register: %v", err)
}
clientID, secret, err := registry.Provision(ctx, name)
if err != nil {
t.Fatalf("provision: %v", err)
}
if clientID == "" || secret == "" {
t.Fatal("erwartet nicht-leere client-id/secret")
}
moduleName, ok, err := registry.Authenticate(ctx, clientID, secret)
if err != nil {
t.Fatalf("authenticate (korrekt): %v", err)
}
if !ok || moduleName != name {
t.Fatalf("erwartet erfolgreiche authentifizierung fuer %q, habe ok=%v moduleName=%q", name, ok, moduleName)
}
_, ok, err = registry.Authenticate(ctx, clientID, "falsches-secret")
if err != nil {
t.Fatalf("authenticate (falsch): %v", err)
}
if ok {
t.Fatal("erwartet fehlschlag bei falschem secret")
}
_, ok, err = registry.Authenticate(ctx, "unbekannte-client-id", secret)
if err != nil {
t.Fatalf("authenticate (unbekannt): %v", err)
}
if ok {
t.Fatal("erwartet fehlschlag bei unbekannter client-id")
}
}
func TestProvision_RequiresRegisteredModule(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, _, err := registry.Provision(ctx, "nie-registriert"); !errors.Is(err, ErrModuleNotRegistered) {
t.Fatalf("erwartet ErrModuleNotRegistered, habe %v", err)
}
}
func TestRequireServiceCredential_RejectsInvalidAcceptsValid(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
if _, err := registry.Register(ctx, name, "1.0", nil); err != nil {
t.Fatalf("register: %v", err)
}
clientID, secret, err := registry.Provision(ctx, name)
if err != nil {
t.Fatalf("provision: %v", err)
}
handler := registry.RequireServiceCredential(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Fehlendes Credential.
req := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne credential: status = %d, want 401", rec.Code)
}
// Falsches Secret.
req2 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
req2.Header.Set("X-Client-Id", clientID)
req2.Header.Set("X-Client-Secret", "falsch")
rec2 := httptest.NewRecorder()
handler(rec2, req2)
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("falsches secret: status = %d, want 401", rec2.Code)
}
// Gueltiges Credential.
req3 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
req3.Header.Set("X-Client-Id", clientID)
req3.Header.Set("X-Client-Secret", secret)
rec3 := httptest.NewRecorder()
handler(rec3, req3)
if rec3.Code != http.StatusOK {
t.Fatalf("gueltiges credential: status = %d, want 200", rec3.Code)
}
}
+273
View File
@@ -0,0 +1,273 @@
// Package statuspage implementiert Core OPS-02: eine zentrale Statusseite,
// die den Health-Zustand aller registrierten Module aggregiert und den
// Verlauf vergangener Statusaenderungen speichert. Baut auf OPS-01
// (internal/health) auf, indem es GENAU die dort etablierten
// Readiness-Endpunkte je Modul abfragt — dieses Paket dupliziert keine
// Health-Check-Logik, es aggregiert nur deren Ergebnisse ueber die Zeit.
package statuspage
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Status ist der aggregierte Zustand EINES Moduls zu einem Zeitpunkt.
type Status string
const (
StatusUp Status = "up"
StatusDown Status = "down"
)
// Target ist ein zu ueberwachendes Modul mit seiner Readiness-URL
// (OPS-01-Endpunkt, z.B. "http://dms:8080/readyz"). Eigenstaendige
// Konfiguration statt Erweiterung von internal/moduleregistry.Module, um
// API-02 nicht anzufassen (Kein Umbau angrenzender Bereiche).
type Target struct {
Name string
HealthURL string
}
// Store persistiert Ueberwachungsziele und den Verlauf ihrer
// Statusaenderungen.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// RegisterTarget traegt ein zu ueberwachendes Modul ein oder aktualisiert
// dessen URL (Akzeptanzkriterium 1: "aller registrierten Module").
func (s *Store) RegisterTarget(ctx context.Context, t Target) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO status_targets (name, health_url)
VALUES ($1, $2)
ON CONFLICT (name) DO UPDATE SET health_url = $2
`, t.Name, t.HealthURL)
if err != nil {
return fmt.Errorf("ueberwachungsziel speichern: %w", err)
}
return nil
}
func (s *Store) ListTargets(ctx context.Context) ([]Target, error) {
rows, err := s.pool.Query(ctx, `SELECT name, health_url FROM status_targets ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("ueberwachungsziele auflisten: %w", err)
}
defer rows.Close()
var out []Target
for rows.Next() {
var t Target
if err := rows.Scan(&t.Name, &t.HealthURL); err != nil {
return nil, fmt.Errorf("ueberwachungsziel lesen: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
// recordIfChanged schreibt NUR dann einen neuen Verlaufseintrag, wenn sich
// der Status seit dem letzten Eintrag geaendert hat (oder es der erste
// Eintrag ist) — der Verlauf zeigt Statusaenderungen (Akzeptanzkriterium 3),
// nicht jede einzelne Abfrage.
func (s *Store) recordIfChanged(ctx context.Context, name string, status Status) error {
var lastStatus string
err := s.pool.QueryRow(ctx, `
SELECT status FROM status_history WHERE name = $1 ORDER BY changed_at DESC LIMIT 1
`, name).Scan(&lastStatus)
if err != nil && err != pgx.ErrNoRows {
return fmt.Errorf("letzten status lesen: %w", err)
}
if err == nil && lastStatus == string(status) {
return nil // keine Aenderung, kein neuer Eintrag
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO status_history (name, status, changed_at) VALUES ($1, $2, now())
`, name, string(status)); err != nil {
return fmt.Errorf("statuseintrag schreiben: %w", err)
}
return nil
}
// ModuleStatus ist der aktuelle Zustand EINES Moduls fuer die Uebersicht.
type ModuleStatus struct {
Name string `json:"name"`
Status Status `json:"status"`
LastChecked time.Time `json:"last_checked"`
}
// Overview liefert den aktuellen (letzten bekannten) Status jedes
// registrierten Ziels (Akzeptanzkriterium 1). Ziele ohne jemals erfolgte
// Pruefung erscheinen mit Status "down" — ein Modul, ueber das nichts
// bekannt ist, gilt als nicht verfuegbar (Fail-Safe-Default), nicht als
// stillschweigend "ok".
func (s *Store) Overview(ctx context.Context) ([]ModuleStatus, error) {
targets, err := s.ListTargets(ctx)
if err != nil {
return nil, err
}
out := make([]ModuleStatus, 0, len(targets))
for _, t := range targets {
var status string
var changedAt time.Time
err := s.pool.QueryRow(ctx, `
SELECT status, changed_at FROM status_history WHERE name = $1 ORDER BY changed_at DESC LIMIT 1
`, t.Name).Scan(&status, &changedAt)
if err == pgx.ErrNoRows {
out = append(out, ModuleStatus{Name: t.Name, Status: StatusDown})
continue
}
if err != nil {
return nil, fmt.Errorf("aktuellen status lesen (%s): %w", t.Name, err)
}
out = append(out, ModuleStatus{Name: t.Name, Status: Status(status), LastChecked: changedAt})
}
return out, nil
}
// HistoryEntry ist EIN Verlaufseintrag (Akzeptanzkriterium 3).
type HistoryEntry struct {
Status Status `json:"status"`
ChangedAt time.Time `json:"changed_at"`
}
func (s *Store) History(ctx context.Context, name string) ([]HistoryEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT status, changed_at FROM status_history WHERE name = $1 ORDER BY changed_at DESC
`, name)
if err != nil {
return nil, fmt.Errorf("verlauf abfragen: %w", err)
}
defer rows.Close()
var out []HistoryEntry
for rows.Next() {
var e HistoryEntry
var status string
if err := rows.Scan(&status, &e.ChangedAt); err != nil {
return nil, fmt.Errorf("verlaufseintrag lesen: %w", err)
}
e.Status = Status(status)
out = append(out, e)
}
return out, rows.Err()
}
// HTTPChecker fragt die Readiness-URL eines Moduls ab (OPS-01-Endpunkt) und
// liefert StatusUp NUR bei HTTP 200 — jeder andere Statuscode ODER ein
// Netzwerkfehler/Timeout gilt als StatusDown. Ein einzelnes nicht
// erreichbares Modul liefert einen FEHLERFREIEN StatusDown-Wert statt eines
// Go-Errors, damit Poller.Run ein fehlerhaftes Modul niemals mit einem
// anderen verwechseln oder den gesamten Zyklus abbrechen kann
// (Akzeptanzkriterium 2).
type HTTPChecker struct {
Client *http.Client
Timeout time.Duration
}
func NewHTTPChecker(timeout time.Duration) *HTTPChecker {
return &HTTPChecker{Client: &http.Client{}, Timeout: timeout}
}
func (c *HTTPChecker) Check(ctx context.Context, url string) Status {
ctx, cancel := context.WithTimeout(ctx, c.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return StatusDown
}
resp, err := c.Client.Do(req)
if err != nil {
return StatusDown
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return StatusUp
}
return StatusDown
}
// Poller fragt periodisch alle Ziele ab und schreibt Statusaenderungen fort.
type Poller struct {
store *Store
checker *HTTPChecker
}
func NewPoller(store *Store, checker *HTTPChecker) *Poller {
return &Poller{store: store, checker: checker}
}
// PollOnce prueft ALLE Ziele in einem Durchlauf. Ein fehlschlagendes Ziel
// (Netzwerkfehler, Timeout, Nicht-200) wird als StatusDown vermerkt und
// haelt die Pruefung der UEBRIGEN Ziele nicht auf — die Schleife laeuft
// sequenziell weiter, kein Ziel kann ein anderes blockieren
// (Akzeptanzkriterium 2 / Pruefung 2).
func (p *Poller) PollOnce(ctx context.Context) error {
targets, err := p.store.ListTargets(ctx)
if err != nil {
return err
}
for _, t := range targets {
status := p.checker.Check(ctx, t.HealthURL)
if err := p.store.recordIfChanged(ctx, t.Name, status); err != nil {
// Ein Schreibfehler fuer EIN Ziel darf die Pruefung der anderen
// nicht verhindern — dieselbe Fail-Isolation wie bei einem
// unerreichbaren Modul.
continue
}
}
return nil
}
// Run ruft PollOnce in festen Abstaenden auf, bis ctx beendet wird —
// dieselbe Konvention wie internal/tenant.Lifecycle.RunSweeper.
func (p *Poller) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = p.PollOnce(ctx)
}
}
}
// --- HTTP-Bindung fuer die Oberflaeche ---
func (s *Store) OverviewHandler(w http.ResponseWriter, r *http.Request) {
overview, err := s.Overview(r.Context())
writeJSONResult(w, overview, err)
}
func (s *Store) HistoryHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
history, err := s.History(r.Context(), name)
writeJSONResult(w, history, err)
}
func writeJSONResult(w http.ResponseWriter, body any, err error) {
w.Header().Set("Content-Type", "application/json")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(body)
}
+198
View File
@@ -0,0 +1,198 @@
package statuspage
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(t *testing.T) (*Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS status_targets (name TEXT PRIMARY KEY, health_url TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, status TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), cleanup
}
func uniqueName(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}
// Akzeptanzkriterium 1: Uebersicht zeigt den Status ALLER registrierten
// Module.
func TestOverview_ShowsAllRegisteredModules(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
nameA, nameB := uniqueName("mod-a"), uniqueName("mod-b")
if err := store.RegisterTarget(ctx, Target{Name: nameA, HealthURL: up.URL}); err != nil {
t.Fatalf("target a: %v", err)
}
if err := store.RegisterTarget(ctx, Target{Name: nameB, HealthURL: up.URL}); err != nil {
t.Fatalf("target b: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(time.Second))
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll: %v", err)
}
overview, err := store.Overview(ctx)
if err != nil {
t.Fatalf("overview: %v", err)
}
// Overview() listet ALLE jemals registrierten Ziele (auch aus fruehreren
// Testlaeufen auf derselben geteilten Test-DB) — hier wird deshalb nur
// der Status von nameA/nameB geprueft, nicht jeder Eintrag der Tabelle.
statusByName := map[string]Status{}
for _, m := range overview {
statusByName[m.Name] = m.Status
}
if statusByName[nameA] != StatusUp {
t.Fatalf("modul %s: status = %s, want up", nameA, statusByName[nameA])
}
if statusByName[nameB] != StatusUp {
t.Fatalf("modul %s: status = %s, want up", nameB, statusByName[nameB])
}
}
// Akzeptanzkriterium 2 + Pruefung 1/2: ein simulierter Ausfall eines Moduls
// wird sichtbar, das ANDERE Modul bleibt unbeeinflusst und die Pruefung
// beider laeuft trotzdem in einem Durchlauf durch (kein Totalausfall).
func TestPollOnce_IsolatesFailingModuleFromOthers(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }))
down.Close() // Server sofort schliessen -> Verbindung tatsaechlich unerreichbar (simulierter Ausfall)
healthyName, downName := uniqueName("healthy"), uniqueName("down")
if err := store.RegisterTarget(ctx, Target{Name: healthyName, HealthURL: up.URL}); err != nil {
t.Fatalf("target healthy: %v", err)
}
if err := store.RegisterTarget(ctx, Target{Name: downName, HealthURL: down.URL}); err != nil {
t.Fatalf("target down: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(500*time.Millisecond))
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll haette trotz einem ausgefallenen modul erfolgreich durchlaufen sollen: %v", err)
}
overview, err := store.Overview(ctx)
if err != nil {
t.Fatalf("overview: %v", err)
}
statusByName := map[string]Status{}
for _, m := range overview {
statusByName[m.Name] = m.Status
}
if statusByName[healthyName] != StatusUp {
t.Fatalf("healthy modul: status = %s, want up", statusByName[healthyName])
}
if statusByName[downName] != StatusDown {
t.Fatalf("ausgefallenes modul: status = %s, want down", statusByName[downName])
}
}
// Akzeptanzkriterium 3: Verlauf vergangener Statusaenderungen ist einsehbar.
func TestHistory_RecordsStatusTransitions(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueName("flaky")
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil {
t.Fatalf("target: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(time.Second))
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll 1: %v", err)
}
// Ziel wird "abgeschaltet" (URL zeigt jetzt auf einen bereits
// geschlossenen Server) -> naechster Poll erkennt den Statuswechsel.
closedURL := up.URL
up.Close()
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: closedURL}); err != nil {
t.Fatalf("target aktualisieren: %v", err)
}
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll 2: %v", err)
}
history, err := store.History(ctx, name)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 2 {
t.Fatalf("erwartet genau 2 statuswechsel (up -> down), habe %d: %+v", len(history), history)
}
if history[0].Status != StatusDown || history[1].Status != StatusUp {
t.Fatalf("erwartet verlauf [down, up] (neueste zuerst), habe: %+v", history)
}
}
// Wiederholte Polls OHNE Statuswechsel duerfen den Verlauf nicht mit
// identischen Eintraegen fluten (siehe recordIfChanged).
func TestHistory_DoesNotDuplicateUnchangedStatus(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueName("stable")
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil {
t.Fatalf("target: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(time.Second))
for i := 0; i < 3; i++ {
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll %d: %v", i, err)
}
}
history, err := store.History(ctx, name)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 1 {
t.Fatalf("erwartet genau 1 eintrag trotz 3 unveraenderter polls, habe %d", len(history))
}
}
-164
View File
@@ -1,164 +0,0 @@
package totp
import (
"encoding/json"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// Handler stellt Login (inklusive optionalem zweiten Faktor) und die
// 2FA-Einrichtung als HTTP-Endpunkte bereit (IAM-08). Ersetzt auth.Handler.Login
// nicht (der bleibt fuer Faelle ohne 2FA-Bedarf nutzbar), bietet aber den
// EINEN Login-Endpunkt, den das Frontend tatsaechlich aufruft — LoginWithTOTP
// deckt beide Faelle (mit/ohne 2FA) bereits ab.
type Handler struct {
users *user.TenantUserStore
totp *Store
login *auth.LoginService
issuer string // fuer die otpauth://-Provisioning-URI (Akzeptanzkriterium 1)
}
func NewHandler(users *user.TenantUserStore, totpStore *Store, login *auth.LoginService, issuer string) *Handler {
return &Handler{users: users, totp: totpStore, login: login, issuer: issuer}
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
type loginErrorResponse struct {
// Error ist bewusst IMMER dieselbe generische Meldung fuer falsches
// Passwort/Token (Akzeptanzkriterium 3 / Pruefung 1) — Code unterscheidet
// intern zwischen "second_factor_required" (Formular soll TOTP-Feld
// einblenden) und "invalid_credentials" (alles andere), ohne dem Client
// mehr ueber den tatsaechlichen Fehlgrund zu verraten.
Error string `json:"error"`
Code string `json:"code"`
}
// Login ist der einzige Login-Endpunkt des Frontends (Akzeptanzkriterium 1).
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
token, err := LoginWithTOTP(r.Context(), h.users, h.totp, h.login, req.Email, req.Password, req.TOTPCode)
if err != nil {
w.Header().Set("Content-Type", "application/json")
if err == ErrSecondFactorRequired {
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(loginErrorResponse{
Error: "Anmeldedaten oder Code ungültig.",
Code: "second_factor_required",
})
return
}
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(loginErrorResponse{
Error: "Anmeldedaten oder Code ungültig.",
Code: "invalid_credentials",
})
return
}
http.SetCookie(w, &http.Cookie{
Name: auth.CookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(auth.AccessTokenTTL.Seconds()),
})
w.WriteHeader(http.StatusOK)
}
type statusResponse struct {
Enabled bool `json:"enabled"`
}
// Status liefert, ob 2FA fuer den angemeldeten Benutzer aktiv ist — fuer die
// Profilseite (Akzeptanzkriterium 2: zeigt an, ob Einrichtung schon
// stattgefunden hat). Muss hinter auth.RequireAuth haengen.
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
enabled, err := h.totp.IsEnabled(r.Context(), claims.UserID)
if err != nil {
http.Error(w, "status konnte nicht ermittelt werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(statusResponse{Enabled: enabled})
}
type setupBeginResponse struct {
Secret string `json:"secret"`
ProvisioningURI string `json:"provisioning_uri"`
}
// SetupBegin startet die 2FA-Einrichtung (Akzeptanzkriterium 2) — muss hinter
// auth.RequireAuth haengen.
func (h *Handler) SetupBegin(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
u, err := h.users.Get(r.Context(), claims.UserID)
if err != nil {
http.Error(w, "benutzer nicht gefunden", http.StatusNotFound)
return
}
secret, uri, err := h.totp.BeginSetup(r.Context(), claims.UserID, h.issuer, u.Email)
if err != nil {
http.Error(w, "einrichtung konnte nicht gestartet werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(setupBeginResponse{Secret: secret, ProvisioningURI: uri})
}
type setupConfirmRequest struct {
Code string `json:"code"`
}
type setupConfirmResponse struct {
RecoveryCodes []string `json:"recovery_codes"`
}
// SetupConfirm bestaetigt die Einrichtung und liefert die Wiederherstellungscodes
// EINMALIG im Klartext (Akzeptanzkriterium 2) — muss hinter auth.RequireAuth haengen.
func (h *Handler) SetupConfirm(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
var req setupConfirmRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
codes, err := h.totp.ConfirmSetup(r.Context(), claims.UserID, req.Code)
if err != nil {
http.Error(w, "Code ungültig.", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(setupConfirmResponse{RecoveryCodes: codes})
}
-171
View File
@@ -1,171 +0,0 @@
package totp
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func emailForUser(t *testing.T, users *user.TenantUserStore, userID string) string {
t.Helper()
u, err := users.Get(context.Background(), userID)
if err != nil {
t.Fatalf("benutzer laden: %v", err)
}
return u.Email
}
// authenticatedRequest baut einen Request mit echtem, gueltigem
// Session-Cookie (echter Issuer, echte Verifikation ueber auth.RequireAuth) —
// kein simulierter Context, dasselbe Muster wie internal/oidc (IAM-13).
func authenticatedRequest(t *testing.T, issuer *auth.TokenIssuer, userID string, req *http.Request) *http.Request {
t.Helper()
token, err := issuer.Issue(userID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
return req
}
// Akzeptanzkriterium 1 + Pruefung 1: Login ohne 2FA funktioniert normal,
// Login mit falschem Passwort liefert die generische Fehlermeldung.
func TestHandlerLogin_WithoutTOTP(t *testing.T) {
store, users, userID, cleanup := setupTest(t)
defer cleanup()
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
h := NewHandler(users, store, login, "NEXARCH")
body, _ := json.Marshal(map[string]string{"email": emailForUser(t, users, userID), "password": "korrektes-passwort"})
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.Login(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
if len(rec.Result().Cookies()) == 0 {
t.Fatal("erwartet gesetztes session-cookie")
}
}
func TestHandlerLogin_WrongPassword_GivesGenericError(t *testing.T) {
store, users, userID, cleanup := setupTest(t)
defer cleanup()
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
h := NewHandler(users, store, login, "NEXARCH")
body, _ := json.Marshal(map[string]string{"email": emailForUser(t, users, userID), "password": "falsches-passwort"})
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.Login(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
var resp loginErrorResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("response dekodieren: %v", err)
}
if resp.Error != "Anmeldedaten oder Code ungültig." {
t.Fatalf("erwartet generische Fehlermeldung, habe %q (koennte interne Details preisgeben)", resp.Error)
}
}
// Akzeptanzkriterium 1 + 2: mit aktivem 2FA verlangt Login den Code,
// Setup-Confirm liefert die Wiederherstellungscodes EINMALIG.
func TestHandlerLogin_WithTOTP_RequiresCode(t *testing.T) {
store, users, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
if err != nil {
t.Fatalf("begin setup: %v", err)
}
code, err := GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("code generieren: %v", err)
}
if _, err := store.ConfirmSetup(ctx, userID, code); err != nil {
t.Fatalf("confirm setup: %v", err)
}
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
h := NewHandler(users, store, login, "NEXARCH")
email := emailForUser(t, users, userID)
// Ohne Code: second_factor_required, kein Cookie.
body, _ := json.Marshal(map[string]string{"email": email, "password": "korrektes-passwort"})
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.Login(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne code: status = %d, want 401", rec.Code)
}
var resp loginErrorResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Code != "second_factor_required" {
t.Fatalf("erwartet code=second_factor_required, habe %q", resp.Code)
}
// Mit gueltigem Code: Login gelingt.
freshCode, _ := GenerateCode(secret, time.Now())
body2, _ := json.Marshal(map[string]string{"email": email, "password": "korrektes-passwort", "totp_code": freshCode})
req2 := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body2))
rec2 := httptest.NewRecorder()
h.Login(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("mit code: status = %d, want 200, body: %s", rec2.Code, rec2.Body.String())
}
}
func TestHandlerSetupConfirm_ReturnsRecoveryCodes(t *testing.T) {
store, users, userID, cleanup := setupTest(t)
defer cleanup()
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
sessionIssuer := auth.NewTokenIssuer("test-session-secret")
h := NewHandler(users, store, login, "NEXARCH")
beginReq := authenticatedRequest(t, sessionIssuer, userID, httptest.NewRequest(http.MethodPost, "/auth/totp/setup/begin", nil))
beginRec := httptest.NewRecorder()
auth.RequireAuth(sessionIssuer, h.SetupBegin)(beginRec, beginReq)
if beginRec.Code != http.StatusOK {
t.Fatalf("setup begin: status = %d, body: %s", beginRec.Code, beginRec.Body.String())
}
var beginResp setupBeginResponse
if err := json.Unmarshal(beginRec.Body.Bytes(), &beginResp); err != nil {
t.Fatalf("begin-response dekodieren: %v", err)
}
code, err := GenerateCode(beginResp.Secret, time.Now())
if err != nil {
t.Fatalf("code generieren: %v", err)
}
confirmBody, _ := json.Marshal(setupConfirmRequest{Code: code})
confirmReq := authenticatedRequest(t, sessionIssuer, userID,
httptest.NewRequest(http.MethodPost, "/auth/totp/setup/confirm", bytes.NewReader(confirmBody)))
confirmRec := httptest.NewRecorder()
auth.RequireAuth(sessionIssuer, h.SetupConfirm)(confirmRec, confirmReq)
if confirmRec.Code != http.StatusOK {
t.Fatalf("setup confirm: status = %d, body: %s", confirmRec.Code, confirmRec.Body.String())
}
var confirmResp setupConfirmResponse
if err := json.Unmarshal(confirmRec.Body.Bytes(), &confirmResp); err != nil {
t.Fatalf("confirm-response dekodieren: %v", err)
}
if len(confirmResp.RecoveryCodes) != RecoveryCodeCount {
t.Fatalf("erwartet %d wiederherstellungscodes, habe %d", RecoveryCodeCount, len(confirmResp.RecoveryCodes))
}
}
-42
View File
@@ -1,42 +0,0 @@
package totp
import (
"context"
"errors"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
var ErrSecondFactorRequired = errors.New("totp: zweiter faktor erforderlich")
// LoginWithTOTP komponiert IAM-02s LoginService mit der 2FA-Pruefung, ohne
// LoginService selbst zu veraendern. Ist 2FA fuer den Benutzer aktiv, MUSS
// totpCode gueltig sein (Akzeptanzkriterium 2) — ein leerer/falscher Code
// wird zuverlaessig abgewiesen, auch bei korrektem Passwort.
func LoginWithTOTP(ctx context.Context, users *user.TenantUserStore, totpStore *Store, login *auth.LoginService, email, password, totpCode string) (token string, err error) {
creds, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
return "", auth.ErrInvalidCredentials
}
enabled, err := totpStore.IsEnabled(ctx, creds.User.ID)
if err != nil {
return "", err
}
if enabled {
if totpCode == "" {
return "", ErrSecondFactorRequired
}
ok, err := totpStore.VerifyLoginCode(ctx, creds.User.ID, totpCode)
if err != nil {
return "", err
}
if !ok {
return "", ErrSecondFactorRequired
}
}
return login.Login(ctx, email, password)
}
-163
View File
@@ -1,163 +0,0 @@
package totp
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrInvalidCode = errors.New("totp: code ungueltig")
ErrNotConfirmed = errors.New("totp: einrichtung noch nicht bestaetigt")
ErrRecoveryCodeUsed = errors.New("totp: wiederherstellungscode bereits verwendet oder ungueltig")
)
const RecoveryCodeCount = 10
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// BeginSetup erzeugt ein neues Secret fuer einen Benutzer und speichert es
// als NOCH NICHT bestaetigt — der Code muss vor Aktivierung einmal
// verifiziert werden (Akzeptanzkriterium 1).
func (s *Store) BeginSetup(ctx context.Context, userID, issuer, accountName string) (secret, provisioningURI string, err error) {
secret, err = GenerateSecret()
if err != nil {
return "", "", err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO totp_credentials (user_id, secret, confirmed)
VALUES ($1, $2, false)
ON CONFLICT (user_id) DO UPDATE SET secret = $2, confirmed = false
`, userID, secret)
if err != nil {
return "", "", fmt.Errorf("totp-einrichtung speichern: %w", err)
}
return secret, ProvisioningURI(issuer, accountName, secret), nil
}
// ConfirmSetup verifiziert den ersten eingegebenen Code und aktiviert 2FA
// erst dann (Akzeptanzkriterium 1). Bei Erfolg werden einmalig
// Wiederherstellungscodes erzeugt und im Klartext zurueckgegeben — gespeichert
// wird nur deren Hash.
func (s *Store) ConfirmSetup(ctx context.Context, userID, code string) (recoveryCodes []string, err error) {
var secret string
if err := s.pool.QueryRow(ctx, `
SELECT secret FROM totp_credentials WHERE user_id = $1
`, userID).Scan(&secret); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotConfirmed
}
return nil, fmt.Errorf("secret lesen: %w", err)
}
ok, err := Validate(secret, code, time.Now())
if err != nil {
return nil, err
}
if !ok {
return nil, ErrInvalidCode
}
if _, err := s.pool.Exec(ctx, `UPDATE totp_credentials SET confirmed = true WHERE user_id = $1`, userID); err != nil {
return nil, fmt.Errorf("bestaetigung speichern: %w", err)
}
recoveryCodes, err = s.generateRecoveryCodes(ctx, userID)
if err != nil {
return nil, err
}
return recoveryCodes, nil
}
func (s *Store) generateRecoveryCodes(ctx context.Context, userID string) ([]string, error) {
codes := make([]string, RecoveryCodeCount)
for i := range codes {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return nil, fmt.Errorf("wiederherstellungscode erzeugen: %w", err)
}
code := hex.EncodeToString(buf)
codes[i] = code
hash := hashRecoveryCode(code)
if _, err := s.pool.Exec(ctx, `
INSERT INTO totp_recovery_codes (user_id, code_hash) VALUES ($1, $2)
`, userID, hash); err != nil {
return nil, fmt.Errorf("wiederherstellungscode speichern: %w", err)
}
}
return codes, nil
}
// IsEnabled prueft, ob 2FA fuer einen Benutzer aktiv (bestaetigt) ist.
func (s *Store) IsEnabled(ctx context.Context, userID string) (bool, error) {
var confirmed bool
err := s.pool.QueryRow(ctx, `SELECT confirmed FROM totp_credentials WHERE user_id = $1`, userID).Scan(&confirmed)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("status lesen: %w", err)
}
return confirmed, nil
}
// VerifyLoginCode akzeptiert entweder einen gueltigen TOTP-Code oder einen
// noch nicht verwendeten Wiederherstellungscode (Akzeptanzkriterium 2 + 3).
func (s *Store) VerifyLoginCode(ctx context.Context, userID, code string) (bool, error) {
var secret string
var confirmed bool
err := s.pool.QueryRow(ctx, `SELECT secret, confirmed FROM totp_credentials WHERE user_id = $1`, userID).Scan(&secret, &confirmed)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) || !confirmed {
return false, nil
}
return false, fmt.Errorf("secret lesen: %w", err)
}
if !confirmed {
return false, nil
}
if ok, err := Validate(secret, code, time.Now()); err != nil {
return false, err
} else if ok {
return true, nil
}
return s.consumeRecoveryCode(ctx, userID, code)
}
// consumeRecoveryCode markiert einen Wiederherstellungscode atomar als
// verwendet (WHERE used_at IS NULL) — ein bereits verbrauchter Code kann
// nicht doppelt eingeloest werden (Akzeptanzkriterium 3 / Pruefung 2).
func (s *Store) consumeRecoveryCode(ctx context.Context, userID, code string) (bool, error) {
hash := hashRecoveryCode(code)
tag, err := s.pool.Exec(ctx, `
UPDATE totp_recovery_codes SET used_at = now()
WHERE user_id = $1 AND code_hash = $2 AND used_at IS NULL
`, userID, hash)
if err != nil {
return false, fmt.Errorf("wiederherstellungscode einloesen: %w", err)
}
return tag.RowsAffected() == 1, nil
}
func hashRecoveryCode(code string) []byte {
sum := sha256.Sum256([]byte(code))
return sum[:]
}
-185
View File
@@ -1,185 +0,0 @@
package totp
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupTest(t *testing.T) (*Store, *user.TenantUserStore, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS totp_credentials (
user_id UUID PRIMARY KEY REFERENCES users(id), secret TEXT NOT NULL,
confirmed BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS totp_recovery_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
code_hash BYTEA NOT NULL UNIQUE, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
userStore := user.NewTenantUserStore(pool)
email := fmt.Sprintf("totp-test-%d@example.com", time.Now().UnixNano())
u, err := userStore.Create(ctx, email, "TOTP Test")
if err != nil {
t.Fatalf("create user: %v", err)
}
hash, err := auth.HashPassword("korrektes-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
if err := userStore.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), userStore, u.ID, cleanup
}
// Akzeptanzkriterium 1: Einrichtung per QR-Code (URI), Bestaetigung vor Aktivierung.
func TestBeginAndConfirmSetup(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
secret, uri, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
if err != nil {
t.Fatalf("begin setup: %v", err)
}
if uri == "" {
t.Fatal("erwartet nicht-leere provisioning-uri")
}
enabled, err := store.IsEnabled(ctx, userID)
if err != nil {
t.Fatalf("is enabled (vor bestaetigung): %v", err)
}
if enabled {
t.Fatal("erwartet NICHT aktiviert vor bestaetigung")
}
// Falscher Code darf nicht aktivieren.
if _, err := store.ConfirmSetup(ctx, userID, "000000"); !errors.Is(err, ErrInvalidCode) && err == nil {
t.Fatalf("erwartet ablehnung bei falschem code, habe err=%v", err)
}
code, err := GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("generate code: %v", err)
}
recoveryCodes, err := store.ConfirmSetup(ctx, userID, code)
if err != nil {
t.Fatalf("confirm setup: %v", err)
}
if len(recoveryCodes) != RecoveryCodeCount {
t.Fatalf("erwartet %d wiederherstellungscodes, habe %d", RecoveryCodeCount, len(recoveryCodes))
}
enabled, err = store.IsEnabled(ctx, userID)
if err != nil {
t.Fatalf("is enabled (nach bestaetigung): %v", err)
}
if !enabled {
t.Fatal("erwartet aktiviert nach erfolgreicher bestaetigung")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Login ohne zweiten Faktor bei aktivem
// 2FA wird zuverlaessig abgewiesen.
func TestLoginWithTOTP_RequiresSecondFactorWhenEnabled(t *testing.T) {
store, userStore, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
if err != nil {
t.Fatalf("begin setup: %v", err)
}
code, _ := GenerateCode(secret, time.Now())
if _, err := store.ConfirmSetup(ctx, userID, code); err != nil {
t.Fatalf("confirm setup: %v", err)
}
u, err := userStore.Get(ctx, userID)
if err != nil {
t.Fatalf("get user: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
loginService := auth.NewLoginService(userStore, issuer, "acme")
// Korrektes Passwort, aber KEIN 2FA-Code -> abgewiesen.
if _, err := LoginWithTOTP(ctx, userStore, store, loginService, u.Email, "korrektes-passwort", ""); !errors.Is(err, ErrSecondFactorRequired) {
t.Fatalf("erwartet ErrSecondFactorRequired ohne code, habe %v", err)
}
// Korrektes Passwort + korrekter aktueller Code -> erfolgreich.
loginCode, _ := GenerateCode(secret, time.Now())
token, err := LoginWithTOTP(ctx, userStore, store, loginService, u.Email, "korrektes-passwort", loginCode)
if err != nil {
t.Fatalf("login mit gueltigem 2fa-code: %v", err)
}
if token == "" {
t.Fatal("erwartet gueltiges token")
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Wiederherstellungscode nach Nutzung als
// verbraucht getestet.
func TestVerifyLoginCode_RecoveryCodeIsSingleUse(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
if err != nil {
t.Fatalf("begin setup: %v", err)
}
code, _ := GenerateCode(secret, time.Now())
recoveryCodes, err := store.ConfirmSetup(ctx, userID, code)
if err != nil {
t.Fatalf("confirm setup: %v", err)
}
recovery := recoveryCodes[0]
ok, err := store.VerifyLoginCode(ctx, userID, recovery)
if err != nil {
t.Fatalf("verify (erste nutzung): %v", err)
}
if !ok {
t.Fatal("erwartet erfolgreiche erste nutzung des wiederherstellungscodes")
}
ok, err = store.VerifyLoginCode(ctx, userID, recovery)
if err != nil {
t.Fatalf("verify (zweite nutzung): %v", err)
}
if ok {
t.Fatal("erwartet abgelehnt bei erneuter nutzung desselben wiederherstellungscodes")
}
}
-104
View File
@@ -1,104 +0,0 @@
// Package totp implementiert Core IAM-04: TOTP-basierte Zwei-Faktor-
// Authentifizierung (RFC 6238, aufbauend auf HOTP RFC 4226) mit stdlib
// crypto/hmac+sha1 — kein externes Modul noetig.
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/subtle"
"encoding/base32"
"encoding/binary"
"fmt"
"net/url"
"time"
)
// StepSeconds ist das TOTP-Zeitfenster (RFC-6238-Standard: 30 Sekunden).
const StepSeconds = 30
// DefaultSkewSteps ist die dokumentierte Zeitversatz-Toleranz
// (Akzeptanzkriterium/Pruefung 3): +/- 1 Schritt = +/- 30 Sekunden um die
// aktuelle Serverzeit, insgesamt ein 90-Sekunden-Fenster. Grosszuegig genug
// fuer leicht abweichende Client-Uhren, eng genug um ein TOTP-Fenster nicht
// unbrauchbar lange gueltig zu lassen.
const DefaultSkewSteps = 1
// GenerateSecret erzeugt ein neues, zufaelliges TOTP-Secret (20 Byte, wie von
// Authenticator-Apps erwartet), Base32-kodiert ohne Padding.
func GenerateSecret() (string, error) {
buf := make([]byte, 20)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("secret erzeugen: %w", err)
}
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil
}
// ProvisioningURI liefert die otpauth://-URI fuer den QR-Code
// (Akzeptanzkriterium 1) — das eigentliche QR-Rendering ist Frontend-Sache
// (IAM-08), hier wird nur die standardkonforme URI erzeugt.
func ProvisioningURI(issuer, accountName, secret string) string {
label := url.PathEscape(issuer) + ":" + url.PathEscape(accountName)
q := url.Values{}
q.Set("secret", secret)
q.Set("issuer", issuer)
q.Set("algorithm", "SHA1")
q.Set("digits", "6")
q.Set("period", fmt.Sprintf("%d", StepSeconds))
return fmt.Sprintf("otpauth://totp/%s?%s", label, q.Encode())
}
// GenerateCode berechnet den 6-stelligen TOTP-Code fuer secret zum Zeitpunkt t.
func GenerateCode(secret string, t time.Time) (string, error) {
key, err := decodeSecret(secret)
if err != nil {
return "", err
}
counter := uint64(t.Unix() / StepSeconds)
return hotp(key, counter), nil
}
// Validate prueft code gegen secret mit der dokumentierten Zeitversatz-
// Toleranz (DefaultSkewSteps) um t. Vergleich ist timing-safe.
func Validate(secret, code string, t time.Time) (bool, error) {
key, err := decodeSecret(secret)
if err != nil {
return false, err
}
counter := int64(t.Unix() / StepSeconds)
for delta := -DefaultSkewSteps; delta <= DefaultSkewSteps; delta++ {
candidate := hotp(key, uint64(counter+int64(delta)))
if subtle.ConstantTimeCompare([]byte(candidate), []byte(code)) == 1 {
return true, nil
}
}
return false, nil
}
func decodeSecret(secret string) ([]byte, error) {
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
if err != nil {
return nil, fmt.Errorf("secret dekodieren: %w", err)
}
return key, nil
}
// hotp implementiert RFC 4226 mit HMAC-SHA1, 6 Ziffern.
func hotp(key []byte, counter uint64) string {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, counter)
mac := hmac.New(sha1.New, key)
mac.Write(buf)
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0f
code := (uint32(sum[offset]&0x7f) << 24) |
(uint32(sum[offset+1]) << 16) |
(uint32(sum[offset+2]) << 8) |
uint32(sum[offset+3])
return fmt.Sprintf("%06d", code%1000000)
}
-79
View File
@@ -1,79 +0,0 @@
package totp
import (
"testing"
"time"
)
func TestGenerateAndValidateCode_RoundTrip(t *testing.T) {
secret, err := GenerateSecret()
if err != nil {
t.Fatalf("generate secret: %v", err)
}
now := time.Now()
code, err := GenerateCode(secret, now)
if err != nil {
t.Fatalf("generate code: %v", err)
}
if len(code) != 6 {
t.Fatalf("erwartet 6-stelligen code, habe %q", code)
}
ok, err := Validate(secret, code, now)
if err != nil {
t.Fatalf("validate: %v", err)
}
if !ok {
t.Fatal("erwartet gueltigen code")
}
wrongCode := "000000"
if code == wrongCode {
wrongCode = "111111"
}
ok, err = Validate(secret, wrongCode, now)
if err != nil {
t.Fatalf("validate (falsch): %v", err)
}
if ok {
t.Fatal("erwartet ungueltig fuer falschen code")
}
}
// Akzeptanzkriterium/Pruefung 3: Zeitversatz-Toleranz dokumentiert (siehe
// DefaultSkewSteps = 1 Schritt = 30s) und getestet.
func TestValidate_ClockSkewTolerance(t *testing.T) {
secret, err := GenerateSecret()
if err != nil {
t.Fatalf("generate secret: %v", err)
}
now := time.Now()
// Code aus dem VORHERIGEN Zeitfenster (bis zu 30s in der Vergangenheit)
// muss innerhalb der dokumentierten Toleranz noch akzeptiert werden.
pastCode, err := GenerateCode(secret, now.Add(-25*time.Second))
if err != nil {
t.Fatalf("generate code (past): %v", err)
}
ok, err := Validate(secret, pastCode, now)
if err != nil {
t.Fatalf("validate (past): %v", err)
}
if !ok {
t.Fatal("erwartet gueltig innerhalb der dokumentierten skew-toleranz (25s)")
}
// Code weit ausserhalb der Toleranz (mehrere Schritte in der
// Vergangenheit) muss abgelehnt werden.
farPastCode, err := GenerateCode(secret, now.Add(-5*time.Minute))
if err != nil {
t.Fatalf("generate code (far past): %v", err)
}
ok, err = Validate(secret, farPastCode, now)
if err != nil {
t.Fatalf("validate (far past): %v", err)
}
if ok {
t.Fatal("erwartet ungueltig weit ausserhalb der skew-toleranz (5min)")
}
}
-66
View File
@@ -1,66 +0,0 @@
package user
import (
"encoding/json"
"errors"
"net/http"
)
// Handler stellt die CRUD-API fuer Benutzerkonten bereit (IAM-01-Auftrag).
// Auth/Sessions (IAM-02) und Rollen (RBAC-01) sind ausdruecklich nicht Teil
// dieser Kachel und daher hier noch nicht angebunden.
type Handler struct {
users *TenantUserStore
superadmins *SuperadminStore
}
func NewHandler(users *TenantUserStore, superadmins *SuperadminStore) *Handler {
return &Handler{users: users, superadmins: superadmins}
}
type createUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
u, err := h.users.Create(r.Context(), req.Email, req.Name)
writeUserResult(w, u, err)
}
// CreateSuperadmin legt ein mandantenuebergreifendes Superadmin-Konto an —
// bewusst ein eigener Endpunkt statt eines Tenant-Parameters mit Null-Wert.
func (h *Handler) CreateSuperadmin(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
u, err := h.superadmins.Create(r.Context(), req.Email, req.Name)
writeUserResult(w, u, err)
}
func writeUserResult(w http.ResponseWriter, u User, err error) {
if err != nil {
switch {
case errors.Is(err, ErrInvalidEmail), errors.Is(err, ErrEmailTaken):
http.Error(w, err.Error(), http.StatusBadRequest)
case errors.Is(err, ErrNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
default:
http.Error(w, "benutzer konnte nicht verarbeitet werden", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(u)
}
-173
View File
@@ -1,173 +0,0 @@
package user
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// setupTestDB legt eine frische, isolierte Testdatenbank an, wendet die
// uebergebene Migration an und liefert einen verbundenen Pool. Wird ohne
// TEST_ADMIN_DSN uebersprungen — siehe internal/tenant/provisioner_test.go
// fuer dasselbe Muster.
func setupTestDB(t *testing.T, dbName, schemaSQL string) *pgxpool.Pool {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, schemaSQL); err != nil {
t.Fatalf("schema anwenden: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
return pool
}
const usersSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);`
const superadminsSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE superadmins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);`
// Akzeptanzkriterium 1 + 3, Pruefung 1 (inkl. Negativfall doppelte E-Mail).
func TestTenantUserStore_CRUD(t *testing.T) {
pool := setupTestDB(t, "test_iam01_users", usersSchema)
store := NewTenantUserStore(pool)
ctx := context.Background()
created, err := store.Create(ctx, "alice@example.com", "Alice")
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Status != StatusActive {
t.Fatalf("erwartet status active, hat %q", created.Status)
}
got, err := store.Get(ctx, created.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Email != "alice@example.com" {
t.Fatalf("get email = %q", got.Email)
}
updated, err := store.Update(ctx, created.ID, "", "Alice A.")
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Name != "Alice A." || updated.Email != "alice@example.com" {
t.Fatalf("update ergebnis unerwartet: %+v", updated)
}
list, err := store.List(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 1 {
t.Fatalf("erwartet 1 benutzer, habe %d", len(list))
}
deactivated, err := store.Deactivate(ctx, created.ID)
if err != nil {
t.Fatalf("deactivate: %v", err)
}
if deactivated.Status != StatusInactive {
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
}
// Negativfall: doppelte E-Mail-Adresse.
if _, err := store.Create(ctx, "second@example.com", "Bob"); err != nil {
t.Fatalf("create second: %v", err)
}
if _, err := store.Create(ctx, "second@example.com", "Bob Zwei"); !errors.Is(err, ErrEmailTaken) {
t.Fatalf("erwartet ErrEmailTaken, habe %v", err)
}
// Negativfall: fehlender Benutzer.
if _, err := store.Get(ctx, created.ID+"-nicht-vorhanden"); err == nil {
t.Fatalf("erwartet fehler bei unbekannter/ungueltiger id")
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Superadmin-Anlage ohne Tenant-Kontext.
// SuperadminStore.Create hat keinen Tenant-Parameter — es gibt syntaktisch
// keine Moeglichkeit, hier versehentlich einen Tenant-Sonderfall zu vergessen.
func TestSuperadminStore_CreateWithoutTenantContext(t *testing.T) {
pool := setupTestDB(t, "test_iam01_superadmins", superadminsSchema)
store := NewSuperadminStore(pool)
ctx := context.Background()
created, err := store.Create(ctx, "root@nexarch.internal", "Root")
if err != nil {
t.Fatalf("create superadmin: %v", err)
}
if created.Status != StatusActive {
t.Fatalf("erwartet status active, hat %q", created.Status)
}
got, err := store.Get(ctx, created.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Email != "root@nexarch.internal" {
t.Fatalf("get email = %q", got.Email)
}
if _, err := store.Create(ctx, "root@nexarch.internal", "Root Zwei"); !errors.Is(err, ErrEmailTaken) {
t.Fatalf("erwartet ErrEmailTaken (globale eindeutigkeit), habe %v", err)
}
deactivated, err := store.Deactivate(ctx, created.ID)
if err != nil {
t.Fatalf("deactivate: %v", err)
}
if deactivated.Status != StatusInactive {
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
}
}
-77
View File
@@ -1,77 +0,0 @@
package user
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// SuperadminStore verwaltet mandantenuebergreifende Superadmin-Konten in der
// Control-Plane-Registry (siehe internal/tenant.Registry). Superadmin-ohne-
// Tenant ist dadurch ein eigener Typ statt eines Sonderfalls von User/
// TenantUserStore — es gibt keinen Tenant-Parameter, den man weglassen
// koennte (IAM-01, "ohne Sonderbehandlung im Code").
type SuperadminStore struct {
pool *pgxpool.Pool
}
func NewSuperadminStore(pool *pgxpool.Pool) *SuperadminStore {
return &SuperadminStore{pool: pool}
}
func (s *SuperadminStore) Create(ctx context.Context, email, name string) (User, error) {
if err := ValidateEmail(email); err != nil {
return User{}, err
}
var u User
u.Email, u.Name, u.Status = email, name, StatusActive
row := s.pool.QueryRow(ctx, `
INSERT INTO superadmins (email, name, status)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at
`, u.Email, u.Name, u.Status)
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
return User{}, mapWriteErr(err)
}
return u, nil
}
func (s *SuperadminStore) Get(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM superadmins WHERE id = $1
`, id))
}
func (s *SuperadminStore) List(ctx context.Context) ([]User, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM superadmins ORDER BY created_at
`)
if err != nil {
return nil, fmt.Errorf("superadmins auflisten: %w", err)
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("superadmin lesen: %w", err)
}
out = append(out, u)
}
return out, rows.Err()
}
func (s *SuperadminStore) Deactivate(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
UPDATE superadmins SET status = $2, updated_at = now()
WHERE id = $1
RETURNING id, email, name, status, created_at, updated_at
`, id, StatusInactive))
}
-193
View File
@@ -1,193 +0,0 @@
package user
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// TenantUserStore verwaltet Benutzer innerhalb GENAU EINER Tenant-Datenbank.
// Welcher Mandant gemeint ist, ergibt sich ausschliesslich aus dem
// uebergebenen Pool — es gibt keine tenant_id-Spalte (siehe migrations/tenant/0001_users.up.sql).
type TenantUserStore struct {
pool *pgxpool.Pool
}
func NewTenantUserStore(pool *pgxpool.Pool) *TenantUserStore {
return &TenantUserStore{pool: pool}
}
func (s *TenantUserStore) Create(ctx context.Context, email, name string) (User, error) {
if err := ValidateEmail(email); err != nil {
return User{}, err
}
var u User
u.Email, u.Name, u.Status = email, name, StatusActive
row := s.pool.QueryRow(ctx, `
INSERT INTO users (email, name, status)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at
`, u.Email, u.Name, u.Status)
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
return User{}, mapWriteErr(err)
}
return u, nil
}
func (s *TenantUserStore) Get(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM users WHERE id = $1
`, id))
}
func (s *TenantUserStore) List(ctx context.Context) ([]User, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM users ORDER BY created_at
`)
if err != nil {
return nil, fmt.Errorf("benutzer auflisten: %w", err)
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("benutzer lesen: %w", err)
}
out = append(out, u)
}
return out, rows.Err()
}
// Update aendert Name und E-Mail. Eine leere email/name laesst das jeweilige
// Feld unveraendert.
func (s *TenantUserStore) Update(ctx context.Context, id, email, name string) (User, error) {
if email != "" {
if err := ValidateEmail(email); err != nil {
return User{}, err
}
}
row := s.pool.QueryRow(ctx, `
UPDATE users
SET email = COALESCE(NULLIF($2, ''), email),
name = COALESCE(NULLIF($3, ''), name),
updated_at = now()
WHERE id = $1
RETURNING id, email, name, status, created_at, updated_at
`, id, email, name)
u, err := scanUser(row)
if err != nil {
return User{}, mapWriteErr(err)
}
return u, nil
}
// Deactivate setzt den Benutzer auf inaktiv statt ihn zu loeschen.
func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
UPDATE users SET status = $2, updated_at = now()
WHERE id = $1
RETURNING id, email, name, status, created_at, updated_at
`, id, StatusInactive))
}
// SetPasswordHash schreibt einen bereits berechneten bcrypt-Hash (siehe
// internal/auth, IAM-02). Der Store selbst kennt kein Klartext-Passwort.
func (s *TenantUserStore) SetPasswordHash(ctx context.Context, id, hash string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1
`, id, hash)
if err != nil {
return fmt.Errorf("passwort setzen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// AuthCredentials wird ausschliesslich fuer den Login-Pfad (internal/auth)
// verwendet und traegt bewusst den password_hash, damit er nicht ueber den
// regulaeren User-Typ/JSON-Serialisierungspfad nach aussen dringen kann.
type AuthCredentials struct {
User User
PasswordHash string
}
// GetByIDForAuth liefert Benutzer + Passwort-Hash zu einer User-ID — fuer
// IAM-08 (Passwortaenderung: die bestehende Session liefert die ID, nicht
// die E-Mail-Adresse, gleiches Prinzip wie GetByEmailForAuth).
func (s *TenantUserStore) GetByIDForAuth(ctx context.Context, id string) (AuthCredentials, error) {
var c AuthCredentials
row := s.pool.QueryRow(ctx, `
SELECT id, email, name, status, created_at, updated_at, password_hash
FROM users WHERE id = $1
`, id)
if err := row.Scan(&c.User.ID, &c.User.Email, &c.User.Name, &c.User.Status,
&c.User.CreatedAt, &c.User.UpdatedAt, &c.PasswordHash); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return AuthCredentials{}, ErrNotFound
}
return AuthCredentials{}, fmt.Errorf("anmeldedaten lesen: %w", err)
}
return c, nil
}
// GetByEmailForAuth liefert Benutzer + Passwort-Hash zu einer E-Mail-Adresse
// aus GENAU DIESER Tenant-Datenbank — der Tenant-Scope ergibt sich damit
// zwingend aus dem verwendeten Pool, es gibt keine Moeglichkeit, versehentlich
// ueber Tenant-Grenzen hinweg zu suchen (bekannter archivmail-Fehler, siehe
// IAM-02 "Bekannte Fehler vermeiden").
func (s *TenantUserStore) GetByEmailForAuth(ctx context.Context, email string) (AuthCredentials, error) {
var c AuthCredentials
row := s.pool.QueryRow(ctx, `
SELECT id, email, name, status, created_at, updated_at, password_hash
FROM users WHERE email = $1
`, email)
if err := row.Scan(&c.User.ID, &c.User.Email, &c.User.Name, &c.User.Status,
&c.User.CreatedAt, &c.User.UpdatedAt, &c.PasswordHash); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return AuthCredentials{}, ErrNotFound
}
return AuthCredentials{}, fmt.Errorf("anmeldedaten lesen: %w", err)
}
return c, nil
}
func scanUser(row pgx.Row) (User, error) {
var u User
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
return User{}, fmt.Errorf("benutzer lesen: %w", err)
}
return u, nil
}
// mapWriteErr uebersetzt den Unique-Constraint-Verstoss der E-Mail-Spalte in
// einen sprechenden Fehler statt der rohen Postgres-Fehlermeldung.
func mapWriteErr(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrEmailTaken
}
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("benutzer schreiben: %w", err)
}
-42
View File
@@ -1,42 +0,0 @@
// Package user implementiert Core IAM-01: das Benutzer-Datenmodell und die
// CRUD-Operationen. Tenant-Zugehoerigkeit ist ueber die Zieldatenbank
// gegeben (Modell C, siehe internal/tenant) — Superadmin-Konten leben
// dagegen mandantenuebergreifend in der Registry und sind ueber
// SuperadminStore als eigener, First-Class-Typ modelliert, nicht als
// tenant_id-NULL-Sonderfall in User.
package user
import (
"errors"
"regexp"
"time"
)
type Status string
const (
StatusActive Status = "active"
StatusInactive Status = "inactive"
)
type User struct {
ID string
Email string
Name string
Status Status
CreatedAt time.Time
UpdatedAt time.Time
}
var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
var ErrInvalidEmail = errors.New("user: ungueltige E-Mail-Adresse")
var ErrEmailTaken = errors.New("user: E-Mail-Adresse bereits vergeben")
var ErrNotFound = errors.New("user: nicht gefunden")
func ValidateEmail(email string) error {
if !emailPattern.MatchString(email) {
return ErrInvalidEmail
}
return nil
}
-24
View File
@@ -1,24 +0,0 @@
package user
import "testing"
func TestValidateEmail(t *testing.T) {
cases := []struct {
email string
wantErr bool
}{
{"a@b.de", false},
{"a.b+c@sub.example.com", false},
{"", true},
{"keine-email", true},
{"a@b", true},
{"@b.de", true},
}
for _, c := range cases {
err := ValidateEmail(c.email)
if (err != nil) != c.wantErr {
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", c.email, err, c.wantErr)
}
}
}
-1
View File
@@ -1 +0,0 @@
DROP TABLE IF EXISTS superadmins;
-14
View File
@@ -1,14 +0,0 @@
-- Superadmin-Konten arbeiten mandantenuebergreifend und leben deshalb in der
-- Control-Plane-Registry (siehe TEN-01), nicht in einer Tenant-Datenbank.
-- Das bildet "Superadmin ohne Tenant" strukturell als First-Class-Zustand ab,
-- statt ihn als Sonderfall in der Tenant-users-Tabelle zu behandeln
-- (IAM-01, siehe core-kanban/tickets/IAM-01.md — bekannte Fehler vermeiden).
-- E-Mail-Eindeutigkeit ist hier global, da die Registry-DB einmalig existiert.
CREATE TABLE superadmins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS feature_flags;
+10
View File
@@ -0,0 +1,10 @@
-- Feature-Flags zentral je Mandant/Zielgruppe (LIC-02, siehe core-kanban/tickets/LIC-02.md).
-- Lebt in der Registry-DB, nicht pro Tenant-Datenbank — Flags sind eine
-- Core-weite Konfiguration, keine Mandanten-Geschaeftsdaten.
CREATE TABLE feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100),
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS module_credentials;
DROP TABLE IF EXISTS modules;
+18
View File
@@ -0,0 +1,18 @@
-- Modul-Registry & Aktivierungspruefung (API-02, siehe core-kanban/tickets/API-02.md).
CREATE TABLE modules (
name TEXT PRIMARY KEY,
version TEXT NOT NULL CHECK (version <> ''),
required_flags TEXT[] NOT NULL DEFAULT '{}',
registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Service-Credential je Modul-Instanz, bei Provisionierung ausgestellt
-- (Akzeptanzkriterium 4). secret_hash enthaelt NIEMALS das Secret im
-- Klartext, nur dessen SHA-256-Hash (Timing-safe-Vergleich beim Login,
-- Referenzmuster siehe AUD-02).
CREATE TABLE module_credentials (
module_name TEXT PRIMARY KEY REFERENCES modules(name),
client_id TEXT NOT NULL UNIQUE,
secret_hash BYTEA NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE status_history;
DROP TABLE status_targets;
+15
View File
@@ -0,0 +1,15 @@
-- Zentrale Statusseite (OPS-02, siehe core-kanban/tickets/OPS-02.md) —
-- ueberwachte Module und der Verlauf ihrer Statusaenderungen.
CREATE TABLE status_targets (
name TEXT PRIMARY KEY,
health_url TEXT NOT NULL
);
CREATE TABLE status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
status TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX status_history_name_idx ON status_history (name, changed_at DESC);
-1
View File
@@ -1 +0,0 @@
DROP TABLE IF EXISTS users;
-16
View File
@@ -1,16 +0,0 @@
-- Benutzer-Datenmodell (IAM-01, siehe core-kanban/tickets/IAM-01.md).
-- Diese Migration laeuft in der DB EINES Mandanten (Modell C, siehe TEN-01) —
-- die Tenant-Zugehoerigkeit ist implizit durch die Datenbankverbindung
-- gegeben, es gibt daher bewusst KEINE tenant_id-Spalte.
-- E-Mail-Eindeutigkeit ist hier tenant-scoped: der UNIQUE-Constraint gilt
-- nur innerhalb dieser einen Tenant-Datenbank.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -1 +0,0 @@
ALTER TABLE users DROP COLUMN password_hash;
@@ -1,3 +0,0 @@
-- Passwort-Hash-Spalte fuer Login (IAM-02, siehe core-kanban/tickets/IAM-02.md).
-- Enthaelt AUSSCHLIESSLICH den bcrypt-Hash, niemals das Klartext-Passwort.
ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
@@ -1 +0,0 @@
DROP TABLE IF EXISTS password_tokens;
@@ -1,12 +0,0 @@
-- Einmal-Token fuer Passwort-Reset und Einladung (IAM-03, siehe
-- core-kanban/tickets/IAM-03.md). token_hash enthaelt NIEMALS den
-- Klartext-Token, nur dessen SHA-256-Hash.
CREATE TABLE password_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
token_hash BYTEA NOT NULL UNIQUE,
purpose TEXT NOT NULL CHECK (purpose IN ('reset', 'invite')),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-3
View File
@@ -1,3 +0,0 @@
DROP TABLE IF EXISTS totp_policy;
DROP TABLE IF EXISTS totp_recovery_codes;
DROP TABLE IF EXISTS totp_credentials;
-23
View File
@@ -1,23 +0,0 @@
-- Zwei-Faktor-Authentifizierung TOTP (IAM-04, siehe core-kanban/tickets/IAM-04.md).
CREATE TABLE totp_credentials (
user_id UUID PRIMARY KEY REFERENCES users(id),
secret TEXT NOT NULL,
confirmed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- code_hash enthaelt NIEMALS den Klartext-Wiederherstellungscode, nur dessen
-- SHA-256-Hash. used_at markiert einmalige Verwendbarkeit (Akzeptanzkriterium 3).
CREATE TABLE totp_recovery_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
code_hash BYTEA NOT NULL UNIQUE,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Erzwingbare Aktivierungspflicht pro Tenant (Singleton-Zeile, Modell C).
CREATE TABLE totp_policy (
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id),
required BOOLEAN NOT NULL DEFAULT false
);
-87
View File
@@ -1,87 +0,0 @@
"use client";
import { useState } from "react";
import { TextField, useToast } from "@nexarch/shl";
import { ApiError, login } from "../../lib/api";
export default function LoginPage() {
const { push } = useToast();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [totpCode, setTotpCode] = useState("");
const [needsSecondFactor, setNeedsSecondFactor] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
await login(email, password, needsSecondFactor ? totpCode : undefined);
push("Anmeldung erfolgreich.", "success");
window.location.href = "/profile";
} catch (err) {
if (err instanceof ApiError) {
// Akzeptanzkriterium 3 / Pruefung 1: nur die generische, vom Backend
// gelieferte Meldung anzeigen — keine eigene, evtl. praezisere
// Fehlerursache im Frontend herleiten oder ergaenzen.
setError(err.message);
if (err.code === "second_factor_required") {
setNeedsSecondFactor(true);
}
} else {
setError("Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.");
}
} finally {
setSubmitting(false);
}
}
return (
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
<h1>Anmeldung</h1>
<form onSubmit={onSubmit} noValidate>
<TextField
label="E-Mail-Adresse"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
label="Passwort"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{needsSecondFactor && (
<TextField
label="Zweiter Faktor (Authenticator-Code oder Wiederherstellungscode)"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
required
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
hint="Öffnen Sie Ihre Authenticator-App oder geben Sie einen Wiederherstellungscode ein."
/>
)}
{error && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{error}
</p>
)}
<button type="submit" disabled={submitting}>
{submitting ? "Anmeldung läuft…" : "Anmelden"}
</button>
</form>
<p>
<a href="/password-reset/request">Passwort vergessen?</a>
</p>
</main>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function IndexPage() {
redirect("/login");
}
@@ -1,79 +0,0 @@
"use client";
import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import { TextField } from "@nexarch/shl";
import { ApiError, completePasswordReset } from "../../../lib/api";
export default function PasswordResetCompletePage() {
return (
<Suspense fallback={null}>
<PasswordResetCompleteForm />
</Suspense>
);
}
function PasswordResetCompleteForm() {
const params = useSearchParams();
const token = params.get("token") ?? "";
const [newPassword, setNewPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
await completePasswordReset(token, newPassword);
setDone(true);
} catch (err) {
// Akzeptanzkriterium 3: generische Meldung, keine interne Fehlerursache.
setError(err instanceof ApiError ? err.message : "Der Link ist ungültig oder abgelaufen.");
} finally {
setSubmitting(false);
}
}
if (done) {
return (
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
<h1>Passwort geändert</h1>
<p>
Ihr Passwort wurde erfolgreich geändert. <a href="/login">Jetzt anmelden</a>.
</p>
</main>
);
}
return (
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
<h1>Neues Passwort festlegen</h1>
{!token && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
Kein gültiger Link. Bitte fordern Sie einen neuen Reset-Link an.
</p>
)}
<form onSubmit={onSubmit} noValidate>
<TextField
label="Neues Passwort"
type="password"
autoComplete="new-password"
required
minLength={8}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
{error && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{error}
</p>
)}
<button type="submit" disabled={submitting || !token}>
{submitting ? "Wird gespeichert…" : "Passwort speichern"}
</button>
</form>
</main>
);
}
@@ -1,53 +0,0 @@
"use client";
import { useState } from "react";
import { TextField } from "@nexarch/shl";
import { requestPasswordReset } from "../../../lib/api";
export default function PasswordResetRequestPage() {
const [email, setEmail] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
try {
// Antwort ist absichtlich IMMER dieselbe (Backend-Garantie,
// Akzeptanzkriterium 3) — das Frontend erfindet keine differenzierte
// Anzeige je nachdem ob das Konto existiert.
const result = await requestPasswordReset(email);
setMessage(result.message);
} catch {
setMessage("Falls ein Konto mit dieser E-Mail-Adresse existiert, wurde eine Nachricht mit weiteren Schritten verschickt.");
} finally {
setSubmitting(false);
}
}
return (
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
<h1>Passwort zurücksetzen</h1>
{message ? (
<p role="status">{message}</p>
) : (
<form onSubmit={onSubmit} noValidate>
<TextField
label="E-Mail-Adresse"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button type="submit" disabled={submitting}>
{submitting ? "Wird gesendet…" : "Link anfordern"}
</button>
</form>
)}
<p>
<a href="/login">Zurück zur Anmeldung</a>
</p>
</main>
);
}
-228
View File
@@ -1,228 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { TextField, useToast } from "@nexarch/shl";
import {
ApiError,
Me,
beginTotpSetup,
changePassword,
confirmTotpSetup,
fetchMe,
fetchTotpStatus,
} from "../../lib/api";
export default function ProfilePage() {
const { push } = useToast();
const [me, setMe] = useState<Me | null>(null);
const [totpEnabled, setTotpEnabled] = useState<boolean | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
Promise.all([fetchMe(), fetchTotpStatus()])
.then(([meResult, statusResult]) => {
setMe(meResult);
setTotpEnabled(statusResult.enabled);
})
.catch(() => setLoadError("Bitte melden Sie sich an, um Ihr Profil zu sehen."));
}, []);
if (loadError) {
return (
<main style={{ maxWidth: 480, margin: "80px auto", padding: "0 16px" }}>
<p role="alert">{loadError}</p>
<a href="/login">Zur Anmeldung</a>
</main>
);
}
return (
<main style={{ maxWidth: 480, margin: "40px auto", padding: "0 16px" }}>
<h1>Mein Profil</h1>
{me && (
<section aria-labelledby="stammdaten-heading">
<h2 id="stammdaten-heading">Stammdaten</h2>
<p>
{me.name} {me.email}
</p>
</section>
)}
<ChangePasswordSection onSuccess={() => push("Passwort geändert.", "success")} />
<TotpSection
enabled={totpEnabled}
onEnabled={() => setTotpEnabled(true)}
onNotice={(text) => push(text, "info")}
/>
</main>
);
}
function ChangePasswordSection({ onSuccess }: { onSuccess: () => void }) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
await changePassword(currentPassword, newPassword);
setCurrentPassword("");
setNewPassword("");
onSuccess();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Passwort konnte nicht geändert werden.");
} finally {
setSubmitting(false);
}
}
return (
<section aria-labelledby="passwort-heading">
<h2 id="passwort-heading">Passwort ändern</h2>
<form onSubmit={onSubmit} noValidate>
<TextField
label="Aktuelles Passwort"
type="password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
<TextField
label="Neues Passwort"
type="password"
autoComplete="new-password"
required
minLength={8}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
{error && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{error}
</p>
)}
<button type="submit" disabled={submitting}>
{submitting ? "Wird gespeichert…" : "Passwort ändern"}
</button>
</form>
</section>
);
}
function TotpSection({
enabled,
onEnabled,
onNotice,
}: {
enabled: boolean | null;
onEnabled: () => void;
onNotice: (text: string) => void;
}) {
const [setupData, setSetupData] = useState<{ secret: string; provisioningUri: string } | null>(null);
const [confirmCode, setConfirmCode] = useState("");
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function onBeginSetup() {
setError(null);
setBusy(true);
try {
const result = await beginTotpSetup();
setSetupData({ secret: result.secret, provisioningUri: result.provisioning_uri });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Einrichtung konnte nicht gestartet werden.");
} finally {
setBusy(false);
}
}
async function onConfirm(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setBusy(true);
try {
const result = await confirmTotpSetup(confirmCode);
// Akzeptanzkriterium 2: Wiederherstellungscodes werden HIER, EINMALIG
// im Klartext angezeigt — die Backend-API liefert sie an keiner
// anderen Stelle je wieder aus (siehe internal/totp/store.go).
setRecoveryCodes(result.recovery_codes);
setSetupData(null);
onEnabled();
onNotice("Zwei-Faktor-Authentifizierung aktiviert. Bewahren Sie die Wiederherstellungscodes sicher auf.");
} catch (err) {
setError(err instanceof ApiError ? err.message : "Code ungültig.");
} finally {
setBusy(false);
}
}
return (
<section aria-labelledby="zwei-faktor-heading">
<h2 id="zwei-faktor-heading">Zwei-Faktor-Authentifizierung</h2>
{recoveryCodes ? (
<div role="alert">
<p>
<strong>Wiederherstellungscodes jetzt notieren, sie werden nicht erneut angezeigt:</strong>
</p>
<ul>
{recoveryCodes.map((code) => (
<li key={code}>
<code>{code}</code>
</li>
))}
</ul>
</div>
) : enabled ? (
<p>Zwei-Faktor-Authentifizierung ist aktiv.</p>
) : setupData ? (
<form onSubmit={onConfirm} noValidate>
<p>
Scannen Sie den folgenden Schlüssel mit Ihrer Authenticator-App oder geben Sie ihn manuell ein:
</p>
<p>
<code>{setupData.secret}</code>
</p>
<p style={{ wordBreak: "break-all" }}>
<a href={setupData.provisioningUri}>{setupData.provisioningUri}</a>
</p>
<TextField
label="Bestätigungscode aus der App"
type="text"
inputMode="numeric"
required
value={confirmCode}
onChange={(e) => setConfirmCode(e.target.value)}
/>
{error && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{error}
</p>
)}
<button type="submit" disabled={busy}>
{busy ? "Wird bestätigt…" : "Einrichtung bestätigen"}
</button>
</form>
) : (
<>
<p>Zwei-Faktor-Authentifizierung ist derzeit nicht aktiv.</p>
{error && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{error}
</p>
)}
<button type="button" onClick={onBeginSetup} disabled={busy}>
{busy ? "Wird gestartet…" : "Einrichten"}
</button>
</>
)}
</section>
);
}
-92
View File
@@ -1,92 +0,0 @@
// Duenne fetch-Wrapper gegen die IAM-08-Backend-Endpunkte. Kein Framework,
// keine Abstraktionsschicht ueber das Notwendige hinaus (Produkt-DNA:
// schlanker Go-Dienst + schmales Frontend statt schwergewichtiger Plattform).
const API_BASE = process.env.NEXT_PUBLIC_CORE_API_BASE ?? "";
export class ApiError extends Error {
code?: string;
constructor(message: string, code?: string) {
super(message);
this.code = code;
}
}
async function postJSON<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler", data.code);
}
return data as T;
}
async function getJSON<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { credentials: "include" });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler", data.code);
}
return data as T;
}
export interface LoginResult {
ok: true;
}
export async function login(email: string, password: string, totpCode?: string): Promise<LoginResult> {
await postJSON("/auth/login", { email, password, totp_code: totpCode ?? "" });
return { ok: true };
}
export async function requestPasswordReset(email: string): Promise<{ message: string }> {
return postJSON("/auth/password-reset/request", { email });
}
export async function completePasswordReset(token: string, newPassword: string): Promise<void> {
await postJSON("/auth/password-reset/complete", { token, new_password: newPassword });
}
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
await postJSON("/account/change-password", { current_password: currentPassword, new_password: newPassword });
}
export interface Me {
id: string;
email: string;
name: string;
}
export async function fetchMe(): Promise<Me> {
return getJSON("/account/me");
}
export interface TotpStatus {
enabled: boolean;
}
export async function fetchTotpStatus(): Promise<TotpStatus> {
return getJSON("/auth/totp/status");
}
export interface TotpSetupBegin {
secret: string;
provisioning_uri: string;
}
export async function beginTotpSetup(): Promise<TotpSetupBegin> {
return postJSON("/auth/totp/setup/begin", {});
}
export interface TotpSetupConfirm {
recovery_codes: string[];
}
export async function confirmTotpSetup(code: string): Promise<TotpSetupConfirm> {
return postJSON("/auth/totp/setup/confirm", { code });
}
@@ -1,7 +1,7 @@
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl"; import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
export const metadata = { export const metadata = {
title: "NEXARCH Anmeldung & Profil", title: "NEXARCH Systemstatus",
}; };
export default function RootLayout({ export default function RootLayout({
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useState } from "react";
import { fetchOverview, fetchHistory, type ModuleStatus, type HistoryEntry } from "@/lib/api";
const POLL_INTERVAL_MS = 5000;
export default function Page() {
const [modules, setModules] = useState<ModuleStatus[]>([]);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [history, setHistory] = useState<HistoryEntry[] | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function poll() {
try {
const data = await fetchOverview();
if (!cancelled) {
setModules(data);
setError(null);
}
} catch (e: any) {
// Ein nicht antwortendes Backend darf die zuletzt bekannte Ansicht
// nicht loeschen und die Seite nicht unbedienbar machen
// (Akzeptanzkriterium 2) — nur eine Fehlermeldung anzeigen, alte
// Daten bleiben sichtbar.
if (!cancelled) {
setError(e.message ?? "Unbekannter Fehler beim Laden der Uebersicht");
}
}
}
poll();
const id = setInterval(poll, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
async function openHistory(name: string) {
setSelected(name);
setHistoryError(null);
try {
const data = await fetchHistory(name);
setHistory(data);
} catch (e: any) {
setHistoryError(e.message ?? "Verlauf konnte nicht geladen werden");
setHistory(null);
}
}
const anyDown = modules.some((m) => m.status === "down");
return (
<main style={{ maxWidth: 800, margin: "0 auto", padding: "2rem 1rem" }}>
<h1>Systemstatus</h1>
{error && (
<p style={{ color: "#c62828", background: "#fdecea", padding: "0.5rem", borderRadius: 4 }} role="alert">
Uebersicht konnte gerade nicht aktualisiert werden: {error}. Zuletzt bekannter Stand wird weiter angezeigt.
</p>
)}
{!error && anyDown && (
<p style={{ color: "#c62828", background: "#fdecea", padding: "0.5rem", borderRadius: 4 }}>
Mindestens ein Modul ist derzeit nicht erreichbar.
</p>
)}
<ul style={{ listStyle: "none", padding: 0 }}>
{modules.map((m) => (
<li
key={m.name}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
background: "white",
padding: "0.75rem 1rem",
marginBottom: "0.5rem",
borderRadius: 8,
borderLeft: `6px solid ${m.status === "up" ? "#2e7d32" : "#c62828"}`,
}}
>
<div>
<strong>{m.name}</strong>
<div style={{ fontSize: "0.85rem", color: "#666" }}>
{m.status === "up" ? "Verfügbar" : "Nicht verfügbar"}
{m.last_checked && ` — zuletzt geprüft ${new Date(m.last_checked).toLocaleString("de-DE")}`}
</div>
</div>
<button onClick={() => openHistory(m.name)}>Verlauf</button>
</li>
))}
{modules.length === 0 && !error && <li>Lade Modulstatus</li>}
</ul>
{selected && (
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginTop: "1.5rem" }}>
<h2>Verlauf: {selected}</h2>
{historyError && (
<p style={{ color: "#c62828" }} role="alert">
{historyError}
</p>
)}
{history && (
<ul>
{history.map((h, i) => (
<li key={i}>
{new Date(h.changed_at).toLocaleString("de-DE")} {h.status === "up" ? "verfügbar" : "nicht verfügbar"}
</li>
))}
{history.length === 0 && <li>Keine Statusänderungen bisher.</li>}
</ul>
)}
<button onClick={() => setSelected(null)}>Schließen</button>
</section>
)}
</main>
);
}
+44
View File
@@ -0,0 +1,44 @@
// Duenner Client des OPS-02-Backends (internal/statuspage) — keine eigene
// Aggregations-/Polling-Logik im Frontend.
export type ModuleStatus = {
name: string;
status: "up" | "down";
last_checked?: string;
};
export type HistoryEntry = {
status: "up" | "down";
changed_at: string;
};
function apiBase(): string {
const base = process.env.NEXT_PUBLIC_STATUSPAGE_API_URL;
if (!base) {
throw new Error(
"NEXT_PUBLIC_STATUSPAGE_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
);
}
return base;
}
// fetchOverview holt den Status EINES Moduls unabhaengig vom Erfolg der
// anderen — ein Netzwerkfehler beim Abruf der Gesamtuebersicht wird vom
// Aufrufer (Page-Komponente) abgefangen, sodass ein nicht antwortendes
// Backend die Seite nicht zum Absturz bringt (Akzeptanzkriterium 2).
export async function fetchOverview(): Promise<ModuleStatus[]> {
const res = await fetch(`${apiBase()}/status/overview`, { cache: "no-store" });
if (!res.ok) {
throw new Error(`Uebersicht konnte nicht geladen werden (${res.status})`);
}
return res.json();
}
export async function fetchHistory(name: string): Promise<HistoryEntry[]> {
const res = await fetch(`${apiBase()}/status/history?name=${encodeURIComponent(name)}`, {
cache: "no-store",
});
if (!res.ok) {
throw new Error(`Verlauf konnte nicht geladen werden (${res.status})`);
}
return res.json();
}
@@ -1,5 +1,5 @@
{ {
"name": "nexarch-account", "name": "nexarch-status-page",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -1,8 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,