Files
scrabble-game/backend/internal/account/link.go
T
Ilia Denisov a3eb4719de refactor(account): generalize RemoveEmailIdentity to RemoveIdentity(kind)
Generalize the email-erase store op to any identity kind (with the same
last-identity guard and, for email, the pending-confirmation cleanup) so the
profile Unlink control can reuse it for Telegram/VK. RemoveEmailIdentity stays as a
thin wrapper for the admin console.
2026-07-03 09:20:59 +02:00

195 lines
7.2 KiB
Go

package account
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// ErrIdentityTaken is returned when a platform identity being linked already
// belongs to another account; the caller turns it into a merge.
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
// ErrLastIdentity is returned when removing an identity would leave the account with
// none, making it unreachable after logout. The admin email-erase refuses it.
var ErrLastIdentity = errors.New("account: cannot remove the last identity")
// RemoveIdentity deletes the account's identity of the given kind (and, for an email,
// any pending confirmations for it), freeing it for reuse. It refuses when that is the
// account's only identity (ErrLastIdentity) — which would leave the account
// unreachable — and returns ErrNotFound when the account has no identity of that kind.
// It backs the profile Unlink control and the admin "erase email" action.
func (s *Store) RemoveIdentity(ctx context.Context, accountID uuid.UUID, kind string) error {
ids, err := s.Identities(ctx, accountID)
if err != nil {
return err
}
hasKind, others := false, 0
for _, id := range ids {
if id.Kind == kind {
hasKind = true
} else {
others++
}
}
if !hasKind {
return ErrNotFound
}
if others == 0 {
return ErrLastIdentity
}
return withTx(ctx, s.db, func(tx *sql.Tx) error {
delID := table.Identities.DELETE().WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(kind))),
)
if _, err := delID.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("account: delete %s identity %s: %w", kind, accountID, err)
}
if kind == KindEmail {
delConf := table.EmailConfirmations.DELETE().WHERE(
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)),
)
if _, err := delConf.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("account: delete email confirmations %s: %w", accountID, err)
}
}
return nil
})
}
// RemoveEmailIdentity erases the account's email identity. It backs the admin console's
// "erase email" action; the user-facing profile never unlinks email (it is changed).
func (s *Store) RemoveEmailIdentity(ctx context.Context, accountID uuid.UUID) error {
return s.RemoveIdentity(ctx, accountID, KindEmail)
}
// RequestLinkCode issues and mails a confirm-code for email to accountID,
// replacing any prior pending code. Unlike RequestCode it never refuses up front
// (taken or already-confirmed): possession of the address is the authorization for
// a later link or merge, and the merge is only revealed once the code is verified,
// so a probe cannot learn whether an address is registered.
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
addr, err := normalizeEmail(email)
if err != nil {
return err
}
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
}
// ConfirmLink verifies code for (accountID, email) and reports the address's
// current owner. When the address is free it binds a confirmed email identity to
// accountID and returns (accountID, true, nil). When accountID already owns it,
// it returns (accountID, true, nil) unchanged. When another account owns it, it
// returns (owner, false, nil) without consuming the code, so the explicit merge
// step can re-verify the same live code. It returns the usual confirm-code errors
// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch).
func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, email, code string) (uuid.UUID, bool, error) {
addr, err := normalizeEmail(email)
if err != nil {
return uuid.Nil, false, err
}
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
if err != nil {
return uuid.Nil, false, err
}
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
if err != nil {
return uuid.Nil, false, err
}
if ok {
if owner == accountID {
return accountID, true, nil
}
return owner, false, nil
}
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
return uuid.Nil, false, err
}
return accountID, true, nil
}
// 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) {
conf, err := s.store.latestPendingConfirmation(ctx, accountID, addr)
if err != nil {
return emailConfirmation{}, err
}
if s.now().After(conf.expiresAt) {
return emailConfirmation{}, ErrCodeExpired
}
if conf.attempts >= emailCodeMaxAttempts {
return emailConfirmation{}, ErrTooManyAttempts
}
if hashCode(code) != conf.codeHash {
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
return emailConfirmation{}, err
}
return emailConfirmation{}, ErrCodeMismatch
}
return conf, nil
}
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
// flow.
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
acc, err := s.findByIdentity(ctx, kind, externalID)
if errors.Is(err, ErrNotFound) {
return uuid.Nil, false, nil
}
if err != nil {
return uuid.Nil, false, err
}
return acc.ID, true, nil
}
// AttachIdentity links a new (kind, externalID) identity to an existing account.
// A unique-constraint violation means the identity was taken meanwhile, surfaced
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
// to the current account during linking.
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new identity id: %w", err)
}
ins := table.Identities.INSERT(
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
table.Identities.ExternalID, table.Identities.Confirmed,
).VALUES(id, accountID, kind, externalID, confirmed)
if _, err := ins.ExecContext(ctx, s.db); err != nil {
if isUniqueViolation(err) {
return ErrIdentityTaken
}
return fmt.Errorf("account: attach identity (%s, %s): %w", kind, externalID, err)
}
return nil
}
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
// to a durable account once it gains its first identity. It is a no-op
// for an already-durable account.
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
SET(postgres.Bool(false), postgres.TimestampzT(time.Now().UTC())).
WHERE(
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Accounts.IsGuest.EQ(postgres.Bool(true))),
)
if _, err := upd.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: clear guest %s: %w", accountID, err)
}
return nil
}