aa2290b7b4
Step-up: email accounts confirm with a mailed code (purpose=delete, no deeplink —
ConfirmByToken refuses a delete token so a stray click can't delete); platform-only
accounts type a fixed phrase (anti-impulse). Endpoints /user/delete/{request,confirm};
the confirm orchestration resigns active games, drops all-robot games, tombstones +
anonymizes the account (freeing its creds), and revokes its sessions — the tombstone is
the point of no return, the rest best-effort. Gateway account.delete.{request,confirm}
ops + fbs AccountDeleteConfirm/AccountDeleteRequestResult + branded ru/en delete email.
Integration tests cover the step-up (code + no-email) and the orchestration pieces.
297 lines
11 KiB
Go
297 lines
11 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
|
|
}
|
|
var toRetain []Identity
|
|
others := 0
|
|
for _, id := range ids {
|
|
if id.Kind == kind {
|
|
toRetain = append(toRetain, id)
|
|
} else {
|
|
others++
|
|
}
|
|
}
|
|
if len(toRetain) == 0 {
|
|
return ErrNotFound
|
|
}
|
|
if others == 0 {
|
|
return ErrLastIdentity
|
|
}
|
|
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
|
// Journal the detached credential before removing it, so the legal dossier
|
|
// survives while the identity frees for reuse (see retention.go).
|
|
for _, id := range toRetain {
|
|
if err := retainIdentityTx(ctx, tx, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainUnlink); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// HasEmail reports whether accountID owns a confirmed email. The account-deletion step-up
|
|
// mails a confirm-code when it does, and falls back to a typed phrase otherwise.
|
|
func (s *EmailService) HasEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
|
_, ok, err := s.store.confirmedEmailOf(ctx, accountID)
|
|
return ok, err
|
|
}
|
|
|
|
// RequestDeleteCode mails an account-deletion confirm-code to the account's own confirmed
|
|
// email (no deeplink — deletion is confirmed in the app). It returns ErrNoEmail when the
|
|
// account holds no email, ErrTooManyRequests when throttled.
|
|
func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUID) error {
|
|
addr, ok, err := s.store.confirmedEmailOf(ctx, accountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return ErrNoEmail
|
|
}
|
|
if !s.allowSend(addr) {
|
|
return ErrTooManyRequests
|
|
}
|
|
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID))
|
|
}
|
|
|
|
// VerifyDeleteCode verifies the account-deletion code against the account's own email and
|
|
// consumes it on success. It returns ErrNoEmail (no email), the usual confirm-code errors
|
|
// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch), or nil when the
|
|
// code is valid — the caller then performs the deletion.
|
|
func (s *EmailService) VerifyDeleteCode(ctx context.Context, accountID uuid.UUID, code string) error {
|
|
addr, ok, err := s.store.confirmedEmailOf(ctx, accountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return ErrNoEmail
|
|
}
|
|
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.store.consumeConfirmation(ctx, conf.id, s.now())
|
|
}
|
|
|
|
// 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
|
|
}
|