changes for email signup

This commit is contained in:
onysd 2026-07-13 20:09:57 +03:00
parent 0fbfc8cd71
commit 3d9b0f1de7
15 changed files with 469 additions and 27 deletions

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

View file

@ -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 {

View file

@ -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{

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

View file

@ -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 {

View file

@ -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