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) }