feat(auth): confirm the e-mail address of new accounts by link
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m21s

- Setting / REGISTRATION_VERIFY_EMAIL; only with SMTP and BASE_URL; invites skip it
- Hashed one-time tokens (48 h), resend with cooldown, unconfirmed accounts purged
- Approval modes notify admins only after the address is confirmed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-31 14:01:41 +01:00
1 parent 1406650b7c
commit ab97638361
25 files changed
+621 -56

No files matched your search

+103
View File
@@ -22,6 +22,9 @@ type registrationPolicy struct {
Mode string
Domains []string
ViaEnv bool
// VerifyEmail: self-registrations confirm their address by link first (only when mails with links
// can go out, see emailLinksAvailable)
VerifyEmail bool
}
// registrationPolicy: the environment (REGISTRATION_MODE / REGISTRATION_DOMAINS) wins over the admin
@@ -31,7 +34,12 @@ func (s *Server) registrationPolicy() registrationPolicy {
if sys, _ := s.store.GetSystemSettings(); sys != nil {
p.Mode = sys.RegistrationMode
p.Domains = storage.ParseEmailDomains(sys.RegistrationDomains)
p.VerifyEmail = sys.RegistrationVerifyEmail
}
if s.cfg.RegistrationVerifyEmail != "" {
p.VerifyEmail = s.cfg.RegistrationVerifyEmail == "true"
}
p.VerifyEmail = p.VerifyEmail && s.emailLinksAvailable()
if s.cfg.RegistrationMode != "" {
p.Mode, p.ViaEnv = s.cfg.RegistrationMode, true
}
@@ -72,6 +80,100 @@ func domainList(domains []string) string {
return strings.Join(out, ", ")
}
// emailLinksAvailable: mails with links to people who are not logged in can go out – a mail server
// and BASE_URL (links to strangers are never built from the request's Host header).
func (s *Server) emailLinksAvailable() bool {
return s.passwordResetAvailable()
}
// sendVerificationMail mails the confirmation link of a new account.
func (s *Server) sendVerificationMail(user *domain.User) {
raw, err := s.store.CreateEmailVerification(user.ID)
if err != nil {
if !errors.Is(err, storage.ErrResetTooSoon) {
slog.Warn("Bestätigungslink konnte nicht erzeugt werden", "user_id", user.ID, "error", err)
}
return
}
link := strings.TrimRight(s.cfg.BaseURL, "/") + "/?verifyToken=" + url.QueryEscape(raw)
err = s.sendMailContent(user.Email, "MindSift: Bitte bestätige deine E-Mail-Adresse", mailContent{
Preheader: "Ein Klick, dann ist dein Konto bereit",
Greeting: mailGreeting(time.Now(), user.DisplayName),
Intro: "Du hast dich bei MindSift registriert. Bitte bestätige noch deine E-Mail-Adresse.",
Button: &mailButton{Label: "E-Mail-Adresse bestätigen", URL: link},
Note: "Der Link gilt 48 Stunden. Hast du dich nicht registriert, ignoriere diese E-Mail – das Konto wird dann automatisch gelöscht.\n" +
"Falls der Knopf nicht geht: " + link,
Footer: mailFooterAccount,
})
if err != nil {
slog.Warn("Bestätigungs-E-Mail nicht verschickt", "user_id", user.ID, "error", err)
}
}
// handleVerifyEmail: POST /auth/verify-email {"token"} – the link from the mail. Without approval the
// account is active now, otherwise it goes on to the admins.
func (s *Server) handleVerifyEmail(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || strings.TrimSpace(req.Token) == "" {
writeError(w, http.StatusBadRequest, storage.ErrVerificationInvalid.Error())
return
}
userID, err := s.store.ConfirmEmailVerification(strings.TrimSpace(req.Token))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, err := s.store.GetUserByID(userID)
if err != nil || user == nil {
writeError(w, http.StatusBadRequest, storage.ErrVerificationInvalid.Error())
return
}
if s.store.IsPendingRegistration(userID) {
go s.notifyAdminsOfRegistration(user)
writeJSON(w, http.StatusOK, map[string]interface{}{
"verified": true, "pending": true,
"message": "Deine E-Mail-Adresse ist bestätigt. Ein Administrator schaltet dein Konto noch frei – du bekommst dann eine E-Mail.",
})
return
}
active := true
if _, err := s.store.UpdateUser(userID, nil, nil, nil, &active, nil, nil); err != nil {
writeError(w, http.StatusInternalServerError, "Konto konnte nicht freigeschaltet werden")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"verified": true, "pending": false,
"message": "Deine E-Mail-Adresse ist bestätigt. Du kannst dich jetzt anmelden.",
})
}
// handleResendVerification: POST /auth/resend-verification {"email"} – always the same answer, the
// mail goes out in the background (at most every 2 minutes per account).
func (s *Server) handleResendVerification(w http.ResponseWriter, r *http.Request) {
if wait := s.loginGuard.throttle("forgot", clientIPFrom(r), limitForgotPerIP); wait > 0 {
writeThrottled(w, wait)
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || strings.TrimSpace(req.Email) == "" {
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
return
}
email := strings.TrimSpace(req.Email)
if s.emailLinksAvailable() {
go func() {
if user, err := s.store.GetUserByEmail(email); err == nil && user != nil && s.store.IsEmailUnverified(user.ID) {
s.sendVerificationMail(user)
}
}()
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// appLink is the address of this instance for links in mails: BASE_URL, otherwise the address the
// admin is using right now (only for mails an admin triggers, never for anonymous requests).
func (s *Server) appLink(r *http.Request) string {
@@ -137,6 +239,7 @@ func (s *Server) handleAdminApproveUser(w http.ResponseWriter, r *http.Request)
return
}
user.IsActive = true
s.store.ClearEmailVerification(userID) // the admin vouches for the account
link := s.appLink(r)
go func() {
_ = s.sendMailContent(user.Email, "MindSift: Dein Konto ist freigeschaltet", mailContent{