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 ) // 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 Stage 10 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") ) // 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 Stage 10 — and // using an email as a login is Stage 6, which reuses this mechanism. type EmailService struct { store *Store mailer Mailer now func() time.Time } // NewEmailService constructs an EmailService over store, sending via mailer. func NewEmailService(store *Store, mailer Mailer) *EmailService { return &EmailService{store: store, mailer: mailer, now: func() time.Time { return time.Now().UTC() }} } // 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 } owner, ok, err := s.store.confirmedEmailAccount(ctx, addr) if err != nil { return err } if ok { if owner == accountID { return ErrAlreadyConfirmed } return ErrEmailTaken } code, hash, err := generateCode() if err != nil { return err } if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil { return err } subject := "Your Scrabble confirmation code" body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute)) return s.mailer.Send(ctx, addr, subject, body) } // 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) durable account when the email is new. It is // the unauthenticated email-login entry point (Stage 6) 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. It returns the target account id for the subsequent LoginWithCode. func (s *EmailService) RequestLoginCode(ctx context.Context, email string) (uuid.UUID, error) { addr, err := normalizeEmail(email) if err != nil { return uuid.UUID{}, err } acc, err := s.store.ProvisionByIdentity(ctx, KindEmail, addr) if err != nil { return uuid.UUID{}, err } code, hash, err := generateCode() if err != nil { return uuid.UUID{}, err } if err := s.store.replacePendingConfirmation(ctx, acc.ID, addr, hash, s.now().Add(emailCodeTTL)); err != nil { return uuid.UUID{}, err } subject := "Your Scrabble login code" body := fmt.Sprintf("Your login code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute)) if err := s.mailer.Send(ctx, addr, subject, body); 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 } 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[:]) }