061366da5a
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Two email changes, per the owner: 1. Code-only login from an installed PWA. A login requested while running as a standalone PWA now omits the one-tap confirm link from the email — the link would open in a separate browser whose minted session cannot reach the PWA, stranding the login. The code is typed in the same window instead. The client sends a `pwa` flag (isStandalone) on the email-code request (a new FBS field, threaded through the gateway); the backend omits the deeplink when it is set, reusing the existing deletion-code link-omission path. Non-PWA browser logins keep the one-tap link. No polling, no migration. 2. Security: the operator alert digest no longer embeds the admin-console (/_gm) URL. An admin link must never travel in an email, where a mail provider could cache or index it; the operator opens the console directly. Tests: an inttest asserting the PWA login email omits the link (and a browser one keeps it); a codec round-trip of the new pwa field; the alert-digest test flipped to guard the admin link is absent. Docs: ARCHITECTURE + FUNCTIONAL (+ru).
702 lines
28 KiB
Go
702 lines
28 KiB
Go
package account
|
|
|
|
import (
|
|
"context"
|
|
crand "crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"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
|
|
// linkTokenBytes is the entropy of a confirm deeplink token: 256 bits.
|
|
linkTokenBytes = 32
|
|
// emailConfirmPath is the SPA route the one-tap confirm deeplink opens (the token
|
|
// is appended). The SPA is served under /app/ behind a hash router.
|
|
emailConfirmPath = "/app/#/confirm/"
|
|
)
|
|
|
|
// Confirmation purposes recorded on a pending confirm-code row. They select what
|
|
// verifying the code or the deeplink token does: sign in (login), link/confirm the
|
|
// address on the current account (link), or replace the account's confirmed email with
|
|
// a new address (change). Account deletion adds a further purpose in a later stage.
|
|
const (
|
|
purposeLogin = "login"
|
|
purposeLink = "link"
|
|
purposeChange = "change"
|
|
purposeDelete = "delete"
|
|
)
|
|
|
|
// 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")
|
|
// ErrNoEmail is returned when an email-code step-up is requested for an account that
|
|
// holds no confirmed email (the caller must use the typed-phrase path instead).
|
|
ErrNoEmail = errors.New("account: no confirmed email")
|
|
)
|
|
|
|
// 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 and one-tap deeplink token for (accountID,
|
|
// email), replaces any prior pending confirmation, and mails the branded code in
|
|
// locale; purpose selects the email wording and what verifying does. omitLink drops the
|
|
// one-tap deeplink from the email (account deletion, or a login requested from an installed
|
|
// PWA). Only the SHA-256 hashes of the code and token are stored.
|
|
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string, omitLink bool) error {
|
|
code, codeHash, err := generateCode()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token, tokenHash, err := generateLinkToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
|
|
return err
|
|
}
|
|
// Omit the one-tap deeplink when asked: for a login from an installed PWA (the link would
|
|
// open in a separate browser whose minted session cannot reach the PWA), or for account
|
|
// deletion (a prefetch or stray click must not delete an account — the delete code is entered
|
|
// in the app only, and ConfirmByToken refuses a delete token).
|
|
deeplink := s.confirmURL(token, locale)
|
|
if purpose == purposeDelete || omitLink {
|
|
deeplink = ""
|
|
}
|
|
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
msg.To = email
|
|
return s.mailer.Send(ctx, msg)
|
|
}
|
|
|
|
// confirmURL builds the absolute one-tap confirm deeplink for token in locale, or ""
|
|
// when no public base URL is configured. The locale rides the fragment as ?lang so the
|
|
// confirm screen (opened in a browser with no session) renders in the email's language.
|
|
func (s *EmailService) confirmURL(token, locale string) string {
|
|
if s.baseURL == "" {
|
|
return ""
|
|
}
|
|
return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + "?lang=" + normalizeLocale(locale)
|
|
}
|
|
|
|
// 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), false)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// Binding the first confirmed email promotes a guest to a durable account, matching the
|
|
// link and deeplink flows (defence-in-depth: no confirmed-email path leaves is_guest set).
|
|
if err := s.store.ClearGuest(ctx, accountID); 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. When pwa is set (the request came from an installed PWA) the login email omits the
|
|
// one-tap confirm link — it would open in a separate browser, out of the PWA's reach — so the
|
|
// code is entered in the same window. It returns the target account id for the subsequent
|
|
// LoginWithCode.
|
|
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string, pwa bool) (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, pwa); 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)
|
|
}
|
|
|
|
// LinkConfirmation is the outcome of confirming a one-tap deeplink token: what the
|
|
// transport layer must finish. Purpose is the pending row's purpose. For a login,
|
|
// Account is the account to sign in. For a link, Account is the account the email was
|
|
// (or would be) attached to; NeedsMerge is set when another account (MergeOwner)
|
|
// already owns the address, so the caller drives the interactive merge instead of a
|
|
// plain link — the token is left unconsumed for that merge step.
|
|
type LinkConfirmation struct {
|
|
Purpose string
|
|
Account uuid.UUID
|
|
NeedsMerge bool
|
|
MergeOwner uuid.UUID
|
|
}
|
|
|
|
// IsLogin reports whether the confirmation is a login (the caller mints a session)
|
|
// rather than a link (attach the identity, or drive a merge).
|
|
func (r LinkConfirmation) IsLogin() bool { return r.Purpose == purposeLogin }
|
|
|
|
// ConfirmByToken verifies a one-tap deeplink token and performs its purpose. A login
|
|
// confirms the email identity, clears the guest flag and returns the account to sign
|
|
// in. A link attaches the confirmed email to the pending account when the address is
|
|
// free, or reports NeedsMerge when another account already owns it (leaving the token
|
|
// live so the caller's merge step can re-verify). It returns ErrNoPendingCode when the
|
|
// token matches no live confirmation and ErrCodeExpired when it has lapsed. The token
|
|
// is high-entropy, so there is no wrong-attempt counter.
|
|
func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkConfirmation, error) {
|
|
pend, err := s.store.pendingByTokenHash(ctx, hashCode(token))
|
|
if err != nil {
|
|
return LinkConfirmation{}, err
|
|
}
|
|
if s.now().After(pend.expiresAt) {
|
|
return LinkConfirmation{}, ErrCodeExpired
|
|
}
|
|
switch pend.purpose {
|
|
case purposeLogin:
|
|
if err := s.store.confirmEmailLogin(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
|
|
return LinkConfirmation{}, err
|
|
}
|
|
if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
|
|
return LinkConfirmation{}, err
|
|
}
|
|
return LinkConfirmation{Purpose: purposeLogin, Account: pend.accountID}, nil
|
|
case purposeLink:
|
|
owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
|
|
if err != nil {
|
|
return LinkConfirmation{}, err
|
|
}
|
|
if ok {
|
|
if owner == pend.accountID {
|
|
if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
|
|
return LinkConfirmation{}, err
|
|
}
|
|
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
|
|
}
|
|
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID, NeedsMerge: true, MergeOwner: owner}, nil
|
|
}
|
|
if err := s.store.confirmEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
|
|
return LinkConfirmation{}, err
|
|
}
|
|
// Binding the first email promotes a guest to a durable account, matching the
|
|
// code-based link flow (which clears the guest flag in the link service).
|
|
if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
|
|
return LinkConfirmation{}, err
|
|
}
|
|
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
|
|
case purposeChange:
|
|
owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
|
|
if err != nil {
|
|
return LinkConfirmation{}, err
|
|
}
|
|
if ok && owner != pend.accountID {
|
|
// The new address is confirmed by a different account: refuse without
|
|
// disclosing it (anti-enumeration). Unlike a link, a change never merges.
|
|
return LinkConfirmation{}, ErrEmailTaken
|
|
}
|
|
if ok && owner == pend.accountID {
|
|
if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
|
|
return LinkConfirmation{}, err
|
|
}
|
|
return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
|
|
}
|
|
if err := s.store.replaceEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
|
|
return LinkConfirmation{}, err
|
|
}
|
|
return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
|
|
case purposeDelete:
|
|
// Deletion is confirmed in the app with the code, never via a one-tap link.
|
|
return LinkConfirmation{}, fmt.Errorf("account: deletion cannot be confirmed by link")
|
|
default:
|
|
return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// confirmedEmailOf returns the account's confirmed email address and true, or ("", false)
|
|
// when it holds none. It backs the deletion step-up, which mails a code to the account's
|
|
// own address.
|
|
func (s *Store) confirmedEmailOf(ctx context.Context, accountID uuid.UUID) (string, bool, error) {
|
|
stmt := postgres.SELECT(table.Identities.ExternalID).
|
|
FROM(table.Identities).
|
|
WHERE(
|
|
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
|
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))).
|
|
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 "", false, nil
|
|
}
|
|
return "", false, fmt.Errorf("account: confirmed email of %s: %w", accountID, err)
|
|
}
|
|
return row.ExternalID, 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, linkTokenHash, purpose 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,
|
|
table.EmailConfirmations.LinkTokenHash, table.EmailConfirmations.Purpose,
|
|
).VALUES(id, accountID, email, codeHash, expiresAt, linkTokenHash, purpose)
|
|
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
|
|
}
|
|
|
|
// pendingConfirmation is a pending confirm-code row loaded by its deeplink token, in
|
|
// domain form.
|
|
type pendingConfirmation struct {
|
|
id uuid.UUID
|
|
accountID uuid.UUID
|
|
email string
|
|
purpose string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// pendingByTokenHash loads the unconsumed confirmation whose deeplink token hashes to
|
|
// tokenHash, or ErrNoPendingCode. The high-entropy token needs no attempt counter, so
|
|
// a partial-unique index guarantees at most one match.
|
|
func (s *Store) pendingByTokenHash(ctx context.Context, tokenHash string) (pendingConfirmation, error) {
|
|
stmt := postgres.SELECT(table.EmailConfirmations.AllColumns).
|
|
FROM(table.EmailConfirmations).
|
|
WHERE(
|
|
table.EmailConfirmations.LinkTokenHash.EQ(postgres.String(tokenHash)).
|
|
AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
|
|
).LIMIT(1)
|
|
var row model.EmailConfirmations
|
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
|
if errors.Is(err, qrm.ErrNoRows) {
|
|
return pendingConfirmation{}, ErrNoPendingCode
|
|
}
|
|
return pendingConfirmation{}, fmt.Errorf("account: load confirmation by token: %w", err)
|
|
}
|
|
return pendingConfirmation{
|
|
id: row.ConfirmationID,
|
|
accountID: row.AccountID,
|
|
email: row.Email,
|
|
purpose: row.Purpose,
|
|
expiresAt: row.ExpiresAt,
|
|
}, nil
|
|
}
|
|
|
|
// consumeConfirmation marks a confirmation consumed without writing an identity, used
|
|
// for the idempotent already-linked deeplink path.
|
|
func (s *Store) consumeConfirmation(ctx context.Context, id uuid.UUID, now time.Time) error {
|
|
upd := table.EmailConfirmations.UPDATE(table.EmailConfirmations.ConsumedAt).
|
|
SET(postgres.TimestampzT(now)).
|
|
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(id)))
|
|
if _, err := upd.ExecContext(ctx, s.db); err != nil {
|
|
return fmt.Errorf("account: consume confirmation: %w", err)
|
|
}
|
|
return 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
|
|
}
|
|
|
|
// replaceEmailIdentity consumes the confirmation, deletes the account's existing email
|
|
// identity (freeing the old address) and inserts newEmail as its confirmed email, inside
|
|
// one transaction. It backs the change-email flow. A unique-constraint violation — the
|
|
// new address was confirmed elsewhere in the meantime — surfaces as ErrEmailTaken. When
|
|
// the account holds no email identity yet the delete is a no-op, so this doubles as an
|
|
// attach.
|
|
func (s *Store) replaceEmailIdentity(ctx context.Context, confirmationID, accountID uuid.UUID, newEmail 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)
|
|
}
|
|
// Journal the outgoing email before replacing it, so the legal dossier keeps the
|
|
// address the account used to hold (see retention.go).
|
|
var old model.Identities
|
|
sel := postgres.SELECT(
|
|
table.Identities.ExternalID, table.Identities.Confirmed, table.Identities.CreatedAt,
|
|
).FROM(table.Identities).WHERE(
|
|
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
|
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
|
|
).LIMIT(1)
|
|
switch err := sel.QueryContext(ctx, tx, &old); {
|
|
case err == nil:
|
|
if err := retainIdentityTx(ctx, tx, accountID, KindEmail, old.ExternalID, old.Confirmed, old.CreatedAt, retainChange); err != nil {
|
|
return err
|
|
}
|
|
case errors.Is(err, qrm.ErrNoRows):
|
|
// No prior email (this doubles as an attach); nothing to retain.
|
|
default:
|
|
return fmt.Errorf("load outgoing email identity: %w", err)
|
|
}
|
|
del := table.Identities.DELETE().WHERE(
|
|
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
|
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
|
|
)
|
|
if _, err := del.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("delete old email identity: %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, newEmail, 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: replace 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
|
|
}
|
|
|
|
// generateLinkToken returns a fresh opaque one-tap confirm deeplink token (URL-safe
|
|
// base64, 256-bit) and its hex SHA-256 hash. Only the hash is stored; the token
|
|
// travels only in the emailed link, mirroring the session-token model.
|
|
func generateLinkToken() (token, hash string, err error) {
|
|
buf := make([]byte, linkTokenBytes)
|
|
if _, err := crand.Read(buf); err != nil {
|
|
return "", "", fmt.Errorf("account: generate link token: %w", err)
|
|
}
|
|
token = base64.RawURLEncoding.EncodeToString(buf)
|
|
return token, hashCode(token), 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[:])
|
|
}
|