From cee960fea0510aa47252898debca05a62caecffc Mon Sep 17 00:00:00 2001 From: onysd Date: Mon, 13 Jul 2026 23:09:11 +0300 Subject: [PATCH] new message on login --- internal/app/auth/email_signup_test.go | 35 +++++ internal/app/auth/service.go | 54 ++++++- internal/app/auth/service_test.go | 110 +++++++------- internal/app/auth/signup_state_test.go | 12 +- internal/app/auth/welcome_message_test.go | 168 ++++++++++++++++++++++ internal/domain/account.go | 21 ++- internal/domain/emailphone.go | 134 +++++++++++++---- internal/domain/welcome_message.go | 40 ++++++ internal/rpc/auth.go | 8 ++ 9 files changed, 492 insertions(+), 90 deletions(-) create mode 100644 internal/app/auth/welcome_message_test.go create mode 100644 internal/domain/welcome_message.go diff --git a/internal/app/auth/email_signup_test.go b/internal/app/auth/email_signup_test.go index aecefff6..d22b13a9 100644 --- a/internal/app/auth/email_signup_test.go +++ b/internal/app/auth/email_signup_test.go @@ -58,6 +58,41 @@ func TestEmailSignupSendCodeRoutesFreshSignupToEmail(t *testing.T) { } } +// Regression test: TELESRV_LOGIN_EMAIL_REQUIRE_SETUP=true (a real deployment +// combo, not just EMAIL_SIGNUP_ENABLE alone) used to permanently reject +// SignUp for every email-signup account with ErrCodeInvalid, because SignUp's +// "must have a verified/pending login email" gate only recognized the legacy +// VerifiedEmail/PendingEmail fields, which the email-signup path never sets. +func TestEmailSignupSignUpSucceedsWithLoginEmailRequireSetupAlsoOn(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, RequireSetup: true, Enabled: true}), + WithEmailSignup(true)) + + phone, ok := domain.EncodeEmailPhone("requiresetup@owpengram.local") + if !ok { + t.Fatalf("EncodeEmailPhone: ok=false") + } + + hash, err := svc.SendCode(ctx, phone) + if err != nil { + t.Fatalf("SendCode: %v", err) + } + if _, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{}, phone, hash, sender.code); err != nil || !needSignUp { + t.Fatalf("SignInWithEmail: needSignUp=%v err=%v", needSignUp, err) + } + created, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Needs", "Setup") + if err != nil { + t.Fatalf("SignUp: %v (this is the loop bug if it fails with ErrCodeInvalid)", 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() diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index ba5f8652..c603f4b0 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -291,7 +291,17 @@ func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte) if s == nil || s.auths == nil { return nil } - return s.auths.MarkPasswordPassed(ctx, authKeyID) + if err := s.auths.MarkPasswordPassed(ctx, authKeyID); err != nil { + return err + } + // This is where a 2FA account's sign-in actually finishes — finishSignIn + // deliberately skipped the welcome message while password_pending. + if a, found, err := s.auths.ByAuthKey(ctx, authKeyID); err == nil && found { + if u, found, err := s.users.ByID(ctx, a.UserID); err == nil && found { + s.recordWelcomeMessage(ctx, u.ID, u.Phone) + } + } + return nil } // SendCode 为 phone 生成 phone_code_hash,按配置选择开发 app code、登录邮箱 code @@ -893,6 +903,10 @@ func (s *Service) finishSignIn(ctx context.Context, auth domain.Authorization, e if passwordNeeded { return existing, domain.Message{}, false, domain.ErrSessionPasswordNeeded } + // 2FA accounts only really finish authorizing in CompletePasswordSignIn; + // firing the welcome message here too would notify about an attempt that + // never actually got past the password check. + s.recordWelcomeMessage(ctx, existing.ID, existing.Phone) return existing, domain.Message{}, false, nil } @@ -936,7 +950,14 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, if rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin { return domain.User{}, domain.Message{}, ErrCodeInvalid } - if s.loginEmailRequireSetup && !rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) == "" { + // Email-signup accounts (888-encoded phone) already proved ownership of + // their email through the code they just entered — their whole identity + // is that email. The separate loginEmailRequireSetup gate exists to force + // a *phone*-based account to additionally configure a recovery/login + // email via the legacy VerifiedEmail/PendingEmail flow; it does not apply + // here and would otherwise permanently block SignUp for every + // email-signup account. + if s.loginEmailRequireSetup && !rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) == "" && !domain.IsEmailSignupPhone(phone) { return domain.User{}, domain.Message{}, ErrCodeInvalid } if current, currentFound, err := s.currentPhoneOwner(ctx, phone); err != nil { @@ -991,13 +1012,16 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, } 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. + // the normal phone/app-code registration path creates the bootstrap dialog + // carrying the actual code; every account additionally gets the + // welcome message below regardless of channel. if rec.Channel == codeChannelPhone { loginMessage, err = s.recordLoginMessage(ctx, u.ID, rec.Code) if err != nil { return domain.User{}, domain.Message{}, err } } + s.recordWelcomeMessage(ctx, u.ID, phone) return u, loginMessage, nil } @@ -1300,6 +1324,30 @@ func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code str return msg, nil } +// recordWelcomeMessage writes the unconditional "Welcome to OwpenGram!" +// 777000 message for every completed sign-in (SignUp and every subsequent +// SignIn/SignInWithEmail), regardless of channel. Best-effort: a failure here +// must never fail the sign-in itself, since unlike recordLoginMessage it +// carries no secret the caller needs. +func (s *Service) recordWelcomeMessage(ctx context.Context, userID int64, phone string) { + if s == nil || s.messages == nil || s.dialogs == nil { + return + } + msg, err := domain.OfficialWelcomeMessage(userID, domain.SignInMethodLabel(phone), int(time.Now().Unix())) + if err != nil { + return + } + created, err := s.messages.Create(ctx, msg) + if err != nil { + return + } + _ = s.dialogs.UpsertInbox(ctx, userID, domain.Dialog{ + Peer: created.Peer, + TopMessage: created.ID, + TopMessageDate: created.Date, + }) +} + func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, error) { if binding.ExpiresAt <= int(time.Now().Unix()) { return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid diff --git a/internal/app/auth/service_test.go b/internal/app/auth/service_test.go index f8016554..b8d114f5 100644 --- a/internal/app/auth/service_test.go +++ b/internal/app/auth/service_test.go @@ -490,11 +490,15 @@ func TestSignUpWritesOfficialLoginMessage(t *testing.T) { if msg.ID == 0 || !strings.Contains(msg.Body, "Login code: 12345") { t.Fatalf("login message = %+v, want returned official login code message", msg) } - if len(list.Messages) != 1 || !strings.Contains(list.Messages[0].Body, "Login code: 12345") { - t.Fatalf("messages = %+v, want login code message", list.Messages) + // SignUp now also writes an unconditional welcome message alongside the + // phone channel's login-code message, created after it — so it becomes + // the dialog's new top message (ListByUser surfaces the top message per + // dialog, not full history). + if len(list.Messages) != 1 || !strings.Contains(list.Messages[0].Body, "Welcome to OwpenGram") { + t.Fatalf("messages = %+v, want welcome message as new dialog top message", list.Messages) } - if list.Dialogs[0].TopMessage != list.Messages[0].ID || list.Dialogs[0].UnreadCount != 1 { - t.Fatalf("dialog top/unread = %+v, message = %+v", list.Dialogs[0], list.Messages[0]) + if list.Dialogs[0].TopMessage != list.Messages[0].ID || list.Dialogs[0].UnreadCount != 2 { + t.Fatalf("dialog top/unread = %+v, message = %+v, want unread=2 (code + welcome)", list.Dialogs[0], list.Messages[0]) } } @@ -509,85 +513,83 @@ func TestSendCodeLoginMessagePreservesOfficialDialogReadWatermark(t *testing.T) WithLoginCodeDelivery(delivery), ) phone := "+15550004312" + peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID} hash, err := svc.SendCode(ctx, phone) if err != nil { t.Fatalf("SendCode signup: %v", err) } verifyCodeForSignUp(t, svc, phone, hash, "12345") - u, first, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Test", "User") + u, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Test", "User") if err != nil { t.Fatalf("SignUp: %v", err) } - peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID} - if read, err := dialogs.MarkRead(ctx, u.ID, peer, domain.MaxMessageBoxID); err != nil { - t.Fatalf("MarkRead first login message: %v", err) - } else if read.MaxID != first.ID || read.StillUnreadCount != 0 { - t.Fatalf("read first login message = %+v, want max_id %d unread 0", read, first.ID) - } - assertOfficialDialog := func(wantTop, wantRead, wantUnread int) { + + dialogState := func() domain.Dialog { t.Helper() list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10}) - if err != nil { - t.Fatalf("ListByUser: %v", err) + if err != nil || len(list.Dialogs) != 1 { + t.Fatalf("ListByUser: dialogs=%+v err=%v, want exactly one official dialog", list.Dialogs, err) } - if len(list.Dialogs) != 1 { - t.Fatalf("dialogs = %+v, want official dialog", list.Dialogs) - } - got := list.Dialogs[0] - if got.TopMessage != wantTop || got.ReadInboxMaxID != wantRead || got.UnreadCount != wantUnread { - t.Fatalf("dialog = %+v, want top=%d read=%d unread=%d", got, wantTop, wantRead, wantUnread) - } - } - latestLoginMessage := func(wantCount int) domain.Message { - t.Helper() - history, err := messages.ListByUser(ctx, u.ID, domain.MessageFilter{ - HasPeer: true, - Peer: peer, - Limit: 10, - }) - if err != nil || len(history.Messages) != wantCount { - t.Fatalf("official history count=%d err=%v, want %d", len(history.Messages), err, wantCount) - } - latest := history.Messages[0] - for _, msg := range history.Messages[1:] { - if msg.ID > latest.ID { - latest = msg - } - } - return latest + return list.Dialogs[0] } + // SignUp writes the code-echo message, then the unconditional welcome + // message: two unread messages, dialog top is the welcome message. + afterSignUp := dialogState() + if afterSignUp.UnreadCount != 2 { + t.Fatalf("dialog after SignUp = %+v, want 2 unread (code + welcome)", afterSignUp) + } + readWatermark := afterSignUp.TopMessage + if read, err := dialogs.MarkRead(ctx, u.ID, peer, domain.MaxMessageBoxID); err != nil { + t.Fatalf("MarkRead after SignUp: %v", err) + } else if read.MaxID != readWatermark || read.StillUnreadCount != 0 { + t.Fatalf("read after SignUp = %+v, want max_id %d unread 0", read, readWatermark) + } + + // A repeat SendCode on an existing account delivers a new code message + // before SignIn even runs. This must not reset the read watermark just + // established above — only the fresh message should count as unread. hash, err = svc.SendCode(ctx, phone) if err != nil { t.Fatalf("SendCode signin second: %v", err) } - second := latestLoginMessage(2) - // 核心时序:SendCode 返回时 message/dialog/unread 已提交,尚未 SignIn。 - assertOfficialDialog(second.ID, first.ID, 1) - _, signInMessage, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345") - if err != nil || needSignUp { - t.Fatalf("SignIn second needSignUp=%v err=%v", needSignUp, err) + afterSecondSendCode := dialogState() + if afterSecondSendCode.ReadInboxMaxID != readWatermark || afterSecondSendCode.UnreadCount != 1 { + t.Fatalf("dialog after second SendCode = %+v, want read=%d unread=1", afterSecondSendCode, readWatermark) } - if signInMessage.ID != 0 { + + // Completing SignIn adds its own welcome message (a second, independent + // source of new messages) without touching the read watermark either. + if _, signInMessage, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); err != nil || needSignUp { + t.Fatalf("SignIn second needSignUp=%v err=%v", needSignUp, err) + } else if signInMessage.ID != 0 { t.Fatalf("SignIn second returned a late login message %+v", signInMessage) } - assertOfficialDialog(second.ID, first.ID, 1) + afterSecondSignIn := dialogState() + if afterSecondSignIn.ReadInboxMaxID != readWatermark || afterSecondSignIn.UnreadCount != 2 { + t.Fatalf("dialog after second SignIn = %+v, want read=%d unread=2 (new code + its own welcome message)", afterSecondSignIn, readWatermark) + } + // One more full round trip to make sure the watermark keeps holding + // across repeated cycles, not just the first one. hash, err = svc.SendCode(ctx, phone) if err != nil { t.Fatalf("SendCode signin third: %v", err) } - third := latestLoginMessage(3) - assertOfficialDialog(third.ID, first.ID, 2) - _, signInMessage, needSignUp, err = svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345") - if err != nil || needSignUp { - t.Fatalf("SignIn third needSignUp=%v err=%v", needSignUp, err) + afterThirdSendCode := dialogState() + if afterThirdSendCode.ReadInboxMaxID != readWatermark || afterThirdSendCode.UnreadCount != 3 { + t.Fatalf("dialog after third SendCode = %+v, want read=%d unread=3", afterThirdSendCode, readWatermark) } - if signInMessage.ID != 0 { + if _, signInMessage, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); err != nil || needSignUp { + t.Fatalf("SignIn third needSignUp=%v err=%v", needSignUp, err) + } else if signInMessage.ID != 0 { t.Fatalf("SignIn third returned a late login message %+v", signInMessage) } - assertOfficialDialog(third.ID, first.ID, 2) + afterThirdSignIn := dialogState() + if afterThirdSignIn.ReadInboxMaxID != readWatermark || afterThirdSignIn.UnreadCount != 4 { + t.Fatalf("dialog after third SignIn = %+v, want read=%d unread=4", afterThirdSignIn, readWatermark) + } } func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) { diff --git a/internal/app/auth/signup_state_test.go b/internal/app/auth/signup_state_test.go index 1b369885..8ee5ab8b 100644 --- a/internal/app/auth/signup_state_test.go +++ b/internal/app/auth/signup_state_test.go @@ -3,6 +3,7 @@ package auth import ( "context" "errors" + "strings" "sync" "testing" "time" @@ -311,7 +312,7 @@ func TestOwnerTransferAwayAndBackCannotReviveLoginHash(t *testing.T) { }) } -func TestEmailSetupVerificationAuthorizesSignUpWithout777000Message(t *testing.T) { +func TestEmailSetupVerificationAuthorizesSignUpWithWelcomeMessageOnlyNoCodeEcho(t *testing.T) { ctx := context.Background() users := memory.NewUserStore() codes := memory.NewCodeStore() @@ -364,6 +365,11 @@ func TestEmailSetupVerificationAuthorizesSignUpWithout777000Message(t *testing.T if err != nil { t.Fatalf("SignUp after email setup: %v", err) } + // The SMTP setup code itself is still never echoed back as a 777000 + // message (it's a secret factor) — SignUp's own explicit return stays + // empty for the email channel. The unconditional welcome message (added + // for every completed sign-in, regardless of channel) is a separate, + // non-secret message verified below. if msg.ID != 0 || msg.Body != "" { t.Fatalf("email SignUp returned SMTP code message: %+v", msg) } @@ -371,8 +377,8 @@ func TestEmailSetupVerificationAuthorizesSignUpWithout777000Message(t *testing.T if err != nil { t.Fatalf("ListByUser: %v", err) } - if len(list.Dialogs) != 0 || len(list.Messages) != 0 { - t.Fatalf("email SignUp created 777000 bootstrap state: dialogs=%+v messages=%+v", list.Dialogs, list.Messages) + if len(list.Dialogs) != 1 || len(list.Messages) != 1 || !strings.Contains(list.Messages[0].Body, "Welcome to OwpenGram") { + t.Fatalf("email SignUp welcome message = dialogs=%+v messages=%+v, want exactly one welcome message", list.Dialogs, list.Messages) } if email, found, err := accountSvc.LoginEmailByPhone(ctx, phone); err != nil || !found || email != "new@example.test" { t.Fatalf("LoginEmailByPhone email=%q found=%v err=%v", email, found, err) diff --git a/internal/app/auth/welcome_message_test.go b/internal/app/auth/welcome_message_test.go new file mode 100644 index 00000000..118c8865 --- /dev/null +++ b/internal/app/auth/welcome_message_test.go @@ -0,0 +1,168 @@ +package auth + +import ( + "context" + "strings" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestEmailSignupSignUpWritesWelcomeMessageMentioningEmail(t *testing.T) { + ctx := context.Background() + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + sender := &testMailSender{} + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", + WithLoginMessages(messages, dialogs), + WithLoginEmail(LoginEmailOptions{Sender: sender}), + WithEmailSignup(true)) + + phone, ok := domain.EncodeEmailPhone("welcome@owpengram.local") + if !ok { + t.Fatalf("EncodeEmailPhone: ok=false") + } + hash, err := svc.SendCode(ctx, phone) + if err != nil { + t.Fatalf("SendCode: %v", err) + } + if _, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{}, phone, hash, sender.code); err != nil || !needSignUp { + t.Fatalf("SignInWithEmail: needSignUp=%v err=%v", needSignUp, err) + } + u, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Welcome", "User") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + + list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListByUser: %v", err) + } + if len(list.Messages) != 1 { + t.Fatalf("messages = %+v, want exactly the welcome message (email channel skips the code-echo message)", list.Messages) + } + if !strings.Contains(list.Messages[0].Body, "Welcome to OwpenGram") || !strings.Contains(list.Messages[0].Body, "via email") { + t.Fatalf("welcome message body = %q, want greeting mentioning email", list.Messages[0].Body) + } +} + +func TestSignInWritesWelcomeMessageOnEveryLogin(t *testing.T) { + ctx := context.Background() + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + delivery := memory.NewLoginCodeDeliveryStore(messages, memory.NewUpdateEventStore()) + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", + WithLoginMessages(messages, dialogs), + WithLoginCodeDelivery(delivery), + ) + var key [8]byte + key[0] = 42 + + hash, err := svc.SendCode(ctx, "+15550009911") + if err != nil { + t.Fatalf("SendCode signup: %v", err) + } + verifyCodeForSignUp(t, svc, "+15550009911", hash, "12345") + u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550009911", hash, "Repeat", "Login") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + if err := svc.LogOut(ctx, key); err != nil { + t.Fatalf("LogOut: %v", err) + } + + // A second, independent login (different device/session) must also get a + // fresh welcome message, not just the original SignUp. + hash, err = svc.SendCode(ctx, "+15550009911") + if err != nil { + t.Fatalf("SendCode signin: %v", err) + } + var key2 [8]byte + key2[0] = 43 + if _, _, _, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key2}, "+15550009911", hash, "12345"); err != nil { + t.Fatalf("SignIn: %v", err) + } + + list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListByUser: %v", err) + } + if len(list.Messages) != 1 || !strings.Contains(list.Messages[0].Body, "Welcome to OwpenGram") { + t.Fatalf("messages = %+v, want the second sign-in's fresh welcome message as new top message", list.Messages) + } + // SignUp's welcome + login-code message, plus SendCode's re-delivered code + // message, plus the second sign-in's welcome message. + if list.Dialogs[0].UnreadCount < 3 { + t.Fatalf("dialog unread = %+v, want at least 3 accumulated messages across both logins", list.Dialogs[0]) + } +} + +func TestTwoFactorSignInDefersWelcomeMessageUntilPasswordCompletes(t *testing.T) { + ctx := context.Background() + passwords := memory.NewPasswordStore() + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + delivery := memory.NewLoginCodeDeliveryStore(messages, memory.NewUpdateEventStore()) + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", + WithPasswords(passwords), + WithLoginMessages(messages, dialogs), + WithLoginCodeDelivery(delivery), + ) + var key [8]byte + key[0] = 9 + + hash, err := svc.SendCode(ctx, "+15550009922") + if err != nil { + t.Fatalf("SendCode signup: %v", err) + } + verifyCodeForSignUp(t, svc, "+15550009922", hash, "12345") + u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550009922", hash, "Two", "Factor") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + if err := svc.LogOut(ctx, key); err != nil { + t.Fatalf("LogOut: %v", err) + } + if err := passwords.Save(ctx, u.ID, domain.PasswordSettings{HasPassword: true}); err != nil { + t.Fatalf("save password settings: %v", err) + } + + afterSignUp, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListByUser after signup: %v", err) + } + unreadAfterSignUp := afterSignUp.Dialogs[0].UnreadCount + + hash, err = svc.SendCode(ctx, "+15550009922") + if err != nil { + t.Fatalf("SendCode signin: %v", err) + } + if _, _, _, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, "+15550009922", hash, "12345"); err == nil { + t.Fatalf("SignIn err = nil, want ErrSessionPasswordNeeded") + } + + // Still pending 2FA: no welcome message yet, only the re-delivered code. + pending, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListByUser pending: %v", err) + } + if strings.Contains(pending.Messages[0].Body, "Welcome to OwpenGram") { + t.Fatalf("welcome message fired before password check completed: %+v", pending.Messages[0]) + } + + if err := svc.CompletePasswordSignIn(ctx, key); err != nil { + t.Fatalf("CompletePasswordSignIn: %v", err) + } + + done, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListByUser done: %v", err) + } + if len(done.Messages) != 1 || !strings.Contains(done.Messages[0].Body, "Welcome to OwpenGram") { + t.Fatalf("messages after CompletePasswordSignIn = %+v, want fresh welcome message", done.Messages) + } + if done.Dialogs[0].UnreadCount <= unreadAfterSignUp { + t.Fatalf("unread did not grow after CompletePasswordSignIn: before=%d after=%d", unreadAfterSignUp, done.Dialogs[0].UnreadCount) + } +} diff --git a/internal/domain/account.go b/internal/domain/account.go index 75a9e1f3..96a63d4e 100644 --- a/internal/domain/account.go +++ b/internal/domain/account.go @@ -235,7 +235,14 @@ func MaskEmail(email string) string { // NormalizePhone 仅保留手机号中的数字(与 users.phone 的存储形态一致)。全部被过滤 // 掉时返回原串,便于上层做 validPhone 拒绝。auth/account 两域共用同一规则避免漂移。 +// +// Email-signup 合成号码(EncodeEmailPhone 生成,"888" 前缀 + 至少一个字母)是唯一例外: +// 原样保留(仅 lower+trim),不剥离字母——否则 DecodeEmailPhone 会因编码内容被剥空而 +// 永远解不出邮箱。真实手机号恒为纯数字,不含字母,故这个判定不会误伤任何真实号码。 func NormalizePhone(phone string) string { + if IsEmailSignupPhone(phone) { + return strings.ToLower(strings.TrimSpace(phone)) + } var b strings.Builder b.Grow(len(phone)) for _, r := range phone { @@ -249,14 +256,22 @@ func NormalizePhone(phone string) string { return b.String() } -// ValidPhone 校验 NormalizePhone 后的持久化形态:5-200 位纯数字。 +// ValidPhone 校验 NormalizePhone 后的持久化形态:真实手机号是 5-200 位纯数字; +// email-signup 合成号码额外允许小写字母(EncodeEmailPhone 的转义字符集)。 // 上限与 users.phone 列宽一致;当前开发登录/改号链路不强制精确 E.164 长度, -// 但拒绝空串、非数字和会截断的超长输入。上限从 32 放宽到 200 是为了容纳 -// EncodeEmailPhone 生成的 "888"+反向可解码大整数(真实手机号远用不到这个上限)。 +// 但拒绝空串、非法字符和会截断的超长输入。 func ValidPhone(phone string) bool { if len(phone) < 5 || len(phone) > 200 { return false } + if IsEmailSignupPhone(phone) { + for _, r := range phone { + if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') { + return false + } + } + return true + } for _, r := range phone { if r < '0' || r > '9' { return false diff --git a/internal/domain/emailphone.go b/internal/domain/emailphone.go index 19740f4e..23f92454 100644 --- a/internal/domain/emailphone.go +++ b/internal/domain/emailphone.go @@ -1,9 +1,6 @@ package domain -import ( - "math/big" - "strings" -) +import "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 @@ -17,24 +14,69 @@ const EmailPhonePrefix = "888" // 0087) and ValidPhone's upper bound. const MaxEmailSignupPhoneLen = 200 +// emailPhoneEscape marks a 2-character escape sequence standing in for one +// punctuation byte email addresses may contain but a "phone number" string +// otherwise can't (see NormalizePhone, which only preserves letters/digits +// for values recognized as email-signup phones). Every encoded value +// contains at least one 'q' (from the mandatory '@' escape), which is what +// lets IsEmailSignupPhone tell an encoded phone apart from a real, +// all-digit, "888"-area-code phone number without any extra bookkeeping. +const emailPhoneEscape = 'q' + +var emailPhoneEscapeEncode = map[rune]byte{ + '@': '0', + '.': '1', + '-': '2', + '_': '3', + '+': '4', + emailPhoneEscape: '5', +} + +var emailPhoneEscapeDecode = map[byte]rune{ + '0': '@', + '1': '.', + '2': '-', + '3': '_', + '4': '+', + '5': emailPhoneEscape, +} + // 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. +// into a synthetic "888"-prefixed phone-number-shaped string: letters and +// digits pass through unchanged, and the handful of punctuation characters +// real email addresses use are each replaced by a 2-character escape +// ('q' + a digit). This keeps the encoded length close to the email's own +// length (unlike a byte-for-byte big-integer encoding, which runs ~2.4x +// longer), while staying fully reversible with no server-side lookup table +// and no new TL constructors — the existing phone-based sendCode/signUp/ +// signIn/changePhone flow carries it end to end unchanged. // -// 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). +// ok is false if email is empty/invalid, contains a character outside +// [a-z0-9@._+-], or the encoded result would not fit the users.phone column +// (MaxEmailSignupPhoneLen). 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 + var b strings.Builder + b.Grow(len(normalized) * 2) + for _, r := range normalized { + switch { + case r >= 'a' && r <= 'z' && r != rune(emailPhoneEscape): + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + default: + digit, escapable := emailPhoneEscapeEncode[r] + if !escapable { + return "", false + } + b.WriteByte(emailPhoneEscape) + b.WriteByte(digit) + } + } + phone = EmailPhonePrefix + b.String() if len(phone) > MaxEmailSignupPhoneLen { return "", false } @@ -44,20 +86,46 @@ func EncodeEmailPhone(email string) (phone string, ok bool) { // 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 == "" { + lower := strings.ToLower(strings.TrimSpace(phone)) + body, found := strings.CutPrefix(lower, EmailPhonePrefix) + if !found || body == "" { return "", false } - n, valid := new(big.Int).SetString(digits, 10) - if !valid { + var b strings.Builder + b.Grow(len(body)) + runes := []rune(body) + for i := 0; i < len(runes); i++ { + r := runes[i] + if r != rune(emailPhoneEscape) { + b.WriteRune(r) + continue + } + i++ + if i >= len(runes) { + return "", false + } + digitByte, ok := asciiDigitByte(runes[i]) + if !ok { + return "", false + } + decoded, known := emailPhoneEscapeDecode[digitByte] + if !known { + return "", false + } + b.WriteRune(decoded) + } + email = b.String() + if email == "" || !strings.Contains(email, "@") { return "", false } - decoded := string(n.Bytes()) - if decoded == "" || !strings.Contains(decoded, "@") { - return "", false + return email, true +} + +func asciiDigitByte(r rune) (byte, bool) { + if r < '0' || r > '9' { + return 0, false } - return decoded, true + return byte(r), true } // NormalizeEmailForPhone lowercases and trims an email so the same address @@ -67,8 +135,20 @@ 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. +// IsEmailSignupPhone reports whether phone was produced by EncodeEmailPhone. +// Every encoded value contains at least one letter (the mandatory '@' +// escape's 'q' marker byte), which real, all-digit phone numbers — even +// ones that happen to start with the 888 area code — never do; this keeps +// the check unambiguous without any extra prefix bookkeeping. func IsEmailSignupPhone(phone string) bool { - return strings.HasPrefix(NormalizePhone(strings.TrimSpace(phone)), EmailPhonePrefix) + lower := strings.ToLower(strings.TrimSpace(phone)) + if !strings.HasPrefix(lower, EmailPhonePrefix) { + return false + } + for _, r := range lower { + if r >= 'a' && r <= 'z' { + return true + } + } + return false } diff --git a/internal/domain/welcome_message.go b/internal/domain/welcome_message.go new file mode 100644 index 00000000..d6a912bf --- /dev/null +++ b/internal/domain/welcome_message.go @@ -0,0 +1,40 @@ +package domain + +import ( + "fmt" + "math" + "strings" +) + +const officialWelcomeMessageTemplate = "👋 Welcome to OwpenGram!\n\nYou just signed in via %s.\n\nIf this wasn't you, revoke this session from Settings → Devices immediately." + +// OfficialWelcomeMessage builds the account-visible incoming message sent +// from the official system account on every completed sign-in (SignUp and +// every subsequent SignIn/SignInWithEmail), regardless of delivery channel. +// Unlike OfficialLoginCodeMessage this never embeds a secret, so it is safe +// to send unconditionally — it exists to give the account owner (and, on a +// self-hosted single-admin server, that's usually also "the admin") a +// visible record of every session start. +func OfficialWelcomeMessage(userID int64, method string, date int) (Message, error) { + method = strings.TrimSpace(method) + if userID <= 0 || IsSystemUserID(userID) || method == "" || date < 0 || date > math.MaxInt32 { + return Message{}, fmt.Errorf("%w: user=%d method=%q date=%d", ErrLoginCodeDeliveryInvalid, userID, method, date) + } + return Message{ + OwnerUserID: userID, + Peer: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID}, + From: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID}, + Date: date, + Body: fmt.Sprintf(officialWelcomeMessageTemplate, method), + }, nil +} + +// SignInMethodLabel returns the human-readable method name embedded in +// OfficialWelcomeMessage, derived from whether phone is an email-signup +// synthetic number (see EncodeEmailPhone) or a real phone number. +func SignInMethodLabel(phone string) string { + if IsEmailSignupPhone(phone) { + return "email" + } + return "phone number" +} diff --git a/internal/rpc/auth.go b/internal/rpc/auth.go index efd3b971..ecf76c34 100644 --- a/internal/rpc/auth.go +++ b/internal/rpc/auth.go @@ -228,6 +228,14 @@ func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest return nil, err } r.rememberClientAPIID(ctx, req.APIID) + if r.log != nil { + normalized := domain.NormalizePhone(req.PhoneNumber) + _, decodeOK := domain.DecodeEmailPhone(normalized) + r.log.Info("auth.sendCode phone diagnostics", + zap.Int("phone_len", len(normalized)), + zap.Bool("looks_like_email_signup", domain.IsEmailSignupPhone(normalized)), + zap.Bool("decodes_to_email", decodeOK)) + } hash, err := r.deps.Auth.SendCode(ctx, req.PhoneNumber) if err != nil { if errors.Is(err, auth.ErrPhoneNumberInvalid) ||