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