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

+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
}