feat: sync configurable OTP delivery providers

This commit is contained in:
A 2026-07-17 17:04:39 +08:00
parent c18f773701
commit 6af61f26ba
28 changed files with 2100 additions and 118 deletions

View file

@ -9,6 +9,7 @@ import (
"time"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
@ -30,8 +31,10 @@ func createUser(t *testing.T, users *memory.UserStore, phone string) domain.User
}
type captureMailSender struct {
to string
code string
to string
code string
requests []otpdelivery.Request
err error
}
type blockingCodeCAS struct {
@ -116,10 +119,102 @@ func (s *blockingCodeCAS) CompareAndDelete(ctx context.Context, key, revision st
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
return nil
func (s *captureMailSender) Deliver(_ context.Context, req otpdelivery.Request) (otpdelivery.Result, error) {
s.to = req.Recipient
s.code = req.Code
s.requests = append(s.requests, req)
return otpdelivery.Result{}, s.err
}
func TestLoginEmailDeliveryCarriesPurposeAndStableID(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
codes := memory.NewCodeStore()
sender := &captureMailSender{}
svc := NewService(memory.NewPasswordStore(),
WithUsers(users),
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6))
u := createUser(t, users, "15550010150")
pattern, length, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "Alice@Example.Test", false)
if err != nil {
t.Fatalf("SendLoginEmailCode: %v", err)
}
if pattern == "" || length != 6 || len(sender.requests) != 1 {
t.Fatalf("pattern=%q length=%d requests=%d", pattern, length, len(sender.requests))
}
req := sender.requests[0]
if req.DeliveryID == "" || req.Purpose != otpdelivery.PurposeLoginEmailChange || req.Channel != otpdelivery.ChannelEmail ||
req.Recipient != "alice@example.test" || len(req.Code) != 6 {
t.Fatalf("request = %+v", req)
}
snapshot, found, err := codes.GetSnapshot(ctx, loginEmailVerifyChangePrefix+fmt.Sprint(u.ID))
if err != nil || !found || snapshot.Record.DeliveryID != req.DeliveryID || snapshot.Record.Code != req.Code {
t.Fatalf("snapshot=%+v found=%v err=%v", snapshot, found, err)
}
}
func TestLoginEmailSetupDeliveryUsesSetupPurpose(t *testing.T) {
ctx := context.Background()
codes := memory.NewCodeStore()
sender := &captureMailSender{}
phone := "15550010151"
phoneHash := "setup-purpose-hash"
if err := codes.Set(ctx, phoneHash, store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Channel: codeChannelEmailSetupRequired,
}, time.Minute); err != nil {
t.Fatalf("seed setup code: %v", err)
}
svc := NewService(memory.NewPasswordStore(),
WithUsers(memory.NewUserStore()),
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6))
if _, _, err := svc.SendLoginEmailCode(ctx, 0, phone, phoneHash, "new@example.test", true); err != nil {
t.Fatalf("SendLoginEmailCode setup: %v", err)
}
if len(sender.requests) != 1 || sender.requests[0].Purpose != otpdelivery.PurposeLoginEmailSetup {
t.Fatalf("requests = %+v", sender.requests)
}
}
func TestLoginEmailExplicitRejectionDeletesOnlyCurrentAttempt(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
codes := memory.NewCodeStore()
sender := &captureMailSender{err: &otpdelivery.RejectedError{StatusCode: 400, Code: "RECIPIENT_INVALID"}}
svc := NewService(memory.NewPasswordStore(),
WithUsers(users),
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6))
u := createUser(t, users, "15550010152")
key := loginEmailVerifyChangePrefix + fmt.Sprint(u.ID)
if _, _, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "bad@example.test", false); err == nil {
t.Fatal("explicit rejection succeeded")
}
if _, found, err := codes.Get(ctx, key); err != nil || found {
t.Fatalf("rejected code found=%v err=%v", found, err)
}
}
func TestLoginEmailUnknownOutcomeReturnsSuccessAndKeepsCode(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
codes := memory.NewCodeStore()
sender := &captureMailSender{err: &otpdelivery.OutcomeUnknownError{Cause: errors.New("ack lost")}}
svc := NewService(memory.NewPasswordStore(),
WithUsers(users),
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6))
u := createUser(t, users, "15550010153")
if _, _, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "unknown@example.test", false); err != nil {
t.Fatalf("unknown outcome: %v", err)
}
key := loginEmailVerifyChangePrefix + fmt.Sprint(u.ID)
if rec, found, err := codes.Get(ctx, key); err != nil || !found || rec.Code != sender.code {
t.Fatalf("unknown code=%+v found=%v err=%v", rec, found, err)
}
}
// TestSetLoginEmailPersistsAndMasks 设置登录邮箱后,GetPassword 下发掩码 pattern,原始

View file

@ -4,11 +4,13 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
)
@ -42,27 +44,60 @@ func (s *Service) SendChangePhoneCode(ctx context.Context, userID int64, authKey
} else if found && existing.ID != 0 {
return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberOccupied
}
if s.codes == nil || strings.TrimSpace(s.phoneChangeCode) == "" {
if s.codes == nil || (s.phoneCodeSender == 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
}
code := s.phoneChangeCode
channel := store.PhoneCodeChannelPhone
deliveryID := ""
if s.phoneCodeSender != nil {
code, err = randomDigits(s.phoneCodeLength)
if err != nil {
return "", domain.AuthCodeDelivery{}, err
}
deliveryID, err = otpdelivery.NewDeliveryID()
if err != nil {
return "", domain.AuthCodeDelivery{}, err
}
channel = store.PhoneCodeChannelSMS
}
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Code: s.phoneChangeCode,
Channel: store.PhoneCodeChannelPhone,
Code: code,
DeliveryID: deliveryID,
Channel: channel,
Purpose: store.PhoneCodePurposeChangePhone,
UserID: userID,
AuthKeyID: authKeyID,
SessionID: sessionID,
MaxAttempts: s.phoneChangeMaxAttempts,
}
expiresAt := time.Now().Add(s.phoneChangeCodeTTL)
if err := s.codes.Set(ctx, hash, rec, s.phoneChangeCodeTTL); err != nil {
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store phone change code: %w", err)
}
if s.phoneCodeSender != nil {
if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: otpdelivery.PurposeChangePhone,
Channel: otpdelivery.ChannelSMS,
Recipient: phone,
Code: code,
ExpiresAt: expiresAt,
}); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
return "", domain.AuthCodeDelivery{}, errors.Join(err, fmt.Errorf("rollback phone change code: %w", cleanupErr))
}
return "", domain.AuthCodeDelivery{}, err
}
}
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}, nil
}
@ -107,7 +142,8 @@ func (s *Service) ChangePhone(ctx context.Context, userID int64, authKeyID, orig
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
}
consumed := verified.Record
if consumed.Version != store.PhoneCodeVersionCurrent || consumed.Scope() != scope || consumed.Channel != store.PhoneCodeChannelPhone {
if consumed.Version != store.PhoneCodeVersionCurrent || consumed.Scope() != scope ||
(consumed.Channel != store.PhoneCodeChannelPhone && consumed.Channel != store.PhoneCodeChannelSMS) {
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid
}
if date == 0 {

View file

@ -8,6 +8,7 @@ import (
"time"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
@ -30,6 +31,16 @@ type recordingPhoneChangeStore struct {
last domain.PhoneChangeRequest
}
type trackingPhoneCodeStore struct {
store.CodeStore
lastHash string
}
func (s *trackingPhoneCodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
s.lastHash = hash
return s.CodeStore.Set(ctx, hash, code, ttl)
}
func (s *recordingPhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error) {
s.mu.Lock()
s.last = req
@ -67,6 +78,53 @@ func newPhoneChangeFixture(t *testing.T) phoneChangeFixture {
return phoneChangeFixture{ctx: ctx, service: service, users: users, auths: auths, codes: codes, events: events, user: u, authKeyID: authKeyID, changes: changes}
}
func TestPhoneChangeWebhookDeliversRandomScopedCode(t *testing.T) {
f := newPhoneChangeFixture(t)
sender := &captureMailSender{}
f.service.phoneCodeSender = sender
f.service.phoneCodeLength = 6
f.service.phoneChangeCode = ""
hash, delivery, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012020")
if err != nil {
t.Fatalf("SendChangePhoneCode: %v", err)
}
if hash == "" || delivery.Kind != domain.AuthCodeDeliverySMS || delivery.Length != 6 || len(sender.requests) != 1 {
t.Fatalf("hash=%q delivery=%+v requests=%d", hash, delivery, len(sender.requests))
}
req := sender.requests[0]
if req.Purpose != otpdelivery.PurposeChangePhone || req.Channel != otpdelivery.ChannelSMS || req.Recipient != "15550012020" || req.DeliveryID == "" {
t.Fatalf("request = %+v", req)
}
rec, found, err := f.codes.Get(f.ctx, hash)
if err != nil || !found || rec.Channel != store.PhoneCodeChannelSMS || rec.DeliveryID != req.DeliveryID || rec.Code != req.Code {
t.Fatalf("record=%+v found=%v err=%v", rec, found, err)
}
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, f.authKeyID, 78, req.Recipient, hash, req.Code, 1700000000); err != nil {
t.Fatalf("ChangePhone: %v", err)
}
}
func TestPhoneChangeWebhookRejectionRevokesScopedCode(t *testing.T) {
f := newPhoneChangeFixture(t)
sender := &captureMailSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}}
tracked := &trackingPhoneCodeStore{CodeStore: f.codes}
f.service.codes = tracked
f.service.phoneCodeSender = sender
f.service.phoneCodeLength = 5
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012021")
if hash != "" || err == nil || len(sender.requests) != 1 {
t.Fatalf("hash=%q err=%v requests=%d", hash, err, len(sender.requests))
}
if tracked.lastHash == "" {
t.Fatal("code was not stored before delivery")
}
if rec, found, getErr := f.codes.Get(f.ctx, tracked.lastHash); getErr != nil || found || rec.Code != "" {
t.Fatalf("post-rejection code rec=%+v found=%v err=%v", rec, found, getErr)
}
}
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")

View file

@ -4,13 +4,14 @@ import (
"context"
"crypto/rand"
"crypto/subtle"
"errors"
"fmt"
"strings"
"time"
"telesrv/internal/domain"
"telesrv/internal/links"
"telesrv/internal/mail"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
)
@ -47,7 +48,9 @@ type Service struct {
phoneChangeCode string
phoneChangeCodeTTL time.Duration
phoneChangeMaxAttempts int
loginEmailSender mail.Sender
loginEmailSender otpdelivery.Sender
phoneCodeSender otpdelivery.Sender
phoneCodeLength int
loginEmailCodeTTL time.Duration
loginEmailCodeMaxAttempts int
loginEmailCodeLength int
@ -136,7 +139,7 @@ func WithPublicBaseURL(baseURL string) ServiceOption {
}
}
func WithLoginEmailVerification(codes store.CodeStore, sender mail.Sender, ttl time.Duration, maxAttempts, length int) ServiceOption {
func WithLoginEmailVerification(codes store.CodeStore, sender otpdelivery.Sender, ttl time.Duration, maxAttempts, length int) ServiceOption {
return func(s *Service) {
s.codes = codes
s.loginEmailSender = sender
@ -152,6 +155,17 @@ func WithLoginEmailVerification(codes store.CodeStore, sender mail.Sender, ttl t
}
}
// WithPhoneCodeDelivery replaces the fixed development code used by the
// change-phone flow with an externally delivered SMS code.
func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) ServiceOption {
return func(s *Service) {
s.phoneCodeSender = sender
if length > 0 {
s.phoneCodeLength = length
}
}
}
// NewService 创建 account 服务。
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
s := &Service{
@ -162,6 +176,7 @@ func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
loginEmailCodeLength: 6,
phoneChangeCodeTTL: 5 * time.Minute,
phoneChangeMaxAttempts: 5,
phoneCodeLength: 5,
}
for _, opt := range opts {
opt(s)
@ -616,18 +631,54 @@ func (s *Service) SendLoginEmailCode(ctx context.Context, userID int64, phone, p
return "", 0, err
}
rec.Code = code
deliveryID, err := otpdelivery.NewDeliveryID()
if err != nil {
return "", 0, err
}
rec.DeliveryID = deliveryID
expiresAt := time.Now().Add(s.loginEmailCodeTTL)
if err := s.codes.Set(ctx, key, rec, s.loginEmailCodeTTL); err != nil {
return "", 0, err
}
if err := s.loginEmailSender.SendLoginCode(ctx, email, code, s.loginEmailCodeTTL); err != nil {
// 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.
snapshot, found, err := s.codes.GetSnapshot(ctx, key)
if err != nil {
return "", 0, err
}
if !found || snapshot.Record.DeliveryID != deliveryID {
return "", 0, domain.ErrEmailCodeInvalid
}
purpose := otpdelivery.PurposeLoginEmailChange
if setup {
purpose = otpdelivery.PurposeLoginEmailSetup
}
if err := deliverOTP(ctx, s.loginEmailSender, otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: purpose,
Channel: otpdelivery.ChannelEmail,
Recipient: email,
Code: code,
ExpiresAt: expiresAt,
}); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
deleted, cleanupErr := s.codes.CompareAndDelete(cleanupCtx, key, snapshot.Revision)
if cleanupErr != nil {
return "", 0, fmt.Errorf("%w; rollback email code: %v", err, cleanupErr)
}
_ = deleted // false means a newer concurrent resend owns the key.
return "", 0, err
}
return emailPattern(email), len(code), nil
}
func deliverOTP(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) error {
_, err := sender.Deliver(ctx, req)
if errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
return nil
}
return err
}
func (s *Service) VerifyLoginEmail(ctx context.Context, userID int64, phone, phoneCodeHash, code string, setup bool) (string, error) {
if s == nil || s.codes == nil {
return "", domain.ErrEmailNotAllowed

View file

@ -7,6 +7,7 @@ import (
"time"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
@ -340,7 +341,7 @@ func TestExistingAccountResendDeliveryFailureLeavesNoUsableCode(t *testing.T) {
}
}
func TestConfiguredEmailLoginDoesNotLeakCodeThroughAppDelivery(t *testing.T) {
func TestConfiguredEmailLoginMirrorsSameCodeThroughAppDelivery(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
if _, err := users.Create(ctx, domain.User{Phone: "15550009207"}); err != nil {
@ -360,7 +361,40 @@ func TestConfiguredEmailLoginDoesNotLeakCodeThroughAppDelivery(t *testing.T) {
if mailSender.to != "secure@example.test" || mailSender.code == "" {
t.Fatalf("email delivery = %q/%q", mailSender.to, mailSender.code)
}
if len(delivery.requests) != 0 {
t.Fatalf("email code leaked into app delivery: %+v", delivery.requests)
if len(delivery.requests) != 1 || delivery.requests[0].Code != mailSender.code || delivery.requests[0].PhoneCodeHash == "" {
t.Fatalf("email App-code delivery=%+v, want same code and non-empty hash", delivery.requests)
}
}
func TestConfiguredEmailLoginProviderFailureKeepsDurableAppCode(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
user, err := users.Create(ctx, domain.User{Phone: "15550009215"})
if err != nil {
t.Fatalf("create user: %v", err)
}
emails := &testLoginEmailStore{emails: map[string]string{user.Phone: "fallback@example.test"}}
codes := memory.NewCodeStore()
mailSender := &captureOTPSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}}
delivery := &captureLoginCodeDelivery{}
var observed []error
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithLoginEmail(LoginEmailOptions{Enabled: true, CodeLength: 6, Store: emails, Sender: mailSender}),
WithLoginCodeDelivery(delivery),
WithOTPDeliveryFailureObserver(func(_ context.Context, _ otpdelivery.Request, err error) {
observed = append(observed, err)
}),
)
hash, err := svc.SendCode(ctx, user.Phone)
if err != nil || hash == "" {
t.Fatalf("SendCode hash=%q err=%v, want App fallback success", hash, err)
}
if len(delivery.requests) != 1 || len(mailSender.requests) != 1 || len(observed) != 1 ||
delivery.requests[0].Code != mailSender.requests[0].Code {
t.Fatalf("App=%+v provider=%+v observed=%d", delivery.requests, mailSender.requests, len(observed))
}
if rec, found, getErr := codes.Get(ctx, hash); getErr != nil || !found || rec.Code != delivery.requests[0].Code {
t.Fatalf("code=%+v found=%v err=%v", rec, found, getErr)
}
}

View file

@ -4,9 +4,9 @@ import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store/memory"
)
@ -28,10 +28,10 @@ type testMailSender struct {
code string
}
func (s *testMailSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error {
s.to = to
s.code = code
return nil
func (s *testMailSender) Deliver(_ context.Context, req otpdelivery.Request) (otpdelivery.Result, error) {
s.to = req.Recipient
s.code = req.Code
return otpdelivery.Result{}, nil
}
func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) {
@ -43,7 +43,9 @@ func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) {
}
emails := &testLoginEmailStore{emails: map[string]string{"15550009101": "alice@example.test"}}
sender := &testMailSender{}
appDelivery := &captureLoginCodeDelivery{}
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
WithLoginCodeDelivery(appDelivery),
WithLoginEmail(LoginEmailOptions{
Enabled: true,
CodeLength: 6,
@ -59,6 +61,9 @@ func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) {
if sender.to != "alice@example.test" || len(sender.code) != 6 {
t.Fatalf("sent email to/code = %q/%q, want alice@example.test/6 digits", sender.to, sender.code)
}
if len(appDelivery.requests) != 1 || appDelivery.requests[0].PhoneCodeHash != hash || appDelivery.requests[0].Code != sender.code {
t.Fatalf("App-code delivery=%+v, want same email code/hash", appDelivery.requests)
}
delivery, found, err := svc.CodeDelivery(ctx, hash)
if err != nil || !found {
t.Fatalf("CodeDelivery found=%v err=%v", found, err)
@ -109,9 +114,11 @@ func TestConfiguredEmailLoginAcceptsCorrectCode(t *testing.T) {
}
emails := &testLoginEmailStore{emails: map[string]string{"15550009102": "bob@example.test"}}
sender := &testMailSender{}
appDelivery := &captureLoginCodeDelivery{}
var key [8]byte
key[0] = 0x91
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
WithLoginCodeDelivery(appDelivery),
WithLoginEmail(LoginEmailOptions{
Enabled: true,
CodeLength: 5,
@ -123,6 +130,9 @@ func TestConfiguredEmailLoginAcceptsCorrectCode(t *testing.T) {
if err != nil {
t.Fatalf("SendCode: %v", err)
}
if len(appDelivery.requests) != 1 || appDelivery.requests[0].Code != sender.code {
t.Fatalf("App-code delivery=%+v, want same email code", appDelivery.requests)
}
got, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009102", hash, sender.code)
if err != nil {
t.Fatalf("SignInWithEmail: %v", err)

View file

@ -0,0 +1,163 @@
package auth
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type captureOTPSender struct {
requests []otpdelivery.Request
err error
before func()
}
func (s *captureOTPSender) Deliver(_ context.Context, req otpdelivery.Request) (otpdelivery.Result, error) {
if s.before != nil {
s.before()
}
s.requests = append(s.requests, req)
return otpdelivery.Result{ProviderMessageID: "capture-message"}, s.err
}
func TestWebhookPhoneLoginUsesRandomSMSCode(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
user, err := users.Create(ctx, domain.User{Phone: "15550009301", FirstName: "Webhook"})
if err != nil {
t.Fatalf("create user: %v", err)
}
codes := memory.NewCodeStore()
appDelivery := &captureLoginCodeDelivery{}
sender := &captureOTPSender{before: func() {
if len(appDelivery.requests) != 1 {
t.Fatalf("provider called before durable App-code: requests=%d", len(appDelivery.requests))
}
}}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "fixed-code-must-not-leak",
WithLoginCodeDelivery(appDelivery),
WithPhoneCodeDelivery(sender, 6))
hash, err := svc.SendCode(ctx, "+1 555 000 9301")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
if hash == "" || len(sender.requests) != 1 {
t.Fatalf("hash=%q requests=%d", hash, len(sender.requests))
}
req := sender.requests[0]
if req.DeliveryID == "" || req.Purpose != otpdelivery.PurposeLoginSMS || req.Channel != otpdelivery.ChannelSMS ||
req.Recipient != "15550009301" || len(req.Code) != 6 || req.Code == "fixed-code-must-not-leak" || time.Until(req.ExpiresAt) < 4*time.Minute {
t.Fatalf("request = %+v", req)
}
if len(appDelivery.requests) != 1 || appDelivery.requests[0].PhoneCodeHash != hash || appDelivery.requests[0].Code != req.Code {
t.Fatalf("App-code delivery=%+v, want same hash/code as provider", appDelivery.requests)
}
rec, found, err := codes.Get(ctx, hash)
if err != nil || !found || rec.Code != req.Code || rec.DeliveryID != req.DeliveryID || rec.Channel != store.PhoneCodeChannelSMS {
t.Fatalf("stored code=%+v found=%v err=%v", rec, found, err)
}
delivery, found, err := svc.CodeDelivery(ctx, hash)
if err != nil || !found || delivery.Kind != domain.AuthCodeDeliverySMS || delivery.Length != 6 {
t.Fatalf("delivery=%+v found=%v err=%v", delivery, found, err)
}
got, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: [8]byte{3}}, req.Recipient, hash, req.Code)
if err != nil || needSignUp || got.ID != user.ID {
t.Fatalf("SignIn user=%+v needSignUp=%v err=%v", got, needSignUp, err)
}
}
func TestWebhookExistingAccountRejectionKeepsDurableAppCode(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
user, err := users.Create(ctx, domain.User{Phone: "15550009305", FirstName: "Fallback"})
if err != nil {
t.Fatalf("create user: %v", err)
}
codes := memory.NewCodeStore()
appDelivery := &captureLoginCodeDelivery{}
sender := &captureOTPSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}}
var observed []error
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithLoginCodeDelivery(appDelivery),
WithPhoneCodeDelivery(sender, 6),
WithOTPDeliveryFailureObserver(func(_ context.Context, _ otpdelivery.Request, err error) {
observed = append(observed, err)
}),
)
hash, err := svc.SendCode(ctx, user.Phone)
if err != nil || hash == "" {
t.Fatalf("SendCode hash=%q err=%v, want App fallback success", hash, err)
}
if len(sender.requests) != 1 || len(appDelivery.requests) != 1 || len(observed) != 1 {
t.Fatalf("provider=%d App=%d observed=%d, want 1/1/1", len(sender.requests), len(appDelivery.requests), len(observed))
}
rec, found, err := codes.Get(ctx, hash)
if err != nil || !found || rec.Code != appDelivery.requests[0].Code || rec.Code != sender.requests[0].Code {
t.Fatalf("code=%+v found=%v err=%v App=%+v provider=%+v", rec, found, err, appDelivery.requests, sender.requests)
}
}
func TestWebhookPhoneLoginExplicitRejectionRollsBackCode(t *testing.T) {
ctx := context.Background()
baseCodes := memory.NewCodeStore()
codes := &trackingCodeStore{CodeStore: baseCodes}
sender := &captureOTPSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}}
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithPhoneCodeDelivery(sender, 5))
hash, err := svc.SendCode(ctx, "15550009302")
if hash != "" || err == nil || len(sender.requests) != 1 || codes.lastSetHash == "" {
t.Fatalf("hash=%q err=%v requests=%d set=%q", hash, err, len(sender.requests), codes.lastSetHash)
}
if _, found, getErr := baseCodes.Get(ctx, codes.lastSetHash); getErr != nil || found {
t.Fatalf("rejected code found=%v err=%v", found, getErr)
}
}
func TestWebhookPhoneLoginUnknownOutcomeKeepsUsableCode(t *testing.T) {
ctx := context.Background()
codes := memory.NewCodeStore()
sender := &captureOTPSender{err: &otpdelivery.OutcomeUnknownError{Cause: errors.New("response lost")}}
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithPhoneCodeDelivery(sender, 5))
hash, err := svc.SendCode(ctx, "15550009303")
if err != nil || hash == "" || len(sender.requests) != 1 {
t.Fatalf("hash=%q err=%v requests=%d", hash, err, len(sender.requests))
}
rec, found, err := codes.Get(ctx, hash)
if err != nil || !found || rec.Code != sender.requests[0].Code {
t.Fatalf("unknown outcome code=%+v found=%v err=%v", rec, found, err)
}
}
func TestWebhookPhoneResendRotatesCodeAndDeliveryID(t *testing.T) {
ctx := context.Background()
sender := &captureOTPSender{}
codes := memory.NewCodeStore()
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithPhoneCodeDelivery(sender, 6))
firstHash, err := svc.SendCode(ctx, "15550009304")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
secondHash, err := svc.ResendCode(ctx, "15550009304", firstHash)
if err != nil {
t.Fatalf("ResendCode: %v", err)
}
if firstHash == secondHash || len(sender.requests) != 2 ||
sender.requests[0].DeliveryID == sender.requests[1].DeliveryID {
t.Fatalf("hashes=%q/%q requests=%+v", firstHash, secondHash, sender.requests)
}
if _, found, err := codes.Get(ctx, firstHash); err != nil || found {
t.Fatalf("old code found=%v err=%v", found, err)
}
}

View file

@ -18,7 +18,7 @@ import (
mtcrypto "github.com/iamxvbaba/td/crypto"
"telesrv/internal/domain"
"telesrv/internal/mail"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
)
@ -48,9 +48,10 @@ var (
)
const (
codeChannelPhone = "phone"
codeChannelEmailLogin = "email_login"
codeChannelEmailSetupRequired = "email_setup_required"
codeChannelPhone = store.PhoneCodeChannelPhone
codeChannelSMS = store.PhoneCodeChannelSMS
codeChannelEmailLogin = store.PhoneCodeChannelEmailLogin
codeChannelEmailSetupRequired = store.PhoneCodeChannelEmailSetupRequired
loginCodeRollbackTimeout = 2 * time.Second
)
@ -70,7 +71,9 @@ func systemLoginPhoneForbidden(phone string) bool {
return ok
}
// Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。
// Service 实现登录/注册业务。默认保留开发固定码;配置外部 provider
// 后生成随机验证码并通过 otpdelivery 投递。已有账号的外部投递是 durable
// 777000 App-code 的附加渠道,不能替换或削弱原有消息事实。
type Service struct {
users store.UserStore
auths store.AuthorizationStore
@ -86,7 +89,10 @@ type Service struct {
codeTTL time.Duration
codeMaxAttempts int
loginEmails loginEmailStore
loginEmailSender mail.Sender
loginEmailSender otpdelivery.Sender
phoneCodeSender otpdelivery.Sender
otpDeliveryFailure func(context.Context, otpdelivery.Request, error)
phoneCodeLength int
loginEmailEnabled bool
loginEmailRequireSetup bool
loginEmailCodeLength int
@ -104,7 +110,7 @@ type LoginEmailOptions struct {
RequireSetup bool
CodeLength int
Store loginEmailStore
Sender mail.Sender
Sender otpdelivery.Sender
}
type authorizationRevoker interface {
@ -185,9 +191,31 @@ func WithLoginEmail(opts LoginEmailOptions) Option {
}
}
// WithPhoneCodeDelivery enables an external SMS delivery provider. Existing
// accounts keep their durable 777000 App-code and receive the same code through
// the provider as an additional channel. A nil sender preserves development
// behavior.
func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) Option {
return func(s *Service) {
s.phoneCodeSender = sender
if length > 0 {
s.phoneCodeLength = length
}
}
}
// WithOTPDeliveryFailureObserver observes failures of an additional provider
// delivery after an existing account already has a durable 777000 App-code.
// Observers must not log the recipient or code.
func WithOTPDeliveryFailureObserver(observer func(context.Context, otpdelivery.Request, error)) Option {
return func(s *Service) {
s.otpDeliveryFailure = observer
}
}
// NewService 创建登录服务。fixedCode 为开发固定验证码。
func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service {
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6}
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6, phoneCodeLength: 5}
if linker, ok := auths.(store.AuthKeyAuthorityLinker); ok && authKeys != nil {
linker.LinkAuthKeyAuthority(authKeys)
}
@ -372,32 +400,68 @@ func (s *Service) createPhoneCode(ctx context.Context, phone string, existingUse
if err != nil {
return "", err
}
if err := s.codes.Set(ctx, hash, store.PhoneCode{
code := s.fixedCode
channel := codeChannelPhone
deliveryID := ""
if s.phoneCodeSender != nil {
code, err = randomDigits(s.phoneCodeLength)
if err != nil {
return "", err
}
deliveryID, err = otpdelivery.NewDeliveryID()
if err != nil {
return "", err
}
channel = codeChannelSMS
}
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
IssuedUserID: existingUserID,
Phone: phone,
Code: s.fixedCode,
Channel: codeChannelPhone,
Code: code,
DeliveryID: deliveryID,
Channel: channel,
MaxAttempts: s.codeMaxAttempts,
}, s.codeTTL); err != nil {
}
expiresAt := time.Now().Add(s.codeTTL)
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
return "", fmt.Errorf("store code: %w", err)
}
rec := store.PhoneCode{Phone: phone, IssuedUserID: existingUserID}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
// 新手机号还没有 owner/dialog,只能在 SignUp 创建用户后写第一条
// 777000 消息。已有账号则必须在 sendCode RPC 返回前把 app-code
// 作为普通 incoming message + durable update/outbox 提交;登录成功不再补发。
if existingUserID == 0 {
// Existing accounts always retain the original durable App-code path. Commit
// it before attempting the external mirror so a provider cannot replace the
// message fact or leave an externally disclosed code without local state.
if existingUserID != 0 {
if err := s.deliverLoginCode(ctx, existingUserID, hash, code); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, err)
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
}
if s.phoneCodeSender != nil {
request := otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: otpdelivery.PurposeLoginSMS,
Channel: otpdelivery.ChannelSMS,
Recipient: phone,
Code: code,
ExpiresAt: expiresAt,
}
if existingUserID != 0 {
s.deliverOTPWithAppFallback(ctx, s.phoneCodeSender, request)
} else if err := deliverOTP(ctx, s.phoneCodeSender, request); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("send login SMS code: %w", err))
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
return hash, nil
}
if err := s.deliverLoginCode(ctx, existingUserID, hash, s.fixedCode); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, err)
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
// 新手机号还没有 owner/dialog,不能在签发阶段创建 777000 消息;
// 已有账号的 App-code 已在上面的 provider 分支之前 durable 提交。
return hash, nil
}
@ -463,25 +527,60 @@ func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string,
if err != nil {
return "", err
}
deliveryID, err := otpdelivery.NewDeliveryID()
if err != nil {
return "", err
}
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
IssuedUserID: issuedUserID,
Phone: phone,
Code: code,
DeliveryID: deliveryID,
Channel: codeChannelEmailLogin,
Email: strings.TrimSpace(email),
MaxAttempts: s.codeMaxAttempts,
}
expiresAt := time.Now().Add(s.codeTTL)
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
return "", fmt.Errorf("store email code: %w", err)
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
if issuedUserID != 0 {
if err := s.deliverLoginCode(ctx, issuedUserID, hash, code); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, err)
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
return "", err
}
}
if s.loginEmailSender == nil {
if issuedUserID != 0 {
s.reportOTPDeliveryFailure(ctx, otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: otpdelivery.PurposeLoginEmail,
Channel: otpdelivery.ChannelEmail,
Recipient: rec.Email,
Code: code,
ExpiresAt: expiresAt,
}, fmt.Errorf("login email sender is not configured"))
return hash, nil
}
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("login email sender is not configured"))
}
if err := s.loginEmailSender.SendLoginCode(ctx, rec.Email, code, s.codeTTL); err != nil {
request := otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: otpdelivery.PurposeLoginEmail,
Channel: otpdelivery.ChannelEmail,
Recipient: rec.Email,
Code: code,
ExpiresAt: expiresAt,
}
if issuedUserID != 0 {
s.deliverOTPWithAppFallback(ctx, s.loginEmailSender, request)
} else if err := deliverOTP(ctx, s.loginEmailSender, request); err != nil {
return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("send login email code: %w", err))
}
if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil {
@ -490,6 +589,34 @@ func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string,
return hash, nil
}
// deliverOTPWithAppFallback performs an additional provider delivery only
// after the same code is durably visible through 777000. A provider failure
// must not invalidate that visible code or fail the RPC; it remains observable
// through the injected failure observer.
func (s *Service) deliverOTPWithAppFallback(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) {
if _, err := sender.Deliver(ctx, req); err != nil {
s.reportOTPDeliveryFailure(ctx, req, err)
}
}
func (s *Service) reportOTPDeliveryFailure(ctx context.Context, req otpdelivery.Request, err error) {
if s.otpDeliveryFailure != nil && err != nil {
s.otpDeliveryFailure(ctx, req, err)
}
}
// deliverOTP treats a transport-level unknown outcome as a successful issue:
// the provider may already have accepted the request, so the code must remain
// usable and the client needs the hash in order to verify or explicitly resend
// it. Only an explicit provider rejection is safe to roll back.
func deliverOTP(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) error {
_, err := sender.Deliver(ctx, req)
if errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
return nil
}
return err
}
func (s *Service) CodeDelivery(ctx context.Context, phoneCodeHash string) (domain.AuthCodeDelivery, bool, error) {
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
if err != nil || !found {
@ -503,6 +630,8 @@ func codeDelivery(rec store.PhoneCode) domain.AuthCodeDelivery {
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}
}
switch rec.Channel {
case codeChannelSMS:
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}
case codeChannelEmailLogin:
return domain.AuthCodeDelivery{
Kind: domain.AuthCodeDeliveryEmail,
@ -582,7 +711,7 @@ func (s *Service) resendCode(ctx context.Context, authKeyID [8]byte, phone, phon
if rec.Channel == codeChannelEmailSetupRequired {
return s.createSetupRequiredCode(ctx, phone, rec.IssuedUserID)
}
if rec.Channel != codeChannelPhone {
if rec.Channel != codeChannelPhone && rec.Channel != codeChannelSMS {
return "", ErrCodeInvalid
}
return s.createPhoneCode(ctx, phone, rec.IssuedUserID)
@ -594,14 +723,44 @@ func (s *Service) recreateChangePhoneCode(ctx context.Context, rec store.PhoneCo
return "", err
}
rec.Code = s.fixedCode
rec.DeliveryID = ""
rec.Channel = codeChannelPhone
if s.phoneCodeSender != nil {
rec.Code, err = randomDigits(s.phoneCodeLength)
if err != nil {
return "", err
}
rec.DeliveryID, err = otpdelivery.NewDeliveryID()
if err != nil {
return "", err
}
rec.Channel = codeChannelSMS
}
rec.Attempts = 0
if rec.MaxAttempts <= 0 {
rec.MaxAttempts = s.codeMaxAttempts
}
expiresAt := time.Now().Add(s.codeTTL)
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
return "", fmt.Errorf("store resent phone change code: %w", err)
}
if s.phoneCodeSender != nil {
if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{
DeliveryID: rec.DeliveryID,
Purpose: otpdelivery.PurposeChangePhone,
Channel: otpdelivery.ChannelSMS,
Recipient: rec.Phone,
Code: rec.Code,
ExpiresAt: expiresAt,
}); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginCodeRollbackTimeout)
defer cancel()
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
return "", errors.Join(err, fmt.Errorf("rollback undelivered phone change code: %w", cleanupErr))
}
return "", err
}
}
return hash, nil
}
@ -785,7 +944,7 @@ func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, cod
if rec.Phone != phone || rec.Purpose != "" {
return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid
}
channelAllowed := rec.Channel == codeChannelPhone && !emailPath
channelAllowed := (rec.Channel == codeChannelPhone || rec.Channel == codeChannelSMS) && !emailPath
if emailPath {
channelAllowed = rec.Channel == codeChannelEmailLogin || (!s.loginEmailEnabled && rec.Channel == codeChannelPhone)
}
@ -928,7 +1087,7 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone)
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
if rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin {
if rec.Channel != codeChannelPhone && rec.Channel != codeChannelSMS && rec.Channel != codeChannelEmailLogin {
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
if s.loginEmailRequireSetup && !rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) == "" {
@ -948,7 +1107,7 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
return domain.User{}, domain.Message{}, ErrCodeExpired
}
rec = consumed
if rec.IssuedUserID != 0 || !rec.SignUpVerified || (rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin) {
if rec.IssuedUserID != 0 || !rec.SignUpVerified || (rec.Channel != codeChannelPhone && rec.Channel != codeChannelSMS && rec.Channel != codeChannelEmailLogin) {
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
if current, currentFound, err := s.currentPhoneOwner(ctx, phone); err != nil {
@ -985,8 +1144,9 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
return domain.User{}, domain.Message{}, err
}
loginMessage := domain.Message{}
// SMTP setup/login codes are secret factors, not 777000 app messages. Only
// the normal phone/app-code registration path creates the bootstrap dialog.
// A new account has no owner/dialog at issuance time. Only the development
// phone/App registration path creates its bootstrap 777000 message here;
// external SMS and email setup registration retain only their verified fact.
if rec.Channel == codeChannelPhone {
loginMessage, err = s.recordLoginMessage(ctx, u.ID, rec.Code)
if err != nil {