2207ac6132
Add an in-memory SendLimiter enforcing a one-per-minute cooldown and a five-per-rolling-hour cap per recipient address, checked before provisioning or sending in RequestCode, RequestLoginCode and RequestLinkCode. It guards against email bombing and protects the relay quota. The limiter is injected in main (nil in tests, so the domain suite is unaffected); ErrTooManyRequests maps to HTTP 429.
422 lines
16 KiB
Go
422 lines
16 KiB
Go
package account
|
|
|
|
import (
|
|
"context"
|
|
crand "crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"net/mail"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-jet/jet/v2/postgres"
|
|
"github.com/go-jet/jet/v2/qrm"
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
|
)
|
|
|
|
const (
|
|
// emailCodeTTL bounds how long an issued confirm-code stays valid.
|
|
emailCodeTTL = 15 * time.Minute
|
|
// emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
|
|
emailCodeMaxAttempts = 5
|
|
)
|
|
|
|
// Confirmation purposes recorded on a pending confirm-code row. They select what
|
|
// verifying the code or the deeplink token does: sign in (login) or link/confirm the
|
|
// address on the current account (link). Email change and account deletion add
|
|
// further purposes in later stages.
|
|
const (
|
|
purposeLogin = "login"
|
|
purposeLink = "link"
|
|
)
|
|
|
|
// Errors returned by the email confirm-code flow.
|
|
var (
|
|
// ErrInvalidEmail is returned for an unparseable email address.
|
|
ErrInvalidEmail = errors.New("account: invalid email address")
|
|
// ErrEmailTaken is returned when the email is already confirmed by another
|
|
// account; binding it would be a merge, which the link/merge flow owns.
|
|
ErrEmailTaken = errors.New("account: email already confirmed by another account")
|
|
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
|
|
// requesting account.
|
|
ErrAlreadyConfirmed = errors.New("account: email already confirmed for this account")
|
|
// ErrNoPendingCode is returned when no live confirm-code exists to verify.
|
|
ErrNoPendingCode = errors.New("account: no pending confirmation code")
|
|
// ErrCodeExpired is returned when the confirm-code has passed its TTL.
|
|
ErrCodeExpired = errors.New("account: confirmation code expired")
|
|
// ErrTooManyAttempts is returned when the code is locked after too many tries.
|
|
ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
|
|
// ErrCodeMismatch is returned when the submitted code does not match.
|
|
ErrCodeMismatch = errors.New("account: confirmation code does not match")
|
|
// ErrTooManyRequests is returned when confirm-code sends to an address are being
|
|
// requested too frequently (the resend cooldown or the rolling-hour cap).
|
|
ErrTooManyRequests = errors.New("account: too many code requests")
|
|
)
|
|
|
|
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a
|
|
// Mailer and verifies it, binding a confirmed email identity to the requesting
|
|
// account. Only the SHA-256 hash of a code is stored (never the plaintext),
|
|
// matching the session model. Binding an email already confirmed by a different
|
|
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
|
|
// and using an email as a login reuses this mechanism.
|
|
type EmailService struct {
|
|
store *Store
|
|
mailer Mailer
|
|
baseURL string
|
|
limiter *SendLimiter
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewEmailService constructs an EmailService over store, sending via mailer. baseURL
|
|
// is the canonical public origin (scheme + host) used to build the one-tap confirm
|
|
// deeplink and the email footer landing link; an empty baseURL omits the deeplink
|
|
// (development / log mailer).
|
|
func NewEmailService(store *Store, mailer Mailer, baseURL string) *EmailService {
|
|
return &EmailService{store: store, mailer: mailer, baseURL: baseURL, now: func() time.Time { return time.Now().UTC() }}
|
|
}
|
|
|
|
// SetSendLimiter installs a per-recipient send throttle. When unset (nil), sends are
|
|
// not throttled — production wires a limiter; tests leave it off.
|
|
func (s *EmailService) SetSendLimiter(l *SendLimiter) { s.limiter = l }
|
|
|
|
// allowSend reports whether a confirm-code send to email is permitted now, recording
|
|
// it when so. A nil limiter permits every send.
|
|
func (s *EmailService) allowSend(email string) bool {
|
|
return s.limiter == nil || s.limiter.Allow(email)
|
|
}
|
|
|
|
// issueCode generates a fresh confirm-code for (accountID, email), replaces any prior
|
|
// pending confirmation, and mails the branded code in locale; purpose selects the
|
|
// email wording. Only the SHA-256 hash of the code is stored.
|
|
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string) error {
|
|
code, codeHash, err := generateCode()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, s.now().Add(emailCodeTTL)); err != nil {
|
|
return err
|
|
}
|
|
msg, err := renderConfirmationEmail(purpose, code, "", s.baseURL, locale)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
msg.To = email
|
|
return s.mailer.Send(ctx, msg)
|
|
}
|
|
|
|
// accountLocale returns the account's preferred UI language for localising email,
|
|
// defaulting to "en" when the account cannot be loaded.
|
|
func (s *EmailService) accountLocale(ctx context.Context, accountID uuid.UUID) string {
|
|
acc, err := s.store.GetByID(ctx, accountID)
|
|
if err != nil {
|
|
return "en"
|
|
}
|
|
return acc.PreferredLanguage
|
|
}
|
|
|
|
// RequestCode issues a fresh confirm-code for email to accountID and mails it,
|
|
// replacing any prior pending code for the same account and address. It returns
|
|
// ErrInvalidEmail, ErrEmailTaken or ErrAlreadyConfirmed without sending.
|
|
func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, email string) error {
|
|
addr, err := normalizeEmail(email)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !s.allowSend(addr) {
|
|
return ErrTooManyRequests
|
|
}
|
|
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ok {
|
|
if owner == accountID {
|
|
return ErrAlreadyConfirmed
|
|
}
|
|
return ErrEmailTaken
|
|
}
|
|
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
|
|
}
|
|
|
|
// ConfirmCode verifies code for accountID and email. On success it attaches a
|
|
// confirmed email identity and returns the account. It returns ErrNoPendingCode,
|
|
// ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch (counting the attempt), or
|
|
// ErrEmailTaken if the address was confirmed elsewhere in the meantime.
|
|
func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, email, code string) (Account, error) {
|
|
addr, err := normalizeEmail(email)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
conf, err := s.store.latestPendingConfirmation(ctx, accountID, addr)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
if s.now().After(conf.expiresAt) {
|
|
return Account{}, ErrCodeExpired
|
|
}
|
|
if conf.attempts >= emailCodeMaxAttempts {
|
|
return Account{}, ErrTooManyAttempts
|
|
}
|
|
if hashCode(code) != conf.codeHash {
|
|
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
|
|
return Account{}, err
|
|
}
|
|
return Account{}, ErrCodeMismatch
|
|
}
|
|
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
|
|
return Account{}, err
|
|
}
|
|
return s.store.GetByID(ctx, accountID)
|
|
}
|
|
|
|
// RequestLoginCode issues a login confirm-code to the account that owns email,
|
|
// provisioning a fresh (unconfirmed) guest account when the email is new — it becomes
|
|
// durable once the code is confirmed. It is the unauthenticated email-login entry
|
|
// point and, unlike RequestCode, does not refuse an already-confirmed email — that is
|
|
// the ordinary returning-user login. The code is mailed to the address, so only its
|
|
// real owner can complete the login. On first contact browserTZ (the client's
|
|
// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
|
|
// language. It returns the target account id for the subsequent LoginWithCode.
|
|
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) {
|
|
addr, err := normalizeEmail(email)
|
|
if err != nil {
|
|
return uuid.UUID{}, err
|
|
}
|
|
if !s.allowSend(addr) {
|
|
return uuid.UUID{}, ErrTooManyRequests
|
|
}
|
|
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ, language)
|
|
if err != nil {
|
|
return uuid.UUID{}, err
|
|
}
|
|
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language); err != nil {
|
|
return uuid.UUID{}, err
|
|
}
|
|
return acc.ID, nil
|
|
}
|
|
|
|
// LoginWithCode verifies a login code for email and returns the owning account,
|
|
// marking the email identity confirmed on first success (idempotent for a
|
|
// returning user). It mirrors ConfirmCode's checks but updates the existing
|
|
// identity rather than inserting one, since RequestLoginCode already provisioned
|
|
// it. It returns ErrNotFound when no account owns the email.
|
|
func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (Account, error) {
|
|
addr, err := normalizeEmail(email)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
acc, err := s.store.findByIdentity(ctx, KindEmail, addr)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
conf, err := s.store.latestPendingConfirmation(ctx, acc.ID, addr)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
if s.now().After(conf.expiresAt) {
|
|
return Account{}, ErrCodeExpired
|
|
}
|
|
if conf.attempts >= emailCodeMaxAttempts {
|
|
return Account{}, ErrTooManyAttempts
|
|
}
|
|
if hashCode(code) != conf.codeHash {
|
|
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
|
|
return Account{}, err
|
|
}
|
|
return Account{}, ErrCodeMismatch
|
|
}
|
|
if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil {
|
|
return Account{}, err
|
|
}
|
|
if err := s.store.ClearGuest(ctx, acc.ID); err != nil {
|
|
return Account{}, err
|
|
}
|
|
return s.store.GetByID(ctx, acc.ID)
|
|
}
|
|
|
|
// emailConfirmation is a pending confirm-code row in domain form.
|
|
type emailConfirmation struct {
|
|
id uuid.UUID
|
|
codeHash string
|
|
expiresAt time.Time
|
|
attempts int
|
|
}
|
|
|
|
// confirmedEmailAccount returns the account that holds a confirmed email identity
|
|
// for email and true, or (zero, false) when none does.
|
|
func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.UUID, bool, error) {
|
|
stmt := postgres.SELECT(table.Identities.AccountID).
|
|
FROM(table.Identities).
|
|
WHERE(
|
|
table.Identities.Kind.EQ(postgres.String(KindEmail)).
|
|
AND(table.Identities.ExternalID.EQ(postgres.String(email))).
|
|
AND(table.Identities.Confirmed.EQ(postgres.Bool(true))),
|
|
).LIMIT(1)
|
|
var row model.Identities
|
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
|
if errors.Is(err, qrm.ErrNoRows) {
|
|
return uuid.UUID{}, false, nil
|
|
}
|
|
return uuid.UUID{}, false, fmt.Errorf("account: confirmed email owner %s: %w", email, err)
|
|
}
|
|
return row.AccountID, true, nil
|
|
}
|
|
|
|
// replacePendingConfirmation clears any pending code for (accountID, email) and
|
|
// inserts a fresh one, inside one transaction.
|
|
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash string, expiresAt time.Time) error {
|
|
id, err := uuid.NewV7()
|
|
if err != nil {
|
|
return fmt.Errorf("account: new confirmation id: %w", err)
|
|
}
|
|
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
|
del := table.EmailConfirmations.DELETE().WHERE(
|
|
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)).
|
|
AND(table.EmailConfirmations.Email.EQ(postgres.String(email))).
|
|
AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
|
|
)
|
|
if _, err := del.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("clear pending confirmations: %w", err)
|
|
}
|
|
ins := table.EmailConfirmations.INSERT(
|
|
table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
|
|
table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt,
|
|
).VALUES(id, accountID, email, codeHash, expiresAt)
|
|
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("insert confirmation: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// latestPendingConfirmation loads the newest unconsumed confirm-code for
|
|
// (accountID, email), or ErrNoPendingCode.
|
|
func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UUID, email string) (emailConfirmation, error) {
|
|
stmt := postgres.SELECT(table.EmailConfirmations.AllColumns).
|
|
FROM(table.EmailConfirmations).
|
|
WHERE(
|
|
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)).
|
|
AND(table.EmailConfirmations.Email.EQ(postgres.String(email))).
|
|
AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
|
|
).ORDER_BY(table.EmailConfirmations.CreatedAt.DESC()).LIMIT(1)
|
|
var row model.EmailConfirmations
|
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
|
if errors.Is(err, qrm.ErrNoRows) {
|
|
return emailConfirmation{}, ErrNoPendingCode
|
|
}
|
|
return emailConfirmation{}, fmt.Errorf("account: load confirmation: %w", err)
|
|
}
|
|
return emailConfirmation{
|
|
id: row.ConfirmationID,
|
|
codeHash: row.CodeHash,
|
|
expiresAt: row.ExpiresAt,
|
|
attempts: int(row.Attempts),
|
|
}, nil
|
|
}
|
|
|
|
// bumpConfirmationAttempts increments a code's wrong-attempt counter by one.
|
|
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
|
|
stmt := table.EmailConfirmations.
|
|
UPDATE(table.EmailConfirmations.Attempts).
|
|
SET(table.EmailConfirmations.Attempts.ADD(postgres.Int(1))).
|
|
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(id)))
|
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
|
return fmt.Errorf("account: bump confirmation attempts: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// confirmEmailIdentity consumes the code and inserts a confirmed email identity,
|
|
// inside one transaction. A unique-constraint violation means the address was
|
|
// confirmed by another account first, surfaced as ErrEmailTaken.
|
|
func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accountID uuid.UUID, email string, now time.Time) error {
|
|
identityID, err := uuid.NewV7()
|
|
if err != nil {
|
|
return fmt.Errorf("account: new identity id: %w", err)
|
|
}
|
|
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
|
upd := table.EmailConfirmations.
|
|
UPDATE(table.EmailConfirmations.ConsumedAt).
|
|
SET(postgres.TimestampzT(now)).
|
|
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
|
|
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("consume confirmation: %w", err)
|
|
}
|
|
ins := table.Identities.INSERT(
|
|
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
|
|
table.Identities.ExternalID, table.Identities.Confirmed,
|
|
).VALUES(identityID, accountID, KindEmail, email, true)
|
|
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return ErrEmailTaken
|
|
}
|
|
return fmt.Errorf("account: confirm email identity: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// confirmEmailLogin consumes the login code and marks the existing email
|
|
// identity confirmed, inside one transaction. The identity already exists (a
|
|
// login provisioned it), so this updates rather than inserts and is idempotent
|
|
// for a returning user whose identity is already confirmed.
|
|
func (s *Store) confirmEmailLogin(ctx context.Context, confirmationID, accountID uuid.UUID, email string, now time.Time) error {
|
|
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
|
upd := table.EmailConfirmations.
|
|
UPDATE(table.EmailConfirmations.ConsumedAt).
|
|
SET(postgres.TimestampzT(now)).
|
|
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
|
|
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("consume login code: %w", err)
|
|
}
|
|
confirm := table.Identities.
|
|
UPDATE(table.Identities.Confirmed).
|
|
SET(postgres.Bool(true)).
|
|
WHERE(
|
|
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
|
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))).
|
|
AND(table.Identities.ExternalID.EQ(postgres.String(email))),
|
|
)
|
|
if _, err := confirm.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("confirm email identity: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// normalizeEmail parses and lower-cases an email address, or returns ErrInvalidEmail.
|
|
func normalizeEmail(email string) (string, error) {
|
|
addr, err := mail.ParseAddress(strings.TrimSpace(email))
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %q", ErrInvalidEmail, email)
|
|
}
|
|
return strings.ToLower(addr.Address), nil
|
|
}
|
|
|
|
// generateCode returns a random 6-digit code and its SHA-256 hex hash.
|
|
func generateCode() (code, hash string, err error) {
|
|
n, err := crand.Int(crand.Reader, big.NewInt(1_000_000))
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("account: generate code: %w", err)
|
|
}
|
|
code = fmt.Sprintf("%06d", n.Int64())
|
|
return code, hashCode(code), nil
|
|
}
|
|
|
|
// hashCode returns the hex-encoded SHA-256 of a confirm-code.
|
|
func hashCode(code string) string {
|
|
sum := sha256.Sum256([]byte(code))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|