feat: sync account deletion lifecycle
Sync telesrv 73f5c91 (feat(account): implement unified account deletion lifecycle). Skipped telesrv docs changes per public sync rules.
This commit is contained in:
parent
96a419b565
commit
edb7057757
32 changed files with 3236 additions and 88 deletions
|
|
@ -15,6 +15,15 @@ import (
|
|||
|
||||
// registerAccount 注册 account.* RPC handler。
|
||||
func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
||||
registerRPC[*tg.AccountDeleteAccountRequest](d, tlprofile.SemanticMethodAccountDeleteAccount, func(ctx context.Context, req *tg.AccountDeleteAccountRequest) (any, error) {
|
||||
return r.onAccountDeleteAccount(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.AccountSendConfirmPhoneCodeRequest](d, tlprofile.SemanticMethodAccountSendConfirmPhoneCode, func(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (any, error) {
|
||||
return r.onAccountSendConfirmPhoneCode(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.AccountConfirmPhoneRequest](d, tlprofile.SemanticMethodAccountConfirmPhone, func(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (any, error) {
|
||||
return r.onAccountConfirmPhone(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.AccountRegisterDeviceRequest](d, tlprofile.SemanticMethodAccountRegisterDevice, func(ctx context.Context, req *tg.AccountRegisterDeviceRequest) (any, error) {
|
||||
return true, nil
|
||||
})
|
||||
|
|
@ -902,7 +911,7 @@ func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysT
|
|||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if ttl.Days <= 0 {
|
||||
if ttl.Days <= 0 || ttl.Days > domain.MaxAccountTTLDays {
|
||||
return false, tgerr400("TTL_DAYS_INVALID")
|
||||
}
|
||||
if svc, ok := r.accountSettingsSvc(); ok {
|
||||
|
|
|
|||
152
internal/rpc/account_deletion.go
Normal file
152
internal/rpc/account_deletion.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/postresponse"
|
||||
)
|
||||
|
||||
type accountDeletionService interface {
|
||||
DeleteAccount(ctx context.Context, userID int64, authKeyID [8]byte, reason string, password *domain.PasswordCheck, now time.Time) (domain.AccountDeleteOutcome, error)
|
||||
SendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, hash string) (string, domain.AuthCodeDelivery, error)
|
||||
ConfirmPhone(ctx context.Context, userID int64, authKeyID [8]byte, phoneCodeHash, code string, now time.Time) ([]domain.Authorization, error)
|
||||
ResendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, oldHash string) (string, domain.AuthCodeDelivery, bool, error)
|
||||
CancelConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, phone, hash string) (bool, error)
|
||||
}
|
||||
|
||||
func (r *Router) accountDeletionSvc() (accountDeletionService, bool) {
|
||||
svc, ok := r.deps.Account.(accountDeletionService)
|
||||
return svc, ok
|
||||
}
|
||||
|
||||
func (r *Router) onAccountDeleteAccount(ctx context.Context, req *tg.AccountDeleteAccountRequest) (bool, error) {
|
||||
userID, authorized, passwordPending, err := r.currentOrPendingPasswordUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if userID == 0 || (!authorized && !passwordPending) {
|
||||
return false, authKeyUnregisteredErr()
|
||||
}
|
||||
svc, ok := r.accountDeletionSvc()
|
||||
if !ok {
|
||||
return false, internalErr()
|
||||
}
|
||||
authKeyID, ok := AuthKeyIDFrom(ctx)
|
||||
if !ok || authKeyID == ([8]byte{}) {
|
||||
return false, authKeyUnregisteredErr()
|
||||
}
|
||||
var password *domain.PasswordCheck
|
||||
if check, present := req.GetPassword(); present {
|
||||
converted := domainPasswordCheck(check)
|
||||
password = &converted
|
||||
}
|
||||
outcome, err := svc.DeleteAccount(ctx, userID, authKeyID, req.Reason, password, time.Now().UTC())
|
||||
if err != nil {
|
||||
return false, accountDeletionErr(err)
|
||||
}
|
||||
if outcome.Kind == domain.AccountDeleteDelayed {
|
||||
wait := outcome.WaitSeconds
|
||||
if wait < 1 {
|
||||
wait = 1
|
||||
}
|
||||
return false, tgerr.New(420, fmt.Sprintf("2FA_CONFIRM_WAIT_%d", wait))
|
||||
}
|
||||
r.finishDeletedAccountAuthorizations(ctx, userID, outcome.Deletion.RevokedAuthorizations)
|
||||
r.invalidateRPCProjectionForUser(userID)
|
||||
dispatchNotifications := func() {
|
||||
dispatchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
r.runAccountLifecycleOnce(dispatchCtx, 500)
|
||||
}
|
||||
if !postresponse.Register(ctx, dispatchNotifications) {
|
||||
go dispatchNotifications()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) onAccountSendConfirmPhoneCode(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (tg.AuthSentCodeClass, error) {
|
||||
userID, authorized, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !authorized || userID == 0 {
|
||||
return nil, authKeyUnregisteredErr()
|
||||
}
|
||||
svc, ok := r.accountDeletionSvc()
|
||||
if !ok {
|
||||
return nil, internalErr()
|
||||
}
|
||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||
sessionID, _ := SessionIDFrom(ctx)
|
||||
hash, delivery, err := svc.SendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.Hash)
|
||||
if err != nil {
|
||||
return nil, accountDeletionErr(err)
|
||||
}
|
||||
return tgSMSSentCode(hash, delivery.Length), nil
|
||||
}
|
||||
|
||||
func (r *Router) onAccountConfirmPhone(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (bool, error) {
|
||||
userID, authorized, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if !authorized || userID == 0 {
|
||||
return false, authKeyUnregisteredErr()
|
||||
}
|
||||
svc, ok := r.accountDeletionSvc()
|
||||
if !ok {
|
||||
return false, internalErr()
|
||||
}
|
||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||
revoked, err := svc.ConfirmPhone(ctx, userID, authKeyID, req.PhoneCodeHash, req.PhoneCode, time.Now().UTC())
|
||||
if err != nil {
|
||||
return false, accountDeletionErr(err)
|
||||
}
|
||||
r.finishDeletedAccountAuthorizations(ctx, userID, revoked)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) finishDeletedAccountAuthorizations(ctx context.Context, userID int64, revoked []domain.Authorization) {
|
||||
current, _ := AuthKeyIDFrom(ctx)
|
||||
for _, authorization := range revoked {
|
||||
a := authorization
|
||||
finish := func() {
|
||||
r.discardSecretChatsForAuthKey(context.Background(), businessAuthKeyInt64(a.AuthKeyID), userID)
|
||||
r.revokeAuthKeySessions(a.AuthKeyID)
|
||||
}
|
||||
if a.AuthKeyID == current {
|
||||
if postresponse.Register(ctx, finish) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
func accountDeletionErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrPasswordHashInvalid), errors.Is(err, domain.ErrSRPIDInvalid), errors.Is(err, domain.ErrSRPPasswordChanged):
|
||||
return passwordErr(err)
|
||||
case errors.Is(err, domain.ErrAccountDeletionHashInvalid), errors.Is(err, domain.ErrAccountDeletionNotPending):
|
||||
return tgerr.New(400, "HASH_INVALID")
|
||||
case errors.Is(err, domain.ErrPhoneCodeEmpty):
|
||||
return phoneCodeEmptyErr()
|
||||
case errors.Is(err, domain.ErrPhoneCodeInvalid):
|
||||
return phoneCodeInvalidErr()
|
||||
case errors.Is(err, domain.ErrPhoneCodeExpired):
|
||||
return phoneCodeExpiredErr()
|
||||
case errors.Is(err, domain.ErrAccountDeletionForbidden):
|
||||
return botMethodInvalidErr()
|
||||
case errors.Is(err, domain.ErrAccountDeleted):
|
||||
return authKeyUnregisteredErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
177
internal/rpc/account_deletion_rpc_test.go
Normal file
177
internal/rpc/account_deletion_rpc_test.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/proto"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appaccount "telesrv/internal/app/account"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/postresponse"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestAccountDeleteRPCDeliversResultBeforeClosingCurrentSession(t *testing.T) {
|
||||
current := [8]byte{1}
|
||||
other := [8]byte{2}
|
||||
accountSvc := &rpcDeletionAccountService{
|
||||
Service: appaccount.NewService(memory.NewPasswordStore()),
|
||||
outcome: domain.AccountDeleteOutcome{
|
||||
Kind: domain.AccountDeleteImmediate,
|
||||
Deletion: domain.AccountDeletionResult{Changed: true, RevokedAuthorizations: []domain.Authorization{
|
||||
{AuthKeyID: current, UserID: 42},
|
||||
{AuthKeyID: other, UserID: 42},
|
||||
}},
|
||||
},
|
||||
}
|
||||
sessions := &deletionCaptureSessions{}
|
||||
r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := postresponse.WithCallbacks(WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 42), current), 77))
|
||||
ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "manual"})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("delete account ok=%v err=%v", ok, err)
|
||||
}
|
||||
if sessions.wasClosed(current) {
|
||||
t.Fatal("current auth key closed before rpc_result delivery")
|
||||
}
|
||||
if !sessions.wasClosed(other) {
|
||||
t.Fatal("other auth key was not revoked immediately")
|
||||
}
|
||||
postresponse.Run(ctx)
|
||||
if !sessions.wasClosed(current) {
|
||||
t.Fatal("current auth key not closed after rpc_result delivery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDeleteRPCMapsDelayedTwoFAWait(t *testing.T) {
|
||||
accountSvc := &rpcDeletionAccountService{
|
||||
Service: appaccount.NewService(memory.NewPasswordStore()),
|
||||
outcome: domain.AccountDeleteOutcome{Kind: domain.AccountDeleteDelayed, WaitSeconds: 604800},
|
||||
}
|
||||
r := New(Config{}, Deps{Account: accountSvc}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithAuthKeyID(WithUserID(context.Background(), 42), [8]byte{1})
|
||||
ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "Forgot password"})
|
||||
if ok || !tgerr.Is(err, "2FA_CONFIRM_WAIT") || !strings.Contains(err.Error(), "604800") {
|
||||
t.Fatalf("delayed delete ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccountAllowedWithoutFullAuthorization(t *testing.T) {
|
||||
if !rpcAllowedWithoutAuthorization(tg.AccountDeleteAccountRequestTypeID) {
|
||||
t.Fatal("account.deleteAccount must reach the narrow password_pending identity resolver")
|
||||
}
|
||||
if rpcAllowedWithoutAuthorization(tg.AccountConfirmPhoneRequestTypeID) || rpcAllowedWithoutAuthorization(tg.AccountSendConfirmPhoneCodeRequestTypeID) {
|
||||
t.Fatal("confirm-phone methods must remain fully authorized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDeletionNotificationCompletesForOfflineTarget(t *testing.T) {
|
||||
sessions := &offlineDeletionSessions{}
|
||||
svc := &deletionWorkerService{}
|
||||
r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||
r.dispatchAccountDeletionNotification(context.Background(), svc, domain.AccountDeletionNotification{
|
||||
ID: 9, TargetUserID: 42, DeletedUserID: 77, Attempts: 1,
|
||||
})
|
||||
if len(svc.completed) != 1 || svc.completed[0] != 9 {
|
||||
t.Fatalf("completed notifications = %v, want [9]", svc.completed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountLifecyclePartialSweepFinishesCommittedDeletion(t *testing.T) {
|
||||
revoked := [8]byte{3}
|
||||
svc := &rpcDeletionAccountService{
|
||||
Service: appaccount.NewService(memory.NewPasswordStore()),
|
||||
sweepResults: []domain.AccountDeletionResult{{
|
||||
Changed: true,
|
||||
User: domain.User{ID: 42, Deleted: true},
|
||||
RevokedAuthorizations: []domain.Authorization{{AuthKeyID: revoked, UserID: 42}},
|
||||
}},
|
||||
sweepErr: errors.New("later candidate failed"),
|
||||
}
|
||||
sessions := &deletionCaptureSessions{}
|
||||
r := New(Config{}, Deps{Account: svc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||
r.runAccountLifecycleOnce(context.Background(), 10)
|
||||
if !sessions.wasClosed(revoked) {
|
||||
t.Fatal("committed deletion authorization was not closed after partial sweep failure")
|
||||
}
|
||||
}
|
||||
|
||||
type rpcDeletionAccountService struct {
|
||||
*appaccount.Service
|
||||
outcome domain.AccountDeleteOutcome
|
||||
err error
|
||||
sweepResults []domain.AccountDeletionResult
|
||||
sweepErr error
|
||||
}
|
||||
|
||||
func (s *rpcDeletionAccountService) DeleteAccount(context.Context, int64, [8]byte, string, *domain.PasswordCheck, time.Time) (domain.AccountDeleteOutcome, error) {
|
||||
return s.outcome, s.err
|
||||
}
|
||||
|
||||
func (*rpcDeletionAccountService) SendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string) (string, domain.AuthCodeDelivery, error) {
|
||||
return "hash", domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: 5}, nil
|
||||
}
|
||||
|
||||
func (*rpcDeletionAccountService) ConfirmPhone(context.Context, int64, [8]byte, string, string, time.Time) ([]domain.Authorization, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*rpcDeletionAccountService) ResendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string, string) (string, domain.AuthCodeDelivery, bool, error) {
|
||||
return "", domain.AuthCodeDelivery{}, false, nil
|
||||
}
|
||||
|
||||
func (*rpcDeletionAccountService) CancelConfirmPhoneCode(context.Context, int64, [8]byte, string, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *rpcDeletionAccountService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
|
||||
return s.sweepResults, s.sweepErr
|
||||
}
|
||||
|
||||
type deletionCaptureSessions struct {
|
||||
captureSessions
|
||||
closed [][8]byte
|
||||
}
|
||||
|
||||
type offlineDeletionSessions struct{ captureSessions }
|
||||
|
||||
func (*offlineDeletionSessions) PushToUserExceptAuthKeySession(context.Context, int64, [8]byte, int64, proto.MessageType, tg.UpdatesClass) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type deletionWorkerService struct{ completed []int64 }
|
||||
|
||||
func (*deletionWorkerService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*deletionWorkerService) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *deletionWorkerService) CompleteAccountDeletionNotification(_ context.Context, id int64, _ time.Time) error {
|
||||
s.completed = append(s.completed, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *deletionCaptureSessions) CloseSessionsForBusinessAuthKey(id [8]byte) int {
|
||||
s.closed = append(s.closed, id)
|
||||
return 1
|
||||
}
|
||||
|
||||
func (s *deletionCaptureSessions) wasClosed(id [8]byte) bool {
|
||||
for _, closed := range s.closed {
|
||||
if closed == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
99
internal/rpc/account_lifecycle_worker.go
Normal file
99
internal/rpc/account_lifecycle_worker.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type accountLifecycleWorkerService interface {
|
||||
SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error)
|
||||
ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error)
|
||||
CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error
|
||||
}
|
||||
|
||||
// RunAccountLifecycle executes all due account deletion sources through one
|
||||
// tombstone path and drains the durable non-pts updateUser queue. The queue is
|
||||
// a crash-safe, bounded online nudge: offline users are completed after the
|
||||
// first attempt because getDialogs/getHistory hydration independently returns
|
||||
// the authoritative tombstone. This avoids an immortal retry queue for a
|
||||
// non-pts update that cannot participate in getDifference.
|
||||
func (r *Router) RunAccountLifecycle(ctx context.Context, interval time.Duration, batch int) {
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
if batch <= 0 {
|
||||
batch = 500
|
||||
}
|
||||
r.runAccountLifecycleOnce(ctx, batch)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.runAccountLifecycleOnce(ctx, batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) runAccountLifecycleOnce(ctx context.Context, batch int) {
|
||||
svc, ok := r.deps.Account.(accountLifecycleWorkerService)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
now := r.clock.Now().UTC()
|
||||
sweepCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
results, err := svc.SweepDueAccountDeletions(sweepCtx, now, batch)
|
||||
cancel()
|
||||
for _, result := range results {
|
||||
if !result.Changed {
|
||||
continue
|
||||
}
|
||||
r.invalidateRPCProjectionForUser(result.User.ID)
|
||||
r.finishDeletedAccountAuthorizations(context.Background(), result.User.ID, result.RevokedAuthorizations)
|
||||
}
|
||||
if err != nil {
|
||||
// SweepDueAccountDeletions may return already-committed results before a
|
||||
// later candidate fails. Always finish those sessions/caches and drain
|
||||
// their durable notifications; the failed and remaining candidates are
|
||||
// retried from their authoritative due rows on the next tick.
|
||||
r.log.Warn("account lifecycle deletion sweep partially failed", zap.Int("completed", len(results)), zap.Error(err))
|
||||
}
|
||||
for {
|
||||
claimCtx, claimCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
notifications, err := svc.ClaimAccountDeletionNotifications(claimCtx, now, batch, 2*time.Minute)
|
||||
claimCancel()
|
||||
if err != nil {
|
||||
r.log.Warn("claim account deletion notifications failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, notification := range notifications {
|
||||
r.dispatchAccountDeletionNotification(ctx, svc, notification)
|
||||
}
|
||||
if len(notifications) < batch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) dispatchAccountDeletionNotification(ctx context.Context, svc accountLifecycleWorkerService, notification domain.AccountDeletionNotification) {
|
||||
now := r.clock.Now().UTC()
|
||||
updates := &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: notification.DeletedUserID}},
|
||||
Users: []tg.UserClass{tgUser(domain.User{
|
||||
ID: notification.DeletedUserID,
|
||||
Deleted: true,
|
||||
})},
|
||||
Date: int(now.Unix()),
|
||||
}
|
||||
r.pushUserUpdates(ctx, notification.TargetUserID, updates)
|
||||
if err := svc.CompleteAccountDeletionNotification(ctx, notification.ID, now); err != nil {
|
||||
r.log.Warn("complete account deletion notification failed", zap.Int64("notification_id", notification.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
|
@ -490,6 +490,19 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
|
|||
if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
|
||||
if svc, ok := r.deps.Account.(accountDeletionService); ok {
|
||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||
sessionID, _ := SessionIDFrom(ctx)
|
||||
hash, delivery, handled, err := svc.ResendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.PhoneNumber, req.PhoneCodeHash)
|
||||
if handled {
|
||||
if err != nil {
|
||||
return nil, accountDeletionErr(err)
|
||||
}
|
||||
return tgSMSSentCode(hash, delivery.Length), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
var hash string
|
||||
var err error
|
||||
if scoped, ok := r.deps.Auth.(interface {
|
||||
|
|
@ -507,6 +520,18 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
|
|||
}
|
||||
|
||||
func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) {
|
||||
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
|
||||
if svc, ok := r.deps.Account.(accountDeletionService); ok {
|
||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||
handled, err := svc.CancelConfirmPhoneCode(ctx, userID, authKeyID, req.PhoneNumber, req.PhoneCodeHash)
|
||||
if handled {
|
||||
if err != nil {
|
||||
return false, accountDeletionErr(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
var err error
|
||||
if scoped, ok := r.deps.Auth.(interface {
|
||||
CancelCodeForAuthKey(context.Context, [8]byte, string, string) error
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ func rpcAllowedWithoutAuthorization(id uint32) bool {
|
|||
tg.AuthReportMissingCodeRequestTypeID,
|
||||
tg.AuthResetLoginEmailRequestTypeID,
|
||||
tg.AccountGetPasswordRequestTypeID,
|
||||
// deleteAccount may complete the narrow password_pending login path when
|
||||
// the user forgot 2FA. The handler resolves only that bound identity.
|
||||
tg.AccountDeleteAccountRequestTypeID,
|
||||
// 登录邮箱 setup(emailVerifyPurposeLoginSetup)发生在登录流程中、尚未鉴权,
|
||||
// 故这两个 account.* 方法必须放行 pre-auth;loginChange 分支内部仍校验 userID。
|
||||
tg.AccountSendVerifyEmailCodeRequestTypeID,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import (
|
|||
|
||||
// tgSelfUser 把 domain.User 转为 self 标记的 tg.User(optional 字段由 Encode 自动 SetFlags)。
|
||||
func tgSelfUser(u domain.User) *tg.User {
|
||||
if u.Deleted {
|
||||
return &tg.User{ID: u.ID, Deleted: true}
|
||||
}
|
||||
out := &tg.User{
|
||||
ID: u.ID,
|
||||
AccessHash: u.AccessHash,
|
||||
|
|
@ -34,6 +37,9 @@ func tgSelfUser(u domain.User) *tg.User {
|
|||
}
|
||||
|
||||
func tgUser(u domain.User) *tg.User {
|
||||
if u.Deleted {
|
||||
return &tg.User{ID: u.ID, Deleted: true}
|
||||
}
|
||||
out := &tg.User{
|
||||
ID: u.ID,
|
||||
AccessHash: u.AccessHash,
|
||||
|
|
|
|||
56
internal/rpc/convert_users_deleted_test.go
Normal file
56
internal/rpc/convert_users_deleted_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestDeletedUserTLProjectionContainsOnlyTombstoneIdentity(t *testing.T) {
|
||||
u := domain.User{
|
||||
ID: 42, AccessHash: 99, Phone: "secret", FirstName: "Alice", LastName: "Private",
|
||||
Username: "released", About: "hidden", Verified: true, PremiumUntil: 2_000_000_000,
|
||||
PhotoID: 123, Deleted: true, DeletedAt: 1_800_000_000,
|
||||
}
|
||||
got := tgUser(u)
|
||||
if got.ID != u.ID || !got.Deleted {
|
||||
t.Fatalf("deleted user = %+v", got)
|
||||
}
|
||||
if got.AccessHash != 0 || got.Phone != "" || got.FirstName != "" || got.LastName != "" || got.Username != "" || got.Verified || got.Premium || got.Photo != nil || got.Status != nil || len(got.Usernames) != 0 {
|
||||
t.Fatalf("deleted user leaked profile state: %+v", got)
|
||||
}
|
||||
self := tgSelfUser(u)
|
||||
if !self.Deleted || self.Self || self.ID != u.ID {
|
||||
t.Fatalf("deleted self projection = %+v", self)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryHydrationReplacesStaleUserWithDeletedTombstone(t *testing.T) {
|
||||
viewer := domain.User{ID: 7, FirstName: "Viewer"}
|
||||
deleted := domain.User{ID: 42, AccessHash: 99, Deleted: true, DeletedAt: 1_800_000_000}
|
||||
r := New(Config{}, Deps{Users: mapUsersService{users: map[int64]domain.User{
|
||||
viewer.ID: viewer, deleted.ID: deleted,
|
||||
}}}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
list := r.enrichMessageList(context.Background(), viewer.ID, domain.MessageList{
|
||||
Messages: []domain.Message{{
|
||||
OwnerUserID: viewer.ID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
|
||||
Body: "retained history",
|
||||
}},
|
||||
// Simulate an old denormalized message query row. The authoritative
|
||||
// Users.ByIDs hydration must replace it, not keep an empty active user.
|
||||
Users: []domain.User{{ID: deleted.ID, Phone: "stale", FirstName: "Stale"}},
|
||||
})
|
||||
if len(list.Users) != 1 || !list.Users[0].Deleted || list.Users[0].Phone != "" || list.Users[0].FirstName != "" {
|
||||
t.Fatalf("history users = %+v, want authoritative tombstone", list.Users)
|
||||
}
|
||||
if got := tgUser(list.Users[0]); !got.Deleted || got.ID != deleted.ID {
|
||||
t.Fatalf("history TL user = %+v", got)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue