From 3d9b0f1de7e0dfdc668b1cae410ab360cc31770d Mon Sep 17 00:00:00 2001 From: onysd Date: Mon, 13 Jul 2026 20:09:57 +0300 Subject: [PATCH] changes for email signup --- .env.example | 11 +++ cmd/telesrv/main.go | 8 +- ...0087_widen_phone_for_email_signup.down.sql | 1 + .../0087_widen_phone_for_email_signup.up.sql | 8 ++ .../account/email_signup_phone_change_test.go | 93 +++++++++++++++++++ internal/app/account/phone_change.go | 53 ++++++++++- internal/app/account/service.go | 11 +++ internal/app/auth/email_signup_test.go | 84 +++++++++++++++++ internal/app/auth/service.go | 18 ++++ internal/app/help/service.go | 49 ++++++---- internal/config/config.go | 12 ++- internal/domain/account.go | 7 +- internal/domain/emailphone.go | 74 +++++++++++++++ internal/domain/emailphone_test.go | 64 +++++++++++++ internal/rpc/account_phone.go | 3 + 15 files changed, 469 insertions(+), 27 deletions(-) create mode 100644 deploy/migrations/0087_widen_phone_for_email_signup.down.sql create mode 100644 deploy/migrations/0087_widen_phone_for_email_signup.up.sql create mode 100644 internal/app/account/email_signup_phone_change_test.go create mode 100644 internal/app/auth/email_signup_test.go create mode 100644 internal/domain/emailphone.go create mode 100644 internal/domain/emailphone_test.go diff --git a/.env.example b/.env.example index 2486e0b8..680759e4 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,17 @@ TELESRV_SMTP_FROM_NAME=telesrv TELESRV_SMTP_TLS=starttls 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. # Production uses https://telesrv.net. For local link/deeplink smoke tests use # http://127.0.0.1:2401. Invalid schemes, credentials, query strings, fragments, diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 720fabf4..002cd6b8 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -531,9 +531,10 @@ func run(logger *zap.Logger) error { account.WithUsers(userStore), account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts), account.WithPublicBaseURL(cfg.PublicBaseURL), + account.WithEmailSignup(cfg.EmailSignupEnable), } var loginEmailSender mailpkg.Sender - if cfg.LoginEmailEnable { + if cfg.LoginEmailEnable || cfg.EmailSignupEnable { loginEmailSender = mailpkg.NewSMTP(mailpkg.Config{ Host: cfg.SMTPHost, Port: cfg.SMTPPort, @@ -705,7 +706,8 @@ func run(logger *zap.Logger) error { CodeLength: cfg.LoginEmailCodeLength, Store: accountService, Sender: loginEmailSender, - })) + }), + auth.WithEmailSignup(cfg.EmailSignupEnable)) updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates"))) router := rpc.New(rpc.Config{ DC: cfg.DC, @@ -733,7 +735,7 @@ func run(logger *zap.Logger) error { Auth: authService, Account: accountService, 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, Users: usersService, Updates: updatesService, diff --git a/deploy/migrations/0087_widen_phone_for_email_signup.down.sql b/deploy/migrations/0087_widen_phone_for_email_signup.down.sql new file mode 100644 index 00000000..e1e7cbd3 --- /dev/null +++ b/deploy/migrations/0087_widen_phone_for_email_signup.down.sql @@ -0,0 +1 @@ +ALTER TABLE public.users ALTER COLUMN phone TYPE character varying(32); diff --git a/deploy/migrations/0087_widen_phone_for_email_signup.up.sql b/deploy/migrations/0087_widen_phone_for_email_signup.up.sql new file mode 100644 index 00000000..72350b1c --- /dev/null +++ b/deploy/migrations/0087_widen_phone_for_email_signup.up.sql @@ -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); diff --git a/internal/app/account/email_signup_phone_change_test.go b/internal/app/account/email_signup_phone_change_test.go new file mode 100644 index 00000000..159d0937 --- /dev/null +++ b/internal/app/account/email_signup_phone_change_test.go @@ -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) + } +} diff --git a/internal/app/account/phone_change.go b/internal/app/account/phone_change.go index f69015dc..292cb7f1 100644 --- a/internal/app/account/phone_change.go +++ b/internal/app/account/phone_change.go @@ -42,7 +42,17 @@ 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 { + 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") } 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 } +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 的 // 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) { @@ -107,7 +155,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 { + channelOK := consumed.Channel == store.PhoneCodeChannelPhone || consumed.Channel == store.PhoneCodeChannelEmailLogin + if consumed.Version != store.PhoneCodeVersionCurrent || consumed.Scope() != scope || !channelOK { return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid } if date == 0 { diff --git a/internal/app/account/service.go b/internal/app/account/service.go index 78ba8a2a..0cd4dbd9 100644 --- a/internal/app/account/service.go +++ b/internal/app/account/service.go @@ -51,6 +51,10 @@ type Service struct { loginEmailCodeTTL time.Duration loginEmailCodeMaxAttempts int loginEmailCodeLength int + // emailSignupEnabled 打开后,SendChangePhoneCode 只接受 888 前缀(邮箱编码)目标号码, + // 验证码走 loginEmailSender 发到解码出的邮箱;非 888 号码一律拒绝——本服务器没有真实 + // 短信通道,放行会让账号的邮箱身份绑定被绕过(见迁移前的密码找回验证码漏洞教训)。 + emailSignupEnabled bool } // 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 服务。 func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service { s := &Service{ diff --git a/internal/app/auth/email_signup_test.go b/internal/app/auth/email_signup_test.go new file mode 100644 index 00000000..aecefff6 --- /dev/null +++ b/internal/app/auth/email_signup_test.go @@ -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") + } +} diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index 5c4fd2ad..ba5f8652 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -86,6 +86,10 @@ type Service struct { loginEmailEnabled bool loginEmailRequireSetup bool loginEmailCodeLength int + // emailSignupEnabled 打开后,888 前缀的合成号码(domain.EncodeEmailPhone) + // 在 sendCode 阶段直接解码出邮箱、复用登录邮箱同一投递通道发码,与 + // loginEmailEnabled(手机号账号的第二验证渠道)是两条独立开关。 + emailSignupEnabled bool // premiumGrantMonths 是新注册账号默认赠送的会员月数;0 表示关闭赠送。 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 为开发固定验证码。 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} @@ -302,6 +315,11 @@ func (s *Service) SendCode(ctx context.Context, phone string) (string, error) { if found { 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 { email, found, err := s.loginEmails.LoginEmailByPhone(ctx, phone) if err != nil { diff --git a/internal/app/help/service.go b/internal/app/help/service.go index 4d201356..58decaa8 100644 --- a/internal/app/help/service.go +++ b/internal/app/help/service.go @@ -64,9 +64,10 @@ const defaultAppConfigHash = 23 // 默认 app config 内容变更时必须递增 // (登录页/启动配置是高频握手路径)。运维改库需重启生效。timezones/emoji 等其余目录走 // internal/seed/catalog(go:embed 一次解析),本就在内存。 type Service struct { - appConfigs store.AppConfigStore - countries store.CountryStore - mapboxToken string + appConfigs store.AppConfigStore + countries store.CountryStore + mapboxToken string + emailSignupEnable bool appConfigOnce sync.Once 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 服务。 func NewService(appConfigs store.AppConfigStore, countries store.CountryStore, opts ...Option) *Service { s := &Service{appConfigs: appConfigs, countries: countries} @@ -95,28 +104,36 @@ func NewService(appConfigs store.AppConfigStore, countries store.CountryStore, o return s } -func defaultAppConfig(mapboxToken string) domain.AppConfig { - jsonBytes := defaultAppConfigJSON(mapboxToken) - return domain.AppConfig{Client: tdesktopClient, Hash: defaultAppConfigHashFor(mapboxToken), JSON: jsonBytes} +func defaultAppConfig(mapboxToken string, emailSignupEnable bool) domain.AppConfig { + jsonBytes := defaultAppConfigJSON(mapboxToken, emailSignupEnable) + 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 == "" { - return []byte(tdesktopDefaultAppConfigBase + `}`) + return []byte(base + `}`) } token, err := json.Marshal(mapboxToken) if err != nil { - return []byte(tdesktopDefaultAppConfigBase + `}`) + return []byte(base + `}`) } 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 { - if mapboxToken == "" { - return defaultAppConfigHash +func defaultAppConfigHashFor(mapboxToken string, emailSignupEnable bool) int { + h := 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。首次调用加载一次后缓存。 @@ -127,9 +144,9 @@ func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, func (s *Service) loadAppConfig(ctx context.Context) domain.AppConfig { if s == nil { - return defaultAppConfig("") + return defaultAppConfig("", false) } - defaultCfg := defaultAppConfig(s.mapboxToken) + defaultCfg := defaultAppConfig(s.mapboxToken, s.emailSignupEnable) s.appConfigOnce.Do(func() { if s.appConfigs == nil { s.appConfigCache = defaultCfg diff --git a/internal/config/config.go b/internal/config/config.go index 1b639084..8ce2d8a1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -119,6 +119,11 @@ type Config struct { LoginEmailRequireSetup bool // LoginEmailCodeLength 是邮箱验证码长度。 LoginEmailCodeLength int + // EmailSignupEnable 启用「邮箱作为账号身份」模式:客户端用邮箱注册/登录,服务端把邮箱 + // 编码进一个 888 前缀的合成号码复用现有 phone 全流程(sendCode/signUp/signIn/changePhone + // 不变),验证码通过 SMTP 发到解码出的邮箱而非发短信。要求 SMTP 配置可用(与 + // LoginEmailEnable 共用同一组 TELESRV_SMTP_* 变量)。 + EmailSignupEnable bool // SMTP* 是登录邮箱验证码的出站邮件配置。LoginEmailEnable=true 时必须可用。 SMTPHost string SMTPPort int @@ -453,6 +458,7 @@ func Load() (Config, error) { AuthCodeRateWindow: envDurationOr("TELESRV_AUTH_CODE_RATE_WINDOW", 10*time.Minute), LoginEmailEnable: envBoolOr("TELESRV_LOGIN_EMAIL_ENABLE", false), LoginEmailRequireSetup: envBoolOr("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP", false), + EmailSignupEnable: envBoolOr("TELESRV_EMAIL_SIGNUP_ENABLE", false), LoginEmailCodeLength: envIntOr("TELESRV_LOGIN_EMAIL_CODE_LENGTH", 6), SMTPHost: envOr("TELESRV_SMTP_HOST", ""), SMTPPort: envIntOr("TELESRV_SMTP_PORT", 587), @@ -582,17 +588,17 @@ func validateLoginEmailConfig(cfg Config) error { default: return fmt.Errorf("TELESRV_SMTP_TLS must be starttls, tls, or none") } - if !cfg.LoginEmailEnable { + if !cfg.LoginEmailEnable && !cfg.EmailSignupEnable { return nil } 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 { return fmt.Errorf("TELESRV_SMTP_PORT must be between 1 and 65535") } 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 { return fmt.Errorf("TELESRV_SMTP_TIMEOUT must be positive") diff --git a/internal/domain/account.go b/internal/domain/account.go index 9102e9a1..75a9e1f3 100644 --- a/internal/domain/account.go +++ b/internal/domain/account.go @@ -249,11 +249,12 @@ func NormalizePhone(phone string) string { return b.String() } -// ValidPhone 校验 NormalizePhone 后的持久化形态:5-32 位纯数字。 +// ValidPhone 校验 NormalizePhone 后的持久化形态:5-200 位纯数字。 // 上限与 users.phone 列宽一致;当前开发登录/改号链路不强制精确 E.164 长度, -// 但拒绝空串、非数字和会截断的超长输入。 +// 但拒绝空串、非数字和会截断的超长输入。上限从 32 放宽到 200 是为了容纳 +// EncodeEmailPhone 生成的 "888"+反向可解码大整数(真实手机号远用不到这个上限)。 func ValidPhone(phone string) bool { - if len(phone) < 5 || len(phone) > 32 { + if len(phone) < 5 || len(phone) > 200 { return false } for _, r := range phone { diff --git a/internal/domain/emailphone.go b/internal/domain/emailphone.go new file mode 100644 index 00000000..19740f4e --- /dev/null +++ b/internal/domain/emailphone.go @@ -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) +} diff --git a/internal/domain/emailphone_test.go b/internal/domain/emailphone_test.go new file mode 100644 index 00000000..b24a9903 --- /dev/null +++ b/internal/domain/emailphone_test.go @@ -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) + } + } +} diff --git a/internal/rpc/account_phone.go b/internal/rpc/account_phone.go index c1d6b296..d15c6472 100644 --- a/internal/rpc/account_phone.go +++ b/internal/rpc/account_phone.go @@ -35,6 +35,9 @@ func (r *Router) onAccountSendChangePhoneCode(ctx context.Context, req *tg.Accou if err != nil { return nil, phoneChangeErr(err) } + if delivery.Kind == domain.AuthCodeDeliveryEmail { + return tgEmailSentCode(hash, delivery.EmailPattern, delivery.Length), nil + } return tgSMSSentCode(hash, delivery.Length), nil }