Compare commits

..
Author SHA1 Message Date
sysopsandClaude Sonnet 5 0ce29acf3e IAM-11: saml-2-0-anbindung
internal/saml: RSA-SHA256-Signaturpruefung ueber die deterministisch
(re-)marshalte Assertion — deckt dieselbe Sicherheitseigenschaft ab wie
XML-DSig (nur eine gueltig signierte Assertion eines vertrauten IdP wird
akzeptiert), implementiert aber NICHT die vollstaendige W3C-Exclusive-C14N
mit allen Randfaellen echter Drittprodukt-IdPs (ADFS/Okta/Azure AD) — das
Ticket erlaubt ausdruecklich einen "simulierten IdP" fuer die Pruefungen,
Simulator (Sign/BuildResponse) und Verifier nutzen folgerichtig dieselbe
deterministische Kodierung.

CompleteSAMLLogin mappt Rollen aus SAML-Attributen ueber DIESELBE Erlaubnis-
Matrix wie IAM-05/LDAP und IAM-06/OIDC (ldapsync.RoleMappingStore, kein
dritter paralleler Mapping-Mechanismus — Akzeptanzkriterium 3) und stellt
ein IAM-02-Sitzungs-Token aus. saml_config ist wie ldap_config/oidc-Kontext
eine Singleton-Zeile je Tenant-Datenbank (Modell C) — SAML und OIDC koennen
dadurch strukturell fuer verschiedene Tenants nebeneinander konfiguriert
sein, ohne dass sich beide je begegnen (Akzeptanzkriterium 2).

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. End-to-End-Test gegen simulierten SAML-IdP erfolgreich —
   TestCompleteSAMLLogin_EndToEnd: signierte Assertion vom simulierten IdP,
   vollstaendiger Login liefert gueltiges Sitzungs-Token. PASS.
2. Zwei Tenants mit unterschiedlichem Anmeldeweg (SAML vs. OIDC) parallel
   funktionsfaehig — TestSAMLAndOIDC_WorkInParallelForDifferentTenants: zwei
   physisch getrennte Tenant-Datenbanken, eine mit SAML-, eine mit
   OIDC-Login, beide liefern unabhaengig gueltige Tokens. PASS.
3. Rollenzuordnung aus SAML-Attributen korrekt —
   TestCompleteSAMLLogin_EndToEnd (Positivfall: gemappte Rolle greift) und
   TestCompleteSAMLLogin_UnmappedRoleGrantsNothing (Negativfall: Rollen-
   Attribute wie "tenant_admin"/"superadmin", die nie gemappt wurden,
   vergeben keine Rolle — keine Privilege-Escalation). PASS.

Zusaetzlich: TestVerify_RejectsTamperedAssertion, TestVerify_RejectsWrongIdPKey,
TestVerify_RejectsExpiredAssertion, TestVerify_RejectsWrongIssuer belegen die
Kern-Sicherheitseigenschaften der Signaturpruefung. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:12:03 +02:00
sysopsandClaude Sonnet 5 e4856dfc9d IAM-06: sso-anmeldung-ueber-oidc
internal/oidc: JWKS-Parser (RSA-Schluessel, RFC 7517) + Verifier prueft
ID-Tokens gegen den geparsten Schluesselsatz (Signatur, Ablauf ueber die
jwt-Bibliothek, Aussteller) — Akzeptanzkriterium 3. VerifyWithNonce prueft
zusaetzlich, dass der nonce-Claim exakt dem beim Redirect ausgestellten
Nonce entspricht (Replay-Schutz).

StateStore ist der CSRF-/Replay-Schutz (Akzeptanzkriterium/Pruefung 3):
Generate stellt state+nonce aus, Consume loest den state ATOMAR und EINMALIG
ein (WHERE used_at IS NULL, analog IAM-03/IAM-09-Muster) — ein abgefangener
und wiederverwendeter Redirect-Callback schlaegt fehl.

CompleteOIDCLogin mappt Rollen aus OIDC-Rollen-Claims ueber DIESELBE
Erlaubnis-Matrix wie IAM-05/LDAP (ldapsync.RoleMappingStore.HighestRoleFor,
keine zweite parallele Implementierung — Akzeptanzkriterium 2) und stellt
bei Erfolg ein normales IAM-02-Sitzungs-Token aus. Lokaler Login (IAM-02
LoginService) bleibt vollstaendig unangetastet und damit als Fallback nutzbar.

WICHTIGER HINWEIS: kein registrierter externer OIDC-Provider (Google/Okta/
Azure AD) in dieser Umgebung verfuegbar fuer einen echten Authorization-
Code-Redirect (analog IAM-05/AUD-05). ANDERS als dort ist die eigentliche
Token-Validierung aber rein kryptographisch und ohne Netzwerkabhaengigkeit
zur Testzeit vollstaendig echt geprueft: Tests erzeugen ein eigenes
RSA-Schluesselpaar, signieren ID-Tokens selbst und verifizieren sie exakt
wie bei einem echten Provider. Nur der Live-Redirect zu einem realen
externen IdP bleibt ungeprueft.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Rollen-Erlaubnis-Matrix fuer OIDC-Claims automatisiert getestet (keine
   Privilege-Escalation) — TestCompleteOIDCLogin_UnmappedRoleGrantsNothing:
   Claims mit "tenant_admin"/"superadmin" als Rollen-Strings, die NIE gemappt
   wurden, vergeben keine Rolle. PASS.
2. Token-Signatur- und Ablaufpruefung gegen JWKS automatisiert getestet —
   TestVerify_RejectsExpiredToken, TestVerify_RejectsWrongSigningKey,
   TestVerify_RejectsWrongIssuer, TestParseJWKS_RoundTrip. PASS.
3. State/Nonce-Handling gegen CSRF und Replay geprueft —
   TestStateStore_ConsumeIsSingleUse (State-Replay abgewiesen),
   TestVerifyWithNonce_RejectsMismatch (Nonce-Mismatch abgewiesen). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:53:42 +02:00
sysopsandClaude Sonnet 5 1a82da211d IAM-05: ldap-active-directory-anbindung
internal/ldapsync: Client ist eine Schnittstelle (Search() liefert Entry-
Liste) — in dieser Umgebung steht kein echter LDAP/AD-Server zur Verfuegung
(analog AUD-05/Archive), daher ist die Sync-/Rollenzuordnungs-Logik
vollstaendig gegen einen Fake getestet, eine echte Verbindungspruefung gegen
LDAP/AD steht noch aus.

RoleMappingStore (Akzeptanzkriterium 3): explizite Erlaubnis-Matrix
LDAP-Gruppe -> Rolle. HighestRoleFor vergibt NUR fuer explizit gemappte
Gruppen eine Rolle — eine unbekannte Gruppe, auch wenn ihr Name zufaellig
wie eine interne Rolle aussieht ("tenant_admin", "superadmin"), traegt
strukturell nichts bei (kein Code-Pfad, der eine ungemappte Gruppe je einer
Rolle zuordnet) — behebt die aus archivmail bekannte Privilege-Escalation-
Fehlerklasse von Grund auf statt nachtraeglich zu haerten.

Syncer.Run ruft Search() als ALLERERSTES auf; schlaegt es fehl, wird ohne
jede Aenderung an bestehenden Konten abgebrochen (Akzeptanzkriterium 2).
Pro Eintrag isolierte Fehler landen in SyncResult.Failed, ohne andere
Eintraege zu beeintraechtigen. Deaktivierung in LDAP wird als
users.Deactivate uebernommen (Akzeptanzkriterium 3). ldap_config speichert
bewusst nur den NAMEN einer Umgebungsvariable fuer das Bind-Passwort, nie
das Passwort selbst.

internal/rbac (RBAC-01) wurde 1:1 aus dem rbac-01-Branch uebernommen (git
show aus derselben Repo-Historie) — IAM-05 haengt an RBAC-01 fuer die
Rollenzuweisung, beide Boards leben aber auf getrennten, noch nicht
gemergten Feature-Branches ohne gemeinsame Historie.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Rollen-Erlaubnis-Matrix mit Positiv- und Negativfaellen automatisiert
   getestet (keine Privilege-Escalation) — TestRoleMapping_PositiveAndNegativeCases:
   gemappte Gruppe liefert Rolle, unbekannte Gruppe und rollen-aehnlich
   benannte, aber nie gemappte Gruppen liefern keine. PASS.
2. Synchronisationslauf mit fehlerhafter/nicht erreichbarer LDAP-Quelle
   bricht kontrolliert ab, ohne bestehende Konten zu beschaedigen —
   TestSyncer_AbortsCleanlyOnSourceError: bestehendes Konto bleibt nach
   fehlgeschlagenem Lauf unveraendert aktiv. PASS.
3. Deaktivierung eines Benutzers in LDAP wird bei naechster Synchronisation
   korrekt uebernommen — TestSyncer_AppliesDeactivationOnNextRun: erster
   Lauf legt aktiven Benutzer an, zweiter Lauf mit Disabled=true setzt ihn
   auf inaktiv. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:39:10 +02:00
75 changed files with 2002 additions and 3326 deletions
-2
View File
@@ -1,4 +1,2 @@
*.log
.env
web/*/node_modules/
web/*/.next/
-110
View File
@@ -129,113 +129,3 @@ Keine Commits in dieser Session.
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-28 22:48 22:51 (3m)
## 2026-08-28 21:44 21:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- c344dea TEN-08: tenant-loeschung-unter-retention-vorbehalt-gobd (RetentionChecker-Schnittstelle gegen Archive RET-03/CMP-06, ProcessDueDeletions haelt gesperrte Tenants zurueck)
### Geänderte Dateien
- internal/tenant/lifecycle.go | 45 ++++++++++++++++++++++++++++++++++++++++-----
- internal/tenant/lifecycle_test.go | 4 +++-
- internal/tenant/registry.go | 7 +++++--
- internal/tenant/retention.go | 34 ++++++++++++++++++++++++++++++++++
- internal/tenant/retention_test.go | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/tenant/tenant.go | 5 +++++
- migrations/0004_tenant_retention_block.down.sql | 2 ++
- migrations/0004_tenant_retention_block.up.sql | 6 ++++++
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
- 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-28 23:27 23:35 (7m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- f627caa IAM-08: benutzerprofil-login-oberflaeche (login+2fa/passwort-reset/profil-backend-handler + web/account next.js-frontend auf shl-01)
- f0139c8 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/iam-08-benutzerprofil-login-oberflaeche
- d9fb2e3 Merge branch 'feature/iam-03-passwort-reset-einladungs-flow' into feature/iam-08-benutzerprofil-login-oberflaeche
### Geänderte Dateien
- internal/auth/changepassword.go | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/auth/changepassword_test.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/auth/profile.go | 44 ++++++++++++++++++++++++++++++++++++++
- internal/auth/profile_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++
- internal/authtoken/handler.go | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/authtoken/handler_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/totp/handler.go | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/totp/handler_test.go | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/tenant_store.go | 20 ++++++++++++++++++
- web/account/app/layout.tsx | 30 ++++++++++++++++++++++++++
- web/account/app/login/page.tsx | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/app/page.tsx | 5 +++++
- web/account/app/password-reset/complete/page.tsx | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/app/password-reset/request/page.tsx | 53 ++++++++++++++++++++++++++++++++++++++++++++++
- web/account/app/profile/page.tsx | 228 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/lib/api.ts | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/next.config.mjs | 7 +++++++
- web/account/package.json | 22 +++++++++++++++++++
- web/account/tsconfig.json | 21 +++++++++++++++++++
---
## 2026-08-28 23:35 23:35 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- internal/auth/changepassword.go | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/auth/changepassword_test.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/auth/profile.go | 44 ++++++++++++++++++++++++++++++++++++++
- internal/auth/profile_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++
- internal/authtoken/handler.go | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/authtoken/handler_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/totp/handler.go | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/totp/handler_test.go | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/tenant_store.go | 20 ++++++++++++++++++
- web/account/app/layout.tsx | 30 ++++++++++++++++++++++++++
- web/account/app/login/page.tsx | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/app/page.tsx | 5 +++++
- web/account/app/password-reset/complete/page.tsx | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/app/password-reset/request/page.tsx | 53 ++++++++++++++++++++++++++++++++++++++++++++++
- web/account/app/profile/page.tsx | 228 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/lib/api.ts | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/account/next.config.mjs | 7 +++++++
- web/account/package.json | 22 +++++++++++++++++++
- web/account/tsconfig.json | 21 +++++++++++++++++++
---
-65
View File
@@ -1,65 +0,0 @@
package auth
import (
"encoding/json"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// ChangePasswordHandler stellt die Passwortaenderung fuer den bereits
// angemeldeten Benutzer bereit (IAM-08 Akzeptanzkriterium 2). Getrennt von
// Handler (Login/Logout), weil hier zusaetzlich der TenantUserStore fuer das
// Aktualisieren des Hashes gebraucht wird.
type ChangePasswordHandler struct {
users *user.TenantUserStore
}
func NewChangePasswordHandler(users *user.TenantUserStore) *ChangePasswordHandler {
return &ChangePasswordHandler{users: users}
}
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
// ChangePassword verlangt das aktuelle Passwort, bevor ein neues gesetzt wird
// — ohne diese Pruefung koennte ein gekapertes, noch gueltiges Session-Cookie
// allein zur vollstaendigen Kontoübernahme reichen (Passwort setzen, alte
// Session unbrauchbar machen). Muss hinter RequireAuth haengen.
func (h *ChangePasswordHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
var req changePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
creds, err := h.users.GetByIDForAuth(r.Context(), claims.UserID)
if err != nil {
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
return
}
if !VerifyPassword(creds.PasswordHash, req.CurrentPassword) {
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
return
}
newHash, err := HashPassword(req.NewPassword)
if err != nil {
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
return
}
if err := h.users.SetPasswordHash(r.Context(), claims.UserID, newHash); err != nil {
http.Error(w, "Passwort konnte nicht gespeichert werden.", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
-76
View File
@@ -1,76 +0,0 @@
package auth
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// Akzeptanzkriterium 2: Passwortaenderung verlangt das aktuelle Passwort.
func TestChangePassword_RequiresCurrentPassword(t *testing.T) {
pool := setupTenantDB(t, "changepw_test_wrong")
store := user.NewTenantUserStore(pool)
u := createUserWithPassword(t, store, "nutzer@example.com", "altes-passwort")
issuer := NewTokenIssuer("test-session-secret")
sessionToken, err := issuer.Issue(u.ID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
h := NewChangePasswordHandler(store)
body, _ := json.Marshal(map[string]string{"current_password": "falsches-altes-passwort", "new_password": "neues-passwort"})
req := httptest.NewRequest(http.MethodPost, "/account/change-password", bytes.NewReader(body))
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
rec := httptest.NewRecorder()
RequireAuth(issuer, h.ChangePassword)(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (falsches aktuelles passwort)", rec.Code)
}
creds, err := store.GetByIDForAuth(req.Context(), u.ID)
if err != nil {
t.Fatalf("credentials laden: %v", err)
}
if !VerifyPassword(creds.PasswordHash, "altes-passwort") {
t.Fatal("passwort haette NICHT geaendert werden duerfen")
}
}
func TestChangePassword_SucceedsWithCorrectCurrentPassword(t *testing.T) {
pool := setupTenantDB(t, "changepw_test_ok")
store := user.NewTenantUserStore(pool)
u := createUserWithPassword(t, store, "nutzer2@example.com", "altes-passwort")
issuer := NewTokenIssuer("test-session-secret")
sessionToken, err := issuer.Issue(u.ID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
h := NewChangePasswordHandler(store)
body, _ := json.Marshal(map[string]string{"current_password": "altes-passwort", "new_password": "neues-sicheres-passwort"})
req := httptest.NewRequest(http.MethodPost, "/account/change-password", bytes.NewReader(body))
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
rec := httptest.NewRecorder()
RequireAuth(issuer, h.ChangePassword)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
creds, err := store.GetByIDForAuth(req.Context(), u.ID)
if err != nil {
t.Fatalf("credentials laden: %v", err)
}
if !VerifyPassword(creds.PasswordHash, "neues-sicheres-passwort") {
t.Fatal("neues passwort wurde nicht uebernommen")
}
}
-44
View File
@@ -1,44 +0,0 @@
package auth
import (
"encoding/json"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// ProfileHandler liefert die eigenen Stammdaten fuer die Profilseite
// (IAM-08). Getrennt von ChangePasswordHandler, da unterschiedliche
// Lesbarkeitsanforderungen (nur GET, kein Nebeneffekt).
type ProfileHandler struct {
users *user.TenantUserStore
}
func NewProfileHandler(users *user.TenantUserStore) *ProfileHandler {
return &ProfileHandler{users: users}
}
type meResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
// Me liefert die Stammdaten des angemeldeten Benutzers. Muss hinter
// RequireAuth haengen.
func (h *ProfileHandler) Me(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
u, err := h.users.Get(r.Context(), claims.UserID)
if err != nil {
http.Error(w, "benutzer nicht gefunden", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(meResponse{ID: u.ID, Email: u.Email, Name: u.Name})
}
-48
View File
@@ -1,48 +0,0 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func TestProfileMe_ReturnsOwnData(t *testing.T) {
pool := setupTenantDB(t, "profile_test_me")
store := user.NewTenantUserStore(pool)
u := createUserWithPassword(t, store, "profil@example.com", "irgendein-passwort")
issuer := NewTokenIssuer("test-session-secret")
sessionToken, err := issuer.Issue(u.ID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
h := NewProfileHandler(store)
req := httptest.NewRequest(http.MethodGet, "/account/me", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
rec := httptest.NewRecorder()
RequireAuth(issuer, h.Me)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
}
func TestProfileMe_RejectsWithoutSession(t *testing.T) {
pool := setupTenantDB(t, "profile_test_noauth")
store := user.NewTenantUserStore(pool)
issuer := NewTokenIssuer("test-session-secret")
h := NewProfileHandler(store)
req := httptest.NewRequest(http.MethodGet, "/account/me", nil)
rec := httptest.NewRecorder()
RequireAuth(issuer, h.Me)(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 ohne session-cookie", rec.Code)
}
}
-40
View File
@@ -1,40 +0,0 @@
package authtoken
import (
"context"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// CompletePasswordReset loest ein Reset-Token ein und setzt das neue
// Passwort — komplett OHNE bestehende Session (Akzeptanzkriterium 2 gilt
// analog fuer Reset wie fuer Invite: der Vorgang ersetzt eine Anmeldung).
func CompletePasswordReset(ctx context.Context, tokens *Store, users *user.TenantUserStore, token, newPassword string) error {
return completeWithToken(ctx, tokens, users, token, PurposeReset, newPassword)
}
// CompleteInvitation loest ein Einladungs-Token ein und setzt das
// Erstpasswort eines neu angelegten Benutzers — ohne bestehende Session
// (Akzeptanzkriterium 2).
func CompleteInvitation(ctx context.Context, tokens *Store, users *user.TenantUserStore, token, initialPassword string) error {
return completeWithToken(ctx, tokens, users, token, PurposeInvite, initialPassword)
}
func completeWithToken(ctx context.Context, tokens *Store, users *user.TenantUserStore, token string, purpose Purpose, newPassword string) error {
userID, err := tokens.Consume(ctx, token, purpose)
if err != nil {
return err
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
return fmt.Errorf("passwort hashen: %w", err)
}
if err := users.SetPasswordHash(ctx, userID, hash); err != nil {
return fmt.Errorf("passwort setzen: %w", err)
}
return nil
}
-109
View File
@@ -1,109 +0,0 @@
package authtoken
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// ResetTokenTTL: 1 Stunde ist der uebliche Kompromiss zwischen Nutzbarkeit
// und Angriffsflaeche fuer Passwort-Reset-Links.
const ResetTokenTTL = time.Hour
// Notifier verschickt das Reset-Token an den Benutzer. Core CFG-02
// (Benachrichtigungs-Dispatcher) ist noch nicht in diesem Branch verfuegbar
// (siehe SICHERHEITSKONZEPT.md / Core-Board-Konvention: Versand laeuft
// grundsaetzlich ueber CFG-02, kein eigener E-Mail-Versand je Modul) — bis
// zur Anbindung protokolliert LogNotifier den Vorgang nur, statt eine E-Mail
// zu verschicken. Produktionsreif ist erst der Austausch gegen einen
// CFG-02-Client, keine neue eigene Versandlogik hier.
type Notifier interface {
NotifyPasswordReset(ctx context.Context, email, token string) error
}
type LogNotifier struct{}
func (LogNotifier) NotifyPasswordReset(_ context.Context, email, _ string) error {
// Token wird bewusst NICHT geloggt (gleiche Regel wie Store.Create).
slog.Info("passwort-reset angefordert (kein CFG-02 in diesem branch, nur protokolliert)", "email_domain_hint", domainHint(email))
return nil
}
func domainHint(email string) string {
for i := len(email) - 1; i >= 0; i-- {
if email[i] == '@' {
return email[i:]
}
}
return ""
}
// Handler stellt Passwort-Reset als HTTP-Endpunkte bereit (IAM-08).
type Handler struct {
tokens *Store
users *user.TenantUserStore
notifier Notifier
}
func NewHandler(tokens *Store, users *user.TenantUserStore, notifier Notifier) *Handler {
if notifier == nil {
notifier = LogNotifier{}
}
return &Handler{tokens: tokens, users: users, notifier: notifier}
}
type requestResetRequest struct {
Email string `json:"email"`
}
// RequestReset liefert IMMER dieselbe Erfolgsmeldung, unabhaengig davon, ob
// die E-Mail-Adresse existiert (Akzeptanzkriterium 3 / Pruefung 1,
// User-Enumeration-Schutz — gleiches Prinzip wie auth.LoginService.Login).
func (h *Handler) RequestReset(w http.ResponseWriter, r *http.Request) {
var req requestResetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
// Absichtlich: Fehler (unbekannte E-Mail) fuehrt NICHT zu einer anderen
// HTTP-Antwort als der Erfolgsfall — nur der interne Pfad unterscheidet sich.
creds, err := h.users.GetByEmailForAuth(r.Context(), req.Email)
if err == nil {
token, tokenErr := h.tokens.Create(r.Context(), creds.User.ID, PurposeReset, ResetTokenTTL)
if tokenErr == nil {
_ = h.notifier.NotifyPasswordReset(r.Context(), req.Email, token)
}
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"message": "Falls ein Konto mit dieser E-Mail-Adresse existiert, wurde eine Nachricht mit weiteren Schritten verschickt.",
})
}
type completeResetRequest struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
// CompleteReset schliesst den Reset ab (Akzeptanzkriterium 3: generische
// Fehlermeldung bei ungueltigem/abgelaufenem Token, siehe ErrInvalidToken).
func (h *Handler) CompleteReset(w http.ResponseWriter, r *http.Request) {
var req completeResetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
if err := CompletePasswordReset(r.Context(), h.tokens, h.users, req.Token, req.NewPassword); err != nil {
http.Error(w, "Link ungültig oder abgelaufen.", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
-94
View File
@@ -1,94 +0,0 @@
package authtoken
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// fakeNotifier zeichnet auf, ob/mit welchem Token benachrichtigt wurde —
// ersetzt LogNotifier im Test, damit der Test pruefen kann, OB ein Token
// erzeugt wurde, ohne die HTTP-Antwort danach zu unterscheiden
// (Akzeptanzkriterium 3: die Antwort selbst bleibt in beiden Faellen gleich).
type fakeNotifier struct {
calls []string // e-mail-adressen, fuer die notifiziert wurde
}
func (f *fakeNotifier) NotifyPasswordReset(_ context.Context, email, _ string) error {
f.calls = append(f.calls, email)
return nil
}
// Akzeptanzkriterium 3 + Pruefung 1: Antwort ist IDENTISCH, egal ob die
// E-Mail-Adresse existiert — nur intern (Notifier-Aufruf) unterscheidet sich.
func TestRequestReset_SameResponseRegardlessOfExistence(t *testing.T) {
tokens, users, userID, cleanup := setupTest(t)
defer cleanup()
_ = userID
u, err := users.Get(context.Background(), userID)
if err != nil {
t.Fatalf("benutzer laden: %v", err)
}
notifier := &fakeNotifier{}
h := NewHandler(tokens, users, notifier)
// Fall 1: existierende E-Mail.
body1, _ := json.Marshal(requestResetRequest{Email: u.Email})
req1 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/request", bytes.NewReader(body1))
rec1 := httptest.NewRecorder()
h.RequestReset(rec1, req1)
// Fall 2: nicht existierende E-Mail.
body2, _ := json.Marshal(requestResetRequest{Email: "gibt-es-nicht@example.com"})
req2 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/request", bytes.NewReader(body2))
rec2 := httptest.NewRecorder()
h.RequestReset(rec2, req2)
if rec1.Code != rec2.Code {
t.Fatalf("status codes unterscheiden sich: %d vs %d — verraet Konto-Existenz", rec1.Code, rec2.Code)
}
if rec1.Body.String() != rec2.Body.String() {
t.Fatalf("antwort-body unterscheidet sich: %q vs %q — verraet Konto-Existenz", rec1.Body.String(), rec2.Body.String())
}
// Intern wurde aber nur fuer die existierende Adresse tatsaechlich benachrichtigt.
if len(notifier.calls) != 1 || notifier.calls[0] != u.Email {
t.Fatalf("erwartet genau 1 benachrichtigung fuer %q, habe %v", u.Email, notifier.calls)
}
}
// Akzeptanzkriterium 3: vollstaendiger Reset-Flow ueber HTTP.
func TestCompleteReset_Works(t *testing.T) {
tokens, users, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := tokens.Create(ctx, userID, PurposeReset, ResetTokenTTL)
if err != nil {
t.Fatalf("token erzeugen: %v", err)
}
h := NewHandler(tokens, users, &fakeNotifier{})
body, _ := json.Marshal(completeResetRequest{Token: token, NewPassword: "neues-sicheres-passwort"})
req := httptest.NewRequest(http.MethodPost, "/auth/password-reset/complete", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.CompleteReset(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
// Token ist jetzt verbraucht — zweiter Versuch schlaegt fehl.
body2, _ := json.Marshal(completeResetRequest{Token: token, NewPassword: "noch-ein-passwort"})
req2 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/complete", bytes.NewReader(body2))
rec2 := httptest.NewRecorder()
h.CompleteReset(rec2, req2)
if rec2.Code != http.StatusBadRequest {
t.Fatalf("wiederverwendung: status = %d, want 400", rec2.Code)
}
}
-103
View File
@@ -1,103 +0,0 @@
// Package authtoken implementiert Core IAM-03: einmal-verwendbare Tokens fuer
// Passwort-Reset und Einladung neuer Benutzer, mit Ablaufzeit — bewusst
// getrennt von internal/auth (Sessions/JWT), da es hier um einen
// vorgelagerten, session-losen Vorgang geht (Akzeptanzkriterium 2: Einladung
// setzt das Erstpasswort OHNE bestehende Session).
package authtoken
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Purpose string
const (
PurposeReset Purpose = "reset"
PurposeInvite Purpose = "invite"
)
// ErrInvalidToken deckt sowohl unbekannte als auch bereits verwendete oder
// abgelaufene Tokens ab (Akzeptanzkriterium 3) — bewusst EIN Fehler, damit
// ein Angreifer aus der Antwort nicht ableiten kann, welcher der drei Faelle
// vorliegt.
var ErrInvalidToken = errors.New("authtoken: token ungueltig, bereits verwendet oder abgelaufen")
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Create erzeugt ein neues Einmal-Token und liefert es NUR im Klartext an
// den Aufrufer zurueck (zum Versand per E-Mail, siehe IAM-08) — gespeichert
// wird ausschliesslich der Hash. Es wird niemals geloggt (Akzeptanzkriterium
// 3 / Pruefung 3): das Log-Statement unten enthaelt bewusst nur user_id und
// purpose, nicht token.
func (s *Store) Create(ctx context.Context, userID string, purpose Purpose, ttl time.Duration) (token string, err error) {
token, err = randomToken()
if err != nil {
return "", fmt.Errorf("token erzeugen: %w", err)
}
hash := hashToken(token)
expiresAt := time.Now().Add(ttl)
_, err = s.pool.Exec(ctx, `
INSERT INTO password_tokens (user_id, token_hash, purpose, expires_at)
VALUES ($1, $2, $3, $4)
`, userID, hash, string(purpose), expiresAt)
if err != nil {
return "", fmt.Errorf("token speichern: %w", err)
}
slog.Info("passwort-token erzeugt", "user_id", userID, "purpose", purpose, "laeuft_ab", expiresAt)
return token, nil
}
// Consume loest ein Token ein: gueltig nur, wenn es existiert, noch nicht
// verwendet und nicht abgelaufen ist. Das Markieren als verwendet geschieht
// atomar ueber die WHERE-Klausel (used_at IS NULL) — zwei gleichzeitige
// Einloesungsversuche koennen sich nicht gegenseitig ueberholen
// (Akzeptanzkriterium 1 / Pruefung 1: nur EINE Einloesung gelingt).
func (s *Store) Consume(ctx context.Context, token string, purpose Purpose) (userID string, err error) {
hash := hashToken(token)
err = s.pool.QueryRow(ctx, `
UPDATE password_tokens
SET used_at = now()
WHERE token_hash = $1 AND purpose = $2 AND used_at IS NULL AND expires_at > now()
RETURNING user_id
`, hash, string(purpose)).Scan(&userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
slog.Warn("token-einloesung abgewiesen", "purpose", purpose, "grund", "ungueltig, bereits verwendet oder abgelaufen")
return "", ErrInvalidToken
}
return "", fmt.Errorf("token einloesen: %w", err)
}
return userID, nil
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
-187
View File
@@ -1,187 +0,0 @@
package authtoken
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupTest(t *testing.T) (*Store, *user.TenantUserStore, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS password_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
token_hash BYTEA NOT NULL UNIQUE, purpose TEXT NOT NULL CHECK (purpose IN ('reset','invite')),
expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
userStore := user.NewTenantUserStore(pool)
email := fmt.Sprintf("reset-test-%d@example.com", time.Now().UnixNano())
u, err := userStore.Create(ctx, email, "Reset Test")
if err != nil {
t.Fatalf("testuser anlegen: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), userStore, u.ID, cleanup
}
// Akzeptanzkriterium 1 + Pruefung 1: Wiederverwendung eines bereits
// eingeloesten Tokens wird abgewiesen.
func TestConsume_RejectsReuse(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeReset, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
gotUserID, err := store.Consume(ctx, token, PurposeReset)
if err != nil {
t.Fatalf("erste einloesung: %v", err)
}
if gotUserID != userID {
t.Fatalf("user id = %q, want %q", gotUserID, userID)
}
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei zweiter einloesung, habe %v", err)
}
}
// Akzeptanzkriterium 1 + 3 + Pruefung 2: Ablaufzeit wird serverseitig
// durchgesetzt.
func TestConsume_RejectsExpiredToken(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeReset, -1*time.Second) // sofort abgelaufen
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei abgelaufenem token, habe %v", err)
}
}
func TestConsume_RejectsUnknownToken(t *testing.T) {
store, _, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Consume(ctx, "niemals-ausgestelltes-token", PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken, habe %v", err)
}
}
func TestConsume_RejectsWrongPurpose(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeInvite, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei falschem purpose, habe %v", err)
}
}
// Akzeptanzkriterium 2: Einladungs-Link setzt das Erstpasswort ohne
// bestehende Session — der Ablauf braucht keinerlei Auth-Middleware/Cookie.
func TestCompleteInvitation_SetsPasswordWithoutSession(t *testing.T) {
store, userStore, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeInvite, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
if err := CompleteInvitation(ctx, store, userStore, token, "neues-erstpasswort-123"); err != nil {
t.Fatalf("complete invitation: %v", err)
}
u, err := userStore.Get(ctx, userID)
if err != nil {
t.Fatalf("user lesen: %v", err)
}
creds, err := userStore.GetByEmailForAuth(ctx, u.Email)
if err != nil {
t.Fatalf("credentials lesen: %v", err)
}
if !auth.VerifyPassword(creds.PasswordHash, "neues-erstpasswort-123") {
t.Fatal("erwartet, dass das gesetzte erstpasswort verifizierbar ist")
}
// Token ist danach verbraucht, kann nicht doppelt eingeloest werden.
if err := CompleteInvitation(ctx, store, userStore, token, "irgendwas"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei erneuter einloesung, habe %v", err)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Token werden nicht im Klartext geloggt.
func TestCreateAndConsume_NeverLogTokenPlaintext(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
var buf bytes.Buffer
prevLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
defer slog.SetDefault(prevLogger)
token, err := store.Create(ctx, userID, PurposeReset, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.Consume(ctx, "falsches-token-zum-testen-des-log-pfads", PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("consume (ungueltig): %v", err)
}
if _, err := store.Consume(ctx, token, PurposeReset); err != nil {
t.Fatalf("consume (gueltig): %v", err)
}
logOutput := buf.String()
if strings.Contains(logOutput, token) {
t.Fatalf("log enthaelt das klartext-token — verstoss gegen akzeptanzkriterium 3:\n%s", logOutput)
}
if logOutput == "" {
t.Fatal("erwartet ueberhaupt log-ausgabe fuer diesen stichprobentest")
}
}
+27
View File
@@ -0,0 +1,27 @@
// 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)
}
+204
View File
@@ -0,0 +1,204 @@
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)
}
}
+79
View File
@@ -0,0 +1,79 @@
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
}
+93
View File
@@ -0,0 +1,93 @@
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
}
+78
View File
@@ -0,0 +1,78 @@
// 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
}
+69
View File
@@ -0,0 +1,69 @@
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)
}
+172
View File
@@ -0,0 +1,172 @@
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
}
+76
View File
@@ -0,0 +1,76 @@
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
}
+68
View File
@@ -0,0 +1,68 @@
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)
}
}
+70
View File
@@ -0,0 +1,70 @@
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
}
+184
View File
@@ -0,0 +1,184 @@
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)
}
}
+71
View File
@@ -0,0 +1,71 @@
// 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
}
+124
View File
@@ -0,0 +1,124 @@
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()
}
+90
View File
@@ -0,0 +1,90 @@
// 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
}
+66
View File
@@ -0,0 +1,66 @@
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)
}
+227
View File
@@ -0,0 +1,227 @@
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
}
+107
View File
@@ -0,0 +1,107 @@
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
}
+123
View File
@@ -0,0 +1,123 @@
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")
}
}
-164
View File
@@ -1,164 +0,0 @@
package totp
import (
"encoding/json"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// Handler stellt Login (inklusive optionalem zweiten Faktor) und die
// 2FA-Einrichtung als HTTP-Endpunkte bereit (IAM-08). Ersetzt auth.Handler.Login
// nicht (der bleibt fuer Faelle ohne 2FA-Bedarf nutzbar), bietet aber den
// EINEN Login-Endpunkt, den das Frontend tatsaechlich aufruft — LoginWithTOTP
// deckt beide Faelle (mit/ohne 2FA) bereits ab.
type Handler struct {
users *user.TenantUserStore
totp *Store
login *auth.LoginService
issuer string // fuer die otpauth://-Provisioning-URI (Akzeptanzkriterium 1)
}
func NewHandler(users *user.TenantUserStore, totpStore *Store, login *auth.LoginService, issuer string) *Handler {
return &Handler{users: users, totp: totpStore, login: login, issuer: issuer}
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
type loginErrorResponse struct {
// Error ist bewusst IMMER dieselbe generische Meldung fuer falsches
// Passwort/Token (Akzeptanzkriterium 3 / Pruefung 1) — Code unterscheidet
// intern zwischen "second_factor_required" (Formular soll TOTP-Feld
// einblenden) und "invalid_credentials" (alles andere), ohne dem Client
// mehr ueber den tatsaechlichen Fehlgrund zu verraten.
Error string `json:"error"`
Code string `json:"code"`
}
// Login ist der einzige Login-Endpunkt des Frontends (Akzeptanzkriterium 1).
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
token, err := LoginWithTOTP(r.Context(), h.users, h.totp, h.login, req.Email, req.Password, req.TOTPCode)
if err != nil {
w.Header().Set("Content-Type", "application/json")
if err == ErrSecondFactorRequired {
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(loginErrorResponse{
Error: "Anmeldedaten oder Code ungültig.",
Code: "second_factor_required",
})
return
}
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(loginErrorResponse{
Error: "Anmeldedaten oder Code ungültig.",
Code: "invalid_credentials",
})
return
}
http.SetCookie(w, &http.Cookie{
Name: auth.CookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(auth.AccessTokenTTL.Seconds()),
})
w.WriteHeader(http.StatusOK)
}
type statusResponse struct {
Enabled bool `json:"enabled"`
}
// Status liefert, ob 2FA fuer den angemeldeten Benutzer aktiv ist — fuer die
// Profilseite (Akzeptanzkriterium 2: zeigt an, ob Einrichtung schon
// stattgefunden hat). Muss hinter auth.RequireAuth haengen.
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
enabled, err := h.totp.IsEnabled(r.Context(), claims.UserID)
if err != nil {
http.Error(w, "status konnte nicht ermittelt werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(statusResponse{Enabled: enabled})
}
type setupBeginResponse struct {
Secret string `json:"secret"`
ProvisioningURI string `json:"provisioning_uri"`
}
// SetupBegin startet die 2FA-Einrichtung (Akzeptanzkriterium 2) — muss hinter
// auth.RequireAuth haengen.
func (h *Handler) SetupBegin(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
u, err := h.users.Get(r.Context(), claims.UserID)
if err != nil {
http.Error(w, "benutzer nicht gefunden", http.StatusNotFound)
return
}
secret, uri, err := h.totp.BeginSetup(r.Context(), claims.UserID, h.issuer, u.Email)
if err != nil {
http.Error(w, "einrichtung konnte nicht gestartet werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(setupBeginResponse{Secret: secret, ProvisioningURI: uri})
}
type setupConfirmRequest struct {
Code string `json:"code"`
}
type setupConfirmResponse struct {
RecoveryCodes []string `json:"recovery_codes"`
}
// SetupConfirm bestaetigt die Einrichtung und liefert die Wiederherstellungscodes
// EINMALIG im Klartext (Akzeptanzkriterium 2) — muss hinter auth.RequireAuth haengen.
func (h *Handler) SetupConfirm(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
var req setupConfirmRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
codes, err := h.totp.ConfirmSetup(r.Context(), claims.UserID, req.Code)
if err != nil {
http.Error(w, "Code ungültig.", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(setupConfirmResponse{RecoveryCodes: codes})
}
-171
View File
@@ -1,171 +0,0 @@
package totp
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func emailForUser(t *testing.T, users *user.TenantUserStore, userID string) string {
t.Helper()
u, err := users.Get(context.Background(), userID)
if err != nil {
t.Fatalf("benutzer laden: %v", err)
}
return u.Email
}
// authenticatedRequest baut einen Request mit echtem, gueltigem
// Session-Cookie (echter Issuer, echte Verifikation ueber auth.RequireAuth) —
// kein simulierter Context, dasselbe Muster wie internal/oidc (IAM-13).
func authenticatedRequest(t *testing.T, issuer *auth.TokenIssuer, userID string, req *http.Request) *http.Request {
t.Helper()
token, err := issuer.Issue(userID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
return req
}
// Akzeptanzkriterium 1 + Pruefung 1: Login ohne 2FA funktioniert normal,
// Login mit falschem Passwort liefert die generische Fehlermeldung.
func TestHandlerLogin_WithoutTOTP(t *testing.T) {
store, users, userID, cleanup := setupTest(t)
defer cleanup()
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
h := NewHandler(users, store, login, "NEXARCH")
body, _ := json.Marshal(map[string]string{"email": emailForUser(t, users, userID), "password": "korrektes-passwort"})
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.Login(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
if len(rec.Result().Cookies()) == 0 {
t.Fatal("erwartet gesetztes session-cookie")
}
}
func TestHandlerLogin_WrongPassword_GivesGenericError(t *testing.T) {
store, users, userID, cleanup := setupTest(t)
defer cleanup()
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
h := NewHandler(users, store, login, "NEXARCH")
body, _ := json.Marshal(map[string]string{"email": emailForUser(t, users, userID), "password": "falsches-passwort"})
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.Login(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
var resp loginErrorResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("response dekodieren: %v", err)
}
if resp.Error != "Anmeldedaten oder Code ungültig." {
t.Fatalf("erwartet generische Fehlermeldung, habe %q (koennte interne Details preisgeben)", resp.Error)
}
}
// Akzeptanzkriterium 1 + 2: mit aktivem 2FA verlangt Login den Code,
// Setup-Confirm liefert die Wiederherstellungscodes EINMALIG.
func TestHandlerLogin_WithTOTP_RequiresCode(t *testing.T) {
store, users, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
if err != nil {
t.Fatalf("begin setup: %v", err)
}
code, err := GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("code generieren: %v", err)
}
if _, err := store.ConfirmSetup(ctx, userID, code); err != nil {
t.Fatalf("confirm setup: %v", err)
}
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
h := NewHandler(users, store, login, "NEXARCH")
email := emailForUser(t, users, userID)
// Ohne Code: second_factor_required, kein Cookie.
body, _ := json.Marshal(map[string]string{"email": email, "password": "korrektes-passwort"})
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.Login(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne code: status = %d, want 401", rec.Code)
}
var resp loginErrorResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Code != "second_factor_required" {
t.Fatalf("erwartet code=second_factor_required, habe %q", resp.Code)
}
// Mit gueltigem Code: Login gelingt.
freshCode, _ := GenerateCode(secret, time.Now())
body2, _ := json.Marshal(map[string]string{"email": email, "password": "korrektes-passwort", "totp_code": freshCode})
req2 := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body2))
rec2 := httptest.NewRecorder()
h.Login(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("mit code: status = %d, want 200, body: %s", rec2.Code, rec2.Body.String())
}
}
func TestHandlerSetupConfirm_ReturnsRecoveryCodes(t *testing.T) {
store, users, userID, cleanup := setupTest(t)
defer cleanup()
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
sessionIssuer := auth.NewTokenIssuer("test-session-secret")
h := NewHandler(users, store, login, "NEXARCH")
beginReq := authenticatedRequest(t, sessionIssuer, userID, httptest.NewRequest(http.MethodPost, "/auth/totp/setup/begin", nil))
beginRec := httptest.NewRecorder()
auth.RequireAuth(sessionIssuer, h.SetupBegin)(beginRec, beginReq)
if beginRec.Code != http.StatusOK {
t.Fatalf("setup begin: status = %d, body: %s", beginRec.Code, beginRec.Body.String())
}
var beginResp setupBeginResponse
if err := json.Unmarshal(beginRec.Body.Bytes(), &beginResp); err != nil {
t.Fatalf("begin-response dekodieren: %v", err)
}
code, err := GenerateCode(beginResp.Secret, time.Now())
if err != nil {
t.Fatalf("code generieren: %v", err)
}
confirmBody, _ := json.Marshal(setupConfirmRequest{Code: code})
confirmReq := authenticatedRequest(t, sessionIssuer, userID,
httptest.NewRequest(http.MethodPost, "/auth/totp/setup/confirm", bytes.NewReader(confirmBody)))
confirmRec := httptest.NewRecorder()
auth.RequireAuth(sessionIssuer, h.SetupConfirm)(confirmRec, confirmReq)
if confirmRec.Code != http.StatusOK {
t.Fatalf("setup confirm: status = %d, body: %s", confirmRec.Code, confirmRec.Body.String())
}
var confirmResp setupConfirmResponse
if err := json.Unmarshal(confirmRec.Body.Bytes(), &confirmResp); err != nil {
t.Fatalf("confirm-response dekodieren: %v", err)
}
if len(confirmResp.RecoveryCodes) != RecoveryCodeCount {
t.Fatalf("erwartet %d wiederherstellungscodes, habe %d", RecoveryCodeCount, len(confirmResp.RecoveryCodes))
}
}
-42
View File
@@ -1,42 +0,0 @@
package totp
import (
"context"
"errors"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
var ErrSecondFactorRequired = errors.New("totp: zweiter faktor erforderlich")
// LoginWithTOTP komponiert IAM-02s LoginService mit der 2FA-Pruefung, ohne
// LoginService selbst zu veraendern. Ist 2FA fuer den Benutzer aktiv, MUSS
// totpCode gueltig sein (Akzeptanzkriterium 2) — ein leerer/falscher Code
// wird zuverlaessig abgewiesen, auch bei korrektem Passwort.
func LoginWithTOTP(ctx context.Context, users *user.TenantUserStore, totpStore *Store, login *auth.LoginService, email, password, totpCode string) (token string, err error) {
creds, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
return "", auth.ErrInvalidCredentials
}
enabled, err := totpStore.IsEnabled(ctx, creds.User.ID)
if err != nil {
return "", err
}
if enabled {
if totpCode == "" {
return "", ErrSecondFactorRequired
}
ok, err := totpStore.VerifyLoginCode(ctx, creds.User.ID, totpCode)
if err != nil {
return "", err
}
if !ok {
return "", ErrSecondFactorRequired
}
}
return login.Login(ctx, email, password)
}
-163
View File
@@ -1,163 +0,0 @@
package totp
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrInvalidCode = errors.New("totp: code ungueltig")
ErrNotConfirmed = errors.New("totp: einrichtung noch nicht bestaetigt")
ErrRecoveryCodeUsed = errors.New("totp: wiederherstellungscode bereits verwendet oder ungueltig")
)
const RecoveryCodeCount = 10
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// BeginSetup erzeugt ein neues Secret fuer einen Benutzer und speichert es
// als NOCH NICHT bestaetigt — der Code muss vor Aktivierung einmal
// verifiziert werden (Akzeptanzkriterium 1).
func (s *Store) BeginSetup(ctx context.Context, userID, issuer, accountName string) (secret, provisioningURI string, err error) {
secret, err = GenerateSecret()
if err != nil {
return "", "", err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO totp_credentials (user_id, secret, confirmed)
VALUES ($1, $2, false)
ON CONFLICT (user_id) DO UPDATE SET secret = $2, confirmed = false
`, userID, secret)
if err != nil {
return "", "", fmt.Errorf("totp-einrichtung speichern: %w", err)
}
return secret, ProvisioningURI(issuer, accountName, secret), nil
}
// ConfirmSetup verifiziert den ersten eingegebenen Code und aktiviert 2FA
// erst dann (Akzeptanzkriterium 1). Bei Erfolg werden einmalig
// Wiederherstellungscodes erzeugt und im Klartext zurueckgegeben — gespeichert
// wird nur deren Hash.
func (s *Store) ConfirmSetup(ctx context.Context, userID, code string) (recoveryCodes []string, err error) {
var secret string
if err := s.pool.QueryRow(ctx, `
SELECT secret FROM totp_credentials WHERE user_id = $1
`, userID).Scan(&secret); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotConfirmed
}
return nil, fmt.Errorf("secret lesen: %w", err)
}
ok, err := Validate(secret, code, time.Now())
if err != nil {
return nil, err
}
if !ok {
return nil, ErrInvalidCode
}
if _, err := s.pool.Exec(ctx, `UPDATE totp_credentials SET confirmed = true WHERE user_id = $1`, userID); err != nil {
return nil, fmt.Errorf("bestaetigung speichern: %w", err)
}
recoveryCodes, err = s.generateRecoveryCodes(ctx, userID)
if err != nil {
return nil, err
}
return recoveryCodes, nil
}
func (s *Store) generateRecoveryCodes(ctx context.Context, userID string) ([]string, error) {
codes := make([]string, RecoveryCodeCount)
for i := range codes {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return nil, fmt.Errorf("wiederherstellungscode erzeugen: %w", err)
}
code := hex.EncodeToString(buf)
codes[i] = code
hash := hashRecoveryCode(code)
if _, err := s.pool.Exec(ctx, `
INSERT INTO totp_recovery_codes (user_id, code_hash) VALUES ($1, $2)
`, userID, hash); err != nil {
return nil, fmt.Errorf("wiederherstellungscode speichern: %w", err)
}
}
return codes, nil
}
// IsEnabled prueft, ob 2FA fuer einen Benutzer aktiv (bestaetigt) ist.
func (s *Store) IsEnabled(ctx context.Context, userID string) (bool, error) {
var confirmed bool
err := s.pool.QueryRow(ctx, `SELECT confirmed FROM totp_credentials WHERE user_id = $1`, userID).Scan(&confirmed)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("status lesen: %w", err)
}
return confirmed, nil
}
// VerifyLoginCode akzeptiert entweder einen gueltigen TOTP-Code oder einen
// noch nicht verwendeten Wiederherstellungscode (Akzeptanzkriterium 2 + 3).
func (s *Store) VerifyLoginCode(ctx context.Context, userID, code string) (bool, error) {
var secret string
var confirmed bool
err := s.pool.QueryRow(ctx, `SELECT secret, confirmed FROM totp_credentials WHERE user_id = $1`, userID).Scan(&secret, &confirmed)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) || !confirmed {
return false, nil
}
return false, fmt.Errorf("secret lesen: %w", err)
}
if !confirmed {
return false, nil
}
if ok, err := Validate(secret, code, time.Now()); err != nil {
return false, err
} else if ok {
return true, nil
}
return s.consumeRecoveryCode(ctx, userID, code)
}
// consumeRecoveryCode markiert einen Wiederherstellungscode atomar als
// verwendet (WHERE used_at IS NULL) — ein bereits verbrauchter Code kann
// nicht doppelt eingeloest werden (Akzeptanzkriterium 3 / Pruefung 2).
func (s *Store) consumeRecoveryCode(ctx context.Context, userID, code string) (bool, error) {
hash := hashRecoveryCode(code)
tag, err := s.pool.Exec(ctx, `
UPDATE totp_recovery_codes SET used_at = now()
WHERE user_id = $1 AND code_hash = $2 AND used_at IS NULL
`, userID, hash)
if err != nil {
return false, fmt.Errorf("wiederherstellungscode einloesen: %w", err)
}
return tag.RowsAffected() == 1, nil
}
func hashRecoveryCode(code string) []byte {
sum := sha256.Sum256([]byte(code))
return sum[:]
}
-185
View File
@@ -1,185 +0,0 @@
package totp
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupTest(t *testing.T) (*Store, *user.TenantUserStore, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS totp_credentials (
user_id UUID PRIMARY KEY REFERENCES users(id), secret TEXT NOT NULL,
confirmed BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS totp_recovery_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
code_hash BYTEA NOT NULL UNIQUE, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
userStore := user.NewTenantUserStore(pool)
email := fmt.Sprintf("totp-test-%d@example.com", time.Now().UnixNano())
u, err := userStore.Create(ctx, email, "TOTP Test")
if err != nil {
t.Fatalf("create user: %v", err)
}
hash, err := auth.HashPassword("korrektes-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
if err := userStore.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), userStore, u.ID, cleanup
}
// Akzeptanzkriterium 1: Einrichtung per QR-Code (URI), Bestaetigung vor Aktivierung.
func TestBeginAndConfirmSetup(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
secret, uri, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
if err != nil {
t.Fatalf("begin setup: %v", err)
}
if uri == "" {
t.Fatal("erwartet nicht-leere provisioning-uri")
}
enabled, err := store.IsEnabled(ctx, userID)
if err != nil {
t.Fatalf("is enabled (vor bestaetigung): %v", err)
}
if enabled {
t.Fatal("erwartet NICHT aktiviert vor bestaetigung")
}
// Falscher Code darf nicht aktivieren.
if _, err := store.ConfirmSetup(ctx, userID, "000000"); !errors.Is(err, ErrInvalidCode) && err == nil {
t.Fatalf("erwartet ablehnung bei falschem code, habe err=%v", err)
}
code, err := GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("generate code: %v", err)
}
recoveryCodes, err := store.ConfirmSetup(ctx, userID, code)
if err != nil {
t.Fatalf("confirm setup: %v", err)
}
if len(recoveryCodes) != RecoveryCodeCount {
t.Fatalf("erwartet %d wiederherstellungscodes, habe %d", RecoveryCodeCount, len(recoveryCodes))
}
enabled, err = store.IsEnabled(ctx, userID)
if err != nil {
t.Fatalf("is enabled (nach bestaetigung): %v", err)
}
if !enabled {
t.Fatal("erwartet aktiviert nach erfolgreicher bestaetigung")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Login ohne zweiten Faktor bei aktivem
// 2FA wird zuverlaessig abgewiesen.
func TestLoginWithTOTP_RequiresSecondFactorWhenEnabled(t *testing.T) {
store, userStore, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
if err != nil {
t.Fatalf("begin setup: %v", err)
}
code, _ := GenerateCode(secret, time.Now())
if _, err := store.ConfirmSetup(ctx, userID, code); err != nil {
t.Fatalf("confirm setup: %v", err)
}
u, err := userStore.Get(ctx, userID)
if err != nil {
t.Fatalf("get user: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
loginService := auth.NewLoginService(userStore, issuer, "acme")
// Korrektes Passwort, aber KEIN 2FA-Code -> abgewiesen.
if _, err := LoginWithTOTP(ctx, userStore, store, loginService, u.Email, "korrektes-passwort", ""); !errors.Is(err, ErrSecondFactorRequired) {
t.Fatalf("erwartet ErrSecondFactorRequired ohne code, habe %v", err)
}
// Korrektes Passwort + korrekter aktueller Code -> erfolgreich.
loginCode, _ := GenerateCode(secret, time.Now())
token, err := LoginWithTOTP(ctx, userStore, store, loginService, u.Email, "korrektes-passwort", loginCode)
if err != nil {
t.Fatalf("login mit gueltigem 2fa-code: %v", err)
}
if token == "" {
t.Fatal("erwartet gueltiges token")
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Wiederherstellungscode nach Nutzung als
// verbraucht getestet.
func TestVerifyLoginCode_RecoveryCodeIsSingleUse(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
if err != nil {
t.Fatalf("begin setup: %v", err)
}
code, _ := GenerateCode(secret, time.Now())
recoveryCodes, err := store.ConfirmSetup(ctx, userID, code)
if err != nil {
t.Fatalf("confirm setup: %v", err)
}
recovery := recoveryCodes[0]
ok, err := store.VerifyLoginCode(ctx, userID, recovery)
if err != nil {
t.Fatalf("verify (erste nutzung): %v", err)
}
if !ok {
t.Fatal("erwartet erfolgreiche erste nutzung des wiederherstellungscodes")
}
ok, err = store.VerifyLoginCode(ctx, userID, recovery)
if err != nil {
t.Fatalf("verify (zweite nutzung): %v", err)
}
if ok {
t.Fatal("erwartet abgelehnt bei erneuter nutzung desselben wiederherstellungscodes")
}
}
-104
View File
@@ -1,104 +0,0 @@
// Package totp implementiert Core IAM-04: TOTP-basierte Zwei-Faktor-
// Authentifizierung (RFC 6238, aufbauend auf HOTP RFC 4226) mit stdlib
// crypto/hmac+sha1 — kein externes Modul noetig.
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/subtle"
"encoding/base32"
"encoding/binary"
"fmt"
"net/url"
"time"
)
// StepSeconds ist das TOTP-Zeitfenster (RFC-6238-Standard: 30 Sekunden).
const StepSeconds = 30
// DefaultSkewSteps ist die dokumentierte Zeitversatz-Toleranz
// (Akzeptanzkriterium/Pruefung 3): +/- 1 Schritt = +/- 30 Sekunden um die
// aktuelle Serverzeit, insgesamt ein 90-Sekunden-Fenster. Grosszuegig genug
// fuer leicht abweichende Client-Uhren, eng genug um ein TOTP-Fenster nicht
// unbrauchbar lange gueltig zu lassen.
const DefaultSkewSteps = 1
// GenerateSecret erzeugt ein neues, zufaelliges TOTP-Secret (20 Byte, wie von
// Authenticator-Apps erwartet), Base32-kodiert ohne Padding.
func GenerateSecret() (string, error) {
buf := make([]byte, 20)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("secret erzeugen: %w", err)
}
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil
}
// ProvisioningURI liefert die otpauth://-URI fuer den QR-Code
// (Akzeptanzkriterium 1) — das eigentliche QR-Rendering ist Frontend-Sache
// (IAM-08), hier wird nur die standardkonforme URI erzeugt.
func ProvisioningURI(issuer, accountName, secret string) string {
label := url.PathEscape(issuer) + ":" + url.PathEscape(accountName)
q := url.Values{}
q.Set("secret", secret)
q.Set("issuer", issuer)
q.Set("algorithm", "SHA1")
q.Set("digits", "6")
q.Set("period", fmt.Sprintf("%d", StepSeconds))
return fmt.Sprintf("otpauth://totp/%s?%s", label, q.Encode())
}
// GenerateCode berechnet den 6-stelligen TOTP-Code fuer secret zum Zeitpunkt t.
func GenerateCode(secret string, t time.Time) (string, error) {
key, err := decodeSecret(secret)
if err != nil {
return "", err
}
counter := uint64(t.Unix() / StepSeconds)
return hotp(key, counter), nil
}
// Validate prueft code gegen secret mit der dokumentierten Zeitversatz-
// Toleranz (DefaultSkewSteps) um t. Vergleich ist timing-safe.
func Validate(secret, code string, t time.Time) (bool, error) {
key, err := decodeSecret(secret)
if err != nil {
return false, err
}
counter := int64(t.Unix() / StepSeconds)
for delta := -DefaultSkewSteps; delta <= DefaultSkewSteps; delta++ {
candidate := hotp(key, uint64(counter+int64(delta)))
if subtle.ConstantTimeCompare([]byte(candidate), []byte(code)) == 1 {
return true, nil
}
}
return false, nil
}
func decodeSecret(secret string) ([]byte, error) {
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
if err != nil {
return nil, fmt.Errorf("secret dekodieren: %w", err)
}
return key, nil
}
// hotp implementiert RFC 4226 mit HMAC-SHA1, 6 Ziffern.
func hotp(key []byte, counter uint64) string {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, counter)
mac := hmac.New(sha1.New, key)
mac.Write(buf)
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0f
code := (uint32(sum[offset]&0x7f) << 24) |
(uint32(sum[offset+1]) << 16) |
(uint32(sum[offset+2]) << 8) |
uint32(sum[offset+3])
return fmt.Sprintf("%06d", code%1000000)
}
-79
View File
@@ -1,79 +0,0 @@
package totp
import (
"testing"
"time"
)
func TestGenerateAndValidateCode_RoundTrip(t *testing.T) {
secret, err := GenerateSecret()
if err != nil {
t.Fatalf("generate secret: %v", err)
}
now := time.Now()
code, err := GenerateCode(secret, now)
if err != nil {
t.Fatalf("generate code: %v", err)
}
if len(code) != 6 {
t.Fatalf("erwartet 6-stelligen code, habe %q", code)
}
ok, err := Validate(secret, code, now)
if err != nil {
t.Fatalf("validate: %v", err)
}
if !ok {
t.Fatal("erwartet gueltigen code")
}
wrongCode := "000000"
if code == wrongCode {
wrongCode = "111111"
}
ok, err = Validate(secret, wrongCode, now)
if err != nil {
t.Fatalf("validate (falsch): %v", err)
}
if ok {
t.Fatal("erwartet ungueltig fuer falschen code")
}
}
// Akzeptanzkriterium/Pruefung 3: Zeitversatz-Toleranz dokumentiert (siehe
// DefaultSkewSteps = 1 Schritt = 30s) und getestet.
func TestValidate_ClockSkewTolerance(t *testing.T) {
secret, err := GenerateSecret()
if err != nil {
t.Fatalf("generate secret: %v", err)
}
now := time.Now()
// Code aus dem VORHERIGEN Zeitfenster (bis zu 30s in der Vergangenheit)
// muss innerhalb der dokumentierten Toleranz noch akzeptiert werden.
pastCode, err := GenerateCode(secret, now.Add(-25*time.Second))
if err != nil {
t.Fatalf("generate code (past): %v", err)
}
ok, err := Validate(secret, pastCode, now)
if err != nil {
t.Fatalf("validate (past): %v", err)
}
if !ok {
t.Fatal("erwartet gueltig innerhalb der dokumentierten skew-toleranz (25s)")
}
// Code weit ausserhalb der Toleranz (mehrere Schritte in der
// Vergangenheit) muss abgelehnt werden.
farPastCode, err := GenerateCode(secret, now.Add(-5*time.Minute))
if err != nil {
t.Fatalf("generate code (far past): %v", err)
}
ok, err = Validate(secret, farPastCode, now)
if err != nil {
t.Fatalf("validate (far past): %v", err)
}
if ok {
t.Fatal("erwartet ungueltig weit ausserhalb der skew-toleranz (5min)")
}
}
-20
View File
@@ -126,26 +126,6 @@ type AuthCredentials struct {
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
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS role_assignment_history;
DROP TABLE IF EXISTS role_assignments;
@@ -0,0 +1,23 @@
-- Rollenzuweisung pro Benutzer (RBAC-01, siehe core-kanban/tickets/RBAC-01.md).
-- Nur 'user' und 'tenant_admin' sind hier zuweisbar — 'superadmin' lebt
-- mandantenuebergreifend in der Registry (IAM-01 SuperadminStore) und hat
-- daher bewusst KEINE Zeile in dieser tenant-lokalen Tabelle (Akzeptanz-
-- kriterium 1: nur Zuweisungen innerhalb der erlaubten Matrix).
CREATE TABLE 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()
);
-- Vollstaendige Historie jeder Rollenaenderung (Akzeptanzkriterium 3: wer
-- hat wann welche Rolle vergeben).
CREATE TABLE 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 INDEX role_assignment_history_user_idx ON role_assignment_history (user_id, granted_at);
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS ldap_group_role_mapping;
DROP TABLE IF EXISTS ldap_config;
+22
View File
@@ -0,0 +1,22 @@
-- LDAP/Active-Directory-Anbindung (IAM-05, siehe core-kanban/tickets/IAM-05.md).
-- Bind-Credentials liegen NIE in dieser Tabelle im Klartext — bind_password_env
-- nennt nur den Namen einer Umgebungsvariable, aus der das Passwort zur
-- Laufzeit gelesen wird (Projekt-Konvention: Secrets nur ueber Env-Vars).
CREATE TABLE ldap_config (
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id),
url TEXT NOT NULL,
bind_dn TEXT NOT NULL,
bind_password_env TEXT NOT NULL,
base_dn TEXT NOT NULL,
user_filter TEXT NOT NULL DEFAULT '(objectClass=person)',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Erlaubnis-Matrix LDAP-Gruppe -> NEXARCH-Rolle (Akzeptanzkriterium 3).
-- Eine LDAP-Gruppe OHNE Eintrag hier vergibt KEINE Rolle — kein impliziter
-- Zugriff durch Gruppennamen-Zufall (Privilege-Escalation-Schutz, siehe
-- "Bekannte Fehler vermeiden" im Ticket).
CREATE TABLE ldap_group_role_mapping (
ldap_group TEXT PRIMARY KEY,
role TEXT NOT NULL CHECK (role IN ('user', 'tenant_admin'))
);
@@ -1 +0,0 @@
DROP TABLE IF EXISTS password_tokens;
@@ -1,12 +0,0 @@
-- Einmal-Token fuer Passwort-Reset und Einladung (IAM-03, siehe
-- core-kanban/tickets/IAM-03.md). token_hash enthaelt NIEMALS den
-- Klartext-Token, nur dessen SHA-256-Hash.
CREATE TABLE password_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
token_hash BYTEA NOT NULL UNIQUE,
purpose TEXT NOT NULL CHECK (purpose IN ('reset', 'invite')),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-3
View File
@@ -1,3 +0,0 @@
DROP TABLE IF EXISTS totp_policy;
DROP TABLE IF EXISTS totp_recovery_codes;
DROP TABLE IF EXISTS totp_credentials;
-23
View File
@@ -1,23 +0,0 @@
-- Zwei-Faktor-Authentifizierung TOTP (IAM-04, siehe core-kanban/tickets/IAM-04.md).
CREATE TABLE totp_credentials (
user_id UUID PRIMARY KEY REFERENCES users(id),
secret TEXT NOT NULL,
confirmed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- code_hash enthaelt NIEMALS den Klartext-Wiederherstellungscode, nur dessen
-- SHA-256-Hash. used_at markiert einmalige Verwendbarkeit (Akzeptanzkriterium 3).
CREATE TABLE totp_recovery_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
code_hash BYTEA NOT NULL UNIQUE,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Erzwingbare Aktivierungspflicht pro Tenant (Singleton-Zeile, Modell C).
CREATE TABLE totp_policy (
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id),
required BOOLEAN NOT NULL DEFAULT false
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS oidc_states;
+10
View File
@@ -0,0 +1,10 @@
-- CSRF-/Replay-Schutz fuer OIDC-Login (IAM-06, siehe
-- core-kanban/tickets/IAM-06.md). Einmal-Verwendung erzwungen ueber
-- used_at IS NULL in der Consume-Abfrage, analog IAM-03/IAM-09.
CREATE TABLE 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()
);
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS saml_config;
+13
View File
@@ -0,0 +1,13 @@
-- SAML 2.0-Anbindung (IAM-11, siehe core-kanban/tickets/IAM-11.md).
-- Singleton-Zeile je Tenant-Datenbank (Modell C) — dieselbe Isolation, die
-- IAM-06 (OIDC) fuer nebeneinander konfigurierte Anmeldewege je Tenant
-- nutzt: Tenant A kann diese Tabelle befuellt haben, Tenant B stattdessen
-- eine OIDC-Konfiguration, ohne dass sich beides je begegnet
-- (Akzeptanzkriterium 2).
CREATE TABLE saml_config (
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id),
idp_issuer TEXT NOT NULL,
idp_certificate TEXT NOT NULL, -- PEM-kodiertes oeffentliches Zertifikat des IdP
sp_entity_id TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-30
View File
@@ -1,30 +0,0 @@
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Anmeldung & Profil",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<body
style={{
fontFamily: typography.fontFamily,
margin: 0,
background: "var(--shl-color-background, #ffffff)",
color: "var(--shl-color-text-primary, #14181f)",
}}
>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
</html>
);
}
-87
View File
@@ -1,87 +0,0 @@
"use client";
import { useState } from "react";
import { TextField, useToast } from "@nexarch/shl";
import { ApiError, login } from "../../lib/api";
export default function LoginPage() {
const { push } = useToast();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [totpCode, setTotpCode] = useState("");
const [needsSecondFactor, setNeedsSecondFactor] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
await login(email, password, needsSecondFactor ? totpCode : undefined);
push("Anmeldung erfolgreich.", "success");
window.location.href = "/profile";
} catch (err) {
if (err instanceof ApiError) {
// Akzeptanzkriterium 3 / Pruefung 1: nur die generische, vom Backend
// gelieferte Meldung anzeigen — keine eigene, evtl. praezisere
// Fehlerursache im Frontend herleiten oder ergaenzen.
setError(err.message);
if (err.code === "second_factor_required") {
setNeedsSecondFactor(true);
}
} else {
setError("Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.");
}
} finally {
setSubmitting(false);
}
}
return (
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
<h1>Anmeldung</h1>
<form onSubmit={onSubmit} noValidate>
<TextField
label="E-Mail-Adresse"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
label="Passwort"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{needsSecondFactor && (
<TextField
label="Zweiter Faktor (Authenticator-Code oder Wiederherstellungscode)"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
required
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
hint="Öffnen Sie Ihre Authenticator-App oder geben Sie einen Wiederherstellungscode ein."
/>
)}
{error && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{error}
</p>
)}
<button type="submit" disabled={submitting}>
{submitting ? "Anmeldung läuft…" : "Anmelden"}
</button>
</form>
<p>
<a href="/password-reset/request">Passwort vergessen?</a>
</p>
</main>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function IndexPage() {
redirect("/login");
}
@@ -1,79 +0,0 @@
"use client";
import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import { TextField } from "@nexarch/shl";
import { ApiError, completePasswordReset } from "../../../lib/api";
export default function PasswordResetCompletePage() {
return (
<Suspense fallback={null}>
<PasswordResetCompleteForm />
</Suspense>
);
}
function PasswordResetCompleteForm() {
const params = useSearchParams();
const token = params.get("token") ?? "";
const [newPassword, setNewPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
await completePasswordReset(token, newPassword);
setDone(true);
} catch (err) {
// Akzeptanzkriterium 3: generische Meldung, keine interne Fehlerursache.
setError(err instanceof ApiError ? err.message : "Der Link ist ungültig oder abgelaufen.");
} finally {
setSubmitting(false);
}
}
if (done) {
return (
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
<h1>Passwort geändert</h1>
<p>
Ihr Passwort wurde erfolgreich geändert. <a href="/login">Jetzt anmelden</a>.
</p>
</main>
);
}
return (
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
<h1>Neues Passwort festlegen</h1>
{!token && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
Kein gültiger Link. Bitte fordern Sie einen neuen Reset-Link an.
</p>
)}
<form onSubmit={onSubmit} noValidate>
<TextField
label="Neues Passwort"
type="password"
autoComplete="new-password"
required
minLength={8}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
{error && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{error}
</p>
)}
<button type="submit" disabled={submitting || !token}>
{submitting ? "Wird gespeichert…" : "Passwort speichern"}
</button>
</form>
</main>
);
}
@@ -1,53 +0,0 @@
"use client";
import { useState } from "react";
import { TextField } from "@nexarch/shl";
import { requestPasswordReset } from "../../../lib/api";
export default function PasswordResetRequestPage() {
const [email, setEmail] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
try {
// Antwort ist absichtlich IMMER dieselbe (Backend-Garantie,
// Akzeptanzkriterium 3) — das Frontend erfindet keine differenzierte
// Anzeige je nachdem ob das Konto existiert.
const result = await requestPasswordReset(email);
setMessage(result.message);
} catch {
setMessage("Falls ein Konto mit dieser E-Mail-Adresse existiert, wurde eine Nachricht mit weiteren Schritten verschickt.");
} finally {
setSubmitting(false);
}
}
return (
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
<h1>Passwort zurücksetzen</h1>
{message ? (
<p role="status">{message}</p>
) : (
<form onSubmit={onSubmit} noValidate>
<TextField
label="E-Mail-Adresse"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button type="submit" disabled={submitting}>
{submitting ? "Wird gesendet…" : "Link anfordern"}
</button>
</form>
)}
<p>
<a href="/login">Zurück zur Anmeldung</a>
</p>
</main>
);
}
-228
View File
@@ -1,228 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { TextField, useToast } from "@nexarch/shl";
import {
ApiError,
Me,
beginTotpSetup,
changePassword,
confirmTotpSetup,
fetchMe,
fetchTotpStatus,
} from "../../lib/api";
export default function ProfilePage() {
const { push } = useToast();
const [me, setMe] = useState<Me | null>(null);
const [totpEnabled, setTotpEnabled] = useState<boolean | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
Promise.all([fetchMe(), fetchTotpStatus()])
.then(([meResult, statusResult]) => {
setMe(meResult);
setTotpEnabled(statusResult.enabled);
})
.catch(() => setLoadError("Bitte melden Sie sich an, um Ihr Profil zu sehen."));
}, []);
if (loadError) {
return (
<main style={{ maxWidth: 480, margin: "80px auto", padding: "0 16px" }}>
<p role="alert">{loadError}</p>
<a href="/login">Zur Anmeldung</a>
</main>
);
}
return (
<main style={{ maxWidth: 480, margin: "40px auto", padding: "0 16px" }}>
<h1>Mein Profil</h1>
{me && (
<section aria-labelledby="stammdaten-heading">
<h2 id="stammdaten-heading">Stammdaten</h2>
<p>
{me.name} {me.email}
</p>
</section>
)}
<ChangePasswordSection onSuccess={() => push("Passwort geändert.", "success")} />
<TotpSection
enabled={totpEnabled}
onEnabled={() => setTotpEnabled(true)}
onNotice={(text) => push(text, "info")}
/>
</main>
);
}
function ChangePasswordSection({ onSuccess }: { onSuccess: () => void }) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
await changePassword(currentPassword, newPassword);
setCurrentPassword("");
setNewPassword("");
onSuccess();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Passwort konnte nicht geändert werden.");
} finally {
setSubmitting(false);
}
}
return (
<section aria-labelledby="passwort-heading">
<h2 id="passwort-heading">Passwort ändern</h2>
<form onSubmit={onSubmit} noValidate>
<TextField
label="Aktuelles Passwort"
type="password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
<TextField
label="Neues Passwort"
type="password"
autoComplete="new-password"
required
minLength={8}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
{error && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{error}
</p>
)}
<button type="submit" disabled={submitting}>
{submitting ? "Wird gespeichert…" : "Passwort ändern"}
</button>
</form>
</section>
);
}
function TotpSection({
enabled,
onEnabled,
onNotice,
}: {
enabled: boolean | null;
onEnabled: () => void;
onNotice: (text: string) => void;
}) {
const [setupData, setSetupData] = useState<{ secret: string; provisioningUri: string } | null>(null);
const [confirmCode, setConfirmCode] = useState("");
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function onBeginSetup() {
setError(null);
setBusy(true);
try {
const result = await beginTotpSetup();
setSetupData({ secret: result.secret, provisioningUri: result.provisioning_uri });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Einrichtung konnte nicht gestartet werden.");
} finally {
setBusy(false);
}
}
async function onConfirm(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setBusy(true);
try {
const result = await confirmTotpSetup(confirmCode);
// Akzeptanzkriterium 2: Wiederherstellungscodes werden HIER, EINMALIG
// im Klartext angezeigt — die Backend-API liefert sie an keiner
// anderen Stelle je wieder aus (siehe internal/totp/store.go).
setRecoveryCodes(result.recovery_codes);
setSetupData(null);
onEnabled();
onNotice("Zwei-Faktor-Authentifizierung aktiviert. Bewahren Sie die Wiederherstellungscodes sicher auf.");
} catch (err) {
setError(err instanceof ApiError ? err.message : "Code ungültig.");
} finally {
setBusy(false);
}
}
return (
<section aria-labelledby="zwei-faktor-heading">
<h2 id="zwei-faktor-heading">Zwei-Faktor-Authentifizierung</h2>
{recoveryCodes ? (
<div role="alert">
<p>
<strong>Wiederherstellungscodes jetzt notieren, sie werden nicht erneut angezeigt:</strong>
</p>
<ul>
{recoveryCodes.map((code) => (
<li key={code}>
<code>{code}</code>
</li>
))}
</ul>
</div>
) : enabled ? (
<p>Zwei-Faktor-Authentifizierung ist aktiv.</p>
) : setupData ? (
<form onSubmit={onConfirm} noValidate>
<p>
Scannen Sie den folgenden Schlüssel mit Ihrer Authenticator-App oder geben Sie ihn manuell ein:
</p>
<p>
<code>{setupData.secret}</code>
</p>
<p style={{ wordBreak: "break-all" }}>
<a href={setupData.provisioningUri}>{setupData.provisioningUri}</a>
</p>
<TextField
label="Bestätigungscode aus der App"
type="text"
inputMode="numeric"
required
value={confirmCode}
onChange={(e) => setConfirmCode(e.target.value)}
/>
{error && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{error}
</p>
)}
<button type="submit" disabled={busy}>
{busy ? "Wird bestätigt…" : "Einrichtung bestätigen"}
</button>
</form>
) : (
<>
<p>Zwei-Faktor-Authentifizierung ist derzeit nicht aktiv.</p>
{error && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{error}
</p>
)}
<button type="button" onClick={onBeginSetup} disabled={busy}>
{busy ? "Wird gestartet…" : "Einrichten"}
</button>
</>
)}
</section>
);
}
-92
View File
@@ -1,92 +0,0 @@
// Duenne fetch-Wrapper gegen die IAM-08-Backend-Endpunkte. Kein Framework,
// keine Abstraktionsschicht ueber das Notwendige hinaus (Produkt-DNA:
// schlanker Go-Dienst + schmales Frontend statt schwergewichtiger Plattform).
const API_BASE = process.env.NEXT_PUBLIC_CORE_API_BASE ?? "";
export class ApiError extends Error {
code?: string;
constructor(message: string, code?: string) {
super(message);
this.code = code;
}
}
async function postJSON<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler", data.code);
}
return data as T;
}
async function getJSON<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { credentials: "include" });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler", data.code);
}
return data as T;
}
export interface LoginResult {
ok: true;
}
export async function login(email: string, password: string, totpCode?: string): Promise<LoginResult> {
await postJSON("/auth/login", { email, password, totp_code: totpCode ?? "" });
return { ok: true };
}
export async function requestPasswordReset(email: string): Promise<{ message: string }> {
return postJSON("/auth/password-reset/request", { email });
}
export async function completePasswordReset(token: string, newPassword: string): Promise<void> {
await postJSON("/auth/password-reset/complete", { token, new_password: newPassword });
}
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
await postJSON("/account/change-password", { current_password: currentPassword, new_password: newPassword });
}
export interface Me {
id: string;
email: string;
name: string;
}
export async function fetchMe(): Promise<Me> {
return getJSON("/account/me");
}
export interface TotpStatus {
enabled: boolean;
}
export async function fetchTotpStatus(): Promise<TotpStatus> {
return getJSON("/auth/totp/status");
}
export interface TotpSetupBegin {
secret: string;
provisioning_uri: string;
}
export async function beginTotpSetup(): Promise<TotpSetupBegin> {
return postJSON("/auth/totp/setup/begin", {});
}
export interface TotpSetupConfirm {
recovery_codes: string[];
}
export async function confirmTotpSetup(code: string): Promise<TotpSetupConfirm> {
return postJSON("/auth/totp/setup/confirm", { code });
}
-7
View File
@@ -1,7 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// @nexarch/shl liegt als file:-Dependency mit TS-Quellen in node_modules —
// Next.js transpiliert node_modules standardmäßig nicht, siehe web/shl/README.md.
transpilePackages: ["@nexarch/shl"],
};
export default nextConfig;
-22
View File
@@ -1,22 +0,0 @@
{
"name": "nexarch-account",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@nexarch/shl": "file:../shl",
"next": "14.2.35",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/node": "20.14.9",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"typescript": "5.5.3"
}
}
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
-45
View File
@@ -1,45 +0,0 @@
# @nexarch/shl — UI-Shell & Design-System (Core SHL-01)
Gemeinsames Paket für alle NEXARCH-Modul-Frontends (Core, DMS, Mail, Archive, Workflow, AI, Connect).
Ein Modul-Frontend importiert ausschließlich über `index.ts`, kopiert keine Komponenten oder Tokens lokal.
## Enthält
- **Design-Tokens** (`tokens/tokens.ts`) — Farbe (Hell/Dunkel), Abstand, Typografie. Kontrastwerte gegen WCAG 2.1 AA geprüft (siehe `__tests__/tokens.test.ts`).
- **Theming** (`theme/ThemeProvider.tsx`) — zentrale Hell/Dunkel-Umschaltung, respektiert `prefers-color-scheme`, persistiert in `localStorage`.
- **i18n-Rahmen** (`i18n/i18n.tsx`) — Umschaltmechanismus Deutsch/Englisch. Modul-Frontends registrieren ihre fachlichen Textbausteine über `registerMessages()`, statt einen eigenen Mechanismus zu bauen.
- **Basis-Komponenten** (`components/`) — `Shell` (Layout + Navigation), `Table`, `Dialog`, `TextField`/`SelectField`/`CheckboxField`, `Toast`. Alle mit WCAG-2.1-AA-Grundlage (Tastaturbedienung, ARIA-Attribute, Fokus-Management).
## Verwendung in einem Modul-Frontend
```tsx
import { ThemeProvider, I18nProvider, ToastProvider, Shell } from "@nexarch/shl";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>
<Shell modules={[]} tenantLabel="Mandant XY">
{children}
</Shell>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
);
}
```
## Bekannter offener Punkt
Die vier bereits gebauten Core-Frontends (`TEN-05`, `LIC-04`, `AUD-04`, `OPS-02`) sind vor diesem Paket entstanden und binden es noch nicht ein — Retrofit ist der nächste Schritt, siehe `nexarch-state.json`.
## Tests
Ausführung auf dem Test-Host (nicht lokal, siehe Projekt-Testinfrastruktur):
```bash
npm install
npm test
npm run typecheck
```
-38
View File
@@ -1,38 +0,0 @@
// Prüfung: Tastaturbedienung der Basis-Komponenten funktioniert (SHL-01 Prüfung 2).
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { Dialog } from "../components/Dialog";
import { I18nProvider } from "../i18n/i18n";
function renderDialog(onClose: () => void) {
return render(
<I18nProvider>
<Dialog open titleId="test-title" title="Test-Dialog" onClose={onClose}>
<button type="button">Erste Aktion</button>
<button type="button">Zweite Aktion</button>
</Dialog>
</I18nProvider>,
);
}
describe("Dialog: Tastaturbedienung", () => {
it("schließt sich bei ESC", () => {
const onClose = vi.fn();
renderDialog(onClose);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledOnce();
});
it("setzt den Fokus beim Öffnen auf das erste fokussierbare Element", () => {
renderDialog(vi.fn());
const closeButton = screen.getByRole("button", { name: /schließen/i });
expect(document.activeElement).toBe(closeButton);
});
it("ist als modaler Dialog mit Titel-Referenz ausgezeichnet", () => {
renderDialog(vi.fn());
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(dialog).toHaveAttribute("aria-labelledby", "test-title");
});
});
-39
View File
@@ -1,39 +0,0 @@
// Prüfung: Kontrastwerte erfüllen mindestens AA (SHL-01 Prüfung 3 / Akzeptanzkriterium 4).
import { describe, expect, it } from "vitest";
import { colorTokens } from "../tokens/tokens";
// WCAG-2.1-AA-Kontrastberechnung (relative Luminanz, sRGB) — keine externe Abhängigkeit nötig.
function relLuminance(hex: string): number {
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrastRatio(a: string, b: string): number {
const l1 = relLuminance(a);
const l2 = relLuminance(b);
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
return (lighter + 0.05) / (darker + 0.05);
}
describe("Design-Tokens: WCAG 2.1 AA Kontrast", () => {
for (const scheme of ["light", "dark"] as const) {
const c = colorTokens[scheme];
it(`${scheme}: textPrimary auf background erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.textPrimary, c.background)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: textSecondary auf surface erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.textSecondary, c.surface)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: accentContrast auf accent erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.accentContrast, c.accent)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: dangerContrast auf danger erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.dangerContrast, c.danger)).toBeGreaterThanOrEqual(4.5);
});
}
});
-88
View File
@@ -1,88 +0,0 @@
"use client";
// Dialog-Basis-Komponente — SHL-01. WCAG 2.1 AA: Fokus-Falle, ESC schließt, Tastaturbedienung vollständig.
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
export interface DialogProps {
open: boolean;
onClose: () => void;
titleId: string;
title: string;
children: ReactNode;
}
export function Dialog({ open, onClose, titleId, title, children }: DialogProps) {
const { t } = useI18n();
const dialogRef = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement | null;
const node = dialogRef.current;
const focusables = node?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
focusables?.[0]?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
return;
}
if (event.key !== "Tab" || !node) return;
const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previouslyFocused.current?.focus();
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="shl-dialog-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
className="shl-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="shl-dialog-header">
<h2 id={titleId}>{title}</h2>
<button type="button" onClick={onClose} aria-label={t("shl.dialog.close")}>
×
</button>
</div>
<div className="shl-dialog-body">{children}</div>
</div>
</div>
);
}
-98
View File
@@ -1,98 +0,0 @@
// Formularelemente-Basis-Komponenten — SHL-01. WCAG: jedes Feld hat verknüpftes <label>,
// Fehler werden per aria-describedby + aria-invalid angebunden, nicht nur farblich markiert.
import { useId } from "react";
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
interface FieldWrapperProps {
label: string;
error?: string;
hint?: string;
children: (ids: { inputId: string; describedBy: string | undefined }) => ReactNode;
}
function FieldWrapper({ label, error, hint, children }: FieldWrapperProps) {
const inputId = useId();
const hintId = hint ? `${inputId}-hint` : undefined;
const errorId = error ? `${inputId}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className="shl-field">
<label htmlFor={inputId}>{label}</label>
{children({ inputId, describedBy })}
{hint && (
<p id={hintId} className="shl-field-hint">
{hint}
</p>
)}
{error && (
<p id={errorId} className="shl-field-error" role="alert">
{error}
</p>
)}
</div>
);
}
export interface TextFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "aria-describedby"> {
label: string;
error?: string;
hint?: string;
}
export function TextField({ label, error, hint, ...inputProps }: TextFieldProps) {
return (
<FieldWrapper label={label} error={error} hint={hint}>
{({ inputId, describedBy }) => (
<input
id={inputId}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
{...inputProps}
/>
)}
</FieldWrapper>
);
}
export interface SelectFieldProps
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "id" | "aria-describedby"> {
label: string;
error?: string;
hint?: string;
children: ReactNode;
}
export function SelectField({ label, error, hint, children, ...selectProps }: SelectFieldProps) {
return (
<FieldWrapper label={label} error={error} hint={hint}>
{({ inputId, describedBy }) => (
<select
id={inputId}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
{...selectProps}
>
{children}
</select>
)}
</FieldWrapper>
);
}
export interface CheckboxFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "type"> {
label: string;
}
export function CheckboxField({ label, ...inputProps }: CheckboxFieldProps) {
const inputId = useId();
return (
<div className="shl-field shl-field-checkbox">
<input id={inputId} type="checkbox" {...inputProps} />
<label htmlFor={inputId}>{label}</label>
</div>
);
}
-62
View File
@@ -1,62 +0,0 @@
"use client";
// Layout-Shell mit Navigation — SHL-01 Akzeptanzkriterium 1.
// Globale Navigation zeigt nur Module, die Core für Tenant/Benutzer freigibt (Backend entscheidet, UI blendet nur aus).
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
import { useTheme } from "../theme/ThemeProvider";
export interface ModuleLink {
key: string;
label: string;
href: string;
active?: boolean;
}
export interface ShellProps {
modules: ModuleLink[];
tenantLabel: string;
children: ReactNode;
}
export function Shell({ modules, tenantLabel, children }: ShellProps) {
const { scheme, toggle } = useTheme();
const { t } = useI18n();
return (
<div className="shl-shell">
<a className="shl-skip-link" href="#shl-main-content">
{t("shl.shell.skipToContent", "Zum Inhalt springen")}
</a>
<header className="shl-shell-header">
<nav aria-label={t("shl.shell.moduleNav", "Modul-Navigation")}>
<ul>
{modules.map((mod) => (
<li key={mod.key}>
<a href={mod.href} aria-current={mod.active ? "page" : undefined}>
{mod.label}
</a>
</li>
))}
</ul>
</nav>
<div className="shl-shell-context">
<span className="shl-tenant-context">{tenantLabel}</span>
<button
type="button"
onClick={toggle}
aria-label={
scheme === "light" ? t("shl.theme.toggleToDark") : t("shl.theme.toggleToLight")
}
>
{scheme === "light" ? "🌙" : "☀️"}
</button>
</div>
</header>
<main id="shl-main-content" className="shl-shell-content" tabIndex={-1}>
{children}
</main>
</div>
);
}
-83
View File
@@ -1,83 +0,0 @@
// Table-Basis-Komponente — SHL-01. WCAG: semantische <table>, scope auf Kopfzellen, sortierbare Spalten per Tastatur.
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
export interface TableColumn<Row> {
key: string;
header: string;
render: (row: Row) => ReactNode;
sortable?: boolean;
}
export interface TableProps<Row> {
columns: TableColumn<Row>[];
rows: Row[];
rowKey: (row: Row) => string;
sortKey?: string;
sortDirection?: "asc" | "desc";
onSort?: (key: string) => void;
caption?: string;
}
export function Table<Row>({
columns,
rows,
rowKey,
sortKey,
sortDirection,
onSort,
caption,
}: TableProps<Row>) {
const { t } = useI18n();
return (
<table className="shl-table">
{caption && <caption>{caption}</caption>}
<thead>
<tr>
{columns.map((column) => {
const isSorted = column.key === sortKey;
const ariaSort = column.sortable
? isSorted
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
: undefined;
return (
<th key={column.key} scope="col" aria-sort={ariaSort}>
{column.sortable ? (
<button
type="button"
onClick={() => onSort?.(column.key)}
className="shl-table-sort-button"
>
{column.header}
</button>
) : (
column.header
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={columns.length}>{t("shl.table.noRows")}</td>
</tr>
) : (
rows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((column) => (
<td key={column.key}>{column.render(row)}</td>
))}
</tr>
))
)}
</tbody>
</table>
);
}
-67
View File
@@ -1,67 +0,0 @@
"use client";
// Toast-Basis-Komponente — SHL-01. WCAG: aria-live sorgt dafür, dass Screenreader Meldungen ansagen.
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
export type ToastVariant = "info" | "success" | "danger" | "warning";
export interface ToastMessage {
id: string;
text: string;
variant: ToastVariant;
}
interface ToastContextValue {
toasts: ToastMessage[];
push: (text: string, variant?: ToastVariant) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const { t } = useI18n();
const dismiss = useCallback((id: string) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const push = useCallback((text: string, variant: ToastVariant = "info") => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((current) => [...current, { id, text, variant }]);
}, []);
const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="shl-toast-region" role="status" aria-live="polite" aria-atomic="false">
{toasts.map((toast) => (
<div key={toast.id} className={`shl-toast shl-toast-${toast.variant}`}>
<span>{toast.text}</span>
<button
type="button"
onClick={() => dismiss(toast.id)}
aria-label={t("shl.toast.dismiss")}
>
×
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error("useToast muss innerhalb von <ToastProvider> aufgerufen werden");
}
return ctx;
}
-75
View File
@@ -1,75 +0,0 @@
"use client";
// i18n-Rahmen (mind. Deutsch/Englisch) — SHL-01 Akzeptanzkriterium 5.
// Liefert nur den Umschaltmechanismus + Basis-Komponenten-Texte.
// Modul-Frontends liefern ihre eigenen fachlichen Textbausteine über registerMessages(),
// statt einen eigenen i18n-Mechanismus zu bauen (siehe UI-UX-KONZEPT.md Abschnitt 4).
import { createContext, useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";
export type Locale = "de" | "en";
type MessageDict = Record<string, string>;
type MessageBundle = Record<Locale, MessageDict>;
const baseMessages: MessageBundle = {
de: {
"shl.dialog.close": "Schließen",
"shl.toast.dismiss": "Meldung schließen",
"shl.table.noRows": "Keine Einträge vorhanden",
"shl.theme.toggleToLight": "Helles Erscheinungsbild",
"shl.theme.toggleToDark": "Dunkles Erscheinungsbild",
},
en: {
"shl.dialog.close": "Close",
"shl.toast.dismiss": "Dismiss message",
"shl.table.noRows": "No entries",
"shl.theme.toggleToLight": "Switch to light theme",
"shl.theme.toggleToDark": "Switch to dark theme",
},
};
// Registry, in die Modul-Frontends ihre eigenen Textbausteine einhängen.
const registry: MessageBundle = { de: { ...baseMessages.de }, en: { ...baseMessages.en } };
export function registerMessages(locale: Locale, messages: MessageDict): void {
registry[locale] = { ...registry[locale], ...messages };
}
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string, fallback?: string) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({
initialLocale = "de",
children,
}: {
initialLocale?: Locale;
children: ReactNode;
}) {
const [locale, setLocale] = useState<Locale>(initialLocale);
const value = useMemo<I18nContextValue>(
() => ({
locale,
setLocale,
t: (key: string, fallback?: string) => registry[locale][key] ?? fallback ?? key,
}),
[locale],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) {
throw new Error("useI18n muss innerhalb von <I18nProvider> aufgerufen werden");
}
return ctx;
}
-25
View File
@@ -1,25 +0,0 @@
// Öffentliche Schnittstelle des Pakets @nexarch/shl — Modul-Frontends importieren ausschließlich hierüber,
// nicht aus internen Unterpfaden (SHL-01 Akzeptanzkriterium 2: dokumentiert, versioniert, importierbar statt kopiert).
export { colorTokens, spacing, breakpoints, typography, cssVariables } from "./tokens/tokens";
export type { ColorScheme, ColorTokens } from "./tokens/tokens";
export { ThemeProvider, useTheme, currentColors } from "./theme/ThemeProvider";
export { I18nProvider, useI18n, registerMessages } from "./i18n/i18n";
export type { Locale } from "./i18n/i18n";
export { Shell } from "./components/Shell";
export type { ShellProps, ModuleLink } from "./components/Shell";
export { Dialog } from "./components/Dialog";
export type { DialogProps } from "./components/Dialog";
export { Table } from "./components/Table";
export type { TableProps, TableColumn } from "./components/Table";
export { TextField, SelectField, CheckboxField } from "./components/FormElements";
export type { TextFieldProps, SelectFieldProps, CheckboxFieldProps } from "./components/FormElements";
export { ToastProvider, useToast } from "./components/Toast";
export type { ToastMessage, ToastVariant } from "./components/Toast";
-25
View File
@@ -1,25 +0,0 @@
{
"name": "@nexarch/shl",
"version": "0.1.0",
"private": true,
"description": "NEXARCH UI-Shell & Design-System (Core SHL-01) — gemeinsames Paket für alle Modul-Frontends.",
"main": "index.ts",
"types": "index.ts",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"jsdom": "^24.1.1",
"typescript": "5.5.3",
"vitest": "^2.0.0"
}
}
-81
View File
@@ -1,81 +0,0 @@
"use client";
// Zentrales Theming (Hell/Dunkel) — SHL-01 Akzeptanzkriterium 6.
// Einzige Quelle für Hell/Dunkel-Werte; Modul-Frontends schalten nur um, bauen kein eigenes Theming.
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { colorTokens, cssVariables, type ColorScheme } from "../tokens/tokens";
const STORAGE_KEY = "nexarch-shl-theme";
interface ThemeContextValue {
scheme: ColorScheme;
setScheme: (scheme: ColorScheme) => void;
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function readStoredScheme(): ColorScheme | null {
if (typeof window === "undefined") return null;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored === "light" || stored === "dark" ? stored : null;
} catch {
// localStorage kann in privaten Fenstern/eingeschränkten Kontexten fehlschlagen — kein Absturz, nur kein persistierter Zustand.
return null;
}
}
function systemPrefersDark(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [scheme, setSchemeState] = useState<ColorScheme>("light");
useEffect(() => {
const stored = readStoredScheme();
setSchemeState(stored ?? (systemPrefersDark() ? "dark" : "light"));
}, []);
const setScheme = useCallback((next: ColorScheme) => {
setSchemeState(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Speichern optional — Umschaltung funktioniert auch ohne Persistenz.
}
}, []);
const toggle = useCallback(() => {
setScheme(scheme === "light" ? "dark" : "light");
}, [scheme, setScheme]);
useEffect(() => {
const vars = cssVariables(scheme);
const root = document.documentElement;
for (const [key, value] of Object.entries(vars)) {
root.style.setProperty(key, value);
}
root.dataset.shlTheme = scheme;
}, [scheme]);
const value = useMemo(() => ({ scheme, setScheme, toggle }), [scheme, setScheme, toggle]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error("useTheme muss innerhalb von <ThemeProvider> aufgerufen werden");
}
return ctx;
}
export function currentColors(scheme: ColorScheme) {
return colorTokens[scheme];
}
-96
View File
@@ -1,96 +0,0 @@
// Design-Tokens: einzige Quelle für Farbe, Abstand, Typografie im gesamten Frontend-Verbund.
// Modul-Frontends importieren diese Tokens, überschreiben sie nicht lokal (SHL-01 Akzeptanzkriterium 3).
// Kontrastwerte sind gegen WCAG 2.1 AA geprüft (Akzeptanzkriterium 1/4): mindestens 4.5:1 für Fließtext.
export type ColorScheme = "light" | "dark";
export interface ColorTokens {
background: string;
surface: string;
surfaceRaised: string;
border: string;
textPrimary: string;
textSecondary: string;
accent: string;
accentContrast: string;
danger: string;
dangerContrast: string;
success: string;
warning: string;
focusRing: string;
}
// Kontrastwerte geprüft: textPrimary auf background/surface >= 7:1, textSecondary >= 4.5:1,
// accentContrast auf accent >= 4.5:1 (WCAG AA, siehe SHL-01 Prüfung 3).
export const colorTokens: Record<ColorScheme, ColorTokens> = {
light: {
background: "#FFFFFF",
surface: "#F5F6F8",
surfaceRaised: "#FFFFFF",
border: "#D7DBE0",
textPrimary: "#14181F",
textSecondary: "#4B5563",
accent: "#1D4ED8",
accentContrast: "#FFFFFF",
danger: "#B91C1C",
dangerContrast: "#FFFFFF",
success: "#15803D",
warning: "#B45309",
focusRing: "#1D4ED8",
},
dark: {
background: "#0F1115",
surface: "#181B21",
surfaceRaised: "#20242C",
border: "#333944",
textPrimary: "#F2F4F7",
textSecondary: "#B4BAC4",
accent: "#5B8DEF",
accentContrast: "#0F1115",
danger: "#F87171",
dangerContrast: "#0F1115",
success: "#4ADE80",
warning: "#FBBF24",
focusRing: "#5B8DEF",
},
};
export const spacing = {
xs: "4px",
sm: "8px",
md: "16px",
lg: "24px",
xl: "32px",
xxl: "48px",
} as const;
export const breakpoints = {
mobile: "0px",
tablet: "768px",
desktop: "1200px",
} as const;
export const typography = {
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
fontFamilyMono: "'JetBrains Mono', ui-monospace, monospace",
sizeSm: "13px",
sizeMd: "15px",
sizeLg: "18px",
sizeXl: "24px",
lineHeight: 1.5,
weightRegular: 400,
weightMedium: 500,
weightBold: 600,
} as const;
export function cssVariables(scheme: ColorScheme): Record<string, string> {
const c = colorTokens[scheme];
const vars: Record<string, string> = {};
for (const [key, value] of Object.entries(c)) {
vars[`--shl-color-${key.replace(/([A-Z])/g, "-$1").toLowerCase()}`] = value;
}
for (const [key, value] of Object.entries(spacing)) {
vars[`--shl-spacing-${key}`] = value;
}
return vars;
}
-18
View File
@@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
},
});
-9
View File
@@ -1,9 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// Ohne explizites Cleanup bleiben zwischen den it()-Blöcken gerenderte Dialoge im DOM stehen
// (mehrere <html>/<body>-Bäume stapeln sich), wodurch getByRole() mehrere Treffer statt einen findet.
afterEach(() => {
cleanup();
});