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)
}

View file

@ -0,0 +1,71 @@
package auth
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
func TestResendCodePreservesChangePhoneScopeAndSMSDelivery(t *testing.T) {
ctx := context.Background()
codes := memory.NewCodeStore()
authKeyID := [8]byte{8, 7, 6}
rec := store.PhoneCode{
Phone: "15550014001",
Code: "old",
Channel: codeChannelPhone,
Purpose: store.PhoneCodePurposeChangePhone,
UserID: 42,
AuthKeyID: authKeyID,
SessionID: 77,
Attempts: 2,
MaxAttempts: 5,
}
if err := codes.Set(ctx, "old-hash", rec, time.Minute); err != nil {
t.Fatalf("set old code: %v", err)
}
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithCodeTTL(time.Minute))
if _, err := svc.ResendCodeForAuthKey(ctx, [8]byte{1}, rec.Phone, "old-hash"); err != ErrCodeInvalid {
t.Fatalf("cross-auth resend err = %v", err)
}
if _, found, _ := codes.Get(ctx, "old-hash"); !found {
t.Fatal("cross-auth resend invalidated victim hash")
}
hash, err := svc.ResendCodeForAuthKey(ctx, authKeyID, rec.Phone, "old-hash")
if err != nil {
t.Fatalf("resend change code: %v", err)
}
if hash == "" || hash == "old-hash" {
t.Fatalf("new hash = %q", hash)
}
if _, found, _ := codes.Get(ctx, "old-hash"); found {
t.Fatal("old hash remains valid")
}
got, found, err := codes.Get(ctx, hash)
if err != nil || !found {
t.Fatalf("new code found=%v err=%v", found, err)
}
if got.Purpose != rec.Purpose || got.UserID != rec.UserID || got.AuthKeyID != rec.AuthKeyID || got.SessionID != rec.SessionID || got.Code != "12345" || got.Attempts != 0 {
t.Fatalf("resent scoped code = %+v", got)
}
delivery, found, err := svc.CodeDelivery(ctx, hash)
if err != nil || !found || delivery.Kind != domain.AuthCodeDeliverySMS || delivery.Length != 5 {
t.Fatalf("delivery = %+v found=%v err=%v", delivery, found, err)
}
if err := svc.CancelCodeForAuthKey(ctx, [8]byte{2}, rec.Phone, hash); err != ErrCodeInvalid {
t.Fatalf("cross-auth cancel err = %v", err)
}
if _, found, _ := codes.Get(ctx, hash); !found {
t.Fatal("cross-auth cancel invalidated victim hash")
}
if err := svc.CancelCodeForAuthKey(ctx, authKeyID, rec.Phone, hash); err != nil {
t.Fatalf("scoped cancel: %v", err)
}
if _, found, _ := codes.Get(ctx, hash); found {
t.Fatal("scoped cancel left hash valid")
}
}

View file

@ -46,15 +46,7 @@ const (
// 核心目的是拒绝空/非数字 phone防 0090 partial index 下无限铸造幽灵账号),
// 长度上限从宽,不强求 E.164 精确位数(测试常用更长的唯一 phone
func validPhone(phone string) bool {
if len(phone) < 5 || len(phone) > 32 {
return false
}
for _, r := range phone {
if r < '0' || r > '9' {
return false
}
}
return true
return domain.ValidPhone(phone)
}
func systemUserLoginForbidden(u domain.User) bool {
@ -364,6 +356,9 @@ func (s *Service) CodeDelivery(ctx context.Context, phoneCodeHash string) (domai
}
func codeDelivery(rec store.PhoneCode) domain.AuthCodeDelivery {
if rec.Purpose == store.PhoneCodePurposeChangePhone {
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}
}
switch rec.Channel {
case codeChannelEmailLogin:
return domain.AuthCodeDelivery{
@ -380,6 +375,16 @@ func codeDelivery(rec store.PhoneCode) domain.AuthCodeDelivery {
// ResendCode invalidates an existing code hash and sends a fresh code to the same phone.
func (s *Service) ResendCode(ctx context.Context, phone, phoneCodeHash string) (string, error) {
return s.resendCode(ctx, [8]byte{}, phone, phoneCodeHash)
}
// ResendCodeForAuthKey 对已登录敏感操作额外校验发起 auth key普通登录码
// 没有 AuthKeyID 作用域,行为与 ResendCode 相同。
func (s *Service) ResendCodeForAuthKey(ctx context.Context, authKeyID [8]byte, phone, phoneCodeHash string) (string, error) {
return s.resendCode(ctx, authKeyID, phone, phoneCodeHash)
}
func (s *Service) resendCode(ctx context.Context, authKeyID [8]byte, phone, phoneCodeHash string) (string, error) {
phone = normalizePhone(phone)
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
if err != nil {
@ -391,7 +396,13 @@ func (s *Service) ResendCode(ctx context.Context, phone, phoneCodeHash string) (
if rec.Phone != phone {
return "", ErrCodeInvalid
}
if rec.Purpose == store.PhoneCodePurposeChangePhone && (authKeyID == ([8]byte{}) || rec.AuthKeyID != authKeyID) {
return "", ErrCodeInvalid
}
_ = s.codes.Del(ctx, phoneCodeHash)
if rec.Purpose == store.PhoneCodePurposeChangePhone {
return s.recreateChangePhoneCode(ctx, rec)
}
if rec.Channel == codeChannelEmailLogin && strings.TrimSpace(rec.Email) != "" {
return s.createEmailLoginCode(ctx, phone, rec.Email)
}
@ -401,8 +412,34 @@ func (s *Service) ResendCode(ctx context.Context, phone, phoneCodeHash string) (
return s.SendCode(ctx, phone)
}
func (s *Service) recreateChangePhoneCode(ctx context.Context, rec store.PhoneCode) (string, error) {
hash, err := randomHex(8)
if err != nil {
return "", err
}
rec.Code = s.fixedCode
rec.Channel = codeChannelPhone
rec.Attempts = 0
if rec.MaxAttempts <= 0 {
rec.MaxAttempts = s.codeMaxAttempts
}
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
return "", fmt.Errorf("store resent phone change code: %w", err)
}
return hash, nil
}
// CancelCode invalidates a pending login code hash.
func (s *Service) CancelCode(ctx context.Context, phone, phoneCodeHash string) error {
return s.cancelCode(ctx, [8]byte{}, phone, phoneCodeHash)
}
// CancelCodeForAuthKey 是 ResendCodeForAuthKey 对应的取消路径。
func (s *Service) CancelCodeForAuthKey(ctx context.Context, authKeyID [8]byte, phone, phoneCodeHash string) error {
return s.cancelCode(ctx, authKeyID, phone, phoneCodeHash)
}
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)
if err != nil {
@ -414,6 +451,9 @@ func (s *Service) CancelCode(ctx context.Context, phone, phoneCodeHash string) e
if rec.Phone != phone {
return ErrCodeInvalid
}
if rec.Purpose == store.PhoneCodePurposeChangePhone && (authKeyID == ([8]byte{}) || rec.AuthKeyID != authKeyID) {
return ErrCodeInvalid
}
return s.codes.Del(ctx, phoneCodeHash)
}

View file

@ -110,6 +110,21 @@ func (s *Service) CanSee(ctx context.Context, ownerUserID, viewerUserID int64, k
return Evaluate(rules, evalCtx), nil
}
// CanSeeAnonymous evaluates one owner's privacy rules for an unauthenticated
// public-web viewer. Anonymous viewers are never contacts, premium users,
// close friends, bots, or shared-chat participants; explicit allow-all and
// disallow rules still retain their normal precedence through Evaluate.
func (s *Service) CanSeeAnonymous(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (bool, error) {
if ownerUserID == 0 {
return false, nil
}
rules, err := s.GetRules(ctx, ownerUserID, key)
if err != nil {
return false, err
}
return Evaluate(rules, domain.PrivacyContext{OwnerUserID: ownerUserID}), nil
}
// CanSeeBatch 批量评估多个 owner 对同一 viewer 在多个 key 上的可见性,结果等价于对每个
// (owner,key) 调一次 CanSee但只用一次 ListPrivacyRules + 一次 GetReverseContacts + 内存
// Evaluate消除 projectBatch / fan-out 投影里 per-user 3×CanSee×2行 的 N+1。返回

View file

@ -34,6 +34,32 @@ func TestDefaultPrivacyRules(t *testing.T) {
}
}
func TestCanSeeAnonymousHonorsPublicOnlyRules(t *testing.T) {
ctx := context.Background()
store := memory.NewPrivacyStore()
svc := NewService(store, nil)
const ownerID int64 = 1001
if visible, err := svc.CanSeeAnonymous(ctx, ownerID, domain.PrivacyKeyAbout); err != nil || !visible {
t.Fatalf("default anonymous about visibility = %v, err=%v; want true", visible, err)
}
if _, err := svc.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
t.Fatalf("set contacts-only profile photo: %v", err)
}
if visible, err := svc.CanSeeAnonymous(ctx, ownerID, domain.PrivacyKeyProfilePhoto); err != nil || visible {
t.Fatalf("contacts-only anonymous photo visibility = %v, err=%v; want false", visible, err)
}
if _, err := svc.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleDisallowUsers, UserIDs: []int64{2002}},
{Kind: domain.PrivacyRuleAllowAll},
}); err != nil {
t.Fatalf("set public profile photo: %v", err)
}
if visible, err := svc.CanSeeAnonymous(ctx, ownerID, domain.PrivacyKeyProfilePhoto); err != nil || !visible {
t.Fatalf("allow-all anonymous photo visibility = %v, err=%v; want true", visible, err)
}
}
func TestAddAllowUserOverridesDisallowAll(t *testing.T) {
ctx := context.Background()
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore())

View file

@ -2,6 +2,7 @@ package updates
import (
"context"
"fmt"
"sort"
"time"
@ -113,6 +114,23 @@ func (s *Service) ConfirmedState(ctx context.Context, authKeyID [8]byte, userID
return st, found, nil
}
// ConfirmEvent 把一个已在其它业务事务中原子提交的事件标记为当前设备已消费。
// 典型调用是 account.changePhoneRPC result 已携最新 User当前设备不应再收
// updateUserPhone但仍需把设备确认水位推进到该事件 pts。
func (s *Service) ConfirmEvent(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent) error {
if event.UserID != 0 && event.UserID != userID {
return fmt.Errorf("confirm update event user mismatch: event=%d caller=%d", event.UserID, userID)
}
if event.Pts <= 0 {
return nil
}
date := event.Date
if date == 0 {
date = int(time.Now().Unix())
}
return s.saveConfirmedState(ctx, authKeyID, userID, domain.UpdateState{Pts: event.Pts, Date: date, Seq: 0})
}
// AcknowledgeCurrentState 返回账号当前最大连续状态,并把该设备的确认水位推进到此。
//
// 供 updates.getState 使用:协议语义是客户端宣告「从现在开始同步」,启动期的