feat(account): unlink provider + change-email edges (backend + gateway)
Unlink: POST /user/link/unlink (telegram|vk) via account.RemoveIdentity, refusing
the last identity; email is never unlinked. New fbs LinkUnlinkRequest + gateway
link.unlink op, returning the refreshed profile.
Change-email: purposeChange confirm-codes (RequestChangeCode/ConfirmChange) that
atomically replace the account's confirmed email (account.replaceEmailIdentity);
a new address owned by another account is refused without disclosure, never merged.
The one-tap deeplink handles purposeChange too. Reuses the LinkEmail* fbs tables;
gateway link.email.change.{request,confirm} ops + backendclient methods; branded
ru/en change-email copy.
This commit is contained in:
@@ -35,12 +35,13 @@ const (
|
||||
)
|
||||
|
||||
// Confirmation purposes recorded on a pending confirm-code row. They select what
|
||||
// verifying the code or the deeplink token does: sign in (login) or link/confirm the
|
||||
// address on the current account (link). Email change and account deletion add
|
||||
// further purposes in later stages.
|
||||
// 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"
|
||||
purposeLogin = "login"
|
||||
purposeLink = "link"
|
||||
purposeChange = "change"
|
||||
)
|
||||
|
||||
// Errors returned by the email confirm-code flow.
|
||||
@@ -326,6 +327,26 @@ func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkCo
|
||||
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
|
||||
default:
|
||||
return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose)
|
||||
}
|
||||
@@ -506,6 +527,50 @@ func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accoun
|
||||
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)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -80,6 +80,24 @@ var confirmEmailCopy = map[string]map[string]emailCopy{
|
||||
FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.",
|
||||
},
|
||||
},
|
||||
purposeChange: {
|
||||
"en": {
|
||||
Subject: "Confirm your new Erudit e-mail",
|
||||
Preheader: "Confirm your new address",
|
||||
Heading: "Confirm your new e-mail",
|
||||
Intro: "Enter this code to switch your account to this address:",
|
||||
CTALabel: "Confirm with one tap",
|
||||
FooterIgnore: "If you didn't request this change, you can safely ignore it — your address stays the same.",
|
||||
},
|
||||
"ru": {
|
||||
Subject: "Подтвердите новый e-mail в Эрудит",
|
||||
Preheader: "Подтвердите новый адрес",
|
||||
Heading: "Смена e-mail",
|
||||
Intro: "Введите этот код, чтобы привязать аккаунт к новому адресу:",
|
||||
CTALabel: "Подтвердить одним нажатием",
|
||||
FooterIgnore: "Если вы не запрашивали смену, просто проигнорируйте письмо — адрес останется прежним.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// emailBrand is the brand wordmark per locale.
|
||||
|
||||
@@ -119,6 +119,57 @@ func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, ema
|
||||
return accountID, true, nil
|
||||
}
|
||||
|
||||
// RequestChangeCode issues and mails a confirm-code to newEmail for an authenticated
|
||||
// email change on accountID, replacing any prior pending code. Like RequestLinkCode it
|
||||
// never refuses up front on "taken" (anti-enumeration): possession of newEmail is the
|
||||
// authorization, and a conflict with another account is revealed only at confirm — as a
|
||||
// non-disclosing refusal, never a merge.
|
||||
func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUID, newEmail string) error {
|
||||
addr, err := normalizeEmail(newEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.allowSend(addr) {
|
||||
return ErrTooManyRequests
|
||||
}
|
||||
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID))
|
||||
}
|
||||
|
||||
// ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the
|
||||
// account's confirmed email with newEmail, freeing the old address. When newEmail is
|
||||
// already confirmed by another account it refuses with ErrEmailTaken (surfaced to the
|
||||
// user as a non-disclosing "check the address or contact support"), never merging; when
|
||||
// the account already owns newEmail it is an idempotent no-op. It returns the usual
|
||||
// confirm-code errors (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts,
|
||||
// ErrCodeMismatch) and the updated account on success.
|
||||
func (s *EmailService) ConfirmChange(ctx context.Context, accountID uuid.UUID, newEmail, code string) (Account, error) {
|
||||
addr, err := normalizeEmail(newEmail)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if ok && owner != accountID {
|
||||
return Account{}, ErrEmailTaken
|
||||
}
|
||||
if ok && owner == accountID {
|
||||
if err := s.store.consumeConfirmation(ctx, conf.id, s.now()); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return s.store.GetByID(ctx, accountID)
|
||||
}
|
||||
if err := s.store.replaceEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return s.store.GetByID(ctx, accountID)
|
||||
}
|
||||
|
||||
// verifyPendingCode loads and checks the pending confirm-code for (accountID,
|
||||
// addr), counting a wrong attempt. It returns the confirmation on success.
|
||||
func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) {
|
||||
|
||||
Reference in New Issue
Block a user