Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877

This commit is contained in:
onysd 2026-08-03 23:29:20 +03:00
commit ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions

View file

@ -870,27 +870,24 @@ func (s *Service) CancelCodeForAuthKey(ctx context.Context, authKeyID [8]byte, p
// never offers a "Can't access this email?" escape hatch that can only ever
// fail (or, before this was locked down, silently succeed with the
// well-known fixed dev code).
//
// emailSignupEnabled accounts fail this even with a real phoneCodeSender:
// their "phone" is a synthetic 888-prefixed display number
// (domain.NewEmailSignupDisplayPhone), never a real number anyone can
// receive SMS on -- email is the actual identity there, regardless of what
// SMS infra exists for other (real-phone) accounts on this server.
func (s *Service) LoginEmailResetAvailable() bool {
return s.phoneCodeSender != nil && !s.emailSignupEnabled
return s != nil && s.phoneCodeSender != nil && s.codes != nil && s.users != nil && !s.emailSignupEnabled
}
// 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) {
// This flow exists to fall back to an SMS code when the login email is
// unreachable. Two independent reasons it must refuse outright, before
// ClearLoginEmail runs so nothing is ever mutated on a doomed request:
// - no real phoneCodeSender: the "SMS code" is always the well-known
// TELESRV_DEV_AUTH_CODE (see createPhoneCode), so anyone who can call
// sendCode for a phone (no email access required) could strip the
// login-email requirement with a publicly known code.
// - emailSignupEnabled: this account's "phone" is a synthetic 888-
// prefixed display number (domain.NewEmailSignupDisplayPhone), never
// a real number anyone can receive SMS on. Email is the actual
// identity here regardless of whether a real SMS sender happens to
// be configured for other (real-phone) accounts on this server.
if s.phoneCodeSender == nil || s.emailSignupEnabled {
// 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)
@ -1488,18 +1485,7 @@ func (s *Service) ResetAuthorization(ctx context.Context, userID, hash int64) (d
if revoker, ok := s.auths.(authorizationRevoker); ok {
return revoker.RevokeByHash(ctx, userID, hash)
}
target, found, err := s.authorizationByHash(ctx, userID, hash)
if err != nil || !found {
return target, found, err
}
if err := s.deleteAuthKey(ctx, target.AuthKeyID); err != nil {
return target, true, err
}
deleted, found, err := s.auths.DeleteByHash(ctx, userID, hash)
if err != nil || !found {
return deleted, found, err
}
return deleted, true, nil
return s.auths.DeleteByHash(ctx, userID, hash)
}
func (s *Service) ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
@ -1509,54 +1495,7 @@ func (s *Service) ResetAuthorizations(ctx context.Context, userID int64, keepAut
if revoker, ok := s.auths.(authorizationRevoker); ok {
return revoker.RevokeByUserExcept(ctx, userID, keepAuthKeyID)
}
targets, err := s.authorizationsByUserExcept(ctx, userID, keepAuthKeyID)
if err != nil {
return nil, err
}
for _, a := range targets {
if err := s.deleteAuthKey(ctx, a.AuthKeyID); err != nil {
return nil, err
}
}
deleted, err := s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
if err != nil {
return nil, err
}
return deleted, nil
}
func (s *Service) deleteAuthKey(ctx context.Context, authKeyID [8]byte) error {
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
return nil
}
return s.authKeys.Delete(ctx, authKeyID)
}
func (s *Service) authorizationByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
items, err := s.auths.ListByUser(ctx, userID)
if err != nil {
return domain.Authorization{}, false, err
}
for _, a := range items {
if a.Hash == hash {
return a, true, nil
}
}
return domain.Authorization{}, false, nil
}
func (s *Service) authorizationsByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
items, err := s.auths.ListByUser(ctx, userID)
if err != nil {
return nil, err
}
out := make([]domain.Authorization, 0, len(items))
for _, a := range items {
if a.AuthKeyID != keepAuthKeyID {
out = append(out, a)
}
}
return out, nil
return s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
}
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {

View file

@ -535,7 +535,7 @@ func TestLogOutThenSignInSameAuthKeySwitchesUser(t *testing.T) {
}
}
func TestResetAuthorizationDeletesProtocolAuthKey(t *testing.T) {
func TestResetAuthorizationKeepsProtocolAuthKeyForRPCLogout(t *testing.T) {
ctx := context.Background()
authz := memory.NewAuthorizationStore()
keys := memory.NewAuthKeyStore()
@ -562,15 +562,15 @@ func TestResetAuthorizationDeletesProtocolAuthKey(t *testing.T) {
if err != nil || !found || deleted.AuthKeyID != key {
t.Fatalf("ResetAuthorization deleted=%x found=%v err=%v, want key %x", deleted.AuthKeyID, found, err, key)
}
if _, found, err := keys.Get(ctx, key); err != nil || found {
t.Fatalf("auth key after reset found=%v err=%v, want missing", found, err)
if _, found, err := keys.Get(ctx, key); err != nil || !found {
t.Fatalf("auth key after reset found=%v err=%v, want present for RPC 401", found, err)
}
if _, found, err := svc.UserID(ctx, key); err != nil || found {
t.Fatalf("user after reset found=%v err=%v, want missing", found, err)
}
}
func TestResetAuthorizationsDeletesOnlyRevokedProtocolAuthKeys(t *testing.T) {
func TestResetAuthorizationsKeepsRevokedProtocolAuthKeys(t *testing.T) {
ctx := context.Background()
authz := memory.NewAuthorizationStore()
keys := memory.NewAuthKeyStore()
@ -600,12 +600,18 @@ func TestResetAuthorizationsDeletesOnlyRevokedProtocolAuthKeys(t *testing.T) {
if err != nil || len(deleted) != 1 || deleted[0].AuthKeyID != revoked {
t.Fatalf("ResetAuthorizations deleted=%v err=%v, want revoked key", deleted, err)
}
if _, found, err := keys.Get(ctx, revoked); err != nil || found {
t.Fatalf("revoked auth key found=%v err=%v, want missing", found, err)
if _, found, err := keys.Get(ctx, revoked); err != nil || !found {
t.Fatalf("revoked auth key found=%v err=%v, want present for RPC 401", found, err)
}
if _, found, err := keys.Get(ctx, keep); err != nil || !found {
t.Fatalf("kept auth key found=%v err=%v, want present", found, err)
}
if _, found, err := svc.UserID(ctx, revoked); err != nil || found {
t.Fatalf("revoked user found=%v err=%v, want missing", found, err)
}
if got, found, err := svc.UserID(ctx, keep); err != nil || !found || got != u.ID {
t.Fatalf("kept user=%d found=%v err=%v, want %d", got, found, err, u.ID)
}
}
func TestSignUpWritesOfficialLoginMessage(t *testing.T) {

View file

@ -443,8 +443,12 @@ func TestConsumeLoginEmailResetRequiresExactIssuedHash(t *testing.T) {
users := &switchablePhoneOwnerStore{UserStore: baseUsers}
codes := memory.NewCodeStore()
delivery := &captureLoginCodeDelivery{}
otp := &captureOTPSender{}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithLoginCodeDelivery(delivery), WithPhoneCodeDelivery(&captureOTPSender{}, 5))
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{
@ -501,6 +505,33 @@ func TestConsumeLoginEmailResetRequiresExactIssuedHash(t *testing.T) {
}
}
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()