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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user