Files
scrabble-game/backend/internal/account/link.go
T
Ilia Denisov 2207ac6132 feat(account): throttle confirm-code sends per recipient
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.
2026-07-03 03:02:49 +02:00

140 lines
5.2 KiB
Go

package account
import (
"context"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// ErrIdentityTaken is returned when a platform identity being linked already
// belongs to another account; the caller turns it into a merge.
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
// RequestLinkCode issues and mails a confirm-code for email to accountID,
// replacing any prior pending code. Unlike RequestCode it never refuses up front
// (taken or already-confirmed): possession of the address is the authorization for
// a later link or merge, and the merge is only revealed once the code is verified,
// so a probe cannot learn whether an address is registered.
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
addr, err := normalizeEmail(email)
if err != nil {
return err
}
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
}
// ConfirmLink verifies code for (accountID, email) and reports the address's
// current owner. When the address is free it binds a confirmed email identity to
// accountID and returns (accountID, true, nil). When accountID already owns it,
// it returns (accountID, true, nil) unchanged. When another account owns it, it
// returns (owner, false, nil) without consuming the code, so the explicit merge
// step can re-verify the same live code. It returns the usual confirm-code errors
// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch).
func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, email, code string) (uuid.UUID, bool, error) {
addr, err := normalizeEmail(email)
if err != nil {
return uuid.Nil, false, err
}
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
if err != nil {
return uuid.Nil, false, err
}
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
if err != nil {
return uuid.Nil, false, err
}
if ok {
if owner == accountID {
return accountID, true, nil
}
return owner, false, nil
}
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
return uuid.Nil, false, err
}
return accountID, true, nil
}
// verifyPendingCode loads and checks the pending confirm-code for (accountID,
// addr), counting a wrong attempt. It returns the confirmation on success.
func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) {
conf, err := s.store.latestPendingConfirmation(ctx, accountID, addr)
if err != nil {
return emailConfirmation{}, err
}
if s.now().After(conf.expiresAt) {
return emailConfirmation{}, ErrCodeExpired
}
if conf.attempts >= emailCodeMaxAttempts {
return emailConfirmation{}, ErrTooManyAttempts
}
if hashCode(code) != conf.codeHash {
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
return emailConfirmation{}, err
}
return emailConfirmation{}, ErrCodeMismatch
}
return conf, nil
}
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
// flow.
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
acc, err := s.findByIdentity(ctx, kind, externalID)
if errors.Is(err, ErrNotFound) {
return uuid.Nil, false, nil
}
if err != nil {
return uuid.Nil, false, err
}
return acc.ID, true, nil
}
// AttachIdentity links a new (kind, externalID) identity to an existing account.
// A unique-constraint violation means the identity was taken meanwhile, surfaced
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
// to the current account during linking.
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new identity id: %w", err)
}
ins := table.Identities.INSERT(
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
table.Identities.ExternalID, table.Identities.Confirmed,
).VALUES(id, accountID, kind, externalID, confirmed)
if _, err := ins.ExecContext(ctx, s.db); err != nil {
if isUniqueViolation(err) {
return ErrIdentityTaken
}
return fmt.Errorf("account: attach identity (%s, %s): %w", kind, externalID, err)
}
return nil
}
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
// to a durable account once it gains its first identity. It is a no-op
// for an already-durable account.
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
SET(postgres.Bool(false), postgres.TimestampzT(time.Now().UTC())).
WHERE(
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Accounts.IsGuest.EQ(postgres.Bool(true))),
)
if _, err := upd.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: clear guest %s: %w", accountID, err)
}
return nil
}