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

@ -5,6 +5,23 @@ TELESRV_LISTEN=0.0.0.0:2398
TELESRV_ADVERTISE_IP=127.0.0.1
TELESRV_DC=2
TELESRV_DEV_AUTH_CODE=12345
TELESRV_AUTH_CODE_TTL=5m
TELESRV_AUTH_CODE_MAX_ATTEMPTS=5
# Optional login-email verification. When enabled, accounts with a confirmed
# login email receive login codes by email; REQUIRE_SETUP also forces new/legacy
# accounts without a login email to set one during the phone login flow.
TELESRV_LOGIN_EMAIL_ENABLE=false
TELESRV_LOGIN_EMAIL_REQUIRE_SETUP=false
TELESRV_LOGIN_EMAIL_CODE_LENGTH=6
TELESRV_SMTP_HOST=
TELESRV_SMTP_PORT=587
TELESRV_SMTP_USERNAME=
TELESRV_SMTP_PASSWORD=
TELESRV_SMTP_FROM=
TELESRV_SMTP_FROM_NAME=telesrv
TELESRV_SMTP_TLS=starttls
TELESRV_SMTP_TIMEOUT=10s
# Client-visible telesrv links. Production uses https://telesrv.net.
# For local link/deeplink smoke tests use http://127.0.0.1:2401.

View file

@ -104,6 +104,10 @@ Useful local environment variables:
| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | IP advertised to compatible clients |
| `TELESRV_DC` | `2` | self-hosted DC id |
| `TELESRV_DEV_AUTH_CODE` | `12345` | fixed login code for local development |
| `TELESRV_AUTH_CODE_MAX_ATTEMPTS` | `5` | wrong-code attempts before the code hash is deleted |
| `TELESRV_LOGIN_EMAIL_ENABLE` | `false` | send login codes to confirmed login email addresses through SMTP |
| `TELESRV_LOGIN_EMAIL_REQUIRE_SETUP` | `false` | force phone login/registration to set a login email first |
| `TELESRV_SMTP_HOST` | empty | SMTP host used when login email verification is enabled |
| `TELESRV_PUBLIC_BASE_URL` | `https://telesrv.net` | canonical base URL for public sticker/chatlist links |
| `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL connection string |
| `TELESRV_REDIS_ADDR` | `127.0.0.1:6399` | Redis address |

View file

@ -96,6 +96,10 @@ go build -o bin/gramsrv ./cmd/telesrv
| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | 下发给兼容客户端的连接 IP |
| `TELESRV_DC` | `2` | 自建 DC id |
| `TELESRV_DEV_AUTH_CODE` | `12345` | 本地开发固定登录验证码 |
| `TELESRV_AUTH_CODE_MAX_ATTEMPTS` | `5` | 同一验证码 hash 允许的错误次数,达到后删除并要求重发 |
| `TELESRV_LOGIN_EMAIL_ENABLE` | `false` | 已绑定登录邮箱的账号通过 SMTP 接收登录验证码 |
| `TELESRV_LOGIN_EMAIL_REQUIRE_SETUP` | `false` | 登录/注册时强制先设置登录邮箱 |
| `TELESRV_SMTP_HOST` | 空 | 开启登录邮箱验证时使用的 SMTP host |
| `TELESRV_PUBLIC_BASE_URL` | `https://telesrv.net` | sticker/chatlist 公开链接的 canonical base URL |
| `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL 连接串 |
| `TELESRV_REDIS_ADDR` | `127.0.0.1:6399` | Redis 地址 |

View file

@ -54,6 +54,7 @@ import (
"telesrv/internal/botapi"
"telesrv/internal/config"
"telesrv/internal/domain"
mailpkg "telesrv/internal/mail"
"telesrv/internal/mtprotoedge"
"telesrv/internal/rpc"
"telesrv/internal/seed/catalog"
@ -464,7 +465,7 @@ func run(logger *zap.Logger) error {
// userCache 与 users 服务共享同一实例bot 元数据写入version bump后必须
// 失效缓存,否则 TTL 内 getUsers 回旧 first_name/旧 bot_info_version。
userCache := redisstore.NewUserCache(rdb, redisstore.DefaultUserCacheTTL)
accountService := account.NewService(passwordStore,
accountOptions := []account.ServiceOption{
account.WithReactionSettings(passwordStore),
account.WithAccountSettings(passwordStore),
account.WithNotifySettings(passwordStore),
@ -473,7 +474,24 @@ func run(logger *zap.Logger) error {
account.WithSavedMusic(passwordStore),
account.WithBusinessAutomation(passwordStore),
account.WithUsers(userStore),
account.WithPublicBaseURL(cfg.PublicBaseURL))
account.WithPublicBaseURL(cfg.PublicBaseURL),
}
var loginEmailSender mailpkg.Sender
if cfg.LoginEmailEnable {
loginEmailSender = mailpkg.NewSMTP(mailpkg.Config{
Host: cfg.SMTPHost,
Port: cfg.SMTPPort,
Username: cfg.SMTPUsername,
Password: cfg.SMTPPassword,
From: cfg.SMTPFrom,
FromName: cfg.SMTPFromName,
TLSMode: cfg.SMTPTLSMode,
Timeout: cfg.SMTPTimeout,
})
accountOptions = append(accountOptions,
account.WithLoginEmailVerification(codeStore, loginEmailSender, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength))
}
accountService := account.NewService(passwordStore, accountOptions...)
botsService := botsapp.NewService(userStore, botStore, messageStore,
botsapp.WithLogger(logger.Named("bots")),
botsapp.WithBlockChecker(contactStore),
@ -611,7 +629,20 @@ func run(logger *zap.Logger) error {
messageapp.WithSendPermissionChecker(adminService),
messageapp.WithBusinessAutomation(passwordStore, businessAutomationOptions...),
)
authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore), auth.WithPasswords(passwordStore), auth.WithBotLogin(botStore), auth.WithPremiumGrant(cfg.PremiumGrantMonths))
authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode,
auth.WithLoginMessages(messageStore, dialogStore),
auth.WithPasswords(passwordStore),
auth.WithBotLogin(botStore),
auth.WithPremiumGrant(cfg.PremiumGrantMonths),
auth.WithCodeTTL(cfg.AuthCodeTTL),
auth.WithCodeMaxAttempts(cfg.AuthCodeMaxAttempts),
auth.WithLoginEmail(auth.LoginEmailOptions{
Enabled: cfg.LoginEmailEnable,
RequireSetup: cfg.LoginEmailRequireSetup,
CodeLength: cfg.LoginEmailCodeLength,
Store: accountService,
Sender: loginEmailSender,
}))
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
router := rpc.New(rpc.Config{
DC: cfg.DC,

View file

@ -0,0 +1 @@
DROP INDEX IF EXISTS public.account_passwords_login_email_lower_unique_idx;

View file

@ -0,0 +1,3 @@
CREATE UNIQUE INDEX account_passwords_login_email_lower_unique_idx
ON public.account_passwords (lower((login_email)::text))
WHERE ((login_email)::text <> ''::text);

View file

@ -0,0 +1,7 @@
ALTER TABLE auth_keys
DROP COLUMN IF EXISTS app_version,
DROP COLUMN IF EXISTS api_id,
DROP COLUMN IF EXISTS system_version,
DROP COLUMN IF EXISTS platform,
DROP COLUMN IF EXISTS device_model,
DROP COLUMN IF EXISTS layer;

View file

@ -0,0 +1,7 @@
ALTER TABLE auth_keys
ADD COLUMN layer integer NOT NULL DEFAULT 0,
ADD COLUMN device_model varchar(128) NOT NULL DEFAULT '',
ADD COLUMN platform varchar(64) NOT NULL DEFAULT '',
ADD COLUMN system_version varchar(64) NOT NULL DEFAULT '',
ADD COLUMN api_id integer NOT NULL DEFAULT 0,
ADD COLUMN app_version varchar(64) NOT NULL DEFAULT '';

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,11 +4,13 @@ import (
"context"
"crypto/rand"
"crypto/subtle"
"fmt"
"strings"
"time"
"telesrv/internal/domain"
"telesrv/internal/links"
"telesrv/internal/mail"
"telesrv/internal/store"
)
@ -17,6 +19,11 @@ var defaultSecureRandom = []byte("telesrv-tdesktop-dev-secure-rand")
const (
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 提供账号安全配置查询。
@ -32,6 +39,11 @@ type Service struct {
// users 仅用于登录邮箱的 phone→user 解析sendCode 检测 / login-setup / reset 走 phone
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
@ -72,10 +79,29 @@ type Service struct {
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,9 +471,16 @@ func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization
if rec.Phone != phone {
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 {
return domain.User{}, domain.Message{}, false, err
@ -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 {

View file

@ -71,6 +71,26 @@ type Config struct {
// DevAuthCode 是开发固定验证码;生产短信/风控不在当前范围内。
DevAuthCode string
// AuthCodeTTL 是登录/注册/邮箱验证 code 的有效期。
AuthCodeTTL time.Duration
// AuthCodeMaxAttempts 是同一 phone_code_hash / email verification code 的最大错误次数。
// 达到上限后验证码立即失效,用户必须重发。
AuthCodeMaxAttempts int
// LoginEmailEnable 启用手机号登录流程中的邮箱验证码投递。
LoginEmailEnable bool
// LoginEmailRequireSetup 为 true 时,没有登录邮箱的账号/新手机号会要求先设置邮箱。
LoginEmailRequireSetup bool
// LoginEmailCodeLength 是邮箱验证码长度。
LoginEmailCodeLength int
// SMTP* 是登录邮箱验证码的出站邮件配置。LoginEmailEnable=true 时必须可用。
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
SMTPFrom string
SMTPFromName string
SMTPTLSMode string
SMTPTimeout time.Duration
// MapboxToken 是服务端代理地图缩略图upload.getWebFile请求 Mapbox Static Images API
// 的 access token为空则关闭代理、回退确定性占位图。客户端选点器 token 经 appConfig
// `tdesktop_config_map` 下发(同源运行时配置)。
@ -322,6 +342,19 @@ func Load() (Config, error) {
RedisDB: envIntOr("TELESRV_REDIS_DB", 0),
DevAuthCode: envOr("TELESRV_DEV_AUTH_CODE", "12345"),
AuthCodeTTL: envDurationOr("TELESRV_AUTH_CODE_TTL", 5*time.Minute),
AuthCodeMaxAttempts: envIntOr("TELESRV_AUTH_CODE_MAX_ATTEMPTS", 5),
LoginEmailEnable: envBoolOr("TELESRV_LOGIN_EMAIL_ENABLE", false),
LoginEmailRequireSetup: envBoolOr("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP", false),
LoginEmailCodeLength: envIntOr("TELESRV_LOGIN_EMAIL_CODE_LENGTH", 6),
SMTPHost: envOr("TELESRV_SMTP_HOST", ""),
SMTPPort: envIntOr("TELESRV_SMTP_PORT", 587),
SMTPUsername: envOr("TELESRV_SMTP_USERNAME", ""),
SMTPPassword: envOr("TELESRV_SMTP_PASSWORD", ""),
SMTPFrom: envOr("TELESRV_SMTP_FROM", ""),
SMTPFromName: envOr("TELESRV_SMTP_FROM_NAME", "telesrv"),
SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))),
SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second),
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),
BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"),
StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"),
@ -406,9 +439,48 @@ func Load() (Config, error) {
LiveStreamWorkDir: envOr("TELESRV_LIVESTREAM_WORK_DIR", ""),
LiveStreamSegmentKeep: envIntOr("TELESRV_LIVESTREAM_SEGMENT_KEEP", 32),
}
if err := validateLoginEmailConfig(cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
func validateLoginEmailConfig(cfg Config) error {
if cfg.LoginEmailRequireSetup && !cfg.LoginEmailEnable {
return fmt.Errorf("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP requires TELESRV_LOGIN_EMAIL_ENABLE=true")
}
if cfg.AuthCodeTTL <= 0 {
return fmt.Errorf("TELESRV_AUTH_CODE_TTL must be positive")
}
if cfg.AuthCodeMaxAttempts <= 0 {
return fmt.Errorf("TELESRV_AUTH_CODE_MAX_ATTEMPTS must be positive")
}
if cfg.LoginEmailCodeLength < 4 || cfg.LoginEmailCodeLength > 10 {
return fmt.Errorf("TELESRV_LOGIN_EMAIL_CODE_LENGTH must be between 4 and 10")
}
switch cfg.SMTPTLSMode {
case "", "starttls", "tls", "none":
default:
return fmt.Errorf("TELESRV_SMTP_TLS must be starttls, tls, or none")
}
if !cfg.LoginEmailEnable {
return nil
}
if strings.TrimSpace(cfg.SMTPHost) == "" {
return fmt.Errorf("TELESRV_SMTP_HOST is required when TELESRV_LOGIN_EMAIL_ENABLE=true")
}
if cfg.SMTPPort <= 0 || cfg.SMTPPort > 65535 {
return fmt.Errorf("TELESRV_SMTP_PORT must be between 1 and 65535")
}
if strings.TrimSpace(cfg.SMTPFrom) == "" && strings.TrimSpace(cfg.SMTPUsername) == "" {
return fmt.Errorf("TELESRV_SMTP_FROM or TELESRV_SMTP_USERNAME is required when TELESRV_LOGIN_EMAIL_ENABLE=true")
}
if cfg.SMTPTimeout <= 0 {
return fmt.Errorf("TELESRV_SMTP_TIMEOUT must be positive")
}
return nil
}
func loadAIProviders(env envSource) []AIProviderConfig {
names := env.envListOr("TELESRV_AI_PROVIDERS", []string{"local"})
out := make([]AIProviderConfig, 0, len(names))

View file

@ -63,6 +63,63 @@ func TestLoadBusinessAIProviderDefaultsToEcho(t *testing.T) {
}
}
func TestLoadLoginEmailDefaultsDisabled(t *testing.T) {
disableDefaultConfigFile(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LoginEmailEnable {
t.Fatal("LoginEmailEnable = true, want false")
}
if cfg.LoginEmailRequireSetup {
t.Fatal("LoginEmailRequireSetup = true, want false")
}
if cfg.AuthCodeTTL != 5*time.Minute || cfg.AuthCodeMaxAttempts != 5 || cfg.LoginEmailCodeLength != 6 {
t.Fatalf("auth/login email defaults = %v/%d/%d", cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength)
}
}
func TestLoadLoginEmailSMTPConfig(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_LOGIN_EMAIL_ENABLE", "true")
t.Setenv("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP", "true")
t.Setenv("TELESRV_AUTH_CODE_TTL", "3m")
t.Setenv("TELESRV_AUTH_CODE_MAX_ATTEMPTS", "4")
t.Setenv("TELESRV_LOGIN_EMAIL_CODE_LENGTH", "7")
t.Setenv("TELESRV_SMTP_HOST", "smtp.example.test")
t.Setenv("TELESRV_SMTP_PORT", "2525")
t.Setenv("TELESRV_SMTP_USERNAME", "smtp-user")
t.Setenv("TELESRV_SMTP_PASSWORD", "smtp-pass")
t.Setenv("TELESRV_SMTP_FROM", "noreply@example.test")
t.Setenv("TELESRV_SMTP_TLS", "none")
t.Setenv("TELESRV_SMTP_TIMEOUT", "2s")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.LoginEmailEnable || !cfg.LoginEmailRequireSetup {
t.Fatalf("login email flags = %v/%v, want true/true", cfg.LoginEmailEnable, cfg.LoginEmailRequireSetup)
}
if cfg.AuthCodeTTL != 3*time.Minute || cfg.AuthCodeMaxAttempts != 4 || cfg.LoginEmailCodeLength != 7 {
t.Fatalf("auth/login email config = %v/%d/%d", cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength)
}
if cfg.SMTPHost != "smtp.example.test" || cfg.SMTPPort != 2525 || cfg.SMTPUsername != "smtp-user" || cfg.SMTPPassword != "smtp-pass" || cfg.SMTPFrom != "noreply@example.test" || cfg.SMTPTLSMode != "none" || cfg.SMTPTimeout != 2*time.Second {
t.Fatalf("smtp config = %#v", cfg)
}
}
func TestLoadLoginEmailRequiresSMTPWhenEnabled(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_LOGIN_EMAIL_ENABLE", "true")
if _, err := Load(); err == nil {
t.Fatal("Load succeeded with login email enabled but no SMTP host")
}
}
func TestLoadKeepsAdminAndRtmpDefaultPortsSeparate(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_ADMIN_UI_ADDR", "")

View file

@ -14,9 +14,25 @@ var (
ErrPasswordRecoveryNA = errors.New("password recovery not available")
ErrEmailCodeInvalid = errors.New("email code invalid")
ErrEmailInvalid = errors.New("email invalid")
ErrEmailNotAllowed = errors.New("email not allowed")
ErrEmailOccupied = errors.New("email occupied")
ErrSessionPasswordNeeded = errors.New("session password needed")
)
type AuthCodeDeliveryKind string
const (
AuthCodeDeliveryPhone AuthCodeDeliveryKind = "phone"
AuthCodeDeliveryEmail AuthCodeDeliveryKind = "email"
AuthCodeDeliveryEmailSetupRequired AuthCodeDeliveryKind = "email_setup_required"
)
type AuthCodeDelivery struct {
Kind AuthCodeDeliveryKind
EmailPattern string
Length int
}
// PasswordKDFAlgo 是业务层的 SRP KDF 算法描述,不依赖 tg.*。
type PasswordKDFAlgo struct {
Salt1 []byte

View file

@ -21,3 +21,15 @@ type Authorization struct {
CreatedAt time.Time
ActiveAt time.Time
}
// AuthKeyClientInfo 是未登录 auth_key 也需要保留的客户端协商元数据。
// 登录后的设备授权仍由 Authorization 表达;这里仅用于服务端重启后恢复
// pre-auth / setup 流程的 client type 与 layer。
type AuthKeyClientInfo struct {
Layer int
DeviceModel string
Platform string
SystemVersion string
APIID int
AppVersion string
}

154
internal/mail/sender.go Normal file
View file

@ -0,0 +1,154 @@
package mail
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"mime"
"net"
stdmail "net/mail"
"net/smtp"
"strings"
"time"
)
type Config struct {
Host string
Port int
Username string
Password string
From string
FromName string
TLSMode string
Timeout time.Duration
}
type Sender interface {
SendLoginCode(ctx context.Context, to, code string, ttl time.Duration) error
}
type SMTP struct {
cfg Config
}
func NewSMTP(cfg Config) *SMTP {
if cfg.Timeout <= 0 {
cfg.Timeout = 10 * time.Second
}
cfg.TLSMode = strings.ToLower(strings.TrimSpace(cfg.TLSMode))
if cfg.TLSMode == "" {
cfg.TLSMode = "starttls"
}
if strings.TrimSpace(cfg.From) == "" {
cfg.From = cfg.Username
}
return &SMTP{cfg: cfg}
}
func (s *SMTP) SendLoginCode(ctx context.Context, to, code string, ttl time.Duration) error {
subject := "Your telesrv login code"
body := fmt.Sprintf("Your telesrv login code is %s.\n\nThis code expires in %s. If you did not request it, ignore this email.\n", code, humanTTL(ttl))
return s.send(ctx, to, subject, body)
}
func (s *SMTP) send(ctx context.Context, to, subject, body string) error {
if strings.TrimSpace(s.cfg.Host) == "" {
return fmt.Errorf("smtp host is empty")
}
from := strings.TrimSpace(s.cfg.From)
if from == "" {
return fmt.Errorf("smtp from is empty")
}
if _, err := stdmail.ParseAddress(to); err != nil {
return fmt.Errorf("parse recipient: %w", err)
}
fromAddr := from
if s.cfg.FromName != "" {
fromAddr = (&stdmail.Address{Name: s.cfg.FromName, Address: from}).String()
}
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
var d net.Dialer
d.Timeout = s.cfg.Timeout
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("dial smtp: %w", err)
}
defer conn.Close()
mode := strings.ToLower(strings.TrimSpace(s.cfg.TLSMode))
var c *smtp.Client
if mode == "tls" {
tlsConn := tls.Client(conn, &tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12})
if err := tlsConn.HandshakeContext(ctx); err != nil {
return fmt.Errorf("smtp tls handshake: %w", err)
}
c, err = smtp.NewClient(tlsConn, s.cfg.Host)
} else {
c, err = smtp.NewClient(conn, s.cfg.Host)
}
if err != nil {
return fmt.Errorf("new smtp client: %w", err)
}
defer c.Close()
if mode == "starttls" {
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12}); err != nil {
return fmt.Errorf("smtp starttls: %w", err)
}
} else {
return fmt.Errorf("smtp server does not support STARTTLS")
}
}
if s.cfg.Username != "" {
if err := c.Auth(smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("smtp mail from: %w", err)
}
if err := c.Rcpt(to); err != nil {
return fmt.Errorf("smtp rcpt: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
msg := buildMessage(fromAddr, to, subject, body)
if _, err := w.Write(msg); err != nil {
_ = w.Close()
return fmt.Errorf("smtp write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close data: %w", err)
}
return c.Quit()
}
func buildMessage(from, to, subject, body string) []byte {
var b bytes.Buffer
b.WriteString("From: " + from + "\r\n")
b.WriteString("To: " + to + "\r\n")
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", subject) + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n")
b.WriteString("\r\n")
b.WriteString(body)
return b.Bytes()
}
func humanTTL(ttl time.Duration) string {
if ttl <= 0 {
return "a short time"
}
if ttl%time.Minute == 0 {
minutes := int(ttl / time.Minute)
if minutes == 1 {
return "1 minute"
}
return fmt.Sprintf("%d minutes", minutes)
}
return ttl.String()
}

View file

@ -31,8 +31,19 @@ import (
"telesrv/internal/store/memory"
)
type loginEmailTestSender struct {
to string
code string
}
func (s *loginEmailTestSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error {
s.to = to
s.code = code
return nil
}
// TestLoginEmailEndToEnd 端到端验证登录邮箱:设备 A 注册并设置登录邮箱loginChange
// 一个全新设备 B 调 sendCode 收到 sentCodeTypeEmailCode凭任意邮箱验证码经 signIn
// 一个全新设备 B 调 sendCode 收到 sentCodeTypeEmailCode真实邮箱验证码经 signIn
// (email_verification) 完成登录。
func TestLoginEmailEndToEnd(t *testing.T) {
const (
@ -59,10 +70,23 @@ func TestLoginEmailEndToEnd(t *testing.T) {
authKeyStore := memory.NewAuthKeyStore()
passwordStore := memory.NewPasswordStore()
helpStore := memory.NewHelpStore()
codeStore := memory.NewCodeStore()
emailSender := &loginEmailTestSender{}
accountService := account.NewService(passwordStore,
account.WithUsers(userStore),
account.WithLoginEmailVerification(codeStore, emailSender, 5*time.Minute, 5, 6))
authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, memory.NewTempAuthKeyBindingStore(), code,
auth.WithPasswords(passwordStore),
auth.WithLoginEmail(auth.LoginEmailOptions{
Enabled: true,
CodeLength: 6,
Store: accountService,
Sender: emailSender,
}))
deps := rpc.Deps{
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), code, auth.WithPasswords(passwordStore)),
Account: account.NewService(passwordStore, account.WithUsers(userStore)),
Auth: authService,
Account: accountService,
Help: help.NewService(helpStore, helpStore),
Users: users.NewService(userStore),
Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()),
@ -125,7 +149,7 @@ func TestLoginEmailEndToEnd(t *testing.T) {
}
verified, err := raw.AccountVerifyEmail(ctx, &tg.AccountVerifyEmailRequest{
Purpose: &tg.EmailVerifyPurposeLoginChange{},
Verification: &tg.EmailVerificationCode{Code: "whatever"},
Verification: &tg.EmailVerificationCode{Code: emailSender.code},
})
if err != nil {
return err
@ -176,7 +200,7 @@ func TestLoginEmailEndToEnd(t *testing.T) {
signInRes, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{
PhoneNumber: phone,
PhoneCodeHash: sentCode.PhoneCodeHash,
EmailVerification: &tg.EmailVerificationCode{Code: "any-email-code"},
EmailVerification: &tg.EmailVerificationCode{Code: emailSender.code},
})
if err != nil {
return err

View file

@ -416,8 +416,7 @@ func (r *Router) onAccountCancelPasswordEmail(ctx context.Context) (bool, error)
}
// onAccountSendVerifyEmailCode 处理 account.sendVerifyEmailCode为登录邮箱的设置/变更
// 发送验证码。开发环境不真正发邮件、验证码任意,故此处直接持久化待确认的登录邮箱地址,
// 由后续 verifyEmail 做确认回显。loginChange 走已登录用户loginSetup 走登录流程中的手机号。
// 发送邮箱验证码。loginChange 走已登录用户loginSetup 走登录流程中的手机号 + phone_code_hash。
func (r *Router) onAccountSendVerifyEmailCode(ctx context.Context, req *tg.AccountSendVerifyEmailCodeRequest) (*tg.AccountSentEmailCode, error) {
if r.deps.Account == nil {
return nil, internalErr()
@ -435,29 +434,32 @@ func (r *Router) onAccountSendVerifyEmailCode(ctx context.Context, req *tg.Accou
if userID == 0 {
return nil, authKeyUnregisteredErr()
}
if err := r.deps.Account.SetLoginEmail(ctx, userID, email); err != nil {
pattern, length, err := r.deps.Account.SendLoginEmailCode(ctx, userID, "", "", email, false)
if err != nil {
return nil, passwordErr(err)
}
return &tg.AccountSentEmailCode{EmailPattern: pattern, Length: length}, nil
case *tg.EmailVerifyPurposeLoginSetup:
if err := r.deps.Account.SetLoginEmailByPhone(ctx, p.PhoneNumber, email); err != nil {
pattern, length, err := r.deps.Account.SendLoginEmailCode(ctx, 0, p.PhoneNumber, p.PhoneCodeHash, email, true)
if err != nil {
return nil, passwordErr(err)
}
return &tg.AccountSentEmailCode{EmailPattern: pattern, Length: length}, nil
default:
return nil, emailInvalidErr()
}
return &tg.AccountSentEmailCode{EmailPattern: domain.MaskEmail(email), Length: devCodeLength}, nil
}
// onAccountVerifyEmail 处理 account.verifyEmail确认登录邮箱验证码任意非空即通过)
// onAccountVerifyEmail 处理 account.verifyEmail确认登录邮箱验证码。
// loginChange已登录返回 emailVerified{email}loginSetup登录流程中返回
// emailVerifiedLogin{email, sent_code},其中 sent_code 是供客户端继续手机登录的新验证码。
// emailVerifiedLogin{email, sent_code}。TDesktop 能消费嵌套 auth.sentCodeSuccess
// 直接进入注册/登录完成DrKLO Android 12.8.1 该路径漏处理 sentCodeSuccess
// 临时降级为普通 emailCode sentCode待 Android 补齐后移除。
func (r *Router) onAccountVerifyEmail(ctx context.Context, req *tg.AccountVerifyEmailRequest) (tg.AccountEmailVerifiedClass, error) {
if r.deps.Account == nil {
return nil, internalErr()
}
if strings.TrimSpace(emailVerificationCode(req.Verification)) == "" {
return nil, emailCodeInvalidErr()
}
code := emailVerificationCode(req.Verification)
switch p := req.Purpose.(type) {
case *tg.EmailVerifyPurposeLoginChange:
userID, _, err := r.currentUserID(ctx)
@ -467,30 +469,36 @@ func (r *Router) onAccountVerifyEmail(ctx context.Context, req *tg.AccountVerify
if userID == 0 {
return nil, authKeyUnregisteredErr()
}
email, found, err := r.deps.Account.LoginEmail(ctx, userID)
email, err := r.deps.Account.VerifyLoginEmail(ctx, userID, "", "", code, false)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, emailCodeInvalidErr()
return nil, passwordErr(err)
}
return &tg.AccountEmailVerified{Email: email}, nil
case *tg.EmailVerifyPurposeLoginSetup:
email, found, err := r.deps.Account.LoginEmailByPhone(ctx, p.PhoneNumber)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, emailCodeInvalidErr()
}
if r.deps.Auth == nil {
return nil, internalErr()
}
hash, err := r.deps.Auth.SendCode(ctx, p.PhoneNumber)
email, err := r.deps.Account.VerifyLoginEmail(ctx, 0, p.PhoneNumber, p.PhoneCodeHash, code, true)
if err != nil {
return nil, internalErr()
return nil, passwordErr(err)
}
return &tg.AccountEmailVerifiedLogin{Email: email, SentCode: tgSentCode(hash)}, nil
if ClientTypeFrom(ctx) == ClientTypeAndroid {
return &tg.AccountEmailVerifiedLogin{
Email: email,
SentCode: tgEmailSentCode(p.PhoneCodeHash, domain.MaskEmail(email), len(strings.TrimSpace(code))),
}, nil
}
u, loginMessage, needSignUp, signInErr := r.deps.Auth.SignInWithEmail(ctx, r.authzFromCtx(ctx), p.PhoneNumber, p.PhoneCodeHash, code)
authorization, err := r.finishAuthSignIn(ctx, u, loginMessage, needSignUp, signInErr)
if err != nil {
return nil, err
}
return &tg.AccountEmailVerifiedLogin{
Email: email,
SentCode: &tg.AuthSentCodeSuccess{
Authorization: authorization,
},
}, nil
default:
return nil, emailInvalidErr()
}

View file

@ -241,6 +241,7 @@ func (r *Router) pushLoginTokenAccepted(ctx context.Context, target loginTokenTa
// 若该手机号账号设置了登录邮箱,验证码改投递到邮箱,返回 sentCodeTypeEmailCode
// (客户端据此进入"输入邮箱验证码"界面,随后用 auth.signIn 的 email_verification 完成登录)。
func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest) (tg.AuthSentCodeClass, error) {
r.rememberClientAPIID(ctx, req.APIID)
hash, err := r.deps.Auth.SendCode(ctx, req.PhoneNumber)
if err != nil {
if errors.Is(err, auth.ErrPhoneNumberInvalid) ||
@ -249,35 +250,30 @@ func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest
}
return nil, internalErr()
}
if pattern, ok := r.loginEmailPattern(ctx, req.PhoneNumber); ok {
return tgEmailSentCode(hash, pattern), nil
}
return tgSentCode(hash), nil
}
// loginEmailPattern 返回该手机号账号已确认登录邮箱的掩码,不存在则 ok=false。
func (r *Router) loginEmailPattern(ctx context.Context, phone string) (string, bool) {
if r.deps.Account == nil {
return "", false
}
email, found, err := r.deps.Account.LoginEmailByPhone(ctx, phone)
if err != nil || !found || email == "" {
return "", false
}
return domain.MaskEmail(email), true
return r.tgSentCodeForHash(ctx, hash)
}
func tgSentCode(hash string) tg.AuthSentCodeClass {
return tgSentCodeWithLength(hash, devCodeLength)
}
func tgSentCodeWithLength(hash string, length int) tg.AuthSentCodeClass {
if length <= 0 {
length = devCodeLength
}
return &tg.AuthSentCode{
Type: &tg.AuthSentCodeTypeApp{Length: devCodeLength},
Type: &tg.AuthSentCodeTypeApp{Length: length},
PhoneCodeHash: hash,
}
}
func tgEmailSentCode(hash, emailPattern string) tg.AuthSentCodeClass {
func tgEmailSentCode(hash, emailPattern string, length int) tg.AuthSentCodeClass {
if length <= 0 {
length = devCodeLength
}
codeType := &tg.AuthSentCodeTypeEmailCode{
EmailPattern: emailPattern,
Length: devCodeLength,
Length: length,
}
// reset_available_period=0 表示可立即调用 auth.resetLoginEmail开发环境无等待期
// 让客户端的"无法访问邮箱?"逃生入口可用。
@ -288,6 +284,34 @@ func tgEmailSentCode(hash, emailPattern string) tg.AuthSentCodeClass {
}
}
func tgEmailSetupRequiredSentCode(hash string) tg.AuthSentCodeClass {
return &tg.AuthSentCode{
Type: &tg.AuthSentCodeTypeSetUpEmailRequired{},
PhoneCodeHash: hash,
}
}
func (r *Router) tgSentCodeForHash(ctx context.Context, hash string) (tg.AuthSentCodeClass, error) {
if r.deps.Auth == nil {
return tgSentCode(hash), nil
}
delivery, found, err := r.deps.Auth.CodeDelivery(ctx, hash)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, signInErr(auth.ErrCodeExpired)
}
switch delivery.Kind {
case domain.AuthCodeDeliveryEmail:
return tgEmailSentCode(hash, delivery.EmailPattern, delivery.Length), nil
case domain.AuthCodeDeliveryEmailSetupRequired:
return tgEmailSetupRequiredSentCode(hash), nil
default:
return tgSentCodeWithLength(hash, delivery.Length), nil
}
}
// onAuthSignIn 处理 auth.signIn校验验证码用户不存在时返回 SignUpRequired。
// 带 email_verification 时走登录邮箱路径(验证码来自邮箱而非短信)。
func (r *Router) onAuthSignIn(ctx context.Context, req *tg.AuthSignInRequest) (tg.AuthAuthorizationClass, error) {
@ -302,6 +326,10 @@ func (r *Router) onAuthSignIn(ctx context.Context, req *tg.AuthSignInRequest) (t
} else {
u, loginMessage, needSignUp, err = r.deps.Auth.SignIn(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.PhoneCode)
}
return r.finishAuthSignIn(ctx, u, loginMessage, needSignUp, err)
}
func (r *Router) finishAuthSignIn(ctx context.Context, u domain.User, loginMessage domain.Message, needSignUp bool, err error) (tg.AuthAuthorizationClass, error) {
if err != nil {
if errors.Is(err, domain.ErrSessionPasswordNeeded) && u.ID != 0 {
if err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil {
@ -337,7 +365,7 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
if err != nil {
return nil, signInErr(err)
}
return tgSentCode(hash), nil
return r.tgSentCodeForHash(ctx, hash)
}
func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) {
@ -502,7 +530,7 @@ func (r *Router) onAuthResetLoginEmail(ctx context.Context, req *tg.AuthResetLog
}
return nil, internalErr()
}
return tgSentCode(hash), nil
return r.tgSentCodeForHash(ctx, hash)
}
// emailVerificationCode 从 emailVerification 取出可校验的字符串(验证码 / Google·Apple

View file

@ -0,0 +1,145 @@
package rpc
import (
"context"
"testing"
"time"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
type loginEmailAccountService struct {
AccountService
verifiedEmail string
}
func (s loginEmailAccountService) VerifyLoginEmail(context.Context, int64, string, string, string, bool) (string, error) {
return s.verifiedEmail, nil
}
func TestEmailSentCodeUsesDeliveryLength(t *testing.T) {
authSvc := &captureAuthService{
codeDelivery: domain.AuthCodeDelivery{
Kind: domain.AuthCodeDeliveryEmail,
EmailPattern: "a***e@example.test",
Length: 6,
},
}
r := New(Config{}, Deps{Auth: authSvc}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000000, 0)})
sent, err := r.tgSentCodeForHash(context.Background(), "hash-email")
if err != nil {
t.Fatalf("tgSentCodeForHash: %v", err)
}
code, ok := sent.(*tg.AuthSentCode)
if !ok {
t.Fatalf("sent = %T, want *tg.AuthSentCode", sent)
}
emailType, ok := code.Type.(*tg.AuthSentCodeTypeEmailCode)
if !ok {
t.Fatalf("sent type = %T, want *tg.AuthSentCodeTypeEmailCode", code.Type)
}
if emailType.Length != 6 {
t.Fatalf("email sent code length = %d, want 6", emailType.Length)
}
}
func TestAccountVerifyEmailLoginSetupReturnsSentCodeSuccess(t *testing.T) {
user := domain.User{
ID: 100200300,
AccessHash: 900100200,
Phone: "8618800000020",
FirstName: "Alice",
}
authSvc := &captureAuthService{signInUser: user}
r := New(Config{}, Deps{
Auth: authSvc,
Account: loginEmailAccountService{verifiedEmail: "alice@example.test"},
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000000, 0)})
got, err := r.onAccountVerifyEmail(context.Background(), &tg.AccountVerifyEmailRequest{
Purpose: &tg.EmailVerifyPurposeLoginSetup{
PhoneNumber: "+86 188 0000 0020",
PhoneCodeHash: "hash-email-setup",
},
Verification: &tg.EmailVerificationCode{Code: "654321"},
})
if err != nil {
t.Fatalf("onAccountVerifyEmail: %v", err)
}
verified, ok := got.(*tg.AccountEmailVerifiedLogin)
if !ok {
t.Fatalf("verified = %T, want *tg.AccountEmailVerifiedLogin", got)
}
if verified.Email != "alice@example.test" {
t.Fatalf("verified email = %q", verified.Email)
}
success, ok := verified.SentCode.(*tg.AuthSentCodeSuccess)
if !ok {
t.Fatalf("sent code = %T, want *tg.AuthSentCodeSuccess", verified.SentCode)
}
authorization, ok := success.Authorization.(*tg.AuthAuthorization)
if !ok {
t.Fatalf("authorization = %T, want *tg.AuthAuthorization", success.Authorization)
}
self, ok := authorization.User.(*tg.User)
if !ok {
t.Fatalf("authorization user = %T, want *tg.User", authorization.User)
}
if self.ID != user.ID || !self.Self {
t.Fatalf("authorization user = %+v, want self user %d", self, user.ID)
}
if authSvc.signInWithEmailCount != 1 {
t.Fatalf("SignInWithEmail calls = %d, want 1", authSvc.signInWithEmailCount)
}
}
func TestAccountVerifyEmailLoginSetupAndroidReturnsEmailSentCode(t *testing.T) {
authSvc := &captureAuthService{}
r := New(Config{}, Deps{
Auth: authSvc,
Account: loginEmailAccountService{verifiedEmail: "alice@example.test"},
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000000, 0)})
ctx := WithClientInfo(context.Background(), ClientInfo{
Type: ClientTypeAndroid,
AppVersion: "12.8.1 (69169) pbeta",
})
got, err := r.onAccountVerifyEmail(ctx, &tg.AccountVerifyEmailRequest{
Purpose: &tg.EmailVerifyPurposeLoginSetup{
PhoneNumber: "+86 188 0000 0020",
PhoneCodeHash: "hash-email-setup",
},
Verification: &tg.EmailVerificationCode{Code: "654321"},
})
if err != nil {
t.Fatalf("onAccountVerifyEmail: %v", err)
}
verified, ok := got.(*tg.AccountEmailVerifiedLogin)
if !ok {
t.Fatalf("verified = %T, want *tg.AccountEmailVerifiedLogin", got)
}
sent, ok := verified.SentCode.(*tg.AuthSentCode)
if !ok {
t.Fatalf("sent code = %T, want *tg.AuthSentCode", verified.SentCode)
}
if sent.PhoneCodeHash != "hash-email-setup" {
t.Fatalf("phone_code_hash = %q", sent.PhoneCodeHash)
}
emailType, ok := sent.Type.(*tg.AuthSentCodeTypeEmailCode)
if !ok {
t.Fatalf("sent type = %T, want *tg.AuthSentCodeTypeEmailCode", sent.Type)
}
if emailType.EmailPattern != "a***e@example.test" {
t.Fatalf("email pattern = %q", emailType.EmailPattern)
}
if emailType.Length != 6 {
t.Fatalf("email code length = %d, want 6", emailType.Length)
}
if authSvc.signInWithEmailCount != 0 {
t.Fatalf("SignInWithEmail calls = %d, want 0 for Android compat downgrade", authSvc.signInWithEmailCount)
}
}

View file

@ -2,12 +2,13 @@ package rpc
import "context"
// withAndroidCompatMetadata 为「客户端构造器漂移」请求仅兜底 client 类型。
// DrKLO/OwpenGram Android 可能在不同版本使用不同 TL layerclient-private 构造器
// 只能证明这是 Android 兼容路径,不能替代 invokeWithLayer 里的真实 layer。
func (r *Router) withAndroidCompatMetadata(ctx context.Context) context.Context {
if ClientTypeFrom(ctx) == ClientTypeUnknown {
ctx = WithClientInfo(ctx, ClientInfo{LangPack: string(ClientTypeAndroid), Type: ClientTypeAndroid})
}
// withClientDriftMetadata 只在调用方已经用 constructor drift 证明客户端来源时
// 补最小 client 类型。它不是 unknown fallback不能在普通裸 RPC 上调用。
// DrKLO Android 的 client-private constructor 只能证明 Android 兼容路径,
// 不能替代 invokeWithLayer/auth_keys/authorizations 里的真实 layer。
func (r *Router) withClientDriftMetadata(ctx context.Context, typ ClientType) context.Context {
if typ == ClientTypeUnknown || ClientTypeFrom(ctx) != ClientTypeUnknown {
return ctx
}
return WithClientInfo(ctx, ClientInfo{Type: typ})
}

View file

@ -97,7 +97,23 @@ func knownClientType(t ClientType) bool {
}
}
func clientTypeFromAPIID(apiID int) ClientType {
switch apiID {
// DrKLO local BuildVars.APP_ID uses 4; TDesktop's active session
// classifier also recognizes the official Android ids below.
case 4, 5, 6, 24, 1026, 1083, 2458, 2521, 21724:
return ClientTypeAndroid
case 2040, 17349, 611335:
return ClientTypeTDesktop
default:
return ClientTypeUnknown
}
}
func detectClientType(info ClientInfo) ClientType {
if t := clientTypeFromAPIID(info.APIID); t != ClientTypeUnknown {
return t
}
if strings.EqualFold(info.LangPack, string(ClientTypeAndroid)) {
return ClientTypeAndroid
}

View file

@ -25,6 +25,7 @@ type AuthService interface {
PendingPasswordUserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error)
CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte) error
SendCode(ctx context.Context, phone string) (string, error)
CodeDelivery(ctx context.Context, phoneCodeHash string) (domain.AuthCodeDelivery, bool, error)
ResendCode(ctx context.Context, phone, phoneCodeHash string) (string, error)
CancelCode(ctx context.Context, phone, phoneCodeHash string) error
SignIn(ctx context.Context, a domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error)
@ -40,6 +41,8 @@ type AuthService interface {
LogOut(ctx context.Context, authKeyID [8]byte) error
Authorization(ctx context.Context, authKeyID [8]byte) (domain.Authorization, bool, error)
UpdateAuthorizationLayer(ctx context.Context, authKeyID [8]byte, layer int) error
AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (domain.AuthKeyClientInfo, bool, error)
UpdateAuthKeyClientInfo(ctx context.Context, authKeyID [8]byte, info domain.AuthKeyClientInfo) error
ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error)
ResetAuthorization(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error)
ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
@ -262,6 +265,8 @@ type AccountService interface {
ResendPasswordEmail(ctx context.Context, userID int64) error
CancelPasswordEmail(ctx context.Context, userID int64) error
// 登录邮箱(独立于 2FA 恢复邮箱authed 走 userID登录流程/重置走 phone。
SendLoginEmailCode(ctx context.Context, userID int64, phone, phoneCodeHash, email string, setup bool) (string, int, error)
VerifyLoginEmail(ctx context.Context, userID int64, phone, phoneCodeHash, code string, setup bool) (string, error)
SetLoginEmail(ctx context.Context, userID int64, email string) error
SetLoginEmailByPhone(ctx context.Context, phone, email string) error
LoginEmail(ctx context.Context, userID int64) (string, bool, error)

View file

@ -261,6 +261,7 @@ func srpPasswordChangedErr() error { return tgerr.New(400, "SRP_PASSWORD_CHAN
func newSettingsInvalidErr() error { return tgerr.New(400, "NEW_SETTINGS_INVALID") }
func newSaltInvalidErr() error { return tgerr.New(400, "NEW_SALT_INVALID") }
func emailInvalidErr() error { return tgerr.New(400, "EMAIL_INVALID") }
func emailNotAllowedErr() error { return tgerr.New(400, "EMAIL_NOT_ALLOWED") }
func emailCodeInvalidErr() error { return tgerr.New(400, "CODE_INVALID") }
func passwordRecoveryNAErr() error { return tgerr.New(400, "PASSWORD_RECOVERY_NA") }
@ -393,6 +394,10 @@ func passwordErr(err error) error {
return newSaltInvalidErr()
case errors.Is(err, domain.ErrEmailInvalid):
return emailInvalidErr()
case errors.Is(err, domain.ErrEmailOccupied):
return emailNotAllowedErr()
case errors.Is(err, domain.ErrEmailNotAllowed):
return emailNotAllowedErr()
case errors.Is(err, domain.ErrEmailCodeInvalid):
return emailCodeInvalidErr()
case errors.Is(err, domain.ErrPasswordRecoveryNA):

View file

@ -0,0 +1,15 @@
package rpc
import (
"testing"
"github.com/gotd/td/tgerr"
"telesrv/internal/domain"
)
func TestPasswordErrMapsOccupiedLoginEmailToNotAllowed(t *testing.T) {
if err := passwordErr(domain.ErrEmailOccupied); !tgerr.Is(err, "EMAIL_NOT_ALLOWED") {
t.Fatalf("passwordErr(ErrEmailOccupied) = %v, want EMAIL_NOT_ALLOWED", err)
}
}

View file

@ -36,6 +36,7 @@ type tempResolveResult struct {
const (
authKeyResolveSingleflightPrefix = "resolve:"
authClientInfoSingleflightPrefix = "authinfo:"
authKeyClientInfoSingleflightPrefix = "authkeyinfo:"
)
var (
@ -154,6 +155,7 @@ type clientSessionInfo struct {
layer int
clientInfo ClientInfo
hasClientInfo bool
authKeyInfoChecked bool
authorizationChecked bool
}
@ -226,6 +228,19 @@ func (r *Router) Dispatch(ctx context.Context, authKeyID [8]byte, sessionID int6
}
tUser := r.clock.Now()
info, hasClientMetadata, clientMetadataStored := r.clientSessionInfo(ctx)
if authInfo, ok := r.clientSessionInfoFromAuthKey(ctx, effectiveAuthKeyID, info); ok {
info = mergeClientSessionInfo(info, authInfo)
hasClientMetadata = true
r.rememberClientSessionInfo(ctx, info)
clientMetadataStored = true
if info.layer != 0 {
if binder, okBinder := r.deps.Sessions.(ClientLayerBinder); okBinder {
if rawAuthKeyID, okRaw := RawAuthKeyIDFrom(ctx); okRaw {
binder.SetClientLayerForAuthKey(rawAuthKeyID, sessionID, info.layer)
}
}
}
}
if hasUserID {
if authInfo, ok := r.clientSessionInfoFromAuthorization(ctx, userID, effectiveAuthKeyID, info); ok {
info = mergeClientSessionInfo(info, authInfo)
@ -501,6 +516,7 @@ func (r *Router) invalidateAuthUserCache(authKeyID [8]byte) {
r.authUserSF.Forget(key)
r.authUserSF.Forget(authKeyResolveSingleflightPrefix + key)
r.authUserSF.Forget(authClientInfoSingleflightPrefix + key)
r.authUserSF.Forget(authKeyClientInfoSingleflightPrefix + key)
}
func (r *Router) scopedSessions() (ScopedSessionBinder, bool) {
@ -619,7 +635,7 @@ func (r *Router) dispatch(ctx context.Context, b *bin.Buffer, depth int) (bin.En
if clientDrift {
// 客户端漂移只能证明这是 Android 兼容路径layer 仍以
// invokeWithLayer 或授权记录里的真实观测值为准。
ctx = r.withAndroidCompatMetadata(ctx)
ctx = r.withClientDriftMetadata(ctx, ClientTypeAndroid)
}
}
}
@ -699,6 +715,17 @@ func (r *Router) rememberClientInfo(ctx context.Context, info ClientInfo) {
sessionInfo.layer = layer
}
})
r.persistAuthKeyClientInfo(ctx, clientSessionInfo{layer: layer, clientInfo: info, hasClientInfo: true})
}
func (r *Router) rememberClientAPIID(ctx context.Context, apiID int) {
if apiID == 0 {
return
}
info := ClientInfo{APIID: apiID, Type: clientTypeFromAPIID(apiID)}
sessionInfo := clientSessionInfo{clientInfo: info, hasClientInfo: true}
r.rememberClientSessionInfo(ctx, sessionInfo)
r.persistAuthKeyClientInfo(ctx, sessionInfo)
}
func (r *Router) rememberClientLayer(ctx context.Context, layer int) {
@ -747,6 +774,7 @@ func (r *Router) rememberClientLayer(ctx context.Context, layer int) {
binder.SetClientLayerForAuthKey(rawAuthKeyID, sessionID, layer)
}
}
r.persistAuthKeyClientInfo(ctx, clientSessionInfo{layer: layer})
if persistAuthLayer && r.deps.Auth != nil {
if err := r.deps.Auth.UpdateAuthorizationLayer(ctx, authKeyID, layer); err != nil {
r.log.Warn("update authorization layer failed",
@ -757,6 +785,36 @@ func (r *Router) rememberClientLayer(ctx context.Context, layer int) {
}
}
func (r *Router) persistAuthKeyClientInfo(ctx context.Context, info clientSessionInfo) {
if r.deps.Auth == nil {
return
}
domainInfo := domainAuthKeyClientInfo(info)
if domainInfo.Layer == 0 && domainInfo.DeviceModel == "" && domainInfo.Platform == "" &&
domainInfo.SystemVersion == "" && domainInfo.APIID == 0 && domainInfo.AppVersion == "" {
return
}
seen := make(map[[8]byte]struct{}, 2)
if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok && rawAuthKeyID != ([8]byte{}) {
seen[rawAuthKeyID] = struct{}{}
if err := r.deps.Auth.UpdateAuthKeyClientInfo(ctx, rawAuthKeyID, domainInfo); err != nil {
r.log.Warn("update auth key client info failed",
zap.String("auth_key_id", fmt.Sprintf("%x", rawAuthKeyID[:])),
zap.Error(err))
}
}
if authKeyID, ok := AuthKeyIDFrom(ctx); ok && authKeyID != ([8]byte{}) {
if _, done := seen[authKeyID]; done {
return
}
if err := r.deps.Auth.UpdateAuthKeyClientInfo(ctx, authKeyID, domainInfo); err != nil {
r.log.Warn("update auth key client info failed",
zap.String("auth_key_id", fmt.Sprintf("%x", authKeyID[:])),
zap.Error(err))
}
}
}
// NegotiatedLayer returns the TL layer the given session negotiated via
// invokeWithLayer/initConnection. It is keyed first by (auth_key, session) then
// falls back to the stable auth_key — so a reconnect with a new session_id still
@ -892,6 +950,9 @@ func clientSessionInfoContains(current, required clientSessionInfo) bool {
if required.authorizationChecked && !current.authorizationChecked {
return false
}
if required.authKeyInfoChecked && !current.authKeyInfoChecked {
return false
}
return true
}
@ -982,6 +1043,43 @@ func (r *Router) cachedResolvedAuthClientInfo(authKeyID [8]byte) (clientSessionI
return info, true
}
func (r *Router) cachedResolvedAuthKeyClientInfo(authKeyID [8]byte) (clientSessionInfo, bool) {
r.clientInfoMu.RLock()
defer r.clientInfoMu.RUnlock()
info, ok := r.authInfo[authKeyID]
if !ok || clientSessionInfoNeedsAuthKeyInfo(info) {
return clientSessionInfo{}, false
}
return info, true
}
func (r *Router) clientSessionInfoFromAuthKey(ctx context.Context, authKeyID [8]byte, current clientSessionInfo) (clientSessionInfo, bool) {
if !clientSessionInfoNeedsAuthKeyInfo(current) || r.deps.Auth == nil || authKeyID == ([8]byte{}) {
return clientSessionInfo{}, false
}
v, err, _ := r.authUserSF.Do(authKeyClientInfoSingleflightPrefix+string(authKeyID[:]), func() (any, error) {
if cached, ok := r.cachedResolvedAuthKeyClientInfo(authKeyID); ok {
return cached, nil
}
info, found, err := r.deps.Auth.AuthKeyClientInfo(ctx, authKeyID)
if err != nil {
return clientSessionInfo{}, err
}
if !found {
return clientSessionInfo{authKeyInfoChecked: true}, nil
}
return clientSessionInfoFromAuthKeyClientInfo(info, current), nil
})
if err != nil {
return clientSessionInfo{}, false
}
info := v.(clientSessionInfo)
if info.layer == 0 && !info.hasClientInfo && !info.authKeyInfoChecked {
return clientSessionInfo{}, false
}
return info, true
}
func mergeClientSessionInfo(base, fallback clientSessionInfo) clientSessionInfo {
if base.layer == 0 {
base.layer = fallback.layer
@ -993,9 +1091,48 @@ func mergeClientSessionInfo(base, fallback clientSessionInfo) clientSessionInfo
if fallback.authorizationChecked {
base.authorizationChecked = true
}
if fallback.authKeyInfoChecked {
base.authKeyInfoChecked = true
}
return base
}
func clientSessionInfoFromAuthKeyClientInfo(item domain.AuthKeyClientInfo, current clientSessionInfo) clientSessionInfo {
info := clientSessionInfo{
layer: item.Layer,
authKeyInfoChecked: true,
clientInfo: ClientInfo{
APIID: item.APIID,
DeviceModel: item.DeviceModel,
SystemVersion: item.SystemVersion,
AppVersion: item.AppVersion,
Type: ClientType(item.Platform),
},
}
info.clientInfo = normalizeClientInfo(info.clientInfo)
info.hasClientInfo = info.clientInfo.ClientType() != ClientTypeUnknown ||
info.clientInfo.DeviceModel != "" ||
info.clientInfo.SystemVersion != "" ||
info.clientInfo.AppVersion != "" ||
info.clientInfo.APIID != 0
if info.layer == 0 && current.layer != 0 {
info.layer = current.layer
}
return info
}
func domainAuthKeyClientInfo(info clientSessionInfo) domain.AuthKeyClientInfo {
out := domain.AuthKeyClientInfo{Layer: info.layer}
if info.hasClientInfo {
out.APIID = info.clientInfo.APIID
out.DeviceModel = info.clientInfo.DeviceModel
out.SystemVersion = info.clientInfo.SystemVersion
out.AppVersion = info.clientInfo.AppVersion
out.Platform = string(info.clientInfo.ClientType())
}
return out
}
func (r *Router) clientSessionInfoFromAuthorization(ctx context.Context, userID int64, authKeyID [8]byte, current clientSessionInfo) (clientSessionInfo, bool) {
if !clientSessionInfoNeedsAuthorization(current) || r.deps.Auth == nil || userID == 0 {
return clientSessionInfo{}, false
@ -1052,6 +1189,13 @@ func clientSessionInfoNeedsAuthorization(info clientSessionInfo) bool {
return info.layer == 0 || !info.hasClientInfo || info.clientInfo.ClientType() == ClientTypeUnknown
}
func clientSessionInfoNeedsAuthKeyInfo(info clientSessionInfo) bool {
if info.authKeyInfoChecked {
return false
}
return info.layer == 0 || !info.hasClientInfo || info.clientInfo.ClientType() == ClientTypeUnknown
}
// fallback 处理未注册的 RPC记录到 compatibility trace落兼容矩阵
// 返回 NOT_IMPLEMENTED rpc_error 让客户端继续运行而非断连。
func (r *Router) fallback(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) {

View file

@ -188,6 +188,146 @@ func TestDispatchRemembersLayerAndClientTypeForSession(t *testing.T) {
}
}
func TestDispatchPersistsPreLoginClientMetadataOnInitConnection(t *testing.T) {
auth := &captureAuthService{}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Auth: auth,
}, zaptest.NewLogger(t), clock.System)
rawAuthKeyID := [8]byte{0x22, 0xdb, 0xcf, 0xc8, 0x0d, 0x4c, 0x77, 0x97}
sessionID := int64(8103956954238395544)
req := &tg.InvokeWithLayerRequest{
Layer: currentClientLayer,
Query: &tg.InitConnectionRequest{
APIID: 4,
DeviceModel: "GooglePixel 9a",
SystemVersion: "SDK 36",
AppVersion: "12.8.1 (69169) pbeta",
SystemLangCode: "en",
LangPack: "android",
LangCode: "en",
Query: &tg.HelpGetConfigRequest{},
},
}
var in bin.Buffer
if err := req.Encode(&in); err != nil {
t.Fatalf("encode init request: %v", err)
}
if _, err := r.Dispatch(context.Background(), rawAuthKeyID, sessionID, &in); err != nil {
t.Fatalf("dispatch init request: %v", err)
}
got, ok := auth.authKeyClientInfos[rawAuthKeyID]
if !ok {
t.Fatalf("auth key client metadata was not persisted")
}
if got.Layer != currentClientLayer {
t.Fatalf("persisted layer = %d, want %d", got.Layer, currentClientLayer)
}
if got.Platform != string(ClientTypeAndroid) {
t.Fatalf("persisted platform = %q, want android", got.Platform)
}
if got.DeviceModel != "GooglePixel 9a" || got.SystemVersion != "SDK 36" || got.APIID != 4 || got.AppVersion != "12.8.1 (69169) pbeta" {
t.Fatalf("persisted client metadata = %+v", got)
}
}
func TestDispatchPersistsPreLoginClientMetadataFromSendCodeAPIID(t *testing.T) {
auth := &captureAuthService{}
rawAuthKeyID := [8]byte{0x33, 0xdb, 0xcf, 0xc8, 0x0d, 0x4c, 0x77, 0x97}
const sessionID = int64(8103956954238395544)
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Auth: auth,
}, zaptest.NewLogger(t), clock.System)
var sendCode bin.Buffer
if err := (&tg.AuthSendCodeRequest{
PhoneNumber: "+8618800000020",
APIID: 4,
APIHash: "android",
Settings: tg.CodeSettings{},
}).Encode(&sendCode); err != nil {
t.Fatalf("encode auth.sendCode: %v", err)
}
if _, err := r.Dispatch(context.Background(), rawAuthKeyID, sessionID, &sendCode); err != nil {
t.Fatalf("dispatch auth.sendCode: %v", err)
}
persisted, ok := auth.authKeyClientInfos[rawAuthKeyID]
if !ok {
t.Fatalf("auth.sendCode did not persist auth key client metadata")
}
if persisted.APIID != 4 || persisted.Platform != string(ClientTypeAndroid) {
t.Fatalf("persisted client metadata = %+v, want android api_id=4", persisted)
}
core, logs := observer.New(zap.DebugLevel)
afterRestart := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Auth: auth,
}, zap.New(core), clock.System)
var help bin.Buffer
if err := (&tg.HelpGetConfigRequest{}).Encode(&help); err != nil {
t.Fatalf("encode help.getConfig: %v", err)
}
if _, err := afterRestart.Dispatch(context.Background(), rawAuthKeyID, sessionID+1, &help); err != nil {
t.Fatalf("dispatch help.getConfig after restart: %v", err)
}
entries := logs.FilterMessage("RPC inner handled").All()
if len(entries) == 0 {
t.Fatalf("RPC inner handled log missing")
}
fields := entries[len(entries)-1].ContextMap()
if got := fields["client_type"]; got != string(ClientTypeAndroid) {
t.Fatalf("logged client_type = %v, want %s", got, ClientTypeAndroid)
}
}
func TestDispatchRestoresPreLoginAndroidMetadataFromAuthKey(t *testing.T) {
core, logs := observer.New(zap.DebugLevel)
authKeyID := [8]byte{0x22, 0xdb, 0xcf, 0xc8, 0x0d, 0x4c, 0x77, 0x97}
auth := &captureAuthService{
authKeyClientInfos: map[[8]byte]domain.AuthKeyClientInfo{
authKeyID: {
Layer: currentClientLayer,
DeviceModel: "GooglePixel 9a",
Platform: string(ClientTypeAndroid),
SystemVersion: "SDK 36",
APIID: 4,
AppVersion: "12.8.1 (69169) pbeta",
},
},
}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Auth: auth,
}, zap.New(core), clock.System)
var in bin.Buffer
if err := (&tg.HelpGetConfigRequest{}).Encode(&in); err != nil {
t.Fatalf("encode request: %v", err)
}
const sessionID = int64(8103956954238395544)
if _, err := r.Dispatch(context.Background(), authKeyID, sessionID, &in); err != nil {
t.Fatalf("dispatch plain request: %v", err)
}
entries := logs.FilterMessage("RPC inner handled").All()
if len(entries) == 0 {
t.Fatalf("RPC inner handled log missing")
}
fields := entries[len(entries)-1].ContextMap()
if got := intLogField(fields["layer"]); got != currentClientLayer {
t.Fatalf("logged layer = %d fields=%v, want %d", got, fields, currentClientLayer)
}
if got := fields["client_type"]; got != string(ClientTypeAndroid) {
t.Fatalf("logged client_type = %v, want %s", got, ClientTypeAndroid)
}
if got := fields["app_version"]; got != "12.8.1 (69169) pbeta" {
t.Fatalf("logged app_version = %v, want 12.8.1 (69169) pbeta", got)
}
if got, ok := r.NegotiatedLayer(authKeyID, sessionID+1); !ok || got != currentClientLayer {
t.Fatalf("auth-key fallback layer = (%d,%v), want (%d,true)", got, ok, currentClientLayer)
}
}
func TestAndroidLegacyCompatLogsClientMetadataWithoutInit(t *testing.T) {
core, logs := observer.New(zap.DebugLevel)
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zap.New(core), clock.System)
@ -210,9 +350,10 @@ func TestAndroidLegacyCompatLogsClientMetadataWithoutInit(t *testing.T) {
t.Fatalf("dispatch legacy updates.getDifference: %v", err)
}
// The legacy android constructor is upgraded by layerwire and dispatched
// normally; client metadata is still applied (withAndroidCompatMetadata for
// client drift), now surfaced on the standard "RPC inner handled" log.
// The legacy Android constructor is upgraded by layerwire and dispatched
// normally; client metadata is still applied only because IsClientDrift
// positively identified a DrKLO constructor, now surfaced on the standard
// "RPC inner handled" log.
entries := logs.FilterMessage("RPC inner handled").All()
if len(entries) == 0 {
t.Fatalf("RPC inner handled log missing")
@ -337,6 +478,16 @@ func TestClientTypeDetectsAndroidSDKVersion(t *testing.T) {
if got := info.ClientType(); got != ClientTypeUnknown {
t.Fatalf("gotd test client type = %s, want %s", got, ClientTypeUnknown)
}
info = normalizeClientInfo(ClientInfo{APIID: 4})
if got := info.ClientType(); got != ClientTypeAndroid {
t.Fatalf("DrKLO api_id=4 client type = %s, want %s", got, ClientTypeAndroid)
}
info = normalizeClientInfo(ClientInfo{APIID: 2040})
if got := info.ClientType(); got != ClientTypeTDesktop {
t.Fatalf("TDesktop api_id=2040 client type = %s, want %s", got, ClientTypeTDesktop)
}
}
func TestDispatchRestoresClientMetadataFromAuthorization(t *testing.T) {
@ -538,6 +689,28 @@ func TestDispatchCachesMissingClientMetadataAuthorizationLookup(t *testing.T) {
}
}
func TestDispatchCachesMissingAuthKeyClientMetadataLookup(t *testing.T) {
authKeyID := [8]byte{0x68, 0x25, 0xc2, 0xee, 0xf8, 0x82, 0xef, 0x72}
auth := &captureAuthService{}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Auth: auth,
}, zaptest.NewLogger(t), clock.System)
for _, sessionID := range []int64{101, 102, 103} {
var in bin.Buffer
if err := (&tg.HelpGetConfigRequest{}).Encode(&in); err != nil {
t.Fatalf("encode request: %v", err)
}
if _, err := r.Dispatch(context.Background(), authKeyID, sessionID, &in); err != nil {
t.Fatalf("dispatch session %d: %v", sessionID, err)
}
}
if auth.authKeyInfoLookups != 1 {
t.Fatalf("auth key client info lookups = %d, want 1 cached miss", auth.authKeyInfoLookups)
}
}
func TestCurrentUserIDUsesAuthUserCache(t *testing.T) {
authKeyID := [8]byte{0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42}
auth := &captureAuthService{userID: 1000000001}

View file

@ -25,11 +25,15 @@ type captureAuthService struct {
authorizationLookups int
authorizationLists int
layerUpdates int
authKeyClientInfos map[[8]byte]domain.AuthKeyClientInfo
authKeyInfoLookups int
loggedOutAuthKeyID [8]byte
pendingPasswordUserID int64
pendingPassword bool
completedPasswordKey [8]byte
completePasswordCount int
codeDelivery domain.AuthCodeDelivery
signInWithEmailCount int
}
type blockingUserAuthService struct {
@ -72,6 +76,10 @@ func (s *blockingUserAuthService) SendCode(context.Context, string) (string, err
return "", nil
}
func (s *blockingUserAuthService) CodeDelivery(context.Context, string) (domain.AuthCodeDelivery, bool, error) {
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliveryPhone, Length: devCodeLength}, true, nil
}
func (s *blockingUserAuthService) ResendCode(context.Context, string, string) (string, error) {
return "", nil
}
@ -116,6 +124,14 @@ func (s *blockingUserAuthService) UpdateAuthorizationLayer(context.Context, [8]b
return nil
}
func (s *blockingUserAuthService) AuthKeyClientInfo(context.Context, [8]byte) (domain.AuthKeyClientInfo, bool, error) {
return domain.AuthKeyClientInfo{}, false, nil
}
func (s *blockingUserAuthService) UpdateAuthKeyClientInfo(context.Context, [8]byte, domain.AuthKeyClientInfo) error {
return nil
}
func (s *blockingUserAuthService) ListAuthorizations(context.Context, int64) ([]domain.Authorization, error) {
return nil, nil
}
@ -154,6 +170,13 @@ func (s *captureAuthService) SendCode(context.Context, string) (string, error) {
return "", nil
}
func (s *captureAuthService) CodeDelivery(context.Context, string) (domain.AuthCodeDelivery, bool, error) {
if s.codeDelivery.Kind != "" {
return s.codeDelivery, true, nil
}
return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliveryPhone, Length: devCodeLength}, true, nil
}
func (s *captureAuthService) ResendCode(context.Context, string, string) (string, error) {
return "", nil
}
@ -170,6 +193,7 @@ func (s *captureAuthService) SignIn(context.Context, domain.Authorization, strin
}
func (s *captureAuthService) SignInWithEmail(context.Context, domain.Authorization, string, string, string) (domain.User, domain.Message, bool, error) {
s.signInWithEmailCount++
if s.signInUser.ID != 0 {
return s.signInUser, domain.Message{}, false, nil
}
@ -238,6 +262,39 @@ func (s *captureAuthService) UpdateAuthorizationLayer(_ context.Context, authKey
return nil
}
func (s *captureAuthService) AuthKeyClientInfo(_ context.Context, authKeyID [8]byte) (domain.AuthKeyClientInfo, bool, error) {
s.authKeyInfoLookups++
info, ok := s.authKeyClientInfos[authKeyID]
return info, ok, nil
}
func (s *captureAuthService) UpdateAuthKeyClientInfo(_ context.Context, authKeyID [8]byte, info domain.AuthKeyClientInfo) error {
if s.authKeyClientInfos == nil {
s.authKeyClientInfos = make(map[[8]byte]domain.AuthKeyClientInfo)
}
current := s.authKeyClientInfos[authKeyID]
if info.Layer > 0 {
current.Layer = info.Layer
}
if info.DeviceModel != "" {
current.DeviceModel = info.DeviceModel
}
if info.Platform != "" {
current.Platform = info.Platform
}
if info.SystemVersion != "" {
current.SystemVersion = info.SystemVersion
}
if info.APIID != 0 {
current.APIID = info.APIID
}
if info.AppVersion != "" {
current.AppVersion = info.AppVersion
}
s.authKeyClientInfos[authKeyID] = current
return nil
}
func (s *captureAuthService) ListAuthorizations(context.Context, int64) ([]domain.Authorization, error) {
s.authorizationLists++
return append([]domain.Authorization(nil), s.authorizations...), nil

View file

@ -9,6 +9,7 @@ import (
// PasswordStore 持久化账号 2FA/SRP 配置。
type PasswordStore interface {
GetByUser(ctx context.Context, userID int64) (domain.PasswordSettings, bool, error)
LoginEmailOwner(ctx context.Context, email string) (int64, bool, error)
Save(ctx context.Context, userID int64, settings domain.PasswordSettings) error
}

View file

@ -10,15 +10,33 @@ type AuthKeyData struct {
Value [256]byte // 2048-bit auth key
ServerSalt int64 // 密钥交换产出的初始 server salt
CreatedAt int64 // unix 秒
Layer int
DeviceModel string
Platform string
SystemVersion string
APIID int
AppVersion string
// 用户绑定不在此处auth_key 是协议产物授权auth_key↔user + 设备信息)由 authorization 承载P2
}
type AuthKeyClientInfo struct {
Layer int
DeviceModel string
Platform string
SystemVersion string
APIID int
AppVersion string
}
// AuthKeyStore 持久化 auth key。实现见 store/memory测试替身、store/postgres。
type AuthKeyStore interface {
// Save 保存或覆盖一条 auth key 记录。
Save(ctx context.Context, k AuthKeyData) error
// Get 按 auth_key_id 查询;不存在时 found=false。
Get(ctx context.Context, id [8]byte) (data AuthKeyData, found bool, err error)
// UpdateClientInfo 合并更新 auth key 的客户端协商元数据。
// 空字段不覆盖已有值layer/api_id 为 0 时不覆盖。
UpdateClientInfo(ctx context.Context, id [8]byte, info AuthKeyClientInfo) error
// Delete 删除一条 auth key 记录destroy_auth_key。不存在时静默成功。
// 连接层每帧按 auth_key_id 回查本接口,删除后该 key 的入站帧立即失效。
Delete(ctx context.Context, id [8]byte) error

View file

@ -5,10 +5,18 @@ import (
"time"
)
// PhoneCode 是一条登录验证码记录(与某次 sendCode 的 phone_code_hash 关联)。
// PhoneCode 是一条登录验证码记录(与某次 sendCode 的 phone_code_hash 或邮箱验证键关联)。
type PhoneCode struct {
Phone string
Code string
Channel string
Email string
PendingEmail string
Attempts int
MaxAttempts int
VerifiedEmail bool
RequireSignUp bool
LoginEmailHash string
}
// CodeStore 暂存登录验证码phone_code_hash → 手机号 + 验证码,带 TTL。
@ -16,5 +24,6 @@ type PhoneCode struct {
type CodeStore interface {
Set(ctx context.Context, phoneCodeHash string, code PhoneCode, ttl time.Duration) error
Get(ctx context.Context, phoneCodeHash string) (PhoneCode, bool, error)
Update(ctx context.Context, phoneCodeHash string, code PhoneCode) error
Del(ctx context.Context, phoneCodeHash string) error
}

View file

@ -34,6 +34,38 @@ func (s *AuthKeyStore) Get(_ context.Context, id [8]byte) (store.AuthKeyData, bo
return k, ok, nil
}
func (s *AuthKeyStore) UpdateClientInfo(_ context.Context, id [8]byte, info store.AuthKeyClientInfo) error {
s.mu.Lock()
k, ok := s.keys[id]
if ok {
mergeAuthKeyClientInfo(&k, info)
s.keys[id] = k
}
s.mu.Unlock()
return nil
}
func mergeAuthKeyClientInfo(k *store.AuthKeyData, info store.AuthKeyClientInfo) {
if info.Layer > 0 {
k.Layer = info.Layer
}
if info.DeviceModel != "" {
k.DeviceModel = info.DeviceModel
}
if info.Platform != "" {
k.Platform = info.Platform
}
if info.SystemVersion != "" {
k.SystemVersion = info.SystemVersion
}
if info.APIID != 0 {
k.APIID = info.APIID
}
if info.AppVersion != "" {
k.AppVersion = info.AppVersion
}
}
func (s *AuthKeyStore) Delete(_ context.Context, id [8]byte) error {
s.mu.Lock()
delete(s.keys, id)
@ -256,6 +288,18 @@ func (s *CodeStore) Get(_ context.Context, hash string) (store.PhoneCode, bool,
return e.code, true, nil
}
func (s *CodeStore) Update(_ context.Context, hash string, code store.PhoneCode) error {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.m[hash]
if !ok || time.Now().After(e.expires) {
return nil
}
e.code = code
s.m[hash] = e
return nil
}
func (s *CodeStore) Del(_ context.Context, hash string) error {
s.mu.Lock()
delete(s.m, hash)

View file

@ -3,6 +3,7 @@ package memory
import (
"context"
"sort"
"strings"
"sync"
"telesrv/internal/domain"
)
@ -75,11 +76,40 @@ func (s *PasswordStore) GetByUser(_ context.Context, userID int64) (domain.Passw
func (s *PasswordStore) Save(_ context.Context, userID int64, settings domain.PasswordSettings) error {
s.mu.Lock()
settings.LoginEmail = normalizeLoginEmail(settings.LoginEmail)
settings.LoginEmailPattern = domain.MaskEmail(settings.LoginEmail)
if settings.LoginEmail != "" {
for ownerUserID, existing := range s.m {
if ownerUserID != userID && strings.EqualFold(existing.LoginEmail, settings.LoginEmail) {
s.mu.Unlock()
return domain.ErrEmailOccupied
}
}
}
s.m[userID] = clonePasswordSettings(settings)
s.mu.Unlock()
return nil
}
func (s *PasswordStore) LoginEmailOwner(_ context.Context, email string) (int64, bool, error) {
email = normalizeLoginEmail(email)
if email == "" {
return 0, false, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
for userID, settings := range s.m {
if strings.EqualFold(settings.LoginEmail, email) {
return userID, true, nil
}
}
return 0, false, nil
}
func normalizeLoginEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
func clonePasswordSettings(in domain.PasswordSettings) domain.PasswordSettings {
out := in
if in.CurrentAlgo != nil {

View file

@ -5,14 +5,19 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
const accountPasswordsLoginEmailUniqueIdx = "account_passwords_login_email_lower_unique_idx"
// PasswordStore 用 PostgreSQL 实现 store.PasswordStore。
type PasswordStore struct {
db sqlcgen.DBTX
@ -70,7 +75,29 @@ WHERE user_id = $1`, userID)
return settings, true, nil
}
func (s *PasswordStore) LoginEmailOwner(ctx context.Context, email string) (int64, bool, error) {
email = normalizeStoredLoginEmail(email)
if email == "" {
return 0, false, nil
}
row := s.db.QueryRow(ctx, `
SELECT user_id
FROM account_passwords
WHERE login_email <> '' AND lower(login_email) = $1
LIMIT 1`, email)
var userID int64
if err := row.Scan(&userID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, false, nil
}
return 0, false, fmt.Errorf("get login email owner: %w", err)
}
return userID, true, nil
}
func (s *PasswordStore) Save(ctx context.Context, userID int64, settings domain.PasswordSettings) error {
settings.LoginEmail = normalizeStoredLoginEmail(settings.LoginEmail)
settings.LoginEmailPattern = domain.MaskEmail(settings.LoginEmail)
algo := settings.NewAlgo
if settings.CurrentAlgo != nil {
algo = *settings.CurrentAlgo
@ -117,11 +144,25 @@ ON CONFLICT (user_id) DO UPDATE SET
settings.RecoveryEmail, settings.RecoveryCode, recoveryExpires, settings.LoginEmail,
)
if err != nil {
if isAccountPasswordLoginEmailUnique(err) {
return domain.ErrEmailOccupied
}
return fmt.Errorf("upsert account password: %w", err)
}
return nil
}
func normalizeStoredLoginEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
func isAccountPasswordLoginEmailUnique(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) &&
pgErr.Code == pgerrcode.UniqueViolation &&
pgErr.ConstraintName == accountPasswordsLoginEmailUniqueIdx
}
func nonNilBytea(in []byte) []byte {
if in != nil {
return in

View file

@ -0,0 +1,40 @@
package postgres
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func TestPasswordStoreLoginEmailUniqueCaseInsensitivePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
passwords := NewPasswordStore(pool)
users := NewUserStore(pool)
suffix := randomSuffix(t)
u1, err := users.Create(ctx, domain.User{AccessHash: 101, Phone: "+1665" + suffix + "01", FirstName: "EmailOne"})
if err != nil {
t.Fatalf("create user1: %v", err)
}
u2, err := users.Create(ctx, domain.User{AccessHash: 102, Phone: "+1665" + suffix + "02", FirstName: "EmailTwo"})
if err != nil {
t.Fatalf("create user2: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM account_passwords WHERE user_id = ANY($1::bigint[])", []int64{u1.ID, u2.ID})
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{u1.ID, u2.ID})
})
if err := passwords.Save(ctx, u1.ID, domain.PasswordSettings{LoginEmail: "Owner@Example.Test"}); err != nil {
t.Fatalf("save user1 email: %v", err)
}
ownerID, found, err := passwords.LoginEmailOwner(ctx, "owner@example.test")
if err != nil || !found || ownerID != u1.ID {
t.Fatalf("LoginEmailOwner = id %d found %v err %v, want user1", ownerID, found, err)
}
if err := passwords.Save(ctx, u2.ID, domain.PasswordSettings{LoginEmail: "owner@example.test"}); !errors.Is(err, domain.ErrEmailOccupied) {
t.Fatalf("save duplicate email err = %v, want ErrEmailOccupied", err)
}
}

View file

@ -7,6 +7,7 @@ import (
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
@ -26,11 +27,12 @@ func NewAuthKeyStore(db sqlcgen.DBTX) *AuthKeyStore {
// Save 实现 store.AuthKeyStore。auth_key_id 以小端解释为 int64 存入 BIGINT
// created_at 交由 DB 默认值now()),故传入的 CreatedAt 不落库。
func (s *AuthKeyStore) Save(ctx context.Context, k store.AuthKeyData) error {
if err := s.q.UpsertAuthKey(ctx, sqlcgen.UpsertAuthKeyParams{
AuthKeyID: authKeyIDToInt64(k.ID),
Body: k.Value[:],
ServerSalt: k.ServerSalt,
}); err != nil {
if _, err := s.db.Exec(ctx, `
INSERT INTO auth_keys (auth_key_id, body, server_salt)
VALUES ($1, $2, $3)
ON CONFLICT (auth_key_id) DO UPDATE
SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt
`, authKeyIDToInt64(k.ID), k.Value[:], k.ServerSalt); err != nil {
return fmt.Errorf("upsert auth key: %w", err)
}
return nil
@ -38,24 +40,65 @@ func (s *AuthKeyStore) Save(ctx context.Context, k store.AuthKeyData) error {
// Get 实现 store.AuthKeyStore。不存在时 found=false。
func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
row, err := s.q.GetAuthKey(ctx, authKeyIDToInt64(id))
var (
body []byte
serverSalt int64
createdAt pgtype.Timestamptz
layer int
deviceModel string
platform string
systemVersion string
apiID int
appVersion string
)
err := s.db.QueryRow(ctx, `
SELECT auth_key_id, body, server_salt, created_at,
layer, device_model, platform, system_version, api_id, app_version
FROM auth_keys
WHERE auth_key_id = $1
`, authKeyIDToInt64(id)).Scan(new(int64), &body, &serverSalt, &createdAt, &layer, &deviceModel, &platform, &systemVersion, &apiID, &appVersion)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return store.AuthKeyData{}, false, nil
}
return store.AuthKeyData{}, false, fmt.Errorf("get auth key: %w", err)
}
if len(row.Body) != len(store.AuthKeyData{}.Value) {
return store.AuthKeyData{}, false, fmt.Errorf("auth key body length = %d, want 256", len(row.Body))
if len(body) != len(store.AuthKeyData{}.Value) {
return store.AuthKeyData{}, false, fmt.Errorf("auth key body length = %d, want 256", len(body))
}
data := store.AuthKeyData{ID: id, ServerSalt: row.ServerSalt}
copy(data.Value[:], row.Body)
if row.CreatedAt.Valid {
data.CreatedAt = row.CreatedAt.Time.Unix()
data := store.AuthKeyData{
ID: id,
ServerSalt: serverSalt,
Layer: layer,
DeviceModel: deviceModel,
Platform: platform,
SystemVersion: systemVersion,
APIID: apiID,
AppVersion: appVersion,
}
copy(data.Value[:], body)
if createdAt.Valid {
data.CreatedAt = createdAt.Time.Unix()
}
return data, true, nil
}
func (s *AuthKeyStore) UpdateClientInfo(ctx context.Context, id [8]byte, info store.AuthKeyClientInfo) error {
if _, err := s.db.Exec(ctx, `
UPDATE auth_keys
SET layer = CASE WHEN $2::integer > 0 THEN $2 ELSE layer END,
device_model = CASE WHEN $3::text <> '' THEN $3 ELSE device_model END,
platform = CASE WHEN $4::text <> '' THEN $4 ELSE platform END,
system_version = CASE WHEN $5::text <> '' THEN $5 ELSE system_version END,
api_id = CASE WHEN $6::integer <> 0 THEN $6 ELSE api_id END,
app_version = CASE WHEN $7::text <> '' THEN $7 ELSE app_version END
WHERE auth_key_id = $1
`, authKeyIDToInt64(id), info.Layer, info.DeviceModel, info.Platform, info.SystemVersion, info.APIID, info.AppVersion); err != nil {
return fmt.Errorf("update auth key client info: %w", err)
}
return nil
}
// Delete 实现 store.AuthKeyStore。不存在时静默成功。
// 手写 SQL 而非 sqlc 生成:避免触碰 sqlcgen 再生成链路。
//

View file

@ -70,3 +70,62 @@ func TestAuthKeyStoreRoundTrip(t *testing.T) {
t.Fatalf("missing key: found=%v err=%v, want found=false err=nil", found, err)
}
}
func TestAuthKeyStoreClientInfoRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
var id [8]byte
var val [256]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatal(err)
}
if _, err := rand.Read(val[:]); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(id))
})
keys := NewAuthKeyStore(pool)
if err := keys.Save(ctx, store.AuthKeyData{ID: id, Value: val, ServerSalt: 0x0badf00d}); err != nil {
t.Fatalf("save: %v", err)
}
if err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
Layer: 227,
DeviceModel: "GooglePixel 9a",
Platform: "android",
SystemVersion: "SDK 36",
APIID: 6,
AppVersion: "12.8.1 (69169) pbeta",
}); err != nil {
t.Fatalf("update client info: %v", err)
}
got, found, err := NewAuthKeyStore(pool).Get(ctx, id)
if err != nil {
t.Fatalf("get: %v", err)
}
if !found {
t.Fatal("auth key not found after client info update")
}
if got.Layer != 227 || got.DeviceModel != "GooglePixel 9a" || got.Platform != "android" ||
got.SystemVersion != "SDK 36" || got.APIID != 6 || got.AppVersion != "12.8.1 (69169) pbeta" {
t.Fatalf("client info mismatch: %+v", got)
}
if err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{AppVersion: "12.8.2"}); err != nil {
t.Fatalf("partial update client info: %v", err)
}
got, found, err = NewAuthKeyStore(pool).Get(ctx, id)
if err != nil {
t.Fatalf("get after partial update: %v", err)
}
if !found {
t.Fatal("auth key not found after partial client info update")
}
if got.Layer != 227 || got.DeviceModel != "GooglePixel 9a" || got.Platform != "android" ||
got.SystemVersion != "SDK 36" || got.APIID != 6 || got.AppVersion != "12.8.2" {
t.Fatalf("partial client info merge mismatch: %+v", got)
}
}

View file

@ -50,6 +50,25 @@ func (s *CodeStore) Get(ctx context.Context, hash string) (store.PhoneCode, bool
return code, true, nil
}
func (s *CodeStore) Update(ctx context.Context, hash string, code store.PhoneCode) error {
key := codeKey(hash)
ttl, err := s.c.TTL(ctx, key).Result()
if err != nil {
return fmt.Errorf("redis ttl phone code: %w", err)
}
if ttl <= 0 {
return nil
}
v, err := json.Marshal(code)
if err != nil {
return fmt.Errorf("marshal phone code: %w", err)
}
if err := s.c.Set(ctx, key, v, ttl).Err(); err != nil {
return fmt.Errorf("redis update phone code: %w", err)
}
return nil
}
func (s *CodeStore) Del(ctx context.Context, hash string) error {
return s.c.Del(ctx, codeKey(hash)).Err()
}