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) {
|
||||
|
||||
@@ -74,6 +74,12 @@ func (s *Server) registerRoutes() {
|
||||
u.POST("/link/email/merge", s.handleLinkEmailMerge)
|
||||
u.POST("/link/telegram", s.handleLinkTelegram)
|
||||
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
|
||||
u.POST("/link/unlink", s.handleUnlink)
|
||||
// Change the account's confirmed email: mail a code to the new address, then
|
||||
// atomically switch on confirm (a new address owned by another account is
|
||||
// refused without disclosure, never merged).
|
||||
u.POST("/link/email/change/request", s.handleChangeEmailRequest)
|
||||
u.POST("/link/email/change/confirm", s.handleChangeEmailConfirm)
|
||||
}
|
||||
if s.games != nil {
|
||||
u.GET("/games", s.handleListGames)
|
||||
@@ -231,6 +237,8 @@ func statusForError(err error) (int, string) {
|
||||
return http.StatusUnprocessableEntity, "illegal_play"
|
||||
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
||||
return http.StatusConflict, "email_taken"
|
||||
case errors.Is(err, account.ErrLastIdentity):
|
||||
return http.StatusConflict, "last_identity"
|
||||
case errors.Is(err, accountmerge.ErrActiveGameConflict):
|
||||
return http.StatusConflict, "merge_active_game_conflict"
|
||||
case errors.Is(err, account.ErrInvalidEmail):
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/link"
|
||||
)
|
||||
|
||||
@@ -66,6 +67,89 @@ func (s *Server) handleLinkEmailRequest(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// unlinkBody carries the provider kind to detach.
|
||||
type unlinkBody struct {
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// handleUnlink detaches a platform identity (telegram or vk) from the caller's
|
||||
// account, refusing to remove the last identity (ErrLastIdentity). Email is never
|
||||
// unlinked — it is replaced through the change-email flow — so an email kind is
|
||||
// rejected. It returns the refreshed profile so the client updates its controls.
|
||||
func (s *Server) handleUnlink(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req unlinkBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Kind != account.KindTelegram && req.Kind != account.KindVK {
|
||||
abortBadRequest(c, "only telegram or vk can be unlinked")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if err := s.accounts.RemoveIdentity(ctx, uid, req.Kind); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
acc, err := s.accounts.GetByID(ctx, uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
r := s.profileResponse(ctx, acc)
|
||||
c.JSON(http.StatusOK, linkResultResponse{Status: "unlinked", Profile: &r})
|
||||
}
|
||||
|
||||
// handleChangeEmailRequest mails a confirm-code to a new address for an authenticated
|
||||
// email change. Like the link request it never signals "taken" up front — a conflict is
|
||||
// only revealed (non-disclosingly) at confirm.
|
||||
func (s *Server) handleChangeEmailRequest(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req linkEmailRequestBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
if err := s.emails.RequestChangeCode(c.Request.Context(), uid, req.Email); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// handleChangeEmailConfirm verifies the code and atomically switches the account to the
|
||||
// new address, returning the refreshed profile. A new address confirmed by another
|
||||
// account is refused (ErrEmailTaken → the non-disclosing message), never merged.
|
||||
func (s *Server) handleChangeEmailConfirm(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req linkEmailConfirmBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
acc, err := s.emails.ConfirmChange(ctx, uid, req.Email, req.Code)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
r := s.profileResponse(ctx, acc)
|
||||
c.JSON(http.StatusOK, linkResultResponse{Status: "changed", Profile: &r})
|
||||
}
|
||||
|
||||
// handleLinkEmailConfirm verifies the code and binds a free email or reports a
|
||||
// required merge.
|
||||
func (s *Server) handleLinkEmailConfirm(c *gin.Context) {
|
||||
|
||||
Reference in New Issue
Block a user