perf: sync protocol and core hardening updates
This commit is contained in:
parent
152fed3b87
commit
4390ebf5a9
283 changed files with 29231 additions and 2295 deletions
|
|
@ -15,6 +15,7 @@ func TestResendCodePreservesChangePhoneScopeAndSMSDelivery(t *testing.T) {
|
|||
codes := memory.NewCodeStore()
|
||||
authKeyID := [8]byte{8, 7, 6}
|
||||
rec := store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: "15550014001",
|
||||
Code: "old",
|
||||
Channel: codeChannelPhone,
|
||||
|
|
@ -69,3 +70,34 @@ func TestResendCodePreservesChangePhoneScopeAndSMSDelivery(t *testing.T) {
|
|||
t.Fatal("scoped cancel left hash valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResendAndCancelRejectLegacyChangePhoneCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := memory.NewCodeStore()
|
||||
authKeyID := [8]byte{8, 8, 8}
|
||||
legacy := store.PhoneCode{
|
||||
Version: 0, Phone: "15550014002", Code: "12345", Channel: codeChannelPhone,
|
||||
Purpose: store.PhoneCodePurposeChangePhone, UserID: 43, AuthKeyID: authKeyID,
|
||||
}
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithCodeTTL(time.Minute))
|
||||
|
||||
if err := codes.Set(ctx, "legacy-resend", legacy, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ResendCodeForAuthKey(ctx, authKeyID, legacy.Phone, "legacy-resend"); err != ErrCodeExpired {
|
||||
t.Fatalf("legacy resend err=%v, want ErrCodeExpired", err)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "legacy-resend"); found {
|
||||
t.Fatal("legacy resend left code active")
|
||||
}
|
||||
|
||||
if err := codes.Set(ctx, "legacy-cancel", legacy, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.CancelCodeForAuthKey(ctx, authKeyID, legacy.Phone, "legacy-cancel"); err != ErrCodeExpired {
|
||||
t.Fatalf("legacy cancel err=%v, want ErrCodeExpired", err)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "legacy-cancel"); found {
|
||||
t.Fatal("legacy cancel left code active")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
366
internal/app/auth/login_code_delivery_test.go
Normal file
366
internal/app/auth/login_code_delivery_test.go
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type captureLoginCodeDelivery struct {
|
||||
requests []domain.LoginCodeDeliveryRequest
|
||||
result domain.LoginCodeDeliveryResult
|
||||
err error
|
||||
failAt int
|
||||
}
|
||||
|
||||
func (d *captureLoginCodeDelivery) DeliverLoginCodeMessage(_ context.Context, req domain.LoginCodeDeliveryRequest) (domain.LoginCodeDeliveryResult, error) {
|
||||
d.requests = append(d.requests, req)
|
||||
if d.err != nil && (d.failAt == 0 || len(d.requests) == d.failAt) {
|
||||
return domain.LoginCodeDeliveryResult{}, d.err
|
||||
}
|
||||
return d.result, nil
|
||||
}
|
||||
|
||||
type trackingCodeStore struct {
|
||||
store.CodeStore
|
||||
lastSetHash string
|
||||
deleted []string
|
||||
deleteCtx []error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func (s *trackingCodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
|
||||
s.lastSetHash = hash
|
||||
return s.CodeStore.Set(ctx, hash, code, ttl)
|
||||
}
|
||||
|
||||
func (s *trackingCodeStore) Del(ctx context.Context, hash string) error {
|
||||
s.deleted = append(s.deleted, hash)
|
||||
s.deleteCtx = append(s.deleteCtx, ctx.Err())
|
||||
if s.deleteErr != nil {
|
||||
return s.deleteErr
|
||||
}
|
||||
return s.CodeStore.Del(ctx, hash)
|
||||
}
|
||||
|
||||
func TestExistingAccountSendCodeDeliversBeforeSignInAndDoesNotRedeliver(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
codes := memory.NewCodeStore()
|
||||
u, err := users.Create(ctx, domain.User{Phone: "15550009201", FirstName: "Existing"})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
delivery := &captureLoginCodeDelivery{result: domain.LoginCodeDeliveryResult{Created: true}}
|
||||
svc := NewService(users, authz, codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
|
||||
|
||||
before := int(time.Now().Unix())
|
||||
hash, err := svc.SendCode(ctx, "+1 555 000 9201")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
if hash == "" || len(delivery.requests) != 1 {
|
||||
t.Fatalf("SendCode hash=%q delivery calls=%d, want non-empty/1", hash, len(delivery.requests))
|
||||
}
|
||||
req := delivery.requests[0]
|
||||
if req.UserID != u.ID || req.PhoneCodeHash != hash || req.Code != "12345" || req.Date < before || req.ExpiresAt < int64(before)+int64((5*time.Minute)/time.Second)-1 {
|
||||
t.Fatalf("delivery request = %+v, want user=%d hash=%q code=12345 date>=%d", req, u.ID, hash, before)
|
||||
}
|
||||
if rec, found, err := codes.Get(ctx, hash); err != nil || !found || rec.Code != "12345" {
|
||||
t.Fatalf("code after synchronous delivery = %+v found=%v err=%v", rec, found, err)
|
||||
}
|
||||
|
||||
var key [8]byte
|
||||
key[0] = 0x92
|
||||
got, lateMessage, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, "+15550009201", hash, "12345")
|
||||
if err != nil || needSignUp || got.ID != u.ID {
|
||||
t.Fatalf("SignIn user=%d needSignUp=%v err=%v, want %d/false", got.ID, needSignUp, err, u.ID)
|
||||
}
|
||||
if lateMessage.ID != 0 || len(delivery.requests) != 1 {
|
||||
t.Fatalf("SignIn lateMessage=%+v delivery calls=%d, want zero/unchanged", lateMessage, len(delivery.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveredLoginCodeSurvivesWrongSignInAndCancelWithoutDuplicate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
u, err := users.Create(ctx, domain.User{Phone: "15550009208", FirstName: "Cancel"})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
codes := memory.NewCodeStore()
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
events := memory.NewUpdateEventStore()
|
||||
delivery := memory.NewLoginCodeDeliveryStore(messages, events)
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
|
||||
|
||||
hash, err := svc.SendCode(ctx, "15550009208")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
assertFacts := func(stage string) {
|
||||
t.Helper()
|
||||
history, historyErr := messages.ListByUser(ctx, u.ID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
Limit: 10,
|
||||
})
|
||||
durable, eventErr := events.ListAfter(ctx, u.ID, 0, 10)
|
||||
if historyErr != nil || eventErr != nil || len(history.Messages) != 1 || len(durable) != 1 {
|
||||
t.Fatalf("%s messages=%d events=%d historyErr=%v eventErr=%v, want 1/1", stage, len(history.Messages), len(durable), historyErr, eventErr)
|
||||
}
|
||||
}
|
||||
assertFacts("after SendCode")
|
||||
|
||||
if _, late, _, err := svc.SignIn(ctx, domain.Authorization{}, "15550009208", hash, "00000"); !errors.Is(err, ErrCodeInvalid) || late.ID != 0 {
|
||||
t.Fatalf("wrong SignIn late=%+v err=%v, want ErrCodeInvalid/no message", late, err)
|
||||
}
|
||||
assertFacts("after wrong SignIn")
|
||||
|
||||
if err := svc.CancelCode(ctx, "15550009208", hash); err != nil {
|
||||
t.Fatalf("CancelCode: %v", err)
|
||||
}
|
||||
assertFacts("after CancelCode")
|
||||
if _, late, _, err := svc.SignIn(ctx, domain.Authorization{}, "15550009208", hash, "12345"); !errors.Is(err, ErrCodeExpired) || late.ID != 0 {
|
||||
t.Fatalf("SignIn after cancel late=%+v err=%v, want ErrCodeExpired/no message", late, err)
|
||||
}
|
||||
assertFacts("after canceled SignIn")
|
||||
}
|
||||
|
||||
func TestCodeIssuedBeforeConcurrentOwnerCreationIsRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
codes := memory.NewCodeStore()
|
||||
delivery := &captureLoginCodeDelivery{}
|
||||
svc := NewService(users, authz, codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
|
||||
|
||||
hash, err := svc.SendCode(ctx, "15550009209")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode before signup: %v", err)
|
||||
}
|
||||
rec, found, err := codes.Get(ctx, hash)
|
||||
if err != nil || !found || rec.Version != store.PhoneCodeVersionCurrent || rec.IssuedUserID != 0 || rec.SignUpVerified || len(delivery.requests) != 0 {
|
||||
t.Fatalf("pre-signup code=%+v found=%v err=%v deliveries=%d", rec, found, err, len(delivery.requests))
|
||||
}
|
||||
u, err := users.Create(ctx, domain.User{Phone: "15550009209", FirstName: "Concurrent"})
|
||||
if err != nil {
|
||||
t.Fatalf("concurrent create user: %v", err)
|
||||
}
|
||||
var key [8]byte
|
||||
key[0] = 0x93
|
||||
got, lateMessage, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, "15550009209", hash, "12345")
|
||||
if !errors.Is(err, ErrCodeInvalid) || needSignUp || got.ID != 0 || lateMessage.ID != 0 {
|
||||
t.Fatalf("SignIn after owner creation got=%+v late=%+v needSignUp=%v err=%v, want invalid", got, lateMessage, needSignUp, err)
|
||||
}
|
||||
if len(delivery.requests) != 0 {
|
||||
t.Fatalf("owner-transfer code was delivered to new owner: %+v", delivery.requests)
|
||||
}
|
||||
if bound, ok, err := svc.UserID(ctx, key); err != nil || ok || bound != 0 {
|
||||
t.Fatalf("bound user=%d ok=%v err=%v, want no authorization (created uid=%d)", bound, ok, err, u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAccountRepeatedSendCodeDeliversEachIssuedHashWithoutSignIn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
if _, err := users.Create(ctx, domain.User{Phone: "15550009202"}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
delivery := &captureLoginCodeDelivery{}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithLoginCodeDelivery(delivery))
|
||||
|
||||
first, err := svc.SendCode(ctx, "15550009202")
|
||||
if err != nil {
|
||||
t.Fatalf("first SendCode: %v", err)
|
||||
}
|
||||
second, err := svc.SendCode(ctx, "15550009202")
|
||||
if err != nil {
|
||||
t.Fatalf("second SendCode: %v", err)
|
||||
}
|
||||
if first == second || len(delivery.requests) != 2 {
|
||||
t.Fatalf("hashes=%q/%q delivery calls=%d, want distinct/2", first, second, len(delivery.requests))
|
||||
}
|
||||
if delivery.requests[0].PhoneCodeHash != first || delivery.requests[1].PhoneCodeHash != second {
|
||||
t.Fatalf("delivery hashes = %q/%q, want %q/%q", delivery.requests[0].PhoneCodeHash, delivery.requests[1].PhoneCodeHash, first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAccountSendCodeDeliveryFailureRevokesCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
if _, err := users.Create(ctx, domain.User{Phone: "15550009203"}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
baseCodes := memory.NewCodeStore()
|
||||
codes := &trackingCodeStore{CodeStore: baseCodes}
|
||||
deliveryCause := errors.New("durable write failed")
|
||||
delivery := &captureLoginCodeDelivery{err: deliveryCause}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
|
||||
|
||||
hash, err := svc.SendCode(ctx, "15550009203")
|
||||
if hash != "" || !errors.Is(err, ErrLoginCodeDeliveryFailed) || !errors.Is(err, deliveryCause) {
|
||||
t.Fatalf("SendCode hash=%q err=%v, want empty ErrLoginCodeDeliveryFailed+cause", hash, err)
|
||||
}
|
||||
if codes.lastSetHash == "" || len(codes.deleted) != 1 || codes.deleted[0] != codes.lastSetHash {
|
||||
t.Fatalf("set hash=%q deleted=%v, want exact rollback", codes.lastSetHash, codes.deleted)
|
||||
}
|
||||
if _, found, getErr := baseCodes.Get(ctx, codes.lastSetHash); getErr != nil || found {
|
||||
t.Fatalf("rolled-back hash found=%v err=%v", found, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAccountAmbiguousDeliveryPreservesCodeForIdempotentRetry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
if _, err := users.Create(ctx, domain.User{Phone: "15550009213"}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
baseCodes := memory.NewCodeStore()
|
||||
codes := &trackingCodeStore{CodeStore: baseCodes}
|
||||
delivery := &captureLoginCodeDelivery{err: domain.ErrLoginCodeDeliveryCommitAmbiguous}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
|
||||
|
||||
hash, err := svc.SendCode(ctx, "15550009213")
|
||||
if hash != "" || !errors.Is(err, ErrLoginCodeDeliveryFailed) || !errors.Is(err, domain.ErrLoginCodeDeliveryCommitAmbiguous) {
|
||||
t.Fatalf("SendCode hash=%q err=%v, want ambiguous delivery failure", hash, err)
|
||||
}
|
||||
if codes.lastSetHash == "" || len(codes.deleted) != 0 {
|
||||
t.Fatalf("ambiguous delivery set=%q deleted=%v, want code preserved", codes.lastSetHash, codes.deleted)
|
||||
}
|
||||
if rec, found, getErr := baseCodes.Get(ctx, codes.lastSetHash); getErr != nil || !found || rec.Code != "12345" {
|
||||
t.Fatalf("ambiguous delivery code=%+v found=%v err=%v", rec, found, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitDeliveryFailureRollsBackWithDetachedContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
users := memory.NewUserStore()
|
||||
if _, err := users.Create(context.Background(), domain.User{Phone: "15550009214"}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
baseCodes := memory.NewCodeStore()
|
||||
codes := &trackingCodeStore{CodeStore: baseCodes}
|
||||
delivery := &captureLoginCodeDelivery{err: errors.New("definite rollback")}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
|
||||
|
||||
if hash, err := svc.SendCode(ctx, "15550009214"); hash != "" || !errors.Is(err, ErrLoginCodeDeliveryFailed) {
|
||||
t.Fatalf("SendCode hash=%q err=%v, want definite failure", hash, err)
|
||||
}
|
||||
if len(codes.deleted) != 1 || len(codes.deleteCtx) != 1 || codes.deleteCtx[0] != nil {
|
||||
t.Fatalf("rollback deleted=%v ctxErr=%v, want one detached delete", codes.deleted, codes.deleteCtx)
|
||||
}
|
||||
if _, found, err := baseCodes.Get(context.Background(), codes.lastSetHash); err != nil || found {
|
||||
t.Fatalf("detached rollback found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAccountMissingDeliveryFailsClosedAndRevokesCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
if _, err := users.Create(ctx, domain.User{Phone: "15550009204"}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
baseCodes := memory.NewCodeStore()
|
||||
codes := &trackingCodeStore{CodeStore: baseCodes}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345")
|
||||
|
||||
hash, err := svc.SendCode(ctx, "15550009204")
|
||||
if hash != "" || !errors.Is(err, ErrLoginCodeDeliveryUnavailable) {
|
||||
t.Fatalf("SendCode hash=%q err=%v, want unavailable", hash, err)
|
||||
}
|
||||
if codes.lastSetHash == "" {
|
||||
t.Fatal("missing delivery was checked before code creation; want rollback path covered")
|
||||
}
|
||||
if _, found, getErr := baseCodes.Get(ctx, codes.lastSetHash); getErr != nil || found {
|
||||
t.Fatalf("unavailable delivery hash found=%v err=%v", found, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAccountResendDeliversNewHashAndInvalidatesOld(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
if _, err := users.Create(ctx, domain.User{Phone: "15550009205"}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
codes := memory.NewCodeStore()
|
||||
delivery := &captureLoginCodeDelivery{}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
|
||||
|
||||
oldHash, err := svc.SendCode(ctx, "15550009205")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
newHash, err := svc.ResendCode(ctx, "15550009205", oldHash)
|
||||
if err != nil {
|
||||
t.Fatalf("ResendCode: %v", err)
|
||||
}
|
||||
if oldHash == newHash || len(delivery.requests) != 2 || delivery.requests[1].PhoneCodeHash != newHash {
|
||||
t.Fatalf("old/new=%q/%q deliveries=%+v", oldHash, newHash, delivery.requests)
|
||||
}
|
||||
if _, found, err := codes.Get(ctx, oldHash); err != nil || found {
|
||||
t.Fatalf("old code found=%v err=%v", found, err)
|
||||
}
|
||||
if _, found, err := codes.Get(ctx, newHash); err != nil || !found {
|
||||
t.Fatalf("new code found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAccountResendDeliveryFailureLeavesNoUsableCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
if _, err := users.Create(ctx, domain.User{Phone: "15550009206"}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
codes := memory.NewCodeStore()
|
||||
delivery := &captureLoginCodeDelivery{err: errors.New("second delivery failed"), failAt: 2}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
|
||||
|
||||
oldHash, err := svc.SendCode(ctx, "15550009206")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
newHash, err := svc.ResendCode(ctx, "15550009206", oldHash)
|
||||
if newHash != "" || !errors.Is(err, ErrLoginCodeDeliveryFailed) || len(delivery.requests) != 2 {
|
||||
t.Fatalf("ResendCode hash=%q err=%v deliveries=%d", newHash, err, len(delivery.requests))
|
||||
}
|
||||
failedHash := delivery.requests[1].PhoneCodeHash
|
||||
for _, hash := range []string{oldHash, failedHash} {
|
||||
if _, found, getErr := codes.Get(ctx, hash); getErr != nil || found {
|
||||
t.Fatalf("failed resend hash %q found=%v err=%v", hash, found, getErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredEmailLoginDoesNotLeakCodeThroughAppDelivery(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
if _, err := users.Create(ctx, domain.User{Phone: "15550009207"}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
emails := &testLoginEmailStore{emails: map[string]string{"15550009207": "secure@example.test"}}
|
||||
mailSender := &testMailSender{}
|
||||
delivery := &captureLoginCodeDelivery{}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithLoginEmail(LoginEmailOptions{Enabled: true, CodeLength: 6, Store: emails, Sender: mailSender}),
|
||||
WithLoginCodeDelivery(delivery),
|
||||
)
|
||||
|
||||
if _, err := svc.SendCode(ctx, "15550009207"); err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
if mailSender.to != "secure@example.test" || mailSender.code == "" {
|
||||
t.Fatalf("email delivery = %q/%q", mailSender.to, mailSender.code)
|
||||
}
|
||||
if len(delivery.requests) != 0 {
|
||||
t.Fatalf("email code leaked into app delivery: %+v", delivery.requests)
|
||||
}
|
||||
}
|
||||
|
|
@ -19,8 +19,7 @@ func (s *testLoginEmailStore) LoginEmailByPhone(_ context.Context, phone string)
|
|||
return email, ok, nil
|
||||
}
|
||||
|
||||
func (s *testLoginEmailStore) SetLoginEmailByPhone(_ context.Context, phone, email string) error {
|
||||
s.emails[domain.NormalizePhone(phone)] = email
|
||||
func (s *testLoginEmailStore) SetLoginEmail(_ context.Context, _ int64, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,13 +9,17 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// TestSignInWithEmailCompletesLogin 验证带 email_verification 的登录:注册账号→登出→
|
||||
// 重新 sendCode→用任意邮箱验证码经 SignInWithEmail 完成登录。
|
||||
// TestSignInWithEmailCompletesLogin 验证旧客户端把 phone channel 放进
|
||||
// email_verification 时仍可登录,但验证码必须精确匹配,不能用任意非空值绕过。
|
||||
func TestSignInWithEmailCompletesLogin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345")
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithLoginCodeDelivery(memory.NewLoginCodeDeliveryStore(messages, memory.NewUpdateEventStore())),
|
||||
)
|
||||
var key [8]byte
|
||||
key[0] = 0x42
|
||||
|
||||
|
|
@ -23,6 +27,7 @@ func TestSignInWithEmailCompletesLogin(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendCode signup: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, "+15550009001", hash, "12345")
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550009001", hash, "Email", "Login")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
|
|
@ -35,7 +40,10 @@ func TestSignInWithEmailCompletesLogin(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendCode signin: %v", err)
|
||||
}
|
||||
got, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009001", hash, "anything-goes")
|
||||
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009001", hash, "anything-goes"); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("SignInWithEmail arbitrary nonempty code err=%v, want ErrCodeInvalid", err)
|
||||
}
|
||||
got, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009001", hash, "12345")
|
||||
if err != nil {
|
||||
t.Fatalf("SignInWithEmail: %v", err)
|
||||
}
|
||||
|
|
@ -48,7 +56,7 @@ func TestSignInWithEmailCompletesLogin(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestSignInWithEmailRejectsEmptyCode 空邮箱验证码必须被拒(即使开发环境码任意,也不能空)。
|
||||
// TestSignInWithEmailRejectsEmptyCode 空邮箱验证码必须被拒。
|
||||
func TestSignInWithEmailRejectsEmptyCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345")
|
||||
|
|
@ -66,7 +74,12 @@ func TestSignInWithEmailRejectsEmptyCode(t *testing.T) {
|
|||
func TestSignInWithEmailStillHonorsTwoFactor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
passwords := memory.NewPasswordStore()
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithPasswords(passwords))
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithPasswords(passwords),
|
||||
WithLoginCodeDelivery(memory.NewLoginCodeDeliveryStore(messages, memory.NewUpdateEventStore())),
|
||||
)
|
||||
var key [8]byte
|
||||
key[0] = 0x43
|
||||
|
||||
|
|
@ -74,6 +87,7 @@ func TestSignInWithEmailStillHonorsTwoFactor(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendCode signup: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, "+15550009003", hash, "12345")
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550009003", hash, "Two", "Factor")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
|
|
@ -89,7 +103,7 @@ func TestSignInWithEmailStillHonorsTwoFactor(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendCode signin: %v", err)
|
||||
}
|
||||
got, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009003", hash, "any-email-code")
|
||||
got, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009003", hash, "12345")
|
||||
if !errors.Is(err, domain.ErrSessionPasswordNeeded) {
|
||||
t.Fatalf("SignInWithEmail err = %v, want ErrSessionPasswordNeeded", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ func TestSignUpPremiumGrant(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, "+15550004401", hash, "12345")
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{}, "+15550004401", hash, "Prem", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
|
|
@ -41,6 +42,7 @@ func TestSignUpPremiumGrantDisabled(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, "+15550004402", hash, "12345")
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{}, "+15550004402", hash, "Free", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ var (
|
|||
ErrCodeExpired = errors.New("phone code expired or not found")
|
||||
ErrCodeInvalid = errors.New("phone code invalid")
|
||||
ErrEncryptedMessageInvalid = errors.New("encrypted message invalid")
|
||||
// ErrLoginCodeDeliveryUnavailable 表示已有账号的 app-code 没有可用的
|
||||
// durable message/event/outbox 投递边界。这是服务端配置错误,不能降级成
|
||||
// “继续返回 sentCode,等 signIn 后补发”。
|
||||
ErrLoginCodeDeliveryUnavailable = errors.New("login code durable delivery unavailable")
|
||||
// ErrLoginCodeDeliveryFailed 表示 durable 投递未成功。SendCode/ResendCode
|
||||
// 必须同时撤销刚写入的 CodeStore hash,防止客户拿到无法送达的码。
|
||||
ErrLoginCodeDeliveryFailed = errors.New("login code durable delivery failed")
|
||||
// ErrPhoneNumberInvalid 表示手机号为空或非纯数字/长度越界。
|
||||
// 0090 把 users.phone 唯一约束改为忽略空串的部分索引(bot 行 phone=''),
|
||||
// 因此 phone 校验必须前移到 auth 入口,否则 sendCode/signUp 可无限铸造
|
||||
|
|
@ -40,6 +47,7 @@ const (
|
|||
codeChannelPhone = "phone"
|
||||
codeChannelEmailLogin = "email_login"
|
||||
codeChannelEmailSetupRequired = "email_setup_required"
|
||||
loginCodeRollbackTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// validPhone 校验规范化后的手机号:5-32 位纯数字(上限对齐 users.phone 列宽)。
|
||||
|
|
@ -68,6 +76,7 @@ type Service struct {
|
|||
passwords store.PasswordStore
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
loginCodeDelivery store.LoginCodeDeliveryStore
|
||||
bots store.BotStore
|
||||
fixedCode string
|
||||
codeTTL time.Duration
|
||||
|
|
@ -83,7 +92,7 @@ type Service struct {
|
|||
|
||||
type loginEmailStore interface {
|
||||
LoginEmailByPhone(ctx context.Context, phone string) (string, bool, error)
|
||||
SetLoginEmailByPhone(ctx context.Context, phone, email string) error
|
||||
SetLoginEmail(ctx context.Context, userID int64, email string) error
|
||||
}
|
||||
|
||||
type LoginEmailOptions struct {
|
||||
|
|
@ -102,7 +111,9 @@ type authorizationRevoker interface {
|
|||
// Option 调整登录服务的可选依赖。
|
||||
type Option func(*Service)
|
||||
|
||||
// WithLoginMessages 在登录成功后写入官方系统账号的登录消息与会话摘要。
|
||||
// WithLoginMessages 在新用户注册成功后写入官方系统账号的首条登录消息与会话摘要。
|
||||
// 已有账号的 app 验证码必须在 auth.sendCode/resendCode 阶段通过
|
||||
// WithLoginCodeDelivery 持久化,禁止在 signIn 成功后补发。
|
||||
func WithLoginMessages(messages store.MessageStore, dialogs store.DialogStore) Option {
|
||||
return func(s *Service) {
|
||||
s.messages = messages
|
||||
|
|
@ -110,6 +121,15 @@ func WithLoginMessages(messages store.MessageStore, dialogs store.DialogStore) O
|
|||
}
|
||||
}
|
||||
|
||||
// WithLoginCodeDelivery 注入已有账号 app-code 的 durable 投递边界。
|
||||
// 实现必须以 user_id + phone_code_hash 幂等,并原子写入 777000
|
||||
// message/dialog/user update event/dispatch outbox。
|
||||
func WithLoginCodeDelivery(delivery store.LoginCodeDeliveryStore) Option {
|
||||
return func(s *Service) {
|
||||
s.loginCodeDelivery = delivery
|
||||
}
|
||||
}
|
||||
|
||||
// WithPasswords lets sign-in stop at SESSION_PASSWORD_NEEDED for 2FA accounts.
|
||||
func WithPasswords(passwords store.PasswordStore) Option {
|
||||
return func(s *Service) {
|
||||
|
|
@ -271,53 +291,157 @@ func (s *Service) SendCode(ctx context.Context, phone string) (string, error) {
|
|||
if systemLoginPhoneForbidden(phone) {
|
||||
return "", ErrSystemUserLoginForbidden
|
||||
}
|
||||
existing, found, err := s.currentPhoneOwner(ctx, phone)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lookup login-code recipient: %w", err)
|
||||
}
|
||||
if found && systemUserLoginForbidden(existing) {
|
||||
return "", ErrSystemUserLoginForbidden
|
||||
}
|
||||
issuedUserID := int64(0)
|
||||
if found {
|
||||
issuedUserID = existing.ID
|
||||
}
|
||||
if s.loginEmailEnabled && s.loginEmails != nil {
|
||||
email, found, err := s.loginEmails.LoginEmailByPhone(ctx, phone)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if found && strings.TrimSpace(email) != "" {
|
||||
return s.createEmailLoginCode(ctx, phone, email)
|
||||
return s.createEmailLoginCode(ctx, phone, email, issuedUserID)
|
||||
}
|
||||
if s.loginEmailRequireSetup {
|
||||
return s.createSetupRequiredCode(ctx, phone)
|
||||
return s.createSetupRequiredCode(ctx, phone, issuedUserID)
|
||||
}
|
||||
}
|
||||
return s.createPhoneCode(ctx, phone)
|
||||
return s.createPhoneCode(ctx, phone, issuedUserID)
|
||||
}
|
||||
|
||||
func (s *Service) createPhoneCode(ctx context.Context, phone string) (string, error) {
|
||||
func (s *Service) currentPhoneOwner(ctx context.Context, phone string) (domain.User, bool, error) {
|
||||
if s == nil || s.users == nil {
|
||||
return domain.User{}, false, fmt.Errorf("user store is not configured")
|
||||
}
|
||||
return s.users.ByPhone(ctx, phone)
|
||||
}
|
||||
|
||||
func (s *Service) issuedOwnerMatches(ctx context.Context, phone string, issuedUserID int64) (bool, error) {
|
||||
current, found, err := s.currentPhoneOwner(ctx, phone)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
currentUserID := int64(0)
|
||||
if found {
|
||||
currentUserID = current.ID
|
||||
}
|
||||
return currentUserID == issuedUserID, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureIssuedOwnerAfterSet(ctx context.Context, hash string, rec store.PhoneCode) error {
|
||||
matches, err := s.issuedOwnerMatches(ctx, rec.Phone, rec.IssuedUserID)
|
||||
if err == nil && matches {
|
||||
return nil
|
||||
}
|
||||
cause := err
|
||||
if cause == nil {
|
||||
cause = ErrCodeInvalid
|
||||
}
|
||||
return s.rollbackUndeliveredCode(ctx, hash, cause)
|
||||
}
|
||||
|
||||
func (s *Service) invalidateLoginCodeDetached(ctx context.Context, hash, phone string) {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginCodeRollbackTimeout)
|
||||
defer cancel()
|
||||
_, _ = s.codes.InvalidateLoginCode(cleanupCtx, hash, phone)
|
||||
}
|
||||
|
||||
func (s *Service) createPhoneCode(ctx context.Context, phone string, existingUserID int64) (string, error) {
|
||||
hash, err := randomHex(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.codes.Set(ctx, hash, store.PhoneCode{
|
||||
Phone: phone,
|
||||
Code: s.fixedCode,
|
||||
Channel: codeChannelPhone,
|
||||
MaxAttempts: s.codeMaxAttempts,
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
IssuedUserID: existingUserID,
|
||||
Phone: phone,
|
||||
Code: s.fixedCode,
|
||||
Channel: codeChannelPhone,
|
||||
MaxAttempts: s.codeMaxAttempts,
|
||||
}, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store code: %w", err)
|
||||
}
|
||||
rec := store.PhoneCode{Phone: phone, IssuedUserID: existingUserID}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 新手机号还没有 owner/dialog,只能在 SignUp 创建用户后写第一条
|
||||
// 777000 消息。已有账号则必须在 sendCode RPC 返回前把 app-code
|
||||
// 作为普通 incoming message + durable update/outbox 提交;登录成功不再补发。
|
||||
if existingUserID == 0 {
|
||||
return hash, nil
|
||||
}
|
||||
if err := s.deliverLoginCode(ctx, existingUserID, hash, s.fixedCode); err != nil {
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, err)
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func (s *Service) createSetupRequiredCode(ctx context.Context, phone string) (string, error) {
|
||||
func (s *Service) deliverLoginCode(ctx context.Context, userID int64, phoneCodeHash, code string) error {
|
||||
if s.loginCodeDelivery == nil {
|
||||
return ErrLoginCodeDeliveryUnavailable
|
||||
}
|
||||
now := time.Now()
|
||||
if _, err := s.loginCodeDelivery.DeliverLoginCodeMessage(ctx, domain.LoginCodeDeliveryRequest{
|
||||
UserID: userID,
|
||||
PhoneCodeHash: phoneCodeHash,
|
||||
Code: code,
|
||||
Date: int(now.Unix()),
|
||||
ExpiresAt: now.Add(s.codeTTL).Unix(),
|
||||
}); err != nil {
|
||||
return errors.Join(ErrLoginCodeDeliveryFailed, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) rollbackUndeliveredCode(ctx context.Context, phoneCodeHash string, cause error) error {
|
||||
// lib/pq can report an I/O failure after COMMIT reached PostgreSQL. In that
|
||||
// state deleting the code could turn an already delivered 777000 message
|
||||
// into an unusable login attempt. Preserve it and let the delivery receipt
|
||||
// make the retry idempotent.
|
||||
if errors.Is(cause, domain.ErrLoginCodeDeliveryCommitAmbiguous) {
|
||||
return cause
|
||||
}
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginCodeRollbackTimeout)
|
||||
defer cancel()
|
||||
if err := s.codes.Del(cleanupCtx, phoneCodeHash); err != nil {
|
||||
return errors.Join(cause, fmt.Errorf("rollback undelivered login code: %w", err))
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func (s *Service) createSetupRequiredCode(ctx context.Context, phone string, issuedUserID int64) (string, error) {
|
||||
hash, err := randomHex(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.codes.Set(ctx, hash, store.PhoneCode{
|
||||
Phone: phone,
|
||||
Channel: codeChannelEmailSetupRequired,
|
||||
MaxAttempts: s.codeMaxAttempts,
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
IssuedUserID: issuedUserID,
|
||||
Phone: phone,
|
||||
Channel: codeChannelEmailSetupRequired,
|
||||
MaxAttempts: s.codeMaxAttempts,
|
||||
}, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store code: %w", err)
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, store.PhoneCode{Phone: phone, IssuedUserID: issuedUserID}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string) (string, error) {
|
||||
func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string, issuedUserID int64) (string, error) {
|
||||
hash, err := randomHex(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -327,22 +451,28 @@ func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string)
|
|||
return "", err
|
||||
}
|
||||
rec := store.PhoneCode{
|
||||
Phone: phone,
|
||||
Code: code,
|
||||
Channel: codeChannelEmailLogin,
|
||||
Email: strings.TrimSpace(email),
|
||||
MaxAttempts: s.codeMaxAttempts,
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
IssuedUserID: issuedUserID,
|
||||
Phone: phone,
|
||||
Code: code,
|
||||
Channel: codeChannelEmailLogin,
|
||||
Email: strings.TrimSpace(email),
|
||||
MaxAttempts: s.codeMaxAttempts,
|
||||
}
|
||||
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store email code: %w", err)
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.loginEmailSender == nil {
|
||||
_ = s.codes.Del(ctx, hash)
|
||||
return "", fmt.Errorf("login email sender is not configured")
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("login email sender is not configured"))
|
||||
}
|
||||
if err := s.loginEmailSender.SendLoginCode(ctx, rec.Email, code, s.codeTTL); err != nil {
|
||||
_ = s.codes.Del(ctx, hash)
|
||||
return "", fmt.Errorf("send login email code: %w", err)
|
||||
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("send login email code: %w", err))
|
||||
}
|
||||
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
|
@ -396,20 +526,53 @@ func (s *Service) resendCode(ctx context.Context, authKeyID [8]byte, phone, phon
|
|||
if rec.Phone != phone {
|
||||
return "", ErrCodeInvalid
|
||||
}
|
||||
if rec.Purpose == store.PhoneCodePurposeChangePhone && (authKeyID == ([8]byte{}) || rec.AuthKeyID != authKeyID) {
|
||||
if rec.Purpose == store.PhoneCodePurposeChangePhone {
|
||||
if authKeyID == ([8]byte{}) || rec.AuthKeyID != authKeyID {
|
||||
return "", ErrCodeInvalid
|
||||
}
|
||||
consumed, ok, err := s.codes.ConsumeScoped(ctx, phoneCodeHash, rec.Scope())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", ErrCodeExpired
|
||||
}
|
||||
return s.recreateChangePhoneCode(ctx, consumed)
|
||||
}
|
||||
if rec.Version != store.PhoneCodeVersionCurrent {
|
||||
_, _, _ = s.codes.TakeLoginCode(ctx, phoneCodeHash, phone)
|
||||
return "", ErrCodeExpired
|
||||
}
|
||||
if matches, err := s.issuedOwnerMatches(ctx, phone, rec.IssuedUserID); err != nil {
|
||||
return "", err
|
||||
} else if !matches {
|
||||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||||
return "", ErrCodeInvalid
|
||||
}
|
||||
_ = s.codes.Del(ctx, phoneCodeHash)
|
||||
if rec.Purpose == store.PhoneCodePurposeChangePhone {
|
||||
return s.recreateChangePhoneCode(ctx, rec)
|
||||
consumed, ok, err := s.codes.TakeLoginCode(ctx, phoneCodeHash, phone)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", ErrCodeExpired
|
||||
}
|
||||
rec = consumed
|
||||
if matches, err := s.issuedOwnerMatches(ctx, phone, rec.IssuedUserID); err != nil {
|
||||
return "", err
|
||||
} else if !matches {
|
||||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||||
return "", ErrCodeInvalid
|
||||
}
|
||||
if rec.Channel == codeChannelEmailLogin && strings.TrimSpace(rec.Email) != "" {
|
||||
return s.createEmailLoginCode(ctx, phone, rec.Email)
|
||||
return s.createEmailLoginCode(ctx, phone, rec.Email, rec.IssuedUserID)
|
||||
}
|
||||
if rec.Channel == codeChannelEmailSetupRequired {
|
||||
return s.createSetupRequiredCode(ctx, phone)
|
||||
return s.createSetupRequiredCode(ctx, phone, rec.IssuedUserID)
|
||||
}
|
||||
return s.SendCode(ctx, phone)
|
||||
if rec.Channel != codeChannelPhone {
|
||||
return "", ErrCodeInvalid
|
||||
}
|
||||
return s.createPhoneCode(ctx, phone, rec.IssuedUserID)
|
||||
}
|
||||
|
||||
func (s *Service) recreateChangePhoneCode(ctx context.Context, rec store.PhoneCode) (string, error) {
|
||||
|
|
@ -439,6 +602,75 @@ func (s *Service) CancelCodeForAuthKey(ctx context.Context, authKeyID [8]byte, p
|
|||
return s.cancelCode(ctx, authKeyID, phone, phoneCodeHash)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
phone = normalizePhone(phone)
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !found {
|
||||
return 0, ErrCodeExpired
|
||||
}
|
||||
if rec.Version != store.PhoneCodeVersionCurrent {
|
||||
_, _, _ = s.codes.TakeLoginCode(ctx, phoneCodeHash, phone)
|
||||
return 0, ErrCodeExpired
|
||||
}
|
||||
if rec.Purpose != "" || rec.Phone != phone || rec.Channel != codeChannelEmailLogin || rec.SignUpVerified {
|
||||
return 0, ErrCodeInvalid
|
||||
}
|
||||
before, beforeFound, err := s.currentPhoneOwner(ctx, phone)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !beforeFound || systemUserLoginForbidden(before) || rec.IssuedUserID == 0 || rec.IssuedUserID != before.ID {
|
||||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||||
return 0, ErrCodeInvalid
|
||||
}
|
||||
consumed, consumedOK, err := s.codes.TakeLoginCode(ctx, phoneCodeHash, phone)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !consumedOK {
|
||||
return 0, ErrCodeExpired
|
||||
}
|
||||
if consumed.Channel != codeChannelEmailLogin || consumed.IssuedUserID != before.ID {
|
||||
return 0, ErrCodeInvalid
|
||||
}
|
||||
after, afterFound, err := s.currentPhoneOwner(ctx, phone)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !afterFound || after.ID != before.ID {
|
||||
return 0, ErrCodeInvalid
|
||||
}
|
||||
return before.ID, nil
|
||||
}
|
||||
|
||||
// SendPhoneCodeAfterLoginEmailReset issues the replacement app code only for
|
||||
// the exact user selected by ConsumeLoginEmailReset. It deliberately bypasses
|
||||
// SendCode's phone→owner reclassification so an A→B transfer cannot send B a
|
||||
// code and return that hash to A's reset flow.
|
||||
func (s *Service) SendPhoneCodeAfterLoginEmailReset(ctx context.Context, phone string, expectedUserID int64) (string, error) {
|
||||
phone = normalizePhone(phone)
|
||||
if !validPhone(phone) {
|
||||
return "", ErrPhoneNumberInvalid
|
||||
}
|
||||
if expectedUserID == 0 || systemLoginPhoneForbidden(phone) {
|
||||
return "", ErrCodeInvalid
|
||||
}
|
||||
owner, found, err := s.currentPhoneOwner(ctx, phone)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found || owner.ID != expectedUserID || systemUserLoginForbidden(owner) {
|
||||
return "", ErrCodeInvalid
|
||||
}
|
||||
return s.createPhoneCode(ctx, phone, expectedUserID)
|
||||
}
|
||||
|
||||
func (s *Service) cancelCode(ctx context.Context, authKeyID [8]byte, phone, phoneCodeHash string) error {
|
||||
phone = normalizePhone(phone)
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
|
|
@ -451,10 +683,37 @@ func (s *Service) cancelCode(ctx context.Context, authKeyID [8]byte, phone, phon
|
|||
if rec.Phone != phone {
|
||||
return ErrCodeInvalid
|
||||
}
|
||||
if rec.Purpose == store.PhoneCodePurposeChangePhone && (authKeyID == ([8]byte{}) || rec.AuthKeyID != authKeyID) {
|
||||
if rec.Purpose == store.PhoneCodePurposeChangePhone {
|
||||
if authKeyID == ([8]byte{}) || rec.AuthKeyID != authKeyID {
|
||||
return ErrCodeInvalid
|
||||
}
|
||||
_, consumed, err := s.codes.ConsumeScoped(ctx, phoneCodeHash, rec.Scope())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !consumed {
|
||||
return ErrCodeExpired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if rec.Version != store.PhoneCodeVersionCurrent {
|
||||
_, _, _ = s.codes.TakeLoginCode(ctx, phoneCodeHash, phone)
|
||||
return ErrCodeExpired
|
||||
}
|
||||
if matches, err := s.issuedOwnerMatches(ctx, phone, rec.IssuedUserID); err != nil {
|
||||
return err
|
||||
} else if !matches {
|
||||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||||
return ErrCodeInvalid
|
||||
}
|
||||
return s.codes.Del(ctx, phoneCodeHash)
|
||||
_, consumed, err := s.codes.TakeLoginCode(ctx, phoneCodeHash, phone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !consumed {
|
||||
return ErrCodeExpired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SignIn 校验验证码并尝试登录。
|
||||
|
|
@ -464,102 +723,164 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
|
|||
if systemLoginPhoneForbidden(phone) {
|
||||
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
|
||||
}
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeExpired
|
||||
}
|
||||
if rec.Phone != phone || rec.Channel == codeChannelEmailSetupRequired {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
}
|
||||
if rec.Channel == codeChannelEmailLogin {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
}
|
||||
if rec.Code != code {
|
||||
return domain.User{}, domain.Message{}, false, s.rejectCode(ctx, phoneCodeHash, rec, ErrCodeInvalid)
|
||||
}
|
||||
|
||||
existing, found, err := s.users.ByPhone(ctx, phone)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.Message{}, true, nil // 验证码对、但需注册
|
||||
}
|
||||
return s.finishSignIn(ctx, auth, existing, phoneCodeHash, rec.Code)
|
||||
}
|
||||
|
||||
// SignInWithEmail 处理带 email_verification 的 auth.signIn:账号设置了登录邮箱后,新设备
|
||||
// 的验证码改投递到邮箱,客户端凭邮箱码(而非短信码)登录。开启真实登录邮箱后必须匹配
|
||||
// 随机邮箱码;未开启该特性时仅保留旧开发路径的任意非空兼容。仍校验 phone_code_hash
|
||||
// 有效、手机号匹配,并与短信登录共用 2FA 门控——即便走邮箱验证,开启了两步验证的账号
|
||||
// 同样会停在 SESSION_PASSWORD_NEEDED。
|
||||
func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error) {
|
||||
phone = normalizePhone(phone)
|
||||
if systemLoginPhoneForbidden(phone) {
|
||||
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
|
||||
}
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeExpired
|
||||
}
|
||||
if rec.Phone != phone {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
}
|
||||
if rec.Channel != codeChannelEmailLogin {
|
||||
if s.loginEmailEnabled {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
}
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
}
|
||||
} else if rec.Code != strings.TrimSpace(code) {
|
||||
return domain.User{}, domain.Message{}, false, s.rejectCode(ctx, phoneCodeHash, rec, ErrCodeInvalid)
|
||||
}
|
||||
existing, found, err := s.users.ByPhone(ctx, phone)
|
||||
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, code, false)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.Message{}, true, nil
|
||||
}
|
||||
return s.finishSignIn(ctx, auth, existing, phoneCodeHash, rec.Code)
|
||||
return s.finishSignIn(ctx, auth, existing)
|
||||
}
|
||||
|
||||
// SignInWithEmail 处理带 email_verification 的 auth.signIn:账号设置了登录邮箱后,新设备
|
||||
// 的验证码改投递到邮箱,客户端凭邮箱码(而非短信码)登录。开启真实登录邮箱后必须匹配
|
||||
// 随机邮箱码;未开启该特性时仍允许旧客户端把 phone channel 放进
|
||||
// email_verification,但必须精确匹配该 phone code,不能再接受任意非空值。
|
||||
// 两条路径共用 owner 绑定、原子尝试计数与 2FA 门控。
|
||||
func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error) {
|
||||
phone = normalizePhone(phone)
|
||||
if systemLoginPhoneForbidden(phone) {
|
||||
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
|
||||
}
|
||||
_, existing, found, err := s.verifyLoginCode(ctx, phone, phoneCodeHash, strings.TrimSpace(code), true)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.Message{}, true, nil
|
||||
}
|
||||
return s.finishSignIn(ctx, auth, existing)
|
||||
}
|
||||
|
||||
// verifyLoginCode closes the login-code state transition around one atomic
|
||||
// CodeStore verification. The phone owner is read both before and after that
|
||||
// linearization point. A hash issued for an unregistered number therefore can
|
||||
// never authorize whichever account happens to acquire that number later.
|
||||
func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, code string, emailPath bool) (store.PhoneCode, domain.User, bool, error) {
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return store.PhoneCode{}, domain.User{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeExpired
|
||||
}
|
||||
if rec.Version != store.PhoneCodeVersionCurrent {
|
||||
_, _, _ = s.codes.TakeLoginCode(ctx, phoneCodeHash, phone)
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeExpired
|
||||
}
|
||||
if rec.Phone != phone || rec.Purpose != "" {
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||||
}
|
||||
channelAllowed := rec.Channel == codeChannelPhone && !emailPath
|
||||
if emailPath {
|
||||
channelAllowed = rec.Channel == codeChannelEmailLogin || (!s.loginEmailEnabled && rec.Channel == codeChannelPhone)
|
||||
}
|
||||
if !channelAllowed {
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||||
}
|
||||
|
||||
before, beforeFound, err := s.currentPhoneOwner(ctx, phone)
|
||||
if err != nil {
|
||||
return store.PhoneCode{}, domain.User{}, false, err
|
||||
}
|
||||
beforeUserID := int64(0)
|
||||
if beforeFound {
|
||||
if systemUserLoginForbidden(before) {
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrSystemUserLoginForbidden
|
||||
}
|
||||
beforeUserID = before.ID
|
||||
}
|
||||
if rec.IssuedUserID != beforeUserID {
|
||||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||||
}
|
||||
// A verified sign-up marker may precede auth.signIn on the email-setup
|
||||
// path, and a normal signIn response can be lost and retried. The marker is
|
||||
// already the durable authorization fact; return signUpRequired
|
||||
// idempotently without asking CodeStore to verify it a second time.
|
||||
if rec.SignUpVerified {
|
||||
if beforeFound || rec.IssuedUserID != 0 || subtle.ConstantTimeCompare([]byte(rec.Code), []byte(code)) != 1 {
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||||
}
|
||||
after, afterFound, err := s.currentPhoneOwner(ctx, phone)
|
||||
if err != nil {
|
||||
return store.PhoneCode{}, domain.User{}, false, err
|
||||
}
|
||||
if afterFound || after.ID != 0 {
|
||||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||||
}
|
||||
return rec, domain.User{}, false, nil
|
||||
}
|
||||
|
||||
result, err := s.codes.VerifyLogin(ctx, phoneCodeHash, phone, code, !beforeFound, s.codeMaxAttempts)
|
||||
if err != nil {
|
||||
return store.PhoneCode{}, domain.User{}, false, err
|
||||
}
|
||||
after, afterFound, ownerErr := s.currentPhoneOwner(ctx, phone)
|
||||
if ownerErr != nil {
|
||||
return store.PhoneCode{}, domain.User{}, false, ownerErr
|
||||
}
|
||||
afterUserID := int64(0)
|
||||
if afterFound {
|
||||
afterUserID = after.ID
|
||||
}
|
||||
recordOwnerMismatch := result.Status != store.LoginCodeVerifyMissing && result.Record.IssuedUserID != rec.IssuedUserID
|
||||
if beforeUserID != afterUserID || recordOwnerMismatch {
|
||||
// keepForSignUp may have left a verified marker behind. Remove it on
|
||||
// owner drift so a later transfer-back cannot resurrect authorization.
|
||||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||||
}
|
||||
switch result.Status {
|
||||
case store.LoginCodeVerifyMissing:
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeExpired
|
||||
case store.LoginCodeVerifyInvalid:
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||||
case store.LoginCodeVerifyAccepted:
|
||||
if result.Record.Version != store.PhoneCodeVersionCurrent || result.Record.Phone != phone || result.Record.IssuedUserID != afterUserID {
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||||
}
|
||||
if afterFound && systemUserLoginForbidden(after) {
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrSystemUserLoginForbidden
|
||||
}
|
||||
return result.Record, after, afterFound, nil
|
||||
default:
|
||||
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
|
||||
}
|
||||
}
|
||||
|
||||
// finishSignIn 是短信/邮箱两条登录路径在「验证码已通过、用户已存在」之后的共用收尾:
|
||||
// 处理 2FA password_pending 绑定、写登录消息、消费验证码。
|
||||
func (s *Service) finishSignIn(ctx context.Context, auth domain.Authorization, existing domain.User, phoneCodeHash, loginCode string) (domain.User, domain.Message, bool, error) {
|
||||
// 验证码已由 VerifyLogin 原子消费;这里只处理 2FA password_pending 绑定。已有账号的 app-code 消息已在
|
||||
// SendCode/ResendCode 返回前持久化与入 outbox,这里绝不能再创建或补发;
|
||||
// 否则未完成登录/2FA 的真实验证码反而不会及时到达旧设备。
|
||||
func (s *Service) finishSignIn(ctx context.Context, auth domain.Authorization, existing domain.User) (domain.User, domain.Message, bool, error) {
|
||||
if systemUserLoginForbidden(existing) {
|
||||
_ = s.codes.Del(ctx, phoneCodeHash)
|
||||
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
|
||||
}
|
||||
// 开启两步验证的账号:把授权标记为 password_pending 再写入,业务鉴权据此拒绝该 auth_key,
|
||||
// 直到 auth.checkPassword 通过。绝不能先以完全授权写入再返回 SESSION_PASSWORD_NEEDED,
|
||||
// 否则客户端忽略该错误即可直接调用业务 RPC 绕过两步验证。
|
||||
passwordNeeded := s.passwordNeeded(ctx, existing.ID)
|
||||
passwordNeeded, err := s.passwordNeeded(ctx, existing.ID)
|
||||
if err != nil {
|
||||
// Password state is part of the authentication decision. Treat store
|
||||
// failures as fail-closed and leave the auth key entirely unbound.
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
auth.PasswordPending = passwordNeeded
|
||||
if err := s.bind(ctx, auth, existing.ID); err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
if passwordNeeded {
|
||||
_ = s.codes.Del(ctx, phoneCodeHash)
|
||||
return existing, domain.Message{}, false, domain.ErrSessionPasswordNeeded
|
||||
}
|
||||
loginMessage, err := s.recordLoginMessage(ctx, existing.ID, loginCode)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
_ = s.codes.Del(ctx, phoneCodeHash)
|
||||
return existing, loginMessage, false, nil
|
||||
return existing, domain.Message{}, false, nil
|
||||
}
|
||||
|
||||
// SignUp 在 SignIn 判定需注册后创建用户并绑定授权。
|
||||
// signUp 的 TL 请求不带验证码,这里校验 phone_code_hash 仍有效且手机号匹配。
|
||||
// signUp 的 TL 请求不带验证码,因此只消费由正确 SignIn/email setup 原子
|
||||
// 标记过的 hash。直接 SendCode→SignUp 永远不能创建账号。
|
||||
func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, firstName, lastName string) (domain.User, domain.Message, error) {
|
||||
phone = normalizePhone(phone)
|
||||
if !validPhone(phone) {
|
||||
|
|
@ -580,15 +901,48 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
|
|||
if !found {
|
||||
return domain.User{}, domain.Message{}, ErrCodeExpired
|
||||
}
|
||||
if rec.Phone != phone {
|
||||
if rec.Version != store.PhoneCodeVersionCurrent {
|
||||
_, _, _ = s.codes.ConsumeSignUpVerified(ctx, phoneCodeHash, phone)
|
||||
return domain.User{}, domain.Message{}, ErrCodeExpired
|
||||
}
|
||||
if rec.Phone != phone || rec.Purpose != "" {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
if rec.Channel == codeChannelEmailSetupRequired {
|
||||
if !rec.SignUpVerified {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
if rec.IssuedUserID != 0 {
|
||||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
if rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
if s.loginEmailRequireSetup && !rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) == "" {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
if current, currentFound, err := s.currentPhoneOwner(ctx, phone); err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
} else if currentFound || current.ID != 0 {
|
||||
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
consumed, consumedOK, err := s.codes.ConsumeSignUpVerified(ctx, phoneCodeHash, phone)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
if !consumedOK {
|
||||
return domain.User{}, domain.Message{}, ErrCodeExpired
|
||||
}
|
||||
rec = consumed
|
||||
if rec.IssuedUserID != 0 || !rec.SignUpVerified || (rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin) {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
if current, currentFound, err := s.currentPhoneOwner(ctx, phone); err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
} else if currentFound || current.ID != 0 {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
|
||||
accessHash, err := randomInt64()
|
||||
if err != nil {
|
||||
|
|
@ -610,18 +964,22 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
|
|||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
if rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) != "" && s.loginEmails != nil {
|
||||
if err := s.loginEmails.SetLoginEmailByPhone(ctx, phone, rec.PendingEmail); err != nil {
|
||||
if err := s.loginEmails.SetLoginEmail(ctx, u.ID, rec.PendingEmail); err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
}
|
||||
if err := s.bind(ctx, auth, u.ID); err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
loginMessage, err := s.recordLoginMessage(ctx, u.ID, rec.Code)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
loginMessage := domain.Message{}
|
||||
// SMTP setup/login codes are secret factors, not 777000 app messages. Only
|
||||
// the normal phone/app-code registration path creates the bootstrap dialog.
|
||||
if rec.Channel == codeChannelPhone {
|
||||
loginMessage, err = s.recordLoginMessage(ctx, u.ID, rec.Code)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
}
|
||||
_ = s.codes.Del(ctx, phoneCodeHash)
|
||||
return u, loginMessage, nil
|
||||
}
|
||||
|
||||
|
|
@ -865,15 +1223,21 @@ func (s *Service) authorizationsByUserExcept(ctx context.Context, userID int64,
|
|||
|
||||
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
|
||||
auth.UserID = userID
|
||||
// Bind 是授权切换的持久化状态边界:生产 store 会先清同 auth key 的旧用户
|
||||
// update state,再原子建立新用户 baseline。RPC 层不得在 Bind 成功后清整个 key,
|
||||
// 否则会把刚建立的 retained-floor checkpoint 一并删除。
|
||||
return s.auths.Bind(ctx, auth)
|
||||
}
|
||||
|
||||
func (s *Service) passwordNeeded(ctx context.Context, userID int64) bool {
|
||||
func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error) {
|
||||
if s.passwords == nil {
|
||||
return false
|
||||
return false, nil
|
||||
}
|
||||
settings, found, err := s.passwords.GetByUser(ctx, userID)
|
||||
return err == nil && found && settings.HasPassword
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return found && settings.HasPassword, nil
|
||||
}
|
||||
|
||||
const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from Telegram!
|
||||
|
|
@ -1005,20 +1369,6 @@ func authKeyIDInt64(id [8]byte) int64 {
|
|||
return int64(binary.LittleEndian.Uint64(id[:]))
|
||||
}
|
||||
|
||||
func (s *Service) rejectCode(ctx context.Context, hash string, rec store.PhoneCode, ret error) error {
|
||||
rec.Attempts++
|
||||
max := rec.MaxAttempts
|
||||
if max <= 0 {
|
||||
max = s.codeMaxAttempts
|
||||
}
|
||||
if max > 0 && rec.Attempts >= max {
|
||||
_ = s.codes.Del(ctx, hash)
|
||||
return ret
|
||||
}
|
||||
_ = s.codes.Update(ctx, hash, rec)
|
||||
return ret
|
||||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
return domain.NormalizePhone(phone)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,6 +178,14 @@ func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func verifyCodeForSignUp(t *testing.T, svc *Service, phone, hash, code string) {
|
||||
t.Helper()
|
||||
got, msg, needSignUp, err := svc.SignIn(context.Background(), domain.Authorization{}, phone, hash, code)
|
||||
if err != nil || !needSignUp || got.ID != 0 || msg.ID != 0 {
|
||||
t.Fatalf("SignIn before SignUp user=%+v message=%+v needSignUp=%v err=%v, want empty/empty/true/nil", got, msg, needSignUp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUserPhoneCannotLoginOrSignUp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := memory.NewCodeStore()
|
||||
|
|
@ -257,6 +265,7 @@ func TestMultipleAuthKeysKeepSeparateUsers(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendCode user1: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, "+15550005001", hash1, "12345")
|
||||
user1, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key1}, "+15550005001", hash1, "One", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp user1: %v", err)
|
||||
|
|
@ -265,6 +274,7 @@ func TestMultipleAuthKeysKeepSeparateUsers(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendCode user2: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, "+15550005002", hash2, "12345")
|
||||
user2, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key2}, "+15550005002", hash2, "Two", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp user2: %v", err)
|
||||
|
|
@ -294,6 +304,7 @@ func TestLogOutThenSignInSameAuthKeySwitchesUser(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendCode user1: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, "+15550006001", hash1, "12345")
|
||||
user1, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550006001", hash1, "One", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp user1: %v", err)
|
||||
|
|
@ -312,6 +323,7 @@ func TestLogOutThenSignInSameAuthKeySwitchesUser(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendCode user2: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, "+15550006002", hash2, "12345")
|
||||
user2, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550006002", hash2, "Two", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp user2: %v", err)
|
||||
|
|
@ -337,6 +349,7 @@ func TestResetAuthorizationDeletesProtocolAuthKey(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, "+15550007001", hash, "12345")
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550007001", hash, "One", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
|
|
@ -375,6 +388,7 @@ func TestResetAuthorizationsDeletesOnlyRevokedProtocolAuthKeys(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, "+15550007002", hash, "12345")
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: keep}, "+15550007002", hash, "Two", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
|
|
@ -399,16 +413,27 @@ func TestSignUpWritesOfficialLoginMessage(t *testing.T) {
|
|||
ctx := context.Background()
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithLoginMessages(messages, dialogs))
|
||||
delivery := &captureLoginCodeDelivery{}
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithLoginMessages(messages, dialogs),
|
||||
WithLoginCodeDelivery(delivery),
|
||||
)
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+15550004311")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
if len(delivery.requests) != 0 {
|
||||
t.Fatalf("unregistered SendCode delivered before user exists: %+v", delivery.requests)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, "+15550004311", hash, "12345")
|
||||
u, msg, err := svc.SignUp(ctx, domain.Authorization{}, "+15550004311", hash, "Test", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
}
|
||||
if len(delivery.requests) != 0 {
|
||||
t.Fatalf("SignUp unexpectedly used existing-account delivery: %+v", delivery.requests)
|
||||
}
|
||||
|
||||
list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
|
|
@ -431,17 +456,23 @@ func TestSignUpWritesOfficialLoginMessage(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSignInLoginMessagePreservesOfficialDialogReadWatermark(t *testing.T) {
|
||||
func TestSendCodeLoginMessagePreservesOfficialDialogReadWatermark(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithLoginMessages(messages, dialogs))
|
||||
events := memory.NewUpdateEventStore()
|
||||
delivery := memory.NewLoginCodeDeliveryStore(messages, events)
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithLoginMessages(messages, dialogs),
|
||||
WithLoginCodeDelivery(delivery),
|
||||
)
|
||||
phone := "+15550004312"
|
||||
|
||||
hash, err := svc.SendCode(ctx, phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode signup: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, phone, hash, "12345")
|
||||
u, first, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Test", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
|
|
@ -452,15 +483,6 @@ func TestSignInLoginMessagePreservesOfficialDialogReadWatermark(t *testing.T) {
|
|||
} else if read.MaxID != first.ID || read.StillUnreadCount != 0 {
|
||||
t.Fatalf("read first login message = %+v, want max_id %d unread 0", read, first.ID)
|
||||
}
|
||||
|
||||
hash, err = svc.SendCode(ctx, phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode signin second: %v", err)
|
||||
}
|
||||
_, second, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345")
|
||||
if err != nil || needSignUp {
|
||||
t.Fatalf("SignIn second needSignUp=%v err=%v", needSignUp, err)
|
||||
}
|
||||
assertOfficialDialog := func(wantTop, wantRead, wantUnread int) {
|
||||
t.Helper()
|
||||
list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
|
||||
|
|
@ -475,23 +497,67 @@ func TestSignInLoginMessagePreservesOfficialDialogReadWatermark(t *testing.T) {
|
|||
t.Fatalf("dialog = %+v, want top=%d read=%d unread=%d", got, wantTop, wantRead, wantUnread)
|
||||
}
|
||||
}
|
||||
latestLoginMessage := func(wantCount int) domain.Message {
|
||||
t.Helper()
|
||||
history, err := messages.ListByUser(ctx, u.ID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: peer,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil || len(history.Messages) != wantCount {
|
||||
t.Fatalf("official history count=%d err=%v, want %d", len(history.Messages), err, wantCount)
|
||||
}
|
||||
latest := history.Messages[0]
|
||||
for _, msg := range history.Messages[1:] {
|
||||
if msg.ID > latest.ID {
|
||||
latest = msg
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
hash, err = svc.SendCode(ctx, phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode signin second: %v", err)
|
||||
}
|
||||
second := latestLoginMessage(2)
|
||||
// 核心时序:SendCode 返回时 message/dialog/unread 已提交,尚未 SignIn。
|
||||
assertOfficialDialog(second.ID, first.ID, 1)
|
||||
_, signInMessage, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345")
|
||||
if err != nil || needSignUp {
|
||||
t.Fatalf("SignIn second needSignUp=%v err=%v", needSignUp, err)
|
||||
}
|
||||
if signInMessage.ID != 0 {
|
||||
t.Fatalf("SignIn second returned a late login message %+v", signInMessage)
|
||||
}
|
||||
assertOfficialDialog(second.ID, first.ID, 1)
|
||||
|
||||
hash, err = svc.SendCode(ctx, phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode signin third: %v", err)
|
||||
}
|
||||
_, third, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345")
|
||||
third := latestLoginMessage(3)
|
||||
assertOfficialDialog(third.ID, first.ID, 2)
|
||||
_, signInMessage, needSignUp, err = svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345")
|
||||
if err != nil || needSignUp {
|
||||
t.Fatalf("SignIn third needSignUp=%v err=%v", needSignUp, err)
|
||||
}
|
||||
if signInMessage.ID != 0 {
|
||||
t.Fatalf("SignIn third returned a late login message %+v", signInMessage)
|
||||
}
|
||||
assertOfficialDialog(third.ID, first.ID, 2)
|
||||
}
|
||||
|
||||
func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
passwords := memory.NewPasswordStore()
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithPasswords(passwords))
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
delivery := memory.NewLoginCodeDeliveryStore(messages, memory.NewUpdateEventStore())
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithPasswords(passwords),
|
||||
WithLoginCodeDelivery(delivery),
|
||||
)
|
||||
var key [8]byte
|
||||
key[0] = 7
|
||||
|
||||
|
|
@ -499,6 +565,7 @@ func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendCode signup: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, "+15550004312", hash, "12345")
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550004312", hash, "Two", "Factor")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
|
|
@ -514,13 +581,16 @@ func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendCode signin: %v", err)
|
||||
}
|
||||
got, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, "+15550004312", hash, "12345")
|
||||
got, signInMessage, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, "+15550004312", hash, "12345")
|
||||
if !errors.Is(err, domain.ErrSessionPasswordNeeded) {
|
||||
t.Fatalf("SignIn err = %v, want ErrSessionPasswordNeeded", err)
|
||||
}
|
||||
if needSignUp || got.ID != u.ID {
|
||||
t.Fatalf("SignIn user=%+v needSignUp=%v, want existing 2FA user", got, needSignUp)
|
||||
}
|
||||
if signInMessage.ID != 0 {
|
||||
t.Fatalf("2FA SignIn returned a late login message %+v", signInMessage)
|
||||
}
|
||||
// 两步验证未完成:业务鉴权(UserID)必须视为未登录,避免绕过 2FA。
|
||||
bound, found, err := svc.UserID(ctx, key)
|
||||
if err != nil || found || bound != 0 {
|
||||
|
|
@ -539,6 +609,10 @@ func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) {
|
|||
if err != nil || !found || bound != u.ID {
|
||||
t.Fatalf("UserID after 2FA passed = %d found=%v err=%v, want %d", bound, found, err, u.ID)
|
||||
}
|
||||
list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
|
||||
if err != nil || len(list.Messages) != 1 {
|
||||
t.Fatalf("2FA login-code messages after password = %+v err=%v, want exactly the SendCode message", list.Messages, err)
|
||||
}
|
||||
}
|
||||
|
||||
func testAuthKey(seed byte) mtcrypto.AuthKey {
|
||||
|
|
|
|||
554
internal/app/auth/signup_state_test.go
Normal file
554
internal/app/auth/signup_state_test.go
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
accountapp "telesrv/internal/app/account"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestSignUpRequiresCorrectSignInAndConsumesMarkerOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
codes := memory.NewCodeStore()
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345")
|
||||
phone := "15550009301"
|
||||
|
||||
hash, err := svc.SendCode(ctx, phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
if _, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Direct", "Bypass"); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("direct SignUp err=%v, want ErrCodeInvalid", err)
|
||||
}
|
||||
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "00000"); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("wrong SignIn err=%v, want ErrCodeInvalid", err)
|
||||
}
|
||||
if rec, found, err := codes.Get(ctx, hash); err != nil || !found || rec.SignUpVerified {
|
||||
t.Fatalf("wrong code marker=%v found=%v err=%v, want live/unverified", rec.SignUpVerified, found, err)
|
||||
}
|
||||
if _, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Wrong", "Code"); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("SignUp after wrong code err=%v, want ErrCodeInvalid", err)
|
||||
}
|
||||
|
||||
verifyCodeForSignUp(t, svc, phone, hash, "12345")
|
||||
if rec, found, err := codes.Get(ctx, hash); err != nil || !found || !rec.SignUpVerified || rec.IssuedUserID != 0 {
|
||||
t.Fatalf("verified record=%+v found=%v err=%v", rec, found, err)
|
||||
}
|
||||
if _, msg, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); err != nil || !needSignUp || msg.ID != 0 {
|
||||
t.Fatalf("idempotent SignIn needSignUp=%v message=%+v err=%v", needSignUp, msg, err)
|
||||
}
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Verified", "User")
|
||||
if err != nil || u.Phone != phone {
|
||||
t.Fatalf("verified SignUp user=%+v err=%v", u, err)
|
||||
}
|
||||
if _, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Replay", "User"); !errors.Is(err, ErrCodeExpired) {
|
||||
t.Fatalf("replayed SignUp err=%v, want ErrCodeExpired", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentSignUpConsumesVerifiedHashExactlyOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345")
|
||||
phone := "15550009302"
|
||||
hash, err := svc.SendCode(ctx, phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, phone, hash, "12345")
|
||||
|
||||
const workers = 16
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func(i int) {
|
||||
<-start
|
||||
var key [8]byte
|
||||
key[0] = byte(i + 1)
|
||||
_, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, phone, hash, "Concurrent", "User")
|
||||
errs <- err
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
successes := 0
|
||||
for i := 0; i < workers; i++ {
|
||||
err := <-errs
|
||||
switch {
|
||||
case err == nil:
|
||||
successes++
|
||||
case errors.Is(err, ErrCodeExpired), errors.Is(err, ErrCodeInvalid):
|
||||
default:
|
||||
t.Fatalf("concurrent SignUp err=%v", err)
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("successful SignUp calls=%d, want 1", successes)
|
||||
}
|
||||
}
|
||||
|
||||
type afterVerifyCodeStore struct {
|
||||
store.CodeStore
|
||||
once sync.Once
|
||||
afterVerify func()
|
||||
}
|
||||
|
||||
type failingPasswordStore struct {
|
||||
store.PasswordStore
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *failingPasswordStore) GetByUser(context.Context, int64) (domain.PasswordSettings, bool, error) {
|
||||
return domain.PasswordSettings{}, false, s.err
|
||||
}
|
||||
|
||||
type switchablePhoneOwnerStore struct {
|
||||
store.UserStore
|
||||
mu sync.RWMutex
|
||||
phone string
|
||||
override bool
|
||||
owner domain.User
|
||||
found bool
|
||||
}
|
||||
|
||||
func (s *switchablePhoneOwnerStore) ByPhone(ctx context.Context, phone string) (domain.User, bool, error) {
|
||||
s.mu.RLock()
|
||||
if s.override && domain.NormalizePhone(phone) == s.phone {
|
||||
owner, found := s.owner, s.found
|
||||
s.mu.RUnlock()
|
||||
return owner, found, nil
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
return s.UserStore.ByPhone(ctx, phone)
|
||||
}
|
||||
|
||||
func (s *switchablePhoneOwnerStore) setOwnerView(phone string, owner domain.User, found bool) {
|
||||
s.mu.Lock()
|
||||
s.phone = domain.NormalizePhone(phone)
|
||||
s.owner = owner
|
||||
s.found = found
|
||||
s.override = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *switchablePhoneOwnerStore) resetOwnerView() {
|
||||
s.mu.Lock()
|
||||
s.override = false
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *afterVerifyCodeStore) VerifyLogin(ctx context.Context, hash, phone, code string, keep bool, maxAttempts int) (store.LoginCodeVerifyResult, error) {
|
||||
result, err := s.CodeStore.VerifyLogin(ctx, hash, phone, code, keep, maxAttempts)
|
||||
if err == nil && result.Status == store.LoginCodeVerifyAccepted && s.afterVerify != nil {
|
||||
s.once.Do(s.afterVerify)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func TestOwnerTransferAcrossVerifyInvalidatesHashPermanently(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
baseCodes := memory.NewCodeStore()
|
||||
var createErr error
|
||||
codes := &afterVerifyCodeStore{CodeStore: baseCodes}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345")
|
||||
phone := "15550009303"
|
||||
hash, err := svc.SendCode(ctx, phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
codes.afterVerify = func() {
|
||||
_, createErr = users.Create(ctx, domain.User{Phone: phone, FirstName: "NewOwner"})
|
||||
}
|
||||
if _, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); !errors.Is(err, ErrCodeInvalid) || needSignUp {
|
||||
t.Fatalf("SignIn across owner transfer needSignUp=%v err=%v, want invalid", needSignUp, err)
|
||||
}
|
||||
if createErr != nil {
|
||||
t.Fatalf("create concurrent owner: %v", createErr)
|
||||
}
|
||||
if _, found, err := baseCodes.Get(ctx, hash); err != nil || found {
|
||||
t.Fatalf("owner-drift hash found=%v err=%v, want invalidated", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordLookupFailureNeverCreatesOrChangesAuthorization(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
target, err := users.Create(ctx, domain.User{Phone: "15550009320", FirstName: "Target"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
previous, err := users.Create(ctx, domain.User{Phone: "15550009321", FirstName: "Previous"})
|
||||
if err != nil {
|
||||
t.Fatalf("create previous: %v", err)
|
||||
}
|
||||
authz := memory.NewAuthorizationStore()
|
||||
lookupErr := errors.New("password store unavailable")
|
||||
passwords := &failingPasswordStore{PasswordStore: memory.NewPasswordStore(), err: lookupErr}
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithPasswords(passwords),
|
||||
WithLoginCodeDelivery(&captureLoginCodeDelivery{}),
|
||||
)
|
||||
|
||||
t.Run("unbound-key-remains-unbound", func(t *testing.T) {
|
||||
key := [8]byte{0xC1}
|
||||
hash, err := svc.SendCode(ctx, target.Phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, target.Phone, hash, "12345"); !errors.Is(err, lookupErr) {
|
||||
t.Fatalf("SignIn err=%v, want password lookup failure", err)
|
||||
}
|
||||
if got, found, err := authz.ByAuthKey(ctx, key); err != nil || found {
|
||||
t.Fatalf("authorization=%+v found=%v err=%v, want absent", got, found, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("previous-binding-remains-unchanged", func(t *testing.T) {
|
||||
key := [8]byte{0xC2}
|
||||
original := domain.Authorization{AuthKeyID: key, UserID: previous.ID, Hash: 987654321}
|
||||
if err := authz.Bind(ctx, original); err != nil {
|
||||
t.Fatalf("bind previous authorization: %v", err)
|
||||
}
|
||||
hash, err := svc.SendCode(ctx, target.Phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, target.Phone, hash, "12345"); !errors.Is(err, lookupErr) {
|
||||
t.Fatalf("SignIn err=%v, want password lookup failure", err)
|
||||
}
|
||||
got, found, err := authz.ByAuthKey(ctx, key)
|
||||
if err != nil || !found || got.UserID != previous.ID || got.Hash != original.Hash || got.PasswordPending != original.PasswordPending {
|
||||
t.Fatalf("authorization after failure=%+v found=%v err=%v, want unchanged %+v", got, found, err, original)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOwnerTransferAwayAndBackCannotReviveLoginHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
t.Run("unregistered-signin", func(t *testing.T) {
|
||||
baseUsers := memory.NewUserStore()
|
||||
other, err := baseUsers.Create(ctx, domain.User{Phone: "15550009311", FirstName: "Other"})
|
||||
if err != nil {
|
||||
t.Fatalf("create other owner: %v", err)
|
||||
}
|
||||
users := &switchablePhoneOwnerStore{UserStore: baseUsers}
|
||||
codes := memory.NewCodeStore()
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345")
|
||||
phone := "15550009310"
|
||||
hash, err := svc.SendCode(ctx, phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
users.setOwnerView(phone, other, true)
|
||||
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("SignIn after 0->B owner transfer err=%v, want invalid", err)
|
||||
}
|
||||
users.resetOwnerView()
|
||||
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); !errors.Is(err, ErrCodeExpired) {
|
||||
t.Fatalf("SignIn after 0->B->0 err=%v, want expired", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing-resend", func(t *testing.T) {
|
||||
baseUsers := memory.NewUserStore()
|
||||
ownerA, err := baseUsers.Create(ctx, domain.User{Phone: "15550009312", FirstName: "A"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner A: %v", err)
|
||||
}
|
||||
ownerB, err := baseUsers.Create(ctx, domain.User{Phone: "15550009313", FirstName: "B"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner B: %v", err)
|
||||
}
|
||||
users := &switchablePhoneOwnerStore{UserStore: baseUsers}
|
||||
codes := memory.NewCodeStore()
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(&captureLoginCodeDelivery{}))
|
||||
hash, err := svc.SendCode(ctx, ownerA.Phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
users.setOwnerView(ownerA.Phone, ownerB, true)
|
||||
if _, err := svc.ResendCode(ctx, ownerA.Phone, hash); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("ResendCode after A->B err=%v, want invalid", err)
|
||||
}
|
||||
users.resetOwnerView()
|
||||
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, ownerA.Phone, hash, "12345"); !errors.Is(err, ErrCodeExpired) {
|
||||
t.Fatalf("SignIn after A->B->A err=%v, want expired", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing-cancel", func(t *testing.T) {
|
||||
baseUsers := memory.NewUserStore()
|
||||
ownerA, err := baseUsers.Create(ctx, domain.User{Phone: "15550009314", FirstName: "A"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner A: %v", err)
|
||||
}
|
||||
ownerB, err := baseUsers.Create(ctx, domain.User{Phone: "15550009315", FirstName: "B"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner B: %v", err)
|
||||
}
|
||||
users := &switchablePhoneOwnerStore{UserStore: baseUsers}
|
||||
codes := memory.NewCodeStore()
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(&captureLoginCodeDelivery{}))
|
||||
hash, err := svc.SendCode(ctx, ownerA.Phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
users.setOwnerView(ownerA.Phone, ownerB, true)
|
||||
if err := svc.CancelCode(ctx, ownerA.Phone, hash); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("CancelCode after A->B err=%v, want invalid", err)
|
||||
}
|
||||
users.resetOwnerView()
|
||||
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, ownerA.Phone, hash, "12345"); !errors.Is(err, ErrCodeExpired) {
|
||||
t.Fatalf("SignIn after canceled A->B->A err=%v, want expired", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEmailSetupVerificationAuthorizesSignUpWithout777000Message(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
codes := memory.NewCodeStore()
|
||||
passwords := memory.NewPasswordStore()
|
||||
sender := &testMailSender{}
|
||||
accountSvc := accountapp.NewService(passwords,
|
||||
accountapp.WithUsers(users),
|
||||
accountapp.WithLoginEmailVerification(codes, sender, time.Minute, 3, 6),
|
||||
)
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
authSvc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
|
||||
WithLoginMessages(messages, dialogs),
|
||||
WithLoginEmail(LoginEmailOptions{
|
||||
Enabled: true,
|
||||
RequireSetup: true,
|
||||
CodeLength: 6,
|
||||
Store: accountSvc,
|
||||
Sender: sender,
|
||||
}),
|
||||
)
|
||||
phone := "15550009304"
|
||||
hash, err := authSvc.SendCode(ctx, phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
if _, _, err := authSvc.SignUp(ctx, domain.Authorization{}, phone, hash, "Direct", "Email"); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("SignUp before email setup err=%v, want ErrCodeInvalid", err)
|
||||
}
|
||||
if _, _, err := accountSvc.SendLoginEmailCode(ctx, 0, phone, hash, "new@example.test", true); err != nil {
|
||||
t.Fatalf("SendLoginEmailCode: %v", err)
|
||||
}
|
||||
bad := wrongCode(sender.code, '0')
|
||||
if _, err := accountSvc.VerifyLoginEmail(ctx, 0, phone, hash, bad, true); !errors.Is(err, domain.ErrEmailCodeInvalid) {
|
||||
t.Fatalf("wrong VerifyLoginEmail err=%v, want ErrEmailCodeInvalid", err)
|
||||
}
|
||||
if rec, found, err := codes.Get(ctx, hash); err != nil || !found || rec.SignUpVerified {
|
||||
t.Fatalf("wrong SMTP code marker=%v found=%v err=%v", rec.SignUpVerified, found, err)
|
||||
}
|
||||
if _, err := accountSvc.VerifyLoginEmail(ctx, 0, phone, hash, sender.code, true); err != nil {
|
||||
t.Fatalf("VerifyLoginEmail: %v", err)
|
||||
}
|
||||
if rec, found, err := codes.Get(ctx, hash); err != nil || !found || !rec.SignUpVerified || rec.Channel != codeChannelEmailLogin {
|
||||
t.Fatalf("email-verified phone code=%+v found=%v err=%v", rec, found, err)
|
||||
}
|
||||
if _, msg, needSignUp, err := authSvc.SignInWithEmail(ctx, domain.Authorization{}, phone, hash, sender.code); err != nil || !needSignUp || msg.ID != 0 {
|
||||
t.Fatalf("SignInWithEmail after setup needSignUp=%v message=%+v err=%v", needSignUp, msg, err)
|
||||
}
|
||||
u, msg, err := authSvc.SignUp(ctx, domain.Authorization{}, phone, hash, "Email", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp after email setup: %v", err)
|
||||
}
|
||||
if msg.ID != 0 || msg.Body != "" {
|
||||
t.Fatalf("email SignUp returned SMTP code message: %+v", msg)
|
||||
}
|
||||
list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListByUser: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 0 || len(list.Messages) != 0 {
|
||||
t.Fatalf("email SignUp created 777000 bootstrap state: dialogs=%+v messages=%+v", list.Dialogs, list.Messages)
|
||||
}
|
||||
if email, found, err := accountSvc.LoginEmailByPhone(ctx, phone); err != nil || !found || email != "new@example.test" {
|
||||
t.Fatalf("LoginEmailByPhone email=%q found=%v err=%v", email, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeLoginEmailResetRequiresExactIssuedHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baseUsers := memory.NewUserStore()
|
||||
owner, err := baseUsers.Create(ctx, domain.User{Phone: "15550009330", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
other, err := baseUsers.Create(ctx, domain.User{Phone: "15550009331", FirstName: "Other"})
|
||||
if err != nil {
|
||||
t.Fatalf("create other: %v", err)
|
||||
}
|
||||
users := &switchablePhoneOwnerStore{UserStore: baseUsers}
|
||||
codes := memory.NewCodeStore()
|
||||
delivery := &captureLoginCodeDelivery{}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
|
||||
seed := func(hash, channel string) {
|
||||
t.Helper()
|
||||
if err := codes.Set(ctx, hash, store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
IssuedUserID: owner.ID,
|
||||
Phone: owner.Phone,
|
||||
Code: "654321",
|
||||
Channel: channel,
|
||||
MaxAttempts: 5,
|
||||
}, time.Minute); err != nil {
|
||||
t.Fatalf("seed %s: %v", hash, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, "arbitrary-missing"); !errors.Is(err, ErrCodeExpired) {
|
||||
t.Fatalf("arbitrary hash err=%v, want expired", err)
|
||||
}
|
||||
seed("wrong-phone", codeChannelEmailLogin)
|
||||
if _, err := svc.ConsumeLoginEmailReset(ctx, other.Phone, "wrong-phone"); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("wrong phone err=%v, want invalid", err)
|
||||
}
|
||||
if _, found, err := codes.Get(ctx, "wrong-phone"); err != nil || !found {
|
||||
t.Fatalf("wrong-phone probe destroyed valid hash found=%v err=%v", found, err)
|
||||
}
|
||||
seed("wrong-channel", codeChannelPhone)
|
||||
if _, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, "wrong-channel"); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("wrong channel err=%v, want invalid", err)
|
||||
}
|
||||
|
||||
seed("owner-drift", codeChannelEmailLogin)
|
||||
users.setOwnerView(owner.Phone, other, true)
|
||||
if _, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, "owner-drift"); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("A->B reset err=%v, want invalid", err)
|
||||
}
|
||||
users.resetOwnerView()
|
||||
if _, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, "owner-drift"); !errors.Is(err, ErrCodeExpired) {
|
||||
t.Fatalf("A->B->A reset err=%v, want expired", err)
|
||||
}
|
||||
|
||||
seed("successful-reset", codeChannelEmailLogin)
|
||||
resetUserID, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, "successful-reset")
|
||||
if err != nil || resetUserID != owner.ID {
|
||||
t.Fatalf("successful reset consume uid=%d err=%v", resetUserID, err)
|
||||
}
|
||||
replacementHash, err := svc.SendPhoneCodeAfterLoginEmailReset(ctx, owner.Phone, resetUserID)
|
||||
if err != nil || replacementHash == "" {
|
||||
t.Fatalf("replacement hash=%q err=%v", replacementHash, err)
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("replacement code=%+v found=%v err=%v", rec, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentLoginEmailResetHasSingleConsumer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
owner, err := users.Create(ctx, domain.User{Phone: "15550009332", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
codes := memory.NewCodeStore()
|
||||
hash := "concurrent-email-reset"
|
||||
if err := codes.Set(ctx, hash, store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
IssuedUserID: owner.ID,
|
||||
Phone: owner.Phone,
|
||||
Code: "654321",
|
||||
Channel: codeChannelEmailLogin,
|
||||
MaxAttempts: 5,
|
||||
}, time.Minute); err != nil {
|
||||
t.Fatalf("seed code: %v", err)
|
||||
}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345")
|
||||
const workers = 24
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func() {
|
||||
<-start
|
||||
_, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, hash)
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
successes := 0
|
||||
for i := 0; i < workers; i++ {
|
||||
err := <-errs
|
||||
if err == nil {
|
||||
successes++
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, ErrCodeExpired) {
|
||||
t.Fatalf("concurrent reset err=%v", err)
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("successful reset consumers=%d, want 1", successes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginEmailResetLocksUserAcrossOwnerTransfer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baseUsers := memory.NewUserStore()
|
||||
ownerA, err := baseUsers.Create(ctx, domain.User{Phone: "15550009340", FirstName: "A"})
|
||||
if err != nil {
|
||||
t.Fatalf("create A: %v", err)
|
||||
}
|
||||
ownerB, err := baseUsers.Create(ctx, domain.User{Phone: "15550009341", FirstName: "B"})
|
||||
if err != nil {
|
||||
t.Fatalf("create B: %v", err)
|
||||
}
|
||||
users := &switchablePhoneOwnerStore{UserStore: baseUsers}
|
||||
passwords := memory.NewPasswordStore()
|
||||
accountSvc := accountapp.NewService(passwords, accountapp.WithUsers(users))
|
||||
if err := accountSvc.SetLoginEmail(ctx, ownerA.ID, "a@example.test"); err != nil {
|
||||
t.Fatalf("SetLoginEmail A: %v", err)
|
||||
}
|
||||
if err := accountSvc.SetLoginEmail(ctx, ownerB.ID, "b@example.test"); err != nil {
|
||||
t.Fatalf("SetLoginEmail B: %v", err)
|
||||
}
|
||||
codes := memory.NewCodeStore()
|
||||
hash := "locked-reset-user"
|
||||
if err := codes.Set(ctx, hash, store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
IssuedUserID: ownerA.ID,
|
||||
Phone: ownerA.Phone,
|
||||
Code: "654321",
|
||||
Channel: codeChannelEmailLogin,
|
||||
MaxAttempts: 5,
|
||||
}, time.Minute); err != nil {
|
||||
t.Fatalf("seed reset code: %v", err)
|
||||
}
|
||||
delivery := &captureLoginCodeDelivery{}
|
||||
authSvc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
|
||||
resetUserID, err := authSvc.ConsumeLoginEmailReset(ctx, ownerA.Phone, hash)
|
||||
if err != nil || resetUserID != ownerA.ID {
|
||||
t.Fatalf("ConsumeLoginEmailReset uid=%d err=%v", resetUserID, err)
|
||||
}
|
||||
users.setOwnerView(ownerA.Phone, ownerB, true)
|
||||
if err := accountSvc.ClearLoginEmail(ctx, resetUserID); err != nil {
|
||||
t.Fatalf("ClearLoginEmail exact A: %v", err)
|
||||
}
|
||||
if _, err := authSvc.SendPhoneCodeAfterLoginEmailReset(ctx, ownerA.Phone, resetUserID); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("SendPhoneCodeAfterLoginEmailReset across A->B err=%v, want invalid", err)
|
||||
}
|
||||
if _, found, err := accountSvc.LoginEmail(ctx, ownerA.ID); err != nil || found {
|
||||
t.Fatalf("A login email found=%v err=%v, want cleared", found, err)
|
||||
}
|
||||
if email, found, err := accountSvc.LoginEmail(ctx, ownerB.ID); err != nil || !found || email != "b@example.test" {
|
||||
t.Fatalf("B login email=%q found=%v err=%v, want unchanged", email, found, err)
|
||||
}
|
||||
if len(delivery.requests) != 0 {
|
||||
t.Fatalf("owner B received reset replacement code: %+v", delivery.requests)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue