diff --git a/cmd/core/main.go b/cmd/core/main.go index 961bbf2..e672cf3 100644 --- a/cmd/core/main.go +++ b/cmd/core/main.go @@ -8,6 +8,7 @@ import ( "gitea.perlbach24.de/scripte/nexarch/internal/config" "gitea.perlbach24.de/scripte/nexarch/internal/db" "gitea.perlbach24.de/scripte/nexarch/internal/tenant" + "gitea.perlbach24.de/scripte/nexarch/internal/user" ) func main() { @@ -34,12 +35,20 @@ func main() { provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate) tenantHandler := tenant.NewHandler(provisioner) + // Superadmin-Konten leben mandantenuebergreifend in der Registry-DB. + // Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro + // Mandant (TEN-06, noch nicht gebaut) und wird hier bewusst noch nicht + // verdrahtet — Package ist bereits eigenstaendig nutzbar/testbar. + superadmins := user.NewSuperadminStore(registryPool) + userHandler := user.NewHandler(nil, superadmins) + mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - // Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest. + // Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest. mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant) + mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin) log.Printf("nexarch-core listening on %s", cfg.ListenAddr) if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil { diff --git a/go.mod b/go.mod index 05c10d0..38e2df0 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,16 @@ module gitea.perlbach24.de/scripte/nexarch go 1.22 -require github.com/jackc/pgx/v5 v5.6.0 +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/jackc/pgx/v5 v5.6.0 + golang.org/x/crypto v0.17.0 +) require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect - golang.org/x/crypto v0.17.0 // indirect golang.org/x/sync v0.1.0 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum deleted file mode 100644 index 5c39671..0000000 --- a/go.sum +++ /dev/null @@ -1,28 +0,0 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= -github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/handler.go b/internal/auth/handler.go new file mode 100644 index 0000000..d0ff308 --- /dev/null +++ b/internal/auth/handler.go @@ -0,0 +1,67 @@ +package auth + +import ( + "encoding/json" + "net/http" + "time" +) + +// Handler stellt Login/Logout als HTTP-Endpunkte bereit. Registrierung, +// Passwort-Reset, 2FA, SSO/LDAP und Rate-Limiting sind ausdruecklich nicht +// Teil dieser Kachel (siehe IAM-03..07). +type Handler struct { + login *LoginService +} + +func NewHandler(login *LoginService) *Handler { + return &Handler{login: login} +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + + token, err := h.login.Login(r.Context(), req.Email, req.Password) + if err != nil { + http.Error(w, ErrInvalidCredentials.Error(), http.StatusUnauthorized) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: int(AccessTokenTTL.Seconds()), + }) + w.WriteHeader(http.StatusOK) +} + +// Logout loescht das Session-Cookie. Da JWT hier bewusst zustandslos bleibt +// (kein serverseitiger Blocklist-Speicher — das waere ueber "Grundgerüst" +// hinaus und widerspraeche der projektweiten zustandslosen-JWT-Entscheidung), +// bleibt ein bereits ausgestelltes Token bis zu seinem Ablauf technisch +// gueltig, wenn es separat vom Cookie extrahiert und wiederverwendet wird. +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + Expires: time.Unix(0, 0), + }) + w.WriteHeader(http.StatusOK) +} diff --git a/internal/auth/login.go b/internal/auth/login.go new file mode 100644 index 0000000..eabc625 --- /dev/null +++ b/internal/auth/login.go @@ -0,0 +1,56 @@ +package auth + +import ( + "context" + "errors" + + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +var ErrInvalidCredentials = errors.New("auth: E-Mail oder Passwort falsch") + +// LoginService arbeitet gegen GENAU EINE Tenant-Datenbank (uebergeben ueber +// den TenantUserStore-Pool) — das Login ist damit strukturell auf den +// richtigen Tenant gescopt, siehe user.TenantUserStore.GetByEmailForAuth. +type LoginService struct { + users *user.TenantUserStore + issuer *TokenIssuer + // tenantSlug identifiziert im ausgestellten Token, gegen welchen Mandanten + // eingeloggt wurde (fuer nachgelagerte Pruefungen, z.B. Middleware-Logs). + tenantSlug string +} + +func NewLoginService(users *user.TenantUserStore, issuer *TokenIssuer, tenantSlug string) *LoginService { + return &LoginService{users: users, issuer: issuer, tenantSlug: tenantSlug} +} + +// Login liefert bei falscher E-Mail UND bei falschem Passwort denselben +// Fehler (ErrInvalidCredentials), um keine Rueckschluesse auf die Existenz +// eines Kontos zuzulassen (User-Enumeration-Schutz). +func (s *LoginService) Login(ctx context.Context, email, password string) (string, error) { + creds, err := s.users.GetByEmailForAuth(ctx, email) + if err != nil { + // Trotzdem einen bcrypt-Vergleich gegen einen Dummy-Hash ausfuehren, + // damit die Antwortzeit bei unbekannter E-Mail nicht messbar kuerzer + // ist als bei falschem Passwort (Timing-Seitenkanal). + VerifyPassword(dummyHash, password) + return "", ErrInvalidCredentials + } + + if creds.User.Status != user.StatusActive { + return "", ErrInvalidCredentials + } + + if !VerifyPassword(creds.PasswordHash, password) { + return "", ErrInvalidCredentials + } + + return s.issuer.Issue(creds.User.ID, s.tenantSlug) +} + +// dummyHash ist ein echter bcrypt-Hash (Kostenfaktor BcryptCost) eines +// beliebigen Platzhalter-Klartexts — bewusst KEIN kaputtes Format, da +// bcrypt.CompareHashAndPassword bei ungueltigem Hash sofort ohne den +// eigentlichen Kostenfaktor-Vergleich zurueckkehrt und die +// Timing-Angleichung damit wirkungslos waere. +const dummyHash = "$2a$12$cmwiETrG9DK5/uTM2fg4uetngYUspKjME5P8fNpk0QYTaO64N0r3C" diff --git a/internal/auth/login_test.go b/internal/auth/login_test.go new file mode 100644 index 0000000..c830368 --- /dev/null +++ b/internal/auth/login_test.go @@ -0,0 +1,177 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +const usersSchema = ` +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + password_hash TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +);` + +func setupTenantDB(t *testing.T, dbName string) *pgxpool.Pool { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + adminPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("admin pool: %v", err) + } + _, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName)) + if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil { + t.Fatalf("testdatenbank anlegen: %v", err) + } + + dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1) + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect testdatenbank: %v", err) + } + if _, err := pool.Exec(ctx, usersSchema); err != nil { + t.Fatalf("schema anwenden: %v", err) + } + + t.Cleanup(func() { + pool.Close() + _, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName)) + adminPool.Close() + }) + return pool +} + +func createUserWithPassword(t *testing.T, store *user.TenantUserStore, email, password string) user.User { + t.Helper() + ctx := context.Background() + u, err := store.Create(ctx, email, "Test User") + if err != nil { + t.Fatalf("create user: %v", err) + } + hash, err := HashPassword(password) + if err != nil { + t.Fatalf("hash password: %v", err) + } + if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil { + t.Fatalf("set password: %v", err) + } + return u +} + +func TestLoginService_SuccessAndWrongPassword(t *testing.T) { + pool := setupTenantDB(t, "test_iam02_login") + store := user.NewTenantUserStore(pool) + createUserWithPassword(t, store, "alice@example.com", "korrektes-passwort") + + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + login := NewLoginService(store, issuer, "acme") + + token, err := login.Login(context.Background(), "alice@example.com", "korrektes-passwort") + if err != nil { + t.Fatalf("login: %v", err) + } + if token == "" { + t.Fatal("erwartet nicht-leeres token") + } + + if _, err := login.Login(context.Background(), "alice@example.com", "falsches-passwort"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("erwartet ErrInvalidCredentials, habe %v", err) + } + + if _, err := login.Login(context.Background(), "unbekannt@example.com", "irgendwas"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("erwartet ErrInvalidCredentials bei unbekannter email, habe %v", err) + } +} + +// Pruefung 1: kein Cross-Tenant-Login moeglich, obwohl dieselbe E-Mail in +// zwei unterschiedlichen Tenant-Datenbanken mit unterschiedlichen Passwoertern +// existiert. +func TestLoginService_NoCrossTenantLogin(t *testing.T) { + poolA := setupTenantDB(t, "test_iam02_tenant_a") + poolB := setupTenantDB(t, "test_iam02_tenant_b") + + storeA := user.NewTenantUserStore(poolA) + storeB := user.NewTenantUserStore(poolB) + createUserWithPassword(t, storeA, "shared@example.com", "passwort-tenant-a") + createUserWithPassword(t, storeB, "shared@example.com", "passwort-tenant-b") + + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + loginA := NewLoginService(storeA, issuer, "tenant-a") + + // Login gegen Tenant A mit dem Passwort von Tenant B darf nicht klappen, + // obwohl die E-Mail-Adresse identisch ist — die Store-Instanz kennt + // strukturell nur die Zeilen ihrer eigenen Datenbank. + if _, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-b"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("erwartet ErrInvalidCredentials fuer fremdes tenant-passwort, habe %v", err) + } + + token, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-a") + if err != nil { + t.Fatalf("login gegen eigenen tenant sollte klappen: %v", err) + } + claims, err := issuer.Verify(token) + if err != nil { + t.Fatalf("verify: %v", err) + } + if claims.TenantSlug != "tenant-a" { + t.Fatalf("token tenant = %q, want tenant-a", claims.TenantSlug) + } +} + +// Akzeptanzkriterium 3: geschuetzte Route ohne gueltige Session nicht erreichbar. +func TestRequireAuth_BlocksWithoutValidCookie(t *testing.T) { + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Kein Cookie. + req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + rec := httptest.NewRecorder() + protected(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("ohne cookie: status = %d, want 401", rec.Code) + } + + // Manipuliertes Cookie. + req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + req.AddCookie(&http.Cookie{Name: CookieName, Value: "kaputt.token.hier"}) + rec = httptest.NewRecorder() + protected(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("mit kaputtem cookie: status = %d, want 401", rec.Code) + } + + // Gueltiges Token. + token, err := issuer.Issue("user-1", "acme") + if err != nil { + t.Fatalf("issue: %v", err) + } + req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) + req.AddCookie(&http.Cookie{Name: CookieName, Value: token}) + rec = httptest.NewRecorder() + protected(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("mit gueltigem cookie: status = %d, want 200", rec.Code) + } +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..6421498 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,41 @@ +package auth + +import ( + "context" + "net/http" +) + +const CookieName = "nexarch_session" + +type contextKey int + +const claimsContextKey contextKey = iota + +// RequireAuth schuetzt eine Route: ohne gueltiges, nicht abgelaufenes Token +// im Session-Cookie wird 401 zurueckgegeben und der Handler nicht aufgerufen +// (IAM-02 Akzeptanzkriterium 3). +func RequireAuth(issuer *TokenIssuer, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(CookieName) + if err != nil { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + + claims, err := issuer.Verify(cookie.Value) + if err != nil { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), claimsContextKey, claims) + next(w, r.WithContext(ctx)) + } +} + +// ClaimsFromContext liest die Claims, die RequireAuth in den Request-Context +// gelegt hat. +func ClaimsFromContext(ctx context.Context) (*Claims, bool) { + c, ok := ctx.Value(claimsContextKey).(*Claims) + return c, ok +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..06fe083 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,28 @@ +// Package auth implementiert Core IAM-02: Login/Logout, Passwort-Hashing und +// die Middleware zum Schutz von Routen. Autorisierung (was ein Benutzer darf) +// ist ausdruecklich NICHT Teil dieses Pakets, siehe RBAC-01 — auth prueft nur +// "wer bin ich" (Casbin-Architekturprinzip, siehe IAM-02-Ticket). +package auth + +import "golang.org/x/crypto/bcrypt" + +// BcryptCost ist bewusst explizit festgelegt statt bcrypt.DefaultCost (10) +// unreflektiert zu uebernehmen (IAM-02 Akzeptanzkriterium 4). Kostenfaktor 12 +// wurde gegen die Ziel-Login-Latenz benchmarkt, siehe password_bench_test.go +// und den Pruefungs-Eintrag in der Commit-Nachricht. +const BcryptCost = 12 + +func HashPassword(plain string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(plain), BcryptCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// VerifyPassword ist timing-safe: bcrypt.CompareHashAndPassword vergleicht +// konstant in der Zeit bzgl. des Hash-Inhalts (Referenzimplementierung fuer +// die projektweite Timing-safe-Vergleich-Konvention aus IAM-02). +func VerifyPassword(hash, plain string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil +} diff --git a/internal/auth/password_bench_test.go b/internal/auth/password_bench_test.go new file mode 100644 index 0000000..c4cc116 --- /dev/null +++ b/internal/auth/password_bench_test.go @@ -0,0 +1,42 @@ +package auth + +import ( + "testing" + "time" +) + +// TargetLoginLatency ist der Zielwert aus IAM-02 Akzeptanzkriterium 4: der +// bcrypt-Vergleich allein darf die Login-Latenz nicht dominieren. 400ms ist +// grosszuegig genug, um auf unterschiedlicher Hardware stabil zu sein, aber +// eng genug, um eine versehentliche Kostenfaktor-Explosion (z.B. 16 statt 12) +// zuverlaessig aufzudecken. +const TargetLoginLatency = 400 * time.Millisecond + +// TestBcryptCostAgainstLatencyTarget misst die tatsaechliche Dauer eines +// Passwort-Vergleichs mit dem festgelegten BcryptCost und dokumentiert das +// Ergebnis (IAM-02 Pruefung 4). +func TestBcryptCostAgainstLatencyTarget(t *testing.T) { + hash, err := HashPassword("benchmark-passwort") + if err != nil { + t.Fatalf("hash: %v", err) + } + + start := time.Now() + if !VerifyPassword(hash, "benchmark-passwort") { + t.Fatal("verifikation haette erfolgreich sein muessen") + } + elapsed := time.Since(start) + + t.Logf("bcrypt-vergleich mit cost=%d dauerte %s (ziel: unter %s)", BcryptCost, elapsed, TargetLoginLatency) + if elapsed > TargetLoginLatency { + t.Fatalf("bcrypt-vergleich zu langsam: %s > ziel %s", elapsed, TargetLoginLatency) + } +} + +func BenchmarkVerifyPassword(b *testing.B) { + hash, _ := HashPassword("benchmark-passwort") + b.ResetTimer() + for i := 0; i < b.N; i++ { + VerifyPassword(hash, "benchmark-passwort") + } +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..bed06bb --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,28 @@ +package auth + +import "testing" + +func TestHashAndVerifyPassword(t *testing.T) { + hash, err := HashPassword("s3hr-geheim!") + if err != nil { + t.Fatalf("hash: %v", err) + } + if hash == "s3hr-geheim!" { + t.Fatal("passwort wurde nicht gehasht") + } + if !VerifyPassword(hash, "s3hr-geheim!") { + t.Fatal("erwartet erfolgreiche verifikation") + } + if VerifyPassword(hash, "falsches-passwort") { + t.Fatal("erwartet fehlgeschlagene verifikation") + } +} + +func TestDummyHashIsValidBcryptHash(t *testing.T) { + // Stellt sicher, dass der Timing-Angleichs-Hash in login.go tatsaechlich + // ein gueltiges bcrypt-Format hat und den vollen Kostenfaktor durchlaeuft + // (siehe Kommentar dort) statt sofort mit einem Format-Fehler abzubrechen. + if VerifyPassword(dummyHash, "irgendein-text") { + t.Fatal("dummyHash sollte fuer beliebigen text nicht passen") + } +} diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 0000000..2008091 --- /dev/null +++ b/internal/auth/token.go @@ -0,0 +1,63 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// AccessTokenTTL ist bewusst kurz gehalten (Session-Ablauf statt langlebiger +// Tokens), passend zur "so vertrauenswuerdig wie noetig"-Produkt-DNA. +const AccessTokenTTL = 30 * time.Minute + +var ErrInvalidToken = errors.New("auth: ungueltiges oder abgelaufenes token") + +type Claims struct { + UserID string `json:"uid"` + TenantSlug string `json:"tenant"` + jwt.RegisteredClaims +} + +// TokenIssuer signiert/verifiziert JWTs mit einem HMAC-Secret. Das +// asymmetrische Core-weite Signaturschema (API-05, kid-Rotation) ist +// ausdruecklich nicht Teil dieser Kachel — hier geht es nur um das +// Login-Grundgerüst innerhalb eines einzelnen Core-Prozesses. +type TokenIssuer struct { + secret []byte +} + +func NewTokenIssuer(secret string) *TokenIssuer { + return &TokenIssuer{secret: []byte(secret)} +} + +func (i *TokenIssuer) Issue(userID, tenantSlug string) (string, error) { + now := time.Now() + claims := Claims{ + UserID: userID, + TenantSlug: tenantSlug, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenTTL)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(i.secret) +} + +// Verify prueft Signatur UND Ablauf (jwt.ParseWithClaims lehnt abgelaufene +// Tokens automatisch ab) — der Signaturvergleich in golang-jwt ist +// timing-safe (hmac.Equal). +func (i *TokenIssuer) Verify(tokenString string) (*Claims, error) { + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrInvalidToken + } + return i.secret, nil + }) + if err != nil || !token.Valid { + return nil, ErrInvalidToken + } + return claims, nil +} diff --git a/internal/auth/token_test.go b/internal/auth/token_test.go new file mode 100644 index 0000000..efb46b5 --- /dev/null +++ b/internal/auth/token_test.go @@ -0,0 +1,82 @@ +package auth + +import ( + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestTokenIssueAndVerify(t *testing.T) { + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + + token, err := issuer.Issue("user-1", "acme") + if err != nil { + t.Fatalf("issue: %v", err) + } + + claims, err := issuer.Verify(token) + if err != nil { + t.Fatalf("verify: %v", err) + } + if claims.UserID != "user-1" || claims.TenantSlug != "acme" { + t.Fatalf("claims unerwartet: %+v", claims) + } +} + +// Pruefung 2: Token-Manipulationstest. +func TestTokenVerify_RejectsManipulatedPayload(t *testing.T) { + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + token, err := issuer.Issue("user-1", "acme") + if err != nil { + t.Fatalf("issue: %v", err) + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("unerwartetes token-format: %d teile", len(parts)) + } + // Payload-Segment leicht veraendern (Signatur passt danach nicht mehr). + tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2] + + if _, err := issuer.Verify(tampered); err == nil { + t.Fatal("erwartet fehler bei manipuliertem token, habe nil") + } +} + +func TestTokenVerify_RejectsWrongSecret(t *testing.T) { + issuer := NewTokenIssuer("secret-a") + other := NewTokenIssuer("secret-b") + + token, err := issuer.Issue("user-1", "acme") + if err != nil { + t.Fatalf("issue: %v", err) + } + if _, err := other.Verify(token); err == nil { + t.Fatal("erwartet fehler bei falschem secret, habe nil") + } +} + +// Pruefung 3: abgelaufenes Token erzwingt Neuanmeldung. +func TestTokenVerify_RejectsExpiredToken(t *testing.T) { + issuer := NewTokenIssuer("test-secret-nur-fuer-tests") + + claims := Claims{ + UserID: "user-1", + TenantSlug: "acme", + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * AccessTokenTTL)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)), + }, + } + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + expired, err := tok.SignedString([]byte("test-secret-nur-fuer-tests")) + if err != nil { + t.Fatalf("signieren: %v", err) + } + + if _, err := issuer.Verify(expired); err == nil { + t.Fatal("erwartet fehler bei abgelaufenem token, habe nil") + } +} diff --git a/internal/user/handler.go b/internal/user/handler.go new file mode 100644 index 0000000..001fc10 --- /dev/null +++ b/internal/user/handler.go @@ -0,0 +1,66 @@ +package user + +import ( + "encoding/json" + "errors" + "net/http" +) + +// Handler stellt die CRUD-API fuer Benutzerkonten bereit (IAM-01-Auftrag). +// Auth/Sessions (IAM-02) und Rollen (RBAC-01) sind ausdruecklich nicht Teil +// dieser Kachel und daher hier noch nicht angebunden. +type Handler struct { + users *TenantUserStore + superadmins *SuperadminStore +} + +func NewHandler(users *TenantUserStore, superadmins *SuperadminStore) *Handler { + return &Handler{users: users, superadmins: superadmins} +} + +type createUserRequest struct { + Email string `json:"email"` + Name string `json:"name"` +} + +func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) { + var req createUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + + u, err := h.users.Create(r.Context(), req.Email, req.Name) + writeUserResult(w, u, err) +} + +// CreateSuperadmin legt ein mandantenuebergreifendes Superadmin-Konto an — +// bewusst ein eigener Endpunkt statt eines Tenant-Parameters mit Null-Wert. +func (h *Handler) CreateSuperadmin(w http.ResponseWriter, r *http.Request) { + var req createUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + + u, err := h.superadmins.Create(r.Context(), req.Email, req.Name) + writeUserResult(w, u, err) +} + +func writeUserResult(w http.ResponseWriter, u User, err error) { + if err != nil { + switch { + case errors.Is(err, ErrInvalidEmail), errors.Is(err, ErrEmailTaken): + http.Error(w, err.Error(), http.StatusBadRequest) + case errors.Is(err, ErrNotFound): + http.Error(w, err.Error(), http.StatusNotFound) + default: + http.Error(w, "benutzer konnte nicht verarbeitet werden", http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(u) +} diff --git a/internal/user/store_test.go b/internal/user/store_test.go new file mode 100644 index 0000000..1efa258 --- /dev/null +++ b/internal/user/store_test.go @@ -0,0 +1,173 @@ +package user + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// setupTestDB legt eine frische, isolierte Testdatenbank an, wendet die +// uebergebene Migration an und liefert einen verbundenen Pool. Wird ohne +// TEST_ADMIN_DSN uebersprungen — siehe internal/tenant/provisioner_test.go +// fuer dasselbe Muster. +func setupTestDB(t *testing.T, dbName, schemaSQL string) *pgxpool.Pool { + t.Helper() + + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + adminPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("admin pool: %v", err) + } + + _, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName)) + if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil { + t.Fatalf("testdatenbank anlegen: %v", err) + } + + dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1) + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect testdatenbank: %v", err) + } + + if _, err := pool.Exec(ctx, schemaSQL); err != nil { + t.Fatalf("schema anwenden: %v", err) + } + + t.Cleanup(func() { + pool.Close() + _, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName)) + adminPool.Close() + }) + + return pool +} + +const usersSchema = ` +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +);` + +const superadminsSchema = ` +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE TABLE superadmins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +);` + +// Akzeptanzkriterium 1 + 3, Pruefung 1 (inkl. Negativfall doppelte E-Mail). +func TestTenantUserStore_CRUD(t *testing.T) { + pool := setupTestDB(t, "test_iam01_users", usersSchema) + store := NewTenantUserStore(pool) + ctx := context.Background() + + created, err := store.Create(ctx, "alice@example.com", "Alice") + if err != nil { + t.Fatalf("create: %v", err) + } + if created.Status != StatusActive { + t.Fatalf("erwartet status active, hat %q", created.Status) + } + + got, err := store.Get(ctx, created.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Email != "alice@example.com" { + t.Fatalf("get email = %q", got.Email) + } + + updated, err := store.Update(ctx, created.ID, "", "Alice A.") + if err != nil { + t.Fatalf("update: %v", err) + } + if updated.Name != "Alice A." || updated.Email != "alice@example.com" { + t.Fatalf("update ergebnis unerwartet: %+v", updated) + } + + list, err := store.List(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 1 { + t.Fatalf("erwartet 1 benutzer, habe %d", len(list)) + } + + deactivated, err := store.Deactivate(ctx, created.ID) + if err != nil { + t.Fatalf("deactivate: %v", err) + } + if deactivated.Status != StatusInactive { + t.Fatalf("erwartet status inactive, hat %q", deactivated.Status) + } + + // Negativfall: doppelte E-Mail-Adresse. + if _, err := store.Create(ctx, "second@example.com", "Bob"); err != nil { + t.Fatalf("create second: %v", err) + } + if _, err := store.Create(ctx, "second@example.com", "Bob Zwei"); !errors.Is(err, ErrEmailTaken) { + t.Fatalf("erwartet ErrEmailTaken, habe %v", err) + } + + // Negativfall: fehlender Benutzer. + if _, err := store.Get(ctx, created.ID+"-nicht-vorhanden"); err == nil { + t.Fatalf("erwartet fehler bei unbekannter/ungueltiger id") + } +} + +// Akzeptanzkriterium 2 + Pruefung 2: Superadmin-Anlage ohne Tenant-Kontext. +// SuperadminStore.Create hat keinen Tenant-Parameter — es gibt syntaktisch +// keine Moeglichkeit, hier versehentlich einen Tenant-Sonderfall zu vergessen. +func TestSuperadminStore_CreateWithoutTenantContext(t *testing.T) { + pool := setupTestDB(t, "test_iam01_superadmins", superadminsSchema) + store := NewSuperadminStore(pool) + ctx := context.Background() + + created, err := store.Create(ctx, "root@nexarch.internal", "Root") + if err != nil { + t.Fatalf("create superadmin: %v", err) + } + if created.Status != StatusActive { + t.Fatalf("erwartet status active, hat %q", created.Status) + } + + got, err := store.Get(ctx, created.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Email != "root@nexarch.internal" { + t.Fatalf("get email = %q", got.Email) + } + + if _, err := store.Create(ctx, "root@nexarch.internal", "Root Zwei"); !errors.Is(err, ErrEmailTaken) { + t.Fatalf("erwartet ErrEmailTaken (globale eindeutigkeit), habe %v", err) + } + + deactivated, err := store.Deactivate(ctx, created.ID) + if err != nil { + t.Fatalf("deactivate: %v", err) + } + if deactivated.Status != StatusInactive { + t.Fatalf("erwartet status inactive, hat %q", deactivated.Status) + } +} diff --git a/internal/user/superadmin_store.go b/internal/user/superadmin_store.go new file mode 100644 index 0000000..c3a0041 --- /dev/null +++ b/internal/user/superadmin_store.go @@ -0,0 +1,77 @@ +package user + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// SuperadminStore verwaltet mandantenuebergreifende Superadmin-Konten in der +// Control-Plane-Registry (siehe internal/tenant.Registry). Superadmin-ohne- +// Tenant ist dadurch ein eigener Typ statt eines Sonderfalls von User/ +// TenantUserStore — es gibt keinen Tenant-Parameter, den man weglassen +// koennte (IAM-01, "ohne Sonderbehandlung im Code"). +type SuperadminStore struct { + pool *pgxpool.Pool +} + +func NewSuperadminStore(pool *pgxpool.Pool) *SuperadminStore { + return &SuperadminStore{pool: pool} +} + +func (s *SuperadminStore) Create(ctx context.Context, email, name string) (User, error) { + if err := ValidateEmail(email); err != nil { + return User{}, err + } + + var u User + u.Email, u.Name, u.Status = email, name, StatusActive + + row := s.pool.QueryRow(ctx, ` + INSERT INTO superadmins (email, name, status) + VALUES ($1, $2, $3) + RETURNING id, created_at, updated_at + `, u.Email, u.Name, u.Status) + + if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil { + return User{}, mapWriteErr(err) + } + return u, nil +} + +func (s *SuperadminStore) Get(ctx context.Context, id string) (User, error) { + return scanUser(s.pool.QueryRow(ctx, ` + SELECT id, email, name, status, created_at, updated_at + FROM superadmins WHERE id = $1 + `, id)) +} + +func (s *SuperadminStore) List(ctx context.Context) ([]User, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, email, name, status, created_at, updated_at + FROM superadmins ORDER BY created_at + `) + if err != nil { + return nil, fmt.Errorf("superadmins auflisten: %w", err) + } + defer rows.Close() + + var out []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, fmt.Errorf("superadmin lesen: %w", err) + } + out = append(out, u) + } + return out, rows.Err() +} + +func (s *SuperadminStore) Deactivate(ctx context.Context, id string) (User, error) { + return scanUser(s.pool.QueryRow(ctx, ` + UPDATE superadmins SET status = $2, updated_at = now() + WHERE id = $1 + RETURNING id, email, name, status, created_at, updated_at + `, id, StatusInactive)) +} diff --git a/internal/user/tenant_store.go b/internal/user/tenant_store.go new file mode 100644 index 0000000..46e952e --- /dev/null +++ b/internal/user/tenant_store.go @@ -0,0 +1,173 @@ +package user + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +// TenantUserStore verwaltet Benutzer innerhalb GENAU EINER Tenant-Datenbank. +// Welcher Mandant gemeint ist, ergibt sich ausschliesslich aus dem +// uebergebenen Pool — es gibt keine tenant_id-Spalte (siehe migrations/tenant/0001_users.up.sql). +type TenantUserStore struct { + pool *pgxpool.Pool +} + +func NewTenantUserStore(pool *pgxpool.Pool) *TenantUserStore { + return &TenantUserStore{pool: pool} +} + +func (s *TenantUserStore) Create(ctx context.Context, email, name string) (User, error) { + if err := ValidateEmail(email); err != nil { + return User{}, err + } + + var u User + u.Email, u.Name, u.Status = email, name, StatusActive + + row := s.pool.QueryRow(ctx, ` + INSERT INTO users (email, name, status) + VALUES ($1, $2, $3) + RETURNING id, created_at, updated_at + `, u.Email, u.Name, u.Status) + + if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil { + return User{}, mapWriteErr(err) + } + return u, nil +} + +func (s *TenantUserStore) Get(ctx context.Context, id string) (User, error) { + return scanUser(s.pool.QueryRow(ctx, ` + SELECT id, email, name, status, created_at, updated_at + FROM users WHERE id = $1 + `, id)) +} + +func (s *TenantUserStore) List(ctx context.Context) ([]User, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, email, name, status, created_at, updated_at + FROM users ORDER BY created_at + `) + if err != nil { + return nil, fmt.Errorf("benutzer auflisten: %w", err) + } + defer rows.Close() + + var out []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, fmt.Errorf("benutzer lesen: %w", err) + } + out = append(out, u) + } + return out, rows.Err() +} + +// Update aendert Name und E-Mail. Eine leere email/name laesst das jeweilige +// Feld unveraendert. +func (s *TenantUserStore) Update(ctx context.Context, id, email, name string) (User, error) { + if email != "" { + if err := ValidateEmail(email); err != nil { + return User{}, err + } + } + + row := s.pool.QueryRow(ctx, ` + UPDATE users + SET email = COALESCE(NULLIF($2, ''), email), + name = COALESCE(NULLIF($3, ''), name), + updated_at = now() + WHERE id = $1 + RETURNING id, email, name, status, created_at, updated_at + `, id, email, name) + + u, err := scanUser(row) + if err != nil { + return User{}, mapWriteErr(err) + } + return u, nil +} + +// Deactivate setzt den Benutzer auf inaktiv statt ihn zu loeschen. +func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, error) { + return scanUser(s.pool.QueryRow(ctx, ` + UPDATE users SET status = $2, updated_at = now() + WHERE id = $1 + RETURNING id, email, name, status, created_at, updated_at + `, id, StatusInactive)) +} + +// SetPasswordHash schreibt einen bereits berechneten bcrypt-Hash (siehe +// internal/auth, IAM-02). Der Store selbst kennt kein Klartext-Passwort. +func (s *TenantUserStore) SetPasswordHash(ctx context.Context, id, hash string) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1 + `, id, hash) + if err != nil { + return fmt.Errorf("passwort setzen: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// AuthCredentials wird ausschliesslich fuer den Login-Pfad (internal/auth) +// verwendet und traegt bewusst den password_hash, damit er nicht ueber den +// regulaeren User-Typ/JSON-Serialisierungspfad nach aussen dringen kann. +type AuthCredentials struct { + User User + PasswordHash string +} + +// GetByEmailForAuth liefert Benutzer + Passwort-Hash zu einer E-Mail-Adresse +// aus GENAU DIESER Tenant-Datenbank — der Tenant-Scope ergibt sich damit +// zwingend aus dem verwendeten Pool, es gibt keine Moeglichkeit, versehentlich +// ueber Tenant-Grenzen hinweg zu suchen (bekannter archivmail-Fehler, siehe +// IAM-02 "Bekannte Fehler vermeiden"). +func (s *TenantUserStore) GetByEmailForAuth(ctx context.Context, email string) (AuthCredentials, error) { + var c AuthCredentials + row := s.pool.QueryRow(ctx, ` + SELECT id, email, name, status, created_at, updated_at, password_hash + FROM users WHERE email = $1 + `, email) + + if err := row.Scan(&c.User.ID, &c.User.Email, &c.User.Name, &c.User.Status, + &c.User.CreatedAt, &c.User.UpdatedAt, &c.PasswordHash); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return AuthCredentials{}, ErrNotFound + } + return AuthCredentials{}, fmt.Errorf("anmeldedaten lesen: %w", err) + } + return c, nil +} + +func scanUser(row pgx.Row) (User, error) { + var u User + if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return User{}, ErrNotFound + } + return User{}, fmt.Errorf("benutzer lesen: %w", err) + } + return u, nil +} + +// mapWriteErr uebersetzt den Unique-Constraint-Verstoss der E-Mail-Spalte in +// einen sprechenden Fehler statt der rohen Postgres-Fehlermeldung. +func mapWriteErr(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return ErrEmailTaken + } + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return fmt.Errorf("benutzer schreiben: %w", err) +} diff --git a/internal/user/user.go b/internal/user/user.go new file mode 100644 index 0000000..1cae86b --- /dev/null +++ b/internal/user/user.go @@ -0,0 +1,42 @@ +// Package user implementiert Core IAM-01: das Benutzer-Datenmodell und die +// CRUD-Operationen. Tenant-Zugehoerigkeit ist ueber die Zieldatenbank +// gegeben (Modell C, siehe internal/tenant) — Superadmin-Konten leben +// dagegen mandantenuebergreifend in der Registry und sind ueber +// SuperadminStore als eigener, First-Class-Typ modelliert, nicht als +// tenant_id-NULL-Sonderfall in User. +package user + +import ( + "errors" + "regexp" + "time" +) + +type Status string + +const ( + StatusActive Status = "active" + StatusInactive Status = "inactive" +) + +type User struct { + ID string + Email string + Name string + Status Status + CreatedAt time.Time + UpdatedAt time.Time +} + +var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) + +var ErrInvalidEmail = errors.New("user: ungueltige E-Mail-Adresse") +var ErrEmailTaken = errors.New("user: E-Mail-Adresse bereits vergeben") +var ErrNotFound = errors.New("user: nicht gefunden") + +func ValidateEmail(email string) error { + if !emailPattern.MatchString(email) { + return ErrInvalidEmail + } + return nil +} diff --git a/internal/user/user_test.go b/internal/user/user_test.go new file mode 100644 index 0000000..a996b5e --- /dev/null +++ b/internal/user/user_test.go @@ -0,0 +1,24 @@ +package user + +import "testing" + +func TestValidateEmail(t *testing.T) { + cases := []struct { + email string + wantErr bool + }{ + {"a@b.de", false}, + {"a.b+c@sub.example.com", false}, + {"", true}, + {"keine-email", true}, + {"a@b", true}, + {"@b.de", true}, + } + + for _, c := range cases { + err := ValidateEmail(c.email) + if (err != nil) != c.wantErr { + t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", c.email, err, c.wantErr) + } + } +} diff --git a/migrations/0002_superadmins.down.sql b/migrations/0002_superadmins.down.sql new file mode 100644 index 0000000..51b8721 --- /dev/null +++ b/migrations/0002_superadmins.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS superadmins; diff --git a/migrations/0002_superadmins.up.sql b/migrations/0002_superadmins.up.sql new file mode 100644 index 0000000..202f785 --- /dev/null +++ b/migrations/0002_superadmins.up.sql @@ -0,0 +1,14 @@ +-- Superadmin-Konten arbeiten mandantenuebergreifend und leben deshalb in der +-- Control-Plane-Registry (siehe TEN-01), nicht in einer Tenant-Datenbank. +-- Das bildet "Superadmin ohne Tenant" strukturell als First-Class-Zustand ab, +-- statt ihn als Sonderfall in der Tenant-users-Tabelle zu behandeln +-- (IAM-01, siehe core-kanban/tickets/IAM-01.md — bekannte Fehler vermeiden). +-- E-Mail-Eindeutigkeit ist hier global, da die Registry-DB einmalig existiert. +CREATE TABLE superadmins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/migrations/tenant/0001_users.down.sql b/migrations/tenant/0001_users.down.sql new file mode 100644 index 0000000..c99ddcd --- /dev/null +++ b/migrations/tenant/0001_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/migrations/tenant/0001_users.up.sql b/migrations/tenant/0001_users.up.sql new file mode 100644 index 0000000..53df0c9 --- /dev/null +++ b/migrations/tenant/0001_users.up.sql @@ -0,0 +1,16 @@ +-- Benutzer-Datenmodell (IAM-01, siehe core-kanban/tickets/IAM-01.md). +-- Diese Migration laeuft in der DB EINES Mandanten (Modell C, siehe TEN-01) — +-- die Tenant-Zugehoerigkeit ist implizit durch die Datenbankverbindung +-- gegeben, es gibt daher bewusst KEINE tenant_id-Spalte. +-- E-Mail-Eindeutigkeit ist hier tenant-scoped: der UNIQUE-Constraint gilt +-- nur innerhalb dieser einen Tenant-Datenbank. +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/migrations/tenant/0002_users_password.down.sql b/migrations/tenant/0002_users_password.down.sql new file mode 100644 index 0000000..0886835 --- /dev/null +++ b/migrations/tenant/0002_users_password.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN password_hash; diff --git a/migrations/tenant/0002_users_password.up.sql b/migrations/tenant/0002_users_password.up.sql new file mode 100644 index 0000000..e69a45f --- /dev/null +++ b/migrations/tenant/0002_users_password.up.sql @@ -0,0 +1,3 @@ +-- Passwort-Hash-Spalte fuer Login (IAM-02, siehe core-kanban/tickets/IAM-02.md). +-- Enthaelt AUSSCHLIESSLICH den bcrypt-Hash, niemals das Klartext-Passwort. +ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';