feat: sync public links and phone change updates

This commit is contained in:
A 2026-07-10 22:01:44 +08:00
parent 41c7f1d018
commit da04c0fa6a
53 changed files with 3029 additions and 111 deletions

View file

@ -0,0 +1,185 @@
package account
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"strings"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
type reliablePhoneChangeDispatcher interface {
UsesReliableDispatch() bool
}
func (s *Service) PhoneChangeUsesReliableDispatch() bool {
if s == nil {
return false
}
reporter, ok := s.phoneChanges.(reliablePhoneChangeDispatcher)
return ok && reporter.UsesReliableDispatch()
}
// SendChangePhoneCode 创建只允许当前 user + perm auth_key 消费的改号验证码。
// CodeStore 会按 purpose+user+auth_key+phone 原子轮换:同一作用域的新请求
// 立即使旧 hash 失效,避免 Android 返回重进页面时留下并行有效验证码。
// SessionID 被记录用于审计,但验证时不要求相等:同一设备在等待短信期间发生
// MTProto session 重建仍可完成流程;其它设备因 auth_key 不同无法复用。
func (s *Service) SendChangePhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string) (string, domain.AuthCodeDelivery, error) {
phone = domain.NormalizePhone(phone)
if !domain.ValidPhone(phone) {
return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberInvalid
}
if _, err := s.phoneChangeCaller(ctx, userID, authKeyID); err != nil {
return "", domain.AuthCodeDelivery{}, err
}
if existing, found, err := s.users.ByPhone(ctx, phone); err != nil {
return "", domain.AuthCodeDelivery{}, err
} else if found && existing.ID != 0 {
return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberOccupied
}
if s.codes == nil || strings.TrimSpace(s.phoneChangeCode) == "" {
return "", domain.AuthCodeDelivery{}, fmt.Errorf("phone change code service is not configured")
}
hash, err := phoneChangeHash()
if err != nil {
return "", domain.AuthCodeDelivery{}, err
}
rec := store.PhoneCode{
Phone: phone,
Code: s.phoneChangeCode,
Channel: "phone",
Purpose: store.PhoneCodePurposeChangePhone,
UserID: userID,
AuthKeyID: authKeyID,
SessionID: sessionID,
MaxAttempts: s.phoneChangeMaxAttempts,
}
if err := s.codes.Set(ctx, hash, rec, s.phoneChangeCodeTTL); err != nil {
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store phone change code: %w", err)
}
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}, nil
}
// 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) {
if strings.TrimSpace(phoneCodeHash) == "" || strings.TrimSpace(code) == "" {
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeEmpty
}
phone = domain.NormalizePhone(phone)
if !domain.ValidPhone(phone) {
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberInvalid
}
if _, err := s.phoneChangeCaller(ctx, userID, authKeyID); err != nil {
return domain.PhoneChangeResult{}, err
}
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)
if err != nil {
return domain.PhoneChangeResult{}, err
}
if !found {
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeExpired
}
if rec.Purpose != store.PhoneCodePurposeChangePhone || rec.Phone != phone || rec.UserID != userID || rec.AuthKeyID != authKeyID {
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 {
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,
ExcludeSessionID: sessionID,
})
if err != nil {
return domain.PhoneChangeResult{}, err
}
if s.userCache != nil && result.User.ID != 0 {
_ = s.userCache.Delete(ctx, []int64{result.User.ID})
}
return result, nil
}
func (s *Service) phoneChangeCaller(ctx context.Context, userID int64, authKeyID [8]byte) (domain.User, error) {
if s == nil || s.users == nil || s.authorizations == nil || userID == 0 || authKeyID == ([8]byte{}) {
return domain.User{}, domain.ErrPhoneChangeAuthInvalid
}
a, found, err := s.authorizations.ByAuthKey(ctx, authKeyID)
if err != nil {
return domain.User{}, err
}
if !found || a.UserID != userID || a.PasswordPending {
return domain.User{}, domain.ErrPhoneChangeAuthInvalid
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return domain.User{}, err
}
if !found {
return domain.User{}, domain.ErrPhoneChangeAuthInvalid
}
if u.Bot || domain.IsSystemUserID(u.ID) {
return domain.User{}, domain.ErrPhoneChangeForbidden
}
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 {
return "", fmt.Errorf("generate phone change hash: %w", err)
}
return hex.EncodeToString(raw[:]), nil
}

View file

@ -0,0 +1,196 @@
package account
import (
"context"
"errors"
"sync"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type phoneChangeFixture struct {
ctx context.Context
service *Service
users *memory.UserStore
auths *memory.AuthorizationStore
codes *memory.CodeStore
events *memory.UpdateEventStore
user domain.User
authKeyID [8]byte
}
func newPhoneChangeFixture(t *testing.T) phoneChangeFixture {
t.Helper()
ctx := context.Background()
users := memory.NewUserStore()
auths := memory.NewAuthorizationStore()
codes := memory.NewCodeStore()
events := memory.NewUpdateEventStore()
u, err := users.Create(ctx, domain.User{AccessHash: 101, Phone: "15550012001", FirstName: "Alice"})
if err != nil {
t.Fatalf("create user: %v", err)
}
authKeyID := [8]byte{1, 2, 3, 4}
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)
}
service := NewService(
memory.NewPasswordStore(),
WithUsers(users),
WithPhoneChange(memory.NewPhoneChangeStore(users, events), 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}
}
func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) {
f := newPhoneChangeFixture(t)
hash, delivery, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "+1 (555) 001-2002")
if err != nil {
t.Fatalf("send change code: %v", err)
}
if hash == "" || delivery.Kind != domain.AuthCodeDeliverySMS || delivery.Length != 5 {
t.Fatalf("delivery = hash %q %+v", hash, delivery)
}
rec, found, err := f.codes.Get(f.ctx, hash)
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 {
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)
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 _, found, _ := f.users.ByPhone(f.ctx, "15550012001"); found {
t.Fatal("old phone still resolves")
}
if got, found, _ := f.users.ByPhone(f.ctx, "15550012002"); !found || got.ID != f.user.ID {
t.Fatalf("new phone resolves to %+v found=%v", got, found)
}
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
if err != nil || len(events) != 1 || events[0].Type != domain.UpdateEventUserPhone || events[0].Phone != "15550012002" {
t.Fatalf("durable events = %+v err=%v", events, err)
}
if _, found, _ := f.codes.Get(f.ctx, hash); found {
t.Fatal("successful code was not consumed")
}
}
func TestPhoneChangeRejectsOccupiedAndCrossAuthCode(t *testing.T) {
f := newPhoneChangeFixture(t)
occupied, err := f.users.Create(f.ctx, domain.User{AccessHash: 102, Phone: "15550012003", FirstName: "Bob"})
if err != nil {
t.Fatalf("create occupied user: %v", err)
}
if _, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, occupied.Phone); !errors.Is(err, domain.ErrPhoneNumberOccupied) {
t.Fatalf("occupied send err = %v", err)
}
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012004")
if err != nil {
t.Fatalf("send code: %v", err)
}
otherKey := [8]byte{9, 9, 9}
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) {
t.Fatalf("cross-auth change err = %v", err)
}
if got, found, _ := f.users.ByID(f.ctx, occupied.ID); !found || got.Phone != "15550012003" {
t.Fatalf("other user changed = %+v found=%v", got, found)
}
}
func TestPhoneChangeWrongCodeExhaustsAttempts(t *testing.T) {
f := newPhoneChangeFixture(t)
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012005")
if err != nil {
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) {
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) {
t.Fatalf("exhausted code err = %v", err)
}
if got, _, _ := f.users.ByID(f.ctx, f.user.ID); got.Phone != "15550012001" {
t.Fatalf("phone changed after exhausted code: %q", got.Phone)
}
}
func TestPhoneChangeNewSendInvalidatesPreviousHash(t *testing.T) {
f := newPhoneChangeFixture(t)
oldHash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012006")
if err != nil {
t.Fatalf("first send: %v", err)
}
newHash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 88, "15550012006")
if err != nil {
t.Fatalf("second send: %v", err)
}
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) {
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 {
t.Fatalf("new hash change: %v", err)
}
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
if err != nil || len(events) != 1 || events[0].Type != domain.UpdateEventUserPhone {
t.Fatalf("events = %+v err=%v", events, err)
}
}
func TestPhoneChangeConcurrentReplayAppendsOneEvent(t *testing.T) {
f := newPhoneChangeFixture(t)
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012007")
if err != nil {
t.Fatalf("send: %v", err)
}
const workers = 24
errs := make(chan error, workers)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
_, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 88, "15550012007", hash, "12345", 1700000003)
errs <- err
}()
}
wg.Wait()
close(errs)
successes := 0
expired := 0
for err := range errs {
switch {
case err == nil:
successes++
case errors.Is(err, domain.ErrPhoneCodeExpired):
expired++
default:
t.Fatalf("unexpected concurrent error: %v", err)
}
}
if successes != 1 || expired != workers-1 {
t.Fatalf("successes=%d expired=%d", successes, expired)
}
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
if err != nil || len(events) != 1 || events[0].Pts != 1 {
t.Fatalf("events = %+v err=%v", events, err)
}
}

View file

@ -38,8 +38,14 @@ type Service struct {
business store.BusinessAutomationStore
// users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。
users store.UserStore
userCache store.UserCache
authorizations store.AuthorizationStore
phoneChanges store.PhoneChangeStore
publicBaseURL string
codes store.CodeStore
phoneChangeCode string
phoneChangeCodeTTL time.Duration
phoneChangeMaxAttempts int
loginEmailSender mail.Sender
loginEmailCodeTTL time.Duration
loginEmailCodeMaxAttempts int
@ -105,6 +111,24 @@ func WithUsers(users store.UserStore) ServiceOption {
}
}
// WithPhoneChange 注入改号所需的授权校验、一次性验证码、原子 user+update
// 写入与基础用户缓存失效依赖。
func WithPhoneChange(phoneChanges store.PhoneChangeStore, authorizations store.AuthorizationStore, codes store.CodeStore, cache store.UserCache, fixedCode string, ttl time.Duration, maxAttempts int) ServiceOption {
return func(s *Service) {
s.phoneChanges = phoneChanges
s.authorizations = authorizations
s.codes = codes
s.userCache = cache
s.phoneChangeCode = fixedCode
if ttl > 0 {
s.phoneChangeCodeTTL = ttl
}
if maxAttempts > 0 {
s.phoneChangeMaxAttempts = maxAttempts
}
}
}
func WithPublicBaseURL(baseURL string) ServiceOption {
return func(s *Service) {
s.publicBaseURL = links.NormalizeBaseURL(baseURL)
@ -129,7 +153,15 @@ func WithLoginEmailVerification(codes store.CodeStore, sender mail.Sender, ttl t
// NewService 创建 account 服务。
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
s := &Service{passwords: passwords, publicBaseURL: links.DefaultPublicBaseURL, loginEmailCodeTTL: 5 * time.Minute, loginEmailCodeMaxAttempts: 5, loginEmailCodeLength: 6}
s := &Service{
passwords: passwords,
publicBaseURL: links.DefaultPublicBaseURL,
loginEmailCodeTTL: 5 * time.Minute,
loginEmailCodeMaxAttempts: 5,
loginEmailCodeLength: 6,
phoneChangeCodeTTL: 5 * time.Minute,
phoneChangeMaxAttempts: 5,
}
for _, opt := range opts {
opt(s)
}