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

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