feat: sync login email verification support

This commit is contained in:
A 2026-07-08 17:09:44 +08:00
parent e0cabb4930
commit 9a501f900a
39 changed files with 2198 additions and 117 deletions

View file

@ -4,8 +4,10 @@ import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
@ -25,6 +27,17 @@ func createUser(t *testing.T, users *memory.UserStore, phone string) domain.User
return u
}
type captureMailSender struct {
to string
code string
}
func (s *captureMailSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error {
s.to = to
s.code = code
return nil
}
// TestSetLoginEmailPersistsAndMasks 设置登录邮箱后GetPassword 下发掩码 pattern原始
// 地址只在 LoginEmail 读路径可见。
func TestSetLoginEmailPersistsAndMasks(t *testing.T) {
@ -88,6 +101,24 @@ func TestSetLoginEmailRejectsInvalid(t *testing.T) {
}
}
func TestSetLoginEmailRejectsDuplicateCaseInsensitive(t *testing.T) {
ctx := context.Background()
svc, users := newLoginEmailService(t)
u1 := createUser(t, users, "15550010103")
u2 := createUser(t, users, "15550010104")
if err := svc.SetLoginEmail(ctx, u1.ID, "Alice@Example.Test"); err != nil {
t.Fatalf("SetLoginEmail user1: %v", err)
}
if err := svc.SetLoginEmail(ctx, u2.ID, "alice@example.test"); !errors.Is(err, domain.ErrEmailOccupied) {
t.Fatalf("SetLoginEmail duplicate err = %v, want ErrEmailOccupied", err)
}
email, found, err := svc.LoginEmail(ctx, u1.ID)
if err != nil || !found || email != "alice@example.test" {
t.Fatalf("LoginEmail user1 = %q found=%v err=%v", email, found, err)
}
}
// TestRecoveryEmailDoesNotLeakIntoLoginEmailPattern 是核心解耦回归:设置 2FA 恢复邮箱
// 不得把恢复邮箱掩码写进 login_email_pattern历史 bug
func TestRecoveryEmailDoesNotLeakIntoLoginEmailPattern(t *testing.T) {
@ -114,3 +145,149 @@ func TestRecoveryEmailDoesNotLeakIntoLoginEmailPattern(t *testing.T) {
t.Fatal("HasRecovery = false, want true after setting recovery email")
}
}
func TestSendLoginEmailCodeRejectsDuplicateBeforeSending(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
codes := memory.NewCodeStore()
passwords := memory.NewPasswordStore()
sender := &captureMailSender{}
svc := NewService(passwords,
WithUsers(users),
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
u1 := createUser(t, users, "15550010105")
u2 := createUser(t, users, "15550010106")
if err := svc.SetLoginEmail(ctx, u1.ID, "taken@example.test"); err != nil {
t.Fatalf("SetLoginEmail user1: %v", err)
}
if _, _, err := svc.SendLoginEmailCode(ctx, u2.ID, "", "", "TAKEN@example.test", false); !errors.Is(err, domain.ErrEmailOccupied) {
t.Fatalf("SendLoginEmailCode duplicate err = %v, want ErrEmailOccupied", err)
}
if sender.to != "" || sender.code != "" {
t.Fatalf("duplicate email sent to=%q code=%q, want no send", sender.to, sender.code)
}
}
func TestLoginEmailSetupRejectsAlreadyOwnedEmailForNewPhone(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
codes := memory.NewCodeStore()
passwords := memory.NewPasswordStore()
sender := &captureMailSender{}
svc := NewService(passwords,
WithUsers(users),
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
owner := createUser(t, users, "15550010107")
if err := svc.SetLoginEmail(ctx, owner.ID, "owner@example.test"); err != nil {
t.Fatalf("SetLoginEmail owner: %v", err)
}
if err := codes.Set(ctx, "new-phone-hash", store.PhoneCode{Phone: "15550010108", Channel: "email_setup_required", MaxAttempts: 2}, time.Minute); err != nil {
t.Fatalf("seed phone code: %v", err)
}
if _, _, err := svc.SendLoginEmailCode(ctx, 0, "+1 555 001 0108", "new-phone-hash", "OWNER@example.test", true); !errors.Is(err, domain.ErrEmailOccupied) {
t.Fatalf("setup duplicate email err = %v, want ErrEmailOccupied", err)
}
if sender.to != "" || sender.code != "" {
t.Fatalf("duplicate setup email sent to=%q code=%q, want no send", sender.to, sender.code)
}
}
func TestSendVerifyLoginEmailPersistsOnlyAfterVerify(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
codes := memory.NewCodeStore()
sender := &captureMailSender{}
svc := NewService(memory.NewPasswordStore(),
WithUsers(users),
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
u := createUser(t, users, "15550010005")
pattern, length, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "alice@example.test", false)
if err != nil {
t.Fatalf("SendLoginEmailCode: %v", err)
}
if pattern != "a***e@example.test" || length != 6 || sender.to != "alice@example.test" || len(sender.code) != 6 {
t.Fatalf("send result pattern=%q length=%d to=%q code=%q", pattern, length, sender.to, sender.code)
}
if _, found, err := svc.LoginEmail(ctx, u.ID); err != nil || found {
t.Fatalf("LoginEmail before verify found=%v err=%v, want not found", found, err)
}
email, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", sender.code, false)
if err != nil {
t.Fatalf("VerifyLoginEmail: %v", err)
}
if email != "alice@example.test" {
t.Fatalf("verified email = %q", email)
}
got, found, err := svc.LoginEmail(ctx, u.ID)
if err != nil || !found || got != "alice@example.test" {
t.Fatalf("LoginEmail after verify = %q found=%v err=%v", got, found, err)
}
}
func TestLoginEmailSetupStoresPendingEmailOnPhoneCodeHash(t *testing.T) {
ctx := context.Background()
codes := memory.NewCodeStore()
sender := &captureMailSender{}
svc := NewService(memory.NewPasswordStore(),
WithUsers(memory.NewUserStore()),
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
if err := codes.Set(ctx, "phone-hash", store.PhoneCode{Phone: "15550010006", Channel: "email_setup_required", MaxAttempts: 2}, time.Minute); err != nil {
t.Fatalf("seed phone code: %v", err)
}
if _, _, err := svc.SendLoginEmailCode(ctx, 0, "+1 555 001 0006", "phone-hash", "new@example.test", true); err != nil {
t.Fatalf("SendLoginEmailCode setup: %v", err)
}
email, err := svc.VerifyLoginEmail(ctx, 0, "+1 555 001 0006", "phone-hash", sender.code, true)
if err != nil {
t.Fatalf("VerifyLoginEmail setup: %v", err)
}
if email != "new@example.test" {
t.Fatalf("verified setup email = %q", email)
}
rec, found, err := codes.Get(ctx, "phone-hash")
if err != nil || !found {
t.Fatalf("phone code found=%v err=%v", found, err)
}
if rec.Channel != "email_login" || rec.Code != sender.code || rec.Email != "new@example.test" || !rec.VerifiedEmail || rec.PendingEmail != "new@example.test" {
t.Fatalf("phone code after verify = %+v", rec)
}
}
func TestVerifyLoginEmailDeletesCodeAfterMaxAttempts(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
codes := memory.NewCodeStore()
sender := &captureMailSender{}
svc := NewService(memory.NewPasswordStore(),
WithUsers(users),
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
u := createUser(t, users, "15550010007")
if _, _, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "limit@example.test", false); err != nil {
t.Fatalf("SendLoginEmailCode: %v", err)
}
bad1 := "000000"
if bad1 == sender.code {
bad1 = "111111"
}
bad2 := "222222"
if bad2 == sender.code {
bad2 = "333333"
}
if _, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", bad1, false); !errors.Is(err, domain.ErrEmailCodeInvalid) {
t.Fatalf("first bad VerifyLoginEmail err = %v, want ErrEmailCodeInvalid", err)
}
if _, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", bad2, false); !errors.Is(err, domain.ErrEmailCodeInvalid) {
t.Fatalf("second bad VerifyLoginEmail err = %v, want ErrEmailCodeInvalid", err)
}
if _, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", sender.code, false); !errors.Is(err, domain.ErrEmailCodeInvalid) {
t.Fatalf("VerifyLoginEmail after max attempts err = %v, want ErrEmailCodeInvalid", err)
}
if _, found, _ := svc.LoginEmail(ctx, u.ID); found {
t.Fatal("login email was set after exhausted verification code")
}
}

View file

@ -4,19 +4,26 @@ import (
"context"
"crypto/rand"
"crypto/subtle"
"fmt"
"strings"
"time"
"telesrv/internal/domain"
"telesrv/internal/links"
"telesrv/internal/mail"
"telesrv/internal/store"
)
var defaultSecureRandom = []byte("telesrv-tdesktop-dev-secure-rand")
const (
passwordResetWait = 7 * 24 * time.Hour
passwordResetRetry = 24 * time.Hour
passwordResetWait = 7 * 24 * time.Hour
passwordResetRetry = 24 * time.Hour
loginEmailVerifyChangePrefix = "login-email-change:"
loginEmailVerifySetupPrefix = "login-email-setup:"
codeChannelEmailSetup = "email_setup"
codeChannelEmailChange = "email_change"
codeChannelEmailLogin = "email_login"
)
// Service 提供账号安全配置查询。
@ -30,8 +37,13 @@ type Service struct {
savedMusic store.SavedMusicStore
business store.BusinessAutomationStore
// users 仅用于登录邮箱的 phone→user 解析sendCode 检测 / login-setup / reset 走 phone
users store.UserStore
publicBaseURL string
users store.UserStore
publicBaseURL string
codes store.CodeStore
loginEmailSender mail.Sender
loginEmailCodeTTL time.Duration
loginEmailCodeMaxAttempts int
loginEmailCodeLength int
}
// ServiceOption 调整 account 服务依赖。
@ -99,9 +111,25 @@ func WithPublicBaseURL(baseURL string) ServiceOption {
}
}
func WithLoginEmailVerification(codes store.CodeStore, sender mail.Sender, ttl time.Duration, maxAttempts, length int) ServiceOption {
return func(s *Service) {
s.codes = codes
s.loginEmailSender = sender
if ttl > 0 {
s.loginEmailCodeTTL = ttl
}
if maxAttempts > 0 {
s.loginEmailCodeMaxAttempts = maxAttempts
}
if length > 0 {
s.loginEmailCodeLength = length
}
}
}
// NewService 创建 account 服务。
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
s := &Service{passwords: passwords, publicBaseURL: links.DefaultPublicBaseURL}
s := &Service{passwords: passwords, publicBaseURL: links.DefaultPublicBaseURL, loginEmailCodeTTL: 5 * time.Minute, loginEmailCodeMaxAttempts: 5, loginEmailCodeLength: 6}
for _, opt := range opts {
opt(s)
}
@ -200,6 +228,7 @@ func normalizePasswordSettings(settings domain.PasswordSettings) domain.Password
}
// login_email_pattern 始终从已确认的登录邮箱派生,与 2FA 恢复邮箱 RecoveryEmail
// 解耦(历史实现曾把恢复邮箱掩码误写进此字段,导致客户端把恢复邮箱当成登录邮箱显示)。
settings.LoginEmail = normalizeLoginEmail(settings.LoginEmail)
settings.LoginEmailPattern = emailPattern(settings.LoginEmail)
return settings
}
@ -467,25 +496,195 @@ func randomInt64() (int64, error) {
return out, nil
}
func randomDigits(n int) (string, error) {
if n <= 0 {
n = 6
}
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
var out strings.Builder
out.Grow(n)
for _, v := range b {
out.WriteByte(byte('0') + v%10)
}
return out.String(), nil
}
func emailPattern(email string) string {
return domain.MaskEmail(email)
}
func normalizeLoginEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// validLoginEmail 是登录邮箱的最小校验:非空且含 '@'。开发环境不做更严格的 RFC 校验。
func validLoginEmail(email string) bool {
email = normalizeLoginEmail(email)
return email != "" && strings.Contains(email, "@")
}
func (s *Service) SendLoginEmailCode(ctx context.Context, userID int64, phone, phoneCodeHash, email string, setup bool) (string, int, error) {
email = normalizeLoginEmail(email)
if !validLoginEmail(email) {
return "", 0, domain.ErrEmailInvalid
}
if s == nil || s.codes == nil || s.loginEmailSender == nil {
return "", 0, domain.ErrEmailNotAllowed
}
key := loginEmailVerifyChangePrefix + fmt.Sprint(userID)
rec := store.PhoneCode{
Code: "",
Channel: codeChannelEmailChange,
PendingEmail: email,
MaxAttempts: s.loginEmailCodeMaxAttempts,
}
if setup {
phone = domain.NormalizePhone(phone)
phoneRec, found, err := s.codes.Get(ctx, phoneCodeHash)
if err != nil {
return "", 0, err
}
if !found {
return "", 0, domain.ErrEmailCodeInvalid
}
if phoneRec.Phone != phone {
return "", 0, domain.ErrEmailInvalid
}
targetUserID := int64(0)
if existingUserID, found, err := s.userIDByPhone(ctx, phone); err != nil {
return "", 0, err
} else if found {
targetUserID = existingUserID
}
if err := s.ensureLoginEmailAvailable(ctx, targetUserID, email); err != nil {
return "", 0, err
}
key = loginEmailVerifySetupPrefix + phoneCodeHash
rec.Phone = phone
rec.Channel = codeChannelEmailSetup
} else if userID == 0 {
return "", 0, domain.ErrEmailInvalid
} else if err := s.ensureLoginEmailAvailable(ctx, userID, email); err != nil {
return "", 0, err
}
code, err := randomDigits(s.loginEmailCodeLength)
if err != nil {
return "", 0, err
}
rec.Code = code
if err := s.codes.Set(ctx, key, rec, s.loginEmailCodeTTL); err != nil {
return "", 0, err
}
if err := s.loginEmailSender.SendLoginCode(ctx, email, code, s.loginEmailCodeTTL); err != nil {
_ = s.codes.Del(ctx, key)
return "", 0, err
}
return emailPattern(email), len(code), nil
}
func (s *Service) VerifyLoginEmail(ctx context.Context, userID int64, phone, phoneCodeHash, code string, setup bool) (string, error) {
if s == nil || s.codes == nil {
return "", domain.ErrEmailNotAllowed
}
key := loginEmailVerifyChangePrefix + fmt.Sprint(userID)
if setup {
key = loginEmailVerifySetupPrefix + phoneCodeHash
}
rec, found, err := s.codes.Get(ctx, key)
if err != nil {
return "", err
}
if !found {
return "", domain.ErrEmailCodeInvalid
}
if strings.TrimSpace(code) == "" || subtle.ConstantTimeCompare([]byte(rec.Code), []byte(strings.TrimSpace(code))) != 1 {
return "", s.rejectEmailCode(ctx, key, rec)
}
email := normalizeLoginEmail(rec.PendingEmail)
if !validLoginEmail(email) {
_ = s.codes.Del(ctx, key)
return "", domain.ErrEmailInvalid
}
if setup {
phone = domain.NormalizePhone(phone)
phoneRec, found, err := s.codes.Get(ctx, phoneCodeHash)
if err != nil {
return "", err
}
if !found || phoneRec.Phone != phone {
return "", domain.ErrEmailCodeInvalid
}
targetUserID := int64(0)
if existingUserID, found, err := s.userIDByPhone(ctx, phone); err != nil {
return "", err
} else if found {
targetUserID = existingUserID
}
if err := s.ensureLoginEmailAvailable(ctx, targetUserID, email); err != nil {
_ = s.codes.Del(ctx, key)
return "", err
}
_ = s.codes.Del(ctx, key)
phoneRec.Channel = codeChannelEmailLogin
phoneRec.Code = strings.TrimSpace(code)
phoneRec.Email = email
phoneRec.PendingEmail = email
phoneRec.VerifiedEmail = true
phoneRec.Attempts = 0
phoneRec.MaxAttempts = s.loginEmailCodeMaxAttempts
if err := s.codes.Update(ctx, phoneCodeHash, phoneRec); err != nil {
return "", err
}
if _, found, err := s.userIDByPhone(ctx, phone); err != nil {
return "", err
} else if found {
if err := s.SetLoginEmailByPhone(ctx, phone, email); err != nil {
return "", err
}
}
return email, nil
}
if err := s.ensureLoginEmailAvailable(ctx, userID, email); err != nil {
_ = s.codes.Del(ctx, key)
return "", err
}
_ = s.codes.Del(ctx, key)
if err := s.SetLoginEmail(ctx, userID, email); err != nil {
return "", err
}
return email, nil
}
func (s *Service) rejectEmailCode(ctx context.Context, key string, rec store.PhoneCode) error {
rec.Attempts++
max := rec.MaxAttempts
if max <= 0 {
max = s.loginEmailCodeMaxAttempts
}
if max > 0 && rec.Attempts >= max {
_ = s.codes.Del(ctx, key)
return domain.ErrEmailCodeInvalid
}
_ = s.codes.Update(ctx, key, rec)
return domain.ErrEmailCodeInvalid
}
// SetLoginEmail 为已登录用户写入登录邮箱authed 的 emailVerifyPurposeLoginChange
// 账号无 2FA 也可设置account_passwords 行可在 has_password=false 下仅承载登录邮箱。
func (s *Service) SetLoginEmail(ctx context.Context, userID int64, email string) error {
if s == nil || s.passwords == nil || userID == 0 {
return domain.ErrEmailInvalid
}
email = strings.TrimSpace(email)
email = normalizeLoginEmail(email)
if !validLoginEmail(email) {
return domain.ErrEmailInvalid
}
if err := s.ensureLoginEmailAvailable(ctx, userID, email); err != nil {
return err
}
settings, err := s.GetPasswordWithoutRefresh(ctx, userID)
if err != nil {
return err
@ -520,7 +719,7 @@ func (s *Service) LoginEmail(ctx context.Context, userID int64) (string, bool, e
if !found || settings.LoginEmail == "" {
return "", false, nil
}
return settings.LoginEmail, true, nil
return normalizeLoginEmail(settings.LoginEmail), true, nil
}
// LoginEmailByPhone 按手机号返回登录邮箱原始地址(供 auth.sendCode 检测是否改投邮箱、
@ -551,6 +750,24 @@ func (s *Service) ClearLoginEmailByPhone(ctx context.Context, phone string) erro
return s.passwords.Save(ctx, userID, settings)
}
func (s *Service) ensureLoginEmailAvailable(ctx context.Context, userID int64, email string) error {
if s == nil || s.passwords == nil {
return domain.ErrEmailInvalid
}
email = normalizeLoginEmail(email)
if !validLoginEmail(email) {
return domain.ErrEmailInvalid
}
ownerUserID, found, err := s.passwords.LoginEmailOwner(ctx, email)
if err != nil || !found {
return err
}
if ownerUserID != userID {
return domain.ErrEmailOccupied
}
return nil
}
func (s *Service) userIDByPhone(ctx context.Context, phone string) (int64, bool, error) {
if s == nil || s.users == nil {
return 0, false, nil

View file

@ -0,0 +1,134 @@
package auth
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type testLoginEmailStore struct {
emails map[string]string
}
func (s *testLoginEmailStore) LoginEmailByPhone(_ context.Context, phone string) (string, bool, error) {
email, ok := s.emails[domain.NormalizePhone(phone)]
return email, ok, nil
}
func (s *testLoginEmailStore) SetLoginEmailByPhone(_ context.Context, phone, email string) error {
s.emails[domain.NormalizePhone(phone)] = email
return nil
}
type testMailSender struct {
to string
code string
}
func (s *testMailSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error {
s.to = to
s.code = code
return nil
}
func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
authz := memory.NewAuthorizationStore()
if _, err := users.Create(ctx, domain.User{Phone: "15550009101", FirstName: "Email"}); err != nil {
t.Fatalf("create user: %v", err)
}
emails := &testLoginEmailStore{emails: map[string]string{"15550009101": "alice@example.test"}}
sender := &testMailSender{}
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
WithLoginEmail(LoginEmailOptions{
Enabled: true,
CodeLength: 6,
Store: emails,
Sender: sender,
}),
WithCodeMaxAttempts(2))
hash, err := svc.SendCode(ctx, "+1 555 000 9101")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
if sender.to != "alice@example.test" || len(sender.code) != 6 {
t.Fatalf("sent email to/code = %q/%q, want alice@example.test/6 digits", sender.to, sender.code)
}
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 || delivery.EmailPattern != "a***e@example.test" || delivery.Length != 6 {
t.Fatalf("delivery = %+v, want email masked length 6", delivery)
}
bad1 := wrongCode(sender.code, '0')
bad2 := wrongCode(sender.code, '1')
if bad2 == bad1 {
bad2 = wrongCode(sender.code, '2')
}
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009101", hash, bad1); !errors.Is(err, ErrCodeInvalid) {
t.Fatalf("first bad SignInWithEmail err = %v, want ErrCodeInvalid", err)
}
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009101", hash, bad2); !errors.Is(err, ErrCodeInvalid) {
t.Fatalf("second bad SignInWithEmail err = %v, want ErrCodeInvalid", err)
}
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009101", hash, sender.code); !errors.Is(err, ErrCodeExpired) {
t.Fatalf("SignInWithEmail after max attempts err = %v, want ErrCodeExpired", err)
}
}
func wrongCode(code string, digit byte) string {
if code == "" {
return string(digit)
}
out := make([]byte, len(code))
for i := range out {
out[i] = digit
}
if string(out) != code {
return string(out)
}
for i := range out {
out[i] = '9'
}
return string(out)
}
func TestConfiguredEmailLoginAcceptsCorrectCode(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
authz := memory.NewAuthorizationStore()
u, err := users.Create(ctx, domain.User{Phone: "15550009102", FirstName: "Email"})
if err != nil {
t.Fatalf("create user: %v", err)
}
emails := &testLoginEmailStore{emails: map[string]string{"15550009102": "bob@example.test"}}
sender := &testMailSender{}
var key [8]byte
key[0] = 0x91
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
WithLoginEmail(LoginEmailOptions{
Enabled: true,
CodeLength: 5,
Store: emails,
Sender: sender,
}))
hash, err := svc.SendCode(ctx, "+15550009102")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
got, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009102", hash, sender.code)
if err != nil {
t.Fatalf("SignInWithEmail: %v", err)
}
if needSignUp || got.ID != u.ID {
t.Fatalf("SignInWithEmail got user=%d needSignUp=%v, want %d/false", got.ID, needSignUp, u.ID)
}
}

View file

@ -18,6 +18,7 @@ import (
mtcrypto "github.com/gotd/td/crypto"
"telesrv/internal/domain"
"telesrv/internal/mail"
"telesrv/internal/store"
)
@ -35,6 +36,12 @@ var (
ErrSystemUserLoginForbidden = errors.New("system user login forbidden")
)
const (
codeChannelPhone = "phone"
codeChannelEmailLogin = "email_login"
codeChannelEmailSetupRequired = "email_setup_required"
)
// validPhone 校验规范化后的手机号5-32 位纯数字(上限对齐 users.phone 列宽)。
// 核心目的是拒绝空/非数字 phone防 0090 partial index 下无限铸造幽灵账号),
// 长度上限从宽,不强求 E.164 精确位数(测试常用更长的唯一 phone
@ -61,21 +68,40 @@ func systemLoginPhoneForbidden(phone string) bool {
// Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。
type Service struct {
users store.UserStore
auths store.AuthorizationStore
codes store.CodeStore
authKeys store.AuthKeyStore
tempKeys store.TempAuthKeyBindingStore
passwords store.PasswordStore
messages store.MessageStore
dialogs store.DialogStore
bots store.BotStore
fixedCode string
codeTTL time.Duration
users store.UserStore
auths store.AuthorizationStore
codes store.CodeStore
authKeys store.AuthKeyStore
tempKeys store.TempAuthKeyBindingStore
passwords store.PasswordStore
messages store.MessageStore
dialogs store.DialogStore
bots store.BotStore
fixedCode string
codeTTL time.Duration
codeMaxAttempts int
loginEmails loginEmailStore
loginEmailSender mail.Sender
loginEmailEnabled bool
loginEmailRequireSetup bool
loginEmailCodeLength int
// premiumGrantMonths 是新注册账号默认赠送的会员月数0 表示关闭赠送。
premiumGrantMonths int
}
type loginEmailStore interface {
LoginEmailByPhone(ctx context.Context, phone string) (string, bool, error)
SetLoginEmailByPhone(ctx context.Context, phone, email string) error
}
type LoginEmailOptions struct {
Enabled bool
RequireSetup bool
CodeLength int
Store loginEmailStore
Sender mail.Sender
}
type authorizationRevoker interface {
RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error)
RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
@ -114,9 +140,38 @@ func WithPremiumGrant(months int) Option {
}
}
func WithCodeTTL(ttl time.Duration) Option {
return func(s *Service) {
if ttl > 0 {
s.codeTTL = ttl
}
}
}
func WithCodeMaxAttempts(max int) Option {
return func(s *Service) {
if max > 0 {
s.codeMaxAttempts = max
}
}
}
func WithLoginEmail(opts LoginEmailOptions) Option {
return func(s *Service) {
s.loginEmailEnabled = opts.Enabled
s.loginEmailRequireSetup = opts.RequireSetup
s.loginEmailCodeLength = opts.CodeLength
if s.loginEmailCodeLength <= 0 {
s.loginEmailCodeLength = 6
}
s.loginEmails = opts.Store
s.loginEmailSender = opts.Sender
}
}
// 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}
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6}
for _, opt := range opts {
opt(s)
}
@ -214,7 +269,8 @@ func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte)
return s.auths.MarkPasswordPassed(ctx, authKeyID)
}
// SendCode 为 phone 生成 phone_code_hash暂存开发固定验证码返回 hash。
// SendCode 为 phone 生成 phone_code_hash按配置选择开发 app code、登录邮箱 code
// 或登录邮箱 setup-required 状态,返回 hash。
func (s *Service) SendCode(ctx context.Context, phone string) (string, error) {
phone = normalizePhone(phone)
if !validPhone(phone) {
@ -223,16 +279,105 @@ func (s *Service) SendCode(ctx context.Context, phone string) (string, error) {
if systemLoginPhoneForbidden(phone) {
return "", ErrSystemUserLoginForbidden
}
if s.loginEmailEnabled && s.loginEmails != nil {
email, found, err := s.loginEmails.LoginEmailByPhone(ctx, phone)
if err != nil {
return "", err
}
if found && strings.TrimSpace(email) != "" {
return s.createEmailLoginCode(ctx, phone, email)
}
if s.loginEmailRequireSetup {
return s.createSetupRequiredCode(ctx, phone)
}
}
return s.createPhoneCode(ctx, phone)
}
func (s *Service) createPhoneCode(ctx context.Context, phone string) (string, error) {
hash, err := randomHex(8)
if err != nil {
return "", err
}
if err := s.codes.Set(ctx, hash, store.PhoneCode{Phone: phone, Code: s.fixedCode}, s.codeTTL); err != nil {
if err := s.codes.Set(ctx, hash, store.PhoneCode{
Phone: phone,
Code: s.fixedCode,
Channel: codeChannelPhone,
MaxAttempts: s.codeMaxAttempts,
}, s.codeTTL); err != nil {
return "", fmt.Errorf("store code: %w", err)
}
return hash, nil
}
func (s *Service) createSetupRequiredCode(ctx context.Context, phone string) (string, error) {
hash, err := randomHex(8)
if err != nil {
return "", err
}
if err := s.codes.Set(ctx, hash, store.PhoneCode{
Phone: phone,
Channel: codeChannelEmailSetupRequired,
MaxAttempts: s.codeMaxAttempts,
}, s.codeTTL); err != nil {
return "", fmt.Errorf("store code: %w", err)
}
return hash, nil
}
func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string) (string, error) {
hash, err := randomHex(8)
if err != nil {
return "", err
}
code, err := randomDigits(s.loginEmailCodeLength)
if err != nil {
return "", err
}
rec := store.PhoneCode{
Phone: phone,
Code: code,
Channel: codeChannelEmailLogin,
Email: strings.TrimSpace(email),
MaxAttempts: s.codeMaxAttempts,
}
if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil {
return "", fmt.Errorf("store email code: %w", err)
}
if s.loginEmailSender == nil {
_ = s.codes.Del(ctx, hash)
return "", fmt.Errorf("login email sender is not configured")
}
if err := s.loginEmailSender.SendLoginCode(ctx, rec.Email, code, s.codeTTL); err != nil {
_ = s.codes.Del(ctx, hash)
return "", fmt.Errorf("send login email code: %w", err)
}
return hash, nil
}
func (s *Service) CodeDelivery(ctx context.Context, phoneCodeHash string) (domain.AuthCodeDelivery, bool, error) {
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
if err != nil || !found {
return domain.AuthCodeDelivery{}, found, err
}
return codeDelivery(rec), true, nil
}
func codeDelivery(rec store.PhoneCode) domain.AuthCodeDelivery {
switch rec.Channel {
case codeChannelEmailLogin:
return domain.AuthCodeDelivery{
Kind: domain.AuthCodeDeliveryEmail,
EmailPattern: domain.MaskEmail(rec.Email),
Length: len(rec.Code),
}
case codeChannelEmailSetupRequired:
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliveryEmailSetupRequired}
default:
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliveryPhone, Length: len(rec.Code)}
}
}
// ResendCode invalidates an existing code hash and sends a fresh code to the same phone.
func (s *Service) ResendCode(ctx context.Context, phone, phoneCodeHash string) (string, error) {
phone = normalizePhone(phone)
@ -247,6 +392,12 @@ func (s *Service) ResendCode(ctx context.Context, phone, phoneCodeHash string) (
return "", ErrCodeInvalid
}
_ = s.codes.Del(ctx, phoneCodeHash)
if rec.Channel == codeChannelEmailLogin && strings.TrimSpace(rec.Email) != "" {
return s.createEmailLoginCode(ctx, phone, rec.Email)
}
if rec.Channel == codeChannelEmailSetupRequired {
return s.createSetupRequiredCode(ctx, phone)
}
return s.SendCode(ctx, phone)
}
@ -280,9 +431,15 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
if !found {
return domain.User{}, domain.Message{}, false, ErrCodeExpired
}
if rec.Phone != phone || rec.Code != code {
if rec.Phone != phone || rec.Channel == codeChannelEmailSetupRequired {
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
}
if rec.Channel == codeChannelEmailLogin {
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
}
if rec.Code != code {
return domain.User{}, domain.Message{}, false, s.rejectCode(ctx, phoneCodeHash, rec, ErrCodeInvalid)
}
existing, found, err := s.users.ByPhone(ctx, phone)
if err != nil {
@ -295,9 +452,10 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
}
// SignInWithEmail 处理带 email_verification 的 auth.signIn账号设置了登录邮箱后新设备
// 的验证码改投递到邮箱,客户端凭邮箱码(而非短信码)登录。开发环境接受任意非空邮箱码
// (与短信固定码同口径,"随意输入");仍校验 phone_code_hash 有效、手机号匹配,并与短信
// 登录共用 2FA 门控——即便走邮箱验证,开启了两步验证的账号同样会停在 SESSION_PASSWORD_NEEDED。
// 的验证码改投递到邮箱,客户端凭邮箱码(而非短信码)登录。开启真实登录邮箱后必须匹配
// 随机邮箱码;未开启该特性时仅保留旧开发路径的任意非空兼容。仍校验 phone_code_hash
// 有效、手机号匹配,并与短信登录共用 2FA 门控——即便走邮箱验证,开启了两步验证的账号
// 同样会停在 SESSION_PASSWORD_NEEDED。
func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error) {
phone = normalizePhone(phone)
if systemLoginPhoneForbidden(phone) {
@ -313,8 +471,15 @@ func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization
if rec.Phone != phone {
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
}
if strings.TrimSpace(code) == "" {
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
if rec.Channel != codeChannelEmailLogin {
if s.loginEmailEnabled {
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
}
if strings.TrimSpace(code) == "" {
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
}
} else if rec.Code != strings.TrimSpace(code) {
return domain.User{}, domain.Message{}, false, s.rejectCode(ctx, phoneCodeHash, rec, ErrCodeInvalid)
}
existing, found, err := s.users.ByPhone(ctx, phone)
if err != nil {
@ -378,6 +543,12 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
if rec.Phone != phone {
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
if rec.Channel == codeChannelEmailSetupRequired {
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
if s.loginEmailRequireSetup && !rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) == "" {
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
accessHash, err := randomInt64()
if err != nil {
@ -398,6 +569,11 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
if err != nil {
return domain.User{}, domain.Message{}, err
}
if rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) != "" && s.loginEmails != nil {
if err := s.loginEmails.SetLoginEmailByPhone(ctx, phone, rec.PendingEmail); err != nil {
return domain.User{}, domain.Message{}, err
}
}
if err := s.bind(ctx, auth, u.ID); err != nil {
return domain.User{}, domain.Message{}, err
}
@ -525,6 +701,43 @@ func (s *Service) UpdateAuthorizationLayer(ctx context.Context, authKeyID [8]byt
return s.auths.UpdateLayer(ctx, authKeyID, layer)
}
func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (domain.AuthKeyClientInfo, bool, error) {
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
return domain.AuthKeyClientInfo{}, false, nil
}
key, found, err := s.authKeys.Get(ctx, authKeyID)
if err != nil || !found {
return domain.AuthKeyClientInfo{}, found, err
}
info := domain.AuthKeyClientInfo{
Layer: key.Layer,
DeviceModel: key.DeviceModel,
Platform: key.Platform,
SystemVersion: key.SystemVersion,
APIID: key.APIID,
AppVersion: key.AppVersion,
}
if info.Layer == 0 && info.DeviceModel == "" && info.Platform == "" &&
info.SystemVersion == "" && info.APIID == 0 && info.AppVersion == "" {
return domain.AuthKeyClientInfo{}, false, nil
}
return info, true, nil
}
func (s *Service) UpdateAuthKeyClientInfo(ctx context.Context, authKeyID [8]byte, info domain.AuthKeyClientInfo) error {
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
return nil
}
return s.authKeys.UpdateClientInfo(ctx, authKeyID, store.AuthKeyClientInfo{
Layer: info.Layer,
DeviceModel: info.DeviceModel,
Platform: info.Platform,
SystemVersion: info.SystemVersion,
APIID: info.APIID,
AppVersion: info.AppVersion,
})
}
func (s *Service) ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error) {
if s == nil || s.auths == nil || userID == 0 {
return nil, nil
@ -752,6 +965,20 @@ func authKeyIDInt64(id [8]byte) int64 {
return int64(binary.LittleEndian.Uint64(id[:]))
}
func (s *Service) rejectCode(ctx context.Context, hash string, rec store.PhoneCode, ret error) error {
rec.Attempts++
max := rec.MaxAttempts
if max <= 0 {
max = s.codeMaxAttempts
}
if max > 0 && rec.Attempts >= max {
_ = s.codes.Del(ctx, hash)
return ret
}
_ = s.codes.Update(ctx, hash, rec)
return ret
}
func normalizePhone(phone string) string {
return domain.NormalizePhone(phone)
}
@ -764,6 +991,22 @@ func randomHex(n int) (string, error) {
return hex.EncodeToString(b), nil
}
func randomDigits(n int) (string, error) {
if n <= 0 {
n = 6
}
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("rand: %w", err)
}
var out strings.Builder
out.Grow(n)
for _, v := range b {
out.WriteByte(byte('0') + v%10)
}
return out.String(), nil
}
func randomInt64() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {