7a48327ab6
- PLAN.md: new Stage 8 (UI social/account/history); Telegram->9, Admin->10, Linking->11, Polish->12; tracker + Stage 7 refinements; split the Stage 6 'wired in Stage 7' note between 7 and 8 - ARCHITECTURE: promote ui to current (slice scope, board-replay, codegen, theming, mock) - FUNCTIONAL(+ru): client-app section with the Stage 7/8 split - README + ui/README + CLAUDE.md: UI build/run/test, codegen, pnpm notes - bumped Stage 8-11 refs (+1) across docs and code comments
373 lines
14 KiB
Go
373 lines
14 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
|
|
)
|
|
|
|
// 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 11 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 11 — 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[:])
|
|
}
|