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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue