fix: sync harden auth, privacy, and PTS state

This commit is contained in:
iamxvbaba 2026-08-02 14:12:04 +08:00
parent c1597696af
commit 2512eab51d
24 changed files with 817 additions and 154 deletions

View file

@ -775,10 +775,23 @@ func (s *Service) CancelCodeForAuthKey(ctx context.Context, authKeyID [8]byte, p
return s.cancelCode(ctx, authKeyID, phone, phoneCodeHash)
}
// LoginEmailResetAvailable reports whether this deployment can complete the
// SMS fallback promised by auth.resetLoginEmail. Fixed development codes are
// deliberately not a recovery channel.
func (s *Service) LoginEmailResetAvailable() bool {
return s != nil && s.phoneCodeSender != nil && s.codes != nil && s.users != nil
}
// ConsumeLoginEmailReset authorizes auth.resetLoginEmail with the exact
// email-login hash previously issued for this phone owner. Possession of only
// a phone number is never sufficient to remove an authentication factor.
func (s *Service) ConsumeLoginEmailReset(ctx context.Context, phone, phoneCodeHash string) (int64, error) {
// Refuse before consuming the email proof or clearing any account state. If
// there is no real SMS sender, the successor code would be the public
// development code and could strip the login-email factor.
if !s.LoginEmailResetAvailable() || s.codes == nil {
return 0, ErrCodeInvalid
}
phone = normalizePhone(phone)
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
if err != nil {

View file

@ -399,7 +399,12 @@ func TestConsumeLoginEmailResetRequiresExactIssuedHash(t *testing.T) {
users := &switchablePhoneOwnerStore{UserStore: baseUsers}
codes := memory.NewCodeStore()
delivery := &captureLoginCodeDelivery{}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
otp := &captureOTPSender{}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithLoginCodeDelivery(delivery), WithPhoneCodeDelivery(otp, 5))
if !svc.LoginEmailResetAvailable() {
t.Fatal("LoginEmailResetAvailable=false with real SMS sender")
}
seed := func(hash, channel string) {
t.Helper()
if err := codes.Set(ctx, hash, store.PhoneCode{
@ -451,11 +456,38 @@ func TestConsumeLoginEmailResetRequiresExactIssuedHash(t *testing.T) {
if len(delivery.requests) != 1 || delivery.requests[0].UserID != owner.ID || delivery.requests[0].PhoneCodeHash != replacementHash {
t.Fatalf("replacement delivery=%+v", delivery.requests)
}
if rec, found, err := codes.Get(ctx, replacementHash); err != nil || !found || rec.Version != store.PhoneCodeVersionCurrent || rec.IssuedUserID != owner.ID || rec.Channel != codeChannelPhone {
if rec, found, err := codes.Get(ctx, replacementHash); err != nil || !found || rec.Version != store.PhoneCodeVersionCurrent || rec.IssuedUserID != owner.ID || rec.Channel != codeChannelSMS {
t.Fatalf("replacement code=%+v found=%v err=%v", rec, found, err)
}
}
func TestLoginEmailResetUnavailableWithoutRealSMSSender(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
owner, err := users.Create(ctx, domain.User{Phone: "15550009339", FirstName: "Owner"})
if err != nil {
t.Fatal(err)
}
codes := memory.NewCodeStore()
const hash = "unavailable-email-reset"
if err := codes.Set(ctx, hash, store.PhoneCode{
Version: store.PhoneCodeVersionCurrent, IssuedUserID: owner.ID,
Phone: owner.Phone, Code: "654321", Channel: codeChannelEmailLogin,
}, time.Minute); err != nil {
t.Fatal(err)
}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345")
if svc.LoginEmailResetAvailable() {
t.Fatal("LoginEmailResetAvailable=true without real SMS sender")
}
if _, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, hash); !errors.Is(err, ErrCodeInvalid) {
t.Fatalf("ConsumeLoginEmailReset err=%v, want invalid", err)
}
if _, found, err := codes.Get(ctx, hash); err != nil || !found {
t.Fatalf("unavailable reset consumed proof found=%v err=%v", found, err)
}
}
func TestConcurrentLoginEmailResetHasSingleConsumer(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
@ -475,7 +507,8 @@ func TestConcurrentLoginEmailResetHasSingleConsumer(t *testing.T) {
}, time.Minute); err != nil {
t.Fatalf("seed code: %v", err)
}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345")
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithPhoneCodeDelivery(&captureOTPSender{}, 5))
const workers = 24
start := make(chan struct{})
errs := make(chan error, workers)
@ -536,7 +569,8 @@ func TestLoginEmailResetLocksUserAcrossOwnerTransfer(t *testing.T) {
t.Fatalf("seed reset code: %v", err)
}
delivery := &captureLoginCodeDelivery{}
authSvc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
authSvc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithLoginCodeDelivery(delivery), WithPhoneCodeDelivery(&captureOTPSender{}, 5))
resetUserID, err := authSvc.ConsumeLoginEmailReset(ctx, ownerA.Phone, hash)
if err != nil || resetUserID != ownerA.ID {
t.Fatalf("ConsumeLoginEmailReset uid=%d err=%v", resetUserID, err)