perf: sync protocol and core hardening updates

This commit is contained in:
A 2026-07-11 19:48:26 +08:00
parent 152fed3b87
commit 4390ebf5a9
283 changed files with 29231 additions and 2295 deletions

View file

@ -3,6 +3,8 @@ package account
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
@ -32,6 +34,88 @@ type captureMailSender struct {
code string
}
type blockingCodeCAS struct {
store.CodeStore
mu sync.Mutex
blockRevision string
blockUpdate bool
blockDelete bool
entered chan struct{}
release chan struct{}
once sync.Once
}
type switchableEmailOwnerStore struct {
store.UserStore
mu sync.RWMutex
phone string
override bool
owner domain.User
found bool
}
func (s *switchableEmailOwnerStore) 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 *switchableEmailOwnerStore) switchOwner(phone string, owner domain.User) {
s.mu.Lock()
s.phone = domain.NormalizePhone(phone)
s.owner = owner
s.found = true
s.override = true
s.mu.Unlock()
}
type afterSavePasswordStore struct {
store.PasswordStore
once sync.Once
afterSave func(userID int64, settings domain.PasswordSettings)
}
func (s *afterSavePasswordStore) Save(ctx context.Context, userID int64, settings domain.PasswordSettings) error {
if err := s.PasswordStore.Save(ctx, userID, settings); err != nil {
return err
}
if s.afterSave != nil {
s.once.Do(func() { s.afterSave(userID, settings) })
}
return nil
}
func (s *blockingCodeCAS) shouldBlock(revision string, update bool) bool {
s.mu.Lock()
defer s.mu.Unlock()
return revision == s.blockRevision && ((update && s.blockUpdate) || (!update && s.blockDelete))
}
func (s *blockingCodeCAS) waitIfBlocked(revision string, update bool) {
if !s.shouldBlock(revision, update) {
return
}
s.once.Do(func() {
close(s.entered)
<-s.release
})
}
func (s *blockingCodeCAS) CompareAndUpdate(ctx context.Context, key, revision string, next store.PhoneCode) (bool, error) {
s.waitIfBlocked(revision, true)
return s.CodeStore.CompareAndUpdate(ctx, key, revision, next)
}
func (s *blockingCodeCAS) CompareAndDelete(ctx context.Context, key, revision string) (bool, error) {
s.waitIfBlocked(revision, false)
return s.CodeStore.CompareAndDelete(ctx, key, revision)
}
func (s *captureMailSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error {
s.to = to
s.code = code
@ -66,22 +150,22 @@ func TestSetLoginEmailPersistsAndMasks(t *testing.T) {
}
}
// TestLoginEmailByPhoneAndClear 验证按手机号读取/清除登录邮箱(sendCode 检测 + reset 用)。
// TestLoginEmailByPhoneAndClear 验证 sendCode 可按手机号读取,但 reset 只按已锁定 userID 清除。
func TestLoginEmailByPhoneAndClear(t *testing.T) {
ctx := context.Background()
svc, users := newLoginEmailService(t)
createUser(t, users, "15550010002")
u := createUser(t, users, "15550010002")
if err := svc.SetLoginEmailByPhone(ctx, "+1 555 001 0002", "bob@mail.com"); err != nil {
t.Fatalf("SetLoginEmailByPhone: %v", err)
if err := svc.SetLoginEmail(ctx, u.ID, "bob@mail.com"); err != nil {
t.Fatalf("SetLoginEmail: %v", err)
}
email, found, err := svc.LoginEmailByPhone(ctx, "15550010002")
if err != nil || !found || email != "bob@mail.com" {
t.Fatalf("LoginEmailByPhone = %q found=%v err=%v", email, found, err)
}
if err := svc.ClearLoginEmailByPhone(ctx, "15550010002"); err != nil {
t.Fatalf("ClearLoginEmailByPhone: %v", err)
if err := svc.ClearLoginEmail(ctx, u.ID); err != nil {
t.Fatalf("ClearLoginEmail: %v", err)
}
if _, found, _ := svc.LoginEmailByPhone(ctx, "15550010002"); found {
t.Fatal("login email still present after clear")
@ -182,7 +266,7 @@ func TestLoginEmailSetupRejectsAlreadyOwnedEmailForNewPhone(t *testing.T) {
if err := svc.SetLoginEmail(ctx, owner.ID, "owner@example.test"); err != nil {
t.Fatalf("SetLoginEmail owner: %v", err)
}
if err := codes.Set(ctx, "new-phone-hash", store.PhoneCode{Phone: "15550010108", Channel: "email_setup_required", MaxAttempts: 2}, time.Minute); err != nil {
if err := codes.Set(ctx, "new-phone-hash", store.PhoneCode{Version: store.PhoneCodeVersionCurrent, Phone: "15550010108", Channel: "email_setup_required", MaxAttempts: 2}, time.Minute); err != nil {
t.Fatalf("seed phone code: %v", err)
}
@ -234,7 +318,7 @@ func TestLoginEmailSetupStoresPendingEmailOnPhoneCodeHash(t *testing.T) {
svc := NewService(memory.NewPasswordStore(),
WithUsers(memory.NewUserStore()),
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
if err := codes.Set(ctx, "phone-hash", store.PhoneCode{Phone: "15550010006", Channel: "email_setup_required", MaxAttempts: 2}, time.Minute); err != nil {
if err := codes.Set(ctx, "phone-hash", store.PhoneCode{Version: store.PhoneCodeVersionCurrent, Phone: "15550010006", Channel: "email_setup_required", MaxAttempts: 2}, time.Minute); err != nil {
t.Fatalf("seed phone code: %v", err)
}
@ -252,7 +336,7 @@ func TestLoginEmailSetupStoresPendingEmailOnPhoneCodeHash(t *testing.T) {
if err != nil || !found {
t.Fatalf("phone code found=%v err=%v", found, err)
}
if rec.Channel != "email_login" || rec.Code != sender.code || rec.Email != "new@example.test" || !rec.VerifiedEmail || rec.PendingEmail != "new@example.test" {
if rec.Channel != "email_login" || rec.Code != sender.code || rec.Email != "new@example.test" || !rec.VerifiedEmail || !rec.SignUpVerified || rec.PendingEmail != "new@example.test" {
t.Fatalf("phone code after verify = %+v", rec)
}
}
@ -291,3 +375,184 @@ func TestVerifyLoginEmailDeletesCodeAfterMaxAttempts(t *testing.T) {
t.Fatal("login email was set after exhausted verification code")
}
}
func TestStaleLoginEmailVerificationCannotMutateResentCode(t *testing.T) {
ctx := context.Background()
for _, tc := range []struct {
name string
blockUpdate bool
blockDelete bool
verificationCode func(old string) string
}{
{
name: "wrong-code-update",
blockUpdate: true,
verificationCode: func(old string) string {
if old != "000000" {
return "000000"
}
return "111111"
},
},
{
name: "correct-code-delete",
blockDelete: true,
verificationCode: func(old string) string { return old },
},
} {
t.Run(tc.name, func(t *testing.T) {
users := memory.NewUserStore()
baseCodes := memory.NewCodeStore()
codes := &blockingCodeCAS{CodeStore: baseCodes}
passwords := memory.NewPasswordStore()
sender := &captureMailSender{}
svc := NewService(passwords,
WithUsers(users),
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6))
u := createUser(t, users, "155500102"+fmt.Sprint(10+len(tc.name)))
if _, _, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "cas@example.test", false); err != nil {
t.Fatalf("first SendLoginEmailCode: %v", err)
}
oldCode := sender.code
key := loginEmailVerifyChangePrefix + fmt.Sprint(u.ID)
oldSnapshot, found, err := baseCodes.GetSnapshot(ctx, key)
if err != nil || !found {
t.Fatalf("old snapshot found=%v err=%v", found, err)
}
codes.blockRevision = oldSnapshot.Revision
codes.blockUpdate = tc.blockUpdate
codes.blockDelete = tc.blockDelete
codes.entered = make(chan struct{})
codes.release = make(chan struct{})
verifyErr := make(chan error, 1)
go func() {
_, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", tc.verificationCode(oldCode), false)
verifyErr <- err
}()
<-codes.entered
for attempts := 0; attempts < 5; attempts++ {
if _, _, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "cas@example.test", false); err != nil {
t.Fatalf("resent SendLoginEmailCode: %v", err)
}
if sender.code != oldCode {
break
}
}
newCode := sender.code
if newCode == oldCode {
t.Fatal("random resend repeatedly produced the old code")
}
newSnapshot, found, err := baseCodes.GetSnapshot(ctx, key)
if err != nil || !found || newSnapshot.Revision == oldSnapshot.Revision {
t.Fatalf("new snapshot=%+v found=%v err=%v", newSnapshot, found, err)
}
close(codes.release)
if err := <-verifyErr; !errors.Is(err, domain.ErrEmailCodeInvalid) {
t.Fatalf("stale verification err=%v, want ErrEmailCodeInvalid", err)
}
current, found, err := baseCodes.GetSnapshot(ctx, key)
if err != nil || !found || current.Revision != newSnapshot.Revision || current.Record.Code != newCode || current.Record.Attempts != 0 {
t.Fatalf("current code after stale verifier=%+v found=%v err=%v", current, found, err)
}
if _, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", newCode, false); err != nil {
t.Fatalf("VerifyLoginEmail new code: %v", err)
}
})
}
}
func TestConcurrentWrongLoginEmailCodesNeverAuthorize(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
codes := memory.NewCodeStore()
passwords := memory.NewPasswordStore()
sender := &captureMailSender{}
svc := NewService(passwords,
WithUsers(users),
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
u := createUser(t, users, "15550010231")
if _, _, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "wrong@example.test", false); err != nil {
t.Fatalf("SendLoginEmailCode: %v", err)
}
correct := sender.code
wrong := "000000"
if wrong == correct {
wrong = "111111"
}
const workers = 32
start := make(chan struct{})
errs := make(chan error, workers)
for i := 0; i < workers; i++ {
go func() {
<-start
_, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", wrong, false)
errs <- err
}()
}
close(start)
for i := 0; i < workers; i++ {
if err := <-errs; !errors.Is(err, domain.ErrEmailCodeInvalid) {
t.Fatalf("wrong concurrent verification err=%v", err)
}
}
if _, found, err := svc.LoginEmail(ctx, u.ID); err != nil || found {
t.Fatalf("LoginEmail after wrong codes found=%v err=%v, want absent", found, err)
}
key := loginEmailVerifyChangePrefix + fmt.Sprint(u.ID)
for attempts := 0; attempts < 3; attempts++ {
if _, found, err := codes.GetSnapshot(ctx, key); err != nil {
t.Fatalf("GetSnapshot after concurrent attempts: %v", err)
} else if !found {
break
}
if _, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", wrong, false); !errors.Is(err, domain.ErrEmailCodeInvalid) {
t.Fatalf("final wrong verification err=%v", err)
}
}
if _, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", correct, false); !errors.Is(err, domain.ErrEmailCodeInvalid) {
t.Fatalf("correct code after exhausted attempts err=%v, want invalid", err)
}
}
func TestEmailSetupOwnerTransferDuringSaveNeverWritesFactorToNewOwner(t *testing.T) {
ctx := context.Background()
baseUsers := memory.NewUserStore()
ownerA := createUser(t, baseUsers, "15550010241")
ownerB := createUser(t, baseUsers, "15550010242")
users := &switchableEmailOwnerStore{UserStore: baseUsers}
basePasswords := memory.NewPasswordStore()
passwords := &afterSavePasswordStore{PasswordStore: basePasswords}
codes := memory.NewCodeStore()
sender := &captureMailSender{}
svc := NewService(passwords,
WithUsers(users),
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6))
hash := "owner-save-race"
if err := codes.Set(ctx, hash, store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
IssuedUserID: ownerA.ID,
Phone: ownerA.Phone,
Channel: codeChannelEmailSetupRequired,
MaxAttempts: 3,
}, time.Minute); err != nil {
t.Fatalf("seed phone code: %v", err)
}
if _, _, err := svc.SendLoginEmailCode(ctx, 0, ownerA.Phone, hash, "owner-a@example.test", true); err != nil {
t.Fatalf("SendLoginEmailCode: %v", err)
}
passwords.afterSave = func(userID int64, settings domain.PasswordSettings) {
if userID == ownerA.ID && settings.LoginEmail == "owner-a@example.test" {
users.switchOwner(ownerA.Phone, ownerB)
}
}
if _, err := svc.VerifyLoginEmail(ctx, 0, ownerA.Phone, hash, sender.code, true); !errors.Is(err, domain.ErrEmailCodeInvalid) {
t.Fatalf("VerifyLoginEmail across save-time owner transfer err=%v, want invalid", err)
}
if settings, found, err := basePasswords.GetByUser(ctx, ownerB.ID); err != nil || (found && settings.LoginEmail != "") {
t.Fatalf("new owner settings=%+v found=%v err=%v, SMTP factor leaked to B", settings, found, err)
}
if _, found, err := codes.GetSnapshot(ctx, hash); err != nil || found {
t.Fatalf("owner-drift phone hash found=%v err=%v, want invalidated", found, err)
}
}

View file

@ -3,7 +3,6 @@ package account
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"strings"
@ -51,9 +50,10 @@ func (s *Service) SendChangePhoneCode(ctx context.Context, userID int64, authKey
return "", domain.AuthCodeDelivery{}, err
}
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Code: s.phoneChangeCode,
Channel: "phone",
Channel: store.PhoneCodeChannelPhone,
Purpose: store.PhoneCodePurposeChangePhone,
UserID: userID,
AuthKeyID: authKeyID,
@ -68,7 +68,7 @@ func (s *Service) SendChangePhoneCode(ctx context.Context, userID int64, authKey
// ChangePhone 验证作用域和验证码后执行原子改号。返回事件用于当前 session 的
// pts 簿记;其它 session 由 transactional outbox 投递 updateUserPhone。
func (s *Service) ChangePhone(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, phoneCodeHash, code string, date int) (domain.PhoneChangeResult, error) {
func (s *Service) ChangePhone(ctx context.Context, userID int64, authKeyID, originRawAuthKeyID [8]byte, sessionID int64, phone, phoneCodeHash, code string, date int) (domain.PhoneChangeResult, error) {
if strings.TrimSpace(phoneCodeHash) == "" || strings.TrimSpace(code) == "" {
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeEmpty
}
@ -82,51 +82,45 @@ func (s *Service) ChangePhone(ctx context.Context, userID int64, authKeyID [8]by
if s.codes == nil || s.phoneChanges == nil {
return domain.PhoneChangeResult{}, fmt.Errorf("phone change service is not configured")
}
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
scope := store.PhoneCodeScope{
Purpose: store.PhoneCodePurposeChangePhone,
UserID: userID,
AuthKeyID: authKeyID,
Phone: phone,
}
verified, err := s.codes.VerifyScoped(ctx, phoneCodeHash, scope, strings.TrimSpace(code), s.phoneChangeMaxAttempts)
if err != nil {
return domain.PhoneChangeResult{}, err
}
if !found {
switch verified.Status {
case store.LoginCodeVerifyMissing:
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeExpired
}
if rec.Purpose != store.PhoneCodePurposeChangePhone || rec.Phone != phone || rec.UserID != userID || rec.AuthKeyID != authKeyID {
case store.LoginCodeVerifyInvalid:
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid
case store.LoginCodeVerifyAccepted:
default:
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid
}
code = strings.TrimSpace(code)
if subtle.ConstantTimeCompare([]byte(rec.Code), []byte(code)) != 1 {
return domain.PhoneChangeResult{}, s.rejectPhoneChangeCode(ctx, phoneCodeHash, rec)
}
if existing, occupied, err := s.users.ByPhone(ctx, phone); err != nil {
return domain.PhoneChangeResult{}, err
} else if occupied && existing.ID != userID {
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
}
// 正确 code 必须在进入持久化事务前原子消费。并发重放中只有一个请求能
// 获得记录,其余请求不得再次推进 pts 或追加 user_phone event。
consumed, found, err := s.codes.ConsumeScoped(ctx, phoneCodeHash, store.PhoneCodeScope{
Purpose: store.PhoneCodePurposeChangePhone,
UserID: userID,
AuthKeyID: authKeyID,
Phone: phone,
})
if err != nil {
return domain.PhoneChangeResult{}, err
}
if !found {
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeExpired
}
if consumed.Purpose != rec.Purpose || consumed.UserID != rec.UserID || consumed.AuthKeyID != rec.AuthKeyID || consumed.Phone != rec.Phone ||
subtle.ConstantTimeCompare([]byte(consumed.Code), []byte(code)) != 1 {
consumed := verified.Record
if consumed.Version != store.PhoneCodeVersionCurrent || consumed.Scope() != scope || consumed.Channel != store.PhoneCodeChannelPhone {
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid
}
if date == 0 {
date = int(time.Now().Unix())
}
result, err := s.phoneChanges.ChangePhone(ctx, domain.PhoneChangeRequest{
UserID: userID,
Phone: phone,
Date: date,
ExcludeAuthKeyID: authKeyID,
UserID: userID,
Phone: phone,
Date: date,
// Authorization/code scope is the stable business (perm) key, while dispatch exclusion
// must use the physical raw key. They differ on PFS/temp connections; conflating them
// echoes updateUserPhone back to the initiating device and suppresses the wrong session.
ExcludeAuthKeyID: originRawAuthKeyID,
ExcludeSessionID: sessionID,
})
if err != nil {
@ -162,20 +156,6 @@ func (s *Service) phoneChangeCaller(ctx context.Context, userID int64, authKeyID
return u, nil
}
func (s *Service) rejectPhoneChangeCode(ctx context.Context, hash string, rec store.PhoneCode) error {
rec.Attempts++
max := rec.MaxAttempts
if max <= 0 {
max = s.phoneChangeMaxAttempts
}
if max > 0 && rec.Attempts >= max {
_ = s.codes.Del(ctx, hash)
return domain.ErrPhoneCodeInvalid
}
_ = s.codes.Update(ctx, hash, rec)
return domain.ErrPhoneCodeInvalid
}
func phoneChangeHash() (string, error) {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {

View file

@ -21,6 +21,26 @@ type phoneChangeFixture struct {
events *memory.UpdateEventStore
user domain.User
authKeyID [8]byte
changes *recordingPhoneChangeStore
}
type recordingPhoneChangeStore struct {
mu sync.Mutex
inner store.PhoneChangeStore
last domain.PhoneChangeRequest
}
func (s *recordingPhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error) {
s.mu.Lock()
s.last = req
s.mu.Unlock()
return s.inner.ChangePhone(ctx, req)
}
func (s *recordingPhoneChangeStore) lastRequest() domain.PhoneChangeRequest {
s.mu.Lock()
defer s.mu.Unlock()
return s.last
}
func newPhoneChangeFixture(t *testing.T) phoneChangeFixture {
@ -38,12 +58,13 @@ func newPhoneChangeFixture(t *testing.T) phoneChangeFixture {
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: authKeyID, UserID: u.ID, CreatedAt: time.Now().Add(-48 * time.Hour)}); err != nil {
t.Fatalf("bind auth: %v", err)
}
changes := &recordingPhoneChangeStore{inner: memory.NewPhoneChangeStore(users, events)}
service := NewService(
memory.NewPasswordStore(),
WithUsers(users),
WithPhoneChange(memory.NewPhoneChangeStore(users, events), auths, codes, nil, "12345", time.Minute, 3),
WithPhoneChange(changes, auths, codes, nil, "12345", time.Minute, 3),
)
return phoneChangeFixture{ctx: ctx, service: service, users: users, auths: auths, codes: codes, events: events, user: u, authKeyID: authKeyID}
return phoneChangeFixture{ctx: ctx, service: service, users: users, auths: auths, codes: codes, events: events, user: u, authKeyID: authKeyID, changes: changes}
}
func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) {
@ -59,17 +80,21 @@ func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) {
if err != nil || !found {
t.Fatalf("load code found=%v err=%v", found, err)
}
if rec.Purpose != store.PhoneCodePurposeChangePhone || rec.Phone != "15550012002" || rec.UserID != f.user.ID || rec.AuthKeyID != f.authKeyID || rec.SessionID != 77 {
if rec.Version != store.PhoneCodeVersionCurrent || rec.Purpose != store.PhoneCodePurposeChangePhone || rec.Phone != "15550012002" || rec.UserID != f.user.ID || rec.AuthKeyID != f.authKeyID || rec.SessionID != 77 {
t.Fatalf("scoped code = %+v", rec)
}
result, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 88, "+1 555 001 2002", hash, "12345", 1700000000)
rawAuthKeyID := [8]byte{8, 8, 8, 8}
result, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, rawAuthKeyID, 88, "+1 555 001 2002", hash, "12345", 1700000000)
if err != nil {
t.Fatalf("change phone after session reconnect: %v", err)
}
if !result.Changed || result.User.Phone != "15550012002" || result.Event.Type != domain.UpdateEventUserPhone || result.Event.Phone != "15550012002" || result.Event.Pts != 1 {
t.Fatalf("change result = %+v", result)
}
if got := f.changes.lastRequest().ExcludeAuthKeyID; got != rawAuthKeyID {
t.Fatalf("outbox exclusion auth key = %x, want physical raw %x", got, rawAuthKeyID)
}
if _, found, _ := f.users.ByPhone(f.ctx, "15550012001"); found {
t.Fatal("old phone still resolves")
}
@ -103,7 +128,7 @@ func TestPhoneChangeRejectsOccupiedAndCrossAuthCode(t *testing.T) {
if err := f.auths.Bind(f.ctx, domain.Authorization{AuthKeyID: otherKey, UserID: occupied.ID}); err != nil {
t.Fatalf("bind other auth: %v", err)
}
if _, err := f.service.ChangePhone(f.ctx, occupied.ID, otherKey, 99, "15550012004", hash, "12345", 0); !errors.Is(err, domain.ErrPhoneCodeInvalid) {
if _, err := f.service.ChangePhone(f.ctx, occupied.ID, otherKey, otherKey, 99, "15550012004", hash, "12345", 0); !errors.Is(err, domain.ErrPhoneCodeExpired) {
t.Fatalf("cross-auth change err = %v", err)
}
if got, found, _ := f.users.ByID(f.ctx, occupied.ID); !found || got.Phone != "15550012003" {
@ -118,11 +143,11 @@ func TestPhoneChangeWrongCodeExhaustsAttempts(t *testing.T) {
t.Fatalf("send code: %v", err)
}
for i := 0; i < 3; i++ {
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 77, "15550012005", hash, "00000", 0); !errors.Is(err, domain.ErrPhoneCodeInvalid) {
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, f.authKeyID, 77, "15550012005", hash, "00000", 0); !errors.Is(err, domain.ErrPhoneCodeInvalid) {
t.Fatalf("wrong attempt %d err = %v", i+1, err)
}
}
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 77, "15550012005", hash, "12345", 0); !errors.Is(err, domain.ErrPhoneCodeExpired) {
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, f.authKeyID, 77, "15550012005", hash, "12345", 0); !errors.Is(err, domain.ErrPhoneCodeExpired) {
t.Fatalf("exhausted code err = %v", err)
}
if got, _, _ := f.users.ByID(f.ctx, f.user.ID); got.Phone != "15550012001" {
@ -143,10 +168,10 @@ func TestPhoneChangeNewSendInvalidatesPreviousHash(t *testing.T) {
if oldHash == newHash {
t.Fatalf("hash was not rotated: %q", oldHash)
}
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 99, "15550012006", oldHash, "12345", 1700000001); !errors.Is(err, domain.ErrPhoneCodeExpired) {
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, f.authKeyID, 99, "15550012006", oldHash, "12345", 1700000001); !errors.Is(err, domain.ErrPhoneCodeExpired) {
t.Fatalf("old hash replay err = %v", err)
}
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 99, "15550012006", newHash, "12345", 1700000002); err != nil {
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, f.authKeyID, 99, "15550012006", newHash, "12345", 1700000002); err != nil {
t.Fatalf("new hash change: %v", err)
}
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
@ -168,7 +193,7 @@ func TestPhoneChangeConcurrentReplayAppendsOneEvent(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
_, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 88, "15550012007", hash, "12345", 1700000003)
_, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, f.authKeyID, 88, "15550012007", hash, "12345", 1700000003)
errs <- err
}()
}

View file

@ -17,13 +17,14 @@ import (
var defaultSecureRandom = []byte("telesrv-tdesktop-dev-secure-rand")
const (
passwordResetWait = 7 * 24 * time.Hour
passwordResetRetry = 24 * time.Hour
loginEmailVerifyChangePrefix = "login-email-change:"
loginEmailVerifySetupPrefix = "login-email-setup:"
codeChannelEmailSetup = "email_setup"
codeChannelEmailChange = "email_change"
codeChannelEmailLogin = "email_login"
passwordResetWait = 7 * 24 * time.Hour
passwordResetRetry = 24 * time.Hour
loginEmailVerifyChangePrefix = "login-email-change:"
loginEmailVerifySetupPrefix = "login-email-setup:"
codeChannelEmailSetup = "email_setup"
codeChannelEmailChange = "email_change"
codeChannelEmailLogin = "email_login"
codeChannelEmailSetupRequired = "email_setup_required"
)
// Service 提供账号安全配置查询。
@ -568,12 +569,16 @@ func (s *Service) SendLoginEmailCode(ctx context.Context, userID int64, phone, p
}
key := loginEmailVerifyChangePrefix + fmt.Sprint(userID)
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Code: "",
Channel: codeChannelEmailChange,
PendingEmail: email,
MaxAttempts: s.loginEmailCodeMaxAttempts,
}
if setup {
if s.users == nil {
return "", 0, domain.ErrEmailNotAllowed
}
phone = domain.NormalizePhone(phone)
phoneRec, found, err := s.codes.Get(ctx, phoneCodeHash)
if err != nil {
@ -582,7 +587,8 @@ func (s *Service) SendLoginEmailCode(ctx context.Context, userID int64, phone, p
if !found {
return "", 0, domain.ErrEmailCodeInvalid
}
if phoneRec.Phone != phone {
if phoneRec.Version != store.PhoneCodeVersionCurrent || phoneRec.Purpose != "" || phoneRec.Phone != phone ||
phoneRec.Channel != codeChannelEmailSetupRequired || phoneRec.SignUpVerified {
return "", 0, domain.ErrEmailInvalid
}
targetUserID := int64(0)
@ -591,6 +597,9 @@ func (s *Service) SendLoginEmailCode(ctx context.Context, userID int64, phone, p
} else if found {
targetUserID = existingUserID
}
if phoneRec.IssuedUserID != targetUserID {
return "", 0, domain.ErrEmailInvalid
}
if err := s.ensureLoginEmailAvailable(ctx, targetUserID, email); err != nil {
return "", 0, err
}
@ -611,7 +620,9 @@ func (s *Service) SendLoginEmailCode(ctx context.Context, userID int64, phone, p
return "", 0, err
}
if err := s.loginEmailSender.SendLoginCode(ctx, email, code, s.loginEmailCodeTTL); err != nil {
_ = s.codes.Del(ctx, key)
// Set does not expose its generated revision. A blind Del here could
// remove a newer concurrent resend; leave the unreachable random code
// to expire or be replaced by the retry instead.
return "", 0, err
}
return emailPattern(email), len(code), nil
@ -625,28 +636,43 @@ func (s *Service) VerifyLoginEmail(ctx context.Context, userID int64, phone, pho
if setup {
key = loginEmailVerifySetupPrefix + phoneCodeHash
}
rec, found, err := s.codes.Get(ctx, key)
snapshot, found, err := s.codes.GetSnapshot(ctx, key)
if err != nil {
return "", err
}
if !found {
return "", domain.ErrEmailCodeInvalid
}
rec := snapshot.Record
if strings.TrimSpace(code) == "" || subtle.ConstantTimeCompare([]byte(rec.Code), []byte(strings.TrimSpace(code))) != 1 {
return "", s.rejectEmailCode(ctx, key, rec)
return "", s.rejectEmailCode(ctx, key, snapshot)
}
email := normalizeLoginEmail(rec.PendingEmail)
if !validLoginEmail(email) {
_ = s.codes.Del(ctx, key)
applied, deleteErr := s.codes.CompareAndDelete(ctx, key, snapshot.Revision)
if deleteErr != nil {
return "", deleteErr
}
if !applied {
return "", domain.ErrEmailCodeInvalid
}
return "", domain.ErrEmailInvalid
}
if setup {
if s.users == nil || rec.Channel != codeChannelEmailSetup {
return "", domain.ErrEmailCodeInvalid
}
phone = domain.NormalizePhone(phone)
phoneRec, found, err := s.codes.Get(ctx, phoneCodeHash)
if rec.Phone != phone {
return "", domain.ErrEmailCodeInvalid
}
phoneSnapshot, found, err := s.codes.GetSnapshot(ctx, phoneCodeHash)
if err != nil {
return "", err
}
if !found || phoneRec.Phone != phone {
phoneRec := phoneSnapshot.Record
if !found || phoneRec.Version != store.PhoneCodeVersionCurrent || phoneRec.Purpose != "" ||
phoneRec.Phone != phone || phoneRec.Channel != codeChannelEmailSetupRequired || phoneRec.SignUpVerified {
return "", domain.ErrEmailCodeInvalid
}
targetUserID := int64(0)
@ -655,11 +681,23 @@ func (s *Service) VerifyLoginEmail(ctx context.Context, userID int64, phone, pho
} else if found {
targetUserID = existingUserID
}
if phoneRec.IssuedUserID != targetUserID {
s.invalidateLoginCode(ctx, phoneCodeHash, phone)
return "", domain.ErrEmailCodeInvalid
}
if err := s.ensureLoginEmailAvailable(ctx, targetUserID, email); err != nil {
_ = s.codes.Del(ctx, key)
return "", err
}
_ = s.codes.Del(ctx, key)
// Claim this exact email-code revision before mutating the phone login
// state. A concurrent resend rotates the revision, so an old verifier
// can neither consume the new code nor authorize the phone hash.
claimed, err := s.codes.CompareAndDelete(ctx, key, snapshot.Revision)
if err != nil {
return "", err
}
if !claimed {
return "", domain.ErrEmailCodeInvalid
}
phoneRec.Channel = codeChannelEmailLogin
phoneRec.Code = strings.TrimSpace(code)
phoneRec.Email = email
@ -667,43 +705,94 @@ func (s *Service) VerifyLoginEmail(ctx context.Context, userID int64, phone, pho
phoneRec.VerifiedEmail = true
phoneRec.Attempts = 0
phoneRec.MaxAttempts = s.loginEmailCodeMaxAttempts
if err := s.codes.Update(ctx, phoneCodeHash, phoneRec); err != nil {
updated, err := s.codes.CompareAndUpdate(ctx, phoneCodeHash, phoneSnapshot.Revision, phoneRec)
if err != nil {
return "", err
}
if _, found, err := s.userIDByPhone(ctx, phone); err != nil {
if !updated {
return "", domain.ErrEmailCodeInvalid
}
if targetUserID == 0 {
verified, err := s.codes.VerifyLogin(ctx, phoneCodeHash, phone, phoneRec.Code, true, s.loginEmailCodeMaxAttempts)
if err != nil {
return "", err
}
if verified.Status != store.LoginCodeVerifyAccepted || verified.Record.IssuedUserID != 0 || !verified.Record.SignUpVerified {
return "", domain.ErrEmailCodeInvalid
}
phoneRec = verified.Record
}
afterUserID := int64(0)
if existingUserID, found, err := s.userIDByPhone(ctx, phone); err != nil {
return "", err
} else if found {
if err := s.SetLoginEmailByPhone(ctx, phone, email); err != nil {
afterUserID = existingUserID
}
if afterUserID != targetUserID || phoneRec.IssuedUserID != afterUserID {
s.invalidateLoginCode(ctx, phoneCodeHash, phone)
return "", domain.ErrEmailCodeInvalid
}
if targetUserID != 0 {
// Keep the identity selected before SMTP verification. Re-resolving
// phone at this write boundary would let an A→B transfer attach A's
// verified factor to B.
if err := s.SetLoginEmail(ctx, targetUserID, email); err != nil {
return "", err
}
finalUserID := int64(0)
if existingUserID, found, err := s.userIDByPhone(ctx, phone); err != nil {
return "", err
} else if found {
finalUserID = existingUserID
}
if finalUserID != targetUserID {
s.invalidateLoginCode(ctx, phoneCodeHash, phone)
return "", domain.ErrEmailCodeInvalid
}
}
return email, nil
}
if err := s.ensureLoginEmailAvailable(ctx, userID, email); err != nil {
_ = s.codes.Del(ctx, key)
return "", err
}
_ = s.codes.Del(ctx, key)
claimed, err := s.codes.CompareAndDelete(ctx, key, snapshot.Revision)
if err != nil {
return "", err
}
if !claimed {
return "", domain.ErrEmailCodeInvalid
}
if err := s.SetLoginEmail(ctx, userID, email); err != nil {
return "", err
}
return email, nil
}
func (s *Service) rejectEmailCode(ctx context.Context, key string, rec store.PhoneCode) error {
func (s *Service) rejectEmailCode(ctx context.Context, key string, snapshot store.PhoneCodeSnapshot) error {
rec := snapshot.Record
rec.Attempts++
max := rec.MaxAttempts
if max <= 0 {
max = s.loginEmailCodeMaxAttempts
}
if max > 0 && rec.Attempts >= max {
_ = s.codes.Del(ctx, key)
if _, err := s.codes.CompareAndDelete(ctx, key, snapshot.Revision); err != nil {
return err
}
return domain.ErrEmailCodeInvalid
}
_ = s.codes.Update(ctx, key, rec)
if _, err := s.codes.CompareAndUpdate(ctx, key, snapshot.Revision, rec); err != nil {
return err
}
return domain.ErrEmailCodeInvalid
}
func (s *Service) invalidateLoginCode(ctx context.Context, hash, phone string) {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
_, _ = s.codes.InvalidateLoginCode(cleanupCtx, hash, phone)
}
// SetLoginEmail 为已登录用户写入登录邮箱(authed 的 emailVerifyPurposeLoginChange)。
// 账号无 2FA 也可设置:account_passwords 行可在 has_password=false 下仅承载登录邮箱。
func (s *Service) SetLoginEmail(ctx context.Context, userID int64, email string) error {
@ -726,19 +815,6 @@ func (s *Service) SetLoginEmail(ctx context.Context, userID int64, email string)
return s.passwords.Save(ctx, userID, settings)
}
// SetLoginEmailByPhone 为某手机号对应的账号写入登录邮箱(登录流程中的
// emailVerifyPurposeLoginSetup,此时尚未鉴权,只能凭 phone 定位用户)。
func (s *Service) SetLoginEmailByPhone(ctx context.Context, phone, email string) error {
userID, found, err := s.userIDByPhone(ctx, phone)
if err != nil {
return err
}
if !found {
return domain.ErrEmailInvalid
}
return s.SetLoginEmail(ctx, userID, email)
}
// LoginEmail 返回已登录用户的登录邮箱原始地址(用于 verifyEmail 回显 emailVerified.email)。
func (s *Service) LoginEmail(ctx context.Context, userID int64) (string, bool, error) {
if s == nil || s.passwords == nil || userID == 0 {
@ -754,8 +830,7 @@ func (s *Service) LoginEmail(ctx context.Context, userID int64) (string, bool, e
return normalizeLoginEmail(settings.LoginEmail), true, nil
}
// LoginEmailByPhone 按手机号返回登录邮箱原始地址(供 auth.sendCode 检测是否改投邮箱、
// login-setup 回显、reset 回显使用)。
// LoginEmailByPhone 按手机号返回登录邮箱原始地址,供 auth.sendCode 检测是否改投邮箱。
func (s *Service) LoginEmailByPhone(ctx context.Context, phone string) (string, bool, error) {
userID, found, err := s.userIDByPhone(ctx, phone)
if err != nil || !found {
@ -764,14 +839,12 @@ func (s *Service) LoginEmailByPhone(ctx context.Context, phone string) (string,
return s.LoginEmail(ctx, userID)
}
// ClearLoginEmailByPhone 清除某手机号账号的登录邮箱(auth.resetLoginEmail)。
func (s *Service) ClearLoginEmailByPhone(ctx context.Context, phone string) error {
userID, found, err := s.userIDByPhone(ctx, phone)
if err != nil {
return err
}
if !found {
return nil
// ClearLoginEmail clears the factor on the exact account selected by the
// preceding reset-code consume. Authentication factors must never be mutated
// through a second phone→user lookup.
func (s *Service) ClearLoginEmail(ctx context.Context, userID int64) error {
if s == nil || s.passwords == nil || userID == 0 {
return domain.ErrEmailInvalid
}
settings, found, err := s.passwords.GetByUser(ctx, userID)
if err != nil || !found {