|
|
|
@@ -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))
|
|
|
|
|