Compare commits

..
Author SHA1 Message Date
sysops 6537888da6 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/aud-04-audit-log-ansicht 2026-08-28 22:02:02 +02:00
sysops 554c9aae66 AUD-04: fix — transpilePackages fuer @nexarch/shl (Next.js transpiliert node_modules sonst nicht, Build brach ab) 2026-08-28 21:56:27 +02:00
sysops 1ba90319a2 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/aud-04-audit-log-ansicht 2026-08-28 21:56:20 +02:00
sysops f344f79326 AUD-04: Retrofit auf SHL-01 (ThemeProvider/I18nProvider/ToastProvider, Design-Tokens statt hartkodierter Werte) 2026-08-28 21:47:23 +02:00
sysops c4bcfa8caf Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/aud-04-audit-log-ansicht
# Conflicts:
#	DEVLOG.md
2026-08-28 21:47:09 +02:00
sysops 3b96d8ef41 AUD-04: backend-authorizer + dev-server + next.js audit-log-ansicht 2026-08-28 00:10:48 +02:00
sysopsandClaude Sonnet 5 c9e8edbbf0 AUD-03: audit-log-export-filter-api
internal/audit/export.go: StreamCSV/StreamJSON filtern nach Tenant, Akteur,
Aktion und Zeitraum (Akzeptanzkriterium 1) und schreiben Zeile fuer Zeile
ueber rows.Next() DIREKT auf den uebergebenen io.Writer — zu keinem
Zeitpunkt wird das komplette Ergebnis im Speicher aufgebaut (Akzeptanz-
kriterium 3). JSON-Export als JSON Lines statt einem grossen Array, um
Streaming ohne Sonderbehandlung von Klammern/Kommas zu ermoeglichen.

ExportHandler (Akzeptanzkriterium 2) schreibt direkt auf http.ResponseWriter
— derselbe Streaming-Pfad wie in Tests, kein Zwischenpuffer nur fuer HTTP.
Authorize ist eine schmale Schnittstelle (Vorbild: AUD-05 RetentionRegistrar-
Muster), da die eigentliche Rollenpruefung RBAC-02 (Policy-Enforcement) ist
und nicht Teil dieser Kachel — der Handler kennt nur "darf dieser Aufrufer
exportieren", nicht wie das entschieden wird.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Export mit hoher Eintragszahl ohne uebermaessigen Speicherverbrauch —
   TestExport_StreamsLargeResultWithoutExcessiveMemory: 20.000 Eintraege,
   Heap-Wachstum waehrend Export nur ~1.8KB (Schwelle 3MB). PASS.
2. Filterkombinationen automatisiert gegen erwartete Ergebnismengen —
   TestExport_FilterCombinations (Tenant/Actor/Action einzeln und kombiniert)
   und TestExport_TimeRangeFilter (innerhalb/ausserhalb Zeitraum). PASS.
3. Zugriff ohne passende Berechtigung abgewiesen —
   TestExportHandler_RejectsWithoutAuthorization: fehlender/falscher caller
   -> 403, berechtigter caller -> 200. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:02:56 +02:00
sysopsandClaude Sonnet 5 b12d53f469 AUD-01: zentrales-audit-log-modell
internal/audit: eigenes, strukturiertes Audit-Datenmodell (Akteur, Aktion,
Zielobjekt, Zeitpunkt, Tenant) in der Registry-DB, getrennt von jedem
allgemeinen Anwendungs-Log (eigenes Paket, eigene Tabelle audit_events,
kein Logging-Framework). Log.Record ist der EINE zentrale Schreibpfad —
es gibt keine zweite Schreibmoeglichkeit, ueber die ein Handler die
Validierung umgehen koennte.

Fehlender Tenant-Bezug wird zweifach verhindert (Akzeptanzkriterium 2):
Log.Record weist leeren TenantSlug direkt ab (ErrMissingTenant), zusaetzlich
erzwingt eine CHECK-Constraint in der Migration dasselbe auf Datenbankebene,
selbst wenn Log.Record umgangen wuerde. Mandantenuebergreifende Ereignisse
(z.B. Superadmin-Aktionen) nutzen den reservierten Wert audit.SystemTenant
statt NULL oder leerem String — es gibt keinen Weg, ganz ohne Tenant-Bezug
zu schreiben.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Automatisierter Test belegt genau einen Audit-Eintrag pro
   sicherheitsrelevantem Vorgang — TestRecord_PersistsExactlyOneEventPerSecurityIncident
   (simulierter fehlgeschlagener Login), Feldinhalte verifiziert. PASS.
2. Fehlender Tenant-Bezug durch Constraint/Test verhindert —
   TestRecord_RejectsMissingTenant (App-Ebene) UND
   TestConstraint_RejectsMissingTenantAtDatabaseLevel (direkter INSERT unter
   Umgehung von Log.Record, durch CHECK-Constraint abgewiesen). PASS.
3. Datenmodell von zweiter Person gegen Dokumentation geprueft — NICHT
   durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:22:50 +02:00
69 changed files with 1045 additions and 3556 deletions
+3 -83
View File
@@ -64,6 +64,9 @@ Keine Änderungen ermittelbar.
## 2026-08-27 17:28 17:29 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
## 2026-08-28 21:44 21:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
@@ -127,29 +130,6 @@ Keine Commits in dieser Session.
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- 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/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
@@ -179,63 +159,3 @@ Keine Commits in dieser Session.
- 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 +++++++++++++++++++
---
+69
View File
@@ -0,0 +1,69 @@
// auditlog-devserver stellt AUD-03s ExportHandler (internal/audit) fuer die
// Next.js-Audit-Log-Ansicht (AUD-04) bereit. Getrennt von cmd/core aus
// demselben Grund wie die anderen *-devserver (siehe LIC-04/TEN-05): echte
// Auth/RBAC ist noch nicht in die zentrale Server-Topologie verdrahtet.
//
// Authorize wird hier mit einem geteilten Admin-Token ueber
// crypto/subtle.ConstantTimeCompare umgesetzt — demselben Timing-safe-Muster
// wie internal/audit.timingsafe (AUD-02), NICHT ueber eine neue
// Rollen-/Rechteschicht, da diese Kachel ausdruecklich nur von AUD-03
// abhaengt und keine Rechteverwaltung duplizieren soll.
package main
import (
"context"
"log"
"net/http"
"os"
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
"gitea.perlbach24.de/scripte/nexarch/internal/auditadmin"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
)
func main() {
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
if dsn == "" {
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
}
adminToken := os.Getenv("NEXARCH_AUDIT_ADMIN_TOKEN")
if adminToken == "" {
log.Fatal("NEXARCH_AUDIT_ADMIN_TOKEN nicht gesetzt")
}
addr := os.Getenv("NEXARCH_AUDITLOG_LISTEN_ADDR")
if addr == "" {
addr = ":8083"
}
ctx := context.Background()
pool, err := db.Connect(ctx, dsn)
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
auditLog := audit.NewLog(pool)
tokenAuthorize := auditadmin.NewTokenAuthorizer(adminToken)
authorize := func(_ context.Context, caller string) bool { return tokenAuthorize(caller) }
handler := audit.NewExportHandler(auditLog, authorize)
mux := http.NewServeMux()
mux.HandleFunc("/audit/export", withCORS(handler.Export))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("auditlog-devserver listening on %s", addr)
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)
}
}
+1 -10
View File
@@ -8,7 +8,6 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/config"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func main() {
@@ -35,20 +34,12 @@ func main() {
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
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.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
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/superadmins", userHandler.CreateSuperadmin)
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
+2 -5
View File
@@ -2,16 +2,13 @@ module gitea.perlbach24.de/scripte/nexarch
go 1.22
require (
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 github.com/jackc/pgx/v5 v5.6.0
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // 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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+96
View File
@@ -0,0 +1,96 @@
// Package audit implementiert Core AUD-01: das zentrale, vom allgemeinen
// Anwendungs-Log getrennte Audit-Datenmodell fuer sicherheits- und
// compliancerelevante Ereignisse (wer, was, wann, an welchem Tenant).
// Unveraenderlichkeit (Append-only) ist AUD-02, Export/Filter-API ist AUD-03
// — dieses Paket liefert nur das Datenmodell und den EINEN zentralen
// Schreibpfad (Akzeptanzkriterium 3).
package audit
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// SystemTenant ist der reservierte Tenant-Bezug fuer mandantenuebergreifende
// Ereignisse (z.B. Superadmin-Aktionen) — es gibt bewusst KEINEN Weg, ein
// Ereignis ganz ohne Tenant-Bezug zu schreiben (Akzeptanzkriterium 2).
const SystemTenant = "system"
var ErrMissingTenant = errors.New("audit: tenant_slug darf nicht leer sein")
var ErrMissingActor = errors.New("audit: actor darf nicht leer sein")
var ErrMissingAction = errors.New("audit: action darf nicht leer sein")
// Event ist ein strukturiertes Audit-Ereignis (Akzeptanzkriterium 1: Akteur,
// Aktion, Zielobjekt, Zeitpunkt, Tenant).
type Event struct {
TenantSlug string
Actor string
Action string
Target string
Metadata map[string]any
OccurredAt time.Time
}
// Log ist der EINE zentrale Schreibpfad fuer Audit-Ereignisse — es gibt
// bewusst keine zweite Schreibmoeglichkeit, damit kein Handler versehentlich
// direkt in audit_events schreibt und dabei die Validierung umgeht
// (Akzeptanzkriterium 3).
type Log struct {
pool *pgxpool.Pool
}
func NewLog(pool *pgxpool.Pool) *Log {
return &Log{pool: pool}
}
// Record persistiert genau einen Audit-Eintrag. Fehlender Tenant-Bezug wird
// bereits hier abgewiesen (klarer Fehler statt Constraint-Verletzung im
// Normalfall) — die Datenbank-CHECK-Constraint aus der Migration ist die
// zweite, unumgehbare Verteidigungslinie (Akzeptanzkriterium 2 / Pruefung 2).
func (l *Log) Record(ctx context.Context, e Event) error {
if e.TenantSlug == "" {
return ErrMissingTenant
}
if e.Actor == "" {
return ErrMissingActor
}
if e.Action == "" {
return ErrMissingAction
}
if e.Metadata == nil {
e.Metadata = map[string]any{}
}
metadataJSON, err := json.Marshal(e.Metadata)
if err != nil {
return fmt.Errorf("metadaten serialisieren: %w", err)
}
if e.OccurredAt.IsZero() {
e.OccurredAt = time.Now()
}
_, err = l.pool.Exec(ctx, `
INSERT INTO audit_events (occurred_at, tenant_slug, actor, action, target, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
`, e.OccurredAt, e.TenantSlug, e.Actor, e.Action, e.Target, metadataJSON)
if err != nil {
return fmt.Errorf("audit-ereignis schreiben: %w", err)
}
return nil
}
// CountByTenant ist eine schlanke Lesehilfe fuer Tests/Diagnose — die
// eigentliche Filter-/Export-API ist AUD-03, hier bewusst nicht vorgezogen.
func (l *Log) CountByTenant(ctx context.Context, tenantSlug string) (int, error) {
var n int
if err := l.pool.QueryRow(ctx, `
SELECT count(*) FROM audit_events WHERE tenant_slug = $1
`, tenantSlug).Scan(&n); err != nil {
return 0, fmt.Errorf("audit-ereignisse zaehlen: %w", err)
}
return n, nil
}
+132
View File
@@ -0,0 +1,132 @@
package audit
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupAuditTest(t *testing.T) (*Log, *pgxpool.Pool, 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 audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
actor TEXT NOT NULL CHECK (actor <> ''),
action TEXT NOT NULL CHECK (action <> ''),
target TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_events WHERE tenant_slug LIKE 'test\_%' ESCAPE '\' OR tenant_slug = $1`, SystemTenant)
pool.Close()
}
return NewLog(pool), pool, cleanup
}
// Akzeptanzkriterium 1 + Pruefung 1: ein sicherheitsrelevanter Vorgang
// (hier: fehlgeschlagener Login) erzeugt zuverlaessig genau einen Eintrag.
func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) {
log, pool, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
err := log.Record(ctx, Event{
TenantSlug: "test_acme",
Actor: "alice@example.com",
Action: "iam.login_failed",
Target: "user:alice@example.com",
Metadata: map[string]any{"reason": "falsches passwort"},
})
if err != nil {
t.Fatalf("record: %v", err)
}
count, err := log.CountByTenant(ctx, "test_acme")
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 1 {
t.Fatalf("erwartet genau 1 audit-eintrag, habe %d", count)
}
var actor, action, target string
if err := pool.QueryRow(ctx, `
SELECT actor, action, target FROM audit_events WHERE tenant_slug = 'test_acme'
`).Scan(&actor, &action, &target); err != nil {
t.Fatalf("eintrag lesen: %v", err)
}
if actor != "alice@example.com" || action != "iam.login_failed" || target != "user:alice@example.com" {
t.Fatalf("eintrag unerwartet: actor=%q action=%q target=%q", actor, action, target)
}
}
// Akzeptanzkriterium 2 + Pruefung 2 (App-Ebene): fehlender Tenant-Bezug wird
// bereits vom zentralen Schreibpfad abgewiesen.
func TestRecord_RejectsMissingTenant(t *testing.T) {
log, _, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
err := log.Record(ctx, Event{TenantSlug: "", Actor: "alice", Action: "irgendwas"})
if !errors.Is(err, ErrMissingTenant) {
t.Fatalf("erwartet ErrMissingTenant, habe %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 2 (DB-Ebene): selbst ein direkter INSERT,
// der Log.Record umgeht, wird durch die CHECK-Constraint verhindert — der
// Schutz haengt nicht allein von der Go-Validierung ab.
func TestConstraint_RejectsMissingTenantAtDatabaseLevel(t *testing.T) {
_, pool, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
_, err := pool.Exec(ctx, `
INSERT INTO audit_events (tenant_slug, actor, action, target)
VALUES ('', 'alice', 'irgendwas', 'ziel')
`)
if err == nil {
t.Fatal("erwartet fehler durch CHECK-constraint bei leerem tenant_slug, habe nil")
}
}
func TestRecord_RejectsMissingActorAndAction(t *testing.T) {
log, _, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "", Action: "x"}); !errors.Is(err, ErrMissingActor) {
t.Fatalf("erwartet ErrMissingActor, habe %v", err)
}
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "alice", Action: ""}); !errors.Is(err, ErrMissingAction) {
t.Fatalf("erwartet ErrMissingAction, habe %v", err)
}
}
func TestRecord_SystemTenantForCrossTenantEvents(t *testing.T) {
log, _, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
if err := log.Record(ctx, Event{TenantSlug: SystemTenant, Actor: "superadmin", Action: "tenant.provisioned", Target: "tenant:acme"}); err != nil {
t.Fatalf("record mit SystemTenant: %v", err)
}
}
+122
View File
@@ -0,0 +1,122 @@
package audit
import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"time"
)
// Filter grenzt einen Export ein (Akzeptanzkriterium 1). Leere/Nil-Felder
// bedeuten "kein Filter auf diesem Feld".
type Filter struct {
TenantSlug string
Actor string
Action string
From *time.Time
To *time.Time
}
func buildFilterQuery(f Filter) (string, []any) {
query := `SELECT occurred_at, tenant_slug, actor, action, target, metadata FROM audit_events WHERE 1=1`
var args []any
if f.TenantSlug != "" {
args = append(args, f.TenantSlug)
query += fmt.Sprintf(" AND tenant_slug = $%d", len(args))
}
if f.Actor != "" {
args = append(args, f.Actor)
query += fmt.Sprintf(" AND actor = $%d", len(args))
}
if f.Action != "" {
args = append(args, f.Action)
query += fmt.Sprintf(" AND action = $%d", len(args))
}
if f.From != nil {
args = append(args, *f.From)
query += fmt.Sprintf(" AND occurred_at >= $%d", len(args))
}
if f.To != nil {
args = append(args, *f.To)
query += fmt.Sprintf(" AND occurred_at <= $%d", len(args))
}
query += " ORDER BY occurred_at"
return query, args
}
// StreamCSV schreibt gefilterte Audit-Eintraege direkt als CSV in w, Zeile
// fuer Zeile ueber rows.Next() — es wird zu keinem Zeitpunkt das gesamte
// Ergebnis im Speicher aufgebaut (Akzeptanzkriterium 3 / Pruefung 1).
func (l *Log) StreamCSV(ctx context.Context, filter Filter, w io.Writer) error {
query, args := buildFilterQuery(filter)
rows, err := l.pool.Query(ctx, query, args...)
if err != nil {
return fmt.Errorf("export abfragen: %w", err)
}
defer rows.Close()
cw := csv.NewWriter(w)
if err := cw.Write([]string{"occurred_at", "tenant_slug", "actor", "action", "target", "metadata"}); err != nil {
return fmt.Errorf("csv-header schreiben: %w", err)
}
for rows.Next() {
var occurredAt time.Time
var tenantSlug, actor, action, target string
var metadataJSON []byte
if err := rows.Scan(&occurredAt, &tenantSlug, &actor, &action, &target, &metadataJSON); err != nil {
return fmt.Errorf("zeile lesen: %w", err)
}
if err := cw.Write([]string{
occurredAt.Format(time.RFC3339), tenantSlug, actor, action, target, string(metadataJSON),
}); err != nil {
return fmt.Errorf("csv-zeile schreiben: %w", err)
}
}
cw.Flush()
if err := rows.Err(); err != nil {
return fmt.Errorf("export lesen: %w", err)
}
return cw.Error()
}
// exportRecord ist die JSON-Repraesentation einer exportierten Zeile.
type exportRecord struct {
OccurredAt time.Time `json:"occurred_at"`
TenantSlug string `json:"tenant_slug"`
Actor string `json:"actor"`
Action string `json:"action"`
Target string `json:"target"`
Metadata json.RawMessage `json:"metadata"`
}
// StreamJSON schreibt gefilterte Audit-Eintraege als JSON Lines (ein
// JSON-Objekt pro Zeile) — bewusst kein einzelnes grosses JSON-Array, da
// dessen korrektes Streaming (Kommas/Klammern ohne Zwischenpufferung)
// unnoetige Komplexitaet fuer denselben Zweck waere. Wie StreamCSV
// zeilenweise ueber rows.Next(), kein Aufbau im Speicher.
func (l *Log) StreamJSON(ctx context.Context, filter Filter, w io.Writer) error {
query, args := buildFilterQuery(filter)
rows, err := l.pool.Query(ctx, query, args...)
if err != nil {
return fmt.Errorf("export abfragen: %w", err)
}
defer rows.Close()
enc := json.NewEncoder(w)
for rows.Next() {
var rec exportRecord
var metadataJSON []byte
if err := rows.Scan(&rec.OccurredAt, &rec.TenantSlug, &rec.Actor, &rec.Action, &rec.Target, &metadataJSON); err != nil {
return fmt.Errorf("zeile lesen: %w", err)
}
rec.Metadata = metadataJSON
if err := enc.Encode(rec); err != nil {
return fmt.Errorf("json-zeile schreiben: %w", err)
}
}
return rows.Err()
}
+73
View File
@@ -0,0 +1,73 @@
package audit
import (
"context"
"net/http"
"time"
)
// Authorize entscheidet, ob caller den Export ausfuehren darf. Die
// eigentliche Rollen-/Rechtepruefung (RBAC-02 Policy-Enforcement) ist nicht
// Teil dieser Kachel — ExportHandler kennt nur diese schmale Schnittstelle,
// analog zum RetentionRegistrar-Muster aus AUD-05.
type Authorize func(ctx context.Context, caller string) bool
// ExportHandler stellt den Export als HTTP-Endpunkt bereit
// (Akzeptanzkriterium 2: fuer berechtigte Rollen verfuegbar).
type ExportHandler struct {
log *Log
authorize Authorize
}
func NewExportHandler(log *Log, authorize Authorize) *ExportHandler {
return &ExportHandler{log: log, authorize: authorize}
}
// Export liest Filter-Query-Parameter (tenant, actor, action, from, to,
// format) und schreibt DIREKT auf den ResponseWriter (io.Writer) — dieselbe
// Streaming-Funktion wie in export.go, kein zusaetzlicher Pufferungsschritt.
func (h *ExportHandler) Export(w http.ResponseWriter, r *http.Request) {
// "caller" identifiziert die anfragende Person fuer die Berechtigungs-
// pruefung — bewusst getrennt vom Filterfeld "actor" (das den
// AUDIT-Akteur meint, ueber den gefiltert wird).
caller := r.URL.Query().Get("caller")
if caller == "" || !h.authorize(r.Context(), caller) {
http.Error(w, "keine berechtigung fuer audit-log-export", http.StatusForbidden)
return
}
filter := Filter{
TenantSlug: r.URL.Query().Get("tenant"),
Actor: r.URL.Query().Get("actor"),
Action: r.URL.Query().Get("action"),
}
if from := r.URL.Query().Get("from"); from != "" {
t, err := time.Parse(time.RFC3339, from)
if err != nil {
http.Error(w, "ungueltiges from-datum, erwartet RFC3339", http.StatusBadRequest)
return
}
filter.From = &t
}
if to := r.URL.Query().Get("to"); to != "" {
t, err := time.Parse(time.RFC3339, to)
if err != nil {
http.Error(w, "ungueltiges to-datum, erwartet RFC3339", http.StatusBadRequest)
return
}
filter.To = &t
}
switch r.URL.Query().Get("format") {
case "json":
w.Header().Set("Content-Type", "application/x-ndjson")
if err := h.log.StreamJSON(r.Context(), filter, w); err != nil {
http.Error(w, "export fehlgeschlagen", http.StatusInternalServerError)
}
default:
w.Header().Set("Content-Type", "text/csv")
if err := h.log.StreamCSV(r.Context(), filter, w); err != nil {
http.Error(w, "export fehlgeschlagen", http.StatusInternalServerError)
}
}
}
+222
View File
@@ -0,0 +1,222 @@
package audit
import (
"bytes"
"context"
"encoding/csv"
"fmt"
"net/http"
"net/http/httptest"
"os"
"runtime"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupExportTest(t *testing.T) (*Log, 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 audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
actor TEXT NOT NULL CHECK (actor <> ''),
action TEXT NOT NULL CHECK (action <> ''),
target TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return NewLog(pool), cleanup
}
// Akzeptanzkriterium 1 + Pruefung 2: Filterkombinationen liefern korrekte
// Teilmengen.
func TestExport_FilterCombinations(t *testing.T) {
log, cleanup := setupExportTest(t)
defer cleanup()
ctx := context.Background()
run := fmt.Sprintf("_%d", time.Now().UnixNano())
tenantA, tenantB := "test_fa"+run, "test_fb"+run
alice, bob := "alice"+run, "bob"+run
events := []Event{
{TenantSlug: tenantA, Actor: alice, Action: "login", Target: "x"},
{TenantSlug: tenantA, Actor: bob, Action: "login", Target: "x"},
{TenantSlug: tenantA, Actor: alice, Action: "logout", Target: "x"},
{TenantSlug: tenantB, Actor: alice, Action: "login", Target: "x"},
}
for _, e := range events {
if err := log.Record(ctx, e); err != nil {
t.Fatalf("record: %v", err)
}
}
cases := []struct {
name string
filter Filter
wantLen int
}{
{"nach tenant", Filter{TenantSlug: tenantA}, 3},
{"nach tenant+actor", Filter{TenantSlug: tenantA, Actor: alice}, 2},
{"nach tenant+actor+action", Filter{TenantSlug: tenantA, Actor: alice, Action: "login"}, 1},
{"nach actor ueber beide tenants", Filter{Actor: alice, Action: "login"}, 2},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var buf bytes.Buffer
if err := log.StreamCSV(ctx, c.filter, &buf); err != nil {
t.Fatalf("stream: %v", err)
}
rows, err := csv.NewReader(&buf).ReadAll()
if err != nil {
t.Fatalf("csv parsen: %v", err)
}
got := len(rows) - 1 // Header abziehen
if got != c.wantLen {
t.Fatalf("erwartet %d zeilen, habe %d", c.wantLen, got)
}
})
}
}
// Akzeptanzkriterium 1 + Pruefung 2: Zeitraum-Filter.
func TestExport_TimeRangeFilter(t *testing.T) {
log, cleanup := setupExportTest(t)
defer cleanup()
ctx := context.Background()
tenant := fmt.Sprintf("test_tr_%d", time.Now().UnixNano())
past := time.Now().Add(-48 * time.Hour)
future := time.Now().Add(48 * time.Hour)
if err := log.Record(ctx, Event{TenantSlug: tenant, Actor: "a", Action: "x", Target: "t", OccurredAt: time.Now()}); err != nil {
t.Fatalf("record: %v", err)
}
var buf bytes.Buffer
if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant, From: &past, To: &future}, &buf); err != nil {
t.Fatalf("stream (innerhalb range): %v", err)
}
if got := countLines(buf.String()) - 1; got != 1 {
t.Fatalf("erwartet 1 eintrag innerhalb des zeitraums, habe %d", got)
}
farPast := time.Now().Add(-96 * time.Hour)
buf.Reset()
if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant, From: &farPast, To: &past}, &buf); err != nil {
t.Fatalf("stream (ausserhalb range): %v", err)
}
if got := countLines(buf.String()) - 1; got != 0 {
t.Fatalf("erwartet 0 eintraege ausserhalb des zeitraums, habe %d", got)
}
}
func countLines(s string) int {
s = strings.TrimRight(s, "\n")
if s == "" {
return 0
}
return len(strings.Split(s, "\n"))
}
// Akzeptanzkriterium 3 + Pruefung 1: Export mit hoher Eintragszahl ohne
// uebermaessigen Speicherverbrauch — Stichprobe per runtime.MemStats.
func TestExport_StreamsLargeResultWithoutExcessiveMemory(t *testing.T) {
log, cleanup := setupExportTest(t)
defer cleanup()
ctx := context.Background()
tenant := fmt.Sprintf("test_large_%d", time.Now().UnixNano())
const n = 20000
for i := 0; i < n; i++ {
if err := log.Record(ctx, Event{TenantSlug: tenant, Actor: "bulk", Action: "test.bulk", Target: fmt.Sprintf("obj-%d", i)}); err != nil {
t.Fatalf("record %d: %v", i, err)
}
}
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
lineCount := 0
cw := &countingWriter{onWrite: func(p []byte) { lineCount += strings.Count(string(p), "\n") }}
if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant}, cw); err != nil {
t.Fatalf("stream: %v", err)
}
runtime.GC()
var after runtime.MemStats
runtime.ReadMemStats(&after)
if lineCount != n+1 { // +1 Header
t.Fatalf("erwartet %d zeilen (inkl. header), habe %d", n+1, lineCount)
}
// Grobe Stichprobe: ein NICHT streamender Export haette hier locker
// mehrere MB an einmal gehaltenen Zeilen/Strings erzeugt. Grosszuegige
// Schwelle, da Go-Heap-Messungen naturgemaess rauschen.
const maxAcceptableGrowth = 3 * 1024 * 1024 // 3 MB
growth := int64(after.HeapAlloc) - int64(before.HeapAlloc)
t.Logf("heap-wachstum waehrend export von %d zeilen: %d bytes (schwelle: %d)", n, growth, maxAcceptableGrowth)
if growth > maxAcceptableGrowth {
t.Fatalf("heap ist um %d bytes gewachsen, erwartet unter %d (hinweis auf vollstaendige pufferung statt streaming)", growth, maxAcceptableGrowth)
}
}
type countingWriter struct {
onWrite func(p []byte)
}
func (w *countingWriter) Write(p []byte) (int, error) {
w.onWrite(p)
return len(p), nil
}
// Akzeptanzkriterium 2 + Pruefung 3: Zugriff ohne passende Berechtigung wird abgewiesen.
func TestExportHandler_RejectsWithoutAuthorization(t *testing.T) {
log, cleanup := setupExportTest(t)
defer cleanup()
handler := NewExportHandler(log, func(ctx context.Context, caller string) bool {
return caller == "berechtigte-person@example.com"
})
req := httptest.NewRequest(http.MethodGet, "/audit/export?caller=unberechtigt@example.com", nil)
rec := httptest.NewRecorder()
handler.Export(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("unberechtigt: status = %d, want 403", rec.Code)
}
reqNoCaller := httptest.NewRequest(http.MethodGet, "/audit/export", nil)
recNoCaller := httptest.NewRecorder()
handler.Export(recNoCaller, reqNoCaller)
if recNoCaller.Code != http.StatusForbidden {
t.Fatalf("ohne caller: status = %d, want 403", recNoCaller.Code)
}
reqOK := httptest.NewRequest(http.MethodGet, "/audit/export?caller=berechtigte-person@example.com", nil)
recOK := httptest.NewRecorder()
handler.Export(recOK, reqOK)
if recOK.Code != http.StatusOK {
t.Fatalf("berechtigt: status = %d, want 200", recOK.Code)
}
}
+23
View File
@@ -0,0 +1,23 @@
// Package auditadmin implementiert Core AUD-04: die Berechtigungspruefung
// fuer die Audit-Log-Ansicht. Enthaelt bewusst KEINE eigene Filter-/
// Export-Logik — die Ansicht ist "reiner Konsument der Export-API" (AUD-03,
// internal/audit.ExportHandler); dieses Paket liefert nur die
// audit.Authorize-Implementierung, die der Entwicklungs-/Testserver
// (cmd/auditlog-devserver) einhaengt.
package auditadmin
import "crypto/subtle"
// NewTokenAuthorizer liefert eine audit.Authorize-Funktion, die den
// aufrufenden "caller"-Wert timing-safe gegen ein geteiltes Admin-Token
// vergleicht — dasselbe Muster wie internal/audit.timingsafe (AUD-02),
// NICHT ueber eine neue Rollen-/Rechteschicht, da AUD-04 ausdruecklich nur
// von AUD-03 abhaengt.
func NewTokenAuthorizer(adminToken string) func(caller string) bool {
return func(caller string) bool {
if caller == "" || adminToken == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(caller), []byte(adminToken)) == 1
}
}
+33
View File
@@ -0,0 +1,33 @@
package auditadmin
import "testing"
// Grundlage fuer Akzeptanzkriterium 1/2: die Oberflaeche darf Daten und den
// Export nur bei korrektem Admin-Token abrufen.
func TestNewTokenAuthorizer_AcceptsCorrectToken(t *testing.T) {
authorize := NewTokenAuthorizer("geheimes-token")
if !authorize("geheimes-token") {
t.Fatal("erwartet true fuer korrektes token")
}
}
func TestNewTokenAuthorizer_RejectsWrongToken(t *testing.T) {
authorize := NewTokenAuthorizer("geheimes-token")
if authorize("falsches-token") {
t.Fatal("erwartet false fuer falsches token")
}
}
func TestNewTokenAuthorizer_RejectsEmptyCaller(t *testing.T) {
authorize := NewTokenAuthorizer("geheimes-token")
if authorize("") {
t.Fatal("erwartet false fuer leeren aufrufer")
}
}
func TestNewTokenAuthorizer_RejectsWhenNoTokenConfigured(t *testing.T) {
authorize := NewTokenAuthorizer("")
if authorize("irgendwas") {
t.Fatal("erwartet false, wenn kein admin-token konfiguriert ist (fail-safe-default)")
}
}
-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")
}
}
-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 audit_events;
+17
View File
@@ -0,0 +1,17 @@
-- Zentrales Audit-Log-Modell (AUD-01, siehe core-kanban/tickets/AUD-01.md).
-- Getrennt vom allgemeinen Anwendungs-Log (Akzeptanzkriterium 2): eigene
-- Tabelle, eigenes Paket (internal/audit), kein Log-Framework.
-- tenant_slug ist NOT NULL + darf nicht leer sein (Akzeptanzkriterium 2 /
-- Pruefung 2) — mandantenuebergreifende Ereignisse nutzen den reservierten
-- Wert 'system', niemals NULL oder leeren String.
CREATE TABLE audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
actor TEXT NOT NULL CHECK (actor <> ''),
action TEXT NOT NULL CHECK (action <> ''),
target TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX audit_events_tenant_slug_idx ON audit_events (tenant_slug, occurred_at);
-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
);
+13
View File
@@ -1,11 +1,24 @@
#!/usr/bin/env bash
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
ROLE="nexarch_test"
export PGPASSWORD="$PASS"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS audit_events CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
for db in $dbs; do
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
done
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
+12
View File
@@ -1,12 +1,24 @@
#!/usr/bin/env bash
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
cd "$(dirname "$0")/.."
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
echo "== go build =="
go build ./...
echo "== go vet =="
go vet ./...
echo "== go test (-p 1) =="
go test ./... -p 1 -count=1
-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";
export const metadata = {
title: "NEXARCH Anmeldung & Profil",
title: "NEXARCH Audit-Log",
};
export default function RootLayout({
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useState } from "react";
import { fetchRecords, exportURL, type AuditRecord, type AuditFilter } from "@/lib/api";
const EMPTY_FILTER: AuditFilter = { tenant: "", actor: "", action: "", from: "", to: "" };
export default function Page() {
const [caller, setCaller] = useState("");
const [filter, setFilter] = useState<AuditFilter>(EMPTY_FILTER);
const [records, setRecords] = useState<AuditRecord[] | null>(null);
const [selected, setSelected] = useState<AuditRecord | null>(null);
const [error, setError] = useState<string | null>(null);
async function onSearch() {
setError(null);
setSelected(null);
try {
const data = await fetchRecords(caller.trim(), filter);
setRecords(data);
} catch (e: any) {
setError(e.message ?? "Unbekannter Fehler");
setRecords(null);
}
}
function onExport(format: "csv" | "json") {
const url = exportURL(caller.trim(), filter, format);
// Echter Datei-Download ueber Browser-Navigation, KEIN erneutes Parsen
// im Frontend (Akzeptanzkriterium 2: Export aus AUD-03 direkt ausloesbar).
window.open(url, "_blank");
}
return (
<main style={{ maxWidth: 1000, margin: "0 auto", padding: "2rem 1rem" }}>
<h1>Audit-Log</h1>
<div style={{ display: "grid", gap: "0.5rem", gridTemplateColumns: "repeat(3, 1fr)", marginBottom: "1rem" }}>
<input
value={caller}
onChange={(e) => setCaller(e.target.value)}
placeholder="Admin-Token (Berechtigung)"
style={{ padding: "0.5rem", gridColumn: "span 3" }}
/>
<input
value={filter.tenant}
onChange={(e) => setFilter({ ...filter, tenant: e.target.value })}
placeholder="Tenant"
style={{ padding: "0.5rem" }}
/>
<input
value={filter.actor}
onChange={(e) => setFilter({ ...filter, actor: e.target.value })}
placeholder="Akteur"
style={{ padding: "0.5rem" }}
/>
<input
value={filter.action}
onChange={(e) => setFilter({ ...filter, action: e.target.value })}
placeholder="Aktion"
style={{ padding: "0.5rem" }}
/>
<label>
Von
<input
type="datetime-local"
value={filter.from}
onChange={(e) =>
setFilter({ ...filter, from: e.target.value ? new Date(e.target.value).toISOString() : "" })
}
style={{ width: "100%", padding: "0.4rem" }}
/>
</label>
<label>
Bis
<input
type="datetime-local"
value={filter.to}
onChange={(e) =>
setFilter({ ...filter, to: e.target.value ? new Date(e.target.value).toISOString() : "" })
}
style={{ width: "100%", padding: "0.4rem" }}
/>
</label>
</div>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
<button onClick={onSearch}>Suchen</button>
<button onClick={() => onExport("csv")}>Export als CSV</button>
<button onClick={() => onExport("json")}>Export als JSON</button>
</div>
{error && (
<p style={{ color: "#c62828" }} role="alert">
Fehler: {error}
</p>
)}
{records && (
<table style={{ width: "100%", borderCollapse: "collapse", background: "white" }}>
<thead>
<tr style={{ textAlign: "left", borderBottom: "2px solid #ddd" }}>
<th style={{ padding: "0.5rem" }}>Zeitpunkt</th>
<th style={{ padding: "0.5rem" }}>Tenant</th>
<th style={{ padding: "0.5rem" }}>Akteur</th>
<th style={{ padding: "0.5rem" }}>Aktion</th>
<th style={{ padding: "0.5rem" }}></th>
</tr>
</thead>
<tbody>
{records.map((r, i) => (
<tr key={i} style={{ borderBottom: "1px solid #eee" }}>
<td style={{ padding: "0.5rem" }}>{new Date(r.occurred_at).toLocaleString("de-DE")}</td>
<td style={{ padding: "0.5rem" }}>{r.tenant_slug}</td>
<td style={{ padding: "0.5rem" }}>{r.actor}</td>
<td style={{ padding: "0.5rem" }}>{r.action}</td>
<td style={{ padding: "0.5rem" }}>
<button onClick={() => setSelected(r)}>Details</button>
</td>
</tr>
))}
{records.length === 0 && (
<tr>
<td colSpan={5} style={{ padding: "0.5rem" }}>
Keine Einträge gefunden.
</td>
</tr>
)}
</tbody>
</table>
)}
{selected && (
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginTop: "1.5rem" }}>
<h2>Eintrag-Details</h2>
<dl>
<dt>Zeitpunkt</dt>
<dd>{new Date(selected.occurred_at).toLocaleString("de-DE")}</dd>
<dt>Tenant</dt>
<dd>{selected.tenant_slug}</dd>
<dt>Akteur</dt>
<dd>{selected.actor}</dd>
<dt>Aktion</dt>
<dd>{selected.action}</dd>
<dt>Ziel</dt>
<dd>{selected.target}</dd>
<dt>Metadaten</dt>
<dd>
<pre style={{ whiteSpace: "pre-wrap", background: "#f5f6f8", padding: "0.5rem" }}>
{JSON.stringify(selected.metadata, null, 2)}
</pre>
</dd>
</dl>
<button onClick={() => setSelected(null)}>Schließen</button>
</section>
)}
</main>
);
}
+63
View File
@@ -0,0 +1,63 @@
// Duenner Client der AUD-03-Export-API (internal/audit.ExportHandler) —
// keine eigene Aggregations-/Filterlogik im Frontend (Ticket-Vorgabe:
// "reiner Konsument der Export-API"). Die JSON-Lines-Antwort wird nur
// dekodiert, nicht neu berechnet oder gefiltert.
export type AuditRecord = {
occurred_at: string;
tenant_slug: string;
actor: string;
action: string;
target: string;
metadata: unknown;
};
export type AuditFilter = {
tenant: string;
actor: string;
action: string;
from: string; // RFC3339, leer = kein Filter
to: string;
};
function apiBase(): string {
const base = process.env.NEXT_PUBLIC_AUDITLOG_API_URL;
if (!base) {
throw new Error(
"NEXT_PUBLIC_AUDITLOG_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
);
}
return base;
}
function buildParams(caller: string, filter: AuditFilter, format: "json" | "csv"): URLSearchParams {
const params = new URLSearchParams({ caller, format });
if (filter.tenant) params.set("tenant", filter.tenant);
if (filter.actor) params.set("actor", filter.actor);
if (filter.action) params.set("action", filter.action);
if (filter.from) params.set("from", filter.from);
if (filter.to) params.set("to", filter.to);
return params;
}
// exportURL liefert die Adresse desselben Endpunkts, den auch die
// Export-Datei-Funktion (Akzeptanzkriterium 2) verwendet — Liste und Export
// sind bewusst DERSELBE API-Aufruf mit unterschiedlichem "format".
export function exportURL(caller: string, filter: AuditFilter, format: "json" | "csv"): string {
return `${apiBase()}/audit/export?${buildParams(caller, filter, format)}`;
}
// fetchRecords laedt die Liste als JSON-Lines und dekodiert Zeile fuer
// Zeile — dieselben Filterparameter wie ein direkter API-Aufruf
// (Akzeptanzkriterium 1 / Pruefung 1).
export async function fetchRecords(caller: string, filter: AuditFilter): Promise<AuditRecord[]> {
const res = await fetch(exportURL(caller, filter, "json"), { cache: "no-store" });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Anfrage fehlgeschlagen (${res.status})`);
}
const text = await res.text();
return text
.split("\n")
.filter((line) => line.trim() !== "")
.map((line) => JSON.parse(line) as AuditRecord);
}
@@ -1,5 +1,5 @@
{
"name": "nexarch-account",
"name": "nexarch-audit-log",
"private": true,
"scripts": {
"dev": "next dev",
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,