new message on login
This commit is contained in:
parent
3d9b0f1de7
commit
cee960fea0
9 changed files with 492 additions and 90 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
168
internal/app/auth/welcome_message_test.go
Normal file
168
internal/app/auth/welcome_message_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
40
internal/domain/welcome_message.go
Normal file
40
internal/domain/welcome_message.go
Normal file
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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) ||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue