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

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

@ -34,8 +34,9 @@ type tempResolveResult struct {
}
const (
authKeyResolveSingleflightPrefix = "resolve:"
authClientInfoSingleflightPrefix = "authinfo:"
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