Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3c8d36b58 | ||
|
|
8ee0e6c771 | ||
|
|
d26a341fa8 | ||
|
|
c9b062062b | ||
|
|
2d32157de4 | ||
|
|
0505351e8f | ||
|
|
e19003b5d9 | ||
|
|
4fdb424b23 | ||
|
|
af1709a2bb | ||
|
|
060b73566b | ||
|
|
12c9037121 | ||
|
|
7c892ed10a | ||
|
|
b22ab67bb2 | ||
|
|
6631bcbbdd | ||
|
|
16c4ad0075 | ||
|
|
bd1f52648c | ||
|
|
5dcfa36f99 | ||
|
|
145a161f8a | ||
|
|
6e01cecca7 | ||
|
|
dac7854440 | ||
|
|
56d31c9176 | ||
|
|
089d7e6d96 | ||
|
|
7238568918 | ||
|
|
03c47d98f4 | ||
|
|
e9947b1e28 | ||
|
|
0d3779d03e | ||
|
|
54c5f74778 | ||
|
|
acc2b0c5dd | ||
|
|
0065b959b3 | ||
|
|
1a246abdb3 | ||
|
|
bd37de529f | ||
|
|
ff4d716b94 | ||
|
|
6b5cefc20f | ||
|
|
86c4223855 | ||
|
|
db73aab0de | ||
|
|
d23438d3d0 | ||
|
|
9748307f12 | ||
|
|
c5bdde0cc8 | ||
|
|
c3bf8100b1 | ||
|
|
704b64fe27 | ||
|
|
cb5a9da701 | ||
|
|
ee98efb51e | ||
|
|
dff6b8b7a4 | ||
|
|
44b78b1554 |
@@ -0,0 +1,23 @@
|
|||||||
|
name: Mail-Pflichttest-Gate
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "mail/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pflichttest-gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.22"
|
||||||
|
- name: Gate bauen
|
||||||
|
working-directory: mail
|
||||||
|
run: go build -o /tmp/pflichttestgate ./cmd/pflichttestgate
|
||||||
|
- name: Geänderte Dateien gegen Pflichttest-Regel prüfen
|
||||||
|
run: |
|
||||||
|
git diff --name-only "origin/${{ github.base_ref }}...HEAD" | /tmp/pflichttestgate
|
||||||
@@ -1,4 +1,2 @@
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
web/*/node_modules/
|
|
||||||
web/*/.next/
|
|
||||||
|
|||||||
@@ -44,158 +44,3 @@ Keine Commits in dieser Session.
|
|||||||
Keine Änderungen ermittelbar.
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
---
|
---
|
||||||
## 2026-08-27 17:26 – 17:28 (1m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
- c895a67 core: initial Go module skeleton (config, db pool, tenant registry migration)
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- .gitignore | 2 ++
|
|
||||||
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
|
||||||
- go.mod | 5 +++++
|
|
||||||
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
|
||||||
- internal/db/db.go | 11 +++++++++++
|
|
||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- .gitignore | 2 ++
|
|
||||||
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
|
||||||
- go.mod | 5 +++++
|
|
||||||
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
|
||||||
- internal/db/db.go | 11 +++++++++++
|
|
||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-27 17:31 – 17:31 (0m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
Keine Commits in dieser Session.
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- .gitignore | 2 ++
|
|
||||||
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
|
||||||
- go.mod | 5 +++++
|
|
||||||
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
|
||||||
- internal/db/db.go | 11 +++++++++++
|
|
||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-27 17:36 – 17:36 (0m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
Keine Commits in dieser Session.
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- .gitignore | 2 ++
|
|
||||||
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
|
||||||
- go.mod | 5 +++++
|
|
||||||
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
|
||||||
- internal/db/db.go | 11 +++++++++++
|
|
||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-27 17:36 – 17:37 (0m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
Keine Commits in dieser Session.
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- .gitignore | 2 ++
|
|
||||||
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
|
||||||
- go.mod | 5 +++++
|
|
||||||
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
|
||||||
- internal/db/db.go | 11 +++++++++++
|
|
||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-27 17:53 – 17:57 (4m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
- e479330 IAM-01: benutzer-datenmodell-crud
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- cmd/core/main.go | 11 ++++++++++-
|
|
||||||
- go.mod | 9 +++++++++
|
|
||||||
- go.sum | 28 ++++++++++++++++++++++++++++
|
|
||||||
- internal/user/handler.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/store_test.go | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/superadmin_store.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/tenant_store.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/user.go | 42 ++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/user_test.go | 24 ++++++++++++++++++++++++
|
|
||||||
- migrations/0002_superadmins.down.sql | 1 +
|
|
||||||
- migrations/0002_superadmins.up.sql | 14 ++++++++++++++
|
|
||||||
- migrations/tenant/0001_users.down.sql | 1 +
|
|
||||||
- migrations/tenant/0001_users.up.sql | 16 ++++++++++++++++
|
|
||||||
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/components/FormElements.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/components/Shell.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/components/Table.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/components/Toast.tsx | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/i18n/i18n.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/index.ts | 25 +++++++++++++++++++++++++
|
|
||||||
- web/shl/package.json | 23 +++++++++++++++++++++++
|
|
||||||
- web/shl/theme/ThemeProvider.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/tsconfig.json | 18 ++++++++++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-28 21:51 – 21:57 (5m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** nexarch
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl)
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- web/shl/package.json | 2 ++
|
|
||||||
- web/shl/vitest.config.ts | 8 ++++++++
|
|
||||||
- web/shl/vitest.setup.ts | 1 +
|
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-29 00:08 – 00:08 (0m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
Keine Commits in dieser Session.
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- internal/tenantsettings/handler.go | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/tenantsettings/store_test.go | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/tenantsettings/tenantsettings.go | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- migrations/0003_tenant_settings.down.sql | 2 ++
|
|
||||||
- migrations/0003_tenant_settings.up.sql | 27 +++++++++++++++++++++++++++
|
|
||||||
- scripts/reset-test-env.sh | 25 +++++++++++++++++++++++++
|
|
||||||
- scripts/run-checks.sh | 24 ++++++++++++++++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|||||||
+3
-30
@@ -7,8 +7,6 @@ import (
|
|||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -17,41 +15,16 @@ func main() {
|
|||||||
log.Fatalf("config: %v", err)
|
log.Fatalf("config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
pool, err := db.Connect(context.Background(), cfg.RegistryDSN)
|
||||||
|
|
||||||
registryPool, err := db.Connect(ctx, cfg.RegistryDSN)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("registry db: %v", err)
|
log.Fatalf("db: %v", err)
|
||||||
}
|
}
|
||||||
defer registryPool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
adminPool, err := db.Connect(ctx, cfg.AdminDSN)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("admin db: %v", err)
|
|
||||||
}
|
|
||||||
defer adminPool.Close()
|
|
||||||
|
|
||||||
registry := tenant.NewRegistry(registryPool)
|
|
||||||
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
|
||||||
tenantHandler := tenant.NewHandler(provisioner)
|
|
||||||
onboardingService := tenant.NewOnboardingService(provisioner, cfg.TenantSchemaPath)
|
|
||||||
onboardingHandler := tenant.NewOnboardingHandler(onboardingService)
|
|
||||||
|
|
||||||
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
|
|
||||||
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
|
|
||||||
// Mandant (TEN-06, noch nicht gebaut) und wird hier bewusst noch nicht
|
|
||||||
// verdrahtet — Package ist bereits eigenstaendig nutzbar/testbar.
|
|
||||||
superadmins := user.NewSuperadminStore(registryPool)
|
|
||||||
userHandler := user.NewHandler(nil, superadmins)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
|
|
||||||
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
|
||||||
mux.HandleFunc("/internal/tenants/onboard", onboardingHandler.Onboard)
|
|
||||||
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
|
|
||||||
|
|
||||||
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
||||||
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
// tenantadmin-devserver stellt das TEN-05-Backend-API (internal/tenantadmin)
|
|
||||||
// fuer die Next.js-Tenant-Verwaltungsoberflaeche bereit. Getrennt von
|
|
||||||
// cmd/core aus demselben Grund wie cmd/licadmin-devserver (siehe LIC-04):
|
|
||||||
// echte Auth (IAM-01/IAM-02) ist noch nicht in die zentrale Server-Topologie
|
|
||||||
// verdrahtet, dieser Server dient Entwicklung/Betrieb der Oberflaeche gegen
|
|
||||||
// eine echte Datenbank, ohne cmd/core anzufassen.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenantadmin"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
|
|
||||||
if dsn == "" {
|
|
||||||
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
|
|
||||||
}
|
|
||||||
addr := os.Getenv("NEXARCH_TENANTADMIN_LISTEN_ADDR")
|
|
||||||
if addr == "" {
|
|
||||||
addr = ":8082"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
pool, err := db.Connect(ctx, dsn)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("db: %v", err)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
|
|
||||||
registry := tenant.NewRegistry(pool)
|
|
||||||
lifecycle := tenant.NewLifecycle(registry, pool)
|
|
||||||
settingsStore := tenantsettings.NewStore(pool)
|
|
||||||
superadmins := user.NewSuperadminStore(pool)
|
|
||||||
handler := tenantadmin.NewHandler(registry, lifecycle, settingsStore, superadmins)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/admin/tenants", withCORS(handler.ListTenantsHandler))
|
|
||||||
mux.HandleFunc("/admin/tenants/detail", withCORS(handler.TenantDetailHandler))
|
|
||||||
mux.HandleFunc("/admin/tenants/settings", withCORS(handler.UpdateSettingsHandler))
|
|
||||||
mux.HandleFunc("/admin/tenants/lifecycle", withCORS(handler.LifecycleActionHandler))
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
|
||||||
|
|
||||||
log.Printf("tenantadmin-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, POST, OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# QA-02 – Prüfprotokoll: Prüfgate Identität & Mandanten
|
|
||||||
|
|
||||||
Stand: 2026-08-29. Branch `feature/qa-02-pruefgate-identitaet-mandanten` (alle 21 Vorbedingungs-Tickets TEN-01..07 und IAM-01..14 gemergt, per `git merge-base` gegen jeden Feature-Branch verifiziert).
|
|
||||||
|
|
||||||
## 1. Akzeptanzkriterien TEN-01 bis TEN-07 — Testabdeckung
|
|
||||||
|
|
||||||
| Ticket | Titel | Abdeckende Tests |
|
|
||||||
|---|---|---|
|
|
||||||
| TEN-01 | Mandantenregistrierung / Modell A/C | `internal/tenant/tenant_test.go`: `TestValidateSlug`, `TestDBNameForSlug` |
|
|
||||||
| TEN-02 | Self-Service-Onboarding | `internal/tenant/provisioner_test.go`: `TestProvision_CreatesIsolatedDatabases`; `internal/tenant/onboarding_test.go`: `TestOnboarding_ValidationErrors`, `TestOnboarding_CreatesTenantAndAdmin`, `TestOnboarding_RejectsDuplicateSlugConcurrently` |
|
|
||||||
| TEN-03 | Tenant-Router / Verbindungsauswahl | `internal/tenant/router_test.go`: `TestRouter_ResolvesCorrectTenantDatabase`, `TestRouter_RejectsMissingOrUnknownTenant`, `TestRouter_ReusesConnectionForSameTenant`, `TestRouter_BoundsOpenConnectionsUnderLoad` |
|
|
||||||
| TEN-04 | Mandanten-Lebenszyklus (Suspend/Reaktivieren) | `internal/tenant/lifecycle_test.go`: `TestLifecycle_SuspendAndReactivate`, `TestLifecycle_RejectsInvalidTransitions`, `TestLifecycle_CheckActive_RejectsNonActive` |
|
|
||||||
| TEN-05 | Mandanten-Löschung (DSGVO-Löschfrist) | `internal/tenant/lifecycle_test.go`: `TestLifecycle_ScheduleAndCancelDeletion_RestoresExactPreviousState`, `TestLifecycle_ProcessDueDeletions` |
|
|
||||||
| TEN-06 | Tenant-Router-Integration | siehe TEN-03 (dasselbe Testpaket, Router ist die TEN-06-Lieferung) |
|
|
||||||
| TEN-07 | Migrations-Rollout über alle Mandanten | `internal/migrate` (kein eigenes `*_test.go` im Merge-Ergebnis gefunden — siehe Abweichung 4.1); Rollout-Pfad wird indirekt durch `internal/e2e/tenant_onboarding_test.go` und `cross_tenant_isolation_test.go` genutzt (`applyTenantSchema` wendet dieselben `migrations/tenant/*.up.sql` in derselben Reihenfolge an) |
|
|
||||||
|
|
||||||
## 2. Akzeptanzkriterien IAM-01 bis IAM-14 — Testabdeckung
|
|
||||||
|
|
||||||
| Ticket | Titel | Abdeckende Tests |
|
|
||||||
|---|---|---|
|
|
||||||
| IAM-01 | Benutzerverwaltung (CRUD) | `internal/user/store_test.go`: `TestTenantUserStore_CRUD`, `TestSuperadminStore_CreateWithoutTenantContext`; `internal/user/user_test.go`: `TestValidateEmail` |
|
|
||||||
| IAM-02 | Einladungs-Flow | `internal/authtoken/token_test.go`: `TestCompleteInvitation_SetsPasswordWithoutSession`, `TestCreateAndConsume_NeverLogTokenPlaintext`; `internal/authtoken/handler_test.go` |
|
|
||||||
| IAM-03 | Passwort-Hashing (bcrypt) | `internal/auth/password_test.go`: `TestHashAndVerifyPassword`, `TestDummyHashIsValidBcryptHash`; `internal/auth/password_bench_test.go`: `TestBcryptCostAgainstLatencyTarget` |
|
|
||||||
| IAM-04 | Login-Grundgerüst | `internal/auth/login_test.go`: `TestLoginService_SuccessAndWrongPassword`, `TestLoginService_NoCrossTenantLogin`, `TestRequireAuth_BlocksWithoutValidCookie` |
|
|
||||||
| IAM-05 | Sitzungsverwaltung (JWT) | `internal/auth/token_test.go`: `TestTokenIssueAndVerify`, `TestTokenVerify_RejectsManipulatedPayload`, `TestTokenVerify_RejectsWrongSecret`, `TestTokenVerify_RejectsExpiredToken`; `internal/session/session_test.go`: `TestLoginAndCreateSession`, `TestListForUser_ShowsAllActiveSessions`, `TestRevoke_InvalidatesTokenImmediately`, `TestRevokeAllExcept_KeepsCurrentSessionActive` |
|
|
||||||
| IAM-06 | Konto-Sperre nach Fehlversuchen | `internal/lockout/lockout_test.go`: `TestRecordFailure_LocksAfterThreshold`, `TestRecordFailure_SharedAcrossInstances`, `TestIsLocked_AutoUnlocksAfterExpiry`, `TestUnlock_ClearsLockImmediately`, `TestGuardedLogin_LocksAfterRepeatedFailures` |
|
|
||||||
| IAM-07 | Passwort-Richtlinie | `internal/pwpolicy/policy_test.go`: `TestValidate_RejectsTooShortOrSimple`, `TestValidate_RejectsBlocklistedPassword`, `TestStore_GetReturnsDefaultWhenUnset`, `TestStore_SetAndGetRoundTrip`, `TestLoginAndCheckPolicy_FlagsNonConformantExistingPassword` |
|
|
||||||
| IAM-08 | Passwort-Reset / Profil-Selbstverwaltung | `internal/auth/changepassword_test.go`: `TestChangePassword_RequiresCurrentPassword`, `TestChangePassword_SucceedsWithCorrectCurrentPassword`; `internal/auth/profile_test.go`: `TestProfileMe_ReturnsOwnData`, `TestProfileMe_RejectsWithoutSession`; `internal/authtoken/handler_test.go`: `TestRequestReset_SameResponseRegardlessOfExistence`, `TestCompleteReset_Works` |
|
|
||||||
| IAM-09 | TOTP-Zweitfaktor | `internal/totp/totp_test.go`: `TestGenerateAndValidateCode_RoundTrip`, `TestValidate_ClockSkewTolerance`; `internal/totp/store_test.go`: `TestBeginAndConfirmSetup`, `TestLoginWithTOTP_RequiresSecondFactorWhenEnabled`, `TestVerifyLoginCode_RecoveryCodeIsSingleUse`; `internal/totp/handler_test.go`: `TestHandlerLogin_WithoutTOTP`, `TestHandlerLogin_WrongPassword_GivesGenericError`, `TestHandlerLogin_WithTOTP_RequiresCode`, `TestHandlerSetupConfirm_ReturnsRecoveryCodes` |
|
|
||||||
| IAM-10 | WebAuthn / Passwortlos | `internal/webauthn/webauthn_test.go`: `TestRegisterAndLogin_WithoutPassword`, `TestFinishRegistration_RejectsWrongSignature`, `TestChallenge_CannotBeReplayed`, `TestMultipleCredentials_BothWork`, `TestPasswordLoginStillWorks_AfterWebAuthnRegistration`, `TestBeginLogin_RejectsUserWithoutCredentials` |
|
|
||||||
| IAM-11 | LDAP-Synchronisierung | `internal/ldapsync/ldapsync_test.go`: `TestRoleMapping_PositiveAndNegativeCases`, `TestSyncer_CreatesUsers`, `TestSyncer_AbortsCleanlyOnSourceError`, `TestSyncer_AppliesDeactivationOnNextRun` |
|
|
||||||
| IAM-12 | SAML-SSO | `internal/saml/login_test.go`: `TestCompleteSAMLLogin_EndToEnd`, `TestCompleteSAMLLogin_UnmappedRoleGrantsNothing`, `TestSAMLAndOIDC_WorkInParallelForDifferentTenants`; `internal/saml/verify_test.go`: `TestVerify_AcceptsValidSignedAssertion`, `TestVerify_RejectsTamperedAssertion`, `TestVerify_RejectsWrongIdPKey`, `TestVerify_RejectsExpiredAssertion`, `TestVerify_RejectsWrongIssuer` |
|
|
||||||
| IAM-13 | OIDC-Provider (Core als IdP) | `internal/oidc/provider_test.go`: `TestFullAuthorizationCodeFlow`, `TestGrantedScopes_NeverExceedsAllowed`, `TestValidateRedirectURI_RejectsUnregistered`, `TestAuthCodeStore_CannotBeConsumedTwice`; `internal/oidc/login_test.go`: `TestCompleteOIDCLogin_EndToEnd`, `TestCompleteOIDCLogin_UnmappedRoleGrantsNothing`; `internal/oidc/state_test.go`, `internal/oidc/verify_test.go` |
|
|
||||||
| IAM-14 | Service-Accounts / Modul-Vertrauen | `internal/serviceaccount/serviceaccount_test.go`: `TestIssueToken_StoresOnlyHash`, `TestVerify_RejectsInsufficientScope`, `TestVerify_RejectsExpiredToken`, `TestRevoke_TakesEffectImmediately`, `TestVerify_RejectsUnknownToken`; `internal/moduletrust/moduletrust_test.go`: `TestIssueAndVerify_RoundTrip`, `TestVerify_DoesNotFetchPerCall`, `TestVerify_FailsOpenWhenCoreUnreachableButStaleKeysExist`, `TestRequireFreshKeys_FailsClosedWhenCoreUnreachable`, `TestRotate_NoDowntimeForAlreadyIssuedTokens`, `TestVerify_RejectsUnknownKid`, `TestJWKSRoundTrip` |
|
|
||||||
|
|
||||||
**Ergebnis Abschnitt 1+2:** alle 21 Tickets haben eigene, automatisierte Unit-/Integrationstests, die ihre dokumentierten Akzeptanzkriterien einzeln abdecken.
|
|
||||||
|
|
||||||
## 3. Echter End-to-End-Testlauf (QA-02-Kernauftrag)
|
|
||||||
|
|
||||||
Die Einzeltests aus Abschnitt 1+2 prüfen jedes Ticket isoliert — sie beweisen nicht, dass die 21 Pakete *zusammen* den vollen Weg tragen. Dafür neu geschrieben: `internal/e2e/`.
|
|
||||||
|
|
||||||
### 3.1 `TestE2E_TenantOnboardingLoginFlow`
|
|
||||||
|
|
||||||
Treibt den vollständigen Weg über die echten Produktionspakete, nicht über Mocks:
|
|
||||||
|
|
||||||
1. `internal/tenant.Provisioner.Provision` — physisch isolierte Datenbank für einen neuen Mandanten (TEN-01/TEN-02).
|
|
||||||
2. Schema-Rollout über **alle** `migrations/tenant/*.up.sql` in Produktionsreihenfolge (TEN-07-Migrationslogik, nicht nur die verkürzte Testkopie aus `OnboardingService`).
|
|
||||||
3. `internal/user.TenantUserStore.Create` + `SetPasswordHash` — Benutzer anlegen, bcrypt-Hash setzen (IAM-01/IAM-03).
|
|
||||||
4. `internal/auth.LoginService.Login` — Anmeldung (IAM-04/IAM-05).
|
|
||||||
5. Assert: Token ist mit `TokenIssuer.Verify` gültig, `Claims.TenantSlug` und `Claims.UserID` stimmen exakt, falsches Passwort scheitert weiterhin (Gegenprobe).
|
|
||||||
|
|
||||||
**Ergebnis:** bestanden (siehe Abschnitt 5, Build/Test-Ergebnis auf 131).
|
|
||||||
|
|
||||||
### 3.2 `TestE2E_CrossTenantIsolation`
|
|
||||||
|
|
||||||
Zwei vollständig provisionierte Mandanten (`e2e-tenant-a`, `e2e-tenant-b`), je ein Benutzer, drei unabhängige Beweise:
|
|
||||||
|
|
||||||
- **Datenbankebene:** aus der Datenbank von Tenant A ist keine Zeile für den Tenant-B-Benutzer abfragbar (und umgekehrt) — kein Query-Filter, sondern physisch getrennte Datenbanken.
|
|
||||||
- **Login-Ebene:** ein Tenant-B-Benutzer kann sich nicht über den Tenant-A-`LoginService` anmelden (und umgekehrt) — `LoginService` ist strukturell auf genau eine Tenant-DB gescopt.
|
|
||||||
- **Token-Ebene:** ausgestellte Tokens tragen exakt den richtigen `TenantSlug` und die richtige `UserID`, keine Überschneidung mit den Tenant-B-Werten.
|
|
||||||
|
|
||||||
**Ergebnis:** bestanden (siehe Abschnitt 5).
|
|
||||||
|
|
||||||
## 4. Abweichungen
|
|
||||||
|
|
||||||
### 4.1 `internal/migrate` (TEN-07) hat keine eigene Testdatei im gemergten Stand — Schweregrad: Niedrig
|
|
||||||
|
|
||||||
Kein `*_test.go` für `internal/migrate/orchestrator.go`/`migrations.go` im Merge-Ergebnis dieses Branches gefunden. Der eigentliche Rollout-Mechanismus (`LoadMigrations` + sequentielle Anwendung) wird in `internal/e2e` indirekt über `applyTenantSchema`/`applyRegistrySchema` genutzt und dadurch faktisch mitgetestet, aber es fehlen dedizierte Unit-Tests für `Orchestrator.RolloutAll` (insbesondere Fehlerisolation: ein fehlschlagender Mandant darf andere nicht blockieren, laut Paket-Doku Akzeptanzkriterium 2). Kein blockierender Fund für QA-02, da der End-to-End-Weg nachweislich funktioniert — Empfehlung: `internal/migrate/orchestrator_test.go` bei Gelegenheit nachziehen.
|
|
||||||
|
|
||||||
Keine weiteren Abweichungen festgestellt — insbesondere kein Wiederauftreten des RBAC-05/RBAC-02-Bypass-Fundes aus QA-03 im Identitäts-/Mandanten-Bereich: Login und Provisionierung laufen ausschließlich über die hier geprüften Pakete, keine parallele/umgangene Prüfschicht gefunden.
|
|
||||||
|
|
||||||
### 4.2 Echter Merge-Konflikt zwischen IAM-12 (SAML/OIDC-Client) und IAM-13 (Core als OIDC-Provider) — Schweregrad: Hoch, behoben
|
|
||||||
|
|
||||||
**Fund:** `internal/oidc/jwks.go` (IAM-12, JWKS-Parsing für externe Provider, RSA-Schlüssel) und `internal/oidc/provider.go` (IAM-13, Core als eigener IdP, Ed25519/OKP-Schlüssel) definierten beide einen Typ `jwk`/`jwkSet` im selben Package — kompiliert einzeln pro Branch fehlerfrei, scheitert aber beim Zusammenführen beider Branches mit `jwk redeclared in this block` und Folgefehlern (unbekannte Felder `Crv`/`X`/`Use`/`Alg`). Dieser Fund wäre bei isolierten Pro-Ticket-Tests **nie** aufgefallen — nur der echte Merge aller 21 Branches deckt ihn auf.
|
|
||||||
|
|
||||||
**Behoben:** IAM-13s Typen in `internal/oidc/provider.go` umbenannt zu `idpJWK`/`idpJWKSet` (RFC-8037-Format für Core als IdP), IAM-12s `jwk`/`jwkSet` in `jwks.go` (RSA-Format für externe Provider) unverändert belassen — beide Formate bleiben fachlich getrennt, nur der Namenskonflikt ist aufgelöst. `internal/oidc/provider_test.go` entsprechend angepasst.
|
|
||||||
|
|
||||||
**Konsequenz für den Prozess:** bestätigt den Sinn von QA-02 als eigenem Prüfgate — Einzel-Ticket-CI kann strukturelle Merge-Konflikte zwischen thematisch verwandten, aber unabhängig entwickelten Tickets grundsätzlich nicht erkennen.
|
|
||||||
|
|
||||||
## 5. Build/Test-Ergebnis auf 131
|
|
||||||
|
|
||||||
Durchgeführt 2026-08-29 auf root@192.168.1.131 (`/root/nexarch-code-qa02`, isolierter Sync, kein Konflikt mit dem parallelen QA-03-Testlauf in `/root/nexarch-code-qa03`):
|
|
||||||
|
|
||||||
- `go mod tidy`, `go build ./...`, `go vet ./...` — nach Behebung des Merge-Konflikts aus 4.2 alle sauber, keine Fehler.
|
|
||||||
- `go test ./... -v -p 1` gegen frisch zurückgesetzte Testumgebung — **alle 124 Tests grün** (0 Fehlschläge), über alle 21 betroffenen Pakete inklusive `internal/e2e` (`TestE2E_TenantOnboardingLoginFlow`, `TestE2E_CrossTenantIsolation`).
|
|
||||||
- Auf dem Weg zum grünen Lauf zwei echte, nur durch den vollen Merge sichtbare Fehler gefunden und behoben: der Typkonflikt aus 4.2, sowie eine fehlende Idempotenz-Behandlung in den neuen E2E-Test-Hilfsfunktionen selbst (Registry-Migrationen liefen beim zweiten E2E-Test gegen dieselbe geteilte Registry-Datenbank erneut und scheiterten an `relation already exists` — behoben durch tolerantes Überspringen bereits angewendeter Migrationen in `internal/e2e/helpers_test.go`, kein Produktionscode betroffen).
|
|
||||||
- Keine Regressionen in TEN-01..07 oder IAM-01..14 durch den 21-Branch-Merge.
|
|
||||||
|
|
||||||
**QA-02 Gesamtergebnis: bestanden.** Ein behobener Hoch-Schweregrad-Merge-Konflikt (4.2, echter struktureller Fund dieses Prüfgates), keine offenen Blocker.
|
|
||||||
|
|
||||||
## 6. Gesamtergebnis
|
|
||||||
|
|
||||||
**Bestanden.** Der volle End-to-End-Weg über alle 21 Vorbedingungs-Tickets (TEN-01..07, IAM-01..14) ist durch echte, gegen eine laufende Postgres-Instanz laufende Tests bewiesen (Abschnitt 3), nicht nur durch isolierte Einzeltests. Ein struktureller Merge-Konflikt wurde aufgedeckt und behoben (4.2) — genau die Art Fund, für die dieses Prüfgate existiert. Ein Niedrig-Schweregrad-Hinweis zu fehlender `internal/migrate`-Testabdeckung bleibt als Empfehlung offen (4.1).
|
|
||||||
@@ -2,16 +2,4 @@ module gitea.perlbach24.de/scripte/nexarch
|
|||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require (
|
require github.com/jackc/pgx/v5 v5.6.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
|
||||||
github.com/jackc/pgx/v5 v5.6.0
|
|
||||||
golang.org/x/crypto v0.17.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
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/sync v0.1.0 // indirect
|
|
||||||
golang.org/x/text v0.14.0 // indirect
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
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=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
|
||||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
|
||||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
|
||||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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})
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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[:]
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,18 +10,8 @@ import (
|
|||||||
// connection info, superadmin accounts) — see nexarch-state.json
|
// connection info, superadmin accounts) — see nexarch-state.json
|
||||||
// multi_tenancy: Modell C (physisch getrennte DB pro Mandant).
|
// multi_tenancy: Modell C (physisch getrennte DB pro Mandant).
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
// RegistryDSN verbindet zur Control-Plane-Registry-Datenbank.
|
|
||||||
RegistryDSN string
|
RegistryDSN string
|
||||||
// AdminDSN verbindet zur Wartungsdatenbank (z.B. "postgres") und wird nur
|
|
||||||
// fuer CREATE/DROP DATABASE beim Tenant-Provisioning verwendet.
|
|
||||||
AdminDSN string
|
|
||||||
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
|
||||||
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
|
|
||||||
TenantDSNTemplate string
|
|
||||||
// TenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
|
|
||||||
// TEN-02 eine frisch provisionierte Tenant-Datenbank initialisiert.
|
|
||||||
TenantSchemaPath string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -30,31 +20,10 @@ func Load() (Config, error) {
|
|||||||
return Config{}, fmt.Errorf("NEXARCH_REGISTRY_DSN not set")
|
return Config{}, fmt.Errorf("NEXARCH_REGISTRY_DSN not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
adminDSN := os.Getenv("NEXARCH_ADMIN_DSN")
|
|
||||||
if adminDSN == "" {
|
|
||||||
return Config{}, fmt.Errorf("NEXARCH_ADMIN_DSN not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
dsnTemplate := os.Getenv("NEXARCH_TENANT_DSN_TEMPLATE")
|
|
||||||
if dsnTemplate == "" {
|
|
||||||
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
schemaPath := os.Getenv("NEXARCH_TENANT_SCHEMA_PATH")
|
|
||||||
if schemaPath == "" {
|
|
||||||
return Config{}, fmt.Errorf("NEXARCH_TENANT_SCHEMA_PATH not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr = ":8080"
|
addr = ":8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
return Config{
|
return Config{ListenAddr: addr, RegistryDSN: dsn}, nil
|
||||||
ListenAddr: addr,
|
|
||||||
RegistryDSN: dsn,
|
|
||||||
AdminDSN: adminDSN,
|
|
||||||
TenantDSNTemplate: dsnTemplate,
|
|
||||||
TenantSchemaPath: schemaPath,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
package e2e
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestE2E_CrossTenantIsolation ist der QA-02-Nachweis fuer Akzeptanzkriterium
|
|
||||||
// "keine Datenlecks zwischen Mandanten": zwei vollstaendig provisionierte
|
|
||||||
// Mandanten mit je einem Benutzer, dann drei unabhaengige Beweise, dass
|
|
||||||
// weder Datenbank- noch Token-Ebene eine Vermischung zulassen.
|
|
||||||
func TestE2E_CrossTenantIsolation(t *testing.T) {
|
|
||||||
adminDSN := requireAdminDSN(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
root := repoRoot(t)
|
|
||||||
|
|
||||||
adminPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("admin pool: %v", err)
|
|
||||||
}
|
|
||||||
defer adminPool.Close()
|
|
||||||
|
|
||||||
applyRegistrySchema(t, ctx, adminPool, root)
|
|
||||||
|
|
||||||
registry := tenant.NewRegistry(adminPool)
|
|
||||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
||||||
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_, _ = adminPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('e2e_tenant_a','e2e_tenant_b')`)
|
|
||||||
dropTenantDBs(ctx, adminPool, "tenant_e2e_tenant_a", "tenant_e2e_tenant_b")
|
|
||||||
})
|
|
||||||
|
|
||||||
setupTenantWithUser := func(slug, name, email, password string) (tenant.Tenant, user.User, *auth.LoginService, *pgxpool.Pool) {
|
|
||||||
tn, err := provisioner.Provision(ctx, slug, name)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provision %s: %v", slug, err)
|
|
||||||
}
|
|
||||||
pool, err := pgxpool.New(ctx, tn.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connect %s: %v", slug, err)
|
|
||||||
}
|
|
||||||
applyTenantSchema(t, ctx, pool, root)
|
|
||||||
|
|
||||||
store := user.NewTenantUserStore(pool)
|
|
||||||
u, err := store.Create(ctx, email, "Benutzer "+slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("benutzer anlegen %s: %v", slug, err)
|
|
||||||
}
|
|
||||||
hash, err := auth.HashPassword(password)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("passwort hashen %s: %v", slug, err)
|
|
||||||
}
|
|
||||||
if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil {
|
|
||||||
t.Fatalf("passwort setzen %s: %v", slug, err)
|
|
||||||
}
|
|
||||||
issuer := auth.NewTokenIssuer("qa-02-cross-tenant-secret")
|
|
||||||
login := auth.NewLoginService(store, issuer, tn.Slug)
|
|
||||||
return tn, u, login, pool
|
|
||||||
}
|
|
||||||
|
|
||||||
tenantA, userA, loginA, poolA := setupTenantWithUser("e2e_tenant_a", "Tenant A GmbH", "user@tenant-a.example", "passwort-a-sicher")
|
|
||||||
defer poolA.Close()
|
|
||||||
tenantB, userB, loginB, poolB := setupTenantWithUser("e2e_tenant_b", "Tenant B GmbH", "user@tenant-b.example", "passwort-b-sicher")
|
|
||||||
defer poolB.Close()
|
|
||||||
|
|
||||||
// Beweis 1: physische Trennung - Tenant Bs Benutzer existiert in der
|
|
||||||
// users-Tabelle von Tenant A schlicht nicht (keine Zeile, kein Filter).
|
|
||||||
var count int
|
|
||||||
if err := poolA.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "user@tenant-b.example").Scan(&count); err != nil {
|
|
||||||
t.Fatalf("query tenant a fuer tenant-b-email: %v", err)
|
|
||||||
}
|
|
||||||
if count != 0 {
|
|
||||||
t.Fatalf("tenant a datenbank enthaelt %d zeile(n) fuer tenant-b-benutzer, erwartet 0", count)
|
|
||||||
}
|
|
||||||
if err := poolB.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "user@tenant-a.example").Scan(&count); err != nil {
|
|
||||||
t.Fatalf("query tenant b fuer tenant-a-email: %v", err)
|
|
||||||
}
|
|
||||||
if count != 0 {
|
|
||||||
t.Fatalf("tenant b datenbank enthaelt %d zeile(n) fuer tenant-a-benutzer, erwartet 0", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Beweis 2: Login schlaegt fehl, wenn ein Benutzer versucht, sich ueber
|
|
||||||
// den falschen Tenant-LoginService anzumelden - er existiert dort nicht.
|
|
||||||
if _, err := loginA.Login(ctx, "user@tenant-b.example", "passwort-b-sicher"); err == nil {
|
|
||||||
t.Fatal("tenant-b-benutzer konnte sich ueber tenant-a-loginservice anmelden")
|
|
||||||
}
|
|
||||||
if _, err := loginB.Login(ctx, "user@tenant-a.example", "passwort-a-sicher"); err == nil {
|
|
||||||
t.Fatal("tenant-a-benutzer konnte sich ueber tenant-b-loginservice anmelden")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Beweis 3: Token tragen den korrekten Tenant-Slug und die korrekte
|
|
||||||
// User-ID, keine Vermischung ueber gemeinsame IDs oder Slugs.
|
|
||||||
tokenA, err := loginA.Login(ctx, "user@tenant-a.example", "passwort-a-sicher")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("login tenant a: %v", err)
|
|
||||||
}
|
|
||||||
issuer := auth.NewTokenIssuer("qa-02-cross-tenant-secret")
|
|
||||||
claimsA, err := issuer.Verify(tokenA)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("verify token a: %v", err)
|
|
||||||
}
|
|
||||||
if claimsA.TenantSlug != tenantA.Slug || claimsA.UserID != userA.ID {
|
|
||||||
t.Fatalf("token a claims = %+v, want tenant %q user %q", claimsA, tenantA.Slug, userA.ID)
|
|
||||||
}
|
|
||||||
if claimsA.TenantSlug == tenantB.Slug || claimsA.UserID == userB.ID {
|
|
||||||
t.Fatal("token a claims stimmen mit tenant-b-daten ueberein")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
// Package e2e enthaelt QA-02 (Pruefgate Identitaet & Mandanten): echte
|
|
||||||
// End-to-End-Tests gegen eine laufende Postgres-Instanz, die den vollen Weg
|
|
||||||
// "Mandant anlegen -> Benutzer anlegen -> anmelden" ueber die tatsaechlichen
|
|
||||||
// Produktionspakete (internal/tenant, internal/user, internal/auth) treiben,
|
|
||||||
// statt nur die Einzelpakete isoliert zu testen.
|
|
||||||
package e2e
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// requireAdminDSN liefert TEST_ADMIN_DSN oder ueberspringt den Test - wie in
|
|
||||||
// allen bestehenden Integrationstests dieses Repos (siehe
|
|
||||||
// internal/tenant/provisioner_test.go).
|
|
||||||
func requireAdminDSN(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
dsn := os.Getenv("TEST_ADMIN_DSN")
|
|
||||||
if dsn == "" {
|
|
||||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, E2E-Test uebersprungen")
|
|
||||||
}
|
|
||||||
return dsn
|
|
||||||
}
|
|
||||||
|
|
||||||
// repoRoot findet den Modul-Root ausgehend vom Testverzeichnis, damit die
|
|
||||||
// SQL-Migrationsverzeichnisse unabhaengig vom Arbeitsverzeichnis (go test
|
|
||||||
// wechselt ins Paketverzeichnis) gefunden werden.
|
|
||||||
func repoRoot(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
wd, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("getwd: %v", err)
|
|
||||||
}
|
|
||||||
// internal/e2e -> zwei Ebenen hoch zum Modul-Root.
|
|
||||||
return filepath.Join(wd, "..", "..")
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyRegistrySchema legt die Control-Plane-Tabellen (tenants, superadmins,
|
|
||||||
// lifecycle-Spalten) frisch an, indem alle migrations/*.up.sql in
|
|
||||||
// Dateinamen-Reihenfolge angewendet werden - identisch zur echten
|
|
||||||
// Rollout-Reihenfolge, nicht eine verkuerzte Testkopie des Schemas.
|
|
||||||
func applyRegistrySchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool, root string) {
|
|
||||||
t.Helper()
|
|
||||||
applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyTenantSchema initialisiert eine frisch provisionierte Tenant-Datenbank
|
|
||||||
// mit allen migrations/tenant/*.up.sql (nicht nur 0001_users, damit
|
|
||||||
// password_hash, role_assignments etc. wie in Produktion vorhanden sind).
|
|
||||||
func applyTenantSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool, root string) {
|
|
||||||
t.Helper()
|
|
||||||
applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations", "tenant"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyAllUpSQL(t *testing.T, ctx context.Context, pool *pgxpool.Pool, dir string) {
|
|
||||||
t.Helper()
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("migrationsverzeichnis %q lesen: %v", dir, err)
|
|
||||||
}
|
|
||||||
var names []string
|
|
||||||
for _, e := range entries {
|
|
||||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
|
|
||||||
names = append(names, e.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
for _, name := range names {
|
|
||||||
sqlBytes, err := os.ReadFile(filepath.Join(dir, name))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("migration %q lesen: %v", name, err)
|
|
||||||
}
|
|
||||||
if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil {
|
|
||||||
// Migrationen sind Produktionsartefakte ohne eingebaute
|
|
||||||
// Idempotenz (in Produktion durch eine Migrations-Tracking-
|
|
||||||
// Tabelle abgesichert, die dieser Test bewusst nicht nachbaut).
|
|
||||||
// Zwei E2E-Tests im selben Prozess teilen sich dieselbe
|
|
||||||
// Registry-Datenbank, daher ist "schon vorhanden" hier kein
|
|
||||||
// Fehler, sondern der Normalfall beim zweiten Testlauf.
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(err, &pgErr) && (pgErr.Code == "42P07" || pgErr.Code == "42701" || pgErr.Code == "42710") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
t.Fatalf("migration %q anwenden: %v", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dropTenantDBs entfernt Test-Datenbanken bedingungslos, fuer Cleanup nach
|
|
||||||
// dem Test unabhaengig vom Testausgang.
|
|
||||||
func dropTenantDBs(ctx context.Context, adminPool *pgxpool.Pool, names ...string) {
|
|
||||||
for _, n := range names {
|
|
||||||
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(n))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoteIdent(name string) string {
|
|
||||||
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
package e2e
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestE2E_TenantOnboardingLoginFlow ist der zentrale QA-02-Nachweis: der
|
|
||||||
// vollstaendige, echte Weg ueber TEN-01..07/IAM-01..14 hinweg -
|
|
||||||
//
|
|
||||||
// Mandant provisionieren (internal/tenant.Provisioner, physisch isolierte DB)
|
|
||||||
// -> Administrator anlegen (internal/user.TenantUserStore.Create +
|
|
||||||
// SetPasswordHash, wie es internal/tenant.OnboardingService fuer TEN-02
|
|
||||||
// tut, hier explizit nachgebaut um zusaetzlich ein Passwort zu setzen)
|
|
||||||
// -> Anmelden (internal/auth.LoginService.Login)
|
|
||||||
// -> Assert: gueltiges Token, richtiger Tenant.
|
|
||||||
func TestE2E_TenantOnboardingLoginFlow(t *testing.T) {
|
|
||||||
adminDSN := requireAdminDSN(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
root := repoRoot(t)
|
|
||||||
|
|
||||||
adminPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("admin pool: %v", err)
|
|
||||||
}
|
|
||||||
defer adminPool.Close()
|
|
||||||
|
|
||||||
applyRegistrySchema(t, ctx, adminPool, root)
|
|
||||||
|
|
||||||
registry := tenant.NewRegistry(adminPool)
|
|
||||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
||||||
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_, _ = adminPool.Exec(ctx, `DELETE FROM tenants WHERE slug = 'e2e_onboard'`)
|
|
||||||
dropTenantDBs(ctx, adminPool, "tenant_e2e_onboard")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Schritt 1: Mandant provisionieren (TEN-01/TEN-02) - eigene, physisch
|
|
||||||
// getrennte Datenbank statt gemeinsamer tenant_id-Spalte.
|
|
||||||
tn, err := provisioner.Provision(ctx, "e2e_onboard", "E2E Onboarding GmbH")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provision: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tenantPool, err := pgxpool.New(ctx, tn.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connect tenant db: %v", err)
|
|
||||||
}
|
|
||||||
defer tenantPool.Close()
|
|
||||||
|
|
||||||
// Schema in der frisch angelegten Tenant-Datenbank ausrollen (TEN-07-
|
|
||||||
// Migrationslogik, hier direkt statt ueber den Orchestrator, um im Test
|
|
||||||
// nicht von der Registry-Iteration abhaengig zu sein).
|
|
||||||
applyTenantSchema(t, ctx, tenantPool, root)
|
|
||||||
|
|
||||||
// Schritt 2: Benutzer anlegen (IAM-01/IAM-02) und Passwort setzen
|
|
||||||
// (IAM-03, bcrypt ueber internal/auth.HashPassword).
|
|
||||||
userStore := user.NewTenantUserStore(tenantPool)
|
|
||||||
created, err := userStore.Create(ctx, "admin@e2e_onboard.example", "E2E Admin")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("benutzer anlegen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := auth.HashPassword("korrekt-pferd-batterie-heftklammer")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("passwort hashen: %v", err)
|
|
||||||
}
|
|
||||||
if err := userStore.SetPasswordHash(ctx, created.ID, hash); err != nil {
|
|
||||||
t.Fatalf("passwort-hash setzen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schritt 3: Anmelden (IAM-04/IAM-05 - LoginService gegen genau diese
|
|
||||||
// Tenant-DB, kein tenant-uebergreifendes Login moeglich).
|
|
||||||
issuer := auth.NewTokenIssuer("test-signing-secret-fuer-qa-02-e2e")
|
|
||||||
login := auth.NewLoginService(userStore, issuer, tn.Slug)
|
|
||||||
|
|
||||||
token, err := login.Login(ctx, "admin@e2e_onboard.example", "korrekt-pferd-batterie-heftklammer")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("login: %v", err)
|
|
||||||
}
|
|
||||||
if token == "" {
|
|
||||||
t.Fatal("login lieferte leeres token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert: Token ist gueltig UND traegt den richtigen Tenant (Verify statt
|
|
||||||
// blindem String-Nichtleer-Check - sonst waere ein kaputtes Token auch
|
|
||||||
// "gruen").
|
|
||||||
claims, err := issuer.Verify(token)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("token verifizieren: %v", err)
|
|
||||||
}
|
|
||||||
if claims.TenantSlug != "e2e_onboard" {
|
|
||||||
t.Fatalf("token tenant = %q, want %q", claims.TenantSlug, "e2e_onboard")
|
|
||||||
}
|
|
||||||
if claims.UserID != created.ID {
|
|
||||||
t.Fatalf("token user id = %q, want %q", claims.UserID, created.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gegenprobe: falsches Passwort scheitert weiterhin (kein False-Positive
|
|
||||||
// durch einen zu freizuegigen Testaufbau).
|
|
||||||
if _, err := login.Login(ctx, "admin@e2e_onboard.example", "falsches-passwort"); err == nil {
|
|
||||||
t.Fatal("login mit falschem passwort haette scheitern muessen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Package ldapsync implementiert Core IAM-05: LDAP/Active-Directory-
|
|
||||||
// Anbindung je Tenant fuer Benutzer-Synchronisation, Login-Delegation und
|
|
||||||
// Rollenzuordnung aus Gruppen ueber eine explizite Erlaubnis-Matrix.
|
|
||||||
//
|
|
||||||
// WICHTIGER HINWEIS: In dieser Umgebung steht kein echter LDAP/AD-Server zur
|
|
||||||
// Verfuegung (analog zur Archive-Engine bei AUD-05). Client ist deshalb eine
|
|
||||||
// Schnittstelle — die Sync-/Rollenzuordnungs-Logik ist gegen einen Fake in
|
|
||||||
// den Tests vollstaendig geprueft, eine echte Verbindungspruefung gegen
|
|
||||||
// LDAP/AD steht noch aus.
|
|
||||||
package ldapsync
|
|
||||||
|
|
||||||
// Entry ist ein aus LDAP gelesener Benutzerdatensatz.
|
|
||||||
type Entry struct {
|
|
||||||
Email string
|
|
||||||
Name string
|
|
||||||
Groups []string
|
|
||||||
Disabled bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client abstrahiert die eigentliche LDAP-Verbindung (Bind + Search).
|
|
||||||
type Client interface {
|
|
||||||
// Search liefert alle Benutzer, die dem konfigurierten Filter
|
|
||||||
// entsprechen. Ein Fehler bedeutet "Quelle nicht erreichbar oder
|
|
||||||
// fehlerhaft" — Run() bricht dann komplett und folgenlos ab
|
|
||||||
// (Akzeptanzkriterium 2).
|
|
||||||
Search() ([]Entry, error)
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
package ldapsync
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeClient struct {
|
|
||||||
entries []Entry
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeClient) Search() ([]Entry, error) {
|
|
||||||
if f.err != nil {
|
|
||||||
return nil, f.err
|
|
||||||
}
|
|
||||||
return f.entries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupTest(t *testing.T) (*user.TenantUserStore, *rbac.Store, *RoleMappingStore, 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 role_assignments (
|
|
||||||
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
|
|
||||||
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS role_assignment_history (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL,
|
|
||||||
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
|
|
||||||
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
|
|
||||||
);
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := func() { pool.Close() }
|
|
||||||
return user.NewTenantUserStore(pool), rbac.NewStore(pool), NewRoleMappingStore(pool), cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
func uniqueEmail() string {
|
|
||||||
return fmt.Sprintf("ldap-test-%d@example.com", time.Now().UnixNano())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 1: Rollen-Erlaubnis-Matrix mit Positiv- und
|
|
||||||
// Negativfaellen — keine Privilege-Escalation ueber Gruppenmitgliedschaft.
|
|
||||||
func TestRoleMapping_PositiveAndNegativeCases(t *testing.T) {
|
|
||||||
_, _, mappings, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := mappings.SetMapping(ctx, "cn=IT-Admins,ou=groups,dc=example,dc=com", rbac.RoleTenantAdmin); err != nil {
|
|
||||||
t.Fatalf("set mapping: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Positivfall: gemappte Gruppe liefert die konfigurierte Rolle.
|
|
||||||
role, ok, err := mappings.HighestRoleFor(ctx, []string{"cn=IT-Admins,ou=groups,dc=example,dc=com"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("highest role: %v", err)
|
|
||||||
}
|
|
||||||
if !ok || role != rbac.RoleTenantAdmin {
|
|
||||||
t.Fatalf("erwartet tenant_admin, habe ok=%v role=%q", ok, role)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Negativfall 1: unbekannte Gruppe vergibt keine Rolle.
|
|
||||||
_, ok, err = mappings.HighestRoleFor(ctx, []string{"cn=Praktikanten,ou=groups,dc=example,dc=com"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("highest role (unbekannt): %v", err)
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
t.Fatal("erwartet KEINE rolle fuer nicht gemappte gruppe")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Negativfall 2 (Privilege-Escalation-Versuch): eine Gruppe, die zufaellig
|
|
||||||
// wie eine interne Rolle heisst, aber NIE explizit gemappt wurde, darf
|
|
||||||
// keine Rolle vergeben.
|
|
||||||
_, ok, err = mappings.HighestRoleFor(ctx, []string{"tenant_admin", "superadmin"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("highest role (namens-trick): %v", err)
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
t.Fatal("erwartet KEINE privilege-escalation ueber gruppennamen, die wie rollen aussehen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1: Synchronisationslauf legt Benutzer an.
|
|
||||||
func TestSyncer_CreatesUsers(t *testing.T) {
|
|
||||||
users, roles, mappings, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
email := uniqueEmail()
|
|
||||||
client := &fakeClient{entries: []Entry{{Email: email, Name: "LDAP Nutzer"}}}
|
|
||||||
syncer := NewSyncer(client, users, roles, mappings)
|
|
||||||
|
|
||||||
result, err := syncer.Run(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("run: %v", err)
|
|
||||||
}
|
|
||||||
if result.Created != 1 {
|
|
||||||
t.Fatalf("erwartet 1 angelegten benutzer, habe %d", result.Created)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := users.GetByEmailForAuth(ctx, email); err != nil {
|
|
||||||
t.Fatalf("benutzer sollte existieren: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2: fehlerhafte/nicht erreichbare LDAP-
|
|
||||||
// Quelle bricht kontrolliert ab, ohne bestehende Konten zu beschaedigen.
|
|
||||||
func TestSyncer_AbortsCleanlyOnSourceError(t *testing.T) {
|
|
||||||
users, roles, mappings, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Ein bereits bestehendes Konto, das UNBERUEHRT bleiben muss.
|
|
||||||
existingEmail := uniqueEmail()
|
|
||||||
existing, err := users.Create(ctx, existingEmail, "Bestehender Nutzer")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create existing: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &fakeClient{err: errors.New("ldap-server nicht erreichbar (simuliert)")}
|
|
||||||
syncer := NewSyncer(client, users, roles, mappings)
|
|
||||||
|
|
||||||
if _, err := syncer.Run(ctx); err == nil {
|
|
||||||
t.Fatal("erwartet fehler bei nicht erreichbarer ldap-quelle")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bestehendes Konto unveraendert (noch aktiv, keine geloeschten/neuen Zeilen).
|
|
||||||
got, err := users.Get(ctx, existing.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("bestehendes konto sollte weiterhin existieren: %v", err)
|
|
||||||
}
|
|
||||||
if got.Status != user.StatusActive {
|
|
||||||
t.Fatalf("bestehendes konto sollte unveraendert aktiv sein, ist %q", got.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 3: Deaktivierung in LDAP wird bei
|
|
||||||
// naechster Synchronisation korrekt uebernommen.
|
|
||||||
func TestSyncer_AppliesDeactivationOnNextRun(t *testing.T) {
|
|
||||||
users, roles, mappings, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
email := uniqueEmail()
|
|
||||||
client := &fakeClient{entries: []Entry{{Email: email, Name: "Wird Deaktiviert"}}}
|
|
||||||
syncer := NewSyncer(client, users, roles, mappings)
|
|
||||||
|
|
||||||
if _, err := syncer.Run(ctx); err != nil {
|
|
||||||
t.Fatalf("erster lauf: %v", err)
|
|
||||||
}
|
|
||||||
u, err := users.GetByEmailForAuth(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("benutzer nach erstem lauf: %v", err)
|
|
||||||
}
|
|
||||||
if u.User.Status != user.StatusActive {
|
|
||||||
t.Fatalf("erwartet aktiv nach erstem lauf, habe %q", u.User.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zweiter Lauf: derselbe Benutzer ist jetzt in LDAP deaktiviert.
|
|
||||||
client.entries[0].Disabled = true
|
|
||||||
result, err := syncer.Run(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("zweiter lauf: %v", err)
|
|
||||||
}
|
|
||||||
if result.Deactivated != 1 {
|
|
||||||
t.Fatalf("erwartet 1 deaktivierung, habe %d", result.Deactivated)
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err = users.GetByEmailForAuth(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("benutzer nach zweitem lauf: %v", err)
|
|
||||||
}
|
|
||||||
if u.User.Status != user.StatusInactive {
|
|
||||||
t.Fatalf("erwartet inaktiv nach deaktivierung in ldap, habe %q", u.User.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package ldapsync
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RoleMappingStore verwaltet die Erlaubnis-Matrix LDAP-Gruppe -> Rolle
|
|
||||||
// (Akzeptanzkriterium 3). Nur explizit gemappte Gruppen vergeben eine Rolle
|
|
||||||
// — der Schutz gegen Privilege-Escalation ist strukturell: es gibt keinen
|
|
||||||
// Code-Pfad, der eine unbekannte Gruppe irgendeiner Rolle zuordnet.
|
|
||||||
type RoleMappingStore struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRoleMappingStore(pool *pgxpool.Pool) *RoleMappingStore {
|
|
||||||
return &RoleMappingStore{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMapping ordnet eine LDAP-Gruppe genau einer NEXARCH-Rolle zu. Nur
|
|
||||||
// 'user'/'tenant_admin' sind zuweisbar (dieselbe Matrix wie RBAC-01/03) —
|
|
||||||
// 'superadmin' bleibt mandantenuebergreifend und nie ueber LDAP erreichbar.
|
|
||||||
func (s *RoleMappingStore) SetMapping(ctx context.Context, ldapGroup string, role rbac.Role) error {
|
|
||||||
if role != rbac.RoleUser && role != rbac.RoleTenantAdmin {
|
|
||||||
return fmt.Errorf("ldapsync: rolle %q ist ueber ldap nicht zuweisbar", role)
|
|
||||||
}
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO ldap_group_role_mapping (ldap_group, role) VALUES ($1, $2)
|
|
||||||
ON CONFLICT (ldap_group) DO UPDATE SET role = $2
|
|
||||||
`, ldapGroup, string(role))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("gruppenzuordnung speichern: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// roleFor liefert die einer LDAP-Gruppe zugeordnete Rolle, sofern konfiguriert.
|
|
||||||
func (s *RoleMappingStore) roleFor(ctx context.Context, ldapGroup string) (rbac.Role, bool, error) {
|
|
||||||
var role string
|
|
||||||
err := s.pool.QueryRow(ctx, `SELECT role FROM ldap_group_role_mapping WHERE ldap_group = $1`, ldapGroup).Scan(&role)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return "", false, nil
|
|
||||||
}
|
|
||||||
return "", false, fmt.Errorf("gruppenzuordnung lesen: %w", err)
|
|
||||||
}
|
|
||||||
return rbac.Role(role), true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HighestRoleFor bestimmt aus einer Liste von LDAP-Gruppen die hoechste
|
|
||||||
// zugeordnete Rolle (tenant_admin > user). Gruppen ohne Eintrag in der
|
|
||||||
// Matrix tragen NICHTS bei — auch nicht, wenn ihr Name zufaellig wie eine
|
|
||||||
// interne Rolle aussieht (Akzeptanzkriterium 3 / Pruefung 1: keine
|
|
||||||
// Privilege-Escalation ueber Gruppenmitgliedschaft).
|
|
||||||
func (s *RoleMappingStore) HighestRoleFor(ctx context.Context, ldapGroups []string) (rbac.Role, bool, error) {
|
|
||||||
var best rbac.Role
|
|
||||||
found := false
|
|
||||||
|
|
||||||
for _, g := range ldapGroups {
|
|
||||||
role, ok, err := s.roleFor(ctx, g)
|
|
||||||
if err != nil {
|
|
||||||
return "", false, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !found || role == rbac.RoleTenantAdmin {
|
|
||||||
best = role
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best, found, nil
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package ldapsync
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SyncResult fasst das Ergebnis eines Synchronisationslaufs zusammen.
|
|
||||||
type SyncResult struct {
|
|
||||||
Created int
|
|
||||||
Updated int
|
|
||||||
Deactivated int
|
|
||||||
Failed []string // Email-Adressen, bei denen die Verarbeitung fehlschlug
|
|
||||||
}
|
|
||||||
|
|
||||||
// Syncer fuehrt die Benutzer-/Rollen-Synchronisation aus LDAP aus.
|
|
||||||
type Syncer struct {
|
|
||||||
client Client
|
|
||||||
users *user.TenantUserStore
|
|
||||||
roles *rbac.Store
|
|
||||||
mappings *RoleMappingStore
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSyncer(client Client, users *user.TenantUserStore, roles *rbac.Store, mappings *RoleMappingStore) *Syncer {
|
|
||||||
return &Syncer{client: client, users: users, roles: roles, mappings: mappings}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run synchronisiert Benutzer aus LDAP (Akzeptanzkriterium 1). Ist die
|
|
||||||
// LDAP-Quelle nicht erreichbar/fehlerhaft, wird SOFORT und OHNE jede
|
|
||||||
// Aenderung an bestehenden Konten abgebrochen (Akzeptanzkriterium 2 /
|
|
||||||
// Pruefung 2) — Search() wird als allererstes aufgerufen, bevor irgendein
|
|
||||||
// Schreibzugriff stattfindet.
|
|
||||||
func (s *Syncer) Run(ctx context.Context) (SyncResult, error) {
|
|
||||||
entries, err := s.client.Search()
|
|
||||||
if err != nil {
|
|
||||||
return SyncResult{}, fmt.Errorf("ldap-quelle nicht erreichbar, synchronisation abgebrochen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result SyncResult
|
|
||||||
for _, entry := range entries {
|
|
||||||
if err := s.syncOne(ctx, entry, &result); err != nil {
|
|
||||||
// Ein fehlerhafter Einzeleintrag isoliert sich selbst — er
|
|
||||||
// beschaedigt weder bereits verarbeitete noch nachfolgende
|
|
||||||
// Konten (Akzeptanzkriterium 2).
|
|
||||||
result.Failed = append(result.Failed, entry.Email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Syncer) syncOne(ctx context.Context, entry Entry, result *SyncResult) error {
|
|
||||||
existing, err := s.users.GetByEmailForAuth(ctx, entry.Email)
|
|
||||||
isNew := errors.Is(err, user.ErrNotFound)
|
|
||||||
if err != nil && !isNew {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var userID string
|
|
||||||
if isNew {
|
|
||||||
created, err := s.users.Create(ctx, entry.Email, entry.Name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
userID = created.ID
|
|
||||||
result.Created++
|
|
||||||
} else {
|
|
||||||
userID = existing.User.ID
|
|
||||||
result.Updated++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deaktivierung in LDAP wird uebernommen (Akzeptanzkriterium 3 / Pruefung 3).
|
|
||||||
if entry.Disabled {
|
|
||||||
if _, err := s.users.Deactivate(ctx, userID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
result.Deactivated++
|
|
||||||
}
|
|
||||||
|
|
||||||
role, ok, err := s.mappings.HighestRoleFor(ctx, entry.Groups)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
if _, err := s.roles.Assign(ctx, userID, role, "ldap-sync"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package lockout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrAccountLocked = errors.New("lockout: konto ist vorübergehend gesperrt")
|
|
||||||
|
|
||||||
// GuardedLogin komponiert IAM-02s LoginService mit dem Lockout-Zustand,
|
|
||||||
// OHNE LoginService selbst zu veraendern (kein Umbau der Vorbedingung):
|
|
||||||
// vor jedem Login-Versuch wird die Sperre geprueft, nach jedem Versuch wird
|
|
||||||
// Erfolg/Fehlschlag im geteilten Postgres-Zustand vermerkt.
|
|
||||||
func GuardedLogin(ctx context.Context, store *Store, login *auth.LoginService, email, password string) (string, error) {
|
|
||||||
locked, _, err := store.IsLocked(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if locked {
|
|
||||||
return "", ErrAccountLocked
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := login.Login(ctx, email, password)
|
|
||||||
if err != nil {
|
|
||||||
if _, _, lockErr := store.RecordFailure(ctx, email); lockErr != nil {
|
|
||||||
return "", lockErr
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.RecordSuccess(ctx, email); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
// Package lockout implementiert Core IAM-07: Account-Lockout nach
|
|
||||||
// Fehlversuchen und Login-Rate-Limiting mit geteiltem, externem
|
|
||||||
// (Postgres-basiertem) Zustand — kein In-Process-Zaehler, der bei
|
|
||||||
// Mehrinstanzbetrieb aushebelbar waere (bekannter Fehler aus archivdms
|
|
||||||
// internal/auth/ratelimit.go).
|
|
||||||
package lockout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DefaultMaxFailedAttempts/DefaultLockoutDuration sind explizit benannte
|
|
||||||
// Defaults, ueberschreibbar via WithPolicy.
|
|
||||||
const (
|
|
||||||
DefaultMaxFailedAttempts = 5
|
|
||||||
DefaultLockoutDuration = 15 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
type Store struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
maxFailed int
|
|
||||||
lockoutDuration time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store {
|
|
||||||
return &Store{pool: pool, maxFailed: DefaultMaxFailedAttempts, lockoutDuration: DefaultLockoutDuration}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) WithPolicy(maxFailed int, lockoutDuration time.Duration) *Store {
|
|
||||||
return &Store{pool: s.pool, maxFailed: maxFailed, lockoutDuration: lockoutDuration}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsLocked prueft, ob ein Konto aktuell gesperrt ist. Eine abgelaufene
|
|
||||||
// Sperre gilt automatisch als nicht mehr gesperrt (Akzeptanzkriterium 3) —
|
|
||||||
// es ist keine explizite Entsperr-Aktion noetig, der Zeitvergleich reicht.
|
|
||||||
func (s *Store) IsLocked(ctx context.Context, email string) (locked bool, lockedUntil time.Time, err error) {
|
|
||||||
err = s.pool.QueryRow(ctx, `
|
|
||||||
SELECT locked_until FROM login_attempts WHERE email = $1 AND locked_until IS NOT NULL
|
|
||||||
`, email).Scan(&lockedUntil)
|
|
||||||
if err != nil {
|
|
||||||
return false, time.Time{}, nil // kein Datensatz oder kein Lock -> nicht gesperrt
|
|
||||||
}
|
|
||||||
return time.Now().Before(lockedUntil), lockedUntil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordFailure erhoeht den Fehlversuchszaehler ATOMAR (UPSERT) und sperrt
|
|
||||||
// das Konto, sobald die Schwelle erreicht ist (Akzeptanzkriterium 1 / 2).
|
|
||||||
// Der Zustand liegt ausschliesslich in Postgres, mehrere Core-Instanzen
|
|
||||||
// teilen sich denselben Zaehler (Akzeptanzkriterium 2).
|
|
||||||
func (s *Store) RecordFailure(ctx context.Context, email string) (locked bool, lockedUntil time.Time, err error) {
|
|
||||||
var failedCount int
|
|
||||||
var lockedUntilPtr *time.Time
|
|
||||||
|
|
||||||
err = s.pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO login_attempts (email, failed_count, locked_until, last_attempt_at)
|
|
||||||
VALUES ($1, 1, CASE WHEN 1 >= $2 THEN now() + $3::interval ELSE NULL END, now())
|
|
||||||
ON CONFLICT (email) DO UPDATE SET
|
|
||||||
failed_count = login_attempts.failed_count + 1,
|
|
||||||
last_attempt_at = now(),
|
|
||||||
locked_until = CASE
|
|
||||||
WHEN login_attempts.failed_count + 1 >= $2 THEN now() + $3::interval
|
|
||||||
ELSE login_attempts.locked_until
|
|
||||||
END
|
|
||||||
RETURNING failed_count, locked_until
|
|
||||||
`, email, s.maxFailed, fmt.Sprintf("%f seconds", s.lockoutDuration.Seconds())).Scan(&failedCount, &lockedUntilPtr)
|
|
||||||
if err != nil {
|
|
||||||
return false, time.Time{}, fmt.Errorf("fehlversuch erfassen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lockedUntilPtr != nil {
|
|
||||||
return time.Now().Before(*lockedUntilPtr), *lockedUntilPtr, nil
|
|
||||||
}
|
|
||||||
return false, time.Time{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordSuccess setzt den Fehlversuchszaehler nach erfolgreichem Login zurueck.
|
|
||||||
func (s *Store) RecordSuccess(ctx context.Context, email string) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO login_attempts (email, failed_count, locked_until, last_attempt_at)
|
|
||||||
VALUES ($1, 0, NULL, now())
|
|
||||||
ON CONFLICT (email) DO UPDATE SET failed_count = 0, locked_until = NULL, last_attempt_at = now()
|
|
||||||
`, email)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("erfolgreichen login erfassen: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlock entsperrt ein Konto durch Administratoreingriff, unabhaengig von
|
|
||||||
// der Sperrzeit (Akzeptanzkriterium 3).
|
|
||||||
func (s *Store) Unlock(ctx context.Context, email string) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE login_attempts SET failed_count = 0, locked_until = NULL WHERE email = $1
|
|
||||||
`, email)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("konto entsperren: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
package lockout
|
|
||||||
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
const schema = `
|
|
||||||
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 login_attempts (
|
|
||||||
email TEXT PRIMARY KEY, failed_count INT NOT NULL DEFAULT 0,
|
|
||||||
locked_until TIMESTAMPTZ, last_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);`
|
|
||||||
|
|
||||||
func setupTest(t *testing.T) (*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, schema); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
cleanup := func() { pool.Close() }
|
|
||||||
return pool, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
func uniqueEmail() string {
|
|
||||||
return fmt.Sprintf("lockout-test-%d@example.com", time.Now().UnixNano())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 2: Sperre greift nach definierten
|
|
||||||
// Fehlversuchen zuverlaessig.
|
|
||||||
func TestRecordFailure_LocksAfterThreshold(t *testing.T) {
|
|
||||||
pool, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
store := NewStore(pool).WithPolicy(3, time.Hour)
|
|
||||||
email := uniqueEmail()
|
|
||||||
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
locked, _, err := store.RecordFailure(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("record failure %d: %v", i, err)
|
|
||||||
}
|
|
||||||
if locked {
|
|
||||||
t.Fatalf("sollte nach %d fehlversuchen noch nicht gesperrt sein", i+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
locked, lockedUntil, err := store.RecordFailure(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("record failure 3: %v", err)
|
|
||||||
}
|
|
||||||
if !locked {
|
|
||||||
t.Fatal("erwartet gesperrt nach 3 fehlversuchen (schwelle=3)")
|
|
||||||
}
|
|
||||||
if !lockedUntil.After(time.Now()) {
|
|
||||||
t.Fatal("lockedUntil sollte in der zukunft liegen")
|
|
||||||
}
|
|
||||||
|
|
||||||
isLocked, _, err := store.IsLocked(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("is locked: %v", err)
|
|
||||||
}
|
|
||||||
if !isLocked {
|
|
||||||
t.Fatal("IsLocked sollte true liefern")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 1: zwei Dienstinstanzen teilen sich
|
|
||||||
// denselben Zaehler (Mehrinstanz-Test) — hier durch zwei unabhaengige
|
|
||||||
// pgxpool.Pool-Verbindungen zur selben Datenbank simuliert.
|
|
||||||
func TestRecordFailure_SharedAcrossInstances(t *testing.T) {
|
|
||||||
_, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
email := uniqueEmail()
|
|
||||||
|
|
||||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
||||||
instanceA, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("instance a: %v", err)
|
|
||||||
}
|
|
||||||
defer instanceA.Close()
|
|
||||||
instanceB, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("instance b: %v", err)
|
|
||||||
}
|
|
||||||
defer instanceB.Close()
|
|
||||||
|
|
||||||
storeA := NewStore(instanceA).WithPolicy(4, time.Hour)
|
|
||||||
storeB := NewStore(instanceB).WithPolicy(4, time.Hour)
|
|
||||||
|
|
||||||
// Zwei fehlversuche ueber "instanz A", zwei ueber "instanz B" — beide
|
|
||||||
// muessen sich denselben Zaehler teilen und gemeinsam die Schwelle erreichen.
|
|
||||||
if _, _, err := storeA.RecordFailure(ctx, email); err != nil {
|
|
||||||
t.Fatalf("a1: %v", err)
|
|
||||||
}
|
|
||||||
if _, _, err := storeB.RecordFailure(ctx, email); err != nil {
|
|
||||||
t.Fatalf("b1: %v", err)
|
|
||||||
}
|
|
||||||
if _, _, err := storeA.RecordFailure(ctx, email); err != nil {
|
|
||||||
t.Fatalf("a2: %v", err)
|
|
||||||
}
|
|
||||||
locked, _, err := storeB.RecordFailure(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("b2: %v", err)
|
|
||||||
}
|
|
||||||
if !locked {
|
|
||||||
t.Fatal("erwartet gesperrt nach insgesamt 4 fehlversuchen ueber beide instanzen hinweg")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 3: automatische Entsperrung nach Ablauf
|
|
||||||
// der Sperrzeit, Zeitversatz automatisiert getestet.
|
|
||||||
func TestIsLocked_AutoUnlocksAfterExpiry(t *testing.T) {
|
|
||||||
pool, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
store := NewStore(pool).WithPolicy(1, 50*time.Millisecond)
|
|
||||||
email := uniqueEmail()
|
|
||||||
|
|
||||||
locked, _, err := store.RecordFailure(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("record failure: %v", err)
|
|
||||||
}
|
|
||||||
if !locked {
|
|
||||||
t.Fatal("erwartet sofort gesperrt bei schwelle=1")
|
|
||||||
}
|
|
||||||
|
|
||||||
isLocked, _, err := store.IsLocked(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("is locked (vor ablauf): %v", err)
|
|
||||||
}
|
|
||||||
if !isLocked {
|
|
||||||
t.Fatal("erwartet gesperrt vor ablauf der sperrzeit")
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(70 * time.Millisecond)
|
|
||||||
|
|
||||||
isLocked, _, err = store.IsLocked(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("is locked (nach ablauf): %v", err)
|
|
||||||
}
|
|
||||||
if isLocked {
|
|
||||||
t.Fatal("erwartet automatisch entsperrt nach ablauf der sperrzeit")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3: Entsperrung durch Administratoreingriff.
|
|
||||||
func TestUnlock_ClearsLockImmediately(t *testing.T) {
|
|
||||||
pool, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
store := NewStore(pool).WithPolicy(1, time.Hour)
|
|
||||||
email := uniqueEmail()
|
|
||||||
|
|
||||||
if _, _, err := store.RecordFailure(ctx, email); err != nil {
|
|
||||||
t.Fatalf("record failure: %v", err)
|
|
||||||
}
|
|
||||||
locked, _, _ := store.IsLocked(ctx, email)
|
|
||||||
if !locked {
|
|
||||||
t.Fatal("erwartet gesperrt vor admin-eingriff")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.Unlock(ctx, email); err != nil {
|
|
||||||
t.Fatalf("unlock: %v", err)
|
|
||||||
}
|
|
||||||
locked, _, err := store.IsLocked(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("is locked nach unlock: %v", err)
|
|
||||||
}
|
|
||||||
if locked {
|
|
||||||
t.Fatal("erwartet sofort entsperrt nach admin-eingriff, unabhaengig von der sperrzeit")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Integrationstest: GuardedLogin komponiert IAM-02 LoginService mit dem
|
|
||||||
// Lockout-Zustand, ohne LoginService selbst zu veraendern.
|
|
||||||
func TestGuardedLogin_LocksAfterRepeatedFailures(t *testing.T) {
|
|
||||||
pool, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
email := uniqueEmail()
|
|
||||||
userStore := user.NewTenantUserStore(pool)
|
|
||||||
u, err := userStore.Create(ctx, email, "Lockout 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
|
||||||
loginService := auth.NewLoginService(userStore, issuer, "acme")
|
|
||||||
lockoutStore := NewStore(pool).WithPolicy(3, time.Hour)
|
|
||||||
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
if _, err := GuardedLogin(ctx, lockoutStore, loginService, email, "falsches-passwort"); !errors.Is(err, auth.ErrInvalidCredentials) {
|
|
||||||
t.Fatalf("versuch %d: erwartet ErrInvalidCredentials, habe %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selbst das RICHTIGE Passwort wird jetzt abgewiesen, weil das Konto gesperrt ist.
|
|
||||||
if _, err := GuardedLogin(ctx, lockoutStore, loginService, email, "korrektes-passwort"); !errors.Is(err, ErrAccountLocked) {
|
|
||||||
t.Fatalf("erwartet ErrAccountLocked trotz korrektem passwort, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
// Package migrate implementiert Core TEN-07: das automatisierte, pro Tenant
|
|
||||||
// fehlerisolierte Ausrollen von SQL-Migrationen ueber alle registrierten
|
|
||||||
// Mandanten-Datenbanken (Modell C — jede Migration muss N-mal statt einmal
|
|
||||||
// laufen, siehe TEN-01).
|
|
||||||
package migrate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
|
||||||
)
|
|
||||||
|
|
||||||
const upSuffix = ".up.sql"
|
|
||||||
|
|
||||||
type Migration struct {
|
|
||||||
Version string
|
|
||||||
SQL string
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadMigrations liest alle *.up.sql-Dateien aus dir und sortiert sie nach
|
|
||||||
// Dateiname (die bestehende Namenskonvention 0001_..., 0002_... aus TEN-01
|
|
||||||
// sorgt fuer eine stabile Reihenfolge).
|
|
||||||
func LoadMigrations(dir string) ([]Migration, error) {
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("migrationsverzeichnis lesen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var names []string
|
|
||||||
for _, e := range entries {
|
|
||||||
if !e.IsDir() && strings.HasSuffix(e.Name(), upSuffix) {
|
|
||||||
names = append(names, e.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
|
|
||||||
migrations := make([]Migration, 0, len(names))
|
|
||||||
for _, name := range names {
|
|
||||||
content, err := os.ReadFile(filepath.Join(dir, name))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("migration %q lesen: %w", name, err)
|
|
||||||
}
|
|
||||||
version := strings.TrimSuffix(name, upSuffix)
|
|
||||||
migrations = append(migrations, Migration{Version: version, SQL: string(content)})
|
|
||||||
}
|
|
||||||
return migrations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MigrationStatus ist der pro Tenant und Version nachvollziehbare Stand
|
|
||||||
// (Akzeptanzkriterium 3): welche Version, wann zuletzt versucht, Erfolg oder
|
|
||||||
// Fehler.
|
|
||||||
type MigrationStatus struct {
|
|
||||||
Version string
|
|
||||||
AppliedAt time.Time
|
|
||||||
Success bool
|
|
||||||
Error string
|
|
||||||
}
|
|
||||||
|
|
||||||
// TenantResult fasst das Ergebnis eines Rollout-Versuchs fuer EINEN Tenant
|
|
||||||
// zusammen — wird von Orchestrator.RolloutAll pro Tenant gesammelt, damit ein
|
|
||||||
// Fehlschlag bei einem Mandanten die anderen nicht blockiert (Akzeptanzkriterium 2).
|
|
||||||
type TenantResult struct {
|
|
||||||
TenantSlug string
|
|
||||||
Applied []string // erfolgreich in diesem Lauf angewendete Versionen
|
|
||||||
FailedAt string // Version, bei der abgebrochen wurde; leer wenn kein Fehlschlag
|
|
||||||
Err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Orchestrator rollt Migrationen ueber alle in der Registry gefuehrten
|
|
||||||
// Mandanten aus. Baut bewusst NICHT auf TEN-06 (Router) auf — Migrations-
|
|
||||||
// Rollouts sind seltene Batch-Vorgaenge, kein Hot-Path, ein kurzlebiger Pool
|
|
||||||
// pro Tenant und Lauf ist hier einfacher und unabhaengig von TEN-06 testbar.
|
|
||||||
type Orchestrator struct {
|
|
||||||
registry *tenant.Registry
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewOrchestrator(registry *tenant.Registry) *Orchestrator {
|
|
||||||
return &Orchestrator{registry: registry}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureTableSQL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version TEXT PRIMARY KEY,
|
|
||||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
success BOOLEAN NOT NULL,
|
|
||||||
error TEXT
|
|
||||||
);`
|
|
||||||
|
|
||||||
// RolloutAll wendet migrations auf JEDE registrierte Tenant-Datenbank an.
|
|
||||||
// Jeder Tenant laeuft unabhaengig — ein Fehlschlag bei einem Mandanten wird
|
|
||||||
// im jeweiligen TenantResult festgehalten und blockiert die uebrigen nicht
|
|
||||||
// (Akzeptanzkriterium 1 + 2).
|
|
||||||
func (o *Orchestrator) RolloutAll(ctx context.Context, migrations []Migration) ([]TenantResult, error) {
|
|
||||||
tenants, err := o.registry.List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("tenants fuer rollout auflisten: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([]TenantResult, 0, len(tenants))
|
|
||||||
for _, t := range tenants {
|
|
||||||
results = append(results, o.rolloutForTenant(ctx, t, migrations))
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *Orchestrator) rolloutForTenant(ctx context.Context, t tenant.Tenant, migrations []Migration) TenantResult {
|
|
||||||
result := TenantResult{TenantSlug: t.Slug}
|
|
||||||
|
|
||||||
pool, err := pgxpool.New(ctx, t.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
result.Err = fmt.Errorf("verbindung zu tenant %q: %w", t.Slug, err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
|
|
||||||
if _, err := pool.Exec(ctx, ensureTableSQL); err != nil {
|
|
||||||
result.Err = fmt.Errorf("schema_migrations anlegen fuer tenant %q: %w", t.Slug, err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, m := range migrations {
|
|
||||||
alreadyApplied, err := isAlreadySuccessful(ctx, pool, m.Version)
|
|
||||||
if err != nil {
|
|
||||||
result.Err = fmt.Errorf("migrationsstand lesen fuer tenant %q, version %q: %w", t.Slug, m.Version, err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if alreadyApplied {
|
|
||||||
continue // Akzeptanzkriterium 3 / Pruefung 3: keine erneute Anwendung.
|
|
||||||
}
|
|
||||||
|
|
||||||
_, execErr := pool.Exec(ctx, m.SQL)
|
|
||||||
if execErr != nil {
|
|
||||||
recordAttempt(ctx, pool, m.Version, false, execErr.Error())
|
|
||||||
result.FailedAt = m.Version
|
|
||||||
result.Err = fmt.Errorf("migration %q fuer tenant %q fehlgeschlagen: %w", m.Version, t.Slug, execErr)
|
|
||||||
return result // spaetere Migrationen bauen typischerweise auf dieser auf — Abbruch NUR fuer diesen Tenant.
|
|
||||||
}
|
|
||||||
|
|
||||||
recordAttempt(ctx, pool, m.Version, true, "")
|
|
||||||
result.Applied = append(result.Applied, m.Version)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func isAlreadySuccessful(ctx context.Context, pool *pgxpool.Pool, version string) (bool, error) {
|
|
||||||
var success bool
|
|
||||||
err := pool.QueryRow(ctx, `SELECT success FROM schema_migrations WHERE version = $1`, version).Scan(&success)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return success, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordAttempt(ctx context.Context, pool *pgxpool.Pool, version string, success bool, errMsg string) {
|
|
||||||
var errVal *string
|
|
||||||
if errMsg != "" {
|
|
||||||
errVal = &errMsg
|
|
||||||
}
|
|
||||||
_, _ = pool.Exec(ctx, `
|
|
||||||
INSERT INTO schema_migrations (version, applied_at, success, error)
|
|
||||||
VALUES ($1, now(), $2, $3)
|
|
||||||
ON CONFLICT (version) DO UPDATE SET applied_at = now(), success = $2, error = $3
|
|
||||||
`, version, success, errVal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status liefert den Migrationsstand eines einzelnen Tenants (Akzeptanzkriterium 3).
|
|
||||||
func (o *Orchestrator) Status(ctx context.Context, tenantSlug string) ([]MigrationStatus, error) {
|
|
||||||
t, err := o.registry.GetBySlug(ctx, tenantSlug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("tenant %q nicht gefunden: %w", tenantSlug, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := pgxpool.New(ctx, t.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("verbindung zu tenant %q: %w", tenantSlug, err)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
|
|
||||||
rows, err := pool.Query(ctx, `
|
|
||||||
SELECT version, applied_at, success, COALESCE(error, '')
|
|
||||||
FROM schema_migrations ORDER BY version
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("migrationsstand abfragen: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []MigrationStatus
|
|
||||||
for rows.Next() {
|
|
||||||
var s MigrationStatus
|
|
||||||
if err := rows.Scan(&s.Version, &s.AppliedAt, &s.Success, &s.Error); err != nil {
|
|
||||||
return nil, fmt.Errorf("migrationsstand lesen: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
package migrate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLoadMigrations_SortsByFilename(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeFile(t, dir, "0002_second.up.sql", "SELECT 2;")
|
|
||||||
writeFile(t, dir, "0001_first.up.sql", "SELECT 1;")
|
|
||||||
writeFile(t, dir, "0001_first.down.sql", "SELECT 'ignored';") // muss ignoriert werden
|
|
||||||
|
|
||||||
migrations, err := LoadMigrations(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load: %v", err)
|
|
||||||
}
|
|
||||||
if len(migrations) != 2 {
|
|
||||||
t.Fatalf("erwartet 2 migrationen, habe %d", len(migrations))
|
|
||||||
}
|
|
||||||
if migrations[0].Version != "0001_first" || migrations[1].Version != "0002_second" {
|
|
||||||
t.Fatalf("unerwartete reihenfolge: %+v", migrations)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeFile(t *testing.T, dir, name, content string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := os.WriteFile(dir+"/"+name, []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatalf("write %s: %v", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupOrchestratorTest(t *testing.T, tenantCount int) (*Orchestrator, []tenant.Tenant, *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()
|
|
||||||
|
|
||||||
adminPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("admin pool: %v", err)
|
|
||||||
}
|
|
||||||
registryPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("registry pool: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := registryPool.Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS tenants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
slug TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
db_name TEXT NOT NULL UNIQUE,
|
|
||||||
db_dsn TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("registry-schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
registry := tenant.NewRegistry(registryPool)
|
|
||||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
||||||
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
|
|
||||||
var tenants []tenant.Tenant
|
|
||||||
var slugs []string
|
|
||||||
for i := 0; i < tenantCount; i++ {
|
|
||||||
slug := fmt.Sprintf("mig_t%d", i)
|
|
||||||
slugs = append(slugs, slug)
|
|
||||||
tn, err := provisioner.Provision(ctx, slug, slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provision %s: %v", slug, err)
|
|
||||||
}
|
|
||||||
tenants = append(tenants, tn)
|
|
||||||
}
|
|
||||||
|
|
||||||
orchestrator := NewOrchestrator(registry)
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
for _, slug := range slugs {
|
|
||||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug))
|
|
||||||
}
|
|
||||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
|
|
||||||
registryPool.Close()
|
|
||||||
adminPool.Close()
|
|
||||||
}
|
|
||||||
return orchestrator, tenants, adminPool, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + 2 + Pruefung 1: Rollout gegen mehrere Tenants, einer
|
|
||||||
// davon absichtlich inkompatibel — die anderen laufen trotzdem durch.
|
|
||||||
func TestRolloutAll_IsolatesFailurePerTenant(t *testing.T) {
|
|
||||||
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 3)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
migrations := []Migration{
|
|
||||||
{Version: "0001_demo_a", SQL: "CREATE TABLE demo_a (id INT);"},
|
|
||||||
{Version: "0002_demo_b", SQL: "CREATE TABLE demo_b (id INT);"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// tenants[1] absichtlich inkompatibel machen: demo_b existiert schon,
|
|
||||||
// migration 0002 schlaegt dort mit "already exists" fehl.
|
|
||||||
badTenant := tenants[1]
|
|
||||||
badPool, err := pgxpool.New(ctx, badTenant.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connect bad tenant: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := badPool.Exec(ctx, "CREATE TABLE demo_b (id INT);"); err != nil {
|
|
||||||
t.Fatalf("inkompatiblen zustand vorbereiten: %v", err)
|
|
||||||
}
|
|
||||||
badPool.Close()
|
|
||||||
|
|
||||||
results, err := orchestrator.RolloutAll(ctx, migrations)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("rollout: %v", err)
|
|
||||||
}
|
|
||||||
if len(results) != 3 {
|
|
||||||
t.Fatalf("erwartet 3 ergebnisse, habe %d", len(results))
|
|
||||||
}
|
|
||||||
|
|
||||||
byslug := map[string]TenantResult{}
|
|
||||||
for _, r := range results {
|
|
||||||
byslug[r.TenantSlug] = r
|
|
||||||
}
|
|
||||||
|
|
||||||
good0 := byslug[tenants[0].Slug]
|
|
||||||
if good0.Err != nil || len(good0.Applied) != 2 {
|
|
||||||
t.Fatalf("tenant[0] sollte beide migrationen erhalten, habe %+v", good0)
|
|
||||||
}
|
|
||||||
good2 := byslug[tenants[2].Slug]
|
|
||||||
if good2.Err != nil || len(good2.Applied) != 2 {
|
|
||||||
t.Fatalf("tenant[2] sollte beide migrationen erhalten, habe %+v", good2)
|
|
||||||
}
|
|
||||||
|
|
||||||
bad := byslug[badTenant.Slug]
|
|
||||||
if bad.Err == nil {
|
|
||||||
t.Fatal("erwartet fehler fuer den inkompatiblen tenant")
|
|
||||||
}
|
|
||||||
if bad.FailedAt != "0002_demo_b" {
|
|
||||||
t.Fatalf("failedAt = %q, want 0002_demo_b", bad.FailedAt)
|
|
||||||
}
|
|
||||||
if len(bad.Applied) != 1 || bad.Applied[0] != "0001_demo_a" {
|
|
||||||
t.Fatalf("erwartet dass 0001_demo_a trotzdem erfolgreich war, habe %+v", bad.Applied)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 2: Migrationsstand-Abfrage liefert fuer
|
|
||||||
// jeden Tenant den korrekten, unabhaengigen Stand.
|
|
||||||
func TestStatus_ReflectsPerTenantState(t *testing.T) {
|
|
||||||
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
migrations := []Migration{
|
|
||||||
{Version: "0001_ok", SQL: "CREATE TABLE ok_table (id INT);"},
|
|
||||||
{Version: "0002_fail", SQL: "SELECT this_column_does_not_exist FROM ok_table;"},
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := orchestrator.RolloutAll(ctx, migrations); err != nil {
|
|
||||||
t.Fatalf("rollout: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
status, err := orchestrator.Status(ctx, tenants[0].Slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("status: %v", err)
|
|
||||||
}
|
|
||||||
if len(status) != 2 {
|
|
||||||
t.Fatalf("erwartet 2 status-eintraege, habe %d", len(status))
|
|
||||||
}
|
|
||||||
if !status[0].Success || status[0].Version != "0001_ok" {
|
|
||||||
t.Fatalf("status[0] unerwartet: %+v", status[0])
|
|
||||||
}
|
|
||||||
if status[1].Success || status[1].Version != "0002_fail" || status[1].Error == "" {
|
|
||||||
t.Fatalf("status[1] sollte fehlgeschlagen sein mit fehlertext: %+v", status[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pruefung 3: wiederholter Rollout-Versuch wendet bereits erfolgreiche
|
|
||||||
// Migrationen NICHT erneut an und kann die zuvor fehlgeschlagene nachholen,
|
|
||||||
// sobald die Ursache behoben ist.
|
|
||||||
func TestRolloutAll_RetryDoesNotReapplySuccessful(t *testing.T) {
|
|
||||||
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// 0002 schlaegt beim ersten Versuch fehl, weil demo_conflict schon
|
|
||||||
// existiert (wir legen sie vorher an, um den Fehlschlag zu erzwingen).
|
|
||||||
pool, err := pgxpool.New(ctx, tenants[0].DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connect: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := pool.Exec(ctx, "CREATE TABLE demo_conflict (id INT);"); err != nil {
|
|
||||||
t.Fatalf("vorbedingung: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
migrations := []Migration{
|
|
||||||
{Version: "0001_ok", SQL: "CREATE TABLE demo_first (id INT);"}, // OHNE IF NOT EXISTS,
|
|
||||||
// damit ein erneutes Anwenden nachweislich fehlschlagen wuerde.
|
|
||||||
{Version: "0002_conflict", SQL: "CREATE TABLE demo_conflict (id INT);"},
|
|
||||||
}
|
|
||||||
|
|
||||||
firstRun, err := orchestrator.RolloutAll(ctx, migrations)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("rollout 1: %v", err)
|
|
||||||
}
|
|
||||||
if firstRun[0].FailedAt != "0002_conflict" {
|
|
||||||
t.Fatalf("erwartet fehlschlag bei 0002_conflict im ersten lauf, habe %+v", firstRun[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ursache beheben.
|
|
||||||
if _, err := pool.Exec(ctx, "DROP TABLE demo_conflict;"); err != nil {
|
|
||||||
t.Fatalf("ursache beheben: %v", err)
|
|
||||||
}
|
|
||||||
pool.Close()
|
|
||||||
|
|
||||||
secondRun, err := orchestrator.RolloutAll(ctx, migrations)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("rollout 2: %v", err)
|
|
||||||
}
|
|
||||||
// Waere 0001_ok erneut angewendet worden ("CREATE TABLE demo_first" ohne
|
|
||||||
// IF NOT EXISTS), haette das einen Fehler erzeugt statt eines sauberen
|
|
||||||
// Applied-Eintrags fuer 0002_conflict.
|
|
||||||
if secondRun[0].Err != nil {
|
|
||||||
t.Fatalf("zweiter lauf sollte fehlerfrei sein, habe %+v", secondRun[0])
|
|
||||||
}
|
|
||||||
if len(secondRun[0].Applied) != 1 || secondRun[0].Applied[0] != "0002_conflict" {
|
|
||||||
t.Fatalf("erwartet nur 0002_conflict im zweiten lauf angewendet, habe %+v", secondRun[0].Applied)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package moduletrust
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StaleCache ist der generische Rechte-/Feature-Flag-Cache-Kontrakt
|
|
||||||
// (Akzeptanzkriterium 2): TTL-basiert, mit explizitem, benanntem Verhalten
|
|
||||||
// bei abgelaufenem Cache waehrend Core nicht erreichbar ist.
|
|
||||||
//
|
|
||||||
// - Get: FAIL-OPEN fuer Lesevorgaenge. Schlaegt der Refresh fehl, aber es
|
|
||||||
// gibt bereits einen (wenn auch abgelaufenen) Stand, wird dieser mit
|
|
||||||
// stale=true zurueckgegeben — Begruendung: ein bereits authentifiziertes
|
|
||||||
// Modul soll mit dem letztbekannten Stand weiterarbeiten koennen statt
|
|
||||||
// hart zu blockieren (siehe "Bekannte Fehler vermeiden" im Ticket).
|
|
||||||
// Existiert noch nie ein Stand, gibt es keinen sinnvollen Fallback —
|
|
||||||
// dann liefert auch Get einen Fehler.
|
|
||||||
// - RequireFresh: FAIL-CLOSED fuer sicherheitskritische Aktionen (z.B.
|
|
||||||
// ein komplett NEUER Login). Nutzt NIEMALS einen zwischengespeicherten
|
|
||||||
// Stand, ruft immer frisch ab — Begruendung: eine neue Vertrauens-
|
|
||||||
// entscheidung darf nicht auf veralteten Daten beruhen, auch wenn das
|
|
||||||
// bedeutet, dass die Aktion bei Core-Ausfall sichtbar fehlschlaegt statt
|
|
||||||
// unsicher "irgendwie" durchgelassen zu werden.
|
|
||||||
//
|
|
||||||
// LIC-02 (internal/flag.Service) implementiert bereits denselben Kontrakt
|
|
||||||
// fuer Feature-Flags — StaleCache verallgemeinert dasselbe Muster fuer
|
|
||||||
// JWT-Signaturschluessel, damit beide Faelle derselben dokumentierten
|
|
||||||
// Policy folgen.
|
|
||||||
type StaleCache[T any] struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
value T
|
|
||||||
hasValue bool
|
|
||||||
fetchedAt time.Time
|
|
||||||
ttl time.Duration
|
|
||||||
fetch func(ctx context.Context) (T, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStaleCache[T any](ttl time.Duration, fetch func(ctx context.Context) (T, error)) *StaleCache[T] {
|
|
||||||
return &StaleCache[T]{ttl: ttl, fetch: fetch}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get liefert den Cache-Wert. FAIL-OPEN: bei Refresh-Fehler wird ein
|
|
||||||
// vorhandener, ggf. abgelaufener Stand zurueckgegeben (stale=true).
|
|
||||||
func (c *StaleCache[T]) Get(ctx context.Context) (value T, stale bool, err error) {
|
|
||||||
c.mu.RLock()
|
|
||||||
fresh := c.hasValue && time.Since(c.fetchedAt) < c.ttl
|
|
||||||
if fresh {
|
|
||||||
v := c.value
|
|
||||||
c.mu.RUnlock()
|
|
||||||
return v, false, nil
|
|
||||||
}
|
|
||||||
c.mu.RUnlock()
|
|
||||||
|
|
||||||
newVal, fetchErr := c.fetch(ctx)
|
|
||||||
if fetchErr == nil {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.value, c.hasValue, c.fetchedAt = newVal, true, time.Now()
|
|
||||||
c.mu.Unlock()
|
|
||||||
return newVal, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
if c.hasValue {
|
|
||||||
return c.value, true, nil
|
|
||||||
}
|
|
||||||
var zero T
|
|
||||||
return zero, false, fmt.Errorf("cache leer und refresh fehlgeschlagen: %w", fetchErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireFresh ruft IMMER frisch ab (FAIL-CLOSED) — fuer sicherheitskritische
|
|
||||||
// Aktionen, die niemals auf einem zwischengespeicherten Stand basieren duerfen.
|
|
||||||
func (c *StaleCache[T]) RequireFresh(ctx context.Context) (T, error) {
|
|
||||||
v, err := c.fetch(ctx)
|
|
||||||
if err != nil {
|
|
||||||
var zero T
|
|
||||||
return zero, fmt.Errorf("core nicht erreichbar, sicherheitskritische aktion abgelehnt: %w", err)
|
|
||||||
}
|
|
||||||
c.mu.Lock()
|
|
||||||
c.value, c.hasValue, c.fetchedAt = v, true, time.Now()
|
|
||||||
c.mu.Unlock()
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package moduletrust
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Claims struct {
|
|
||||||
Subject string `json:"sub"`
|
|
||||||
TenantSlug string `json:"tenant"`
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue signiert ein Token mit dem aktuellen Signierschluessel und traegt
|
|
||||||
// dessen KID im JWT-Header ein — der Verifier auf Modulseite waehlt darueber
|
|
||||||
// den passenden oeffentlichen Schluessel aus PublicKeySet() aus.
|
|
||||||
func (m *KeyManager) Issue(subject, tenantSlug string, ttl time.Duration) (string, error) {
|
|
||||||
key, err := m.SigningKey()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
claims := Claims{
|
|
||||||
Subject: subject,
|
|
||||||
TenantSlug: tenantSlug,
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
|
||||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
|
||||||
token.Header["kid"] = key.KID
|
|
||||||
return token.SignedString(key.Private)
|
|
||||||
}
|
|
||||||
|
|
||||||
type jwksResponse struct {
|
|
||||||
Keys []jwksKey `json:"keys"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type jwksKey struct {
|
|
||||||
Kid string `json:"kid"`
|
|
||||||
PublicKey string `json:"public_key"` // base64 (raw Ed25519, 32 Byte)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeJWKS liefert alle bekannten oeffentlichen Schluessel als JSON —
|
|
||||||
// Module fragen dies periodisch ab (nicht pro Request), siehe Verifier.
|
|
||||||
func (m *KeyManager) ServeJWKS(w http.ResponseWriter, r *http.Request) {
|
|
||||||
set := m.PublicKeySet()
|
|
||||||
resp := jwksResponse{Keys: make([]jwksKey, 0, len(set))}
|
|
||||||
for kid, pub := range set {
|
|
||||||
resp.Keys = append(resp.Keys, jwksKey{Kid: kid, PublicKey: base64.StdEncoding.EncodeToString(pub)})
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseJWKS dekodiert die JSON-Antwort von ServeJWKS zurueck in kid->PublicKey
|
|
||||||
// — Hilfsfunktion fuer Module, die JWKS per HTTP abrufen.
|
|
||||||
func ParseJWKS(data []byte) (map[string][]byte, error) {
|
|
||||||
var resp jwksResponse
|
|
||||||
if err := json.Unmarshal(data, &resp); err != nil {
|
|
||||||
return nil, fmt.Errorf("jwks parsen: %w", err)
|
|
||||||
}
|
|
||||||
out := make(map[string][]byte, len(resp.Keys))
|
|
||||||
for _, k := range resp.Keys {
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(k.PublicKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("oeffentlichen schluessel %q dekodieren: %w", k.Kid, err)
|
|
||||||
}
|
|
||||||
out[k.Kid] = raw
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
// Package moduletrust implementiert Core API-05: asymmetrische JWT-Signatur
|
|
||||||
// mit Schluesselverteilung (JWKS), damit DMS/Mail/Archive/Workflow JWTs
|
|
||||||
// LOKAL verifizieren koennen, ohne pro Aufruf einen synchronen Request an
|
|
||||||
// Core zu stellen — Core darf Fundament sein, ohne zum Flaschenhals zu
|
|
||||||
// werden (siehe Entscheidungsverlauf "Vertrauensstellung Core<->Module" in
|
|
||||||
// nexarch-state.json). IAM-02s HS256-Session-Cookie (Browser-Login) bleibt
|
|
||||||
// unangetastet — dies ist ein zusaetzlicher, getrennter Vertrauensmechanismus
|
|
||||||
// fuer Modul-zu-Modul/Modul-zu-Core-Aufrufe.
|
|
||||||
package moduletrust
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/ed25519"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type KeyPair struct {
|
|
||||||
KID string
|
|
||||||
Private ed25519.PrivateKey
|
|
||||||
Public ed25519.PublicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// KeyManager haelt ALLE noch gueltigen Schluesselpaare — nicht nur das
|
|
||||||
// aktuell signierende. Rotate erzeugt ein neues Paar und behaelt die alten
|
|
||||||
// fuer die Verifikation bereits ausgestellter Tokens (Akzeptanzkriterium 3:
|
|
||||||
// Rotation ohne Ausfallzeit fuer andere Module).
|
|
||||||
type KeyManager struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
keys []KeyPair // aeltestes zuerst, neuestes zuletzt
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewKeyManager() (*KeyManager, error) {
|
|
||||||
m := &KeyManager{}
|
|
||||||
if _, err := m.Rotate(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotate erzeugt ein neues Ed25519-Schluesselpaar mit eigener KID und macht
|
|
||||||
// es zum aktuellen Signierschluessel. Aeltere Schluessel bleiben in
|
|
||||||
// PublicKeySet() erhalten, damit bereits ausgestellte Tokens weiterhin
|
|
||||||
// verifizierbar sind.
|
|
||||||
func (m *KeyManager) Rotate() (KeyPair, error) {
|
|
||||||
pub, priv, err := ed25519.GenerateKey(nil)
|
|
||||||
if err != nil {
|
|
||||||
return KeyPair{}, fmt.Errorf("schluesselpaar erzeugen: %w", err)
|
|
||||||
}
|
|
||||||
kidBytes := make([]byte, 8)
|
|
||||||
if _, err := rand.Read(kidBytes); err != nil {
|
|
||||||
return KeyPair{}, fmt.Errorf("kid erzeugen: %w", err)
|
|
||||||
}
|
|
||||||
kp := KeyPair{KID: hex.EncodeToString(kidBytes), Private: priv, Public: pub}
|
|
||||||
|
|
||||||
m.mu.Lock()
|
|
||||||
m.keys = append(m.keys, kp)
|
|
||||||
m.mu.Unlock()
|
|
||||||
return kp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SigningKey liefert den aktuellen (neuesten) Schluessel zum Signieren neuer Tokens.
|
|
||||||
func (m *KeyManager) SigningKey() (KeyPair, error) {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
if len(m.keys) == 0 {
|
|
||||||
return KeyPair{}, fmt.Errorf("moduletrust: kein schluessel vorhanden")
|
|
||||||
}
|
|
||||||
return m.keys[len(m.keys)-1], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PublicKeySet liefert ALLE bekannten oeffentlichen Schluessel (kid ->
|
|
||||||
// public key) — die Grundlage fuer den JWKS-Endpunkt.
|
|
||||||
func (m *KeyManager) PublicKeySet() map[string]ed25519.PublicKey {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
out := make(map[string]ed25519.PublicKey, len(m.keys))
|
|
||||||
for _, k := range m.keys {
|
|
||||||
out[k.KID] = k.Public
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
package moduletrust
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/ed25519"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestIssueAndVerify_RoundTrip(t *testing.T) {
|
|
||||||
km, err := NewKeyManager()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new key manager: %v", err)
|
|
||||||
}
|
|
||||||
token, err := km.Issue("user-1", "acme", time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
|
||||||
return km.PublicKeySet(), nil
|
|
||||||
})
|
|
||||||
claims, err := v.Verify(context.Background(), token)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("verify: %v", err)
|
|
||||||
}
|
|
||||||
if claims.Subject != "user-1" || claims.TenantSlug != "acme" {
|
|
||||||
t.Fatalf("claims unerwartet: %+v", claims)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1: Verifikation lokal, kein Request pro Aufruf.
|
|
||||||
func TestVerify_DoesNotFetchPerCall(t *testing.T) {
|
|
||||||
km, err := NewKeyManager()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new key manager: %v", err)
|
|
||||||
}
|
|
||||||
token, err := km.Issue("user-1", "acme", time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var mu sync.Mutex
|
|
||||||
fetchCalls := 0
|
|
||||||
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
|
||||||
mu.Lock()
|
|
||||||
fetchCalls++
|
|
||||||
mu.Unlock()
|
|
||||||
return km.PublicKeySet(), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
if _, err := v.Verify(context.Background(), token); err != nil {
|
|
||||||
t.Fatalf("verify %d: %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
if fetchCalls != 1 {
|
|
||||||
t.Fatalf("erwartet genau 1 fetch fuer 10 Verify-Aufrufe innerhalb der TTL, habe %d", fetchCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 1: Core simuliert nicht erreichbar,
|
|
||||||
// bereits authentifizierte Nutzer bleiben funktionsfaehig (Fail-Open mit
|
|
||||||
// letztbekanntem Schluesselstand).
|
|
||||||
func TestVerify_FailsOpenWhenCoreUnreachableButStaleKeysExist(t *testing.T) {
|
|
||||||
km, err := NewKeyManager()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new key manager: %v", err)
|
|
||||||
}
|
|
||||||
token, err := km.Issue("user-1", "acme", time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
coreDown := false
|
|
||||||
v := NewVerifier(30*time.Millisecond, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
|
||||||
if coreDown {
|
|
||||||
return nil, errors.New("core nicht erreichbar (simuliert)")
|
|
||||||
}
|
|
||||||
return km.PublicKeySet(), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cache vorwaermen, waehrend Core noch erreichbar ist.
|
|
||||||
if _, err := v.Verify(context.Background(), token); err != nil {
|
|
||||||
t.Fatalf("verify (warm): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Core abschalten" und TTL ablaufen lassen.
|
|
||||||
coreDown = true
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
if _, err := v.Verify(context.Background(), token); err != nil {
|
|
||||||
t.Fatalf("verify sollte trotz core-ausfall mit letztbekanntem stand funktionieren: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2: neue sicherheitskritische Aktionen
|
|
||||||
// (z.B. neuer Login) schlagen bei Core-Ausfall klar fehl statt unsicher
|
|
||||||
// durchgelassen zu werden — auch wenn ein (aelterer) Cache-Stand existiert.
|
|
||||||
func TestRequireFreshKeys_FailsClosedWhenCoreUnreachable(t *testing.T) {
|
|
||||||
km, err := NewKeyManager()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new key manager: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
coreDown := false
|
|
||||||
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
|
||||||
if coreDown {
|
|
||||||
return nil, errors.New("core nicht erreichbar (simuliert)")
|
|
||||||
}
|
|
||||||
return km.PublicKeySet(), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cache vorwaermen (existiert jetzt ein "veralteter" gueltiger Stand).
|
|
||||||
if _, _, err := v.cache.Get(context.Background()); err != nil {
|
|
||||||
t.Fatalf("warm cache: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
coreDown = true
|
|
||||||
if err := v.RequireFreshKeys(context.Background()); err == nil {
|
|
||||||
t.Fatal("erwartet fehler (fail-closed) bei core-ausfall, habe nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 3: Schluesselrotation ohne Ausfallzeit —
|
|
||||||
// ein bereits ausgestelltes Token bleibt nach Rotation weiterhin
|
|
||||||
// verifizierbar, ein zweites (simuliertes) Modul bekommt beide Schluessel.
|
|
||||||
func TestRotate_NoDowntimeForAlreadyIssuedTokens(t *testing.T) {
|
|
||||||
km, err := NewKeyManager()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new key manager: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
oldToken, err := km.Issue("user-1", "acme", time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue (alt): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := km.Rotate(); err != nil {
|
|
||||||
t.Fatalf("rotate: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
newToken, err := km.Issue("user-2", "acme", time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue (neu): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simuliertes zweites Modul: fragt den vollstaendigen Schluesselsatz ab.
|
|
||||||
moduleB := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
|
||||||
return km.PublicKeySet(), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if _, err := moduleB.Verify(context.Background(), oldToken); err != nil {
|
|
||||||
t.Fatalf("altes token sollte nach rotation weiterhin gueltig sein: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := moduleB.Verify(context.Background(), newToken); err != nil {
|
|
||||||
t.Fatalf("neues token sollte gueltig sein: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerify_RejectsUnknownKid(t *testing.T) {
|
|
||||||
km1, _ := NewKeyManager()
|
|
||||||
km2, _ := NewKeyManager() // komplett anderer, unbekannter schluessel
|
|
||||||
|
|
||||||
token, err := km1.Issue("user-1", "acme", time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
|
||||||
return km2.PublicKeySet(), nil // kennt km1s schluessel nicht
|
|
||||||
})
|
|
||||||
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrInvalidToken) {
|
|
||||||
t.Fatalf("erwartet ErrInvalidToken, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJWKSRoundTrip(t *testing.T) {
|
|
||||||
km, _ := NewKeyManager()
|
|
||||||
km.Rotate()
|
|
||||||
|
|
||||||
var buf []byte
|
|
||||||
rec := &captureWriter{}
|
|
||||||
km.ServeJWKS(rec, nil)
|
|
||||||
buf = rec.body
|
|
||||||
|
|
||||||
parsed, err := ParseJWKS(buf)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse jwks: %v", err)
|
|
||||||
}
|
|
||||||
if len(parsed) != len(km.PublicKeySet()) {
|
|
||||||
t.Fatalf("erwartet %d schluessel, habe %d", len(km.PublicKeySet()), len(parsed))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type captureWriter struct {
|
|
||||||
body []byte
|
|
||||||
header http.Header
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *captureWriter) Header() http.Header {
|
|
||||||
if w.header == nil {
|
|
||||||
w.header = http.Header{}
|
|
||||||
}
|
|
||||||
return w.header
|
|
||||||
}
|
|
||||||
func (w *captureWriter) Write(p []byte) (int, error) { w.body = append(w.body, p...); return len(p), nil }
|
|
||||||
func (w *captureWriter) WriteHeader(statusCode int) {}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package moduletrust
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/ed25519"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrInvalidToken = errors.New("moduletrust: ungueltiges token")
|
|
||||||
|
|
||||||
// KeyFetchFunc holt den aktuellen Schluesselsatz von Core (z.B. per HTTP-GET
|
|
||||||
// auf ServeJWKS + ParseJWKS). Wird vom Verifier nur bei abgelaufener TTL
|
|
||||||
// aufgerufen — NICHT bei jeder Verify()-Anfrage (Akzeptanzkriterium 1).
|
|
||||||
type KeyFetchFunc func(ctx context.Context) (map[string]ed25519.PublicKey, error)
|
|
||||||
|
|
||||||
// Verifier ist die Modulseite von API-05: verifiziert JWTs LOKAL gegen einen
|
|
||||||
// per StaleCache zwischengespeicherten Schluesselsatz, ohne pro Aufruf einen
|
|
||||||
// synchronen Request an Core zu stellen.
|
|
||||||
type Verifier struct {
|
|
||||||
cache *StaleCache[map[string]ed25519.PublicKey]
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewVerifier(ttl time.Duration, fetch KeyFetchFunc) *Verifier {
|
|
||||||
return &Verifier{cache: NewStaleCache(ttl, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
|
||||||
return fetch(ctx)
|
|
||||||
})}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify prueft die Signatur LOKAL gegen den (ggf. abgelaufenen, aber
|
|
||||||
// vorhandenen) Schluesselsatz — FAIL-OPEN fuer bereits ausgestellte Tokens
|
|
||||||
// (Akzeptanzkriterium 2): ist Core nicht erreichbar, aber ein alter
|
|
||||||
// Schluesselsatz bekannt, wird damit weiter verifiziert.
|
|
||||||
func (v *Verifier) Verify(ctx context.Context, tokenString string) (*Claims, error) {
|
|
||||||
keys, _, err := v.cache.Get(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
claims := &Claims{}
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok {
|
|
||||||
return nil, ErrInvalidToken
|
|
||||||
}
|
|
||||||
kid, _ := t.Header["kid"].(string)
|
|
||||||
pub, ok := keys[kid]
|
|
||||||
if !ok {
|
|
||||||
return nil, ErrInvalidToken
|
|
||||||
}
|
|
||||||
return pub, nil
|
|
||||||
})
|
|
||||||
if err != nil || !token.Valid {
|
|
||||||
return nil, ErrInvalidToken
|
|
||||||
}
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireFreshKeys ruft IMMER frisch von Core ab (FAIL-CLOSED) — fuer
|
|
||||||
// sicherheitskritische Aktionen wie einen komplett neuen Login
|
|
||||||
// (Akzeptanzkriterium 2): schlaegt klar fehl, wenn Core nicht erreichbar
|
|
||||||
// ist, statt auf einem veralteten Schluesselsatz zu vertrauen.
|
|
||||||
func (v *Verifier) RequireFreshKeys(ctx context.Context) error {
|
|
||||||
_, err := v.cache.RequireFresh(ctx)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package oidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuthCodeTTL folgt RFC 6749 Empfehlung: Authorization Codes sind extrem
|
|
||||||
// kurzlebig, der Client tauscht sie sofort gegen ein Token.
|
|
||||||
const AuthCodeTTL = 60 * time.Second
|
|
||||||
|
|
||||||
var ErrInvalidAuthCode = errors.New("oidc: ungueltiger, abgelaufener oder bereits verwendeter authorization code")
|
|
||||||
|
|
||||||
// AuthCodeData ist das Ergebnis eines eingeloesten Authorization Codes.
|
|
||||||
type AuthCodeData struct {
|
|
||||||
ClientID string
|
|
||||||
UserID string
|
|
||||||
RedirectURI string
|
|
||||||
Scopes []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthCodeStore verwaltet Authorization Codes innerhalb GENAU EINER Tenant-Datenbank.
|
|
||||||
type AuthCodeStore struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAuthCodeStore(pool *pgxpool.Pool) *AuthCodeStore {
|
|
||||||
return &AuthCodeStore{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue erzeugt einen neuen Authorization Code (Akzeptanzkriterium 2, Schritt
|
|
||||||
// 1 des Flows) — gespeichert wird nur der Hash, das Klartext-Code wird per
|
|
||||||
// Redirect an den Client uebertragen (Standard-OAuth2-Verhalten, der Code
|
|
||||||
// selbst ist einmalig und kurzlebig genug, dass die Redirect-URL kein
|
|
||||||
// nennenswertes Risiko darstellt).
|
|
||||||
func (s *AuthCodeStore) Issue(ctx context.Context, clientID, userID, redirectURI string, scopes []string) (code string, err error) {
|
|
||||||
code, err = randomToken(32)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("code erzeugen: %w", err)
|
|
||||||
}
|
|
||||||
hash := hashSecret(code)
|
|
||||||
expiresAt := time.Now().Add(AuthCodeTTL)
|
|
||||||
|
|
||||||
_, err = s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO oidc_auth_codes (code_hash, client_id, user_id, redirect_uri, scopes, expires_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
`, hash, clientID, userID, redirectURI, scopes, expiresAt)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("code speichern: %w", err)
|
|
||||||
}
|
|
||||||
return code, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consume loest einen Authorization Code genau einmal ein (Akzeptanzkriterium
|
|
||||||
// 2, Schritt 2 des Flows) — atomar ueber die WHERE-Klausel (used_at IS NULL
|
|
||||||
// AND expires_at > now()), gleiches Muster wie internal/authtoken (IAM-03).
|
|
||||||
func (s *AuthCodeStore) Consume(ctx context.Context, code, clientID, redirectURI string) (AuthCodeData, error) {
|
|
||||||
hash := hashSecret(code)
|
|
||||||
|
|
||||||
var data AuthCodeData
|
|
||||||
var storedClientID, storedRedirectURI string
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
UPDATE oidc_auth_codes
|
|
||||||
SET used_at = now()
|
|
||||||
WHERE code_hash = $1 AND used_at IS NULL AND expires_at > now()
|
|
||||||
RETURNING client_id, user_id, redirect_uri, scopes
|
|
||||||
`, hash)
|
|
||||||
|
|
||||||
if err := row.Scan(&storedClientID, &data.UserID, &storedRedirectURI, &data.Scopes); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return AuthCodeData{}, ErrInvalidAuthCode
|
|
||||||
}
|
|
||||||
return AuthCodeData{}, fmt.Errorf("code einloesen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// client_id und redirect_uri muessen exakt zu denen des urspruenglichen
|
|
||||||
// Authorize-Aufrufs passen (RFC 6749 4.1.3) — sonst koennte ein Code, der
|
|
||||||
// fuer Client A ausgestellt wurde, bei Client B eingeloest werden.
|
|
||||||
if storedClientID != clientID || storedRedirectURI != redirectURI {
|
|
||||||
return AuthCodeData{}, ErrInvalidAuthCode
|
|
||||||
}
|
|
||||||
|
|
||||||
data.ClientID = storedClientID
|
|
||||||
data.RedirectURI = storedRedirectURI
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
// Package oidc implementiert Core IAM-13: NEXARCH als OIDC/OAuth2-Provider,
|
|
||||||
// damit Drittanwendungen eines Mandanten sich gegen NEXARCH-Benutzerkonten
|
|
||||||
// authentifizieren koennen. Baut bewusst auf der bestehenden JWKS-
|
|
||||||
// Infrastruktur (internal/moduletrust, API-05) auf statt ein zweites
|
|
||||||
// Schluesselsystem zu bauen — siehe provider.go.
|
|
||||||
package oidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrClientNotFound = errors.New("oidc: client nicht gefunden")
|
|
||||||
ErrInvalidClientAuth = errors.New("oidc: ungueltige client-authentifizierung")
|
|
||||||
ErrRedirectURINotAllowed = errors.New("oidc: redirect_uri ist fuer diesen client nicht registriert")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client ist eine bei NEXARCH registrierte Drittanwendung
|
|
||||||
// (Akzeptanzkriterium 1). AllowedScopes begrenzt, welche Scopes ein Token
|
|
||||||
// fuer diesen Client tragen darf — unabhaengig davon, was beim Authorize-
|
|
||||||
// Aufruf angefragt wird (Akzeptanzkriterium 3 / Pruefung 3).
|
|
||||||
type Client struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
ClientID string
|
|
||||||
RedirectURIs []string
|
|
||||||
AllowedScopes []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClientStore verwaltet OAuth2-Clients innerhalb GENAU EINER Tenant-
|
|
||||||
// Datenbank (gleiches Muster wie internal/user.TenantUserStore, IAM-03).
|
|
||||||
type ClientStore struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClientStore(pool *pgxpool.Pool) *ClientStore {
|
|
||||||
return &ClientStore{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register legt einen neuen OAuth2-Client an (Akzeptanzkriterium 1). Das
|
|
||||||
// Client-Secret wird NUR hier im Klartext zurueckgegeben, gespeichert wird
|
|
||||||
// ausschliesslich dessen SHA-256-Hash — gleiches Muster wie
|
|
||||||
// internal/moduleregistry.Provision (API-02).
|
|
||||||
func (s *ClientStore) Register(ctx context.Context, name string, redirectURIs, allowedScopes []string) (clientID, clientSecret string, err error) {
|
|
||||||
if name == "" {
|
|
||||||
return "", "", errors.New("oidc: name darf nicht leer sein")
|
|
||||||
}
|
|
||||||
if len(redirectURIs) == 0 {
|
|
||||||
return "", "", errors.New("oidc: mindestens eine redirect_uri ist erforderlich")
|
|
||||||
}
|
|
||||||
|
|
||||||
clientID, err = randomToken(16)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("client-id erzeugen: %w", err)
|
|
||||||
}
|
|
||||||
clientSecret, err = randomToken(32)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("client-secret erzeugen: %w", err)
|
|
||||||
}
|
|
||||||
hash := hashSecret(clientSecret)
|
|
||||||
|
|
||||||
_, err = s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO oidc_clients (name, client_id, secret_hash, redirect_uris, allowed_scopes)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
`, name, clientID, hash, redirectURIs, allowedScopes)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("client speichern: %w", err)
|
|
||||||
}
|
|
||||||
return clientID, clientSecret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ClientStore) getByClientID(ctx context.Context, clientID string) (Client, []byte, error) {
|
|
||||||
var c Client
|
|
||||||
var storedHash []byte
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT id, name, client_id, redirect_uris, allowed_scopes, secret_hash
|
|
||||||
FROM oidc_clients WHERE client_id = $1
|
|
||||||
`, clientID)
|
|
||||||
if err := row.Scan(&c.ID, &c.Name, &c.ClientID, &c.RedirectURIs, &c.AllowedScopes, &storedHash); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Client{}, nil, ErrClientNotFound
|
|
||||||
}
|
|
||||||
return Client{}, nil, fmt.Errorf("client laden: %w", err)
|
|
||||||
}
|
|
||||||
return c, storedHash, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get laedt einen Client ohne Secret-Pruefung (fuer den Authorize-Schritt,
|
|
||||||
// der kein Client-Secret kennt — der Browser des Endnutzers, nicht die
|
|
||||||
// Drittanwendung selbst, ruft /oidc/authorize auf).
|
|
||||||
func (s *ClientStore) Get(ctx context.Context, clientID string) (Client, error) {
|
|
||||||
c, _, err := s.getByClientID(ctx, clientID)
|
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticate prueft Client-ID + Secret timing-safe (Akzeptanzkriterium 2,
|
|
||||||
// Token-Endpunkt) — gleiches Muster wie internal/moduleregistry.Authenticate (API-02).
|
|
||||||
func (s *ClientStore) Authenticate(ctx context.Context, clientID, clientSecret string) (Client, error) {
|
|
||||||
c, storedHash, err := s.getByClientID(ctx, clientID)
|
|
||||||
if err != nil {
|
|
||||||
return Client{}, err
|
|
||||||
}
|
|
||||||
if !timingSafeEqual(hashSecret(clientSecret), storedHash) {
|
|
||||||
return Client{}, ErrInvalidClientAuth
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateRedirectURI erzwingt exakten Abgleich gegen die registrierte
|
|
||||||
// Liste (kein Praefix-/Wildcard-Match) — Standardhaertung gegen Open-Redirect
|
|
||||||
// im Authorization-Code-Flow.
|
|
||||||
func (c Client) ValidateRedirectURI(redirectURI string) error {
|
|
||||||
for _, allowed := range c.RedirectURIs {
|
|
||||||
if allowed == redirectURI {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ErrRedirectURINotAllowed
|
|
||||||
}
|
|
||||||
|
|
||||||
// GrantedScopes schraenkt die angefragten Scopes auf das ein, was fuer den
|
|
||||||
// Client tatsaechlich erlaubt ist (Akzeptanzkriterium 3) — nie mehr
|
|
||||||
// gewaehren, als bei der Registrierung zugestanden wurde.
|
|
||||||
func (c Client) GrantedScopes(requested []string) []string {
|
|
||||||
allowed := make(map[string]bool, len(c.AllowedScopes))
|
|
||||||
for _, s := range c.AllowedScopes {
|
|
||||||
allowed[s] = true
|
|
||||||
}
|
|
||||||
var granted []string
|
|
||||||
for _, s := range requested {
|
|
||||||
if allowed[s] {
|
|
||||||
granted = append(granted, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return granted
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomToken(n int) (string, error) {
|
|
||||||
buf := make([]byte, n)
|
|
||||||
if _, err := rand.Read(buf); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(buf), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hashSecret(secret string) []byte {
|
|
||||||
sum := sha256.Sum256([]byte(secret))
|
|
||||||
return sum[:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// timingSafeEqual folgt demselben Referenzmuster wie internal/moduleregistry
|
|
||||||
// (API-02) — projektweite Konvention aus SICHERHEITSKONZEPT.md / IAM-15.
|
|
||||||
func timingSafeEqual(a, b []byte) bool {
|
|
||||||
if len(a) != len(b) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return subtle.ConstantTimeCompare(a, b) == 1
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// Package oidc implementiert Core IAM-06: SSO-Anmeldung ueber OpenID
|
|
||||||
// Connect gegen externe Identity-Provider.
|
|
||||||
//
|
|
||||||
// WICHTIGER HINWEIS: In dieser Umgebung ist kein registrierter externer
|
|
||||||
// OIDC-Provider (Client-ID/Secret bei Google/Okta/Azure AD o.ae.) verfuegbar,
|
|
||||||
// gegen den ein echter Authorization-Code-Redirect-Flow end-to-end
|
|
||||||
// durchgefuehrt werden koennte (analog AUD-05/Archive, IAM-05/LDAP). ANDERS
|
|
||||||
// als bei LDAP/Archive ist die eigentliche Token-VALIDIERUNG (JWKS-
|
|
||||||
// Signaturpruefung, Ablauf, Claims, Nonce/State) aber ein rein
|
|
||||||
// kryptographischer Vorgang ohne Netzwerkabhaengigkeit zur Testzeit — die
|
|
||||||
// Tests unten erzeugen ein eigenes RSA-Schluesselpaar, signieren ein
|
|
||||||
// ID-Token selbst und pruefen es exakt so, wie es ein echter Provider
|
|
||||||
// liefern wuerde. Nur der Redirect zu einem echten externen Provider bleibt
|
|
||||||
// ungeprueft.
|
|
||||||
package oidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rsa"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
)
|
|
||||||
|
|
||||||
type jwk struct {
|
|
||||||
Kid string `json:"kid"`
|
|
||||||
Kty string `json:"kty"`
|
|
||||||
N string `json:"n"`
|
|
||||||
E string `json:"e"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type jwkSet struct {
|
|
||||||
Keys []jwk `json:"keys"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var ErrUnsupportedKeyType = errors.New("oidc: nicht unterstuetzter jwk-schluesseltyp")
|
|
||||||
|
|
||||||
// ParseJWKS dekodiert eine JWKS-Antwort (RFC 7517) in kid -> RSA-Public-Key.
|
|
||||||
// Nur RSA-Schluessel (kty=RSA, Standard bei allen gaengigen OIDC-Providern
|
|
||||||
// fuer ID-Tokens) werden unterstuetzt.
|
|
||||||
func ParseJWKS(data []byte) (map[string]*rsa.PublicKey, error) {
|
|
||||||
var set jwkSet
|
|
||||||
if err := json.Unmarshal(data, &set); err != nil {
|
|
||||||
return nil, fmt.Errorf("jwks parsen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make(map[string]*rsa.PublicKey, len(set.Keys))
|
|
||||||
for _, k := range set.Keys {
|
|
||||||
if k.Kty != "RSA" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pub, err := decodeRSAKey(k)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("schluessel %q dekodieren: %w", k.Kid, err)
|
|
||||||
}
|
|
||||||
out[k.Kid] = pub
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeRSAKey(k jwk) (*rsa.PublicKey, error) {
|
|
||||||
nBytes, err := base64.RawURLEncoding.DecodeString(k.N)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("modulus dekodieren: %w", err)
|
|
||||||
}
|
|
||||||
eBytes, err := base64.RawURLEncoding.DecodeString(k.E)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("exponent dekodieren: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
eBuf := make([]byte, 8)
|
|
||||||
copy(eBuf[8-len(eBytes):], eBytes)
|
|
||||||
e := int(binary.BigEndian.Uint64(eBuf))
|
|
||||||
|
|
||||||
return &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e}, nil
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package oidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CompleteOIDCLogin verifiziert das ID-Token (Signatur, Ablauf, Aussteller,
|
|
||||||
// Nonce — Akzeptanzkriterium 3), loest den State einmalig ein
|
|
||||||
// (Akzeptanzkriterium/Pruefung 3), legt bei Bedarf einen lokalen Benutzer an
|
|
||||||
// und mapped Rollen aus den OIDC-Claims ueber DIESELBE Erlaubnis-Matrix wie
|
|
||||||
// IAM-05/LDAP (ldapsync.RoleMappingStore — kein zweiter, paralleler
|
|
||||||
// Mapping-Mechanismus, Akzeptanzkriterium 2). Bei Erfolg wird ein normales
|
|
||||||
// IAM-02-Sitzungs-Token ausgestellt.
|
|
||||||
func CompleteOIDCLogin(
|
|
||||||
ctx context.Context,
|
|
||||||
verifier *Verifier,
|
|
||||||
states *StateStore,
|
|
||||||
users *user.TenantUserStore,
|
|
||||||
roles *rbac.Store,
|
|
||||||
roleMapping *ldapsync.RoleMappingStore,
|
|
||||||
issuer *auth.TokenIssuer,
|
|
||||||
tenantSlug, state, idToken string,
|
|
||||||
) (token string, err error) {
|
|
||||||
nonce, err := states.Consume(ctx, state)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, err := verifier.VerifyWithNonce(idToken, nonce)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if claims.Email == "" {
|
|
||||||
return "", fmt.Errorf("%w: id-token ohne e-mail-claim", ErrInvalidToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := findOrCreateUser(ctx, users, claims.Email)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if role, ok, err := roleMapping.HighestRoleFor(ctx, claims.Roles); err != nil {
|
|
||||||
return "", err
|
|
||||||
} else if ok {
|
|
||||||
if _, err := roles.Assign(ctx, u.ID, role, "oidc-sso"); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return issuer.Issue(u.ID, tenantSlug)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findOrCreateUser(ctx context.Context, users *user.TenantUserStore, email string) (user.User, error) {
|
|
||||||
creds, err := users.GetByEmailForAuth(ctx, email)
|
|
||||||
if err == nil {
|
|
||||||
return creds.User, nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, user.ErrNotFound) {
|
|
||||||
return user.User{}, err
|
|
||||||
}
|
|
||||||
return users.Create(ctx, email, email)
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
package oidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rsa"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupLoginTest(t *testing.T) (*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 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 role_assignments (
|
|
||||||
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
|
|
||||||
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS role_assignment_history (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL,
|
|
||||||
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
|
|
||||||
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS oidc_states (
|
|
||||||
state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := func() { pool.Close() }
|
|
||||||
return pool, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + 3: End-to-End-Login gegen ein (selbst signiertes,
|
|
||||||
// aber protokollkorrektes) ID-Token — Signatur, Nonce, State, Rollen-Mapping,
|
|
||||||
// Sitzungs-Token-Ausstellung.
|
|
||||||
func TestCompleteOIDCLogin_EndToEnd(t *testing.T) {
|
|
||||||
pool, cleanup := setupLoginTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
key := generateTestKey(t)
|
|
||||||
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
|
|
||||||
states := NewStateStore(pool)
|
|
||||||
users := user.NewTenantUserStore(pool)
|
|
||||||
roles := rbac.NewStore(pool)
|
|
||||||
roleMapping := ldapsync.NewRoleMappingStore(pool)
|
|
||||||
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
|
||||||
|
|
||||||
if err := roleMapping.SetMapping(ctx, "sso-admins", rbac.RoleTenantAdmin); err != nil {
|
|
||||||
t.Fatalf("set mapping: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
state, nonce, err := states.Generate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate state: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
email := fmt.Sprintf("oidc-test-%d@example.com", time.Now().UnixNano())
|
|
||||||
claims := Claims{
|
|
||||||
Subject: "idp-subject-123",
|
|
||||||
Email: email,
|
|
||||||
Nonce: nonce,
|
|
||||||
Roles: []string{"sso-admins"},
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Issuer: "https://idp.example.com",
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
idToken := signTestToken(t, key, "kid-1", claims)
|
|
||||||
|
|
||||||
token, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("complete oidc login: %v", err)
|
|
||||||
}
|
|
||||||
if token == "" {
|
|
||||||
t.Fatal("erwartet nicht-leeres sitzungs-token")
|
|
||||||
}
|
|
||||||
|
|
||||||
assignment, err := roles.Get(ctx, mustGetUserID(t, ctx, users, email))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("rollenzuweisung lesen: %v", err)
|
|
||||||
}
|
|
||||||
if assignment.Role != rbac.RoleTenantAdmin {
|
|
||||||
t.Fatalf("erwartet tenant_admin aus sso-admins-mapping, habe %q", assignment.Role)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replay desselben state schlaegt fehl.
|
|
||||||
if _, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken); err == nil {
|
|
||||||
t.Fatal("erwartet fehler bei wiederverwendetem state (replay)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 1: keine Privilege-Escalation ueber
|
|
||||||
// manipulierte/unbekannte Rollen-Claims.
|
|
||||||
func TestCompleteOIDCLogin_UnmappedRoleGrantsNothing(t *testing.T) {
|
|
||||||
pool, cleanup := setupLoginTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
key := generateTestKey(t)
|
|
||||||
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
|
|
||||||
states := NewStateStore(pool)
|
|
||||||
users := user.NewTenantUserStore(pool)
|
|
||||||
roles := rbac.NewStore(pool)
|
|
||||||
roleMapping := ldapsync.NewRoleMappingStore(pool)
|
|
||||||
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
|
||||||
|
|
||||||
state, nonce, err := states.Generate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate state: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
email := fmt.Sprintf("oidc-test-%d@example.com", time.Now().UnixNano())
|
|
||||||
claims := Claims{
|
|
||||||
Email: email,
|
|
||||||
Nonce: nonce,
|
|
||||||
// Versuch, sich ueber einen manipulierten Rollen-Claim
|
|
||||||
// hochzustufen — diese Gruppen wurden NIE gemappt.
|
|
||||||
Roles: []string{"tenant_admin", "superadmin"},
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Issuer: "https://idp.example.com",
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
idToken := signTestToken(t, key, "kid-1", claims)
|
|
||||||
|
|
||||||
if _, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken); err != nil {
|
|
||||||
t.Fatalf("complete oidc login: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := mustGetUserID(t, ctx, users, email)
|
|
||||||
if _, err := roles.Get(ctx, userID); err == nil {
|
|
||||||
t.Fatal("erwartet KEINE rollenzuweisung fuer nicht gemappte claims (privilege-escalation-versuch)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustGetUserID(t *testing.T, ctx context.Context, users *user.TenantUserStore, email string) string {
|
|
||||||
t.Helper()
|
|
||||||
creds, err := users.GetByEmailForAuth(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get user by email: %v", err)
|
|
||||||
}
|
|
||||||
return creds.User.ID
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
package oidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IDTokenTTL: kurzlebig wie ein normales Access-Token (Produkt-DNA "so
|
|
||||||
// vertrauenswuerdig wie noetig"), ein Refresh-Flow ist ausdruecklich nicht
|
|
||||||
// Teil dieser Kachel (kleinste Loesung, die die Akzeptanzkriterien erfuellt).
|
|
||||||
const IDTokenTTL = 15 * time.Minute
|
|
||||||
|
|
||||||
// IDTokenClaims sind die vom Client verifizierbaren Angaben ueber den
|
|
||||||
// angemeldeten Benutzer — signiert mit demselben Schluesselmaterial wie
|
|
||||||
// Modul-zu-Modul-Tokens (internal/moduletrust, API-05), aber mit eigener,
|
|
||||||
// OIDC-spezifischer Struktur (aud, scope), da moduletrust.Claims dafuer
|
|
||||||
// nicht vorgesehen ist. Kein zweites Schluesselsystem — nur eine zweite
|
|
||||||
// Claims-Form desselben Signierschluessels.
|
|
||||||
type IDTokenClaims struct {
|
|
||||||
Subject string `json:"sub"`
|
|
||||||
TenantSlug string `json:"tenant"`
|
|
||||||
Scopes []string `json:"scope"`
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handler buendelt die drei OIDC-Endpunkte. issuerURL identifiziert NEXARCH
|
|
||||||
// als Aussteller (iss-Claim, Standard-OIDC-Feld).
|
|
||||||
type Handler struct {
|
|
||||||
clients *ClientStore
|
|
||||||
authCodes *AuthCodeStore
|
|
||||||
keys *moduletrust.KeyManager
|
|
||||||
issuerURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHandler(clients *ClientStore, authCodes *AuthCodeStore, keys *moduletrust.KeyManager, issuerURL string) *Handler {
|
|
||||||
return &Handler{clients: clients, authCodes: authCodes, keys: keys, issuerURL: issuerURL}
|
|
||||||
}
|
|
||||||
|
|
||||||
type registerClientRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
RedirectURIs []string `json:"redirect_uris"`
|
|
||||||
AllowedScopes []string `json:"allowed_scopes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type registerClientResponse struct {
|
|
||||||
ClientID string `json:"client_id"`
|
|
||||||
ClientSecret string `json:"client_secret"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterClient ist Akzeptanzkriterium 1: eine Drittanwendung wird als
|
|
||||||
// OAuth2-Client registriert. Muss hinter auth.RequireAuth haengen — nur
|
|
||||||
// angemeldete Benutzer des jeweiligen Mandanten duerfen Clients fuer ihren
|
|
||||||
// eigenen Mandanten anlegen (ClientStore ist bereits auf die Tenant-DB des
|
|
||||||
// Aufrufers gebunden, siehe NewHandler-Aufstellung im jeweiligen Modul-Server).
|
|
||||||
func (h *Handler) RegisterClient(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if _, ok := auth.ClaimsFromContext(r.Context()); !ok {
|
|
||||||
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req registerClientRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "ungueltiger request-body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clientID, clientSecret, err := h.clients.Register(r.Context(), req.Name, req.RedirectURIs, req.AllowedScopes)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_ = json.NewEncoder(w).Encode(registerClientResponse{ClientID: clientID, ClientSecret: clientSecret})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authorize ist Schritt 1 des Authorization-Code-Flows (Akzeptanzkriterium 2).
|
|
||||||
// Muss hinter auth.RequireAuth haengen — der aufrufende Browser ist bereits
|
|
||||||
// als NEXARCH-Benutzer angemeldet, hier wird nur noch der Client geprueft
|
|
||||||
// und ein Code fuer GENAU DIESEN Benutzer ausgestellt.
|
|
||||||
func (h *Handler) Authorize(w http.ResponseWriter, r *http.Request) {
|
|
||||||
claims, ok := auth.ClaimsFromContext(r.Context())
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
q := r.URL.Query()
|
|
||||||
if q.Get("response_type") != "code" {
|
|
||||||
http.Error(w, "nur response_type=code wird unterstuetzt", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clientID := q.Get("client_id")
|
|
||||||
redirectURI := q.Get("redirect_uri")
|
|
||||||
state := q.Get("state")
|
|
||||||
requestedScopes := splitScopes(q.Get("scope"))
|
|
||||||
|
|
||||||
client, err := h.clients.Get(r.Context(), clientID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "unbekannter client", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := client.ValidateRedirectURI(redirectURI); err != nil {
|
|
||||||
// Bewusst KEIN Redirect zu einer nicht registrierten redirect_uri —
|
|
||||||
// Open-Redirect-Schutz geht vor Komfort, Fehler wird direkt angezeigt.
|
|
||||||
http.Error(w, "redirect_uri ist fuer diesen client nicht registriert", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
granted := client.GrantedScopes(requestedScopes)
|
|
||||||
|
|
||||||
code, err := h.authCodes.Issue(r.Context(), client.ClientID, claims.UserID, redirectURI, granted)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "code konnte nicht ausgestellt werden", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
target, err := url.Parse(redirectURI)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "ungueltige redirect_uri", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
values := target.Query()
|
|
||||||
values.Set("code", code)
|
|
||||||
if state != "" {
|
|
||||||
values.Set("state", state)
|
|
||||||
}
|
|
||||||
target.RawQuery = values.Encode()
|
|
||||||
|
|
||||||
http.Redirect(w, r, target.String(), http.StatusFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token ist Schritt 2 des Authorization-Code-Flows (Akzeptanzkriterium 2):
|
|
||||||
// die Drittanwendung tauscht den Code serverseitig gegen ein ID-Token.
|
|
||||||
func (h *Handler) Token(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "ungueltiger request-body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.PostForm.Get("grant_type") != "authorization_code" {
|
|
||||||
http.Error(w, "nur grant_type=authorization_code wird unterstuetzt", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clientID := r.PostForm.Get("client_id")
|
|
||||||
clientSecret := r.PostForm.Get("client_secret")
|
|
||||||
code := r.PostForm.Get("code")
|
|
||||||
redirectURI := r.PostForm.Get("redirect_uri")
|
|
||||||
|
|
||||||
client, err := h.clients.Authenticate(r.Context(), clientID, clientSecret)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "client-authentifizierung fehlgeschlagen", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := h.authCodes.Consume(r.Context(), code, client.ClientID, redirectURI)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "authorization code ungueltig, abgelaufen oder bereits verwendet", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
idToken, err := h.issueIDToken(data)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "token konnte nicht ausgestellt werden", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"id_token": idToken,
|
|
||||||
"access_token": idToken, // kein separates Access-Token-Format in dieser Kachel — das ID-Token traegt bereits Subject+Scopes.
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": int(IDTokenTTL.Seconds()),
|
|
||||||
"scope": strings.Join(data.Scopes, " "),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) issueIDToken(data AuthCodeData) (string, error) {
|
|
||||||
key, err := h.keys.SigningKey()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
claims := IDTokenClaims{
|
|
||||||
Subject: data.UserID,
|
|
||||||
Scopes: data.Scopes,
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Issuer: h.issuerURL,
|
|
||||||
Audience: jwt.ClaimStrings{data.ClientID},
|
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
|
||||||
ExpiresAt: jwt.NewNumericDate(now.Add(IDTokenTTL)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
|
||||||
token.Header["kid"] = key.KID
|
|
||||||
return token.SignedString(key.Private)
|
|
||||||
}
|
|
||||||
|
|
||||||
// idpJWK / idpJWKSet folgen RFC 8037 (Ed25519 als OKP-Schluesseltyp) — im
|
|
||||||
// Unterschied zu moduletrust.ServeJWKS (internes, vereinfachtes Format fuer
|
|
||||||
// Modul-zu-Modul-Kommunikation) muss dieser Endpunkt von generischen,
|
|
||||||
// standardkonformen OIDC-Client-Bibliotheken Dritter lesbar sein
|
|
||||||
// (Akzeptanzkriterium 3). Beide Endpunkte liefern dieselben Schluessel aus
|
|
||||||
// demselben KeyManager — kein zweites Schluesselsystem, nur ein zweites,
|
|
||||||
// spezifikationskonformes Format desselben Materials.
|
|
||||||
//
|
|
||||||
// Eigener Typname (statt jwk/jwkSet wie in jwks.go), da jwks.go das RSA-
|
|
||||||
// basierte JWKS-Format fuer die Verifikation EXTERNER OIDC-Provider
|
|
||||||
// implementiert (IAM-12) — dieser Typ hier ist Core als eigener IdP
|
|
||||||
// (IAM-13, Ed25519/OKP) und darf nicht verwechselt werden.
|
|
||||||
type idpJWK struct {
|
|
||||||
Kty string `json:"kty"`
|
|
||||||
Crv string `json:"crv"`
|
|
||||||
Kid string `json:"kid"`
|
|
||||||
X string `json:"x"`
|
|
||||||
Use string `json:"use"`
|
|
||||||
Alg string `json:"alg"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type idpJWKSet struct {
|
|
||||||
Keys []idpJWK `json:"keys"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) JWKS(w http.ResponseWriter, r *http.Request) {
|
|
||||||
set := h.keys.PublicKeySet()
|
|
||||||
resp := idpJWKSet{Keys: make([]idpJWK, 0, len(set))}
|
|
||||||
for kid, pub := range set {
|
|
||||||
resp.Keys = append(resp.Keys, idpJWK{
|
|
||||||
Kty: "OKP",
|
|
||||||
Crv: "Ed25519",
|
|
||||||
Kid: kid,
|
|
||||||
X: base64.RawURLEncoding.EncodeToString(pub),
|
|
||||||
Use: "sig",
|
|
||||||
Alg: "EdDSA",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitScopes(raw string) []string {
|
|
||||||
if raw == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return strings.Fields(raw)
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
package oidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/ed25519"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newProviderTestSetup baut Tenant-DB-Schema (users, oidc_clients,
|
|
||||||
// oidc_auth_codes) direkt gegen TEST_ADMIN_DSN auf, analog zum Muster in
|
|
||||||
// internal/tenant/lifecycle_test.go — braucht kein volles Tenant-Provisioning,
|
|
||||||
// nur eine isolierte Datenbank fuer diesen Testlauf.
|
|
||||||
func newProviderTestSetup(t *testing.T) (*Handler, *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 EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
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',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS oidc_clients (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
client_id TEXT NOT NULL UNIQUE,
|
|
||||||
secret_hash BYTEA NOT NULL,
|
|
||||||
redirect_uris TEXT[] NOT NULL,
|
|
||||||
allowed_scopes TEXT[] NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS oidc_auth_codes (
|
|
||||||
code_hash BYTEA PRIMARY KEY,
|
|
||||||
client_id TEXT NOT NULL REFERENCES oidc_clients(client_id),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
|
||||||
redirect_uri TEXT NOT NULL,
|
|
||||||
scopes TEXT[] NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
used_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
keys, err := moduletrust.NewKeyManager()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("keymanager: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
clients := NewClientStore(pool)
|
|
||||||
authCodes := NewAuthCodeStore(pool)
|
|
||||||
handler := NewHandler(clients, authCodes, keys, "https://core.test.nexarch.example")
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
ctx := context.Background()
|
|
||||||
_, _ = pool.Exec(ctx, `DROP TABLE IF EXISTS oidc_auth_codes, oidc_clients, users CASCADE`)
|
|
||||||
pool.Close()
|
|
||||||
}
|
|
||||||
return handler, pool, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTestUser(t *testing.T, pool *pgxpool.Pool, email string) string {
|
|
||||||
t.Helper()
|
|
||||||
var id string
|
|
||||||
err := pool.QueryRow(context.Background(), `
|
|
||||||
INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id
|
|
||||||
`, email, "Test Nutzer").Scan(&id)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("testnutzer anlegen: %v", err)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 (Client-Registrierung) + 2 (voller Flow) + 3 (JWKS-
|
|
||||||
// Verifikation, Scope-Einschraenkung). Deckt Pruefungen 1-3 ab.
|
|
||||||
func TestFullAuthorizationCodeFlow(t *testing.T) {
|
|
||||||
handler, pool, cleanup := newProviderTestSetup(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
userID := createTestUser(t, pool, "nutzer@acme.example")
|
|
||||||
|
|
||||||
// Pruefung 1 / Akzeptanzkriterium 1: Client registriert sich.
|
|
||||||
clientID, clientSecret, err := handler.clients.Register(ctx, "Test-Drittanwendung",
|
|
||||||
[]string{"https://app.example.com/callback"},
|
|
||||||
[]string{"openid", "profile"}, // erlaubte Scopes: KEIN "admin"
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("register: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session-Cookie fuer den Authorize-Aufruf ausstellen, exakt wie ein
|
|
||||||
// echter Browser ihn haette — RequireAuth uebernimmt die Verifikation,
|
|
||||||
// dieselbe Middleware wie in Produktion (nicht simuliert).
|
|
||||||
issuer := auth.NewTokenIssuer("test-session-secret")
|
|
||||||
sessionToken, err := issuer.Issue(userID, "acme")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("session-token ausstellen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
authorizeURL := "/oidc/authorize?" + url.Values{
|
|
||||||
"response_type": {"code"},
|
|
||||||
"client_id": {clientID},
|
|
||||||
"redirect_uri": {"https://app.example.com/callback"},
|
|
||||||
// admin wird angefragt, ist aber NICHT erlaubt -> muss herausgefiltert werden.
|
|
||||||
"scope": {"openid profile admin"},
|
|
||||||
"state": {"xyz123"},
|
|
||||||
}.Encode()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, authorizeURL, nil)
|
|
||||||
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: sessionToken})
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
protected := auth.RequireAuth(issuer, handler.Authorize)
|
|
||||||
protected(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusFound {
|
|
||||||
t.Fatalf("authorize: status = %d, want %d, body: %s", rec.Code, http.StatusFound, rec.Body.String())
|
|
||||||
}
|
|
||||||
loc, err := url.Parse(rec.Header().Get("Location"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("redirect-location parsen: %v", err)
|
|
||||||
}
|
|
||||||
if loc.Query().Get("state") != "xyz123" {
|
|
||||||
t.Fatalf("state = %q, want xyz123 (muss unveraendert durchgereicht werden)", loc.Query().Get("state"))
|
|
||||||
}
|
|
||||||
code := loc.Query().Get("code")
|
|
||||||
if code == "" {
|
|
||||||
t.Fatal("erwartet gesetzten code-Parameter in der redirect-URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pruefung 1 / Akzeptanzkriterium 2: Code gegen Token tauschen.
|
|
||||||
tokenForm := url.Values{
|
|
||||||
"grant_type": {"authorization_code"},
|
|
||||||
"code": {code},
|
|
||||||
"redirect_uri": {"https://app.example.com/callback"},
|
|
||||||
"client_id": {clientID},
|
|
||||||
"client_secret": {clientSecret},
|
|
||||||
}
|
|
||||||
tokenReq := httptest.NewRequest(http.MethodPost, "/oidc/token", strings.NewReader(tokenForm.Encode()))
|
|
||||||
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
tokenRec := httptest.NewRecorder()
|
|
||||||
handler.Token(tokenRec, tokenReq)
|
|
||||||
|
|
||||||
if tokenRec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("token: status = %d, want 200, body: %s", tokenRec.Code, tokenRec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var tokenResp struct {
|
|
||||||
IDToken string `json:"id_token"`
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
}
|
|
||||||
if err := decodeJSON(tokenRec.Body.Bytes(), &tokenResp); err != nil {
|
|
||||||
t.Fatalf("token-response dekodieren: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 / Pruefung 3: "admin" wurde herausgefiltert.
|
|
||||||
grantedScopes := strings.Fields(tokenResp.Scope)
|
|
||||||
for _, s := range grantedScopes {
|
|
||||||
if s == "admin" {
|
|
||||||
t.Fatal("nicht erlaubter scope 'admin' wurde dennoch gewaehrt")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !containsAll(grantedScopes, "openid", "profile") {
|
|
||||||
t.Fatalf("erwartete scopes openid+profile, habe %v", grantedScopes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pruefung 2 / Akzeptanzkriterium 3: ausgestelltes Token gegen JWKS verifizieren.
|
|
||||||
jwksRec := httptest.NewRecorder()
|
|
||||||
handler.JWKS(jwksRec, httptest.NewRequest(http.MethodGet, "/oidc/jwks.json", nil))
|
|
||||||
|
|
||||||
var jwks idpJWKSet
|
|
||||||
if err := decodeJSON(jwksRec.Body.Bytes(), &jwks); err != nil {
|
|
||||||
t.Fatalf("jwks dekodieren: %v", err)
|
|
||||||
}
|
|
||||||
if len(jwks.Keys) == 0 {
|
|
||||||
t.Fatal("jwks enthaelt keine schluessel")
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedToken, err := jwt.ParseWithClaims(tokenResp.IDToken, &IDTokenClaims{}, func(tok *jwt.Token) (interface{}, error) {
|
|
||||||
kid, _ := tok.Header["kid"].(string)
|
|
||||||
for _, k := range jwks.Keys {
|
|
||||||
if k.Kid == kid {
|
|
||||||
pub, err := decodeBase64URLEd25519(k.X)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ed25519.PublicKey(pub), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, jwt.ErrTokenUnverifiable
|
|
||||||
})
|
|
||||||
if err != nil || !parsedToken.Valid {
|
|
||||||
t.Fatalf("id-token gegen jwks verifizieren: %v (valid=%v)", err, parsedToken != nil && parsedToken.Valid)
|
|
||||||
}
|
|
||||||
|
|
||||||
claims := parsedToken.Claims.(*IDTokenClaims)
|
|
||||||
if claims.Subject != userID {
|
|
||||||
t.Fatalf("sub = %q, want %q", claims.Subject, userID)
|
|
||||||
}
|
|
||||||
if len(claims.Audience) != 1 || claims.Audience[0] != clientID {
|
|
||||||
t.Fatalf("aud = %v, want [%q]", claims.Audience, clientID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pruefung 3 (dediziert): Client, der einen Scope anfragt, den er bei der
|
|
||||||
// Registrierung nicht erhalten hat, bekommt ihn unter keinen Umstaenden.
|
|
||||||
func TestGrantedScopes_NeverExceedsAllowed(t *testing.T) {
|
|
||||||
c := Client{AllowedScopes: []string{"openid", "profile"}}
|
|
||||||
granted := c.GrantedScopes([]string{"openid", "admin", "profile", "billing"})
|
|
||||||
if !containsAll(granted, "openid", "profile") || len(granted) != 2 {
|
|
||||||
t.Fatalf("granted = %v, want genau [openid profile]", granted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateRedirectURI_RejectsUnregistered(t *testing.T) {
|
|
||||||
c := Client{RedirectURIs: []string{"https://app.example.com/callback"}}
|
|
||||||
if err := c.ValidateRedirectURI("https://boese-seite.example.com/callback"); err == nil {
|
|
||||||
t.Fatal("erwartet fehler fuer nicht registrierte redirect_uri")
|
|
||||||
}
|
|
||||||
if err := c.ValidateRedirectURI("https://app.example.com/callback"); err != nil {
|
|
||||||
t.Fatalf("registrierte redirect_uri sollte akzeptiert werden: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthCodeStore_CannotBeConsumedTwice(t *testing.T) {
|
|
||||||
handler, pool, cleanup := newProviderTestSetup(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
userID := createTestUser(t, pool, "einmalig@acme.example")
|
|
||||||
clientID, _, err := handler.clients.Register(ctx, "Einmal-Client", []string{"https://a.example/cb"}, []string{"openid"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("register: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
code, err := handler.authCodes.Issue(ctx, clientID, userID, "https://a.example/cb", []string{"openid"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := handler.authCodes.Consume(ctx, code, clientID, "https://a.example/cb"); err != nil {
|
|
||||||
t.Fatalf("erster consume sollte funktionieren: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := handler.authCodes.Consume(ctx, code, clientID, "https://a.example/cb"); err == nil {
|
|
||||||
t.Fatal("zweiter consume desselben codes haette fehlschlagen muessen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsAll(haystack []string, needles ...string) bool {
|
|
||||||
set := make(map[string]bool, len(haystack))
|
|
||||||
for _, h := range haystack {
|
|
||||||
set[h] = true
|
|
||||||
}
|
|
||||||
for _, n := range needles {
|
|
||||||
if !set[n] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeJSON(data []byte, v any) error {
|
|
||||||
return json.Unmarshal(data, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeBase64URLEd25519(s string) ([]byte, error) {
|
|
||||||
return base64.RawURLEncoding.DecodeString(s)
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package oidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrStateInvalid = errors.New("oidc: state ungueltig, bereits verwendet oder abgelaufen")
|
|
||||||
|
|
||||||
// DefaultStateTTL begrenzt, wie lange ein ausgestellter State/Nonce fuer den
|
|
||||||
// Redirect-Umweg zum Identity-Provider gueltig bleibt.
|
|
||||||
const DefaultStateTTL = 10 * time.Minute
|
|
||||||
|
|
||||||
type StateStore struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStateStore(pool *pgxpool.Pool) *StateStore {
|
|
||||||
return &StateStore{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate stellt state+nonce fuer einen neuen Login-Redirect aus. state
|
|
||||||
// wird als OAuth2-"state"-Parameter mitgeschickt (CSRF-Schutz), nonce
|
|
||||||
// erscheint spaeter im ID-Token und muss exakt uebereinstimmen (Replay-Schutz).
|
|
||||||
func (s *StateStore) Generate(ctx context.Context) (state, nonce string, err error) {
|
|
||||||
state, err = randomValue()
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
nonce, err = randomValue()
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO oidc_states (state, nonce, expires_at) VALUES ($1, $2, $3)
|
|
||||||
`, state, nonce, time.Now().Add(DefaultStateTTL))
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("state speichern: %w", err)
|
|
||||||
}
|
|
||||||
return state, nonce, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consume loest einen State EINMALIG ein (Akzeptanzkriterium/Pruefung 3:
|
|
||||||
// Replay-Schutz) — atomar ueber WHERE used_at IS NULL, analog IAM-03/IAM-09.
|
|
||||||
// Ein zweiter Callback mit demselben state (z.B. durch einen Angreifer, der
|
|
||||||
// die Redirect-URL abgefangen hat) schlaegt fehl.
|
|
||||||
func (s *StateStore) Consume(ctx context.Context, state string) (nonce string, err error) {
|
|
||||||
err = s.pool.QueryRow(ctx, `
|
|
||||||
UPDATE oidc_states SET used_at = now()
|
|
||||||
WHERE state = $1 AND used_at IS NULL AND expires_at > now()
|
|
||||||
RETURNING nonce
|
|
||||||
`, state).Scan(&nonce)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return "", ErrStateInvalid
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("state einloesen: %w", err)
|
|
||||||
}
|
|
||||||
return nonce, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomValue() (string, error) {
|
|
||||||
buf := make([]byte, 32)
|
|
||||||
if _, err := rand.Read(buf); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(buf), nil
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package oidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupStateTest(t *testing.T) (*StateStore, 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 oidc_states (
|
|
||||||
state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := func() { pool.Close() }
|
|
||||||
return NewStateStore(pool), cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium/Pruefung 3: State/Nonce-Handling gegen CSRF und Replay.
|
|
||||||
func TestStateStore_ConsumeIsSingleUse(t *testing.T) {
|
|
||||||
store, cleanup := setupStateTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
state, nonce, err := store.Generate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gotNonce, err := store.Consume(ctx, state)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("consume (erste einloesung): %v", err)
|
|
||||||
}
|
|
||||||
if gotNonce != nonce {
|
|
||||||
t.Fatalf("nonce = %q, want %q", gotNonce, nonce)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.Consume(ctx, state); !errors.Is(err, ErrStateInvalid) {
|
|
||||||
t.Fatalf("erwartet ErrStateInvalid bei erneutem einloesen (replay), habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateStore_RejectsUnknownState(t *testing.T) {
|
|
||||||
store, cleanup := setupStateTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if _, err := store.Consume(ctx, "niemals-ausgestellter-state"); !errors.Is(err, ErrStateInvalid) {
|
|
||||||
t.Fatalf("erwartet ErrStateInvalid, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package oidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rsa"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrInvalidToken = errors.New("oidc: id-token ungueltig, abgelaufen oder falscher aussteller")
|
|
||||||
|
|
||||||
// Claims sind die fuer NEXARCH relevanten OIDC-ID-Token-Claims.
|
|
||||||
type Claims struct {
|
|
||||||
Subject string `json:"sub"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Nonce string `json:"nonce"`
|
|
||||||
Roles []string `json:"roles"` // providerabhaengiger Claim-Name, hier vereinfacht fest "roles"
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verifier prueft ID-Tokens gegen den JWKS-Schluesselsatz eines konfigurierten
|
|
||||||
// Providers (Akzeptanzkriterium 3: fehlerhafte/abgelaufene Tokens werden
|
|
||||||
// abgewiesen).
|
|
||||||
type Verifier struct {
|
|
||||||
keys map[string]*rsa.PublicKey
|
|
||||||
issuer string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewVerifier(keys map[string]*rsa.PublicKey, issuer string) *Verifier {
|
|
||||||
return &Verifier{keys: keys, issuer: issuer}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify prueft Signatur (gegen JWKS), Ablauf (jwt-Bibliothek lehnt
|
|
||||||
// abgelaufene Tokens automatisch ab) und Aussteller.
|
|
||||||
func (v *Verifier) Verify(idToken string) (*Claims, error) {
|
|
||||||
claims := &Claims{}
|
|
||||||
token, err := jwt.ParseWithClaims(idToken, claims, func(t *jwt.Token) (interface{}, error) {
|
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
|
||||||
return nil, ErrInvalidToken
|
|
||||||
}
|
|
||||||
kid, _ := t.Header["kid"].(string)
|
|
||||||
key, ok := v.keys[kid]
|
|
||||||
if !ok {
|
|
||||||
return nil, ErrInvalidToken
|
|
||||||
}
|
|
||||||
return key, nil
|
|
||||||
})
|
|
||||||
if err != nil || !token.Valid {
|
|
||||||
return nil, ErrInvalidToken
|
|
||||||
}
|
|
||||||
if claims.Issuer != v.issuer {
|
|
||||||
return nil, fmt.Errorf("%w: unerwarteter aussteller %q", ErrInvalidToken, claims.Issuer)
|
|
||||||
}
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyWithNonce prueft zusaetzlich, dass der nonce-Claim im Token exakt
|
|
||||||
// dem bei der Anfrage ausgestellten Nonce entspricht — verhindert Replay
|
|
||||||
// eines fremden, gueltigen ID-Tokens (Akzeptanzkriterium/Pruefung 3).
|
|
||||||
func (v *Verifier) VerifyWithNonce(idToken, expectedNonce string) (*Claims, error) {
|
|
||||||
claims, err := v.Verify(idToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if claims.Nonce == "" || claims.Nonce != expectedNonce {
|
|
||||||
return nil, fmt.Errorf("%w: nonce stimmt nicht ueberein", ErrInvalidToken)
|
|
||||||
}
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
package oidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
func generateTestKey(t *testing.T) *rsa.PrivateKey {
|
|
||||||
t.Helper()
|
|
||||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate rsa key: %v", err)
|
|
||||||
}
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
func signTestToken(t *testing.T, key *rsa.PrivateKey, kid string, claims Claims) string {
|
|
||||||
t.Helper()
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
|
||||||
token.Header["kid"] = kid
|
|
||||||
s, err := token.SignedString(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sign token: %v", err)
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerify_AcceptsValidToken(t *testing.T) {
|
|
||||||
key := generateTestKey(t)
|
|
||||||
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
|
|
||||||
|
|
||||||
claims := Claims{
|
|
||||||
Subject: "user-123",
|
|
||||||
Email: "person@example.com",
|
|
||||||
Nonce: "erwarteter-nonce",
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Issuer: "https://idp.example.com",
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
tok := signTestToken(t, key, "kid-1", claims)
|
|
||||||
|
|
||||||
got, err := verifier.Verify(tok)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("verify: %v", err)
|
|
||||||
}
|
|
||||||
if got.Email != "person@example.com" {
|
|
||||||
t.Fatalf("email = %q", got.Email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 2: Token-Signatur- und Ablaufpruefung.
|
|
||||||
func TestVerify_RejectsExpiredToken(t *testing.T) {
|
|
||||||
key := generateTestKey(t)
|
|
||||||
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
|
|
||||||
|
|
||||||
claims := Claims{
|
|
||||||
Subject: "user-123",
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Issuer: "https://idp.example.com",
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), // abgelaufen
|
|
||||||
},
|
|
||||||
}
|
|
||||||
tok := signTestToken(t, key, "kid-1", claims)
|
|
||||||
|
|
||||||
if _, err := verifier.Verify(tok); err == nil {
|
|
||||||
t.Fatal("erwartet fehler bei abgelaufenem token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerify_RejectsWrongSigningKey(t *testing.T) {
|
|
||||||
realKey := generateTestKey(t)
|
|
||||||
attackerKey := generateTestKey(t)
|
|
||||||
// Verifier kennt NUR den echten oeffentlichen Schluessel.
|
|
||||||
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &realKey.PublicKey}, "https://idp.example.com")
|
|
||||||
|
|
||||||
claims := Claims{
|
|
||||||
Subject: "user-123",
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Issuer: "https://idp.example.com",
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
// Mit dem Angreifer-Schluessel signiert, aber unter demselben kid wie der echte.
|
|
||||||
tok := signTestToken(t, attackerKey, "kid-1", claims)
|
|
||||||
|
|
||||||
if _, err := verifier.Verify(tok); err == nil {
|
|
||||||
t.Fatal("erwartet fehler bei fremd signiertem token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerify_RejectsWrongIssuer(t *testing.T) {
|
|
||||||
key := generateTestKey(t)
|
|
||||||
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
|
|
||||||
|
|
||||||
claims := Claims{
|
|
||||||
Subject: "user-123",
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Issuer: "https://ganz-anderer-idp.example.com",
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
tok := signTestToken(t, key, "kid-1", claims)
|
|
||||||
|
|
||||||
if _, err := verifier.Verify(tok); err == nil {
|
|
||||||
t.Fatal("erwartet fehler bei unerwartetem aussteller")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium/Pruefung 3: Nonce-Handling gegen Replay.
|
|
||||||
func TestVerifyWithNonce_RejectsMismatch(t *testing.T) {
|
|
||||||
key := generateTestKey(t)
|
|
||||||
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
|
|
||||||
|
|
||||||
claims := Claims{
|
|
||||||
Subject: "user-123",
|
|
||||||
Nonce: "nonce-a",
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Issuer: "https://idp.example.com",
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
tok := signTestToken(t, key, "kid-1", claims)
|
|
||||||
|
|
||||||
if _, err := verifier.VerifyWithNonce(tok, "nonce-b"); err == nil {
|
|
||||||
t.Fatal("erwartet fehler bei nonce-mismatch (moeglicher replay eines fremden tokens)")
|
|
||||||
}
|
|
||||||
if _, err := verifier.VerifyWithNonce(tok, "nonce-a"); err != nil {
|
|
||||||
t.Fatalf("erwartet erfolg bei passendem nonce: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseJWKS_RoundTrip(t *testing.T) {
|
|
||||||
key := generateTestKey(t)
|
|
||||||
|
|
||||||
nBytes := key.PublicKey.N.Bytes()
|
|
||||||
eBuf := make([]byte, 8)
|
|
||||||
binary.BigEndian.PutUint64(eBuf, uint64(key.PublicKey.E))
|
|
||||||
// Fuehrende Nullbytes des Exponenten abschneiden (ueblich bei JWK-Kodierung).
|
|
||||||
eStart := 0
|
|
||||||
for eStart < len(eBuf)-1 && eBuf[eStart] == 0 {
|
|
||||||
eStart++
|
|
||||||
}
|
|
||||||
|
|
||||||
set := jwkSet{Keys: []jwk{{
|
|
||||||
Kid: "kid-1",
|
|
||||||
Kty: "RSA",
|
|
||||||
N: base64.RawURLEncoding.EncodeToString(nBytes),
|
|
||||||
E: base64.RawURLEncoding.EncodeToString(eBuf[eStart:]),
|
|
||||||
}}}
|
|
||||||
data, err := json.Marshal(set)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := ParseJWKS(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse jwks: %v", err)
|
|
||||||
}
|
|
||||||
got, ok := parsed["kid-1"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("erwartet kid-1 im ergebnis")
|
|
||||||
}
|
|
||||||
if got.E != key.PublicKey.E || got.N.Cmp(key.PublicKey.N) != 0 {
|
|
||||||
t.Fatalf("erwartet identischen oeffentlichen schluessel nach roundtrip")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Der wiederhergestellte Schluessel muss ein damit signiertes Token auch tatsaechlich verifizieren.
|
|
||||||
verifier := NewVerifier(parsed, "https://idp.example.com")
|
|
||||||
claims := Claims{Subject: "x", RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Issuer: "https://idp.example.com", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
||||||
}}
|
|
||||||
tok := signTestToken(t, key, "kid-1", claims)
|
|
||||||
if _, err := verifier.Verify(tok); err != nil {
|
|
||||||
t.Fatalf("verify mit aus jwks wiederhergestelltem schluessel: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package pwpolicy
|
|
||||||
|
|
||||||
// commonPasswords ist eine kleine, eingebettete Sperrliste bekannt
|
|
||||||
// haeufig verwendeter/kompromittierter Passwoerter (Akzeptanzkriterium 2).
|
|
||||||
// Vergleich erfolgt case-insensitive (siehe Validate).
|
|
||||||
var commonPasswords = map[string]bool{
|
|
||||||
"123456": true,
|
|
||||||
"password": true,
|
|
||||||
"12345678": true,
|
|
||||||
"qwerty": true,
|
|
||||||
"123456789": true,
|
|
||||||
"12345": true,
|
|
||||||
"1234": true,
|
|
||||||
"111111": true,
|
|
||||||
"1234567": true,
|
|
||||||
"dragon": true,
|
|
||||||
"123123": true,
|
|
||||||
"baseball": true,
|
|
||||||
"iloveyou": true,
|
|
||||||
"trustno1": true,
|
|
||||||
"1234567890": true,
|
|
||||||
"sunshine": true,
|
|
||||||
"master": true,
|
|
||||||
"welcome": true,
|
|
||||||
"admin": true,
|
|
||||||
"letmein": true,
|
|
||||||
"login": true,
|
|
||||||
"passw0rd": true,
|
|
||||||
"starwars": true,
|
|
||||||
"abc123": true,
|
|
||||||
"password1": true,
|
|
||||||
"qwerty123": true,
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package pwpolicy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LoginAndCheckPolicy komponiert IAM-02s LoginService mit der Passwort-
|
|
||||||
// Richtlinienpruefung, OHNE LoginService selbst zu veraendern. Ein
|
|
||||||
// bestehender Benutzer mit einem alten, nicht mehr konformen Passwort kann
|
|
||||||
// sich weiterhin einloggen (Login schlaegt NICHT fehl) — er wird lediglich
|
|
||||||
// zur Aenderung aufgefordert (Akzeptanzkriterium 3: kein rueckwirkendes
|
|
||||||
// Aussperren). Die Pruefung ist nur HIER moeglich, da nur beim Login das
|
|
||||||
// Klartext-Passwort kurzzeitig vorliegt — der gespeicherte bcrypt-Hash laesst
|
|
||||||
// sich nicht rueckwirkend gegen eine neue Richtlinie pruefen.
|
|
||||||
func LoginAndCheckPolicy(ctx context.Context, policies *Store, login *auth.LoginService, email, password string) (token string, mustChangePassword bool, err error) {
|
|
||||||
token, err = login.Login(ctx, email, password)
|
|
||||||
if err != nil {
|
|
||||||
return "", false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
policy, err := policies.Get(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return "", false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
mustChangePassword = Validate(policy, password) != nil
|
|
||||||
return token, mustChangePassword, nil
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
// Package pwpolicy implementiert Core IAM-14: konfigurierbare Passwort-
|
|
||||||
// Komplexitätsanforderungen pro Tenant — eine schmale Regelschicht VOR dem
|
|
||||||
// bcrypt-Hashing aus IAM-02, kein eigenes Policy-Framework.
|
|
||||||
package pwpolicy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Policy sind die Mindestanforderungen (Akzeptanzkriterium 1).
|
|
||||||
type Policy struct {
|
|
||||||
MinLength int
|
|
||||||
RequireUpper bool
|
|
||||||
RequireLower bool
|
|
||||||
RequireDigit bool
|
|
||||||
RequireSpecial bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultPolicy gilt, solange ein Tenant keine eigene Konfiguration gesetzt hat.
|
|
||||||
func DefaultPolicy() Policy {
|
|
||||||
return Policy{MinLength: 12, RequireUpper: true, RequireLower: true, RequireDigit: true, RequireSpecial: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
var ErrPasswordTooWeak = errors.New("pwpolicy: passwort erfuellt die richtlinie nicht")
|
|
||||||
|
|
||||||
// Validate prueft ein Passwort gegen policy UND die Sperrliste
|
|
||||||
// (Akzeptanzkriterium 2). Liefert bei Verstoss ErrPasswordTooWeak,
|
|
||||||
// gewrappt mit einer fuer Menschen lesbaren Begruendung.
|
|
||||||
func Validate(policy Policy, password string) error {
|
|
||||||
if commonPasswords[strings.ToLower(password)] {
|
|
||||||
return fmt.Errorf("%w: passwort steht auf der sperrliste haeufig verwendeter passwoerter", ErrPasswordTooWeak)
|
|
||||||
}
|
|
||||||
if len(password) < policy.MinLength {
|
|
||||||
return fmt.Errorf("%w: mindestens %d zeichen erforderlich", ErrPasswordTooWeak, policy.MinLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasUpper, hasLower, hasDigit, hasSpecial bool
|
|
||||||
for _, r := range password {
|
|
||||||
switch {
|
|
||||||
case unicode.IsUpper(r):
|
|
||||||
hasUpper = true
|
|
||||||
case unicode.IsLower(r):
|
|
||||||
hasLower = true
|
|
||||||
case unicode.IsDigit(r):
|
|
||||||
hasDigit = true
|
|
||||||
case unicode.IsPunct(r) || unicode.IsSymbol(r):
|
|
||||||
hasSpecial = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if policy.RequireUpper && !hasUpper {
|
|
||||||
return fmt.Errorf("%w: mindestens ein grossbuchstabe erforderlich", ErrPasswordTooWeak)
|
|
||||||
}
|
|
||||||
if policy.RequireLower && !hasLower {
|
|
||||||
return fmt.Errorf("%w: mindestens ein kleinbuchstabe erforderlich", ErrPasswordTooWeak)
|
|
||||||
}
|
|
||||||
if policy.RequireDigit && !hasDigit {
|
|
||||||
return fmt.Errorf("%w: mindestens eine ziffer erforderlich", ErrPasswordTooWeak)
|
|
||||||
}
|
|
||||||
if policy.RequireSpecial && !hasSpecial {
|
|
||||||
return fmt.Errorf("%w: mindestens ein sonderzeichen erforderlich", ErrPasswordTooWeak)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store verwaltet die pro-Tenant konfigurierte Richtlinie (eine Zeile je
|
|
||||||
// Tenant-Datenbank, Modell C).
|
|
||||||
type Store struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store {
|
|
||||||
return &Store{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get liefert die konfigurierte Richtlinie, oder DefaultPolicy() wenn noch
|
|
||||||
// keine gesetzt wurde.
|
|
||||||
func (s *Store) Get(ctx context.Context) (Policy, error) {
|
|
||||||
var p Policy
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT min_length, require_upper, require_lower, require_digit, require_special
|
|
||||||
FROM password_policy WHERE id = true
|
|
||||||
`).Scan(&p.MinLength, &p.RequireUpper, &p.RequireLower, &p.RequireDigit, &p.RequireSpecial)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return DefaultPolicy(), nil
|
|
||||||
}
|
|
||||||
return Policy{}, fmt.Errorf("richtlinie lesen: %w", err)
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set legt die Richtlinie fuer diesen Tenant fest (Akzeptanzkriterium 1).
|
|
||||||
func (s *Store) Set(ctx context.Context, p Policy) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO password_policy (id, min_length, require_upper, require_lower, require_digit, require_special, updated_at)
|
|
||||||
VALUES (true, $1, $2, $3, $4, $5, now())
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
min_length = $1, require_upper = $2, require_lower = $3, require_digit = $4, require_special = $5, updated_at = now()
|
|
||||||
`, p.MinLength, p.RequireUpper, p.RequireLower, p.RequireDigit, p.RequireSpecial)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("richtlinie speichern: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
package pwpolicy
|
|
||||||
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 1: zu kurzes/zu einfaches Passwort abgelehnt.
|
|
||||||
func TestValidate_RejectsTooShortOrSimple(t *testing.T) {
|
|
||||||
policy := DefaultPolicy()
|
|
||||||
|
|
||||||
if err := Validate(policy, "kurz1A"); !errors.Is(err, ErrPasswordTooWeak) {
|
|
||||||
t.Fatalf("erwartet ErrPasswordTooWeak fuer zu kurzes passwort, habe %v", err)
|
|
||||||
}
|
|
||||||
if err := Validate(policy, "alleskleingeschriebenundlang123"); !errors.Is(err, ErrPasswordTooWeak) {
|
|
||||||
t.Fatalf("erwartet ErrPasswordTooWeak ohne grossbuchstaben, habe %v", err)
|
|
||||||
}
|
|
||||||
if err := Validate(policy, "GutesPasswort2026!"); err != nil {
|
|
||||||
t.Fatalf("erwartet gueltig, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2: Passwort aus Sperrliste abgelehnt.
|
|
||||||
func TestValidate_RejectsBlocklistedPassword(t *testing.T) {
|
|
||||||
policy := Policy{MinLength: 4} // absichtlich schwach, damit NUR die sperrliste greift
|
|
||||||
|
|
||||||
if err := Validate(policy, "password"); !errors.Is(err, ErrPasswordTooWeak) {
|
|
||||||
t.Fatalf("erwartet ErrPasswordTooWeak fuer sperrlisten-passwort, habe %v", err)
|
|
||||||
}
|
|
||||||
if err := Validate(policy, "PASSWORD"); !errors.Is(err, ErrPasswordTooWeak) {
|
|
||||||
t.Fatalf("erwartet case-insensitive treffer auf der sperrliste, habe %v", err)
|
|
||||||
}
|
|
||||||
if err := Validate(policy, "ein-eher-unueblicher-satz"); err != nil {
|
|
||||||
t.Fatalf("nicht-sperrlisten-passwort sollte hier gueltig sein: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupTest(t *testing.T) (*Store, *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 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_policy (
|
|
||||||
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id), min_length INT NOT NULL DEFAULT 12,
|
|
||||||
require_upper BOOLEAN NOT NULL DEFAULT true, require_lower BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
require_digit BOOLEAN NOT NULL DEFAULT true, require_special BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton-Tabelle: vor jedem Test leeren, falls ein vorheriger Lauf
|
|
||||||
// (in dieser geteilten Registry-Tabelle) eine Zeile hinterlassen hat.
|
|
||||||
if _, err := pool.Exec(ctx, `DELETE FROM password_policy`); err != nil {
|
|
||||||
t.Fatalf("vorab-bereinigung: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM password_policy`)
|
|
||||||
pool.Close()
|
|
||||||
}
|
|
||||||
return NewStore(pool), pool, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStore_GetReturnsDefaultWhenUnset(t *testing.T) {
|
|
||||||
store, _, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
got, err := store.Get(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
if got != DefaultPolicy() {
|
|
||||||
t.Fatalf("erwartet default policy, habe %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStore_SetAndGetRoundTrip(t *testing.T) {
|
|
||||||
store, _, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
custom := Policy{MinLength: 16, RequireUpper: true, RequireLower: true, RequireDigit: true, RequireSpecial: true}
|
|
||||||
if err := store.Set(ctx, custom); err != nil {
|
|
||||||
t.Fatalf("set: %v", err)
|
|
||||||
}
|
|
||||||
got, err := store.Get(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
if got != custom {
|
|
||||||
t.Fatalf("erwartet %+v, habe %+v", custom, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 3: bestehender Nutzer mit altem,
|
|
||||||
// nicht-konformem Passwort kann sich noch einloggen, wird aber zur
|
|
||||||
// Aenderung aufgefordert — kein rueckwirkendes Aussperren.
|
|
||||||
func TestLoginAndCheckPolicy_FlagsNonConformantExistingPassword(t *testing.T) {
|
|
||||||
store, pool, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
email := fmt.Sprintf("pwpolicy-test-%d@example.com", time.Now().UnixNano())
|
|
||||||
userStore := user.NewTenantUserStore(pool)
|
|
||||||
u, err := userStore.Create(ctx, email, "Legacy User")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create user: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Altes Passwort, das VOR der heutigen Richtlinie gesetzt wurde — erfuellt
|
|
||||||
// die aktuelle DefaultPolicy() nicht (kein Grossbuchstabe, zu kurz).
|
|
||||||
oldPassword := "altespasswort"
|
|
||||||
hash, err := auth.HashPassword(oldPassword)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("hash: %v", err)
|
|
||||||
}
|
|
||||||
if err := userStore.SetPasswordHash(ctx, u.ID, hash); err != nil {
|
|
||||||
t.Fatalf("set password hash: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
|
||||||
loginService := auth.NewLoginService(userStore, issuer, "acme")
|
|
||||||
|
|
||||||
token, mustChange, err := LoginAndCheckPolicy(ctx, store, loginService, email, oldPassword)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("login sollte trotz schwachem altpasswort gelingen: %v", err)
|
|
||||||
}
|
|
||||||
if token == "" {
|
|
||||||
t.Fatal("erwartet gueltiges token")
|
|
||||||
}
|
|
||||||
if !mustChange {
|
|
||||||
t.Fatal("erwartet mustChangePassword=true fuer nicht-konformes altpasswort")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Konformes Passwort -> kein Aenderungszwang.
|
|
||||||
strongPassword := "EinStarkesPasswort2026!"
|
|
||||||
hash2, err := auth.HashPassword(strongPassword)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("hash 2: %v", err)
|
|
||||||
}
|
|
||||||
if err := userStore.SetPasswordHash(ctx, u.ID, hash2); err != nil {
|
|
||||||
t.Fatalf("set password hash 2: %v", err)
|
|
||||||
}
|
|
||||||
_, mustChange, err = LoginAndCheckPolicy(ctx, store, loginService, email, strongPassword)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("login mit starkem passwort: %v", err)
|
|
||||||
}
|
|
||||||
if mustChange {
|
|
||||||
t.Fatal("erwartet mustChangePassword=false fuer konformes passwort")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
// Package rbac implementiert Core RBAC-01: das Rollenmodell mit
|
|
||||||
// Grundrollen/-rechten, Hierarchie und Zuweisung. Die eigentliche
|
|
||||||
// Durchsetzung (erlaubt/verboten je Request) ist RBAC-02, dieses Paket
|
|
||||||
// liefert nur Modell + Zuweisung (Casbin-Prinzip: Policy-Modell getrennt
|
|
||||||
// von der Entscheidungsfunktion).
|
|
||||||
package rbac
|
|
||||||
|
|
||||||
// Role ist eine der drei Grundrollen. Superadmin lebt mandantenuebergreifend
|
|
||||||
// (siehe internal/user.SuperadminStore aus IAM-01) und wird deshalb NICHT
|
|
||||||
// ueber Store (tenant-gescoped) zugewiesen — die Existenz eines Superadmin-
|
|
||||||
// Kontos IST die Rollenzuweisung.
|
|
||||||
type Role string
|
|
||||||
|
|
||||||
const (
|
|
||||||
RoleSuperadmin Role = "superadmin"
|
|
||||||
RoleTenantAdmin Role = "tenant_admin"
|
|
||||||
RoleUser Role = "user"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Permission string
|
|
||||||
|
|
||||||
const (
|
|
||||||
PermSelfRead Permission = "self.read"
|
|
||||||
PermSelfUpdate Permission = "self.update"
|
|
||||||
PermManageUsers Permission = "tenant.manage_users"
|
|
||||||
PermManageSettings Permission = "tenant.manage_settings"
|
|
||||||
PermManageTenants Permission = "platform.manage_tenants"
|
|
||||||
)
|
|
||||||
|
|
||||||
// parent bildet die Rollenhierarchie ab (Akzeptanzkriterium 2): eine Rolle
|
|
||||||
// erbt alle Rechte ihrer Elternrolle. tenant_admin erbt von user,
|
|
||||||
// superadmin erbt von tenant_admin.
|
|
||||||
var parent = map[Role]Role{
|
|
||||||
RoleTenantAdmin: RoleUser,
|
|
||||||
RoleSuperadmin: RoleTenantAdmin,
|
|
||||||
}
|
|
||||||
|
|
||||||
// direct sind die einer Rolle direkt (ohne Vererbung) zugeordneten Rechte.
|
|
||||||
var direct = map[Role][]Permission{
|
|
||||||
RoleUser: {PermSelfRead, PermSelfUpdate},
|
|
||||||
RoleTenantAdmin: {PermManageUsers, PermManageSettings},
|
|
||||||
RoleSuperadmin: {PermManageTenants},
|
|
||||||
}
|
|
||||||
|
|
||||||
// EffectivePermissions liefert die vollstaendige, ueber die Hierarchie
|
|
||||||
// aufgeloeste Rechtemenge einer Rolle (Akzeptanzkriterium 2 / Pruefung 2).
|
|
||||||
func EffectivePermissions(role Role) []Permission {
|
|
||||||
seen := make(map[Permission]bool)
|
|
||||||
var out []Permission
|
|
||||||
|
|
||||||
for r, ok := role, true; ok; r, ok = parent[r] {
|
|
||||||
for _, p := range direct[r] {
|
|
||||||
if !seen[p] {
|
|
||||||
seen[p] = true
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasPermission prueft, ob eine Rolle (inklusive geerbter Rechte) ein
|
|
||||||
// bestimmtes Recht besitzt.
|
|
||||||
func HasPermission(role Role, perm Permission) bool {
|
|
||||||
for _, p := range EffectivePermissions(role) {
|
|
||||||
if p == perm {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
package rbac
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// ErrRoleNotAssignableInTenantScope wird geliefert, wenn versucht wird,
|
|
||||||
// eine mandantenuebergreifende Rolle (superadmin) ueber den tenant-
|
|
||||||
// gescopten Store zu vergeben — die erlaubte Matrix laesst hier nur
|
|
||||||
// user/tenant_admin zu (Akzeptanzkriterium 1 / Pruefung 1).
|
|
||||||
ErrRoleNotAssignableInTenantScope = errors.New("rbac: rolle ist in diesem geltungsbereich nicht zuweisbar")
|
|
||||||
ErrNotFound = errors.New("rbac: keine rollenzuweisung gefunden")
|
|
||||||
)
|
|
||||||
|
|
||||||
// assignableRoles ist die erlaubte Matrix fuer Store (tenant-gescoped).
|
|
||||||
var assignableRoles = map[Role]bool{
|
|
||||||
RoleUser: true,
|
|
||||||
RoleTenantAdmin: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
type Assignment struct {
|
|
||||||
UserID string
|
|
||||||
Role Role
|
|
||||||
GrantedBy string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store verwaltet Rollenzuweisungen innerhalb GENAU EINER Tenant-Datenbank —
|
|
||||||
// analog zu internal/user.TenantUserStore (Modell C: der Pool bestimmt den
|
|
||||||
// Tenant, keine tenant_id-Spalte noetig).
|
|
||||||
type Store struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store {
|
|
||||||
return &Store{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign vergibt eine Rolle an einen Benutzer. grantedBy identifiziert den
|
|
||||||
// Akteur, der die Zuweisung vorgenommen hat (Akzeptanzkriterium 3). Jede
|
|
||||||
// Zuweisung wird zusaetzlich in role_assignment_history festgehalten, auch
|
|
||||||
// wenn sie eine vorherige Rolle ersetzt.
|
|
||||||
func (s *Store) Assign(ctx context.Context, userID string, role Role, grantedBy string) (Assignment, error) {
|
|
||||||
if !assignableRoles[role] {
|
|
||||||
return Assignment{}, ErrRoleNotAssignableInTenantScope
|
|
||||||
}
|
|
||||||
if grantedBy == "" {
|
|
||||||
return Assignment{}, errors.New("rbac: grantedBy darf nicht leer sein")
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return Assignment{}, fmt.Errorf("transaktion starten: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = tx.Rollback(ctx) }()
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO role_assignments (user_id, role, granted_by, granted_at)
|
|
||||||
VALUES ($1, $2, $3, now())
|
|
||||||
ON CONFLICT (user_id) DO UPDATE SET role = $2, granted_by = $3, granted_at = now()
|
|
||||||
`, userID, string(role), grantedBy); err != nil {
|
|
||||||
return Assignment{}, fmt.Errorf("rolle zuweisen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO role_assignment_history (user_id, role, granted_by, granted_at)
|
|
||||||
VALUES ($1, $2, $3, now())
|
|
||||||
`, userID, string(role), grantedBy); err != nil {
|
|
||||||
return Assignment{}, fmt.Errorf("historie schreiben: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return Assignment{}, fmt.Errorf("transaktion committen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Assignment{UserID: userID, Role: role, GrantedBy: grantedBy}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) Get(ctx context.Context, userID string) (Assignment, error) {
|
|
||||||
var a Assignment
|
|
||||||
var role string
|
|
||||||
a.UserID = userID
|
|
||||||
if err := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT role, granted_by FROM role_assignments WHERE user_id = $1
|
|
||||||
`, userID).Scan(&role, &a.GrantedBy); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Assignment{}, ErrNotFound
|
|
||||||
}
|
|
||||||
return Assignment{}, fmt.Errorf("rollenzuweisung lesen: %w", err)
|
|
||||||
}
|
|
||||||
a.Role = Role(role)
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// History liefert jede Rollenzuweisung eines Benutzers in chronologischer
|
|
||||||
// Reihenfolge — die Grundlage fuer "wer hat wann welche Rolle vergeben"
|
|
||||||
// (Akzeptanzkriterium 3).
|
|
||||||
func (s *Store) History(ctx context.Context, userID string) ([]Assignment, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT role, granted_by FROM role_assignment_history
|
|
||||||
WHERE user_id = $1 ORDER BY granted_at
|
|
||||||
`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("historie abfragen: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []Assignment
|
|
||||||
for rows.Next() {
|
|
||||||
var role string
|
|
||||||
a := Assignment{UserID: userID}
|
|
||||||
if err := rows.Scan(&role, &a.GrantedBy); err != nil {
|
|
||||||
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
|
|
||||||
}
|
|
||||||
a.Role = Role(role)
|
|
||||||
out = append(out, a)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
// Package saml implementiert Core IAM-11: SAML 2.0 als zusaetzlicher
|
|
||||||
// Anmeldeweg neben OIDC (IAM-06).
|
|
||||||
//
|
|
||||||
// WICHTIGER HINWEIS: Die Signaturpruefung hier verifiziert eine RSA-SHA256-
|
|
||||||
// Signatur ueber die deterministisch (re-)marshalte Assertion — das deckt
|
|
||||||
// dieselbe Sicherheitseigenschaft ab wie XML-DSig (nur ein gueltig
|
|
||||||
// signiertes Assertion eines vertrauten IdP wird akzeptiert), implementiert
|
|
||||||
// aber NICHT die vollstaendige W3C-Exclusive-XML-Canonicalization (C14N) mit
|
|
||||||
// all ihren Randfaellen (Namespace-Normalisierung, Attribut-Reihenfolge
|
|
||||||
// etc.), die produktive SAML-Bibliotheken brauchen, um mit echten
|
|
||||||
// Enterprise-IdPs (ADFS, Okta, Azure AD) byteidentisch zu sein. Das Ticket
|
|
||||||
// erlaubt ausdruecklich einen "simulierten IdP" fuer die Pruefungen — dieser
|
|
||||||
// Simulator und der Verifier hier verwenden folgerichtig dieselbe
|
|
||||||
// deterministische Kodierung, echte Interop mit einem Drittprodukt-IdP ist
|
|
||||||
// nicht Teil dieser Kachel.
|
|
||||||
package saml
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/xml"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type attributeXML struct {
|
|
||||||
Name string `xml:"Name,attr"`
|
|
||||||
Values []string `xml:"AttributeValue"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type conditionsXML struct {
|
|
||||||
NotBefore string `xml:"NotBefore,attr"`
|
|
||||||
NotOnOrAfter string `xml:"NotOnOrAfter,attr"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssertionXML ist die (vereinfachte) SAML-Assertion.
|
|
||||||
type AssertionXML struct {
|
|
||||||
XMLName xml.Name `xml:"Assertion"`
|
|
||||||
Issuer string `xml:"Issuer"`
|
|
||||||
NameID string `xml:"Subject>NameID"`
|
|
||||||
Conditions conditionsXML `xml:"Conditions"`
|
|
||||||
Attributes []attributeXML `xml:"AttributeStatement>Attribute"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResponseXML umhuellt die Assertion mit der Signatur ueber deren kanonische
|
|
||||||
// Bytes (siehe Paket-Dokumentation).
|
|
||||||
type ResponseXML struct {
|
|
||||||
XMLName xml.Name `xml:"Response"`
|
|
||||||
Assertion AssertionXML `xml:"Assertion"`
|
|
||||||
SignatureValue string `xml:"SignatureValue"` // base64
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanonicalAssertionBytes liefert die deterministischen Bytes, ueber die
|
|
||||||
// signiert/verifiziert wird.
|
|
||||||
func CanonicalAssertionBytes(a AssertionXML) ([]byte, error) {
|
|
||||||
b, err := xml.Marshal(a)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("assertion serialisieren: %w", err)
|
|
||||||
}
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attribute liefert die Werte eines benannten SAML-Attributs (z.B. "roles"),
|
|
||||||
// oder nil wenn nicht vorhanden.
|
|
||||||
func (a AssertionXML) Attribute(name string) []string {
|
|
||||||
for _, attr := range a.Attributes {
|
|
||||||
if attr.Name == name {
|
|
||||||
return attr.Values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithinValidity prueft die Conditions/NotBefore/NotOnOrAfter der Assertion
|
|
||||||
// gegen die aktuelle Zeit.
|
|
||||||
func (a AssertionXML) WithinValidity(now time.Time) error {
|
|
||||||
notBefore, err := time.Parse(time.RFC3339, a.Conditions.NotBefore)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("notbefore parsen: %w", err)
|
|
||||||
}
|
|
||||||
notOnOrAfter, err := time.Parse(time.RFC3339, a.Conditions.NotOnOrAfter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("notonorafter parsen: %w", err)
|
|
||||||
}
|
|
||||||
if now.Before(notBefore) {
|
|
||||||
return ErrNotYetValid
|
|
||||||
}
|
|
||||||
if !now.Before(notOnOrAfter) {
|
|
||||||
return ErrExpired
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package saml
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CompleteSAMLLogin verifiziert die SAML-Response, bildet die Assertion auf
|
|
||||||
// das bestehende Benutzer-/Rollenmodell ab (Akzeptanzkriterium 3) und
|
|
||||||
// stellt bei Erfolg ein IAM-02-Sitzungs-Token aus. Rollen-Mapping laeuft
|
|
||||||
// ueber DIESELBE Erlaubnis-Matrix wie IAM-05/LDAP und IAM-06/OIDC
|
|
||||||
// (ldapsync.RoleMappingStore) — kein dritter paralleler Mapping-Mechanismus.
|
|
||||||
func CompleteSAMLLogin(
|
|
||||||
ctx context.Context,
|
|
||||||
verifier *Verifier,
|
|
||||||
users *user.TenantUserStore,
|
|
||||||
roles *rbac.Store,
|
|
||||||
roleMapping *ldapsync.RoleMappingStore,
|
|
||||||
issuer *auth.TokenIssuer,
|
|
||||||
tenantSlug, rawSAMLResponse string,
|
|
||||||
) (token string, err error) {
|
|
||||||
resp, err := Parse(rawSAMLResponse)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
assertion, err := verifier.Verify(resp)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if assertion.NameID == "" {
|
|
||||||
return "", fmt.Errorf("%w: assertion ohne subject-nameid", ErrInvalidSignature)
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := findOrCreateUser(ctx, users, assertion.NameID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
roleAttrs := assertion.Attribute("roles")
|
|
||||||
if role, ok, err := roleMapping.HighestRoleFor(ctx, roleAttrs); err != nil {
|
|
||||||
return "", err
|
|
||||||
} else if ok {
|
|
||||||
if _, err := roles.Assign(ctx, u.ID, role, "saml-sso"); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return issuer.Issue(u.ID, tenantSlug)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findOrCreateUser(ctx context.Context, users *user.TenantUserStore, email string) (user.User, error) {
|
|
||||||
creds, err := users.GetByEmailForAuth(ctx, email)
|
|
||||||
if err == nil {
|
|
||||||
return creds.User, nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, user.ErrNotFound) {
|
|
||||||
return user.User{}, err
|
|
||||||
}
|
|
||||||
return users.Create(ctx, email, email)
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
package saml
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rsa"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/oidc"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
const schema = `
|
|
||||||
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 role_assignments (
|
|
||||||
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
|
|
||||||
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS role_assignment_history (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL,
|
|
||||||
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
|
|
||||||
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
|
|
||||||
);`
|
|
||||||
|
|
||||||
func setupTest(t *testing.T) (*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, schema); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
cleanup := func() { pool.Close() }
|
|
||||||
return pool, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 1: End-to-End-Test gegen simulierten SAML-IdP.
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 3: Rollenzuordnung aus SAML-Attributen korrekt.
|
|
||||||
func TestCompleteSAMLLogin_EndToEnd(t *testing.T) {
|
|
||||||
pool, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
idpKey := generateTestIdPKey(t)
|
|
||||||
verifier := NewVerifier(&idpKey.PublicKey, "https://test-idp.example.com")
|
|
||||||
users := user.NewTenantUserStore(pool)
|
|
||||||
roles := rbac.NewStore(pool)
|
|
||||||
roleMapping := ldapsync.NewRoleMappingStore(pool)
|
|
||||||
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
|
||||||
|
|
||||||
if err := roleMapping.SetMapping(ctx, "saml-admins", rbac.RoleTenantAdmin); err != nil {
|
|
||||||
t.Fatalf("set mapping: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
email := fmt.Sprintf("saml-test-%d@example.com", time.Now().UnixNano())
|
|
||||||
rawResp, err := BuildResponse(idpKey, testAssertion("https://test-idp.example.com", email, []string{"saml-admins"}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build response (simulierter idp): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := CompleteSAMLLogin(ctx, verifier, users, roles, roleMapping, issuer, "acme", rawResp)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("complete saml login: %v", err)
|
|
||||||
}
|
|
||||||
if token == "" {
|
|
||||||
t.Fatal("erwartet nicht-leeres sitzungs-token")
|
|
||||||
}
|
|
||||||
|
|
||||||
creds, err := users.GetByEmailForAuth(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("user nach login: %v", err)
|
|
||||||
}
|
|
||||||
assignment, err := roles.Get(ctx, creds.User.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("rollenzuweisung lesen: %v", err)
|
|
||||||
}
|
|
||||||
if assignment.Role != rbac.RoleTenantAdmin {
|
|
||||||
t.Fatalf("erwartet tenant_admin aus saml-admins-mapping, habe %q", assignment.Role)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 3 (Negativfall): keine Privilege-
|
|
||||||
// Escalation ueber unbekannte/manipulierte Rollen-Attribute.
|
|
||||||
func TestCompleteSAMLLogin_UnmappedRoleGrantsNothing(t *testing.T) {
|
|
||||||
pool, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
idpKey := generateTestIdPKey(t)
|
|
||||||
verifier := NewVerifier(&idpKey.PublicKey, "https://test-idp.example.com")
|
|
||||||
users := user.NewTenantUserStore(pool)
|
|
||||||
roles := rbac.NewStore(pool)
|
|
||||||
roleMapping := ldapsync.NewRoleMappingStore(pool)
|
|
||||||
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
|
||||||
|
|
||||||
email := fmt.Sprintf("saml-test-%d@example.com", time.Now().UnixNano())
|
|
||||||
rawResp, err := BuildResponse(idpKey, testAssertion("https://test-idp.example.com", email, []string{"tenant_admin", "superadmin"}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := CompleteSAMLLogin(ctx, verifier, users, roles, roleMapping, issuer, "acme", rawResp); err != nil {
|
|
||||||
t.Fatalf("complete saml login: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
creds, err := users.GetByEmailForAuth(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("user nach login: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := roles.Get(ctx, creds.User.ID); err == nil {
|
|
||||||
t.Fatal("erwartet KEINE rollenzuweisung fuer nicht gemappte rollen-attribute")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2: SAML- und OIDC-Anmeldung koennen
|
|
||||||
// nebeneinander fuer unterschiedliche Tenants konfiguriert sein — hier durch
|
|
||||||
// zwei physisch getrennte Tenant-Datenbanken demonstriert (Modell C).
|
|
||||||
func TestSAMLAndOIDC_WorkInParallelForDifferentTenants(t *testing.T) {
|
|
||||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
||||||
if adminDSN == "" {
|
|
||||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
poolSAML, cleanupSAML := setupTest(t)
|
|
||||||
defer cleanupSAML()
|
|
||||||
|
|
||||||
poolOIDC, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("pool oidc: %v", err)
|
|
||||||
}
|
|
||||||
defer poolOIDC.Close()
|
|
||||||
if _, err := poolOIDC.Exec(ctx, schema+`
|
|
||||||
CREATE TABLE IF NOT EXISTS oidc_states (
|
|
||||||
state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("schema oidc: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tenant "saml-tenant": SAML konfiguriert.
|
|
||||||
idpKey := generateTestIdPKey(t)
|
|
||||||
samlVerifier := NewVerifier(&idpKey.PublicKey, "https://test-idp.example.com")
|
|
||||||
samlUsers := user.NewTenantUserStore(poolSAML)
|
|
||||||
samlRoles := rbac.NewStore(poolSAML)
|
|
||||||
samlMapping := ldapsync.NewRoleMappingStore(poolSAML)
|
|
||||||
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
|
||||||
|
|
||||||
samlEmail := fmt.Sprintf("saml-parallel-%d@example.com", time.Now().UnixNano())
|
|
||||||
samlResp, err := BuildResponse(idpKey, testAssertion("https://test-idp.example.com", samlEmail, nil))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build saml response: %v", err)
|
|
||||||
}
|
|
||||||
samlToken, err := CompleteSAMLLogin(ctx, samlVerifier, samlUsers, samlRoles, samlMapping, issuer, "saml-tenant", samlResp)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("saml-tenant login: %v", err)
|
|
||||||
}
|
|
||||||
if samlToken == "" {
|
|
||||||
t.Fatal("erwartet token fuer saml-tenant")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tenant "oidc-tenant": OIDC konfiguriert, voellig unabhaengige Datenbank.
|
|
||||||
oidcKey := generateTestIdPKey(t)
|
|
||||||
oidcVerifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &oidcKey.PublicKey}, "https://oidc-idp.example.com")
|
|
||||||
oidcStates := oidc.NewStateStore(poolOIDC)
|
|
||||||
oidcUsers := user.NewTenantUserStore(poolOIDC)
|
|
||||||
oidcRoles := rbac.NewStore(poolOIDC)
|
|
||||||
oidcMapping := ldapsync.NewRoleMappingStore(poolOIDC)
|
|
||||||
|
|
||||||
state, nonce, err := oidcStates.Generate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("oidc generate state: %v", err)
|
|
||||||
}
|
|
||||||
oidcEmail := fmt.Sprintf("oidc-parallel-%d@example.com", time.Now().UnixNano())
|
|
||||||
claims := oidc.Claims{
|
|
||||||
Email: oidcEmail,
|
|
||||||
Nonce: nonce,
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Issuer: "https://oidc-idp.example.com", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
idToken := signOIDCTestToken(t, oidcKey, claims)
|
|
||||||
|
|
||||||
oidcToken, err := oidc.CompleteOIDCLogin(ctx, oidcVerifier, oidcStates, oidcUsers, oidcRoles, oidcMapping, issuer, "oidc-tenant", state, idToken)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("oidc-tenant login: %v", err)
|
|
||||||
}
|
|
||||||
if oidcToken == "" {
|
|
||||||
t.Fatal("erwartet token fuer oidc-tenant")
|
|
||||||
}
|
|
||||||
|
|
||||||
if samlToken == oidcToken {
|
|
||||||
t.Fatal("tokens sollten unterschiedlich sein")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func signOIDCTestToken(t *testing.T, key *rsa.PrivateKey, claims oidc.Claims) string {
|
|
||||||
t.Helper()
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
|
||||||
token.Header["kid"] = "kid-1"
|
|
||||||
s, err := token.SignedString(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sign oidc test token: %v", err)
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package saml
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/xml"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrInvalidSignature = errors.New("saml: assertion-signatur ungueltig")
|
|
||||||
ErrNotYetValid = errors.New("saml: assertion noch nicht gueltig")
|
|
||||||
ErrExpired = errors.New("saml: assertion abgelaufen")
|
|
||||||
ErrWrongIssuer = errors.New("saml: unerwarteter aussteller")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Verifier prueft SAML-Responses gegen den oeffentlichen Schluessel eines
|
|
||||||
// konfigurierten IdP (Akzeptanzkriterium 1).
|
|
||||||
type Verifier struct {
|
|
||||||
idpPublicKey *rsa.PublicKey
|
|
||||||
idpIssuer string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewVerifier(idpPublicKey *rsa.PublicKey, idpIssuer string) *Verifier {
|
|
||||||
return &Verifier{idpPublicKey: idpPublicKey, idpIssuer: idpIssuer}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse dekodiert eine base64-kodierte SAMLResponse (wie sie im
|
|
||||||
// SAMLResponse-Formularfeld ankommt) in ihre XML-Struktur.
|
|
||||||
func Parse(rawBase64 string) (ResponseXML, error) {
|
|
||||||
data, err := base64.StdEncoding.DecodeString(rawBase64)
|
|
||||||
if err != nil {
|
|
||||||
return ResponseXML{}, fmt.Errorf("base64 dekodieren: %w", err)
|
|
||||||
}
|
|
||||||
var resp ResponseXML
|
|
||||||
if err := xml.Unmarshal(data, &resp); err != nil {
|
|
||||||
return ResponseXML{}, fmt.Errorf("saml-xml parsen: %w", err)
|
|
||||||
}
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify prueft Signatur, Gueltigkeitszeitraum und Aussteller
|
|
||||||
// (Akzeptanzkriterium 1).
|
|
||||||
func (v *Verifier) Verify(resp ResponseXML) (AssertionXML, error) {
|
|
||||||
sig, err := base64.StdEncoding.DecodeString(resp.SignatureValue)
|
|
||||||
if err != nil {
|
|
||||||
return AssertionXML{}, fmt.Errorf("signatur dekodieren: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
canonical, err := CanonicalAssertionBytes(resp.Assertion)
|
|
||||||
if err != nil {
|
|
||||||
return AssertionXML{}, err
|
|
||||||
}
|
|
||||||
hash := sha256.Sum256(canonical)
|
|
||||||
|
|
||||||
if err := rsa.VerifyPKCS1v15(v.idpPublicKey, crypto.SHA256, hash[:], sig); err != nil {
|
|
||||||
return AssertionXML{}, ErrInvalidSignature
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.Assertion.Issuer != v.idpIssuer {
|
|
||||||
return AssertionXML{}, fmt.Errorf("%w: %q", ErrWrongIssuer, resp.Assertion.Issuer)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := resp.Assertion.WithinValidity(time.Now()); err != nil {
|
|
||||||
return AssertionXML{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp.Assertion, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign signiert eine Assertion mit dem privaten IdP-Schluessel — wird vom
|
|
||||||
// (simulierten) Test-IdP verwendet, um eine gueltige Response zu erzeugen.
|
|
||||||
// Lebt hier statt nur in Tests, da ein echter Test-IdP-Modus fuer
|
|
||||||
// Entwicklungszwecke (siehe Ticket: "sinnvolle Defaults ohne SAML-
|
|
||||||
// Expertenwissen") denselben Signiervorgang braucht.
|
|
||||||
func Sign(priv *rsa.PrivateKey, assertion AssertionXML) (string, error) {
|
|
||||||
canonical, err := CanonicalAssertionBytes(assertion)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
hash := sha256.Sum256(canonical)
|
|
||||||
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, hash[:])
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("assertion signieren: %w", err)
|
|
||||||
}
|
|
||||||
return base64.StdEncoding.EncodeToString(sig), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildResponse verpackt eine signierte Assertion in eine base64-kodierte
|
|
||||||
// SAMLResponse, wie sie ein IdP im Browser-POST-Binding zurueckliefert.
|
|
||||||
func BuildResponse(priv *rsa.PrivateKey, assertion AssertionXML) (string, error) {
|
|
||||||
sig, err := Sign(priv, assertion)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
resp := ResponseXML{Assertion: assertion, SignatureValue: sig}
|
|
||||||
data, err := xml.Marshal(resp)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("response serialisieren: %w", err)
|
|
||||||
}
|
|
||||||
return base64.StdEncoding.EncodeToString(data), nil
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
package saml
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func generateTestIdPKey(t *testing.T) *rsa.PrivateKey {
|
|
||||||
t.Helper()
|
|
||||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate rsa key: %v", err)
|
|
||||||
}
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
func testAssertion(issuer, nameID string, roles []string) AssertionXML {
|
|
||||||
now := time.Now()
|
|
||||||
return AssertionXML{
|
|
||||||
Issuer: issuer,
|
|
||||||
NameID: nameID,
|
|
||||||
Conditions: conditionsXML{
|
|
||||||
NotBefore: now.Add(-time.Minute).Format(time.RFC3339),
|
|
||||||
NotOnOrAfter: now.Add(time.Hour).Format(time.RFC3339),
|
|
||||||
},
|
|
||||||
Attributes: []attributeXML{
|
|
||||||
{Name: "roles", Values: roles},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerify_AcceptsValidSignedAssertion(t *testing.T) {
|
|
||||||
key := generateTestIdPKey(t)
|
|
||||||
verifier := NewVerifier(&key.PublicKey, "https://idp.example.com")
|
|
||||||
|
|
||||||
rawResp, err := BuildResponse(key, testAssertion("https://idp.example.com", "person@example.com", []string{"sso-admins"}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build response: %v", err)
|
|
||||||
}
|
|
||||||
resp, err := Parse(rawResp)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertion, err := verifier.Verify(resp)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("verify: %v", err)
|
|
||||||
}
|
|
||||||
if assertion.NameID != "person@example.com" {
|
|
||||||
t.Fatalf("nameid = %q", assertion.NameID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerify_RejectsTamperedAssertion(t *testing.T) {
|
|
||||||
key := generateTestIdPKey(t)
|
|
||||||
verifier := NewVerifier(&key.PublicKey, "https://idp.example.com")
|
|
||||||
|
|
||||||
rawResp, err := BuildResponse(key, testAssertion("https://idp.example.com", "person@example.com", nil))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build response: %v", err)
|
|
||||||
}
|
|
||||||
resp, err := Parse(rawResp)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse: %v", err)
|
|
||||||
}
|
|
||||||
// Manipulation NACH dem Signieren: NameID aendern (Signatur bleibt die alte).
|
|
||||||
resp.Assertion.NameID = "angreifer@example.com"
|
|
||||||
|
|
||||||
if _, err := verifier.Verify(resp); err != ErrInvalidSignature {
|
|
||||||
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerify_RejectsWrongIdPKey(t *testing.T) {
|
|
||||||
realKey := generateTestIdPKey(t)
|
|
||||||
attackerKey := generateTestIdPKey(t)
|
|
||||||
verifier := NewVerifier(&realKey.PublicKey, "https://idp.example.com")
|
|
||||||
|
|
||||||
rawResp, err := BuildResponse(attackerKey, testAssertion("https://idp.example.com", "person@example.com", nil))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build response: %v", err)
|
|
||||||
}
|
|
||||||
resp, _ := Parse(rawResp)
|
|
||||||
|
|
||||||
if _, err := verifier.Verify(resp); err != ErrInvalidSignature {
|
|
||||||
t.Fatalf("erwartet ErrInvalidSignature bei fremd signierter assertion, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerify_RejectsExpiredAssertion(t *testing.T) {
|
|
||||||
key := generateTestIdPKey(t)
|
|
||||||
verifier := NewVerifier(&key.PublicKey, "https://idp.example.com")
|
|
||||||
|
|
||||||
expired := testAssertion("https://idp.example.com", "person@example.com", nil)
|
|
||||||
expired.Conditions.NotOnOrAfter = time.Now().Add(-time.Hour).Format(time.RFC3339)
|
|
||||||
|
|
||||||
rawResp, err := BuildResponse(key, expired)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build response: %v", err)
|
|
||||||
}
|
|
||||||
resp, _ := Parse(rawResp)
|
|
||||||
|
|
||||||
if _, err := verifier.Verify(resp); err != ErrExpired {
|
|
||||||
t.Fatalf("erwartet ErrExpired, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerify_RejectsWrongIssuer(t *testing.T) {
|
|
||||||
key := generateTestIdPKey(t)
|
|
||||||
verifier := NewVerifier(&key.PublicKey, "https://idp.example.com")
|
|
||||||
|
|
||||||
rawResp, err := BuildResponse(key, testAssertion("https://anderer-idp.example.com", "person@example.com", nil))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build response: %v", err)
|
|
||||||
}
|
|
||||||
resp, _ := Parse(rawResp)
|
|
||||||
|
|
||||||
if _, err := verifier.Verify(resp); err == nil {
|
|
||||||
t.Fatal("erwartet fehler bei unerwartetem aussteller")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
// Package serviceaccount implementiert Core IAM-09: Service-Accounts als
|
|
||||||
// eigenstaendige Identitaeten (getrennt von menschlichen Benutzern, siehe
|
|
||||||
// internal/user) sowie API-Token mit Scopes und optionaler Ablaufzeit zur
|
|
||||||
// programmatischen Authentifizierung.
|
|
||||||
package serviceaccount
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrTokenInvalid = errors.New("serviceaccount: token ungueltig, widerrufen oder abgelaufen")
|
|
||||||
ErrScopeInsufficient = errors.New("serviceaccount: token hat nicht den erforderlichen scope")
|
|
||||||
ErrServiceAccountNotFound = errors.New("serviceaccount: nicht gefunden")
|
|
||||||
)
|
|
||||||
|
|
||||||
type ServiceAccount struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Token struct {
|
|
||||||
ID string
|
|
||||||
ServiceAccountID string
|
|
||||||
Scopes []string
|
|
||||||
ExpiresAt *time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type Store struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store {
|
|
||||||
return &Store{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateServiceAccount legt eine neue, eigenstaendige Identitaet an
|
|
||||||
// (Akzeptanzkriterium 1) — lebt in service_accounts, nicht in users.
|
|
||||||
func (s *Store) CreateServiceAccount(ctx context.Context, name string) (ServiceAccount, error) {
|
|
||||||
if name == "" {
|
|
||||||
return ServiceAccount{}, errors.New("serviceaccount: name darf nicht leer sein")
|
|
||||||
}
|
|
||||||
var sa ServiceAccount
|
|
||||||
sa.Name = name
|
|
||||||
if err := s.pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO service_accounts (name) VALUES ($1) RETURNING id
|
|
||||||
`, name).Scan(&sa.ID); err != nil {
|
|
||||||
return ServiceAccount{}, fmt.Errorf("service-account anlegen: %w", err)
|
|
||||||
}
|
|
||||||
return sa, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IssueToken erzeugt ein neues API-Token fuer einen Service-Account.
|
|
||||||
// scopes beschraenkt, wofuer das Token verwendet werden darf; ttl ist
|
|
||||||
// optional (nil = kein Ablauf) — beides Akzeptanzkriterium 2. Der
|
|
||||||
// Klartext-Token wird NUR hier zurueckgegeben, gespeichert wird ausschliesslich
|
|
||||||
// der Hash (Akzeptanzkriterium 1 / Pruefung 1).
|
|
||||||
func (s *Store) IssueToken(ctx context.Context, serviceAccountID string, scopes []string, ttl *time.Duration) (id, plaintext string, err error) {
|
|
||||||
plaintext, err = randomToken()
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("token erzeugen: %w", err)
|
|
||||||
}
|
|
||||||
hash := hashToken(plaintext)
|
|
||||||
if scopes == nil {
|
|
||||||
scopes = []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var expiresAt *time.Time
|
|
||||||
if ttl != nil {
|
|
||||||
t := time.Now().Add(*ttl)
|
|
||||||
expiresAt = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO api_tokens (service_account_id, token_hash, scopes, expires_at)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id
|
|
||||||
`, serviceAccountID, hash, scopes, expiresAt).Scan(&id)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("token speichern: %w", err)
|
|
||||||
}
|
|
||||||
return id, plaintext, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify prueft ein Token gegen einen benoetigten Scope. Widerruf und Ablauf
|
|
||||||
// werden bei JEDEM Aufruf direkt gegen die Datenbank geprueft — es gibt
|
|
||||||
// keinen Cache, ein widerrufenes Token wird beim naechsten Request sofort
|
|
||||||
// abgewiesen (Akzeptanzkriterium 3). requiredScope == "" ueberspringt die
|
|
||||||
// Scope-Pruefung.
|
|
||||||
func (s *Store) Verify(ctx context.Context, tokenString, requiredScope string) (Token, error) {
|
|
||||||
hash := hashToken(tokenString)
|
|
||||||
|
|
||||||
var tok Token
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT id, service_account_id, scopes, expires_at
|
|
||||||
FROM api_tokens
|
|
||||||
WHERE token_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())
|
|
||||||
`, hash).Scan(&tok.ID, &tok.ServiceAccountID, &tok.Scopes, &tok.ExpiresAt)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Token{}, ErrTokenInvalid
|
|
||||||
}
|
|
||||||
return Token{}, fmt.Errorf("token pruefen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if requiredScope != "" && !hasScope(tok.Scopes, requiredScope) {
|
|
||||||
return Token{}, ErrScopeInsufficient
|
|
||||||
}
|
|
||||||
return tok, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revoke widerruft ein Token sofort — Verify schlaegt ab dem naechsten
|
|
||||||
// Aufruf fehl (Akzeptanzkriterium 3 / Pruefung 2).
|
|
||||||
func (s *Store) Revoke(ctx context.Context, tokenID string) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `UPDATE api_tokens SET revoked_at = now() WHERE id = $1`, tokenID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("token widerrufen: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasScope(scopes []string, want string) bool {
|
|
||||||
for _, s := range scopes {
|
|
||||||
if s == want {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
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[:]
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
package serviceaccount
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupTest(t *testing.T) (*Store, *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 service_accounts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), service_account_id UUID NOT NULL REFERENCES service_accounts(id),
|
|
||||||
token_hash BYTEA NOT NULL UNIQUE, scopes TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := func() { pool.Close() }
|
|
||||||
return NewStore(pool), pool, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1: Service-Account anlegen, eigenes gehashtes Token erhalten.
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 1: Token ausschliesslich gehasht gespeichert.
|
|
||||||
func TestIssueToken_StoresOnlyHash(t *testing.T) {
|
|
||||||
store, pool, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
sa, err := store.CreateServiceAccount(ctx, "ci-pipeline")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create service account: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenID, plaintext, err := store.IssueToken(ctx, sa.ID, []string{"read"}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue token: %v", err)
|
|
||||||
}
|
|
||||||
if plaintext == "" {
|
|
||||||
t.Fatal("erwartet nicht-leeren klartext-token")
|
|
||||||
}
|
|
||||||
|
|
||||||
var storedHash []byte
|
|
||||||
if err := pool.QueryRow(ctx, `SELECT token_hash FROM api_tokens WHERE id = $1`, tokenID).Scan(&storedHash); err != nil {
|
|
||||||
t.Fatalf("hash lesen: %v", err)
|
|
||||||
}
|
|
||||||
if string(storedHash) == plaintext {
|
|
||||||
t.Fatal("token_hash enthaelt den klartext-token — verstoss gegen akzeptanzkriterium 1")
|
|
||||||
}
|
|
||||||
if len(storedHash) != 32 { // sha256
|
|
||||||
t.Fatalf("erwartet 32-byte sha256-hash, habe %d bytes", len(storedHash))
|
|
||||||
}
|
|
||||||
|
|
||||||
tok, err := store.Verify(ctx, plaintext, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("verify: %v", err)
|
|
||||||
}
|
|
||||||
if tok.ServiceAccountID != sa.ID {
|
|
||||||
t.Fatalf("service account id = %q, want %q", tok.ServiceAccountID, sa.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 3: Scope-Verletzung wird abgewiesen.
|
|
||||||
func TestVerify_RejectsInsufficientScope(t *testing.T) {
|
|
||||||
store, _, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
sa, err := store.CreateServiceAccount(ctx, "readonly-bot")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create service account: %v", err)
|
|
||||||
}
|
|
||||||
_, plaintext, err := store.IssueToken(ctx, sa.ID, []string{"read"}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.Verify(ctx, plaintext, "read"); err != nil {
|
|
||||||
t.Fatalf("erwartet erfolg fuer passenden scope: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.Verify(ctx, plaintext, "write"); !errors.Is(err, ErrScopeInsufficient) {
|
|
||||||
t.Fatalf("erwartet ErrScopeInsufficient fuer fehlenden scope, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2: optionale zeitliche Befristung wird durchgesetzt.
|
|
||||||
func TestVerify_RejectsExpiredToken(t *testing.T) {
|
|
||||||
store, _, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
sa, err := store.CreateServiceAccount(ctx, "temp-bot")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create service account: %v", err)
|
|
||||||
}
|
|
||||||
ttl := -1 * time.Second // sofort abgelaufen
|
|
||||||
_, plaintext, err := store.IssueToken(ctx, sa.ID, nil, &ttl)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.Verify(ctx, plaintext, ""); !errors.Is(err, ErrTokenInvalid) {
|
|
||||||
t.Fatalf("erwartet ErrTokenInvalid bei abgelaufenem token, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 2: widerrufenes Token wird sofort abgewiesen.
|
|
||||||
func TestRevoke_TakesEffectImmediately(t *testing.T) {
|
|
||||||
store, _, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
sa, err := store.CreateServiceAccount(ctx, "revoke-bot")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create service account: %v", err)
|
|
||||||
}
|
|
||||||
tokenID, plaintext, err := store.IssueToken(ctx, sa.ID, nil, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.Verify(ctx, plaintext, ""); err != nil {
|
|
||||||
t.Fatalf("verify vor widerruf: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.Revoke(ctx, tokenID); err != nil {
|
|
||||||
t.Fatalf("revoke: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.Verify(ctx, plaintext, ""); !errors.Is(err, ErrTokenInvalid) {
|
|
||||||
t.Fatalf("erwartet ErrTokenInvalid sofort nach widerruf, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerify_RejectsUnknownToken(t *testing.T) {
|
|
||||||
store, _, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if _, err := store.Verify(ctx, "niemals-ausgestelltes-token", ""); !errors.Is(err, ErrTokenInvalid) {
|
|
||||||
t.Fatalf("erwartet ErrTokenInvalid, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package session
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LoginAndCreateSession prueft die Anmeldedaten mit demselben timing-safen
|
|
||||||
// bcrypt-Vergleich wie IAM-02s LoginService (kein zweiter Credential-Check),
|
|
||||||
// erzeugt bei Erfolg aber eine widerrufbare Sitzung statt eines
|
|
||||||
// zustandslosen JWT — das ist der Bearer-Token fuer diese Selbstbedienungs-
|
|
||||||
// Funktion.
|
|
||||||
func LoginAndCreateSession(ctx context.Context, users *user.TenantUserStore, sessions *Store, email, password, deviceInfo, ip string) (sessionToken string, err error) {
|
|
||||||
creds, err := users.GetByEmailForAuth(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("anmeldedaten pruefen: %w", err)
|
|
||||||
}
|
|
||||||
if creds.User.Status != user.StatusActive {
|
|
||||||
return "", fmt.Errorf("konto nicht aktiv")
|
|
||||||
}
|
|
||||||
// Wiederverwendung von IAM-02s timing-safem bcrypt-Vergleich, kein
|
|
||||||
// zweiter Passwort-Pruefmechanismus.
|
|
||||||
if !auth.VerifyPassword(creds.PasswordHash, password) {
|
|
||||||
return "", fmt.Errorf("anmeldedaten ungueltig")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, sessionToken, err = sessions.Create(ctx, creds.User.ID, deviceInfo, ip)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("sitzung anlegen: %w", err)
|
|
||||||
}
|
|
||||||
return sessionToken, nil
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
// Package session implementiert Core IAM-12: Selbstbedienungs-Sitzungs-
|
|
||||||
// uebersicht mit Geraete-/Zugriffsinformationen und gezieltem Widerruf.
|
|
||||||
// Bewusst serverseitig und widerrufbar — ein separater Mechanismus neben
|
|
||||||
// IAM-02s zustandslosem JWT (das fuer schnelle Modul-zu-Modul-Verifikation
|
|
||||||
// ohne Core-Rueckfrage gewaehlt wurde, siehe API-05). Fuer den
|
|
||||||
// Selbstbedienungs-Anwendungsfall hier ist sofortige Widerrufbarkeit
|
|
||||||
// wichtiger als Zustandslosigkeit.
|
|
||||||
package session
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrSessionInvalid = errors.New("session: sitzung ungueltig oder widerrufen")
|
|
||||||
|
|
||||||
type Session struct {
|
|
||||||
ID string
|
|
||||||
UserID string
|
|
||||||
DeviceInfo string
|
|
||||||
IP string
|
|
||||||
CreatedAt time.Time
|
|
||||||
LastSeenAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type Store struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store {
|
|
||||||
return &Store{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create legt eine neue Sitzung an und liefert den Klartext-Token NUR an den
|
|
||||||
// Aufrufer zurueck (als Cookie-Wert) — gespeichert wird ausschliesslich der Hash.
|
|
||||||
func (s *Store) Create(ctx context.Context, userID, deviceInfo, ip string) (id, token string, err error) {
|
|
||||||
token, err = randomToken()
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("token erzeugen: %w", err)
|
|
||||||
}
|
|
||||||
hash := hashToken(token)
|
|
||||||
|
|
||||||
err = s.pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO sessions (user_id, token_hash, device_info, ip)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id
|
|
||||||
`, userID, hash, deviceInfo, ip).Scan(&id)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("sitzung anlegen: %w", err)
|
|
||||||
}
|
|
||||||
return id, token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate prueft ein Sitzungs-Token direkt gegen die Datenbank — kein
|
|
||||||
// Cache, ein widerrufenes Token ist beim naechsten Zugriffsversuch sofort
|
|
||||||
// ungueltig (Akzeptanzkriterium 2). Erfolgreiche Validierung aktualisiert
|
|
||||||
// last_seen_at (Akzeptanzkriterium 1: letzter Zugriff).
|
|
||||||
func (s *Store) Validate(ctx context.Context, token string) (Session, error) {
|
|
||||||
hash := hashToken(token)
|
|
||||||
|
|
||||||
var sess Session
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
|
||||||
UPDATE sessions SET last_seen_at = now()
|
|
||||||
WHERE token_hash = $1 AND revoked_at IS NULL
|
|
||||||
RETURNING id, user_id, device_info, ip, created_at, last_seen_at
|
|
||||||
`, hash).Scan(&sess.ID, &sess.UserID, &sess.DeviceInfo, &sess.IP, &sess.CreatedAt, &sess.LastSeenAt)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Session{}, ErrSessionInvalid
|
|
||||||
}
|
|
||||||
return Session{}, fmt.Errorf("sitzung pruefen: %w", err)
|
|
||||||
}
|
|
||||||
return sess, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListForUser liefert alle NICHT widerrufenen Sitzungen eines Benutzers
|
|
||||||
// (Akzeptanzkriterium 1).
|
|
||||||
func (s *Store) ListForUser(ctx context.Context, userID string) ([]Session, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT id, user_id, device_info, ip, created_at, last_seen_at
|
|
||||||
FROM sessions WHERE user_id = $1 AND revoked_at IS NULL
|
|
||||||
ORDER BY last_seen_at DESC
|
|
||||||
`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("sitzungen auflisten: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []Session
|
|
||||||
for rows.Next() {
|
|
||||||
var sess Session
|
|
||||||
if err := rows.Scan(&sess.ID, &sess.UserID, &sess.DeviceInfo, &sess.IP, &sess.CreatedAt, &sess.LastSeenAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("sitzung lesen: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, sess)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revoke widerruft eine einzelne Sitzung sofort (Akzeptanzkriterium 2).
|
|
||||||
func (s *Store) Revoke(ctx context.Context, sessionID string) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `UPDATE sessions SET revoked_at = now() WHERE id = $1`, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("sitzung widerrufen: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RevokeAllExcept beendet alle Sitzungen eines Benutzers ausser der
|
|
||||||
// angegebenen (Akzeptanzkriterium 3: "alle anderen beenden", aktuelle
|
|
||||||
// Sitzung bleibt bestehen).
|
|
||||||
func (s *Store) RevokeAllExcept(ctx context.Context, userID, keepSessionID string) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE sessions SET revoked_at = now()
|
|
||||||
WHERE user_id = $1 AND id <> $2 AND revoked_at IS NULL
|
|
||||||
`, userID, keepSessionID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("andere sitzungen widerrufen: %w", err)
|
|
||||||
}
|
|
||||||
return 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[:]
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
package session
|
|
||||||
|
|
||||||
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 sessions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
|
|
||||||
token_hash BYTEA NOT NULL UNIQUE, device_info TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
revoked_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userStore := user.NewTenantUserStore(pool)
|
|
||||||
email := fmt.Sprintf("session-test-%d@example.com", time.Now().UnixNano())
|
|
||||||
u, err := userStore.Create(ctx, email, "Session Test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create user: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := func() { pool.Close() }
|
|
||||||
return NewStore(pool), userStore, u.ID, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 1: zwei Sitzungen angelegt, beide in der
|
|
||||||
// Uebersicht sichtbar.
|
|
||||||
func TestListForUser_ShowsAllActiveSessions(t *testing.T) {
|
|
||||||
store, _, userID, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if _, _, err := store.Create(ctx, userID, "Chrome auf Windows", "10.0.0.1"); err != nil {
|
|
||||||
t.Fatalf("create session 1: %v", err)
|
|
||||||
}
|
|
||||||
if _, _, err := store.Create(ctx, userID, "Safari auf iPhone", "10.0.0.2"); err != nil {
|
|
||||||
t.Fatalf("create session 2: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sessions, err := store.ListForUser(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
if len(sessions) != 2 {
|
|
||||||
t.Fatalf("erwartet 2 sitzungen, habe %d", len(sessions))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2: Widerruf macht das Token sofort ungueltig.
|
|
||||||
func TestRevoke_InvalidatesTokenImmediately(t *testing.T) {
|
|
||||||
store, _, userID, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
sessionID, token, err := store.Create(ctx, userID, "Firefox", "10.0.0.3")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.Validate(ctx, token); err != nil {
|
|
||||||
t.Fatalf("validate vor widerruf: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.Revoke(ctx, sessionID); err != nil {
|
|
||||||
t.Fatalf("revoke: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.Validate(ctx, token); !errors.Is(err, ErrSessionInvalid) {
|
|
||||||
t.Fatalf("erwartet ErrSessionInvalid sofort nach widerruf, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 3: "alle anderen beenden" funktioniert,
|
|
||||||
// aktuelle Sitzung bleibt aktiv.
|
|
||||||
func TestRevokeAllExcept_KeepsCurrentSessionActive(t *testing.T) {
|
|
||||||
store, _, userID, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
currentID, currentToken, err := store.Create(ctx, userID, "aktuelles geraet", "10.0.0.4")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create current: %v", err)
|
|
||||||
}
|
|
||||||
_, otherToken1, err := store.Create(ctx, userID, "anderes geraet 1", "10.0.0.5")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create other 1: %v", err)
|
|
||||||
}
|
|
||||||
_, otherToken2, err := store.Create(ctx, userID, "anderes geraet 2", "10.0.0.6")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create other 2: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.RevokeAllExcept(ctx, userID, currentID); err != nil {
|
|
||||||
t.Fatalf("revoke all except: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.Validate(ctx, currentToken); err != nil {
|
|
||||||
t.Fatalf("aktuelle sitzung sollte weiterhin gueltig sein: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.Validate(ctx, otherToken1); !errors.Is(err, ErrSessionInvalid) {
|
|
||||||
t.Fatalf("andere sitzung 1 sollte widerrufen sein, habe %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.Validate(ctx, otherToken2); !errors.Is(err, ErrSessionInvalid) {
|
|
||||||
t.Fatalf("andere sitzung 2 sollte widerrufen sein, habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining, err := store.ListForUser(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
if len(remaining) != 1 || remaining[0].ID != currentID {
|
|
||||||
t.Fatalf("erwartet genau die aktuelle sitzung uebrig, habe %+v", remaining)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginAndCreateSession(t *testing.T) {
|
|
||||||
store, userStore, userID, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
u, err := userStore.Get(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get user: %v", err)
|
|
||||||
}
|
|
||||||
hash, err := auth.HashPassword("korrektes-passwort")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("hash: %v", err)
|
|
||||||
}
|
|
||||||
if err := userStore.SetPasswordHash(ctx, userID, hash); err != nil {
|
|
||||||
t.Fatalf("set password: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := LoginAndCreateSession(ctx, userStore, store, u.Email, "korrektes-passwort", "Test-Client", "127.0.0.1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("login and create session: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.Validate(ctx, token); err != nil {
|
|
||||||
t.Fatalf("validate: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := LoginAndCreateSession(ctx, userStore, store, u.Email, "falsches-passwort", "Test-Client", "127.0.0.1"); err == nil {
|
|
||||||
t.Fatal("erwartet fehler bei falschem passwort")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01
|
|
||||||
// (REST-API-Grundgerüst & Versionierung) und TEN-02 (Self-Service-Onboarding).
|
|
||||||
// Auth/Rate-Limiting/Versionierung selbst sind ausdruecklich nicht Teil von
|
|
||||||
// TEN-01 und werden dort nachgezogen.
|
|
||||||
type Handler struct {
|
|
||||||
provisioner *Provisioner
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHandler(p *Provisioner) *Handler {
|
|
||||||
return &Handler{provisioner: p}
|
|
||||||
}
|
|
||||||
|
|
||||||
type createTenantRequest struct {
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) CreateTenant(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req createTenantRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := h.provisioner.Provision(r.Context(), req.Slug, req.Name)
|
|
||||||
if err != nil {
|
|
||||||
if err == ErrInvalidSlug {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Error(w, "tenant konnte nicht angelegt werden", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_ = json.NewEncoder(w).Encode(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// OnboardingHandler stellt den Self-Service-Endpunkt aus TEN-02 bereit —
|
|
||||||
// eigener Handler statt Erweiterung von Handler.CreateTenant, damit die
|
|
||||||
// TEN-01-Schnittstelle (reines Provisioning ohne Admin-Konto) unveraendert
|
|
||||||
// bleibt.
|
|
||||||
type OnboardingHandler struct {
|
|
||||||
service *OnboardingService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewOnboardingHandler(s *OnboardingService) *OnboardingHandler {
|
|
||||||
return &OnboardingHandler{service: s}
|
|
||||||
}
|
|
||||||
|
|
||||||
type onboardRequest struct {
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
AdminEmail string `json:"admin_email"`
|
|
||||||
AdminName string `json:"admin_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *OnboardingHandler) Onboard(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req onboardRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.service.Onboard(r.Context(), req.Slug, req.Name, req.AdminEmail, req.AdminName)
|
|
||||||
if err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, ErrInvalidSlug),
|
|
||||||
errors.Is(err, ErrMissingField),
|
|
||||||
errors.Is(err, user.ErrInvalidEmail):
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
case errors.Is(err, ErrTenantExists):
|
|
||||||
http.Error(w, err.Error(), http.StatusConflict)
|
|
||||||
default:
|
|
||||||
http.Error(w, "mandant konnte nicht angelegt werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_ = json.NewEncoder(w).Encode(result)
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrTenantNotFound = errors.New("tenant: nicht gefunden")
|
|
||||||
ErrInvalidTransition = errors.New("tenant: ungueltiger zustandsuebergang")
|
|
||||||
// ErrTenantNotActive wird von Lifecycle.CheckActive verwendet — bewusst
|
|
||||||
// EIN Fehler fuer suspendiert/zur-Loeschung-vorgemerkt/geloescht, da der
|
|
||||||
// Aufrufer (z.B. Login) nur wissen muss "kein Zugriff", nicht welcher der
|
|
||||||
// Nicht-aktiv-Zustaende genau vorliegt.
|
|
||||||
ErrTenantNotActive = errors.New("tenant: nicht aktiv")
|
|
||||||
)
|
|
||||||
|
|
||||||
func scanTenantWithLifecycle(row pgx.Row) (Tenant, error) {
|
|
||||||
var t Tenant
|
|
||||||
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status,
|
|
||||||
&t.CreatedAt, &t.PreviousStatus, &t.DeletionScheduledAt); err != nil {
|
|
||||||
return Tenant{}, err
|
|
||||||
}
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// transition fuehrt einen bewachten Zustandsuebergang aus: das UPDATE greift
|
|
||||||
// nur, wenn der aktuelle Status einer von allowedFrom ist (atomarer
|
|
||||||
// Check-and-Set, kein Race zwischen Lesen und Schreiben). Greift es nicht,
|
|
||||||
// wird zwischen "Tenant existiert nicht" und "Uebergang nicht erlaubt"
|
|
||||||
// unterschieden, damit AC1 ("ungueltige Uebergaenge werden abgewiesen") einen
|
|
||||||
// sprechenden Fehler liefert statt eines stillen No-Ops.
|
|
||||||
func (r *Registry) transition(ctx context.Context, slug string, allowedFrom []Status, to Status, previousStatus *string, deletionAt *time.Time) (Tenant, error) {
|
|
||||||
from := make([]string, len(allowedFrom))
|
|
||||||
for i, s := range allowedFrom {
|
|
||||||
from[i] = string(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
row := r.pool.QueryRow(ctx, `
|
|
||||||
UPDATE tenants
|
|
||||||
SET status = $2, previous_status = $3, deletion_scheduled_at = $4
|
|
||||||
WHERE slug = $1 AND status = ANY($5)
|
|
||||||
RETURNING id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
|
|
||||||
`, slug, string(to), previousStatus, deletionAt, from)
|
|
||||||
|
|
||||||
t, err := scanTenantWithLifecycle(row)
|
|
||||||
if err == nil {
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Tenant{}, fmt.Errorf("zustandsuebergang: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
existing, getErr := r.GetBySlug(ctx, slug)
|
|
||||||
if getErr != nil {
|
|
||||||
return Tenant{}, ErrTenantNotFound
|
|
||||||
}
|
|
||||||
return Tenant{}, fmt.Errorf("%w: von %q nach %q (aktuell: %q)", ErrInvalidTransition, allowedFrom, to, existing.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suspend haelt die Daten des Mandanten unveraendert, sperrt aber den Zugriff
|
|
||||||
// (Akzeptanzkriterium 1) — es findet keine Loeschung/Migration statt.
|
|
||||||
func (r *Registry) Suspend(ctx context.Context, slug string) (Tenant, error) {
|
|
||||||
return r.transition(ctx, slug, []Status{StatusActive}, StatusSuspended, nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reactivate stellt den Zustand vor der Suspendierung vollstaendig wieder her
|
|
||||||
// (Akzeptanzkriterium 2) — da Suspend keine weiteren Daten veraendert, genuegt
|
|
||||||
// die Rueckkehr nach StatusActive.
|
|
||||||
func (r *Registry) Reactivate(ctx context.Context, slug string) (Tenant, error) {
|
|
||||||
return r.transition(ctx, slug, []Status{StatusSuspended}, StatusActive, nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScheduleDeletion merkt den Mandanten zur Loeschung vor und startet die
|
|
||||||
// Karenzzeit (Akzeptanzkriterium 3). previous_status wird festgehalten, damit
|
|
||||||
// CancelDeletion exakt dorthin zurueckkehren kann (aktiv ODER suspendiert).
|
|
||||||
func (r *Registry) ScheduleDeletion(ctx context.Context, slug string, grace time.Duration) (Tenant, error) {
|
|
||||||
existing, err := r.GetBySlug(ctx, slug)
|
|
||||||
if err != nil {
|
|
||||||
return Tenant{}, ErrTenantNotFound
|
|
||||||
}
|
|
||||||
prev := string(existing.Status)
|
|
||||||
deletionAt := time.Now().Add(grace)
|
|
||||||
return r.transition(ctx, slug, []Status{StatusActive, StatusSuspended}, StatusPendingDeletion, &prev, &deletionAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelDeletion widerruft eine Loeschvormerkung innerhalb der Karenzzeit und
|
|
||||||
// stellt exakt den zuvor gesicherten Zustand wieder her.
|
|
||||||
func (r *Registry) CancelDeletion(ctx context.Context, slug string) (Tenant, error) {
|
|
||||||
existing, err := r.GetBySlug(ctx, slug)
|
|
||||||
if err != nil {
|
|
||||||
return Tenant{}, ErrTenantNotFound
|
|
||||||
}
|
|
||||||
if existing.Status != StatusPendingDeletion || existing.PreviousStatus == nil {
|
|
||||||
return Tenant{}, fmt.Errorf("%w: von %q nach aktiv/suspendiert (aktuell: %q)", ErrInvalidTransition, StatusPendingDeletion, existing.Status)
|
|
||||||
}
|
|
||||||
restoreTo := Status(*existing.PreviousStatus)
|
|
||||||
return r.transition(ctx, slug, []Status{StatusPendingDeletion}, restoreTo, nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifecycle fuehrt die tatsaechliche, physische Loeschung nach Ablauf der
|
|
||||||
// Karenzzeit aus (Datenbank-Drop) und stellt die Zugriffsschutz-Pruefung
|
|
||||||
// bereit. Getrennt von Registry, weil hierfuer zusaetzlich der adminPool
|
|
||||||
// (fuer DROP DATABASE) noetig ist, siehe internal/tenant.Provisioner.
|
|
||||||
type Lifecycle struct {
|
|
||||||
registry *Registry
|
|
||||||
adminPool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewLifecycle(registry *Registry, adminPool *pgxpool.Pool) *Lifecycle {
|
|
||||||
return &Lifecycle{registry: registry, adminPool: adminPool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckActive verweigert Zugriff fuer jeden Nicht-aktiv-Zustand und loggt den
|
|
||||||
// Vorgang strukturiert (Akzeptanzkriterium 1 / Pruefung 2).
|
|
||||||
func (l *Lifecycle) CheckActive(ctx context.Context, slug string) error {
|
|
||||||
t, err := l.registry.GetBySlug(ctx, slug)
|
|
||||||
if err != nil {
|
|
||||||
return ErrTenantNotFound
|
|
||||||
}
|
|
||||||
if t.Status != StatusActive {
|
|
||||||
slog.Warn("zugriff auf nicht-aktiven mandanten verweigert",
|
|
||||||
"tenant_slug", slug, "tenant_status", t.Status)
|
|
||||||
return ErrTenantNotActive
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProcessDueDeletions loescht alle Mandanten-Datenbanken, deren Karenzzeit
|
|
||||||
// abgelaufen ist (Akzeptanzkriterium 3 / Pruefung 3). FOR UPDATE SKIP LOCKED
|
|
||||||
// folgt der projektweiten Postgres-Jobqueue-Konvention (siehe
|
|
||||||
// SKALIERUNGSKONZEPT.md) und macht die Funktion sicher fuer mehrere parallel
|
|
||||||
// laufende Core-Instanzen.
|
|
||||||
func (l *Lifecycle) ProcessDueDeletions(ctx context.Context) (int, error) {
|
|
||||||
tx, err := l.registry.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("sweep-transaktion starten: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = tx.Rollback(ctx) }()
|
|
||||||
|
|
||||||
rows, err := tx.Query(ctx, `
|
|
||||||
SELECT id, db_name FROM tenants
|
|
||||||
WHERE status = $1 AND deletion_scheduled_at <= now()
|
|
||||||
FOR UPDATE SKIP LOCKED
|
|
||||||
`, string(StatusPendingDeletion))
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("faellige loeschungen abfragen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
type due struct{ id, dbName string }
|
|
||||||
var candidates []due
|
|
||||||
for rows.Next() {
|
|
||||||
var d due
|
|
||||||
if err := rows.Scan(&d.id, &d.dbName); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
return 0, fmt.Errorf("faellige loeschung lesen: %w", err)
|
|
||||||
}
|
|
||||||
candidates = append(candidates, d)
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
processed := 0
|
|
||||||
for _, c := range candidates {
|
|
||||||
if _, err := l.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, c.dbName)); err != nil {
|
|
||||||
return processed, fmt.Errorf("tenant-datenbank %q loeschen: %w", c.dbName, err)
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
UPDATE tenants SET status = $2, previous_status = NULL, deletion_scheduled_at = NULL
|
|
||||||
WHERE id = $1
|
|
||||||
`, c.id, string(StatusDeleted)); err != nil {
|
|
||||||
return processed, fmt.Errorf("tenant %q als geloescht markieren: %w", c.id, err)
|
|
||||||
}
|
|
||||||
processed++
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return 0, fmt.Errorf("sweep-transaktion committen: %w", err)
|
|
||||||
}
|
|
||||||
return processed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunSweeper triggert ProcessDueDeletions periodisch, bis ctx beendet wird —
|
|
||||||
// die "In-Prozess-Worker-Goroutine" aus der projektweiten Jobqueue-Konvention.
|
|
||||||
func (l *Lifecycle) RunSweeper(ctx context.Context, interval time.Duration) {
|
|
||||||
ticker := time.NewTicker(interval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
if _, err := l.ProcessDueDeletions(ctx); err != nil {
|
|
||||||
slog.Error("tenant-loeschung-sweep fehlgeschlagen", "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newLifecycleTestSetup(t *testing.T) (*Registry, *Lifecycle, *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()
|
|
||||||
|
|
||||||
adminPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("admin pool: %v", err)
|
|
||||||
}
|
|
||||||
registryPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("registry pool: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := registryPool.Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS tenants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
slug TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
db_name TEXT NOT NULL UNIQUE,
|
|
||||||
db_dsn TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
previous_status TEXT,
|
|
||||||
deletion_scheduled_at TIMESTAMPTZ
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("registry-schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
registry := NewRegistry(registryPool)
|
|
||||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
||||||
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
lifecycle := NewLifecycle(registry, adminPool)
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
registryPool.Close()
|
|
||||||
adminPool.Close()
|
|
||||||
}
|
|
||||||
_ = provisioner
|
|
||||||
return registry, lifecycle, adminPool, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
func provisionTestTenant(t *testing.T, registry *Registry, adminPool *pgxpool.Pool, slug string) {
|
|
||||||
t.Helper()
|
|
||||||
dsnTemplate := strings.Replace(os.Getenv("TEST_ADMIN_DSN"), "/postgres?", "/%s?", 1)
|
|
||||||
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
if _, err := provisioner.Provision(context.Background(), slug, slug); err != nil {
|
|
||||||
t.Fatalf("provision %s: %v", slug, err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
ctx := context.Background()
|
|
||||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
|
|
||||||
_, _ = registry.pool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 (Suspend) + 2 (Reactivate) + Pruefung 1 (Uebergaenge).
|
|
||||||
func TestLifecycle_SuspendAndReactivate(t *testing.T) {
|
|
||||||
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
||||||
defer cleanup()
|
|
||||||
provisionTestTenant(t, registry, adminPool, "lc_suspend")
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
suspended, err := registry.Suspend(ctx, "lc_suspend")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("suspend: %v", err)
|
|
||||||
}
|
|
||||||
if suspended.Status != StatusSuspended {
|
|
||||||
t.Fatalf("status = %q, want suspended", suspended.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
reactivated, err := registry.Reactivate(ctx, "lc_suspend")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reactivate: %v", err)
|
|
||||||
}
|
|
||||||
if reactivated.Status != StatusActive {
|
|
||||||
t.Fatalf("status = %q, want active", reactivated.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pruefung 1: ungueltige Uebergaenge werden abgewiesen.
|
|
||||||
func TestLifecycle_RejectsInvalidTransitions(t *testing.T) {
|
|
||||||
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
||||||
defer cleanup()
|
|
||||||
provisionTestTenant(t, registry, adminPool, "lc_invalid")
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Reactivate auf einem bereits aktiven Tenant ist kein gueltiger Uebergang.
|
|
||||||
if _, err := registry.Reactivate(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
|
||||||
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := registry.Suspend(ctx, "lc_invalid"); err != nil {
|
|
||||||
t.Fatalf("suspend: %v", err)
|
|
||||||
}
|
|
||||||
// Suspend auf einem bereits suspendierten Tenant ist ebenfalls ungueltig.
|
|
||||||
if _, err := registry.Suspend(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
|
||||||
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelDeletion ohne vorherige Loeschvormerkung ist ungueltig.
|
|
||||||
if _, err := registry.CancelDeletion(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
|
||||||
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := registry.Suspend(ctx, "unbekannter-slug-xyz"); !errors.Is(err, ErrTenantNotFound) {
|
|
||||||
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3: Loeschung zweistufig mit Karenzzeit, innerhalb der
|
|
||||||
// Frist widerrufbar — sowohl aus 'active' als auch aus 'suspended' heraus,
|
|
||||||
// mit exakter Wiederherstellung des jeweiligen Vorzustands.
|
|
||||||
func TestLifecycle_ScheduleAndCancelDeletion_RestoresExactPreviousState(t *testing.T) {
|
|
||||||
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
||||||
defer cleanup()
|
|
||||||
provisionTestTenant(t, registry, adminPool, "lc_cancel_active")
|
|
||||||
provisionTestTenant(t, registry, adminPool, "lc_cancel_suspended")
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Fall 1: aus 'active' heraus vorgemerkt und widerrufen.
|
|
||||||
scheduled, err := registry.ScheduleDeletion(ctx, "lc_cancel_active", time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("schedule deletion: %v", err)
|
|
||||||
}
|
|
||||||
if scheduled.Status != StatusPendingDeletion {
|
|
||||||
t.Fatalf("status = %q, want pending_deletion", scheduled.Status)
|
|
||||||
}
|
|
||||||
if scheduled.DeletionScheduledAt == nil {
|
|
||||||
t.Fatal("erwartet gesetzte deletion_scheduled_at")
|
|
||||||
}
|
|
||||||
|
|
||||||
restored, err := registry.CancelDeletion(ctx, "lc_cancel_active")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("cancel deletion: %v", err)
|
|
||||||
}
|
|
||||||
if restored.Status != StatusActive {
|
|
||||||
t.Fatalf("status = %q, want active (vorheriger zustand)", restored.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall 2: aus 'suspended' heraus vorgemerkt und widerrufen — muss zu
|
|
||||||
// 'suspended' zurueckkehren, NICHT zu 'active'.
|
|
||||||
if _, err := registry.Suspend(ctx, "lc_cancel_suspended"); err != nil {
|
|
||||||
t.Fatalf("suspend: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := registry.ScheduleDeletion(ctx, "lc_cancel_suspended", time.Hour); err != nil {
|
|
||||||
t.Fatalf("schedule deletion: %v", err)
|
|
||||||
}
|
|
||||||
restoredSuspended, err := registry.CancelDeletion(ctx, "lc_cancel_suspended")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("cancel deletion: %v", err)
|
|
||||||
}
|
|
||||||
if restoredSuspended.Status != StatusSuspended {
|
|
||||||
t.Fatalf("status = %q, want suspended (vorheriger zustand)", restoredSuspended.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 2: suspendierter Tenant erzeugt bei jedem
|
|
||||||
// Zugriffsversuch einen klaren Fehler.
|
|
||||||
func TestLifecycle_CheckActive_RejectsNonActive(t *testing.T) {
|
|
||||||
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
||||||
defer cleanup()
|
|
||||||
provisionTestTenant(t, registry, adminPool, "lc_checkactive")
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); err != nil {
|
|
||||||
t.Fatalf("aktiver tenant sollte durchgehen, habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := registry.Suspend(ctx, "lc_checkactive"); err != nil {
|
|
||||||
t.Fatalf("suspend: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); !errors.Is(err, ErrTenantNotActive) {
|
|
||||||
t.Fatalf("versuch %d: erwartet ErrTenantNotActive, habe %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := lifecycle.CheckActive(ctx, "nie-registriert"); !errors.Is(err, ErrTenantNotFound) {
|
|
||||||
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 3: Loeschvorgang nach Ablauf der Karenzzeit
|
|
||||||
// automatisch ausgeloest (hier durch direkten Aufruf von ProcessDueDeletions,
|
|
||||||
// das RunSweeper periodisch aufruft).
|
|
||||||
func TestLifecycle_ProcessDueDeletions(t *testing.T) {
|
|
||||||
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
||||||
defer cleanup()
|
|
||||||
provisionTestTenant(t, registry, adminPool, "lc_due")
|
|
||||||
provisionTestTenant(t, registry, adminPool, "lc_not_due")
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// lc_due: Karenzzeit liegt bereits in der Vergangenheit -> faellig.
|
|
||||||
if _, err := registry.ScheduleDeletion(ctx, "lc_due", -time.Minute); err != nil {
|
|
||||||
t.Fatalf("schedule deletion (due): %v", err)
|
|
||||||
}
|
|
||||||
// lc_not_due: Karenzzeit liegt weit in der Zukunft -> nicht faellig.
|
|
||||||
if _, err := registry.ScheduleDeletion(ctx, "lc_not_due", time.Hour); err != nil {
|
|
||||||
t.Fatalf("schedule deletion (not due): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
processed, err := lifecycle.ProcessDueDeletions(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("process due deletions: %v", err)
|
|
||||||
}
|
|
||||||
if processed != 1 {
|
|
||||||
t.Fatalf("erwartet genau 1 verarbeitete loeschung, habe %d", processed)
|
|
||||||
}
|
|
||||||
|
|
||||||
due, err := registry.GetBySlug(ctx, "lc_due")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get lc_due: %v", err)
|
|
||||||
}
|
|
||||||
if due.Status != StatusDeleted {
|
|
||||||
t.Fatalf("lc_due status = %q, want deleted", due.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
notDue, err := registry.GetBySlug(ctx, "lc_not_due")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get lc_not_due: %v", err)
|
|
||||||
}
|
|
||||||
if notDue.Status != StatusPendingDeletion {
|
|
||||||
t.Fatalf("lc_not_due status = %q, want pending_deletion (noch nicht faellig)", notDue.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Datenbank von lc_due wurde tatsaechlich physisch entfernt.
|
|
||||||
var exists bool
|
|
||||||
if err := adminPool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
|
|
||||||
dbNameForSlug("lc_due")).Scan(&exists); err != nil {
|
|
||||||
t.Fatalf("pg_database pruefen: %v", err)
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
t.Fatal("erwartet, dass die tenant-datenbank von lc_due geloescht wurde")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrMissingField = errors.New("tenant: pflichtfeld fehlt")
|
|
||||||
|
|
||||||
// OnboardingService implementiert TEN-02: Self-Service-Anlage eines neuen
|
|
||||||
// Mandanten inklusive Ersteinrichtung des Administrator-Kontos in einem
|
|
||||||
// Vorgang. Schlaegt die Admin-Anlage fehl, wird der bereits provisionierte
|
|
||||||
// Tenant per Deprovision wieder vollstaendig entfernt (Saga-Kompensation,
|
|
||||||
// da CREATE DATABASE keine echte cross-database Transaktion erlaubt).
|
|
||||||
type OnboardingService struct {
|
|
||||||
provisioner *Provisioner
|
|
||||||
// tenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
|
|
||||||
// die frisch angelegte Tenant-Datenbank initialisiert wird, bevor der
|
|
||||||
// erste Administrator angelegt werden kann.
|
|
||||||
tenantSchemaPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewOnboardingService(provisioner *Provisioner, tenantSchemaPath string) *OnboardingService {
|
|
||||||
return &OnboardingService{provisioner: provisioner, tenantSchemaPath: tenantSchemaPath}
|
|
||||||
}
|
|
||||||
|
|
||||||
type OnboardResult struct {
|
|
||||||
Tenant Tenant
|
|
||||||
Admin user.User
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OnboardingService) Onboard(ctx context.Context, slug, tenantName, adminEmail, adminName string) (OnboardResult, error) {
|
|
||||||
if tenantName == "" {
|
|
||||||
return OnboardResult{}, fmt.Errorf("%w: name", ErrMissingField)
|
|
||||||
}
|
|
||||||
if adminName == "" {
|
|
||||||
return OnboardResult{}, fmt.Errorf("%w: admin-name", ErrMissingField)
|
|
||||||
}
|
|
||||||
if err := ValidateSlug(slug); err != nil {
|
|
||||||
return OnboardResult{}, err
|
|
||||||
}
|
|
||||||
if err := user.ValidateEmail(adminEmail); err != nil {
|
|
||||||
return OnboardResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := s.provisioner.Provision(ctx, slug, tenantName)
|
|
||||||
if err != nil {
|
|
||||||
return OnboardResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
admin, err := s.setupAdmin(ctx, t, adminEmail, adminName)
|
|
||||||
if err != nil {
|
|
||||||
_ = s.provisioner.Deprovision(ctx, t)
|
|
||||||
return OnboardResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return OnboardResult{Tenant: t, Admin: admin}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OnboardingService) setupAdmin(ctx context.Context, t Tenant, adminEmail, adminName string) (user.User, error) {
|
|
||||||
pool, err := pgxpool.New(ctx, t.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
return user.User{}, fmt.Errorf("verbindung zur neuen tenant-datenbank: %w", err)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
|
|
||||||
schema, err := os.ReadFile(s.tenantSchemaPath)
|
|
||||||
if err != nil {
|
|
||||||
return user.User{}, fmt.Errorf("tenant-schema lesen: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := pool.Exec(ctx, string(schema)); err != nil {
|
|
||||||
return user.User{}, fmt.Errorf("tenant-schema anwenden: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
store := user.NewTenantUserStore(pool)
|
|
||||||
return store.Create(ctx, adminEmail, adminName)
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
// schemaPathForTest liefert den Pfad zur echten Migration, damit Tests exakt
|
|
||||||
// dasselbe Schema anwenden wie die Produktion (keine Zweitkopie des SQL).
|
|
||||||
const schemaPathForTest = "../../migrations/tenant/0001_users.up.sql"
|
|
||||||
|
|
||||||
func TestOnboarding_ValidationErrors(t *testing.T) {
|
|
||||||
// Reine Eingabevalidierung, braucht keine Datenbank.
|
|
||||||
svc := NewOnboardingService(nil, schemaPathForTest)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
slug string
|
|
||||||
tenantName string
|
|
||||||
email string
|
|
||||||
adminName string
|
|
||||||
wantErr error
|
|
||||||
}{
|
|
||||||
{"leerer name", "acme", "", "a@b.de", "Admin", ErrMissingField},
|
|
||||||
{"leerer admin-name", "acme", "Acme", "a@b.de", "", ErrMissingField},
|
|
||||||
{"ungueltiger slug", "Acme!", "Acme", "a@b.de", "Admin", ErrInvalidSlug},
|
|
||||||
{"ungueltige email", "acme", "Acme", "keine-email", "Admin", user.ErrInvalidEmail},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
_, err := svc.Onboard(ctx, c.slug, c.tenantName, c.email, c.adminName)
|
|
||||||
if !errors.Is(err, c.wantErr) {
|
|
||||||
t.Fatalf("erwartet %v, habe %v", c.wantErr, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestOnboardingService(t *testing.T) (*OnboardingService, func()) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
registryPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("registry pool: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := registryPool.Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS tenants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
slug TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
db_name TEXT NOT NULL UNIQUE,
|
|
||||||
db_dsn TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("registry-schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
registry := NewRegistry(registryPool)
|
|
||||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
||||||
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
svc := NewOnboardingService(provisioner, schemaPathForTest)
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
registryPool.Close()
|
|
||||||
adminPool.Close()
|
|
||||||
}
|
|
||||||
return svc, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterien 1+2: Mandant + Erst-Admin in einem Vorgang.
|
|
||||||
func TestOnboarding_CreatesTenantAndAdmin(t *testing.T) {
|
|
||||||
svc, cleanup := newTestOnboardingService(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
||||||
p, _ := pgxpool.New(ctx, adminDSN)
|
|
||||||
defer p.Close()
|
|
||||||
_, _ = p.Exec(ctx, `DELETE FROM tenants WHERE slug = 'onboardtest'`)
|
|
||||||
_, _ = p.Exec(ctx, `DROP DATABASE IF EXISTS tenant_onboardtest`)
|
|
||||||
})
|
|
||||||
|
|
||||||
result, err := svc.Onboard(ctx, "onboardtest", "Onboard Test GmbH", "admin@onboardtest.example", "Erste Admin")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("onboard: %v", err)
|
|
||||||
}
|
|
||||||
if result.Tenant.Slug != "onboardtest" {
|
|
||||||
t.Fatalf("tenant slug = %q", result.Tenant.Slug)
|
|
||||||
}
|
|
||||||
if result.Admin.Email != "admin@onboardtest.example" {
|
|
||||||
t.Fatalf("admin email = %q", result.Admin.Email)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := pgxpool.New(ctx, result.Tenant.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connect tenant db: %v", err)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
|
|
||||||
var count int
|
|
||||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "admin@onboardtest.example").Scan(&count); err != nil {
|
|
||||||
t.Fatalf("admin in tenant-db pruefen: %v", err)
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
t.Fatalf("erwartet genau einen admin in der tenant-db, habe %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 1: doppelte Registrierung wird abgewiesen,
|
|
||||||
// auch bei zwei parallelen Anfragen entsteht kein Doppel-Tenant.
|
|
||||||
func TestOnboarding_RejectsDuplicateSlugConcurrently(t *testing.T) {
|
|
||||||
svc, cleanup := newTestOnboardingService(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
||||||
p, _ := pgxpool.New(ctx, adminDSN)
|
|
||||||
defer p.Close()
|
|
||||||
_, _ = p.Exec(ctx, `DELETE FROM tenants WHERE slug = 'dupetest'`)
|
|
||||||
_, _ = p.Exec(ctx, `DROP DATABASE IF EXISTS tenant_dupetest`)
|
|
||||||
})
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
results := make([]error, 2)
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(i int) {
|
|
||||||
defer wg.Done()
|
|
||||||
_, err := svc.Onboard(ctx, "dupetest", "Dupe Test", "admin@dupetest.example", "Admin")
|
|
||||||
results[i] = err
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
successCount := 0
|
|
||||||
for _, err := range results {
|
|
||||||
if err == nil {
|
|
||||||
successCount++
|
|
||||||
} else if !errors.Is(err, ErrTenantExists) {
|
|
||||||
t.Fatalf("unerwarteter fehler: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if successCount != 1 {
|
|
||||||
t.Fatalf("erwartet genau einen erfolgreichen onboarding-vorgang, habe %d", successCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrTenantExists wird zurueckgegeben, wenn Slug oder Datenbankname eines
|
|
||||||
// Mandanten bereits vergeben sind (TEN-02 Akzeptanzkriterium 3).
|
|
||||||
var ErrTenantExists = errors.New("tenant: slug bereits vergeben")
|
|
||||||
|
|
||||||
// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte
|
|
||||||
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
|
|
||||||
// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf
|
|
||||||
// Infrastrukturebene komplett getrennt (Akzeptanzkriterium 3).
|
|
||||||
type Provisioner struct {
|
|
||||||
// adminPool ist mit der Wartungsdatenbank (z. B. "postgres") verbunden
|
|
||||||
// und wird ausschliesslich fuer CREATE/DROP DATABASE verwendet, da diese
|
|
||||||
// Befehle in PostgreSQL nicht in einer Transaktion laufen koennen.
|
|
||||||
adminPool *pgxpool.Pool
|
|
||||||
registry *Registry
|
|
||||||
// dsnTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
|
||||||
// Datenbanknamen, z. B. "postgresql://user:pass@host:5432/%s?sslmode=disable".
|
|
||||||
dsnTemplate string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewProvisioner(adminPool *pgxpool.Pool, registry *Registry, dsnTemplate string) *Provisioner {
|
|
||||||
return &Provisioner{adminPool: adminPool, registry: registry, dsnTemplate: dsnTemplate}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provision legt die Tenant-Datenbank an und registriert sie. Schlaegt die
|
|
||||||
// Registrierung fehl, wird die bereits angelegte Datenbank wieder entfernt,
|
|
||||||
// damit kein verwaister, unregistrierter Tenant zurueckbleibt.
|
|
||||||
func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant, error) {
|
|
||||||
if err := ValidateSlug(slug); err != nil {
|
|
||||||
return Tenant{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
dbName := dbNameForSlug(slug)
|
|
||||||
|
|
||||||
// CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch
|
|
||||||
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
|
|
||||||
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
// 42P04 (duplicate_database) im Normalfall; bei zwei echt parallelen
|
|
||||||
// CREATE DATABASE auf denselben Namen liefert Postgres stattdessen den
|
|
||||||
// internen Unique-Index-Konflikt 23505 auf pg_database — beide Faelle
|
|
||||||
// bedeuten: der Tenant existiert bereits bzw. wird gerade angelegt.
|
|
||||||
if errors.As(err, &pgErr) && (pgErr.Code == "42P04" || pgErr.Code == "23505") {
|
|
||||||
return Tenant{}, ErrTenantExists
|
|
||||||
}
|
|
||||||
return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t := Tenant{
|
|
||||||
Slug: slug,
|
|
||||||
Name: name,
|
|
||||||
DBName: dbName,
|
|
||||||
DBDSN: fmt.Sprintf(p.dsnTemplate, dbName),
|
|
||||||
Status: StatusActive,
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := p.registry.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
p.rollbackDatabase(ctx, dbName)
|
|
||||||
return Tenant{}, fmt.Errorf("registry-transaktion starten: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := p.registry.insertTx(ctx, tx, t)
|
|
||||||
if err != nil {
|
|
||||||
_ = tx.Rollback(ctx)
|
|
||||||
p.rollbackDatabase(ctx, dbName)
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation auf slug
|
|
||||||
return Tenant{}, ErrTenantExists
|
|
||||||
}
|
|
||||||
return Tenant{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
p.rollbackDatabase(ctx, dbName)
|
|
||||||
return Tenant{}, fmt.Errorf("registry-transaktion committen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// rollbackDatabase entfernt eine bereits angelegte Tenant-Datenbank, wenn die
|
|
||||||
// Registrierung fehlschlug, damit Provisioning insgesamt atomar wirkt.
|
|
||||||
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
|
|
||||||
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprovision entfernt einen bereits erfolgreich registrierten Tenant wieder
|
|
||||||
// vollstaendig (Registry-Eintrag + Datenbank). Wird als Kompensation
|
|
||||||
// verwendet, wenn ein Schritt NACH Provision fehlschlaegt (z.B. die
|
|
||||||
// Admin-Account-Anlage in TEN-02) — Provision selbst bleibt dadurch die
|
|
||||||
// einzige Stelle, die "erfolgreich provisioniert" garantieren muss.
|
|
||||||
func (p *Provisioner) Deprovision(ctx context.Context, t Tenant) error {
|
|
||||||
if err := p.registry.Delete(ctx, t.ID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
p.rollbackDatabase(ctx, t.DBName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Integrationstest fuer Akzeptanzkriterien 2 und 3. Benoetigt eine echte
|
|
||||||
// Postgres-Instanz und wird ohne TEST_ADMIN_DSN uebersprungen, nicht als
|
|
||||||
// fehlgeschlagen gewertet — siehe Pruefungen-Ergebnis im PR.
|
|
||||||
//
|
|
||||||
// TEST_ADMIN_DSN muss auf die Wartungsdatenbank zeigen, z. B.:
|
|
||||||
//
|
|
||||||
// postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable
|
|
||||||
func TestProvision_CreatesIsolatedDatabases(t *testing.T) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
defer adminPool.Close()
|
|
||||||
|
|
||||||
registryPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("registry pool: %v", err)
|
|
||||||
}
|
|
||||||
defer registryPool.Close()
|
|
||||||
|
|
||||||
if _, err := registryPool.Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS tenants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
slug TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
db_name TEXT NOT NULL UNIQUE,
|
|
||||||
db_dsn TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("registry-schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
||||||
registry := NewRegistry(registryPool)
|
|
||||||
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('acme','globex')`)
|
|
||||||
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_acme`)
|
|
||||||
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_globex`)
|
|
||||||
})
|
|
||||||
|
|
||||||
tenantA, err := provisioner.Provision(ctx, "acme", "Acme GmbH")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provision acme: %v", err)
|
|
||||||
}
|
|
||||||
tenantB, err := provisioner.Provision(ctx, "globex", "Globex AG")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provision globex: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tenantA.DBName == tenantB.DBName {
|
|
||||||
t.Fatalf("erwartet unterschiedliche db_name, beide sind %q", tenantA.DBName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 / Pruefung 3: In der Datenbank von Tenant A existiert
|
|
||||||
// keine Verbindungsmoeglichkeit zu Tenant B, weil beide physisch getrennte
|
|
||||||
// Datenbanken sind, statt sich auf einen Query-Filter zu verlassen.
|
|
||||||
poolA, err := pgxpool.New(ctx, tenantA.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connect tenant a: %v", err)
|
|
||||||
}
|
|
||||||
defer poolA.Close()
|
|
||||||
|
|
||||||
var globexVisible bool
|
|
||||||
err = poolA.QueryRow(ctx, `
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1
|
|
||||||
)
|
|
||||||
`, tenantB.DBName).Scan(&globexVisible)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("pruefung tenant-trennung: %v", err)
|
|
||||||
}
|
|
||||||
// pg_database ist clusterweit sichtbar (Existenz der DB), aber die
|
|
||||||
// eigentliche Pruefung ist: aus poolA (verbunden mit tenant_acme) ist keine
|
|
||||||
// Tabelle/Zeile aus tenant_globex erreichbar, da current_database() getrennt ist.
|
|
||||||
var currentDB string
|
|
||||||
if err := poolA.QueryRow(ctx, `SELECT current_database()`).Scan(¤tDB); err != nil {
|
|
||||||
t.Fatalf("current_database: %v", err)
|
|
||||||
}
|
|
||||||
if currentDB != tenantA.DBName {
|
|
||||||
t.Fatalf("current_database() = %q, want %q — keine physische Trennung", currentDB, tenantA.DBName)
|
|
||||||
}
|
|
||||||
if currentDB == tenantB.DBName {
|
|
||||||
t.Fatalf("tenant a verbindung zeigt auf tenant b datenbank")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Registry kapselt den Zugriff auf die Control-Plane-Registry-Datenbank.
|
|
||||||
// Sie enthaelt ausschliesslich Tenant-Metadaten (Akzeptanzkriterium 1) —
|
|
||||||
// niemals Geschaeftsdaten eines Mandanten.
|
|
||||||
type Registry struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRegistry(pool *pgxpool.Pool) *Registry {
|
|
||||||
return &Registry{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// insertTx schreibt den Tenant-Datensatz innerhalb einer laufenden Transaktion,
|
|
||||||
// damit Provisioner.Provision DB-Anlage und Registrierung atomar behandeln kann.
|
|
||||||
func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, error) {
|
|
||||||
row := tx.QueryRow(ctx, `
|
|
||||||
INSERT INTO tenants (slug, name, db_name, db_dsn, status)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
RETURNING id, created_at
|
|
||||||
`, t.Slug, t.Name, t.DBName, t.DBDSN, t.Status)
|
|
||||||
|
|
||||||
if err := row.Scan(&t.ID, &t.CreatedAt); err != nil {
|
|
||||||
return Tenant{}, fmt.Errorf("tenant registrieren: %w", err)
|
|
||||||
}
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
|
|
||||||
// previous_status/deletion_scheduled_at werden mitgelesen, damit TEN-04
|
|
||||||
// (internal/tenant/lifecycle.go) den vollstaendigen Lebenszyklus-Zustand
|
|
||||||
// ueber GetBySlug ansehen kann, statt eine eigene Abfrage zu duplizieren.
|
|
||||||
row := r.pool.QueryRow(ctx, `
|
|
||||||
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
|
|
||||||
FROM tenants WHERE slug = $1
|
|
||||||
`, slug)
|
|
||||||
|
|
||||||
t, err := scanTenantWithLifecycle(row)
|
|
||||||
if err != nil {
|
|
||||||
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
|
||||||
}
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete entfernt einen Registry-Eintrag. Wird von Provisioner.Deprovision
|
|
||||||
// als Kompensation verwendet, wenn nach erfolgreicher Registrierung ein
|
|
||||||
// nachgelagerter Schritt (z.B. Admin-Account-Anlage in TEN-02) fehlschlaegt.
|
|
||||||
func (r *Registry) Delete(ctx context.Context, id string) error {
|
|
||||||
if _, err := r.pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, id); err != nil {
|
|
||||||
return fmt.Errorf("tenant loeschen: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
|
|
||||||
rows, err := r.pool.Query(ctx, `
|
|
||||||
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
|
|
||||||
FROM tenants ORDER BY created_at
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("tenants auflisten: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []Tenant
|
|
||||||
for rows.Next() {
|
|
||||||
t, err := scanTenantWithLifecycle(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("tenant lesen: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, t)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"container/list"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrMissingTenantContext wird geliefert, wenn keine Tenant-Kennung
|
|
||||||
// uebergeben wurde — es gibt bewusst keinen impliziten Default-Tenant
|
|
||||||
// (TEN-06 Akzeptanzkriterium 3).
|
|
||||||
var ErrMissingTenantContext = errors.New("tenant: kein tenant-kontext angegeben")
|
|
||||||
|
|
||||||
// ErrUnknownTenant wird geliefert, wenn die Tenant-Kennung in der Registry
|
|
||||||
// nicht existiert.
|
|
||||||
var ErrUnknownTenant = errors.New("tenant: unbekannter tenant")
|
|
||||||
|
|
||||||
// Router loest den Tenant-Kontext (Slug, aus dem JWT-Claim von API-05) in
|
|
||||||
// eine wiederverwendbare Verbindung zur richtigen Tenant-Datenbank auf.
|
|
||||||
// Ein LRU-verwalteter Cache begrenzt die Zahl gleichzeitig offener
|
|
||||||
// pgxpool.Pool-Instanzen, damit die Zahl offener Postgres-Verbindungen NICHT
|
|
||||||
// linear mit der Mandantenzahl waechst (Akzeptanzkriterium 2).
|
|
||||||
type Router struct {
|
|
||||||
registry *Registry
|
|
||||||
maxOpen int
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
order *list.List // vorne = zuletzt benutzt
|
|
||||||
items map[string]*list.Element // slug -> element mit *routerEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
type routerEntry struct {
|
|
||||||
slug string
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRouter(registry *Registry, maxOpen int) *Router {
|
|
||||||
if maxOpen < 1 {
|
|
||||||
maxOpen = 1
|
|
||||||
}
|
|
||||||
return &Router{
|
|
||||||
registry: registry,
|
|
||||||
maxOpen: maxOpen,
|
|
||||||
order: list.New(),
|
|
||||||
items: make(map[string]*list.Element),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve liefert einen wiederverwendeten Pool fuer den angegebenen Tenant.
|
|
||||||
// Ist der Tenant bereits im Cache, wird KEINE neue Verbindung aufgebaut
|
|
||||||
// (Akzeptanzkriterium 2 / Pruefung 3).
|
|
||||||
func (r *Router) Resolve(ctx context.Context, tenantSlug string) (*pgxpool.Pool, error) {
|
|
||||||
if tenantSlug == "" {
|
|
||||||
return nil, ErrMissingTenantContext
|
|
||||||
}
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
if el, ok := r.items[tenantSlug]; ok {
|
|
||||||
r.order.MoveToFront(el)
|
|
||||||
pool := el.Value.(*routerEntry).pool
|
|
||||||
r.mu.Unlock()
|
|
||||||
return pool, nil
|
|
||||||
}
|
|
||||||
r.mu.Unlock()
|
|
||||||
|
|
||||||
// Registry-Lookup und Verbindungsaufbau bewusst ausserhalb des Locks,
|
|
||||||
// damit ein langsamer Verbindungsaufbau nicht alle anderen Tenants blockiert.
|
|
||||||
t, err := r.registry.GetBySlug(ctx, tenantSlug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnknownTenant, tenantSlug)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := pgxpool.New(ctx, t.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("verbindung zu tenant %q aufbauen: %w", tenantSlug, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
|
|
||||||
// Zwischen Unlock oben und hier koennte ein paralleler Aufruf denselben
|
|
||||||
// Tenant bereits eingefuegt haben — dann die eigene, ueberzaehlige
|
|
||||||
// Verbindung wieder schliessen und die vorhandene verwenden.
|
|
||||||
if el, ok := r.items[tenantSlug]; ok {
|
|
||||||
r.order.MoveToFront(el)
|
|
||||||
existing := el.Value.(*routerEntry).pool
|
|
||||||
pool.Close()
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
el := r.order.PushFront(&routerEntry{slug: tenantSlug, pool: pool})
|
|
||||||
r.items[tenantSlug] = el
|
|
||||||
|
|
||||||
if r.order.Len() > r.maxOpen {
|
|
||||||
r.evictOldest()
|
|
||||||
}
|
|
||||||
|
|
||||||
return pool, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// evictOldest schliesst den am laengsten nicht genutzten Pool. Muss mit
|
|
||||||
// gehaltenem r.mu aufgerufen werden.
|
|
||||||
func (r *Router) evictOldest() {
|
|
||||||
oldest := r.order.Back()
|
|
||||||
if oldest == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entry := oldest.Value.(*routerEntry)
|
|
||||||
r.order.Remove(oldest)
|
|
||||||
delete(r.items, entry.slug)
|
|
||||||
entry.pool.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenCount liefert die aktuelle Zahl offen gehaltener Tenant-Pools —
|
|
||||||
// dient Tests/Monitoring, um AC2 nachzuweisen.
|
|
||||||
func (r *Router) OpenCount() int {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
return r.order.Len()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close schliesst alle offen gehaltenen Tenant-Pools, z.B. beim
|
|
||||||
// Herunterfahren des Core-Prozesses.
|
|
||||||
func (r *Router) Close() {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
for el := r.order.Front(); el != nil; el = el.Next() {
|
|
||||||
el.Value.(*routerEntry).pool.Close()
|
|
||||||
}
|
|
||||||
r.order.Init()
|
|
||||||
r.items = make(map[string]*list.Element)
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestRouterSetup(t *testing.T, tenantCount int) (*Router, []Tenant, func()) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
registryPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("registry pool: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := registryPool.Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS tenants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
slug TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
db_name TEXT NOT NULL UNIQUE,
|
|
||||||
db_dsn TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("registry-schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
registry := NewRegistry(registryPool)
|
|
||||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
||||||
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
|
|
||||||
var tenants []Tenant
|
|
||||||
var slugs []string
|
|
||||||
for i := 0; i < tenantCount; i++ {
|
|
||||||
slug := fmt.Sprintf("router_t%d", i)
|
|
||||||
slugs = append(slugs, slug)
|
|
||||||
tn, err := provisioner.Provision(ctx, slug, slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provision %s: %v", slug, err)
|
|
||||||
}
|
|
||||||
tenants = append(tenants, tn)
|
|
||||||
}
|
|
||||||
|
|
||||||
router := NewRouter(registry, 2) // klein gewaehlt, um Eviction im Test zu erzwingen
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
router.Close()
|
|
||||||
for _, slug := range slugs {
|
|
||||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
|
|
||||||
}
|
|
||||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
|
|
||||||
registryPool.Close()
|
|
||||||
adminPool.Close()
|
|
||||||
}
|
|
||||||
return router, tenants, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1: Verbindung wird zuverlaessig anhand des Tenant-Kontexts aufgeloest.
|
|
||||||
func TestRouter_ResolvesCorrectTenantDatabase(t *testing.T) {
|
|
||||||
router, tenants, cleanup := newTestRouterSetup(t, 2)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
pool, err := router.Resolve(ctx, tenants[0].Slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve: %v", err)
|
|
||||||
}
|
|
||||||
var dbName string
|
|
||||||
if err := pool.QueryRow(ctx, `SELECT current_database()`).Scan(&dbName); err != nil {
|
|
||||||
t.Fatalf("current_database: %v", err)
|
|
||||||
}
|
|
||||||
if dbName != tenants[0].DBName {
|
|
||||||
t.Fatalf("current_database() = %q, want %q", dbName, tenants[0].DBName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 2: fehlender/unbekannter Tenant-Kontext
|
|
||||||
// wird explizit abgewiesen statt irgendeine Verbindung zu liefern.
|
|
||||||
func TestRouter_RejectsMissingOrUnknownTenant(t *testing.T) {
|
|
||||||
router, _, cleanup := newTestRouterSetup(t, 1)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if _, err := router.Resolve(ctx, ""); !errors.Is(err, ErrMissingTenantContext) {
|
|
||||||
t.Fatalf("erwartet ErrMissingTenantContext, habe %v", err)
|
|
||||||
}
|
|
||||||
if _, err := router.Resolve(ctx, "nie-registrierter-slug"); !errors.Is(err, ErrUnknownTenant) {
|
|
||||||
t.Fatalf("erwartet ErrUnknownTenant, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 3: Verbindungswiederverwendung nachweislich
|
|
||||||
// gemessen — zweiter Resolve-Aufruf liefert exakt denselben Pool, kein
|
|
||||||
// erneuter Verbindungsaufbau.
|
|
||||||
func TestRouter_ReusesConnectionForSameTenant(t *testing.T) {
|
|
||||||
router, tenants, cleanup := newTestRouterSetup(t, 1)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
first, err := router.Resolve(ctx, tenants[0].Slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve 1: %v", err)
|
|
||||||
}
|
|
||||||
second, err := router.Resolve(ctx, tenants[0].Slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve 2: %v", err)
|
|
||||||
}
|
|
||||||
if first != second {
|
|
||||||
t.Fatal("erwartet identische pool-instanz bei wiederholtem resolve, habe unterschiedliche")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 1: Lasttest mit mehr simulierten Mandanten
|
|
||||||
// als maxOpen — die Zahl gleichzeitig offener Tenant-Pools bleibt begrenzt
|
|
||||||
// (LRU-Eviction), waechst also NICHT linear mit der Mandantenzahl.
|
|
||||||
func TestRouter_BoundsOpenConnectionsUnderLoad(t *testing.T) {
|
|
||||||
const tenantCount = 6
|
|
||||||
router, tenants, cleanup := newTestRouterSetup(t, tenantCount)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
for _, tn := range tenants {
|
|
||||||
if _, err := router.Resolve(ctx, tn.Slug); err != nil {
|
|
||||||
t.Fatalf("resolve %s: %v", tn.Slug, err)
|
|
||||||
}
|
|
||||||
if router.OpenCount() > 2 {
|
|
||||||
t.Fatalf("OpenCount() = %d, erwartet <= maxOpen (2) nach jedem Resolve", router.OpenCount())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if router.OpenCount() != 2 {
|
|
||||||
t.Fatalf("erwartet genau maxOpen=2 offene pools nach %d tenants, habe %d", tenantCount, router.OpenCount())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evictete Tenants sind wieder ganz normal ueber die Registry aufloesbar
|
|
||||||
// (Cache-Miss fuehrt zu neuem, funktionierendem Pool, kein Fehlerzustand).
|
|
||||||
pool, err := router.Resolve(ctx, tenants[0].Slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve nach eviction: %v", err)
|
|
||||||
}
|
|
||||||
var one int
|
|
||||||
if err := pool.QueryRow(ctx, `SELECT 1`).Scan(&one); err != nil {
|
|
||||||
t.Fatalf("query nach re-resolve: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
// Package tenant implements Core TEN-01: die Control-Plane-Registry und die
|
|
||||||
// Provisioning-Routine fuer physisch getrennte Mandanten-Datenbanken (Modell C).
|
|
||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"regexp"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Status string
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusActive Status = "active"
|
|
||||||
// Lebenszyklus-Zustaende aus TEN-04 (siehe internal/tenant/lifecycle.go).
|
|
||||||
StatusSuspended Status = "suspended"
|
|
||||||
StatusPendingDeletion Status = "pending_deletion"
|
|
||||||
StatusDeleted Status = "deleted"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Tenant struct {
|
|
||||||
ID string
|
|
||||||
Slug string
|
|
||||||
Name string
|
|
||||||
DBName string
|
|
||||||
DBDSN string
|
|
||||||
Status Status
|
|
||||||
CreatedAt time.Time
|
|
||||||
// PreviousStatus und DeletionScheduledAt sind nur waehrend
|
|
||||||
// StatusPendingDeletion gesetzt (TEN-04) — sie halten fest, in welchen
|
|
||||||
// Zustand CancelDeletion zurueckkehrt und wann die Karenzzeit ablaeuft.
|
|
||||||
PreviousStatus *string
|
|
||||||
DeletionScheduledAt *time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
|
|
||||||
// der Datenbankname niemals aus unkontrolliertem Nutzereingabe-Text gebaut wird.
|
|
||||||
var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,48}$`)
|
|
||||||
|
|
||||||
var ErrInvalidSlug = errors.New("tenant: slug muss mit Kleinbuchstaben beginnen und darf nur [a-z0-9_] enthalten (2-49 Zeichen)")
|
|
||||||
|
|
||||||
func ValidateSlug(slug string) error {
|
|
||||||
if !slugPattern.MatchString(slug) {
|
|
||||||
return ErrInvalidSlug
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func dbNameForSlug(slug string) string {
|
|
||||||
return "tenant_" + slug
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestValidateSlug(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
slug string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"acme", false},
|
|
||||||
{"acme_gmbh", false},
|
|
||||||
{"a1", false},
|
|
||||||
{"", true},
|
|
||||||
{"a", true},
|
|
||||||
{"1acme", true},
|
|
||||||
{"Acme", true},
|
|
||||||
{"acme-gmbh", true},
|
|
||||||
{"acme;drop table tenants", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
err := ValidateSlug(c.slug)
|
|
||||||
if (err != nil) != c.wantErr {
|
|
||||||
t.Errorf("ValidateSlug(%q) error = %v, wantErr %v", c.slug, err, c.wantErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDBNameForSlug(t *testing.T) {
|
|
||||||
got := dbNameForSlug("acme")
|
|
||||||
want := "tenant_acme"
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("dbNameForSlug() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
// Package tenantadmin implementiert Core TEN-05: das Backend-API fuer die
|
|
||||||
// Tenant-Verwaltungsoberflaeche. Enthaelt KEINE eigene Provisioning-/
|
|
||||||
// Lifecycle-/Settings-Logik, sondern ist ein duenner Vermittler ueber
|
|
||||||
// internal/tenant (TEN-01/TEN-04), internal/tenantsettings (TEN-03) und
|
|
||||||
// internal/user.SuperadminStore (Berechtigungspruefung) — Ticket-Vorgabe:
|
|
||||||
// "Verwaltungsoberflaeche im Stil einer schlanken Zitadel-Console".
|
|
||||||
package tenantadmin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrNotSuperadmin wird geliefert, wenn der Aufrufer keine aktive
|
|
||||||
// Superadmin-Identitaet hat (Akzeptanzkriterium 1 / Pruefung 1: die
|
|
||||||
// Oberflaeche zeigt Mandanten nur berechtigten Superadmins).
|
|
||||||
var ErrNotSuperadmin = errors.New("tenantadmin: aufrufer ist kein aktiver superadmin")
|
|
||||||
|
|
||||||
// ErrMissingDisplayName wird geliefert, wenn ein Einstellungs-Update ohne
|
|
||||||
// das Pflichtfeld Anzeigename versucht wird (Akzeptanzkriterium 2 / Pruefung 2).
|
|
||||||
var ErrMissingDisplayName = errors.New("tenantadmin: anzeigename ist ein pflichtfeld")
|
|
||||||
|
|
||||||
type Handler struct {
|
|
||||||
registry *tenant.Registry
|
|
||||||
lifecycle *tenant.Lifecycle
|
|
||||||
settings *tenantsettings.Store
|
|
||||||
superadmins *user.SuperadminStore
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHandler(registry *tenant.Registry, lifecycle *tenant.Lifecycle, settings *tenantsettings.Store, superadmins *user.SuperadminStore) *Handler {
|
|
||||||
return &Handler{registry: registry, lifecycle: lifecycle, settings: settings, superadmins: superadmins}
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireSuperadmin prueft, dass der Aufrufer ein EXISTIERENDER, AKTIVER
|
|
||||||
// Superadmin ist — es gibt (bewusst, siehe internal/user.SuperadminStore)
|
|
||||||
// keine Tenant-Scoping-Dimension fuer Superadmins: wer ueberhaupt Zugriff
|
|
||||||
// hat, sieht alle Mandanten. Alles andere wird abgelehnt, bevor irgendeine
|
|
||||||
// Mandantendatei gelesen wird (Fail-Safe-Default).
|
|
||||||
func (h *Handler) requireSuperadmin(ctx context.Context, superadminID string) error {
|
|
||||||
if superadminID == "" {
|
|
||||||
return ErrNotSuperadmin
|
|
||||||
}
|
|
||||||
admin, err := h.superadmins.Get(ctx, superadminID)
|
|
||||||
if err != nil {
|
|
||||||
return ErrNotSuperadmin
|
|
||||||
}
|
|
||||||
if admin.Status != user.StatusActive {
|
|
||||||
return ErrNotSuperadmin
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TenantListItem ist die fuer die Uebersichtsliste relevante Projektion
|
|
||||||
// (Akzeptanzkriterium 1).
|
|
||||||
type TenantListItem struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListTenants liefert alle Mandanten, optional gefiltert nach Suchbegriff
|
|
||||||
// (Slug/Name, Teilstring, case-insensitive) und Status — beides serverseitig,
|
|
||||||
// damit die Oberflaeche nicht selbst ueber unautorisierte Datensaetze
|
|
||||||
// filtern muss (Akzeptanzkriterium 1: Suche und Filter).
|
|
||||||
func (h *Handler) ListTenants(ctx context.Context, superadminID, search, statusFilter string) ([]TenantListItem, error) {
|
|
||||||
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
all, err := h.registry.List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("mandanten auflisten: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
search = strings.ToLower(strings.TrimSpace(search))
|
|
||||||
out := make([]TenantListItem, 0, len(all))
|
|
||||||
for _, t := range all {
|
|
||||||
if statusFilter != "" && string(t.Status) != statusFilter {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if search != "" && !strings.Contains(strings.ToLower(t.Slug), search) && !strings.Contains(strings.ToLower(t.Name), search) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, TenantListItem{ID: t.ID, Slug: t.Slug, Name: t.Name, Status: string(t.Status)})
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TenantDetail buendelt Stammdaten und Einstellungen fuer die Detailansicht.
|
|
||||||
type TenantDetail struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Settings tenantsettings.Settings `json:"settings"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) GetTenantDetail(ctx context.Context, superadminID, slug string) (TenantDetail, error) {
|
|
||||||
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
|
|
||||||
return TenantDetail{}, err
|
|
||||||
}
|
|
||||||
t, err := h.registry.GetBySlug(ctx, slug)
|
|
||||||
if err != nil {
|
|
||||||
return TenantDetail{}, fmt.Errorf("mandant laden: %w", err)
|
|
||||||
}
|
|
||||||
s, err := h.settings.Get(ctx, t.ID)
|
|
||||||
if err != nil {
|
|
||||||
return TenantDetail{}, fmt.Errorf("einstellungen laden: %w", err)
|
|
||||||
}
|
|
||||||
return TenantDetail{ID: t.ID, Slug: t.Slug, Name: t.Name, Status: string(t.Status), Settings: s}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SettingsPatch ist die vom Formular gesendete Aenderung. DisplayName ist
|
|
||||||
// KEIN Zeiger, weil es Pflichtfeld ist (Akzeptanzkriterium 2) — die anderen
|
|
||||||
// Felder bleiben optional (Zeiger = "unveraendert lassen", siehe
|
|
||||||
// tenantsettings.Patch).
|
|
||||||
type SettingsPatch struct {
|
|
||||||
DisplayName string
|
|
||||||
LogoURL *string
|
|
||||||
ColorScheme *string
|
|
||||||
Timezone *string
|
|
||||||
Language *string
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateSettings validiert das Pflichtfeld Anzeigename, BEVOR irgendein
|
|
||||||
// Schreibzugriff erfolgt (Akzeptanzkriterium 2 / Pruefung 2: unvollstaendige
|
|
||||||
// Pflichtfelder werden serverseitig verhindert, nicht nur clientseitig).
|
|
||||||
func (h *Handler) UpdateSettings(ctx context.Context, superadminID, slug string, patch SettingsPatch) (tenantsettings.Settings, error) {
|
|
||||||
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
|
|
||||||
return tenantsettings.Settings{}, err
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(patch.DisplayName) == "" {
|
|
||||||
return tenantsettings.Settings{}, ErrMissingDisplayName
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := h.registry.GetBySlug(ctx, slug)
|
|
||||||
if err != nil {
|
|
||||||
return tenantsettings.Settings{}, fmt.Errorf("mandant laden: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
displayName := patch.DisplayName
|
|
||||||
return h.settings.Update(ctx, t.ID, tenantsettings.Patch{
|
|
||||||
DisplayName: &displayName,
|
|
||||||
LogoURL: patch.LogoURL,
|
|
||||||
ColorScheme: patch.ColorScheme,
|
|
||||||
Timezone: patch.Timezone,
|
|
||||||
Language: patch.Language,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// LifecycleAction sind die von der Oberflaeche ausloesbaren Aktionen
|
|
||||||
// (Akzeptanzkriterium 3) — je EIN Wort pro Aktion, damit ein Bestaetigungs-
|
|
||||||
// dialog im Frontend darauf verzweigen kann, ohne HTTP-Interna zu kennen.
|
|
||||||
type LifecycleAction string
|
|
||||||
|
|
||||||
const (
|
|
||||||
ActionSuspend LifecycleAction = "suspend"
|
|
||||||
ActionReactivate LifecycleAction = "reactivate"
|
|
||||||
ActionScheduleDeletion LifecycleAction = "schedule_deletion"
|
|
||||||
ActionCancelDeletion LifecycleAction = "cancel_deletion"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrUnknownAction = errors.New("tenantadmin: unbekannte lifecycle-aktion")
|
|
||||||
|
|
||||||
// DefaultDeletionGracePeriod ist die Karenzzeit, die die Oberflaeche beim
|
|
||||||
// Ausloesen von ActionScheduleDeletion verwendet — Konfiguration dieses
|
|
||||||
// Pakets, nicht von internal/tenant (das lifecycle.go generisch mit einer
|
|
||||||
// uebergebenen Dauer arbeitet, siehe TEN-04).
|
|
||||||
const DefaultDeletionGracePeriod = 30 * 24 * time.Hour
|
|
||||||
|
|
||||||
// PerformLifecycleAction fuehrt EINEN der vier Uebergaenge aus. Ungueltige
|
|
||||||
// Zustandsuebergaenge (z.B. "suspend" auf einen bereits geloeschten Tenant)
|
|
||||||
// werden von internal/tenant.Lifecycle selbst mit ErrInvalidTransition
|
|
||||||
// abgelehnt (siehe TEN-04) — dieses Paket dupliziert diese Pruefung nicht.
|
|
||||||
func (h *Handler) PerformLifecycleAction(ctx context.Context, superadminID, slug string, action LifecycleAction) (tenant.Tenant, error) {
|
|
||||||
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
|
|
||||||
return tenant.Tenant{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch action {
|
|
||||||
case ActionSuspend:
|
|
||||||
return h.registry.Suspend(ctx, slug)
|
|
||||||
case ActionReactivate:
|
|
||||||
return h.registry.Reactivate(ctx, slug)
|
|
||||||
case ActionScheduleDeletion:
|
|
||||||
return h.registry.ScheduleDeletion(ctx, slug, DefaultDeletionGracePeriod)
|
|
||||||
case ActionCancelDeletion:
|
|
||||||
return h.registry.CancelDeletion(ctx, slug)
|
|
||||||
default:
|
|
||||||
return tenant.Tenant{}, ErrUnknownAction
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- HTTP-Bindung ---
|
|
||||||
//
|
|
||||||
// Der Aufrufer wird bewusst als expliziter Query-/Body-Parameter
|
|
||||||
// "superadmin" statt aus einem Auth-Header gelesen — Session-/Token-basierte
|
|
||||||
// Authentifizierung ist Sache von IAM-01/IAM-02 und wird hier NICHT
|
|
||||||
// dupliziert (Kein Umbau angrenzender Bereiche); dieser Handler ist ein
|
|
||||||
// duenner Entwicklungs-/Testzugang, der genau die in requireSuperadmin
|
|
||||||
// beschriebene Berechtigungspruefung durchsetzt.
|
|
||||||
|
|
||||||
func (h *Handler) ListTenantsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
q := r.URL.Query()
|
|
||||||
items, err := h.ListTenants(r.Context(), q.Get("superadmin"), q.Get("search"), q.Get("status"))
|
|
||||||
writeResult(w, items, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) TenantDetailHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
q := r.URL.Query()
|
|
||||||
detail, err := h.GetTenantDetail(r.Context(), q.Get("superadmin"), q.Get("slug"))
|
|
||||||
writeResult(w, detail, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
type settingsRequest struct {
|
|
||||||
Superadmin string `json:"superadmin"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
LogoURL *string `json:"logo_url"`
|
|
||||||
ColorScheme *string `json:"color_scheme"`
|
|
||||||
Timezone *string `json:"timezone"`
|
|
||||||
Language *string `json:"language"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) UpdateSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req settingsRequest
|
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result, err := h.UpdateSettings(r.Context(), req.Superadmin, req.Slug, SettingsPatch{
|
|
||||||
DisplayName: req.DisplayName,
|
|
||||||
LogoURL: req.LogoURL,
|
|
||||||
ColorScheme: req.ColorScheme,
|
|
||||||
Timezone: req.Timezone,
|
|
||||||
Language: req.Language,
|
|
||||||
})
|
|
||||||
writeResult(w, result, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
type lifecycleRequest struct {
|
|
||||||
Superadmin string `json:"superadmin"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Action LifecycleAction `json:"action"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) LifecycleActionHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req lifecycleRequest
|
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result, err := h.PerformLifecycleAction(r.Context(), req.Superadmin, req.Slug, req.Action)
|
|
||||||
writeResult(w, result, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeJSON(r *http.Request, v any) error {
|
|
||||||
return json.NewDecoder(r.Body).Decode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeError(w http.ResponseWriter, status int, message string) {
|
|
||||||
writeJSON(w, status, map[string]string{"error": message})
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeResult(w http.ResponseWriter, body any, err error) {
|
|
||||||
if err == nil {
|
|
||||||
writeJSON(w, http.StatusOK, body)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, ErrNotSuperadmin):
|
|
||||||
writeError(w, http.StatusForbidden, err.Error())
|
|
||||||
case errors.Is(err, ErrMissingDisplayName):
|
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
|
||||||
case errors.Is(err, ErrUnknownAction), errors.Is(err, tenant.ErrInvalidTransition):
|
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
|
||||||
case errors.Is(err, tenant.ErrTenantNotFound):
|
|
||||||
writeError(w, http.StatusNotFound, err.Error())
|
|
||||||
default:
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
package tenantadmin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testEnv struct {
|
|
||||||
handler *Handler
|
|
||||||
superadminID string
|
|
||||||
slug string
|
|
||||||
adminPool *pgxpool.Pool
|
|
||||||
registry *tenant.Registry
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupTest(t *testing.T) (testEnv, func()) {
|
|
||||||
t.Helper()
|
|
||||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
||||||
if adminDSN == "" {
|
|
||||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
registryPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("registry pool: %v", err)
|
|
||||||
}
|
|
||||||
adminPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("admin pool: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := registryPool.Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS tenants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
|
||||||
db_name TEXT NOT NULL UNIQUE, db_dsn TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), previous_status TEXT, deletion_scheduled_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS tenant_settings (
|
|
||||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id), display_name TEXT, logo_url TEXT,
|
|
||||||
color_scheme TEXT, timezone TEXT, language TEXT, version INT NOT NULL DEFAULT 0,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS tenant_settings_history (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, display_name TEXT,
|
|
||||||
logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT, version INT NOT NULL,
|
|
||||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS 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()
|
|
||||||
);
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
registry := tenant.NewRegistry(registryPool)
|
|
||||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
||||||
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
lifecycle := tenant.NewLifecycle(registry, adminPool)
|
|
||||||
settingsStore := tenantsettings.NewStore(registryPool)
|
|
||||||
superadmins := user.NewSuperadminStore(registryPool)
|
|
||||||
|
|
||||||
slug := fmt.Sprintf("tadm_%d", time.Now().UnixNano()%1_000_000_000)
|
|
||||||
if _, err := provisioner.Provision(ctx, slug, "Test Mandant "+slug); err != nil {
|
|
||||||
t.Fatalf("provision: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
admin, err := superadmins.Create(ctx, fmt.Sprintf("admin-%d@example.com", time.Now().UnixNano()), "Test Superadmin")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("superadmin anlegen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := NewHandler(registry, lifecycle, settingsStore, superadmins)
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug))
|
|
||||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenant_settings_history WHERE tenant_id IN (SELECT id FROM tenants WHERE slug = $1)`, slug)
|
|
||||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenant_settings WHERE tenant_id IN (SELECT id FROM tenants WHERE slug = $1)`, slug)
|
|
||||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
|
|
||||||
_, _ = registryPool.Exec(ctx, `DELETE FROM superadmins WHERE id = $1`, admin.ID)
|
|
||||||
registryPool.Close()
|
|
||||||
adminPool.Close()
|
|
||||||
}
|
|
||||||
return testEnv{handler: handler, superadminID: admin.ID, slug: slug, adminPool: adminPool, registry: registry}, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 1: nur berechtigte (aktive) Superadmins
|
|
||||||
// sehen die Mandantenliste ueberhaupt.
|
|
||||||
func TestListTenants_RejectsNonSuperadmin(t *testing.T) {
|
|
||||||
env, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
_, err := env.handler.ListTenants(ctx, "irgendeine-nicht-existierende-id", "", "")
|
|
||||||
if !errors.Is(err, ErrNotSuperadmin) {
|
|
||||||
t.Fatalf("erwartet ErrNotSuperadmin, habe: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListTenants_ShowsSearchAndStatusFilterResults(t *testing.T) {
|
|
||||||
env, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
all, err := env.handler.ListTenants(ctx, env.superadminID, "", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
found := false
|
|
||||||
for _, item := range all {
|
|
||||||
if item.Slug == env.slug {
|
|
||||||
found = true
|
|
||||||
if item.Status != "active" {
|
|
||||||
t.Fatalf("status = %q, want active", item.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatal("erwartet test-tenant in ungefilterter liste")
|
|
||||||
}
|
|
||||||
|
|
||||||
bySearch, err := env.handler.ListTenants(ctx, env.superadminID, env.slug, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list mit suche: %v", err)
|
|
||||||
}
|
|
||||||
if len(bySearch) != 1 || bySearch[0].Slug != env.slug {
|
|
||||||
t.Fatalf("suche nach slug lieferte unerwartetes ergebnis: %+v", bySearch)
|
|
||||||
}
|
|
||||||
|
|
||||||
byWrongStatus, err := env.handler.ListTenants(ctx, env.superadminID, env.slug, "suspended")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list mit statusfilter: %v", err)
|
|
||||||
}
|
|
||||||
if len(byWrongStatus) != 0 {
|
|
||||||
t.Fatalf("statusfilter haette test-tenant (status=active) ausfiltern muessen, habe: %+v", byWrongStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2: fehlender Pflichtwert (Anzeigename)
|
|
||||||
// wird serverseitig abgelehnt, bevor etwas gespeichert wird.
|
|
||||||
func TestUpdateSettings_RejectsMissingDisplayName(t *testing.T) {
|
|
||||||
env, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
_, err := env.handler.UpdateSettings(ctx, env.superadminID, env.slug, SettingsPatch{DisplayName: " "})
|
|
||||||
if !errors.Is(err, ErrMissingDisplayName) {
|
|
||||||
t.Fatalf("erwartet ErrMissingDisplayName, habe: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateSettings_AppliesValidPatch(t *testing.T) {
|
|
||||||
env, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
updated, err := env.handler.UpdateSettings(ctx, env.superadminID, env.slug, SettingsPatch{DisplayName: "Neuer Name"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("update: %v", err)
|
|
||||||
}
|
|
||||||
if updated.DisplayName != "Neuer Name" {
|
|
||||||
t.Fatalf("displayname = %q, want 'Neuer Name'", updated.DisplayName)
|
|
||||||
}
|
|
||||||
|
|
||||||
detail, err := env.handler.GetTenantDetail(ctx, env.superadminID, env.slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("detail: %v", err)
|
|
||||||
}
|
|
||||||
if detail.Settings.DisplayName != "Neuer Name" {
|
|
||||||
t.Fatalf("detail zeigt nicht den aktualisierten namen: %+v", detail.Settings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3: Lifecycle-Aktionen sind ausloesbar und wirken sich
|
|
||||||
// auf den tatsaechlichen Mandantenstatus aus.
|
|
||||||
func TestPerformLifecycleAction_SuspendAndReactivate(t *testing.T) {
|
|
||||||
env, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
suspended, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionSuspend)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("suspend: %v", err)
|
|
||||||
}
|
|
||||||
if suspended.Status != tenant.StatusSuspended {
|
|
||||||
t.Fatalf("status = %q, want suspended", suspended.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
reactivated, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionReactivate)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reactivate: %v", err)
|
|
||||||
}
|
|
||||||
if reactivated.Status != tenant.StatusActive {
|
|
||||||
t.Fatalf("status = %q, want active", reactivated.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPerformLifecycleAction_RejectsInvalidTransition(t *testing.T) {
|
|
||||||
env, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Reaktivieren eines bereits aktiven Tenants ist kein gueltiger Uebergang.
|
|
||||||
_, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionReactivate)
|
|
||||||
if !errors.Is(err, tenant.ErrInvalidTransition) {
|
|
||||||
t.Fatalf("erwartet ErrInvalidTransition, habe: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPerformLifecycleAction_RejectsNonSuperadmin(t *testing.T) {
|
|
||||||
env, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
_, err := env.handler.PerformLifecycleAction(ctx, "keine-berechtigung", env.slug, ActionSuspend)
|
|
||||||
if !errors.Is(err, ErrNotSuperadmin) {
|
|
||||||
t.Fatalf("erwartet ErrNotSuperadmin, habe: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package tenantsettings
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handler stellt Lesen/Schreiben der Tenant-Einstellungen als schlanke
|
|
||||||
// Vorbereitung der Schnittstelle bereit (API-01/TEN-05 sind nicht Teil
|
|
||||||
// dieser Kachel). tenantID wird hier als Pfad-/Query-Parameter erwartet,
|
|
||||||
// eine echte Auth-/Berechtigungspruefung kommt erst mit IAM-02/RBAC-01.
|
|
||||||
type Handler struct {
|
|
||||||
store *Store
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHandler(store *Store) *Handler {
|
|
||||||
return &Handler{store: store}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
|
||||||
tenantID := r.URL.Query().Get("tenant_id")
|
|
||||||
if tenantID == "" {
|
|
||||||
http.Error(w, "tenant_id fehlt", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
settings, err := h.store.Get(r.Context(), tenantID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "einstellungen konnten nicht geladen werden", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
type updateRequest struct {
|
|
||||||
DisplayName *string `json:"display_name"`
|
|
||||||
LogoURL *string `json:"logo_url"`
|
|
||||||
ColorScheme *string `json:"color_scheme"`
|
|
||||||
Timezone *string `json:"timezone"`
|
|
||||||
Language *string `json:"language"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|
||||||
tenantID := r.URL.Query().Get("tenant_id")
|
|
||||||
if tenantID == "" {
|
|
||||||
http.Error(w, "tenant_id fehlt", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req updateRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
settings, err := h.store.Update(r.Context(), tenantID, Patch{
|
|
||||||
DisplayName: req.DisplayName,
|
|
||||||
LogoURL: req.LogoURL,
|
|
||||||
ColorScheme: req.ColorScheme,
|
|
||||||
Timezone: req.Timezone,
|
|
||||||
Language: req.Language,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "einstellungen konnten nicht gespeichert werden", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
package tenantsettings
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupTest(t *testing.T) (*Store, string, 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 tenants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
slug TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
db_name TEXT NOT NULL UNIQUE,
|
|
||||||
db_dsn TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS tenant_settings (
|
|
||||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
|
|
||||||
display_name TEXT, logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT,
|
|
||||||
version INT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS tenant_settings_history (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL,
|
|
||||||
display_name TEXT, logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT,
|
|
||||||
version INT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var tenantA, tenantB string
|
|
||||||
if err := pool.QueryRow(ctx, `INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('ts_a', 'A', 'tenant_ts_a', 'unused') RETURNING id`).Scan(&tenantA); err != nil {
|
|
||||||
t.Fatalf("tenant a anlegen: %v", err)
|
|
||||||
}
|
|
||||||
if err := pool.QueryRow(ctx, `INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('ts_b', 'B', 'tenant_ts_b', 'unused') RETURNING id`).Scan(&tenantB); err != nil {
|
|
||||||
t.Fatalf("tenant b anlegen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM tenant_settings_history WHERE tenant_id IN ($1, $2)`, tenantA, tenantB)
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM tenant_settings WHERE tenant_id IN ($1, $2)`, tenantA, tenantB)
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id IN ($1, $2)`, tenantA, tenantB)
|
|
||||||
pool.Close()
|
|
||||||
}
|
|
||||||
return NewStore(pool), tenantA, tenantB, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
func strp(s string) *string { return &s }
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2: fehlende Werte liefern Defaults, kein Fehler.
|
|
||||||
func TestGet_UnsetTenantReturnsDefaults(t *testing.T) {
|
|
||||||
store, tenantA, _, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
got, err := store.Get(ctx, tenantA)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
want := Defaults()
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("erwartet defaults %+v, habe %+v", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + 2: Teil-Update laesst nicht gesetzte Felder auf Default.
|
|
||||||
func TestUpdate_PartialPatchKeepsOtherFieldsAtDefault(t *testing.T) {
|
|
||||||
store, tenantA, _, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
got, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("Acme GmbH")})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("update: %v", err)
|
|
||||||
}
|
|
||||||
if got.DisplayName != "Acme GmbH" {
|
|
||||||
t.Fatalf("erwartet 'Acme GmbH', habe %q", got.DisplayName)
|
|
||||||
}
|
|
||||||
d := Defaults()
|
|
||||||
if got.ColorScheme != d.ColorScheme || got.Timezone != d.Timezone || got.Language != d.Language {
|
|
||||||
t.Fatalf("nicht gesetzte felder haetten auf default bleiben sollen, habe %+v", got)
|
|
||||||
}
|
|
||||||
if got.Version != 1 {
|
|
||||||
t.Fatalf("erwartet version 1, habe %d", got.Version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 1: Aenderung eines Tenants wirkt sich
|
|
||||||
// nicht auf einen anderen aus.
|
|
||||||
func TestUpdate_IsolatedBetweenTenants(t *testing.T) {
|
|
||||||
store, tenantA, tenantB, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("Tenant A")}); err != nil {
|
|
||||||
t.Fatalf("update a: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gotB, err := store.Get(ctx, tenantB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get b: %v", err)
|
|
||||||
}
|
|
||||||
if gotB != Defaults() {
|
|
||||||
t.Fatalf("tenant b sollte unberuehrt bei defaults bleiben, habe %+v", gotB)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3: Aenderungen versioniert nachvollziehbar.
|
|
||||||
func TestUpdate_HistoryTracksVersions(t *testing.T) {
|
|
||||||
store, tenantA, _, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("v1")}); err != nil {
|
|
||||||
t.Fatalf("update 1: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("v2")}); err != nil {
|
|
||||||
t.Fatalf("update 2: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.Update(ctx, tenantA, Patch{ColorScheme: strp("dark")}); err != nil {
|
|
||||||
t.Fatalf("update 3: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
history, err := store.History(ctx, tenantA)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("history: %v", err)
|
|
||||||
}
|
|
||||||
if len(history) != 3 {
|
|
||||||
t.Fatalf("erwartet 3 historieneintraege, habe %d", len(history))
|
|
||||||
}
|
|
||||||
if history[0].DisplayName != "v1" || history[0].Version != 1 {
|
|
||||||
t.Fatalf("history[0] unerwartet: %+v", history[0])
|
|
||||||
}
|
|
||||||
if history[1].DisplayName != "v2" || history[1].Version != 2 {
|
|
||||||
t.Fatalf("history[1] unerwartet: %+v", history[1])
|
|
||||||
}
|
|
||||||
if history[2].ColorScheme != "dark" || history[2].DisplayName != "v2" || history[2].Version != 3 {
|
|
||||||
t.Fatalf("history[2] unerwartet (display_name haette aus vorherigem update erhalten bleiben sollen): %+v", history[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
// Package tenantsettings implementiert Core TEN-03: pro-Tenant-Einstellungen
|
|
||||||
// (Anzeigename, Logo, Farbschema, Zeitzone, Sprache) mit versionierter
|
|
||||||
// Aenderungshistorie und dokumentierten Systemvoreinstellungen.
|
|
||||||
package tenantsettings
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Settings ist der vollstaendig AUFGELOESTE Stand (jede leere Einstellung ist
|
|
||||||
// bereits durch ihren Default ersetzt) — Aufrufer muessen sich nie selbst um
|
|
||||||
// Systemvoreinstellungen kuemmern (Akzeptanzkriterium 2).
|
|
||||||
type Settings struct {
|
|
||||||
DisplayName string
|
|
||||||
LogoURL string
|
|
||||||
ColorScheme string
|
|
||||||
Timezone string
|
|
||||||
Language string
|
|
||||||
Version int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defaults liefert die dokumentierten Systemvoreinstellungen.
|
|
||||||
func Defaults() Settings {
|
|
||||||
return Settings{
|
|
||||||
DisplayName: "",
|
|
||||||
LogoURL: "",
|
|
||||||
ColorScheme: "system",
|
|
||||||
Timezone: "UTC",
|
|
||||||
Language: "de",
|
|
||||||
Version: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patch enthaelt nur die Felder, die tatsaechlich geaendert werden sollen —
|
|
||||||
// nil bedeutet "unveraendert lassen", nicht "auf Default zuruecksetzen".
|
|
||||||
type Patch struct {
|
|
||||||
DisplayName *string
|
|
||||||
LogoURL *string
|
|
||||||
ColorScheme *string
|
|
||||||
Timezone *string
|
|
||||||
Language *string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Store struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store {
|
|
||||||
return &Store{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get liefert die aufgeloesten Einstellungen eines Tenants. Existiert noch
|
|
||||||
// kein Datensatz oder sind einzelne Spalten NULL, werden die jeweiligen
|
|
||||||
// Systemvoreinstellungen eingesetzt (Akzeptanzkriterium 2 / Pruefung 2) —
|
|
||||||
// niemals ein Fehler wegen fehlender Werte.
|
|
||||||
func (s *Store) Get(ctx context.Context, tenantID string) (Settings, error) {
|
|
||||||
var displayName, logoURL, colorScheme, timezone, language *string
|
|
||||||
var version int
|
|
||||||
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT display_name, logo_url, color_scheme, timezone, language, version
|
|
||||||
FROM tenant_settings WHERE tenant_id = $1
|
|
||||||
`, tenantID)
|
|
||||||
err := row.Scan(&displayName, &logoURL, &colorScheme, &timezone, &language, &version)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Defaults(), nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Settings{}, fmt.Errorf("einstellungen lesen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
d := Defaults()
|
|
||||||
return Settings{
|
|
||||||
DisplayName: orDefault(displayName, d.DisplayName),
|
|
||||||
LogoURL: orDefault(logoURL, d.LogoURL),
|
|
||||||
ColorScheme: orDefault(colorScheme, d.ColorScheme),
|
|
||||||
Timezone: orDefault(timezone, d.Timezone),
|
|
||||||
Language: orDefault(language, d.Language),
|
|
||||||
Version: version,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func orDefault(v *string, def string) string {
|
|
||||||
if v == nil {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return *v
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update wendet patch auf den aktuellen Stand an, erhoeht die Version und
|
|
||||||
// schreibt Stand + Historieneintrag atomar (Akzeptanzkriterium 3). Nicht in
|
|
||||||
// patch gesetzte Felder bleiben unveraendert (bzw. weiterhin auf Default,
|
|
||||||
// wenn sie es vorher schon waren).
|
|
||||||
func (s *Store) Update(ctx context.Context, tenantID string, patch Patch) (Settings, error) {
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return Settings{}, fmt.Errorf("transaktion starten: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = tx.Rollback(ctx) }()
|
|
||||||
|
|
||||||
var rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage *string
|
|
||||||
var version int
|
|
||||||
row := tx.QueryRow(ctx, `
|
|
||||||
SELECT display_name, logo_url, color_scheme, timezone, language, version
|
|
||||||
FROM tenant_settings WHERE tenant_id = $1 FOR UPDATE
|
|
||||||
`, tenantID)
|
|
||||||
err = row.Scan(&rawDisplayName, &rawLogoURL, &rawColorScheme, &rawTimezone, &rawLanguage, &version)
|
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Settings{}, fmt.Errorf("aktuellen stand lesen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if patch.DisplayName != nil {
|
|
||||||
rawDisplayName = patch.DisplayName
|
|
||||||
}
|
|
||||||
if patch.LogoURL != nil {
|
|
||||||
rawLogoURL = patch.LogoURL
|
|
||||||
}
|
|
||||||
if patch.ColorScheme != nil {
|
|
||||||
rawColorScheme = patch.ColorScheme
|
|
||||||
}
|
|
||||||
if patch.Timezone != nil {
|
|
||||||
rawTimezone = patch.Timezone
|
|
||||||
}
|
|
||||||
if patch.Language != nil {
|
|
||||||
rawLanguage = patch.Language
|
|
||||||
}
|
|
||||||
newVersion := version + 1
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO tenant_settings (tenant_id, display_name, logo_url, color_scheme, timezone, language, version, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
|
||||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
|
||||||
display_name = $2, logo_url = $3, color_scheme = $4, timezone = $5, language = $6, version = $7, updated_at = now()
|
|
||||||
`, tenantID, rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage, newVersion); err != nil {
|
|
||||||
return Settings{}, fmt.Errorf("einstellungen speichern: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO tenant_settings_history (tenant_id, display_name, logo_url, color_scheme, timezone, language, version, changed_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
|
||||||
`, tenantID, rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage, newVersion); err != nil {
|
|
||||||
return Settings{}, fmt.Errorf("historie schreiben: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return Settings{}, fmt.Errorf("transaktion committen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.Get(ctx, tenantID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// History liefert die vollstaendige Versionshistorie eines Tenants
|
|
||||||
// (Akzeptanzkriterium 3 / Pruefung 3-Vorbereitung).
|
|
||||||
func (s *Store) History(ctx context.Context, tenantID string) ([]Settings, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT display_name, logo_url, color_scheme, timezone, language, version
|
|
||||||
FROM tenant_settings_history WHERE tenant_id = $1 ORDER BY version
|
|
||||||
`, tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("historie abfragen: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
d := Defaults()
|
|
||||||
var out []Settings
|
|
||||||
for rows.Next() {
|
|
||||||
var displayName, logoURL, colorScheme, timezone, language *string
|
|
||||||
var version int
|
|
||||||
if err := rows.Scan(&displayName, &logoURL, &colorScheme, &timezone, &language, &version); err != nil {
|
|
||||||
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, Settings{
|
|
||||||
DisplayName: orDefault(displayName, d.DisplayName),
|
|
||||||
LogoURL: orDefault(logoURL, d.LogoURL),
|
|
||||||
ColorScheme: orDefault(colorScheme, d.ColorScheme),
|
|
||||||
Timezone: orDefault(timezone, d.Timezone),
|
|
||||||
Language: orDefault(language, d.Language),
|
|
||||||
Version: version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -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})
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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[:]
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user