diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index e4df71f..23c87b3 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -5,6 +5,7 @@ import ( crand "crypto/rand" "crypto/sha256" "database/sql" + "encoding/base64" "encoding/hex" "errors" "fmt" @@ -26,6 +27,11 @@ const ( 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 @@ -92,18 +98,23 @@ 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. +// 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. 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) 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 { + token, tokenHash, err := generateLinkToken() + if err != nil { return err } - msg, err := renderConfirmationEmail(purpose, code, "", s.baseURL, locale) + if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil { + return err + } + msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token), s.baseURL, locale) if err != nil { return err } @@ -111,6 +122,15 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email return s.mailer.Send(ctx, msg) } +// confirmURL builds the absolute one-tap confirm deeplink for token, or "" when no +// public base URL is configured. +func (s *EmailService) confirmURL(token string) string { + if s.baseURL == "" { + return "" + } + return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token +} + // 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 { @@ -241,6 +261,66 @@ func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (A 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 +} + +// 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 + } + return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil + 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 @@ -271,7 +351,7 @@ func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.U // 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 { +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) @@ -288,7 +368,8 @@ func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.U 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) + 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) } @@ -321,6 +402,54 @@ func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UU }, 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. @@ -414,6 +543,18 @@ func generateCode() (code, hash string, err error) { 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)) diff --git a/backend/internal/postgres/jet/backend/model/email_confirmations.go b/backend/internal/postgres/jet/backend/model/email_confirmations.go index d0bf3c9..4ba4a0a 100644 --- a/backend/internal/postgres/jet/backend/model/email_confirmations.go +++ b/backend/internal/postgres/jet/backend/model/email_confirmations.go @@ -21,4 +21,6 @@ type EmailConfirmations struct { Attempts int16 ConsumedAt *time.Time CreatedAt time.Time + Purpose string + LinkTokenHash *string } diff --git a/backend/internal/postgres/jet/backend/table/email_confirmations.go b/backend/internal/postgres/jet/backend/table/email_confirmations.go index 0ddfd0c..35ea754 100644 --- a/backend/internal/postgres/jet/backend/table/email_confirmations.go +++ b/backend/internal/postgres/jet/backend/table/email_confirmations.go @@ -25,6 +25,8 @@ type emailConfirmationsTable struct { Attempts postgres.ColumnInteger ConsumedAt postgres.ColumnTimestampz CreatedAt postgres.ColumnTimestampz + Purpose postgres.ColumnString + LinkTokenHash postgres.ColumnString AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -74,9 +76,11 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo AttemptsColumn = postgres.IntegerColumn("attempts") ConsumedAtColumn = postgres.TimestampzColumn("consumed_at") CreatedAtColumn = postgres.TimestampzColumn("created_at") - allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn} - mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn} - defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn} + PurposeColumn = postgres.StringColumn("purpose") + LinkTokenHashColumn = postgres.StringColumn("link_token_hash") + allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn, PurposeColumn, LinkTokenHashColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn, PurposeColumn, LinkTokenHashColumn} + defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn, PurposeColumn} ) return emailConfirmationsTable{ @@ -91,6 +95,8 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo Attempts: AttemptsColumn, ConsumedAt: ConsumedAtColumn, CreatedAt: CreatedAtColumn, + Purpose: PurposeColumn, + LinkTokenHash: LinkTokenHashColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/migrations/00006_email_confirm_deeplink.sql b/backend/internal/postgres/migrations/00006_email_confirm_deeplink.sql new file mode 100644 index 0000000..2212f6c --- /dev/null +++ b/backend/internal/postgres/migrations/00006_email_confirm_deeplink.sql @@ -0,0 +1,27 @@ +-- Add the confirm deeplink token and the confirmation purpose to email_confirmations. +-- purpose tags what verifying the code/token does (email login, identity link, email +-- change, or account deletion); link_token_hash holds the SHA-256 hex of the one-click +-- deeplink token (only the hash is stored, mirroring the session-token model). +-- Expand-contract: both columns are additive. purpose gets a NOT NULL DEFAULT so +-- existing rows fill in; link_token_hash is nullable with a partial-unique index. A +-- backend image rollback stays DB-safe — older code simply ignores the new columns. The +-- table shape changes, so the generated go-jet model for this table is regenerated. + +-- +goose Up +ALTER TABLE backend.email_confirmations + ADD COLUMN purpose text NOT NULL DEFAULT 'link', + ADD COLUMN link_token_hash text; +ALTER TABLE backend.email_confirmations + ADD CONSTRAINT email_confirmations_purpose_chk + CHECK ((purpose = ANY (ARRAY['login'::text, 'link'::text, 'change'::text, 'delete'::text]))); +CREATE UNIQUE INDEX email_confirmations_link_token_hash_key + ON backend.email_confirmations (link_token_hash) + WHERE link_token_hash IS NOT NULL; + +-- +goose Down +DROP INDEX IF EXISTS backend.email_confirmations_link_token_hash_key; +ALTER TABLE backend.email_confirmations + DROP CONSTRAINT IF EXISTS email_confirmations_purpose_chk; +ALTER TABLE backend.email_confirmations + DROP COLUMN IF EXISTS link_token_hash, + DROP COLUMN IF EXISTS purpose;