Zweistufig wie der Werkzeugkatalog: Betreiber pflegt den plattformweiten Standard (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich übersteuern (/verwaltung/email-vorlagen) - ResolveEmailVorlage nutzt die eigene Vorlage falls vorhanden, sonst fällt sie auf den Plattform- Standard zurück. Passwort-Zurücksetzen ist die einzige aktuell existierende E-Mail und nutzt jetzt diese Vorlage statt Hardcoding. Bug beim Live-Verifizieren gefunden: UNIQUE(account_id, typ) verhindert bei NULLABLE account_id keine Duplikate (NULL != NULL in SQL) - jedes Speichern des Plattform-Standards erzeugte eine neue Zeile statt sie zu aktualisieren. Fix: zwei partielle Unique-Indizes statt eines gemeinsamen Constraints, mit Regressionstest abgesichert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
107 lines
4.4 KiB
Go
107 lines
4.4 KiB
Go
// Passwort-Zurücksetzen (Ebene 1, öffentlich) — nutzt internal/mail für
|
|
// den Versand. Wie beim Login verrät keine Fehlermeldung, ob eine
|
|
// E-Mail-Adresse überhaupt existiert (User-Enumeration-Schutz): die
|
|
// Anfrage-Seite zeigt immer dieselbe Erfolgsmeldung, unabhängig davon,
|
|
// ob tatsächlich eine Mail verschickt wurde.
|
|
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
|
|
"github.com/netcell-it/deklarix/internal/auth"
|
|
)
|
|
|
|
const passwortVergessenHinweis = "Falls diese E-Mail-Adresse bei uns registriert ist, wurde ein Link zum Zurücksetzen verschickt."
|
|
|
|
func (s *Server) handlePasswortVergessenForm(w http.ResponseWriter, r *http.Request) {
|
|
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen"})
|
|
}
|
|
|
|
func (s *Server) handlePasswortVergessen(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen", Error: "ungültiges Formular"})
|
|
return
|
|
}
|
|
email := r.FormValue("email")
|
|
|
|
user, err := s.store.GetUserByEmail(r.Context(), email)
|
|
if err == nil && user.Active {
|
|
token, tokenErr := auth.NewSessionToken()
|
|
if tokenErr == nil {
|
|
if _, createErr := s.store.CreatePasswordResetToken(r.Context(), user.ID, token); createErr == nil {
|
|
scheme := "http"
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
link := fmt.Sprintf("%s://%s/passwort-zuruecksetzen/%s", scheme, r.Host, token)
|
|
betreff, text := "Deklarix — Passwort zurücksetzen", "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n{{link}}\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail."
|
|
if vorlage, err := s.store.ResolveEmailVorlage(r.Context(), user.AccountID, "passwort_zuruecksetzen"); err == nil {
|
|
betreff, text = vorlage.Betreff, vorlage.Text
|
|
}
|
|
// Versandfehler wird bewusst nicht an den Nutzer durchgereicht
|
|
// (keine Enumeration über unterschiedliches Verhalten bei
|
|
// Versandfehlern), aber serverseitig geloggt statt
|
|
// verschluckt — sonst bleibt ein dauerhaft falsch
|
|
// konfigurierter SMTP-Server unbemerkt.
|
|
if err := s.mailer.Send(user.Email, betreff, renderEmailVorlage(text, link)); err != nil {
|
|
log.Printf("passwort-vergessen: mail versand fehlgeschlagen: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen", Message: passwortVergessenHinweis})
|
|
}
|
|
|
|
type passwortZuruecksetzenData struct {
|
|
Title string
|
|
Token string
|
|
Error string
|
|
}
|
|
|
|
func (s *Server) handlePasswortZuruecksetzenForm(w http.ResponseWriter, r *http.Request) {
|
|
token := r.PathValue("token")
|
|
if _, err := s.store.GetValidPasswordResetToken(r.Context(), token); err != nil {
|
|
http.Error(w, "Link ist ungültig oder abgelaufen", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err := s.templates.ExecuteTemplate(w, "passwort-zuruecksetzen", passwortZuruecksetzenData{Title: "Neues Passwort setzen", Token: token}); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handlePasswortZuruecksetzen(w http.ResponseWriter, r *http.Request) {
|
|
token := r.PathValue("token")
|
|
resetToken, err := s.store.GetValidPasswordResetToken(r.Context(), token)
|
|
if err != nil {
|
|
http.Error(w, "Link ist ungültig oder abgelaufen", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderPasswortZuruecksetzenError(w, token, "ungültiges Formular")
|
|
return
|
|
}
|
|
passwordHash, err := auth.HashPassword(r.FormValue("password"))
|
|
if err != nil {
|
|
s.renderPasswortZuruecksetzenError(w, token, err.Error())
|
|
return
|
|
}
|
|
if err := s.store.SetUserPassword(r.Context(), resetToken.UserID, passwordHash); err != nil {
|
|
s.renderPasswortZuruecksetzenError(w, token, "Passwort konnte nicht gesetzt werden")
|
|
return
|
|
}
|
|
if err := s.store.MarkPasswordResetTokenUsed(r.Context(), resetToken.ID); err != nil {
|
|
s.renderPasswortZuruecksetzenError(w, token, "Passwort wurde gesetzt, Token konnte aber nicht als verwendet markiert werden")
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/login?reset=1", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) renderPasswortZuruecksetzenError(w http.ResponseWriter, token, msg string) {
|
|
if err := s.templates.ExecuteTemplate(w, "passwort-zuruecksetzen", passwortZuruecksetzenData{Title: "Neues Passwort setzen", Token: token, Error: msg}); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|