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)
+40 -26
View File
@@ -26,9 +26,11 @@ type Config struct {
// ALLOW_REGISTRATION=false (the template default) leaves the choice to the admin settings.
RegistrationMode string
RegistrationDomains string
LogLevel string
Debug bool
DemoMode bool
// RegistrationVerifyEmail: REGISTRATION_VERIFY_EMAIL ("true"/"false", "" = admin settings decide)
RegistrationVerifyEmail string
LogLevel string
Debug bool
DemoMode bool
// DemoResetMinutes: in demo mode the database is reset to the sample data this often (0 = never)
DemoResetMinutes int
// TrustedProxies: whose X-Forwarded-For/X-Real-IP is believed ("private", "all", "none" or CIDRs)
@@ -233,29 +235,30 @@ func Load() *Config {
smtpSkipVerify := strings.ToLower(getEnv("SMTP_SKIP_VERIFY", "false")) == "true"
return &Config{
Port: port,
DataDir: dataDir,
DatabasePath: dbPath,
JWTSecret: jwtSecret,
BaseURL: baseURL,
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
AllowRegistration: allowReg,
RegistrationMode: registrationMode(allowReg),
RegistrationDomains: strings.TrimSpace(os.Getenv("REGISTRATION_DOMAINS")),
LogLevel: logLevel,
Debug: debug,
DemoMode: demoMode,
DemoResetMinutes: demoResetMinutes(),
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPUser: smtpUser,
SMTPPass: smtpPass,
SMTPFrom: smtpFrom,
SMTPFromName: getEnv("SMTP_FROM_NAME", "MindSift"),
SMTPTLS: smtpTLS,
SMTPSkipVerify: smtpSkipVerify,
Port: port,
DataDir: dataDir,
DatabasePath: dbPath,
JWTSecret: jwtSecret,
BaseURL: baseURL,
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
AllowRegistration: allowReg,
RegistrationMode: registrationMode(allowReg),
RegistrationDomains: strings.TrimSpace(os.Getenv("REGISTRATION_DOMAINS")),
RegistrationVerifyEmail: boolEnvOrEmpty("REGISTRATION_VERIFY_EMAIL"),
LogLevel: logLevel,
Debug: debug,
DemoMode: demoMode,
DemoResetMinutes: demoResetMinutes(),
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPUser: smtpUser,
SMTPPass: smtpPass,
SMTPFrom: smtpFrom,
SMTPFromName: getEnv("SMTP_FROM_NAME", "MindSift"),
SMTPTLS: smtpTLS,
SMTPSkipVerify: smtpSkipVerify,
Speech: SpeechConfig{
URL: strings.TrimRight(getEnv("STT_URL", ""), "/"),
Model: getEnv("STT_MODEL", "deepdml/faster-whisper-large-v3-turbo-ct2"),
@@ -349,3 +352,14 @@ func registrationMode(allowReg bool) string {
}
return ""
}
// boolEnvOrEmpty: "true" or "false" when the variable is set to a boolean, "" when unset or unknown.
func boolEnvOrEmpty(key string) string {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "true", "1", "yes", "on":
return "true"
case "false", "0", "no", "off":
return "false"
}
return ""
}
+13 -5
View File
@@ -137,6 +137,8 @@ type User struct {
CreatedAt string `json:"createdAt"`
// Registered while registration needs approval and not approved yet (admin user list only)
PendingApproval bool `json:"pendingApproval,omitempty"`
// Registered and the e-mail address is not confirmed yet (admin user list only)
EmailUnverified bool `json:"emailUnverified,omitempty"`
// Only filled for the user's own profile (GET /auth/me, PATCH /auth/settings)
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
}
@@ -363,11 +365,17 @@ type SystemSettings struct {
// E-mail domains for the domain modes, comma separated ("firma.de, familie.de")
RegistrationDomains string `json:"registrationDomains"`
// Set by REGISTRATION_MODE / REGISTRATION_DOMAINS (or ALLOW_REGISTRATION=true): not editable here
RegistrationConfiguredViaEnv bool `json:"registrationConfiguredViaEnv,omitempty"`
InstanceName string `json:"instanceName"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUser string `json:"smtpUser"`
RegistrationConfiguredViaEnv bool `json:"registrationConfiguredViaEnv,omitempty"`
// Self-registrations confirm their e-mail address by link first (needs SMTP and BASE_URL)
RegistrationVerifyEmail bool `json:"registrationVerifyEmail"`
// Set by REGISTRATION_VERIFY_EMAIL: not editable here
RegistrationVerifyViaEnv bool `json:"registrationVerifyConfiguredViaEnv,omitempty"`
// Mails with links to unknown people can be sent (SMTP and BASE_URL configured)
EmailLinksAvailable bool `json:"emailLinksAvailable,omitempty"`
InstanceName string `json:"instanceName"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUser string `json:"smtpUser"`
// SMTPPass is never sent to the browser (FLAW-34): reads leave it empty and set SMTPPassSet;
// writing it empty keeps the stored password
SMTPPass string `json:"smtpPass"`
+1
View File
@@ -31,6 +31,7 @@ var migrations = []migration{
{8, "Journal", (*Store).migrateJournal},
{9, "Web-Push", (*Store).migratePush},
{10, "Registrierung und Einladungslinks", (*Store).migrateRegistration},
{11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification},
}
func (s *Store) migrate() error {
+124
View File
@@ -1,6 +1,9 @@
package storage
import (
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"time"
)
@@ -125,3 +128,124 @@ func (s *Store) ApprovePendingRegistration(userID string) (bool, error) {
}
return true, nil
}
// --- E-mail verification ---
// Accounts registered without invite while verification is on stay locked until the link in the
// mail is opened. Only the SHA-256 of the token is stored (like password reset links); unconfirmed
// accounts are deleted after EmailVerificationTTL so the address is free again.
const (
EmailVerificationTTL = 48 * time.Hour
emailVerificationCooldown = 2 * time.Minute // no second mail for the same account within this time
)
var ErrVerificationInvalid = errors.New("Der Bestätigungslink ist ungültig oder abgelaufen. Bitte registriere dich erneut.")
func (s *Store) migrateEmailVerification() error {
return s.execInTx(
stmt(`CREATE TABLE IF NOT EXISTS email_verifications (
user_id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
)`),
stmt(`CREATE INDEX IF NOT EXISTS idx_email_verifications_token ON email_verifications(token_hash)`),
)
}
// CreateEmailVerification returns a new raw token for the account (the previous one stops working),
// or ErrResetTooSoon while the last mail is younger than the cooldown. The expiry of the account stays
// counted from the registration.
func (s *Store) CreateEmailVerification(userID string) (string, error) {
now := time.Now().UTC()
var created, expires string
err := s.db.QueryRow(`SELECT created_at, expires_at FROM email_verifications WHERE user_id = ?`, userID).Scan(&created, &expires)
if err == nil {
if t, perr := time.Parse(time.RFC3339, created); perr == nil && now.Sub(t) < emailVerificationCooldown {
return "", ErrResetTooSoon
}
} else {
expires = now.Add(EmailVerificationTTL).Format(time.RFC3339)
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
raw := hex.EncodeToString(buf)
_, err = s.db.Exec(`INSERT INTO email_verifications (user_id, token_hash, created_at, expires_at) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET token_hash = excluded.token_hash, created_at = excluded.created_at`,
userID, hashResetToken(raw), now.Format(time.RFC3339), expires)
return raw, err
}
// ConfirmEmailVerification checks the token, ends the verification and returns the account.
func (s *Store) ConfirmEmailVerification(raw string) (string, error) {
var userID, expires string
if err := s.db.QueryRow(`SELECT user_id, expires_at FROM email_verifications WHERE token_hash = ?`, hashResetToken(raw)).
Scan(&userID, &expires); err != nil {
return "", ErrVerificationInvalid
}
if exp, err := time.Parse(time.RFC3339, expires); err != nil || time.Now().UTC().After(exp) {
return "", ErrVerificationInvalid
}
res, err := s.db.Exec(`DELETE FROM email_verifications WHERE user_id = ? AND token_hash = ?`, userID, hashResetToken(raw))
if err != nil {
return "", err
}
if n, _ := res.RowsAffected(); n == 0 {
return "", ErrVerificationInvalid // used in parallel
}
return userID, nil
}
func (s *Store) IsEmailUnverified(userID string) bool {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM email_verifications WHERE user_id = ?`, userID).Scan(&n)
return n > 0
}
func (s *Store) ClearEmailVerification(userID string) {
_, _ = s.db.Exec(`DELETE FROM email_verifications WHERE user_id = ?`, userID)
}
// UnverifiedEmailIDs: accounts whose address is not confirmed yet.
func (s *Store) UnverifiedEmailIDs() map[string]bool {
out := map[string]bool{}
rows, err := s.db.Query(`SELECT user_id FROM email_verifications`)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
out[id] = true
}
}
return out
}
// PurgeUnverifiedAccounts deletes accounts whose confirmation link expired unused; returns how many.
func (s *Store) PurgeUnverifiedAccounts(now time.Time) int {
rows, err := s.db.Query(`SELECT user_id FROM email_verifications WHERE expires_at < ?`, now.UTC().Format(time.RFC3339))
if err != nil {
return 0
}
var ids []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
ids = append(ids, id)
}
}
_ = rows.Close()
n := 0
for _, id := range ids {
if err := s.DeleteUser(id); err == nil {
n++
}
s.ClearEmailVerification(id)
s.ClearPendingRegistration(id)
}
return n
}
+36
View File
@@ -0,0 +1,36 @@
package storage
import (
"path/filepath"
"testing"
"time"
)
// Unconfirmed accounts disappear once their link expired, so the address is free again
func TestUnverifiedAccountsArePurged(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "verify.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
u, _ := store.CreateUser("neu@example.com", "hash", "Neu", "member")
keep, _ := store.CreateUser("bleibt@example.com", "hash", "Bleibt", "member")
if _, err := store.CreateEmailVerification(u.ID); err != nil {
t.Fatal(err)
}
if _, err := store.CreateEmailVerification(u.ID); err != ErrResetTooSoon {
t.Fatalf("second mail within the cooldown must be refused, got %v", err)
}
if n := store.PurgeUnverifiedAccounts(time.Now()); n != 0 {
t.Fatalf("nothing expired yet, purged %d", n)
}
if n := store.PurgeUnverifiedAccounts(time.Now().Add(EmailVerificationTTL + time.Hour)); n != 1 {
t.Fatalf("want 1 purged, got %d", n)
}
if got, _ := store.GetUserByID(u.ID); got != nil {
t.Fatal("unconfirmed account must be gone")
}
if got, _ := store.GetUserByID(keep.ID); got == nil {
t.Fatal("other accounts stay")
}
}
+5 -1
View File
@@ -142,6 +142,8 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
settings.RegistrationMode = NormalizeRegistrationMode(v)
case "registration_domains":
settings.RegistrationDomains = v
case "registration_verify_email":
settings.RegistrationVerifyEmail = v == "true"
case "instance_name":
settings.InstanceName = v
case "smtp_host":
@@ -245,7 +247,8 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
('backup_time', ?),
('backup_retention_days', ?),
('registration_mode', ?),
('registration_domains', ?)
('registration_domains', ?),
('registration_verify_email', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
settings.SharingMode,
regStr,
@@ -264,6 +267,7 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
strconv.Itoa(settings.BackupRetentionDays),
settings.RegistrationMode,
settings.RegistrationDomains,
strconv.FormatBool(settings.RegistrationVerifyEmail),
)
return err
}