diff --git a/internal/auth/changepassword.go b/internal/auth/changepassword.go new file mode 100644 index 0000000..81c611f --- /dev/null +++ b/internal/auth/changepassword.go @@ -0,0 +1,65 @@ +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) +} diff --git a/internal/auth/changepassword_test.go b/internal/auth/changepassword_test.go new file mode 100644 index 0000000..f60c0a9 --- /dev/null +++ b/internal/auth/changepassword_test.go @@ -0,0 +1,76 @@ +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") + } +} diff --git a/internal/auth/profile.go b/internal/auth/profile.go new file mode 100644 index 0000000..47ce336 --- /dev/null +++ b/internal/auth/profile.go @@ -0,0 +1,44 @@ +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}) +} diff --git a/internal/auth/profile_test.go b/internal/auth/profile_test.go new file mode 100644 index 0000000..2bb518e --- /dev/null +++ b/internal/auth/profile_test.go @@ -0,0 +1,48 @@ +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) + } +} diff --git a/internal/authtoken/handler.go b/internal/authtoken/handler.go new file mode 100644 index 0000000..9f1bbd9 --- /dev/null +++ b/internal/authtoken/handler.go @@ -0,0 +1,109 @@ +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) +} diff --git a/internal/authtoken/handler_test.go b/internal/authtoken/handler_test.go new file mode 100644 index 0000000..2a4824e --- /dev/null +++ b/internal/authtoken/handler_test.go @@ -0,0 +1,94 @@ +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) + } +} diff --git a/internal/totp/handler.go b/internal/totp/handler.go new file mode 100644 index 0000000..837a20c --- /dev/null +++ b/internal/totp/handler.go @@ -0,0 +1,164 @@ +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}) +} diff --git a/internal/totp/handler_test.go b/internal/totp/handler_test.go new file mode 100644 index 0000000..d3aad54 --- /dev/null +++ b/internal/totp/handler_test.go @@ -0,0 +1,171 @@ +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)) + } +} diff --git a/internal/user/tenant_store.go b/internal/user/tenant_store.go index 46e952e..f6f0bfa 100644 --- a/internal/user/tenant_store.go +++ b/internal/user/tenant_store.go @@ -126,6 +126,26 @@ 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 diff --git a/web/account/app/layout.tsx b/web/account/app/layout.tsx new file mode 100644 index 0000000..98d5597 --- /dev/null +++ b/web/account/app/layout.tsx @@ -0,0 +1,30 @@ +import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl"; + +export const metadata = { + title: "NEXARCH Anmeldung & Profil", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + +
++ Ihr Passwort wurde erfolgreich geändert. Jetzt anmelden. +
++ Kein gültiger Link. Bitte fordern Sie einen neuen Reset-Link an. +
+ )} + +{message}
+ ) : ( + + )} + +{loadError}
+ Zur Anmeldung ++ {me.name} — {me.email} +
++ Wiederherstellungscodes — jetzt notieren, sie werden nicht erneut angezeigt: +
+{code}
+ Zwei-Faktor-Authentifizierung ist aktiv.
+ ) : setupData ? ( + + ) : ( + <> +Zwei-Faktor-Authentifizierung ist derzeit nicht aktiv.
+ {error && ( ++ {error} +
+ )} + + > + )} +