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
360
internal/app/account/lifecycle.go
Normal file
360
internal/app/account/lifecycle.go
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/otpdelivery"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const accountDeletionDelay = 7 * 24 * time.Hour
|
||||
|
||||
// DeleteAccount implements the official 2FA deletion decision. A supplied and
|
||||
// valid SRP proof always deletes immediately. Without a proof, an account whose
|
||||
// password is older than seven days and which was active during the last seven
|
||||
// days gets a cancellable seven-day window; all other cases delete immediately.
|
||||
func (s *Service) DeleteAccount(ctx context.Context, userID int64, authKeyID [8]byte, reason string, password *domain.PasswordCheck, now time.Time) (domain.AccountDeleteOutcome, error) {
|
||||
if s == nil || s.lifecycle == nil || userID == 0 || authKeyID == ([8]byte{}) {
|
||||
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if len(reason) > 1024 {
|
||||
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
|
||||
}
|
||||
snapshot, found, err := s.lifecycle.AccountDeletionSnapshot(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AccountDeleteOutcome{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.AccountDeleteOutcome{}, domain.ErrUserNotFound
|
||||
}
|
||||
if snapshot.User.Deleted {
|
||||
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteImmediate, Deletion: domain.AccountDeletionResult{User: snapshot.User}}, nil
|
||||
}
|
||||
if snapshot.User.Bot || domain.IsSystemUserID(snapshot.User.ID) {
|
||||
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
|
||||
}
|
||||
if password != nil && !password.Empty {
|
||||
if !snapshot.HasPassword {
|
||||
return domain.AccountDeleteOutcome{}, domain.ErrPasswordHashInvalid
|
||||
}
|
||||
if err := s.CheckPassword(ctx, userID, *password); err != nil {
|
||||
return domain.AccountDeleteOutcome{}, err
|
||||
}
|
||||
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
|
||||
}
|
||||
if !snapshot.HasPassword {
|
||||
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
|
||||
}
|
||||
lastActive := snapshot.User.CreatedAt
|
||||
if snapshot.User.LastSeenAt > 0 {
|
||||
seen := time.Unix(int64(snapshot.User.LastSeenAt), 0).UTC()
|
||||
if seen.After(lastActive) {
|
||||
lastActive = seen
|
||||
}
|
||||
}
|
||||
passwordOldEnough := !snapshot.PasswordUpdatedAt.IsZero() && !snapshot.PasswordUpdatedAt.After(now.Add(-accountDeletionDelay))
|
||||
recentlyActive := !lastActive.IsZero() && !lastActive.Before(now.Add(-accountDeletionDelay))
|
||||
if !passwordOldEnough || !recentlyActive {
|
||||
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
|
||||
}
|
||||
if snapshot.Pending != nil {
|
||||
return delayedDeleteOutcome(*snapshot.Pending, now), nil
|
||||
}
|
||||
rawToken, digest, err := newAccountDeletionToken()
|
||||
if err != nil {
|
||||
return domain.AccountDeleteOutcome{}, err
|
||||
}
|
||||
executeAt := now.Add(accountDeletionDelay)
|
||||
message := fmt.Sprintf(
|
||||
"A request was made to delete your Telegram account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s",
|
||||
url.QueryEscape(snapshot.User.Phone), url.QueryEscape(rawToken),
|
||||
)
|
||||
pending, _, err := s.lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
|
||||
UserID: userID,
|
||||
RequesterAuthKeyID: authKeyID,
|
||||
Reason: reason,
|
||||
ConfirmHashDigest: digest,
|
||||
ServiceMessage: message,
|
||||
RequestedAt: now,
|
||||
ExecuteAt: executeAt,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.AccountDeleteOutcome{}, err
|
||||
}
|
||||
return delayedDeleteOutcome(pending, now), nil
|
||||
}
|
||||
|
||||
func (s *Service) executeAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeleteOutcome, error) {
|
||||
result, err := s.lifecycle.ExecuteAccountDeletion(ctx, userID, source, reason, now)
|
||||
if err != nil {
|
||||
return domain.AccountDeleteOutcome{}, err
|
||||
}
|
||||
if s.userCache != nil {
|
||||
_ = s.userCache.Delete(ctx, []int64{userID})
|
||||
}
|
||||
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteImmediate, Deletion: result}, nil
|
||||
}
|
||||
|
||||
func delayedDeleteOutcome(pending domain.AccountDeletionRequest, now time.Time) domain.AccountDeleteOutcome {
|
||||
wait := int(time.Until(pending.ExecuteAt).Seconds())
|
||||
if !now.IsZero() {
|
||||
wait = int(pending.ExecuteAt.Sub(now).Seconds())
|
||||
}
|
||||
if wait < 0 {
|
||||
wait = 0
|
||||
}
|
||||
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteDelayed, WaitSeconds: wait, ExecuteAt: pending.ExecuteAt}
|
||||
}
|
||||
|
||||
func deletionSourceForReason(reason string) domain.AccountDeletionSource {
|
||||
switch strings.ToLower(strings.TrimSpace(reason)) {
|
||||
case "forgot password":
|
||||
return domain.AccountDeletionForgotPassword
|
||||
case "decline tos update":
|
||||
return domain.AccountDeletionTOSDecline
|
||||
default:
|
||||
return domain.AccountDeletionManual
|
||||
}
|
||||
}
|
||||
|
||||
func newAccountDeletionToken() (string, [32]byte, error) {
|
||||
var raw [32]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", [32]byte{}, fmt.Errorf("generate account deletion token: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(raw[:])
|
||||
return token, sha256.Sum256([]byte(token)), nil
|
||||
}
|
||||
|
||||
func accountDeletionDigest(raw string) ([32]byte, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
decoded, err := hex.DecodeString(raw)
|
||||
if err != nil || len(decoded) != 32 {
|
||||
return [32]byte{}, domain.ErrAccountDeletionHashInvalid
|
||||
}
|
||||
return sha256.Sum256([]byte(raw)), nil
|
||||
}
|
||||
|
||||
// SendConfirmPhoneCode validates the secret confirmphone link and issues an
|
||||
// auth-key-scoped SMS code to the account's current phone.
|
||||
func (s *Service) SendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, rawHash string) (string, domain.AuthCodeDelivery, error) {
|
||||
digest, err := accountDeletionDigest(rawHash)
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
if s == nil || s.lifecycle == nil || s.users == nil || s.codes == nil || userID == 0 || authKeyID == ([8]byte{}) {
|
||||
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
|
||||
}
|
||||
if _, found, err := s.lifecycle.PendingAccountDeletionByHash(ctx, userID, digest); err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
} else if !found {
|
||||
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
if !found || u.Deleted || u.Phone == "" {
|
||||
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
|
||||
}
|
||||
return s.issueConfirmPhoneCode(ctx, userID, authKeyID, sessionID, u.Phone, digest)
|
||||
}
|
||||
|
||||
func (s *Service) issueConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string, digest [32]byte) (string, domain.AuthCodeDelivery, error) {
|
||||
hash, err := phoneChangeHash()
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
code := s.phoneChangeCode
|
||||
channel := store.PhoneCodeChannelPhone
|
||||
deliveryID := ""
|
||||
if s.phoneCodeSender != nil {
|
||||
code, err = randomDigits(s.phoneCodeLength)
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
deliveryID, err = otpdelivery.NewDeliveryID()
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
channel = store.PhoneCodeChannelSMS
|
||||
}
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return "", domain.AuthCodeDelivery{}, fmt.Errorf("confirm phone code service is not configured")
|
||||
}
|
||||
rec := store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: phone,
|
||||
Code: code,
|
||||
DeliveryID: deliveryID,
|
||||
Channel: channel,
|
||||
Purpose: store.PhoneCodePurposeConfirmPhone,
|
||||
UserID: userID,
|
||||
AuthKeyID: authKeyID,
|
||||
SessionID: sessionID,
|
||||
MaxAttempts: s.phoneChangeMaxAttempts,
|
||||
AccountDeletionHash: hex.EncodeToString(digest[:]),
|
||||
}
|
||||
expiresAt := time.Now().Add(s.phoneChangeCodeTTL)
|
||||
if err := s.codes.Set(ctx, hash, rec, s.phoneChangeCodeTTL); err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store confirm phone code: %w", err)
|
||||
}
|
||||
if s.phoneCodeSender != nil {
|
||||
if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{
|
||||
DeliveryID: deliveryID,
|
||||
Purpose: otpdelivery.PurposeConfirmPhone,
|
||||
Channel: otpdelivery.ChannelSMS,
|
||||
Recipient: phone,
|
||||
Code: code,
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
||||
defer cancel()
|
||||
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
|
||||
return "", domain.AuthCodeDelivery{}, errors.Join(err, cleanupErr)
|
||||
}
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
}
|
||||
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(code)}, nil
|
||||
}
|
||||
|
||||
// ConfirmPhone consumes the scoped OTP, cancels the pending deletion and
|
||||
// revokes the auth key that initiated the deletion attempt.
|
||||
func (s *Service) ConfirmPhone(ctx context.Context, userID int64, authKeyID [8]byte, phoneCodeHash, code string, now time.Time) ([]domain.Authorization, error) {
|
||||
if strings.TrimSpace(phoneCodeHash) == "" || strings.TrimSpace(code) == "" {
|
||||
return nil, domain.ErrPhoneCodeEmpty
|
||||
}
|
||||
if s == nil || s.codes == nil || s.lifecycle == nil || s.users == nil {
|
||||
return nil, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found || u.Deleted || u.Phone == "" {
|
||||
return nil, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
scope := store.PhoneCodeScope{Purpose: store.PhoneCodePurposeConfirmPhone, UserID: userID, AuthKeyID: authKeyID, Phone: u.Phone}
|
||||
verified, err := s.codes.VerifyScoped(ctx, phoneCodeHash, scope, strings.TrimSpace(code), s.phoneChangeMaxAttempts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch verified.Status {
|
||||
case store.LoginCodeVerifyMissing:
|
||||
return nil, domain.ErrPhoneCodeExpired
|
||||
case store.LoginCodeVerifyInvalid:
|
||||
return nil, domain.ErrPhoneCodeInvalid
|
||||
case store.LoginCodeVerifyAccepted:
|
||||
default:
|
||||
return nil, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
digestBytes, err := hex.DecodeString(verified.Record.AccountDeletionHash)
|
||||
if err != nil || len(digestBytes) != 32 {
|
||||
return nil, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
var digest [32]byte
|
||||
copy(digest[:], digestBytes)
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
return s.lifecycle.CancelAccountDeletion(ctx, userID, digest, now)
|
||||
}
|
||||
|
||||
// ResendConfirmPhoneCode handles auth.resendCode only when the supplied hash is
|
||||
// an active confirm-phone code for this authorized user/auth key.
|
||||
func (s *Service) ResendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, oldHash string) (string, domain.AuthCodeDelivery, bool, error) {
|
||||
if s == nil || s.codes == nil || s.users == nil || userID == 0 {
|
||||
return "", domain.AuthCodeDelivery{}, false, nil
|
||||
}
|
||||
rec, found, err := s.codes.Get(ctx, oldHash)
|
||||
if err != nil || !found || rec.Purpose != store.PhoneCodePurposeConfirmPhone || rec.UserID != userID || rec.AuthKeyID != authKeyID {
|
||||
return "", domain.AuthCodeDelivery{}, false, err
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil || !found || u.Deleted || domain.NormalizePhone(phone) != domain.NormalizePhone(u.Phone) {
|
||||
if err == nil {
|
||||
err = domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
return "", domain.AuthCodeDelivery{}, true, err
|
||||
}
|
||||
consumed, found, err := s.codes.ConsumeScoped(ctx, oldHash, rec.Scope())
|
||||
if err != nil || !found {
|
||||
if err == nil {
|
||||
err = domain.ErrPhoneCodeExpired
|
||||
}
|
||||
return "", domain.AuthCodeDelivery{}, true, err
|
||||
}
|
||||
digestBytes, err := hex.DecodeString(consumed.AccountDeletionHash)
|
||||
if err != nil || len(digestBytes) != 32 {
|
||||
return "", domain.AuthCodeDelivery{}, true, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
var digest [32]byte
|
||||
copy(digest[:], digestBytes)
|
||||
hash, delivery, err := s.issueConfirmPhoneCode(ctx, userID, authKeyID, sessionID, u.Phone, digest)
|
||||
return hash, delivery, true, err
|
||||
}
|
||||
|
||||
func (s *Service) CancelConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, phone, hash string) (bool, error) {
|
||||
if s == nil || s.codes == nil || userID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
rec, found, err := s.codes.Get(ctx, hash)
|
||||
if err != nil || !found || rec.Purpose != store.PhoneCodePurposeConfirmPhone || rec.UserID != userID || rec.AuthKeyID != authKeyID {
|
||||
return false, err
|
||||
}
|
||||
if domain.NormalizePhone(phone) != domain.NormalizePhone(rec.Phone) {
|
||||
return true, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
_, _, err = s.codes.ConsumeScoped(ctx, hash, rec.Scope())
|
||||
return true, err
|
||||
}
|
||||
|
||||
func (s *Service) SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error) {
|
||||
if s == nil || s.lifecycle == nil || limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
candidates, err := s.lifecycle.DueAccountDeletions(ctx, now, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]domain.AccountDeletionResult, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
result, err := s.lifecycle.ExecuteAccountDeletion(ctx, candidate.UserID, candidate.Source, "", now)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if s.userCache != nil {
|
||||
_ = s.userCache.Delete(ctx, []int64{candidate.UserID})
|
||||
}
|
||||
out = append(out, result)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.lifecycle.ClaimAccountDeletionNotifications(ctx, now, limit, lease)
|
||||
}
|
||||
|
||||
func (s *Service) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return nil
|
||||
}
|
||||
return s.lifecycle.CompleteAccountDeletionNotification(ctx, id, now)
|
||||
}
|
||||
149
internal/app/account/lifecycle_test.go
Normal file
149
internal/app/account/lifecycle_test.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestDeleteAccountTwoFADelayDecisionMatrix(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
authKey := [8]byte{1}
|
||||
tests := []struct {
|
||||
name string
|
||||
hasPassword bool
|
||||
passwordUpdated time.Time
|
||||
createdAt time.Time
|
||||
lastSeen int
|
||||
wantKind domain.AccountDeleteKind
|
||||
}{
|
||||
{name: "no password deletes immediately", createdAt: now.Add(-time.Hour), lastSeen: int(now.Unix()), wantKind: domain.AccountDeleteImmediate},
|
||||
{name: "old password and recent activity delays", hasPassword: true, passwordUpdated: now.Add(-8 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-time.Hour).Unix()), wantKind: domain.AccountDeleteDelayed},
|
||||
{name: "recent password change deletes immediately", hasPassword: true, passwordUpdated: now.Add(-2 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-time.Hour).Unix()), wantKind: domain.AccountDeleteImmediate},
|
||||
{name: "inactive account deletes immediately", hasPassword: true, passwordUpdated: now.Add(-30 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-8 * 24 * time.Hour).Unix()), wantKind: domain.AccountDeleteImmediate},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
lifecycle := &fakeAccountLifecycleStore{snapshot: domain.AccountDeletionSnapshot{
|
||||
User: domain.User{ID: 42, Phone: "15550010000", CreatedAt: test.createdAt, LastSeenAt: test.lastSeen},
|
||||
HasPassword: test.hasPassword, PasswordUpdatedAt: test.passwordUpdated,
|
||||
}}
|
||||
svc := NewService(memory.NewPasswordStore(), WithAccountLifecycle(lifecycle))
|
||||
outcome, err := svc.DeleteAccount(context.Background(), 42, authKey, "manual", nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteAccount: %v", err)
|
||||
}
|
||||
if outcome.Kind != test.wantKind {
|
||||
t.Fatalf("kind = %q, want %q", outcome.Kind, test.wantKind)
|
||||
}
|
||||
if test.wantKind == domain.AccountDeleteDelayed {
|
||||
if lifecycle.scheduled == nil || !strings.Contains(lifecycle.scheduled.ServiceMessage, "tg://confirmphone?") || outcome.WaitSeconds != int(accountDeletionDelay.Seconds()) {
|
||||
t.Fatalf("delayed outcome=%+v scheduled=%+v", outcome, lifecycle.scheduled)
|
||||
}
|
||||
} else if lifecycle.executedSource == "" {
|
||||
t.Fatal("immediate path did not execute the tombstone boundary")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmPhoneCancelsPendingDeletionAndRevokesRequester(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
users := memory.NewUserStore()
|
||||
u, err := users.Create(ctx, domain.User{Phone: "15550010001", FirstName: "Alice"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requester := [8]byte{9}
|
||||
confirming := [8]byte{8}
|
||||
lifecycle := &fakeAccountLifecycleStore{snapshot: domain.AccountDeletionSnapshot{User: u}}
|
||||
svc := NewService(memory.NewPasswordStore(),
|
||||
WithUsers(users),
|
||||
WithPhoneChange(nil, nil, memory.NewCodeStore(), nil, "12345", 5*time.Minute, 5),
|
||||
WithAccountLifecycle(lifecycle),
|
||||
)
|
||||
rawToken, digest, err := newAccountDeletionToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lifecycle.pending = &domain.AccountDeletionRequest{
|
||||
ID: 1, UserID: u.ID, RequesterAuthKeyID: requester, State: domain.AccountDeletionPending,
|
||||
ConfirmHashDigest: digest, RequestedAt: now, ExecuteAt: now.Add(accountDeletionDelay),
|
||||
}
|
||||
hash, delivery, err := svc.SendConfirmPhoneCode(ctx, u.ID, confirming, 77, rawToken)
|
||||
if err != nil || hash == "" || delivery.Length != 5 {
|
||||
t.Fatalf("SendConfirmPhoneCode hash=%q delivery=%+v err=%v", hash, delivery, err)
|
||||
}
|
||||
revoked, err := svc.ConfirmPhone(ctx, u.ID, confirming, hash, "12345", now.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("ConfirmPhone: %v", err)
|
||||
}
|
||||
if len(revoked) != 1 || revoked[0].AuthKeyID != requester || lifecycle.pending != nil {
|
||||
t.Fatalf("revoked=%+v pending=%+v", revoked, lifecycle.pending)
|
||||
}
|
||||
if _, err := svc.ConfirmPhone(ctx, u.ID, confirming, hash, "12345", now.Add(2*time.Minute)); !errors.Is(err, domain.ErrPhoneCodeExpired) {
|
||||
t.Fatalf("replay error = %v, want expired", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAccountLifecycleStore struct {
|
||||
snapshot domain.AccountDeletionSnapshot
|
||||
pending *domain.AccountDeletionRequest
|
||||
scheduled *domain.ScheduleAccountDeletion
|
||||
executedSource domain.AccountDeletionSource
|
||||
}
|
||||
|
||||
func (f *fakeAccountLifecycleStore) AccountDeletionSnapshot(context.Context, int64) (domain.AccountDeletionSnapshot, bool, error) {
|
||||
f.snapshot.Pending = f.pending
|
||||
return f.snapshot, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountLifecycleStore) ScheduleAccountDeletion(_ context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error) {
|
||||
f.scheduled = &req
|
||||
pending := domain.AccountDeletionRequest{ID: 1, UserID: req.UserID, RequesterAuthKeyID: req.RequesterAuthKeyID, State: domain.AccountDeletionPending, Reason: req.Reason, ConfirmHashDigest: req.ConfirmHashDigest, RequestedAt: req.RequestedAt, ExecuteAt: req.ExecuteAt}
|
||||
f.pending = &pending
|
||||
return pending, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountLifecycleStore) PendingAccountDeletionByHash(_ context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error) {
|
||||
if f.pending == nil || f.pending.UserID != userID || f.pending.ConfirmHashDigest != digest {
|
||||
return domain.AccountDeletionRequest{}, false, nil
|
||||
}
|
||||
return *f.pending, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountLifecycleStore) ExecuteAccountDeletion(_ context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error) {
|
||||
f.executedSource = source
|
||||
u := f.snapshot.User
|
||||
u.Deleted = true
|
||||
u.DeletedAt = now.Unix()
|
||||
u.DeletionSource = source
|
||||
u.DeletionReason = reason
|
||||
u = u.DeletedTombstone()
|
||||
return domain.AccountDeletionResult{User: u, Changed: true}, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountLifecycleStore) CancelAccountDeletion(_ context.Context, userID int64, digest [32]byte, _ time.Time) ([]domain.Authorization, error) {
|
||||
if f.pending == nil || f.pending.UserID != userID || f.pending.ConfirmHashDigest != digest {
|
||||
return nil, domain.ErrAccountDeletionHashInvalid
|
||||
}
|
||||
revoked := []domain.Authorization{{AuthKeyID: f.pending.RequesterAuthKeyID, UserID: userID}}
|
||||
f.pending = nil
|
||||
return revoked, nil
|
||||
}
|
||||
|
||||
func (*fakeAccountLifecycleStore) DueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionCandidate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*fakeAccountLifecycleStore) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*fakeAccountLifecycleStore) CompleteAccountDeletionNotification(context.Context, int64, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ type Service struct {
|
|||
userCache store.UserCache
|
||||
authorizations store.AuthorizationStore
|
||||
phoneChanges store.PhoneChangeStore
|
||||
lifecycle store.AccountLifecycleStore
|
||||
publicBaseURL string
|
||||
codes store.CodeStore
|
||||
phoneChangeCode string
|
||||
|
|
@ -166,6 +167,14 @@ func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) ServiceOption
|
|||
}
|
||||
}
|
||||
|
||||
// WithAccountLifecycle installs the single durable account deletion boundary.
|
||||
// It shares the already configured phone-code delivery and user cache.
|
||||
func WithAccountLifecycle(lifecycle store.AccountLifecycleStore) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.lifecycle = lifecycle
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 account 服务。
|
||||
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
||||
s := &Service{
|
||||
|
|
|
|||
19
internal/app/userprojection/deleted_test.go
Normal file
19
internal/app/userprojection/deleted_test.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package userprojection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestDeletedUserProjectionCannotReintroducePII(t *testing.T) {
|
||||
in := domain.User{ID: 42, AccessHash: 99, Deleted: true, Phone: "stale", FirstName: "Stale", PhotoID: 123, Contact: true}
|
||||
got, err := New().One(context.Background(), 7, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Deleted || got.ID != 42 || got.Phone != "" || got.FirstName != "" || got.PhotoID != 0 || got.Contact {
|
||||
t.Fatalf("deleted projection leaked PII: %+v", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ func New(opts ...Option) *Projector {
|
|||
|
||||
// ForViewer applies both current profile photos and owner-specific contact view.
|
||||
func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
users = sanitizeDeletedUsers(users)
|
||||
if p == nil {
|
||||
return users, nil
|
||||
}
|
||||
|
|
@ -112,6 +113,7 @@ func (p *Projector) One(ctx context.Context, viewerUserID int64, user domain.Use
|
|||
// (无 O(owner) 反查接口),客户端下次 getChannelDifference/getHistory 会走 projectBatch 完整投影自愈。
|
||||
// 调用方传入的 users 不被修改(内部复制)。
|
||||
func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users []domain.User) (map[int64][]domain.User, error) {
|
||||
users = sanitizeDeletedUsers(users)
|
||||
out := make(map[int64][]domain.User, len(viewerUserIDs))
|
||||
if p == nil || len(users) == 0 {
|
||||
for _, v := range viewerUserIDs {
|
||||
|
|
@ -170,6 +172,10 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
if u.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if u.Deleted {
|
||||
projected[i] = u.DeletedTombstone()
|
||||
continue
|
||||
}
|
||||
if pj, ok := cache[u.ID]; ok {
|
||||
projected[i] = pj
|
||||
continue
|
||||
|
|
@ -276,13 +282,14 @@ func dedupNonZeroInt64(ids []int64) []int64 {
|
|||
// WithProfilePhotos enriches users with their current avatar from profile photo storage.
|
||||
// The lookup is best-effort: a storage error keeps the original user list.
|
||||
func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users []domain.User) []domain.User {
|
||||
users = sanitizeDeletedUsers(users)
|
||||
if photos == nil || len(users) == 0 {
|
||||
return users
|
||||
}
|
||||
ids := make([]int64, 0, len(users))
|
||||
seen := make(map[int64]struct{}, len(users))
|
||||
for _, u := range users {
|
||||
if u.ID == 0 {
|
||||
if u.ID == 0 || u.Deleted {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[u.ID]; ok {
|
||||
|
|
@ -312,6 +319,7 @@ func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users [
|
|||
// In particular, phone is visible for self and contacts; non-contacts should not
|
||||
// receive a phone field because TDesktop will prefer it over the public name.
|
||||
func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
users = sanitizeDeletedUsers(users)
|
||||
if contacts == nil || viewerUserID == 0 || len(users) == 0 {
|
||||
return users, nil
|
||||
}
|
||||
|
|
@ -320,7 +328,7 @@ func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID in
|
|||
cache := make(map[int64]domain.User, len(users))
|
||||
for i := range out {
|
||||
u := out[i]
|
||||
if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
|
||||
if u.ID == 0 || u.Deleted || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
|
||||
continue
|
||||
}
|
||||
if projected, ok := cache[u.ID]; ok {
|
||||
|
|
@ -352,6 +360,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
}
|
||||
out := make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
out = sanitizeDeletedUsers(out)
|
||||
ids := uniqueUserIDs(out)
|
||||
var (
|
||||
profileRefs = map[int64]domain.ProfilePhotoRef{}
|
||||
|
|
@ -430,6 +439,10 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
if u.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if u.Deleted {
|
||||
out[i] = u.DeletedTombstone()
|
||||
continue
|
||||
}
|
||||
if projected, ok := cache[u.ID]; ok {
|
||||
out[i] = projected
|
||||
continue
|
||||
|
|
@ -463,7 +476,7 @@ func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, vi
|
|||
ids := make([]int64, 0, len(users))
|
||||
seen := make(map[int64]struct{}, len(users))
|
||||
for _, u := range users {
|
||||
if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
|
||||
if u.ID == 0 || u.Deleted || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[u.ID]; ok {
|
||||
|
|
@ -479,6 +492,9 @@ func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, vi
|
|||
}
|
||||
|
||||
func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID int64, user domain.User) (domain.User, error) {
|
||||
if user.Deleted {
|
||||
return user.DeletedTombstone(), nil
|
||||
}
|
||||
contact, found, err := contacts.Get(ctx, viewerUserID, user.ID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
|
|
@ -513,7 +529,7 @@ func uniqueUserIDs(users []domain.User) []int64 {
|
|||
seen := make(map[int64]struct{}, len(users))
|
||||
ids := make([]int64, 0, len(users))
|
||||
for _, user := range users {
|
||||
if user.ID == 0 {
|
||||
if user.ID == 0 || user.Deleted {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[user.ID]; ok {
|
||||
|
|
@ -526,6 +542,9 @@ func uniqueUserIDs(users []domain.User) []int64 {
|
|||
}
|
||||
|
||||
func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef, viewerUserID int64) domain.User {
|
||||
if user.Deleted {
|
||||
return user.DeletedTombstone()
|
||||
}
|
||||
if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) {
|
||||
return user
|
||||
}
|
||||
|
|
@ -548,6 +567,9 @@ func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs m
|
|||
}
|
||||
|
||||
func applyContactProjection(user domain.User, contact domain.Contact, found bool) domain.User {
|
||||
if user.Deleted {
|
||||
return user.DeletedTombstone()
|
||||
}
|
||||
if !found {
|
||||
user.Phone = ""
|
||||
user.Contact = false
|
||||
|
|
@ -574,6 +596,9 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
|
|||
}
|
||||
|
||||
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
|
||||
if user.Deleted {
|
||||
return user.DeletedTombstone(), nil
|
||||
}
|
||||
if privacy == nil {
|
||||
return user, nil
|
||||
}
|
||||
|
|
@ -647,3 +672,21 @@ func clearPhoto(user *domain.User) {
|
|||
user.PhotoPersonal = false
|
||||
user.PhotoHasVideo = false
|
||||
}
|
||||
|
||||
func sanitizeDeletedUsers(users []domain.User) []domain.User {
|
||||
var out []domain.User
|
||||
for i, user := range users {
|
||||
if !user.Deleted {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
}
|
||||
out[i] = user.DeletedTombstone()
|
||||
}
|
||||
if out != nil {
|
||||
return out
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue