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
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:
1 parent
1406650b7c
commit
ab97638361
25 files changed
+621
-56
No files matched your search
@@ -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{
|
||||
|
||||
Reference in new issue
Block a user