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

+3
View File
@@ -76,6 +76,9 @@ func (s *Server) sendEmailHTML(toEmail, subject, bodyText, html string) error {
if err != nil {
return err
}
if s.notifyEmailHook != nil { // tests
return s.notifyEmailHook(toEmail, subject, bodyText, html)
}
if err := sendMail(cfg, toEmail, subject, bodyText, html); err != nil {
step := ""
if se, ok := err.(*smtpStepError); ok {
+3
View File
@@ -270,6 +270,9 @@ func (s *Server) startCompaction() {
func (s *Server) compactOnce() {
s.leases.Sweep()
if n := s.store.PurgeUnverifiedAccounts(time.Now()); n > 0 {
slog.Info("Unbestätigte Registrierungen gelöscht", "count", n)
}
s.compactMedia(time.Now())
_ = s.store.PruneLinkPreviews(time.Now().Add(-30 * 24 * time.Hour))
st, err := s.store.CompactDocs(time.Now())
+15 -1
View File
@@ -95,8 +95,10 @@ func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
return
}
pending := s.store.PendingRegistrationIDs()
unverified := s.store.UnverifiedEmailIDs()
for i := range users {
users[i].PendingApproval = pending[users[i].ID]
users[i].EmailUnverified = unverified[users[i].ID]
}
writeJSON(w, http.StatusOK, users)
}
@@ -197,7 +199,9 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
s.wsHub.DisconnectUser(targetUserID)
}
if req.IsActive != nil && *req.IsActive {
s.store.ClearPendingRegistration(targetUserID) // activating by hand counts as approval
// Activating by hand counts as approval and vouches for the address
s.store.ClearPendingRegistration(targetUserID)
s.store.ClearEmailVerification(targetUserID)
}
writeJSON(w, http.StatusOK, u)
}
@@ -332,6 +336,11 @@ func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.Sys
out.RegistrationDomains = strings.Join(storage.ParseEmailDomains(s.cfg.RegistrationDomains), ", ")
out.RegistrationConfiguredViaEnv = true
}
if s.cfg.RegistrationVerifyEmail != "" {
out.RegistrationVerifyEmail = s.cfg.RegistrationVerifyEmail == "true"
out.RegistrationVerifyViaEnv = true
}
out.EmailLinksAvailable = s.emailLinksAvailable()
return &out
}
@@ -358,6 +367,11 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
}
}
}
if s.cfg.RegistrationVerifyEmail != "" {
if cur, err := s.store.GetSystemSettings(); err == nil {
settings.RegistrationVerifyEmail = cur.RegistrationVerifyEmail
}
}
previousMode := s.store.SharingMode()
if err := s.store.UpdateSystemSettings(&settings); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
+30 -9
View File
@@ -43,6 +43,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
"allowRegistration": policy.Mode != storage.RegistrationClosed,
"registrationMode": policy.Mode,
"registrationDomains": policy.Domains,
"emailVerification": policy.VerifyEmail,
"instanceName": instanceName,
"demoMode": s.cfg.DemoMode,
"sharingMode": s.store.SharingMode(),
@@ -199,6 +200,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
role := "member"
var validInvite *domain.UserInvite
pending := false // registered without invite while approval is needed
verify := false // registered without invite while the address must be confirmed
if strings.TrimSpace(req.InviteCode) != "" {
inv, err := s.store.GetUserInviteByCode(req.InviteCode)
@@ -215,8 +217,10 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
} else if count == 0 {
role = "admin"
} else {
policy := s.registrationPolicy()
var err error
pending, err = s.registrationPolicy().registrationOutcome(req.Email)
pending, err = policy.registrationOutcome(req.Email)
verify = policy.VerifyEmail
if err != nil {
writeError(w, http.StatusForbidden, err.Error())
return
@@ -261,22 +265,35 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
}
}
// Waiting for approval: the account exists but stays locked, no session yet
if pending {
// Waiting for the e-mail confirmation and/or approval: the account exists but stays locked, no session
if pending || verify {
inactive := false
if _, err := s.store.UpdateUser(user.ID, nil, nil, nil, &inactive, nil, nil); err != nil {
writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden")
return
}
if err := s.store.AddPendingRegistration(user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden")
return
if pending {
if err := s.store.AddPendingRegistration(user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden")
return
}
}
slog.Info("Registrierung wartet auf Freigabe", "user_id", user.ID, "email", user.Email)
go s.notifyAdminsOfRegistration(user)
message := "Dein Konto wurde angelegt und wartet auf die Freigabe durch einen Administrator. Du bekommst eine E-Mail, sobald du dich anmelden kannst."
if verify {
// Admins hear about it only once the address is confirmed
go s.sendVerificationMail(user)
message = "Fast geschafft: Wir haben dir eine E-Mail an " + user.Email + " geschickt. Bestätige deine Adresse über den Link darin."
if pending {
message += " Danach schaltet ein Administrator dein Konto frei."
}
} else {
go s.notifyAdminsOfRegistration(user)
}
slog.Info("Registrierung wartet", "user_id", user.ID, "email", user.Email, "approval", pending, "verify", verify)
writeJSON(w, http.StatusAccepted, map[string]interface{}{
"pending": true,
"message": "Dein Konto wurde angelegt und wartet auf die Freigabe durch einen Administrator. Du bekommst eine E-Mail, sobald du dich anmelden kannst.",
"verify": verify,
"message": message,
})
return
}
@@ -343,6 +360,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
s.loginGuard.recordSuccess(account, ip)
if !user.IsActive && s.store.IsEmailUnverified(user.ID) {
writeError(w, http.StatusForbidden, "Bitte bestätige zuerst deine E-Mail-Adresse über den Link, den wir dir geschickt haben")
return
}
if !user.IsActive && s.store.IsPendingRegistration(user.ID) {
writeError(w, http.StatusForbidden, "Dein Konto wartet noch auf die Freigabe durch einen Administrator")
return
+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{
+124
View File
@@ -3,8 +3,11 @@ package api
import (
"encoding/json"
"net/http"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
@@ -155,3 +158,124 @@ func TestRegistrationFixedByEnvironment(t *testing.T) {
t.Fatalf("the locked value must not be overwritten in the database")
}
}
// verificationServer: mails can go out (SMTP + BASE_URL); returns the captured mails
func verificationServer(t *testing.T) (*Server, *storage.Store, string, func() []sentNote) {
t.Helper()
srv, store, adminToken := setupTestServer(t)
srv.cfg.BaseURL, srv.cfg.BaseURLSet = "https://mindsift.example", true
sys, _ := store.GetSystemSettings()
sys.SMTPHost, sys.RegistrationVerifyEmail = "smtp.example.com", true
if err := store.UpdateSystemSettings(sys); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var sent []sentNote
srv.notifyEmailHook = func(to, subject, text, html string) error {
mu.Lock()
defer mu.Unlock()
sent = append(sent, sentNote{channel: "email", to: to, subject: subject, text: text, html: html})
return nil
}
mails := func() []sentNote {
mu.Lock()
defer mu.Unlock()
return append([]sentNote(nil), sent...)
}
return srv, store, adminToken, mails
}
// waitMail waits for a mail to `to` (mails go out in the background)
func waitMail(t *testing.T, mails func() []sentNote, to string) sentNote {
t.Helper()
for i := 0; i < 100; i++ {
for _, m := range mails() {
if m.to == to {
return m
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("no mail to %s, got %+v", to, mails())
return sentNote{}
}
func TestEmailVerification(t *testing.T) {
srv, store, _, mails := verificationServer(t)
router := srv.Router()
setRegistration(t, store, storage.RegistrationOpen, "")
sys, _ := store.GetSystemSettings()
sys.RegistrationVerifyEmail = true
_ = store.UpdateSystemSettings(sys)
code, out := register(srv, "neu@example.com", "")
if code != http.StatusAccepted || out["verify"] != true || out["token"] != nil {
t.Fatalf("register: want 202 with verify and no session, got %d %v", code, out)
}
mail := waitMail(t, mails, "neu@example.com")
m := regexp.MustCompile(`verifyToken=([0-9a-f]{64})`).FindStringSubmatch(mail.text)
if m == nil || !strings.HasPrefix(mail.text[strings.Index(mail.text, "https://"):], "https://mindsift.example/") {
t.Fatalf("mail must carry a link built from BASE_URL: %s", mail.text)
}
login := func() int {
return sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"neu@example.com","password":"password123"}`, "").Code
}
if c := login(); c != http.StatusForbidden {
t.Fatalf("login before confirming: want 403, got %d", c)
}
if rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+strings.Repeat("0", 64)+`"}`, ""); rec.Code != http.StatusBadRequest {
t.Fatalf("wrong token: want 400, got %d", rec.Code)
}
rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+m[1]+`"}`, "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"pending":false`) {
t.Fatalf("verify: %d %s", rec.Code, rec.Body.String())
}
if c := login(); c != http.StatusOK {
t.Fatalf("login after confirming: want 200, got %d", c)
}
if rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+m[1]+`"}`, ""); rec.Code != http.StatusBadRequest {
t.Fatalf("a link works only once, got %d", rec.Code)
}
// Invites need no confirmation
inv, _ := store.CreateUserInvite("", "", "member", "", 7, 1)
if code, out := register(srv, "eingeladen@example.com", inv.Code); code != http.StatusCreated || out["token"] == nil {
t.Fatalf("invite: want 201 with session, got %d %v", code, out)
}
}
func TestEmailVerificationBeforeApproval(t *testing.T) {
srv, store, _, mails := verificationServer(t)
router := srv.Router()
setRegistration(t, store, storage.RegistrationApproval, "")
sys, _ := store.GetSystemSettings()
sys.RegistrationVerifyEmail = true
_ = store.UpdateSystemSettings(sys)
admin, err := store.GetFirstUser()
if err != nil || admin == nil || admin.Role != "admin" {
t.Fatalf("test server needs its admin, got %+v %v", admin, err)
}
adminEmail := admin.Email
if code, _ := register(srv, "wartet@example.com", ""); code != http.StatusAccepted {
t.Fatalf("register: want 202, got %d", code)
}
mail := waitMail(t, mails, "wartet@example.com")
time.Sleep(100 * time.Millisecond)
for _, m := range mails() {
if m.to != "wartet@example.com" {
t.Fatalf("admins must not hear about it before the address is confirmed, got mail to %s", m.to)
}
}
token := regexp.MustCompile(`verifyToken=([0-9a-f]{64})`).FindStringSubmatch(mail.text)[1]
rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+token+`"}`, "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"pending":true`) {
t.Fatalf("verify: %d %s", rec.Code, rec.Body.String())
}
if m := waitMail(t, mails, adminEmail); !strings.Contains(m.subject, "Freigabe") {
t.Fatalf("admin mail after confirming expected, got %q", m.subject)
}
if c := sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"wartet@example.com","password":"password123"}`, "").Code; c != http.StatusForbidden {
t.Fatalf("still waiting for approval: want 403, got %d", c)
}
}
+2
View File
@@ -122,6 +122,8 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/auth/status", s.handleAuthStatus)
r.Get("/api/v1/legal/{type}", s.handleLegalText)
r.Post("/api/v1/auth/setup", s.handleFirstBootSetup)
r.Post("/api/v1/auth/verify-email", s.handleVerifyEmail)
r.Post("/api/v1/auth/resend-verification", s.handleResendVerification)
r.Post("/api/v1/auth/register", s.handleRegister)
r.Post("/api/v1/auth/login", s.handleLogin)
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)