owpengram-server/internal/rpc/auth.go
2026-07-20 12:05:18 +03:00

1056 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package rpc
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/app/auth"
"telesrv/internal/domain"
)
// devCodeLength 是开发固定验证码长度,写入 auth.sentCode 的 type.length。
const devCodeLength = 5
// registerAuth 注册 auth.* RPC handler。
func (r *Router) registerAuth(d *tlprofile.Dispatcher) {
registerRPC[*tg.AuthBindTempAuthKeyRequest](d, tlprofile.SemanticMethodAuthBindTempAuthKey, func(ctx context.Context, layerRequest *tg.AuthBindTempAuthKeyRequest) (any, error) {
return r.onAuthBindTempAuthKey(ctx, layerRequest)
})
registerRPC[*tg.AuthExportLoginTokenRequest](d, tlprofile.SemanticMethodAuthExportLoginToken, func(ctx context.Context, layerRequest *tg.AuthExportLoginTokenRequest) (any, error) {
return r.onAuthExportLoginToken(ctx, layerRequest)
})
registerRPC[*tg.AuthImportLoginTokenRequest](d, tlprofile.SemanticMethodAuthImportLoginToken, func(ctx context.Context, layerRequest *tg.AuthImportLoginTokenRequest) (any, error) {
return r.onAuthImportLoginToken(ctx, layerRequest.
Token)
})
registerRPC[*tg.AuthAcceptLoginTokenRequest](d, tlprofile.SemanticMethodAuthAcceptLoginToken, func(ctx context.Context, layerRequest *tg.AuthAcceptLoginTokenRequest) (any, error) {
return r.onAuthAcceptLoginToken(ctx, layerRequest.
Token)
})
registerRPC[*tg.AuthExportAuthorizationRequest](d, tlprofile.SemanticMethodAuthExportAuthorization, func(ctx context.Context, layerRequest *tg.AuthExportAuthorizationRequest) (any, error) {
return r.onAuthExportAuthorization(ctx, layerRequest)
})
registerRPC[*tg.AuthImportAuthorizationRequest](d, tlprofile.SemanticMethodAuthImportAuthorization, func(ctx context.Context, req *tg.AuthImportAuthorizationRequest) (any, error) {
return r.onAuthImportAuthorization(ctx, req)
})
registerRPC[*tg.AuthDropTempAuthKeysRequest](d, tlprofile.SemanticMethodAuthDropTempAuthKeys, func(ctx context.Context, layerRequest *tg.AuthDropTempAuthKeysRequest) (any, error) {
exceptauthkeys := layerRequest.
ExceptAuthKeys
_ = exceptauthkeys
return true, nil
})
registerRPC[*tg.AuthInitPasskeyLoginRequest](d, tlprofile.SemanticMethodAuthInitPasskeyLogin, func(ctx context.Context, layerRequest *tg.AuthInitPasskeyLoginRequest) (any, error) {
return r.onAuthInitPasskeyLogin(ctx, layerRequest)
})
registerRPC[*tg.AuthFinishPasskeyLoginRequest](d, tlprofile.SemanticMethodAuthFinishPasskeyLogin, func(ctx context.Context, layerRequest *tg.AuthFinishPasskeyLoginRequest) (any, error) {
return r.onAuthFinishPasskeyLogin(ctx, layerRequest)
})
registerRPC[*tg.AuthSendCodeRequest](d, tlprofile.SemanticMethodAuthSendCode, func(ctx context.Context, layerRequest *tg.AuthSendCodeRequest) (any, error) {
return r.onAuthSendCode(ctx, layerRequest)
})
registerRPC[*tg.AuthResendCodeRequest](d, tlprofile.SemanticMethodAuthResendCode, func(ctx context.Context, layerRequest *tg.AuthResendCodeRequest) (any, error) {
return r.onAuthResendCode(ctx, layerRequest)
})
registerRPC[*tg.AuthCancelCodeRequest](d, tlprofile.SemanticMethodAuthCancelCode, func(ctx context.Context, layerRequest *tg.AuthCancelCodeRequest) (any, error) {
return r.onAuthCancelCode(ctx, layerRequest)
})
registerRPC[*tg.AuthSignInRequest](d, tlprofile.SemanticMethodAuthSignIn, func(ctx context.Context, layerRequest *tg.AuthSignInRequest) (any, error) {
return r.onAuthSignIn(ctx, layerRequest)
})
registerRPC[*tg.AuthSignUpRequest](d, tlprofile.SemanticMethodAuthSignUp, func(ctx context.Context, layerRequest *tg.AuthSignUpRequest) (any, error) {
return r.onAuthSignUp(ctx, layerRequest)
})
registerRPC[*tg.AuthImportBotAuthorizationRequest](d, tlprofile.SemanticMethodAuthImportBotAuthorization, func(ctx context.Context, layerRequest *tg.AuthImportBotAuthorizationRequest) (any, error) {
return r.onAuthImportBotAuthorization(ctx, layerRequest)
})
registerRPC[*tg.AuthLogOutRequest](d, tlprofile.SemanticMethodAuthLogOut, func(ctx context.Context, layerRequest *tg.AuthLogOutRequest) (any, error) {
return r.onAuthLogOut(ctx)
})
registerRPC[*tg.AuthResetAuthorizationsRequest](d, tlprofile.SemanticMethodAuthResetAuthorizations, func(ctx context.Context, layerRequest *tg.AuthResetAuthorizationsRequest) (any, error) {
return r.onAuthResetAuthorizations(ctx)
})
registerRPC[*tg.AuthCheckPasswordRequest](d, tlprofile.SemanticMethodAuthCheckPassword, func(ctx context.Context, layerRequest *tg.AuthCheckPasswordRequest) (any, error) {
return r.onAuthCheckPassword(ctx, layerRequest.
Password)
})
registerRPC[*tg.AuthRequestPasswordRecoveryRequest](d, tlprofile.SemanticMethodAuthRequestPasswordRecovery, func(ctx context.Context, layerRequest *tg.AuthRequestPasswordRecoveryRequest) (any, error) {
return r.onAuthRequestPasswordRecovery(ctx)
})
registerRPC[*tg.AuthRecoverPasswordRequest](d, tlprofile.SemanticMethodAuthRecoverPassword, func(ctx context.Context, layerRequest *tg.AuthRecoverPasswordRequest) (any, error) {
return r.onAuthRecoverPassword(ctx, layerRequest)
})
registerRPC[*tg.AuthCheckRecoveryPasswordRequest](d, tlprofile.SemanticMethodAuthCheckRecoveryPassword, func(ctx context.Context, layerRequest *tg.AuthCheckRecoveryPasswordRequest) (
// onAuthBindTempAuthKey 记录 TDesktop 的 PFS temp→perm auth key 绑定。
any, error) {
return r.onAuthCheckRecoveryPassword(ctx, layerRequest.
Code)
})
registerRPC[*tg.AuthResetLoginEmailRequest](d, tlprofile.SemanticMethodAuthResetLoginEmail, func(ctx context.Context, layerRequest *tg.AuthResetLoginEmailRequest) (any, error) {
return r.onAuthResetLoginEmail(ctx, layerRequest)
})
}
// exportAuthTokenTTL is how long an auth.exportAuthorization token stays
// valid. Clients use it within seconds (open a second connection to fetch
// media/files believed to live on a different dc_id, then immediately call
// auth.importAuthorization), so a short window is plenty.
const exportAuthTokenTTL = 5 * time.Minute
// exportAuthSecret HMAC-signs auth.exportAuthorization tokens. It is
// process-lifetime random (not persisted): every dc_id this server hands out
// aliases the SAME physical process (see owpengram_servers single-server
// backend + memory "lenient DC key exchange"), so export and import always
// happen against the same running instance within the token's short TTL —
// a restart between the two would just make the client redo the export,
// same as if the token had expired.
var exportAuthSecret = func() []byte {
b := make([]byte, 32)
_, _ = rand.Read(b)
return b
}()
// signExportAuthToken binds userID+expiry into an opaque, tamper-evident
// token: 8 bytes user_id big-endian, 8 bytes expiry unix big-endian, 32 bytes
// HMAC-SHA256 tag over the first 16.
func signExportAuthToken(userID int64) []byte {
payload := make([]byte, 16, 48)
binary.BigEndian.PutUint64(payload[0:8], uint64(userID))
binary.BigEndian.PutUint64(payload[8:16], uint64(time.Now().Add(exportAuthTokenTTL).Unix()))
mac := hmac.New(sha256.New, exportAuthSecret)
mac.Write(payload)
return mac.Sum(payload)
}
// verifyExportAuthToken checks the HMAC tag and expiry and returns the
// embedded user_id.
func verifyExportAuthToken(token []byte) (int64, error) {
if len(token) != 48 {
return 0, errors.New("export auth token: bad length")
}
payload, tag := token[:16], token[16:]
mac := hmac.New(sha256.New, exportAuthSecret)
mac.Write(payload)
if !hmac.Equal(tag, mac.Sum(nil)) {
return 0, errors.New("export auth token: bad signature")
}
expiry := int64(binary.BigEndian.Uint64(payload[8:16]))
if time.Now().Unix() > expiry {
return 0, errors.New("export auth token: expired")
}
return int64(binary.BigEndian.Uint64(payload[0:8])), nil
}
// onAuthExportAuthorization issues a short-lived signed token proving the
// current connection's logged-in identity, so a second connection the client
// opens for what it believes is a different data-center (typically to fetch
// media/files) can prove the same user via auth.importAuthorization without
// repeating the phone/code login flow. Every dc_id this server advertises
// aliases the same physical backend (see Owpengram::ApplyServerToDcOptions /
// OwpengramServers.applyServerConfig on the clients), so req.DCID carries no
// real routing meaning here and is intentionally not validated.
func (r *Router) onAuthExportAuthorization(ctx context.Context, req *tg.AuthExportAuthorizationRequest) (*tg.AuthExportedAuthorization, error) {
userID, ok, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !ok || userID == 0 {
return nil, dcIDInvalidErr()
}
return &tg.AuthExportedAuthorization{
ID: userID,
Bytes: signExportAuthToken(userID),
}, nil
}
// onAuthImportAuthorization is the counterpart consumed on the second
// connection: verifies the token minted by onAuthExportAuthorization and, on
// success, binds THIS connection's auth_key/session to that user — the same
// effect a normal auth.signIn has once it succeeds.
func (r *Router) onAuthImportAuthorization(ctx context.Context, req *tg.AuthImportAuthorizationRequest) (tg.AuthAuthorizationClass, error) {
userID, err := verifyExportAuthToken(req.Bytes)
if err != nil || userID == 0 || userID != req.ID {
return nil, dcIDInvalidErr()
}
u, err := r.deps.Users.Self(ctx, userID)
if err != nil {
return nil, dcIDInvalidErr()
}
if authKeyID, ok := AuthKeyIDFrom(ctx); ok {
r.setAuthUserCache(authKeyID, userID, true)
}
r.bindSessionUser(ctx, userID)
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
}
func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTempAuthKeyRequest) (bool, error) {
if !layerRPCProfileEvidenceFresh(ctx) {
// The inner request is outside MTProto's mutable msg_id window. It may be
// decoded request-locally, but acknowledging it would mutate the durable
// temp→perm identity and every live session from stale replay evidence.
return false, bindTempAuthKeyErr(auth.ErrTempAuthKeyEmpty)
}
if r.deps.Auth == nil {
return true, nil
}
id, _ := RawAuthKeyIDFrom(ctx)
if id == ([8]byte{}) {
id, _ = AuthKeyIDFrom(ctx)
}
sessionID, _ := SessionIDFrom(ctx)
if err := r.deps.Auth.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
TempAuthKeyID: id,
PermAuthKeyID: req.PermAuthKeyID,
Nonce: req.Nonce,
ExpiresAt: req.ExpiresAt,
EncryptedMessage: append([]byte(nil), req.EncryptedMessage...),
}); err != nil {
return false, bindTempAuthKeyErr(err)
}
permID := authKeyIDFromInt64(req.PermAuthKeyID)
// temp key (re)bind 后立即作废其 temp→perm 解析缓存,确保下一帧按新绑定重新解析,
// 不被 TTL 内的旧 perm 缓存命中(防跨账号串号)。
if id != ([8]byte{}) {
r.tempKeyResolveCache.Delete(id)
}
// Save atomically merged raw/permanent Layer observations. Both identities
// must now re-read that durable permanent primary; pre-bind process caches
// are not ordering evidence and cannot overwrite the transaction's winner.
r.invalidateAuthUserCache(id)
r.invalidateAuthUserCache(permID)
unlockLayerCommit := r.lockAuthLayerCommit(id, permID)
defer unlockLayerCommit()
r.invalidateBoundAuthKeyLayerResolution(id, permID)
if r.deps.Sessions != nil {
if all, ok := r.deps.Sessions.(RawAuthKeySessionBinder); ok {
all.BindAuthKeyForRawAuthKey(id, permID)
} else {
r.deps.Sessions.BindAuthKeyForSession(id, sessionID, permID)
}
}
layer, _, err := r.resolveAuthKeyLayerDefault(ctx, permID)
if err != nil {
if clearer, ok := r.deps.Sessions.(AuthKeyInheritedLayerClearer); ok {
clearer.ClearInheritedLayerForRawAuthKey(id)
}
if r.log != nil {
r.log.Warn("reload merged permanent layer after temp auth key bind failed",
zap.String("raw_auth_key_id", fmt.Sprintf("%x", id[:])),
zap.String("perm_auth_key_id", fmt.Sprintf("%x", permID[:])),
zap.Error(err))
}
return false, internalErr()
}
r.cacheBoundAuthKeyLayerResolution(id, permID)
if isSupportedLayer(layer) {
if refresher, ok := r.deps.Sessions.(AuthKeyLayerRefresher); ok {
refresher.RefreshInheritedLayerForRawAuthKey(id, layer)
} else if binder, ok := r.deps.Sessions.(AuthKeyLayerBinder); ok {
binder.SeedInheritedLayerForRawAuthKey(id, layer)
}
} else if clearer, ok := r.deps.Sessions.(AuthKeyInheritedLayerClearer); ok {
clearer.ClearInheritedLayerForRawAuthKey(id)
}
return true, nil
}
func (r *Router) invalidateBoundAuthKeyLayerResolution(authKeyIDs ...[8]byte) {
r.clientInfoMu.Lock()
defer r.clientInfoMu.Unlock()
for _, authKeyID := range authKeyIDs {
if info, ok := r.authInfo[authKeyID]; ok {
info.layer = 0
info.layerObservationID = 0
info.layerAdmissionSeq = 0
info.authKeyInfoChecked = false
info.authorizationChecked = false
info.layerBlocked = false
info.layerBlockedByAuthKey = false
r.authInfo[authKeyID] = info
}
}
}
func (r *Router) cacheBoundAuthKeyLayerResolution(rawAuthKeyID, permAuthKeyID [8]byte) {
r.clientInfoMu.Lock()
defer r.clientInfoMu.Unlock()
if r.authInfo == nil {
r.authInfo = make(map[[8]byte]clientSessionInfo)
}
if _, exists := r.authInfo[rawAuthKeyID]; !exists {
evictMapEntryIfFullLocked(r.authInfo, maxAuthInfoEntries)
}
canonical := r.authInfo[permAuthKeyID]
info := r.authInfo[rawAuthKeyID]
// The bind transaction made the permanent row authoritative for both
// identities. Copy its complete resolution tuple: a Layer without the same
// observation token (or a stale blocked bit) would let later cache merging
// manufacture an ordering state that never existed durably.
info.layer = canonical.layer
info.layerObservationID = canonical.layerObservationID
info.layerAdmissionSeq = canonical.layerAdmissionSeq
info.layerBlocked = canonical.layerBlocked
info.layerBlockedByAuthKey = canonical.layerBlockedByAuthKey
info.authKeyInfoChecked = canonical.authKeyInfoChecked
info.authorizationChecked = canonical.authorizationChecked
r.authInfo[rawAuthKeyID] = info
}
// onAuthExportLoginToken 给 QR 登录请求方返回短期 token扫码端接受后同一目标
// session 后续 export 会升级为 auth.loginTokenSuccess。
func (r *Router) onAuthExportLoginToken(ctx context.Context, req *tg.AuthExportLoginTokenRequest) (tg.AuthLoginTokenClass, error) {
target, ok := loginTokenTargetFromContext(ctx)
if !ok {
return nil, internalErr()
}
authz := r.authzFromCtx(ctx)
result, err := r.loginTokens.export(r.clock.Now(), target, authz, req.ExceptIDs)
if err != nil {
return nil, internalErr()
}
if result.accepted {
return r.authLoginTokenSuccess(ctx, result.acceptedAuth)
}
return &tg.AuthLoginToken{Expires: int(result.expires.Unix()), Token: result.token}, nil
}
func (r *Router) onAuthImportLoginToken(ctx context.Context, token []byte) (tg.AuthLoginTokenClass, error) {
result, err := r.loginTokens.lookup(r.clock.Now(), token)
if err != nil {
return nil, err
}
if result.accepted {
return r.authLoginTokenSuccess(ctx, result.acceptedAuth)
}
return &tg.AuthLoginToken{Expires: int(result.expires.Unix()), Token: result.token}, nil
}
func (r *Router) onAuthAcceptLoginToken(ctx context.Context, token []byte) (*tg.Authorization, error) {
userID, ok, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !ok || userID == 0 {
return nil, authKeyUnregisteredErr()
}
if r.deps.Auth == nil {
return nil, internalErr()
}
now := r.clock.Now()
accept, err := r.loginTokens.beginAccept(now, token, userID)
if err != nil {
return nil, err
}
scannerAuthKeyID, _ := AuthKeyIDFrom(ctx)
if scannerAuthKeyID != ([8]byte{}) && scannerAuthKeyID == accept.authz.AuthKeyID {
r.loginTokens.failAccept(token)
return nil, authTokenExceptionErr()
}
authz := accept.authz
authz.UserID = userID
authz.PasswordPending = false
if err := r.clearAuthKeyState(ctx, authz.AuthKeyID); err != nil {
r.loginTokens.failAccept(token)
return nil, internalErr()
}
bound, err := r.deps.Auth.AcceptLoginToken(ctx, authz, userID)
if err != nil {
r.loginTokens.failAccept(token)
if errors.Is(err, auth.ErrSystemUserLoginForbidden) {
return nil, authKeyUnregisteredErr()
}
return nil, internalErr()
}
if bound.UserID == 0 {
bound.UserID = userID
}
r.loginTokens.finishAccept(now, token, userID, bound)
r.invalidateAuthUserCache(bound.AuthKeyID)
r.setAuthUserCache(bound.AuthKeyID, userID, true)
r.bindLoginTokenTarget(accept.target, userID)
r.pushLoginTokenAccepted(ctx, accept.target)
out := tgAuthorization(bound, scannerAuthKeyID, int(now.Unix()))
return &out, nil
}
func loginTokenTargetFromContext(ctx context.Context) (loginTokenTarget, bool) {
authKeyID, _ := AuthKeyIDFrom(ctx)
rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx)
if rawAuthKeyID == ([8]byte{}) {
rawAuthKeyID = authKeyID
}
sessionID, _ := SessionIDFrom(ctx)
return loginTokenTarget{rawAuthKeyID: rawAuthKeyID, authKeyID: authKeyID, sessionID: sessionID}, true
}
func (r *Router) authLoginTokenSuccess(ctx context.Context, a domain.Authorization) (tg.AuthLoginTokenClass, error) {
if r.deps.Users == nil || a.UserID == 0 {
return nil, internalErr()
}
u, err := r.deps.Users.Self(ctx, a.UserID)
if err != nil {
return nil, internalErr()
}
return &tg.AuthLoginTokenSuccess{
Authorization: &tg.AuthAuthorization{User: r.tgSelfUser(u)},
}, nil
}
func (r *Router) bindLoginTokenTarget(target loginTokenTarget, userID int64) {
if r.deps.Sessions == nil || target.sessionID == 0 {
return
}
r.deps.Sessions.BindAuthKeyForSession(target.rawAuthKeyID, target.sessionID, target.authKeyID)
r.deps.Sessions.BindUserForAuthKey(target.rawAuthKeyID, target.sessionID, userID)
r.announceSessionOnline(loginTokenTargetContext(target, userID), userID)
}
func loginTokenTargetContext(target loginTokenTarget, userID int64) context.Context {
ctx := context.Background()
ctx = WithRawAuthKeyID(ctx, target.rawAuthKeyID)
ctx = WithAuthKeyID(ctx, target.authKeyID)
ctx = WithSessionID(ctx, target.sessionID)
ctx = WithUserID(ctx, userID)
return ctx
}
func (r *Router) pushLoginTokenAccepted(ctx context.Context, target loginTokenTarget) {
if r.deps.Sessions == nil || target.sessionID == 0 {
return
}
updates := &tg.UpdateShort{
Update: &tg.UpdateLoginToken{},
Date: int(r.clock.Now().Unix()),
}
if immediate, ok := r.deps.Sessions.(ImmediateSessionPusher); ok {
if err := immediate.PushToSessionForAuthKeyImmediate(ctx, target.rawAuthKeyID, target.sessionID, proto.MessageFromServer, updates); err != nil {
r.log.Debug("push login token accepted immediate", zap.Int64("session_id", target.sessionID), zap.Error(err))
}
return
}
if err := r.deps.Sessions.PushToSessionForAuthKey(ctx, target.rawAuthKeyID, target.sessionID, proto.MessageFromServer, updates); err != nil {
r.log.Debug("push login token accepted", zap.Int64("session_id", target.sessionID), zap.Error(err))
}
}
// onAuthSendCode 处理 auth.sendCode生成 phone_code_hash 并返回 sentCode。
// 若该手机号账号设置了登录邮箱,验证码改投递到邮箱,返回 sentCodeTypeEmailCode
// (客户端据此进入"输入邮箱验证码"界面,随后用 auth.signIn 的 email_verification 完成登录)。
func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest) (tg.AuthSentCodeClass, error) {
if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil {
return nil, err
}
r.rememberClientAPIID(ctx, req.APIID)
if r.log != nil {
normalized := domain.NormalizePhone(req.PhoneNumber)
_, decodeOK := domain.DecodeEmailPhone(normalized)
r.log.Info("auth.sendCode phone diagnostics",
zap.Int("phone_len", len(normalized)),
zap.Bool("looks_like_email_signup", domain.IsEmailSignupPhone(normalized)),
zap.Bool("decodes_to_email", decodeOK))
}
hash, err := r.deps.Auth.SendCode(ctx, req.PhoneNumber)
if err != nil {
if errors.Is(err, auth.ErrPhoneNumberInvalid) ||
errors.Is(err, auth.ErrSystemUserLoginForbidden) {
return nil, phoneNumberInvalidErr()
}
return nil, internalErr()
}
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: length},
PhoneCodeHash: hash,
}
}
func tgSMSSentCode(hash string, length int) tg.AuthSentCodeClass {
if length <= 0 {
length = devCodeLength
}
return &tg.AuthSentCode{
Type: &tg.AuthSentCodeTypeSMS{Length: length},
PhoneCodeHash: hash,
}
}
func tgEmailSentCode(hash, emailPattern string, length int) tg.AuthSentCodeClass {
if length <= 0 {
length = devCodeLength
}
codeType := &tg.AuthSentCodeTypeEmailCode{
EmailPattern: emailPattern,
Length: length,
}
// reset_available_period=0 表示可立即调用 auth.resetLoginEmail开发环境无等待期
// 让客户端的"无法访问邮箱?"逃生入口可用。
codeType.SetResetAvailablePeriod(0)
return &tg.AuthSentCode{
Type: codeType,
PhoneCodeHash: hash,
}
}
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.AuthCodeDeliverySMS:
return tgSMSSentCode(hash, delivery.Length), nil
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) {
var (
u domain.User
needSignUp bool
err error
)
if verification, ok := req.GetEmailVerification(); ok {
u, _, needSignUp, err = r.deps.Auth.SignInWithEmail(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, emailVerificationCode(verification))
} else {
u, _, needSignUp, err = r.deps.Auth.SignIn(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.PhoneCode)
}
return r.finishAuthSignIn(ctx, u, needSignUp, err)
}
func (r *Router) finishAuthSignIn(ctx context.Context, u domain.User, needSignUp bool, err error) (tg.AuthAuthorizationClass, error) {
if err != nil {
if errors.Is(err, domain.ErrSessionPasswordNeeded) && u.ID != 0 {
// 两步验证未完成:绝不能把 auth_key/session 标记为已登录,否则客户端忽略
// SESSION_PASSWORD_NEEDED、直接调用业务 RPC 即可绕过 2FA。失效缓存并把 session
// 置为未授权,让后续鉴权重新读到 password_pending 并拒绝;待 checkPassword 通过后再授权。
if id, ok := AuthKeyIDFrom(ctx); ok {
r.invalidateAuthUserCache(id)
}
r.bindSessionUser(ctx, 0)
}
return nil, signInErr(err)
}
if needSignUp {
return &tg.AuthAuthorizationSignUpRequired{}, nil
}
if id, ok := AuthKeyIDFrom(ctx); ok {
r.setAuthUserCache(id, u.ID, true)
}
r.bindSessionUser(ctx, u.ID)
r.pushSignInServiceNotificationToOthers(ctx, u)
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
}
func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeRequest) (tg.AuthSentCodeClass, error) {
if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil {
return nil, err
}
var hash string
var err error
if scoped, ok := r.deps.Auth.(interface {
ResendCodeForAuthKey(context.Context, [8]byte, string, string) (string, error)
}); ok {
authKeyID, _ := AuthKeyIDFrom(ctx)
hash, err = scoped.ResendCodeForAuthKey(ctx, authKeyID, req.PhoneNumber, req.PhoneCodeHash)
} else {
hash, err = r.deps.Auth.ResendCode(ctx, req.PhoneNumber, req.PhoneCodeHash)
}
if err != nil {
return nil, signInErr(err)
}
return r.tgSentCodeForHash(ctx, hash)
}
func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) {
var err error
if scoped, ok := r.deps.Auth.(interface {
CancelCodeForAuthKey(context.Context, [8]byte, string, string) error
}); ok {
authKeyID, _ := AuthKeyIDFrom(ctx)
err = scoped.CancelCodeForAuthKey(ctx, authKeyID, req.PhoneNumber, req.PhoneCodeHash)
} else {
err = r.deps.Auth.CancelCode(ctx, req.PhoneNumber, req.PhoneCodeHash)
}
if err != nil {
return false, signInErr(err)
}
return true, nil
}
func (r *Router) onAuthResetAuthorizations(ctx context.Context) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
authKeyID, _ := AuthKeyIDFrom(ctx)
deleted, err := r.deps.Auth.ResetAuthorizations(ctx, userID, authKeyID)
if err != nil {
return false, internalErr()
}
for _, a := range deleted {
r.revokeAuthKeySessions(a.AuthKeyID)
_ = r.clearAuthKeyState(ctx, a.AuthKeyID)
// P1 修复:撤销其它会话同样销毁其 auth_key级联 discard 该设备绑定的活跃密聊并通知对端。
r.discardSecretChatsForAuthKey(ctx, businessAuthKeyInt64(a.AuthKeyID), userID)
}
return true, nil
}
func (r *Router) onAuthCheckPassword(ctx context.Context, password tg.InputCheckPasswordSRPClass) (tg.AuthAuthorizationClass, error) {
authKeyID, _ := AuthKeyIDFrom(ctx)
userID, authorized, pending, err := r.currentOrPendingPasswordUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !pending && (!authorized || userID == 0) {
return nil, passwordHashInvalidErr()
}
if r.deps.Account == nil {
return nil, passwordHashInvalidErr()
}
if err := r.deps.Account.CheckPassword(ctx, userID, domainPasswordCheck(password)); err != nil {
return nil, passwordErr(err)
}
// 两步验证通过:清除 password_pending 并把 auth_key/session 提升为完全授权。
if pending {
if err := r.completePendingPasswordSignIn(ctx, authKeyID, userID); err != nil {
return nil, internalErr()
}
}
u, err := r.deps.Users.Self(ctx, userID)
if err != nil {
return nil, internalErr()
}
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
}
func (r *Router) onAuthRequestPasswordRecovery(ctx context.Context) (*tg.AuthPasswordRecovery, error) {
userID, _, _, err := r.currentOrPendingPasswordUserID(ctx)
if err != nil {
return nil, internalErr()
}
pattern, err := r.deps.Account.RequestPasswordRecovery(ctx, userID)
if err != nil {
return nil, passwordErr(err)
}
return &tg.AuthPasswordRecovery{EmailPattern: pattern}, nil
}
func (r *Router) onAuthRecoverPassword(ctx context.Context, req *tg.AuthRecoverPasswordRequest) (tg.AuthAuthorizationClass, error) {
authKeyID, _ := AuthKeyIDFrom(ctx)
userID, _, pending, err := r.currentOrPendingPasswordUserID(ctx)
if err != nil {
return nil, internalErr()
}
var input *domain.PasswordInputSettings
if settings, ok := req.GetNewSettings(); ok {
converted, err := domainPasswordInputSettings(settings)
if err != nil {
return nil, err
}
input = &converted
}
if err := r.deps.Account.RecoverPassword(ctx, userID, req.Code, input); err != nil {
return nil, passwordErr(err)
}
if pending {
if err := r.completePendingPasswordSignIn(ctx, authKeyID, userID); err != nil {
return nil, internalErr()
}
}
u, err := r.deps.Users.Self(ctx, userID)
if err != nil {
return nil, internalErr()
}
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
}
func (r *Router) onAuthCheckRecoveryPassword(ctx context.Context, code string) (bool, error) {
userID, _, _, err := r.currentOrPendingPasswordUserID(ctx)
if err != nil {
return false, internalErr()
}
if err := r.deps.Account.CheckRecoveryPassword(ctx, userID, code); err != nil {
return false, passwordErr(err)
}
return true, nil
}
// currentOrPendingPasswordUserID returns the fully authorized user when present;
// otherwise it allows the narrow 2FA login continuation path to locate the user
// attached to a password_pending auth key. The pending identity must not be used
// by general business RPCs.
func (r *Router) currentOrPendingPasswordUserID(ctx context.Context) (userID int64, authorized bool, passwordPending bool, err error) {
userID, authorized, err = r.currentUserID(ctx)
if err != nil || authorized {
return userID, authorized, false, err
}
if r.deps.Auth == nil {
return userID, authorized, false, nil
}
authKeyID, ok := AuthKeyIDFrom(ctx)
if !ok {
return userID, authorized, false, nil
}
pendingUserID, pending, err := r.deps.Auth.PendingPasswordUserID(ctx, authKeyID)
if err != nil {
return 0, false, false, err
}
if !pending || pendingUserID == 0 {
return userID, authorized, false, nil
}
return pendingUserID, false, true, nil
}
func (r *Router) completePendingPasswordSignIn(ctx context.Context, authKeyID [8]byte, userID int64) error {
if r.deps.Auth == nil {
return nil
}
if err := r.deps.Auth.CompletePasswordSignIn(ctx, authKeyID); err != nil {
return err
}
r.invalidateAuthUserCache(authKeyID)
r.setAuthUserCache(authKeyID, userID, true)
r.bindSessionUser(ctx, userID)
return nil
}
// onAuthResetLoginEmail 处理 auth.resetLoginEmail用户登录设备时无法访问登录邮箱时
// 清除登录邮箱,改回手机验证码登录,返回一个新的手机 sentCode 供其继续。
type loginEmailResetConsumer interface {
ConsumeLoginEmailReset(ctx context.Context, phone, phoneCodeHash string) (userID int64, err error)
SendPhoneCodeAfterLoginEmailReset(ctx context.Context, phone string, expectedUserID int64) (string, error)
}
func (r *Router) onAuthResetLoginEmail(ctx context.Context, req *tg.AuthResetLoginEmailRequest) (tg.AuthSentCodeClass, error) {
if r.deps.Account == nil || r.deps.Auth == nil {
return nil, internalErr()
}
if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil {
return nil, err
}
resetConsumer, ok := r.deps.Auth.(loginEmailResetConsumer)
if !ok {
return nil, internalErr()
}
resetUserID, err := resetConsumer.ConsumeLoginEmailReset(ctx, req.PhoneNumber, req.PhoneCodeHash)
if err != nil {
return nil, signInErr(err)
}
if err := r.deps.Account.ClearLoginEmail(ctx, resetUserID); err != nil {
return nil, internalErr()
}
hash, err := resetConsumer.SendPhoneCodeAfterLoginEmailReset(ctx, req.PhoneNumber, resetUserID)
if err != nil {
if errors.Is(err, auth.ErrCodeExpired) || errors.Is(err, auth.ErrCodeInvalid) {
return nil, signInErr(err)
}
if errors.Is(err, auth.ErrPhoneNumberInvalid) ||
errors.Is(err, auth.ErrSystemUserLoginForbidden) {
return nil, phoneNumberInvalidErr()
}
return nil, internalErr()
}
return r.tgSentCodeForHash(ctx, hash)
}
// emailVerificationCode 从 emailVerification 取出可校验的字符串(验证码 / Google·Apple
// 令牌);最终必须由 auth service 对签发记录精确校验。
// onAuthInitPasskeyLogin 处理 auth.initPasskeyLogin生成一次性断言挑战discoverable
// 以 DataJSON顶层含 publicKey返回。免授权登录前
func (r *Router) onAuthInitPasskeyLogin(ctx context.Context, req *tg.AuthInitPasskeyLoginRequest) (*tg.AuthPasskeyLoginOptions, error) {
if r.deps.Passkey == nil {
return &tg.AuthPasskeyLoginOptions{Options: tg.DataJSON{Data: "{}"}}, nil
}
options, err := r.deps.Passkey.InitLogin(ctx)
if err != nil {
return nil, internalErr()
}
return &tg.AuthPasskeyLoginOptions{Options: tg.DataJSON{Data: string(options)}}, nil
}
// onAuthFinishPasskeyLogin 处理 auth.finishPasskeyLogin验证登录断言并绑定 auth_key。
// 收尾与 signIn 同构Bind 原子切换 update baseline → 授权缓存 → session 绑定);
// passkey 是强因子,直接完全授权
// (不走 SESSION_PASSWORD_NEEDED。FromDCID/FromAuthKeyID 为多 DC 重路由用,本单 DC 忽略。
func (r *Router) onAuthFinishPasskeyLogin(ctx context.Context, req *tg.AuthFinishPasskeyLoginRequest) (tg.AuthAuthorizationClass, error) {
if r.deps.Passkey == nil || r.deps.Auth == nil {
return nil, internalErr()
}
credID, login, ok := passkeyLoginFromCredential(req.Credential)
if !ok {
return nil, passkeyErr(domain.ErrPasskeyInvalid)
}
userID, err := r.deps.Passkey.FinishLogin(ctx, credID, []byte(login.ClientData.Data), login.AuthenticatorData, login.Signature, login.UserHandle)
if err != nil {
return nil, passkeyErr(err)
}
u, err := r.deps.Auth.BindVerifiedLogin(ctx, r.authzFromCtx(ctx), userID)
if err != nil {
return nil, passkeyErr(err)
}
if id, ok := AuthKeyIDFrom(ctx); ok {
r.setAuthUserCache(id, u.ID, true)
}
r.bindSessionUser(ctx, u.ID)
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
}
func emailVerificationCode(v tg.EmailVerificationClass) string {
switch e := v.(type) {
case *tg.EmailVerificationCode:
return e.Code
case *tg.EmailVerificationGoogle:
return e.Token
case *tg.EmailVerificationApple:
return e.Token
}
return ""
}
// onAuthImportBotAuthorization 处理 auth.importBotAuthorizationbot 程序凭 token
// 登录为 bot 账号。api_id/api_hash 与现有 sendCode 行为一致不校验(无 app 注册表)。
// 收尾与 signIn 同构Bind 原子切换 update baseline → 授权缓存 → session 绑定),但不写登录消息、不推
// signIn 服务通知——那是手机登录语义。
func (r *Router) onAuthImportBotAuthorization(ctx context.Context, req *tg.AuthImportBotAuthorizationRequest) (tg.AuthAuthorizationClass, error) {
if r.deps.Auth == nil {
return nil, accessTokenInvalidErr()
}
u, err := r.deps.Auth.SignInBot(ctx, r.authzFromCtx(ctx), req.BotAuthToken)
if err != nil {
return nil, importBotAuthorizationErr(err)
}
if id, ok := AuthKeyIDFrom(ctx); ok {
r.setAuthUserCache(id, u.ID, true)
}
r.bindSessionUser(ctx, u.ID)
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
}
// onAuthSignUp 处理 auth.signUp创建用户并绑定授权。
func (r *Router) onAuthSignUp(ctx context.Context, req *tg.AuthSignUpRequest) (tg.AuthAuthorizationClass, error) {
u, loginMessage, err := r.deps.Auth.SignUp(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.FirstName, req.LastName)
if err != nil {
return nil, signInErr(err)
}
if id, ok := AuthKeyIDFrom(ctx); ok {
r.setAuthUserCache(id, u.ID, true)
}
r.bindSessionUser(ctx, u.ID)
r.enqueueLoginMessageBootstrap(ctx, loginMessage)
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
}
// onAuthLogOut 处理 auth.logOut解绑当前 auth_key 的授权。
func (r *Router) onAuthLogOut(ctx context.Context) (*tg.AuthLoggedOut, error) {
id, _ := AuthKeyIDFrom(ctx)
userID, authorized, userErr := r.currentUserID(ctx)
if err := r.deps.Auth.LogOut(ctx, id); err != nil {
return nil, internalErr()
}
r.invalidateAuthUserCache(id)
r.unbindAuthKey(id)
// bot 登出不广播 offlinebot 无 presence 语义,与登录路径对称)。
if userErr == nil && authorized && userID != 0 && !r.userIsBot(ctx, userID) {
status, _ := r.setPresenceFromContext(ctx, userID, true, presencePersistSync)
r.pushUserStatus(ctx, userID, status)
// 登出后主动清掉本 session 的 presence 条目:连接通常不断开(客户端回登录页),
// 上面 unbindAuthKey 已把连接 userID 清 0TCP 真正断开时 SessionOffline 因 userID=0
// 提前返回、不再清 presence条目会以 offline 态滞留泄露。这里随登出一并清除。
if key, ok := presenceSessionKeyFromContext(ctx); ok {
r.presence.clearSession(key)
}
}
// P1 修复:登出销毁本设备 perm auth_key 后,级联 discard 其绑定的活跃密聊并通知对端
//(否则对端继续往死 auth_key 投递成静默死链。best-effort不阻断登出。
if userErr == nil && userID != 0 {
r.discardSecretChatsForAuthKey(ctx, businessAuthKeyInt64(id), userID)
}
if err := r.clearAuthKeyState(ctx, id); err != nil {
return nil, internalErr()
}
return &tg.AuthLoggedOut{}, nil
}
func (r *Router) clearAuthKeyState(ctx context.Context, authKeyID [8]byte) error {
if r.deps.Updates == nil {
return nil
}
return r.deps.Updates.ClearAuthKey(ctx, authKeyID)
}
func (r *Router) bindSessionUser(ctx context.Context, userID int64) {
if r.deps.Sessions == nil {
return
}
sessionID, ok := SessionIDFrom(ctx)
if !ok {
return
}
r.deps.Sessions.BindUserForAuthKey(rawAuthKeyIDForOrigin(ctx), sessionID, userID)
r.announceSessionOnline(ctx, userID)
}
func (r *Router) unbindAuthKey(authKeyID [8]byte) {
if r.deps.Sessions == nil {
return
}
r.deps.Sessions.UnbindAuthKey(authKeyID)
}
// revokeAuthKeySessions 是授权撤销(被踢设备)的完整失效闭环:清 Router 授权缓存、
// 清 temp→perm 短缓存、强制断开在线连接、再兜底解绑。断开不可省略——出站推送用
// 连接持有的密钥加密、不回查授权表perm-key 连接的授权缓存也只有重连才会重新回查;
// 不断开的话被踢设备仍能持续收到推送并以缓存身份继续发请求。重连后回查 store 即得
// 未授权401
//
// 顺序关键:先 CloseSessionsForBusinessAuthKey 再 unbindAuthKey。Close 内部 removeLocked
// 读取连接当前 userID 生成 SessionOffline 事件(驱动 presence 清理与 offline 广播);
// 若先 unbind 把 userID 清成 0事件就退化为 userID=0被踢设备的 presence 条目不被
// 清理、好友侧最长一个在线 TTL 仍显示其在线。Close 已把连接移出索引,随后的 unbind
// 对未实现 SessionTerminator 的 Sessions 才有意义(生产实现走 Close 即可unbind 是 no-op
func (r *Router) revokeAuthKeySessions(authKeyID [8]byte) {
r.invalidateAuthUserCache(authKeyID)
rawTempAuthKeyIDs := r.invalidateTempAuthKeyCacheForPerm(authKeyID)
if terminator, ok := r.deps.Sessions.(SessionTerminator); ok {
terminator.CloseSessionsForBusinessAuthKey(authKeyID)
}
if terminator, ok := r.deps.Sessions.(RawSessionTerminator); ok {
for _, rawAuthKeyID := range rawTempAuthKeyIDs {
if rawAuthKeyID == authKeyID {
continue
}
terminator.CloseSessionsForRawAuthKeyExcept(rawAuthKeyID, 0)
}
}
r.unbindAuthKey(authKeyID)
}
func (r *Router) invalidateTempAuthKeyCacheForPerm(authKeyID [8]byte) [][8]byte {
return r.tempKeyResolveCache.DeleteByPerm(authKeyID)
}
func (r *Router) pushSignInServiceNotificationToOthers(ctx context.Context, u domain.User) {
if r.deps.Sessions == nil || u.ID == 0 {
return
}
authKeyID, hasAuthKeyID := AuthKeyIDFrom(ctx)
rawAuthKeyID, hasRawAuthKeyID := RawAuthKeyIDFrom(ctx)
sessionID, hasSessionID := SessionIDFrom(ctx)
if !hasAuthKeyID || !hasRawAuthKeyID || !hasSessionID {
return
}
notification := r.tgSignInServiceNotification(ctx, u, authKeyID)
go func() {
pushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if sent, err := r.deps.Sessions.PushToUserExceptAuthKeySession(pushCtx, u.ID, rawAuthKeyID, sessionID, proto.MessageFromServer, notification); err != nil {
r.log.Debug("push sign-in service notification", zap.Int64("user_id", u.ID), zap.Int("sent", sent), zap.Error(err))
}
}()
}
func (r *Router) tgSignInServiceNotification(ctx context.Context, u domain.User, authKeyID [8]byte) *tg.Updates {
now := r.clock.Now()
client := "Unknown device"
if ci, ok := ClientInfoFrom(ctx); ok {
parts := []string{}
if ci.DeviceModel != "" {
parts = append(parts, ci.DeviceModel)
}
if ci.SystemVersion != "" {
parts = append(parts, ci.SystemVersion)
}
if ci.AppVersion != "" {
parts = append(parts, ci.AppVersion)
}
if len(parts) > 0 {
client = strings.Join(parts, " / ")
}
}
name := strings.TrimSpace(strings.TrimSpace(u.FirstName + " " + u.LastName))
if name == "" {
name = u.Phone
}
if name == "" {
name = "there"
}
message := fmt.Sprintf("New login.\nDear %s, we detected a login into your account from a new device on %s.\n\nDevice: %s\nLocation: Unknown\n\nIf this wasn't you, you can terminate that session in Settings > Devices (or Privacy & Security > Active Sessions).",
name,
now.UTC().Format(time.RFC1123),
client,
)
authID := int64(binary.LittleEndian.Uint64(authKeyID[:]))
update := &tg.UpdateServiceNotification{
InboxDate: int(now.Unix()),
Type: fmt.Sprintf("auth%d_%d", authID, now.Unix()),
Message: message,
Media: &tg.MessageMediaEmpty{},
Entities: signInNotificationEntities(message),
}
return &tg.Updates{
Updates: []tg.UpdateClass{update},
Date: int(now.Unix()),
}
}
func signInNotificationEntities(message string) []tg.MessageEntityClass {
terms := []string{"New login.", "Settings > Devices", "Privacy & Security > Active Sessions"}
out := make([]tg.MessageEntityClass, 0, len(terms))
for _, term := range terms {
if offset := strings.Index(message, term); offset >= 0 {
out = append(out, &tg.MessageEntityBold{Offset: offset, Length: len(term)})
}
}
return out
}
func authKeyIDFromInt64(v int64) [8]byte {
var id [8]byte
binary.LittleEndian.PutUint64(id[:], uint64(v))
return id
}