changes for email signup
This commit is contained in:
parent
0fbfc8cd71
commit
3d9b0f1de7
15 changed files with 469 additions and 27 deletions
11
.env.example
11
.env.example
|
|
@ -51,6 +51,17 @@ TELESRV_SMTP_FROM_NAME=telesrv
|
||||||
TELESRV_SMTP_TLS=starttls
|
TELESRV_SMTP_TLS=starttls
|
||||||
TELESRV_SMTP_TIMEOUT=10s
|
TELESRV_SMTP_TIMEOUT=10s
|
||||||
|
|
||||||
|
# Email-as-identity signup mode. When enabled, patched clients let the user
|
||||||
|
# register/log in with an email address instead of a phone number: the client
|
||||||
|
# encodes the email into a synthetic "888"-prefixed number and drives the
|
||||||
|
# existing sendCode/signUp/signIn/changePhone flow unchanged; the server
|
||||||
|
# decodes 888-numbers back to the email and delivers the code over SMTP
|
||||||
|
# (same TELESRV_SMTP_* settings as login email above) instead of SMS.
|
||||||
|
# account.changePhone is blocked from moving such an account to a number that
|
||||||
|
# doesn't decode back to a valid email, since this server has no real SMS
|
||||||
|
# delivery at all.
|
||||||
|
TELESRV_EMAIL_SIGNUP_ENABLE=false
|
||||||
|
|
||||||
# Client-visible telesrv links. This is an HTTP(S) URL, not a listen address.
|
# Client-visible telesrv links. This is an HTTP(S) URL, not a listen address.
|
||||||
# Production uses https://telesrv.net. For local link/deeplink smoke tests use
|
# Production uses https://telesrv.net. For local link/deeplink smoke tests use
|
||||||
# http://127.0.0.1:2401. Invalid schemes, credentials, query strings, fragments,
|
# http://127.0.0.1:2401. Invalid schemes, credentials, query strings, fragments,
|
||||||
|
|
|
||||||
|
|
@ -531,9 +531,10 @@ func run(logger *zap.Logger) error {
|
||||||
account.WithUsers(userStore),
|
account.WithUsers(userStore),
|
||||||
account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts),
|
account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts),
|
||||||
account.WithPublicBaseURL(cfg.PublicBaseURL),
|
account.WithPublicBaseURL(cfg.PublicBaseURL),
|
||||||
|
account.WithEmailSignup(cfg.EmailSignupEnable),
|
||||||
}
|
}
|
||||||
var loginEmailSender mailpkg.Sender
|
var loginEmailSender mailpkg.Sender
|
||||||
if cfg.LoginEmailEnable {
|
if cfg.LoginEmailEnable || cfg.EmailSignupEnable {
|
||||||
loginEmailSender = mailpkg.NewSMTP(mailpkg.Config{
|
loginEmailSender = mailpkg.NewSMTP(mailpkg.Config{
|
||||||
Host: cfg.SMTPHost,
|
Host: cfg.SMTPHost,
|
||||||
Port: cfg.SMTPPort,
|
Port: cfg.SMTPPort,
|
||||||
|
|
@ -705,7 +706,8 @@ func run(logger *zap.Logger) error {
|
||||||
CodeLength: cfg.LoginEmailCodeLength,
|
CodeLength: cfg.LoginEmailCodeLength,
|
||||||
Store: accountService,
|
Store: accountService,
|
||||||
Sender: loginEmailSender,
|
Sender: loginEmailSender,
|
||||||
}))
|
}),
|
||||||
|
auth.WithEmailSignup(cfg.EmailSignupEnable))
|
||||||
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
|
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
|
||||||
router := rpc.New(rpc.Config{
|
router := rpc.New(rpc.Config{
|
||||||
DC: cfg.DC,
|
DC: cfg.DC,
|
||||||
|
|
@ -733,7 +735,7 @@ func run(logger *zap.Logger) error {
|
||||||
Auth: authService,
|
Auth: authService,
|
||||||
Account: accountService,
|
Account: accountService,
|
||||||
Privacy: privacyService,
|
Privacy: privacyService,
|
||||||
Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken)),
|
Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken), help.WithEmailSignupEnable(cfg.EmailSignupEnable)),
|
||||||
AICompose: aiComposeService,
|
AICompose: aiComposeService,
|
||||||
Users: usersService,
|
Users: usersService,
|
||||||
Updates: updatesService,
|
Updates: updatesService,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE public.users ALTER COLUMN phone TYPE character varying(32);
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- Email-as-identity signup mode encodes an email address as a reversible
|
||||||
|
-- big-integer-decimal string behind an "888" prefix and stores it in
|
||||||
|
-- users.phone via the existing phone-number column/flow. A real phone number
|
||||||
|
-- never approaches this length, but an encoded email can (roughly 2.4 decimal
|
||||||
|
-- digits per source byte), so the original 32-char cap (sized only for real
|
||||||
|
-- phone numbers) is too narrow. 200 chars comfortably covers realistic email
|
||||||
|
-- addresses; see internal/domain.ValidPhone and internal/domain.EncodeEmailPhone.
|
||||||
|
ALTER TABLE public.users ALTER COLUMN phone TYPE character varying(200);
|
||||||
93
internal/app/account/email_signup_phone_change_test.go
Normal file
93
internal/app/account/email_signup_phone_change_test.go
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newEmailSignupPhoneChangeFixture(t *testing.T) (phoneChangeFixture, *captureMailSender) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
users := memory.NewUserStore()
|
||||||
|
auths := memory.NewAuthorizationStore()
|
||||||
|
codes := memory.NewCodeStore()
|
||||||
|
events := memory.NewUpdateEventStore()
|
||||||
|
phone, ok := domain.EncodeEmailPhone("alice@owpengram.local")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("EncodeEmailPhone: ok=false")
|
||||||
|
}
|
||||||
|
u, err := users.Create(ctx, domain.User{AccessHash: 101, Phone: phone, 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)
|
||||||
|
}
|
||||||
|
changes := &recordingPhoneChangeStore{inner: memory.NewPhoneChangeStore(users, events)}
|
||||||
|
sender := &captureMailSender{}
|
||||||
|
service := NewService(
|
||||||
|
memory.NewPasswordStore(),
|
||||||
|
WithUsers(users),
|
||||||
|
WithPhoneChange(changes, auths, codes, nil, "12345", time.Minute, 3),
|
||||||
|
WithLoginEmailVerification(codes, sender, time.Minute, 3, 6),
|
||||||
|
WithEmailSignup(true),
|
||||||
|
)
|
||||||
|
return phoneChangeFixture{ctx: ctx, service: service, users: users, auths: auths, codes: codes, events: events, user: u, authKeyID: authKeyID, changes: changes}, sender
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailSignupChangePhoneRoutesCodeToDecodedEmail(t *testing.T) {
|
||||||
|
f, sender := newEmailSignupPhoneChangeFixture(t)
|
||||||
|
newPhone, ok := domain.EncodeEmailPhone("newmail@owpengram.local")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("EncodeEmailPhone: ok=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, delivery, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, newPhone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendChangePhoneCode: %v", err)
|
||||||
|
}
|
||||||
|
if delivery.Kind != domain.AuthCodeDeliveryEmail {
|
||||||
|
t.Fatalf("delivery.Kind = %v, want AuthCodeDeliveryEmail", delivery.Kind)
|
||||||
|
}
|
||||||
|
if sender.to != "newmail@owpengram.local" {
|
||||||
|
t.Fatalf("sender.to = %q, want newmail@owpengram.local", sender.to)
|
||||||
|
}
|
||||||
|
if len(sender.code) != 6 {
|
||||||
|
t.Fatalf("sender.code = %q, want 6 digits", sender.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Channel != store.PhoneCodeChannelEmailLogin || rec.Email != "newmail@owpengram.local" {
|
||||||
|
t.Fatalf("stored code = %+v", rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawAuthKeyID := [8]byte{8, 8, 8, 8}
|
||||||
|
result, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, rawAuthKeyID, 88, newPhone, hash, sender.code, 1700000000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChangePhone: %v", err)
|
||||||
|
}
|
||||||
|
if result.User.Phone != newPhone {
|
||||||
|
t.Fatalf("result.User.Phone = %q, want %q", result.User.Phone, newPhone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailSignupChangePhoneRejectsRealPhoneNumber(t *testing.T) {
|
||||||
|
f, sender := newEmailSignupPhoneChangeFixture(t)
|
||||||
|
|
||||||
|
_, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "+15550019999")
|
||||||
|
if err != domain.ErrPhoneNumberInvalid {
|
||||||
|
t.Fatalf("SendChangePhoneCode err = %v, want ErrPhoneNumberInvalid", err)
|
||||||
|
}
|
||||||
|
if sender.to != "" {
|
||||||
|
t.Fatalf("sender.to = %q, want empty (no email should have been sent)", sender.to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,7 +42,17 @@ func (s *Service) SendChangePhoneCode(ctx context.Context, userID int64, authKey
|
||||||
} else if found && existing.ID != 0 {
|
} else if found && existing.ID != 0 {
|
||||||
return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberOccupied
|
return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberOccupied
|
||||||
}
|
}
|
||||||
if s.codes == nil || strings.TrimSpace(s.phoneChangeCode) == "" {
|
if s.codes == nil {
|
||||||
|
return "", domain.AuthCodeDelivery{}, fmt.Errorf("phone change code service is not configured")
|
||||||
|
}
|
||||||
|
// Email-as-identity mode: this server has no real SMS delivery at all, so
|
||||||
|
// letting an account switch to an arbitrary non-encoded number would just
|
||||||
|
// hand out the universal dev code with no real verification. Only numbers
|
||||||
|
// that decode back to an email are accepted; the code goes to that inbox.
|
||||||
|
if s.emailSignupEnabled {
|
||||||
|
return s.sendChangePhoneCodeByEmail(ctx, userID, authKeyID, sessionID, phone)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.phoneChangeCode) == "" {
|
||||||
return "", domain.AuthCodeDelivery{}, fmt.Errorf("phone change code service is not configured")
|
return "", domain.AuthCodeDelivery{}, fmt.Errorf("phone change code service is not configured")
|
||||||
}
|
}
|
||||||
hash, err := phoneChangeHash()
|
hash, err := phoneChangeHash()
|
||||||
|
|
@ -66,6 +76,44 @@ func (s *Service) SendChangePhoneCode(ctx context.Context, userID int64, authKey
|
||||||
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}, nil
|
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) sendChangePhoneCodeByEmail(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string) (string, domain.AuthCodeDelivery, error) {
|
||||||
|
email, ok := domain.DecodeEmailPhone(phone)
|
||||||
|
if !ok {
|
||||||
|
return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberInvalid
|
||||||
|
}
|
||||||
|
if s.loginEmailSender == nil {
|
||||||
|
return "", domain.AuthCodeDelivery{}, fmt.Errorf("email signup sender is not configured")
|
||||||
|
}
|
||||||
|
code, err := randomDigits(6)
|
||||||
|
if err != nil {
|
||||||
|
return "", domain.AuthCodeDelivery{}, err
|
||||||
|
}
|
||||||
|
hash, err := phoneChangeHash()
|
||||||
|
if err != nil {
|
||||||
|
return "", domain.AuthCodeDelivery{}, err
|
||||||
|
}
|
||||||
|
ttl := s.phoneChangeCodeTTL
|
||||||
|
rec := store.PhoneCode{
|
||||||
|
Version: store.PhoneCodeVersionCurrent,
|
||||||
|
Phone: phone,
|
||||||
|
Code: code,
|
||||||
|
Channel: store.PhoneCodeChannelEmailLogin,
|
||||||
|
Purpose: store.PhoneCodePurposeChangePhone,
|
||||||
|
Email: email,
|
||||||
|
UserID: userID,
|
||||||
|
AuthKeyID: authKeyID,
|
||||||
|
SessionID: sessionID,
|
||||||
|
MaxAttempts: s.phoneChangeMaxAttempts,
|
||||||
|
}
|
||||||
|
if err := s.codes.Set(ctx, hash, rec, ttl); err != nil {
|
||||||
|
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store phone change code: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.loginEmailSender.SendLoginCode(ctx, email, code, ttl); err != nil {
|
||||||
|
return "", domain.AuthCodeDelivery{}, fmt.Errorf("send phone change email code: %w", err)
|
||||||
|
}
|
||||||
|
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliveryEmail, EmailPattern: emailPattern(email), Length: len(code)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ChangePhone 验证作用域和验证码后执行原子改号。返回事件用于当前 session 的
|
// ChangePhone 验证作用域和验证码后执行原子改号。返回事件用于当前 session 的
|
||||||
// pts 簿记;其它 session 由 transactional outbox 投递 updateUserPhone。
|
// pts 簿记;其它 session 由 transactional outbox 投递 updateUserPhone。
|
||||||
func (s *Service) ChangePhone(ctx context.Context, userID int64, authKeyID, originRawAuthKeyID [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) {
|
||||||
|
|
@ -107,7 +155,8 @@ func (s *Service) ChangePhone(ctx context.Context, userID int64, authKeyID, orig
|
||||||
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
|
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
|
||||||
}
|
}
|
||||||
consumed := verified.Record
|
consumed := verified.Record
|
||||||
if consumed.Version != store.PhoneCodeVersionCurrent || consumed.Scope() != scope || consumed.Channel != store.PhoneCodeChannelPhone {
|
channelOK := consumed.Channel == store.PhoneCodeChannelPhone || consumed.Channel == store.PhoneCodeChannelEmailLogin
|
||||||
|
if consumed.Version != store.PhoneCodeVersionCurrent || consumed.Scope() != scope || !channelOK {
|
||||||
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid
|
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid
|
||||||
}
|
}
|
||||||
if date == 0 {
|
if date == 0 {
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@ type Service struct {
|
||||||
loginEmailCodeTTL time.Duration
|
loginEmailCodeTTL time.Duration
|
||||||
loginEmailCodeMaxAttempts int
|
loginEmailCodeMaxAttempts int
|
||||||
loginEmailCodeLength int
|
loginEmailCodeLength int
|
||||||
|
// emailSignupEnabled 打开后,SendChangePhoneCode 只接受 888 前缀(邮箱编码)目标号码,
|
||||||
|
// 验证码走 loginEmailSender 发到解码出的邮箱;非 888 号码一律拒绝——本服务器没有真实
|
||||||
|
// 短信通道,放行会让账号的邮箱身份绑定被绕过(见迁移前的密码找回验证码漏洞教训)。
|
||||||
|
emailSignupEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceOption 调整 account 服务依赖。
|
// ServiceOption 调整 account 服务依赖。
|
||||||
|
|
@ -152,6 +156,13 @@ func WithLoginEmailVerification(codes store.CodeStore, sender mail.Sender, ttl t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithEmailSignup 打开「邮箱即身份」模式下的改号限制:见 emailSignupEnabled 字段注释。
|
||||||
|
func WithEmailSignup(enabled bool) ServiceOption {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.emailSignupEnabled = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewService 创建 account 服务。
|
// NewService 创建 account 服务。
|
||||||
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
||||||
s := &Service{
|
s := &Service{
|
||||||
|
|
|
||||||
84
internal/app/auth/email_signup_test.go
Normal file
84
internal/app/auth/email_signup_test.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEmailSignupSendCodeRoutesFreshSignupToEmail(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
users := memory.NewUserStore()
|
||||||
|
authz := memory.NewAuthorizationStore()
|
||||||
|
sender := &testMailSender{}
|
||||||
|
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
|
||||||
|
WithLoginEmail(LoginEmailOptions{Sender: sender}),
|
||||||
|
WithEmailSignup(true))
|
||||||
|
|
||||||
|
phone, ok := domain.EncodeEmailPhone("newuser@owpengram.local")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("EncodeEmailPhone: ok=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := svc.SendCode(ctx, phone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendCode: %v", err)
|
||||||
|
}
|
||||||
|
if sender.to != "newuser@owpengram.local" {
|
||||||
|
t.Fatalf("sender.to = %q, want newuser@owpengram.local", sender.to)
|
||||||
|
}
|
||||||
|
delivery, found, err := svc.CodeDelivery(ctx, hash)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("CodeDelivery found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if delivery.Kind != domain.AuthCodeDeliveryEmail {
|
||||||
|
t.Fatalf("delivery.Kind = %v, want AuthCodeDeliveryEmail", delivery.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The email-signup path must reuse the stock auth.signIn/SignInWithEmail
|
||||||
|
// flow unchanged: a fresh (never-registered) 888 phone reports needSignUp.
|
||||||
|
_, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{}, phone, hash, sender.code)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignInWithEmail: %v", err)
|
||||||
|
}
|
||||||
|
if !needSignUp {
|
||||||
|
t.Fatalf("needSignUp = false, want true for a brand-new email-signup account")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignUp itself is completely untouched by email-signup: same call, same
|
||||||
|
// phone (the 888-encoded value), no email-specific parameter anywhere.
|
||||||
|
created, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "New", "User")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignUp: %v", err)
|
||||||
|
}
|
||||||
|
if created.Phone != phone {
|
||||||
|
t.Fatalf("created.Phone = %q, want %q", created.Phone, phone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailSignupSendCodeIgnoredWhenPhoneIsNotEncoded(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
users := memory.NewUserStore()
|
||||||
|
authz := memory.NewAuthorizationStore()
|
||||||
|
sender := &testMailSender{}
|
||||||
|
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
|
||||||
|
WithLoginEmail(LoginEmailOptions{Sender: sender}),
|
||||||
|
WithEmailSignup(true))
|
||||||
|
|
||||||
|
hash, err := svc.SendCode(ctx, "+15550019999")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendCode: %v", err)
|
||||||
|
}
|
||||||
|
if sender.to != "" {
|
||||||
|
t.Fatalf("sender.to = %q, want empty (real phone must fall through to the normal dev-code path)", sender.to)
|
||||||
|
}
|
||||||
|
delivery, found, err := svc.CodeDelivery(ctx, hash)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("CodeDelivery found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if delivery.Kind == domain.AuthCodeDeliveryEmail {
|
||||||
|
t.Fatalf("delivery.Kind = Email, want a non-email fallback for a real phone number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -86,6 +86,10 @@ type Service struct {
|
||||||
loginEmailEnabled bool
|
loginEmailEnabled bool
|
||||||
loginEmailRequireSetup bool
|
loginEmailRequireSetup bool
|
||||||
loginEmailCodeLength int
|
loginEmailCodeLength int
|
||||||
|
// emailSignupEnabled 打开后,888 前缀的合成号码(domain.EncodeEmailPhone)
|
||||||
|
// 在 sendCode 阶段直接解码出邮箱、复用登录邮箱同一投递通道发码,与
|
||||||
|
// loginEmailEnabled(手机号账号的第二验证渠道)是两条独立开关。
|
||||||
|
emailSignupEnabled bool
|
||||||
// premiumGrantMonths 是新注册账号默认赠送的会员月数;0 表示关闭赠送。
|
// premiumGrantMonths 是新注册账号默认赠送的会员月数;0 表示关闭赠送。
|
||||||
premiumGrantMonths int
|
premiumGrantMonths int
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +185,15 @@ func WithLoginEmail(opts LoginEmailOptions) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithEmailSignup 打开「邮箱即身份」登录方式:见 emailSignupEnabled 字段注释。
|
||||||
|
// 邮件投递复用 WithLoginEmail 注入的 loginEmailSender,调用方需保证两条配置
|
||||||
|
// 共用同一组 SMTP 设置时任一开关打开都会构造好 sender(见 internal/config 校验)。
|
||||||
|
func WithEmailSignup(enabled bool) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.emailSignupEnabled = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewService 创建登录服务。fixedCode 为开发固定验证码。
|
// NewService 创建登录服务。fixedCode 为开发固定验证码。
|
||||||
func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service {
|
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}
|
||||||
|
|
@ -302,6 +315,11 @@ func (s *Service) SendCode(ctx context.Context, phone string) (string, error) {
|
||||||
if found {
|
if found {
|
||||||
issuedUserID = existing.ID
|
issuedUserID = existing.ID
|
||||||
}
|
}
|
||||||
|
if s.emailSignupEnabled {
|
||||||
|
if email, ok := domain.DecodeEmailPhone(phone); ok {
|
||||||
|
return s.createEmailLoginCode(ctx, phone, email, issuedUserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
if s.loginEmailEnabled && s.loginEmails != nil {
|
if s.loginEmailEnabled && s.loginEmails != nil {
|
||||||
email, found, err := s.loginEmails.LoginEmailByPhone(ctx, phone)
|
email, found, err := s.loginEmails.LoginEmailByPhone(ctx, phone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,10 @@ const defaultAppConfigHash = 23 // 默认 app config 内容变更时必须递增
|
||||||
// (登录页/启动配置是高频握手路径)。运维改库需重启生效。timezones/emoji 等其余目录走
|
// (登录页/启动配置是高频握手路径)。运维改库需重启生效。timezones/emoji 等其余目录走
|
||||||
// internal/seed/catalog(go:embed 一次解析),本就在内存。
|
// internal/seed/catalog(go:embed 一次解析),本就在内存。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
appConfigs store.AppConfigStore
|
appConfigs store.AppConfigStore
|
||||||
countries store.CountryStore
|
countries store.CountryStore
|
||||||
mapboxToken string
|
mapboxToken string
|
||||||
|
emailSignupEnable bool
|
||||||
|
|
||||||
appConfigOnce sync.Once
|
appConfigOnce sync.Once
|
||||||
appConfigCache domain.AppConfig
|
appConfigCache domain.AppConfig
|
||||||
|
|
@ -84,6 +85,14 @@ func WithMapboxToken(token string) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithEmailSignupEnable 下发 email_signup_enabled,供已适配的客户端在登录/注册入口
|
||||||
|
// 用邮箱输入替代手机号输入(见 domain.EncodeEmailPhone)。
|
||||||
|
func WithEmailSignupEnable(enabled bool) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.emailSignupEnable = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewService 创建 help 服务。
|
// NewService 创建 help 服务。
|
||||||
func NewService(appConfigs store.AppConfigStore, countries store.CountryStore, opts ...Option) *Service {
|
func NewService(appConfigs store.AppConfigStore, countries store.CountryStore, opts ...Option) *Service {
|
||||||
s := &Service{appConfigs: appConfigs, countries: countries}
|
s := &Service{appConfigs: appConfigs, countries: countries}
|
||||||
|
|
@ -95,28 +104,36 @@ func NewService(appConfigs store.AppConfigStore, countries store.CountryStore, o
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultAppConfig(mapboxToken string) domain.AppConfig {
|
func defaultAppConfig(mapboxToken string, emailSignupEnable bool) domain.AppConfig {
|
||||||
jsonBytes := defaultAppConfigJSON(mapboxToken)
|
jsonBytes := defaultAppConfigJSON(mapboxToken, emailSignupEnable)
|
||||||
return domain.AppConfig{Client: tdesktopClient, Hash: defaultAppConfigHashFor(mapboxToken), JSON: jsonBytes}
|
return domain.AppConfig{Client: tdesktopClient, Hash: defaultAppConfigHashFor(mapboxToken, emailSignupEnable), JSON: jsonBytes}
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultAppConfigJSON(mapboxToken string) []byte {
|
func defaultAppConfigJSON(mapboxToken string, emailSignupEnable bool) []byte {
|
||||||
|
base := tdesktopDefaultAppConfigBase
|
||||||
|
if emailSignupEnable {
|
||||||
|
base += `,"email_signup_enabled":true`
|
||||||
|
}
|
||||||
if mapboxToken == "" {
|
if mapboxToken == "" {
|
||||||
return []byte(tdesktopDefaultAppConfigBase + `}`)
|
return []byte(base + `}`)
|
||||||
}
|
}
|
||||||
token, err := json.Marshal(mapboxToken)
|
token, err := json.Marshal(mapboxToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return []byte(tdesktopDefaultAppConfigBase + `}`)
|
return []byte(base + `}`)
|
||||||
}
|
}
|
||||||
tokenJSON := string(token)
|
tokenJSON := string(token)
|
||||||
return []byte(tdesktopDefaultAppConfigBase + `,"tdesktop_config_map":{"maps":` + tokenJSON + `,"geo":` + tokenJSON + `,"bmaps":` + tokenJSON + `,"bgeo":` + tokenJSON + `}}`)
|
return []byte(base + `,"tdesktop_config_map":{"maps":` + tokenJSON + `,"geo":` + tokenJSON + `,"bmaps":` + tokenJSON + `,"bgeo":` + tokenJSON + `}}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultAppConfigHashFor(mapboxToken string) int {
|
func defaultAppConfigHashFor(mapboxToken string, emailSignupEnable bool) int {
|
||||||
if mapboxToken == "" {
|
h := defaultAppConfigHash
|
||||||
return defaultAppConfigHash
|
if emailSignupEnable {
|
||||||
|
h += 1000003 // large odd offset so toggling the flag always changes the hash
|
||||||
}
|
}
|
||||||
return defaultAppConfigHash + 1 + int(crc32.ChecksumIEEE([]byte(mapboxToken))&0x3fffffff)
|
if mapboxToken == "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
return h + 1 + int(crc32.ChecksumIEEE([]byte(mapboxToken))&0x3fffffff)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAppConfig 返回 TDesktop app config,hash 命中时返回 notModified。首次调用加载一次后缓存。
|
// GetAppConfig 返回 TDesktop app config,hash 命中时返回 notModified。首次调用加载一次后缓存。
|
||||||
|
|
@ -127,9 +144,9 @@ func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig,
|
||||||
|
|
||||||
func (s *Service) loadAppConfig(ctx context.Context) domain.AppConfig {
|
func (s *Service) loadAppConfig(ctx context.Context) domain.AppConfig {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return defaultAppConfig("")
|
return defaultAppConfig("", false)
|
||||||
}
|
}
|
||||||
defaultCfg := defaultAppConfig(s.mapboxToken)
|
defaultCfg := defaultAppConfig(s.mapboxToken, s.emailSignupEnable)
|
||||||
s.appConfigOnce.Do(func() {
|
s.appConfigOnce.Do(func() {
|
||||||
if s.appConfigs == nil {
|
if s.appConfigs == nil {
|
||||||
s.appConfigCache = defaultCfg
|
s.appConfigCache = defaultCfg
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,11 @@ type Config struct {
|
||||||
LoginEmailRequireSetup bool
|
LoginEmailRequireSetup bool
|
||||||
// LoginEmailCodeLength 是邮箱验证码长度。
|
// LoginEmailCodeLength 是邮箱验证码长度。
|
||||||
LoginEmailCodeLength int
|
LoginEmailCodeLength int
|
||||||
|
// EmailSignupEnable 启用「邮箱作为账号身份」模式:客户端用邮箱注册/登录,服务端把邮箱
|
||||||
|
// 编码进一个 888 前缀的合成号码复用现有 phone 全流程(sendCode/signUp/signIn/changePhone
|
||||||
|
// 不变),验证码通过 SMTP 发到解码出的邮箱而非发短信。要求 SMTP 配置可用(与
|
||||||
|
// LoginEmailEnable 共用同一组 TELESRV_SMTP_* 变量)。
|
||||||
|
EmailSignupEnable bool
|
||||||
// SMTP* 是登录邮箱验证码的出站邮件配置。LoginEmailEnable=true 时必须可用。
|
// SMTP* 是登录邮箱验证码的出站邮件配置。LoginEmailEnable=true 时必须可用。
|
||||||
SMTPHost string
|
SMTPHost string
|
||||||
SMTPPort int
|
SMTPPort int
|
||||||
|
|
@ -453,6 +458,7 @@ func Load() (Config, error) {
|
||||||
AuthCodeRateWindow: envDurationOr("TELESRV_AUTH_CODE_RATE_WINDOW", 10*time.Minute),
|
AuthCodeRateWindow: envDurationOr("TELESRV_AUTH_CODE_RATE_WINDOW", 10*time.Minute),
|
||||||
LoginEmailEnable: envBoolOr("TELESRV_LOGIN_EMAIL_ENABLE", false),
|
LoginEmailEnable: envBoolOr("TELESRV_LOGIN_EMAIL_ENABLE", false),
|
||||||
LoginEmailRequireSetup: envBoolOr("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP", false),
|
LoginEmailRequireSetup: envBoolOr("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP", false),
|
||||||
|
EmailSignupEnable: envBoolOr("TELESRV_EMAIL_SIGNUP_ENABLE", false),
|
||||||
LoginEmailCodeLength: envIntOr("TELESRV_LOGIN_EMAIL_CODE_LENGTH", 6),
|
LoginEmailCodeLength: envIntOr("TELESRV_LOGIN_EMAIL_CODE_LENGTH", 6),
|
||||||
SMTPHost: envOr("TELESRV_SMTP_HOST", ""),
|
SMTPHost: envOr("TELESRV_SMTP_HOST", ""),
|
||||||
SMTPPort: envIntOr("TELESRV_SMTP_PORT", 587),
|
SMTPPort: envIntOr("TELESRV_SMTP_PORT", 587),
|
||||||
|
|
@ -582,17 +588,17 @@ func validateLoginEmailConfig(cfg Config) error {
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("TELESRV_SMTP_TLS must be starttls, tls, or none")
|
return fmt.Errorf("TELESRV_SMTP_TLS must be starttls, tls, or none")
|
||||||
}
|
}
|
||||||
if !cfg.LoginEmailEnable {
|
if !cfg.LoginEmailEnable && !cfg.EmailSignupEnable {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(cfg.SMTPHost) == "" {
|
if strings.TrimSpace(cfg.SMTPHost) == "" {
|
||||||
return fmt.Errorf("TELESRV_SMTP_HOST is required when TELESRV_LOGIN_EMAIL_ENABLE=true")
|
return fmt.Errorf("TELESRV_SMTP_HOST is required when TELESRV_LOGIN_EMAIL_ENABLE=true or TELESRV_EMAIL_SIGNUP_ENABLE=true")
|
||||||
}
|
}
|
||||||
if cfg.SMTPPort <= 0 || cfg.SMTPPort > 65535 {
|
if cfg.SMTPPort <= 0 || cfg.SMTPPort > 65535 {
|
||||||
return fmt.Errorf("TELESRV_SMTP_PORT must be between 1 and 65535")
|
return fmt.Errorf("TELESRV_SMTP_PORT must be between 1 and 65535")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(cfg.SMTPFrom) == "" && strings.TrimSpace(cfg.SMTPUsername) == "" {
|
if strings.TrimSpace(cfg.SMTPFrom) == "" && strings.TrimSpace(cfg.SMTPUsername) == "" {
|
||||||
return fmt.Errorf("TELESRV_SMTP_FROM or TELESRV_SMTP_USERNAME is required when TELESRV_LOGIN_EMAIL_ENABLE=true")
|
return fmt.Errorf("TELESRV_SMTP_FROM or TELESRV_SMTP_USERNAME is required when TELESRV_LOGIN_EMAIL_ENABLE=true or TELESRV_EMAIL_SIGNUP_ENABLE=true")
|
||||||
}
|
}
|
||||||
if cfg.SMTPTimeout <= 0 {
|
if cfg.SMTPTimeout <= 0 {
|
||||||
return fmt.Errorf("TELESRV_SMTP_TIMEOUT must be positive")
|
return fmt.Errorf("TELESRV_SMTP_TIMEOUT must be positive")
|
||||||
|
|
|
||||||
|
|
@ -249,11 +249,12 @@ func NormalizePhone(phone string) string {
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidPhone 校验 NormalizePhone 后的持久化形态:5-32 位纯数字。
|
// ValidPhone 校验 NormalizePhone 后的持久化形态:5-200 位纯数字。
|
||||||
// 上限与 users.phone 列宽一致;当前开发登录/改号链路不强制精确 E.164 长度,
|
// 上限与 users.phone 列宽一致;当前开发登录/改号链路不强制精确 E.164 长度,
|
||||||
// 但拒绝空串、非数字和会截断的超长输入。
|
// 但拒绝空串、非数字和会截断的超长输入。上限从 32 放宽到 200 是为了容纳
|
||||||
|
// EncodeEmailPhone 生成的 "888"+反向可解码大整数(真实手机号远用不到这个上限)。
|
||||||
func ValidPhone(phone string) bool {
|
func ValidPhone(phone string) bool {
|
||||||
if len(phone) < 5 || len(phone) > 32 {
|
if len(phone) < 5 || len(phone) > 200 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, r := range phone {
|
for _, r := range phone {
|
||||||
|
|
|
||||||
74
internal/domain/emailphone.go
Normal file
74
internal/domain/emailphone.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EmailPhonePrefix marks a "phone number" as a synthetic identity encoding an
|
||||||
|
// email address, not a real phone. It reuses Telegram's own +888 "Anonymous
|
||||||
|
// Number" range (already declared in this server's help.getAppConfig
|
||||||
|
// fragment_prefixes), so patched clients that already special-case 888
|
||||||
|
// numbers have a head start, and the range is guaranteed to never collide
|
||||||
|
// with a real assigned country code.
|
||||||
|
const EmailPhonePrefix = "888"
|
||||||
|
|
||||||
|
// MaxEmailSignupPhoneLen mirrors the users.phone column width (see migration
|
||||||
|
// 0087) and ValidPhone's upper bound.
|
||||||
|
const MaxEmailSignupPhoneLen = 200
|
||||||
|
|
||||||
|
// EncodeEmailPhone deterministically and reversibly encodes an email address
|
||||||
|
// into a synthetic "888"-prefixed all-digit phone number: the email's
|
||||||
|
// lowercased/trimmed UTF-8 bytes, read as a big-endian unsigned integer, then
|
||||||
|
// printed in decimal. This lets the existing phone-based sendCode/signUp/
|
||||||
|
// signIn/changePhone flow carry an email address end to end unchanged — no
|
||||||
|
// new TL constructors, no server-side reverse-lookup table required.
|
||||||
|
//
|
||||||
|
// ok is false if email is empty/invalid or the encoded result would not fit
|
||||||
|
// the users.phone column (MaxEmailSignupPhoneLen) — this comfortably covers
|
||||||
|
// realistic email addresses (roughly up to 80 bytes).
|
||||||
|
func EncodeEmailPhone(email string) (phone string, ok bool) {
|
||||||
|
normalized := NormalizeEmailForPhone(email)
|
||||||
|
if normalized == "" || !strings.Contains(normalized, "@") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
n := new(big.Int).SetBytes([]byte(normalized))
|
||||||
|
digits := n.String()
|
||||||
|
phone = EmailPhonePrefix + digits
|
||||||
|
if len(phone) > MaxEmailSignupPhoneLen {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return phone, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeEmailPhone reverses EncodeEmailPhone. ok is false if phone does not
|
||||||
|
// carry the "888" prefix or does not decode to a plausible email address.
|
||||||
|
func DecodeEmailPhone(phone string) (email string, ok bool) {
|
||||||
|
phone = NormalizePhone(strings.TrimSpace(phone))
|
||||||
|
digits, found := strings.CutPrefix(phone, EmailPhonePrefix)
|
||||||
|
if !found || digits == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
n, valid := new(big.Int).SetString(digits, 10)
|
||||||
|
if !valid {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
decoded := string(n.Bytes())
|
||||||
|
if decoded == "" || !strings.Contains(decoded, "@") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return decoded, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeEmailForPhone lowercases and trims an email so the same address
|
||||||
|
// always encodes to the same synthetic phone number regardless of how the
|
||||||
|
// user typed it (e.g. on a different device).
|
||||||
|
func NormalizeEmailForPhone(email string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(email))
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmailSignupPhone reports whether phone was produced by EncodeEmailPhone
|
||||||
|
// (i.e. carries the synthetic "888" prefix), without decoding it.
|
||||||
|
func IsEmailSignupPhone(phone string) bool {
|
||||||
|
return strings.HasPrefix(NormalizePhone(strings.TrimSpace(phone)), EmailPhonePrefix)
|
||||||
|
}
|
||||||
64
internal/domain/emailphone_test.go
Normal file
64
internal/domain/emailphone_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEncodeDecodeEmailPhoneRoundTrip(t *testing.T) {
|
||||||
|
for _, email := range []string{
|
||||||
|
"onysd@owpengram.local",
|
||||||
|
"a@b.co",
|
||||||
|
"User.Name+Tag@Example.COM",
|
||||||
|
"very.long.email.address.for.testing.purposes@some-long-domain-name.example.com",
|
||||||
|
} {
|
||||||
|
t.Run(email, func(t *testing.T) {
|
||||||
|
phone, ok := EncodeEmailPhone(email)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("EncodeEmailPhone(%q) ok=false", email)
|
||||||
|
}
|
||||||
|
if len(phone) > MaxEmailSignupPhoneLen {
|
||||||
|
t.Fatalf("encoded phone too long: %d chars", len(phone))
|
||||||
|
}
|
||||||
|
if !ValidPhone(phone) {
|
||||||
|
t.Fatalf("encoded phone %q fails ValidPhone", phone)
|
||||||
|
}
|
||||||
|
if !IsEmailSignupPhone(phone) {
|
||||||
|
t.Fatalf("IsEmailSignupPhone(%q) = false, want true", phone)
|
||||||
|
}
|
||||||
|
decoded, ok := DecodeEmailPhone(phone)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("DecodeEmailPhone(%q) ok=false", phone)
|
||||||
|
}
|
||||||
|
want := NormalizeEmailForPhone(email)
|
||||||
|
if decoded != want {
|
||||||
|
t.Fatalf("decoded = %q, want %q", decoded, want)
|
||||||
|
}
|
||||||
|
t.Logf("%q -> %q (%d chars) -> %q", email, phone, len(phone), decoded)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeEmailPhoneCaseAndWhitespaceNormalize(t *testing.T) {
|
||||||
|
p1, ok1 := EncodeEmailPhone("User@Example.com")
|
||||||
|
p2, ok2 := EncodeEmailPhone(" user@example.com ")
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
t.Fatalf("ok1=%v ok2=%v", ok1, ok2)
|
||||||
|
}
|
||||||
|
if p1 != p2 {
|
||||||
|
t.Fatalf("case/whitespace variants encoded differently: %q vs %q", p1, p2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeEmailPhoneRejectsInvalid(t *testing.T) {
|
||||||
|
for _, email := range []string{"", " ", "not-an-email", "@"} {
|
||||||
|
if _, ok := EncodeEmailPhone(email); ok && email != "@" {
|
||||||
|
t.Fatalf("EncodeEmailPhone(%q) ok=true, want false", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeEmailPhoneRejectsNonEmailNumbers(t *testing.T) {
|
||||||
|
for _, phone := range []string{"", "15550001234", "888", "88799999"} {
|
||||||
|
if _, ok := DecodeEmailPhone(phone); ok {
|
||||||
|
t.Fatalf("DecodeEmailPhone(%q) ok=true, want false", phone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,9 @@ func (r *Router) onAccountSendChangePhoneCode(ctx context.Context, req *tg.Accou
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, phoneChangeErr(err)
|
return nil, phoneChangeErr(err)
|
||||||
}
|
}
|
||||||
|
if delivery.Kind == domain.AuthCodeDeliveryEmail {
|
||||||
|
return tgEmailSentCode(hash, delivery.EmailPattern, delivery.Length), nil
|
||||||
|
}
|
||||||
return tgSMSSentCode(hash, delivery.Length), nil
|
return tgSMSSentCode(hash, delivery.Length), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue