owpengram-server/internal/rpc/auth.go
2026-09-01 12:06:31 +03:00

1179 lines
46 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"
"encoding/hex"
"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/branding"
"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.AuthReportMissingCodeRequest](d, tlprofile.SemanticMethodAuthReportMissingCode, func(ctx context.Context, req *tg.AuthReportMissingCodeRequest) (any, error) {
return r.onAuthReportMissingCode(ctx, req)
})
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)
boundState, 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...),
})
if err != nil {
return false, bindTempAuthKeyErr(err)
}
permID := authKeyIDFromInt64(req.PermAuthKeyID)
// The committed bind transaction is authoritative for this immutable
// temp→permanent identity. Replace any prior local entry, then publish the
// exact positive mapping so Layer publication and the first business RPC do
// not re-read the same row. A competing different-permanent bind has already
// failed in the store before reaching this point.
if id != ([8]byte{}) {
r.tempKeyResolveCache.Delete(id)
r.cacheResolvedAuthKey(id, permID)
}
// Save atomically merged raw/permanent Layer observations and returned the
// exact committed tuple. Project that generation directly; a post-commit
// read could observe a later selector and wrongly attribute it to this bind.
r.invalidateAuthUserCache(id)
r.invalidateAuthUserCache(permID)
unlockLayerCommit := r.lockAuthLayerCommit(id, permID)
defer unlockLayerCommit()
layer, blocked, err := r.cacheBoundAuthKeyLayerResolution(id, permID, boundState)
if err != nil {
if r.log != nil {
r.log.Error("project committed temp auth key bind Layer 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()
}
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)
}
}
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 blocked || layer == 0 {
if clearer, ok := r.deps.Sessions.(AuthKeyInheritedLayerClearer); ok {
clearer.ClearInheritedLayerForRawAuthKey(id)
}
}
return true, nil
}
func (r *Router) cacheBoundAuthKeyLayerResolution(
rawAuthKeyID, permAuthKeyID [8]byte,
result domain.TempAuthKeyBindingResult,
) (layer int, blocked bool, err error) {
if result.Layer < 0 || result.LayerObservationID < 0 ||
(result.LayerObservationID > 0 && result.Layer == 0) {
return 0, false, fmt.Errorf(
"invalid bound auth-key Layer result layer=%d observation=%d",
result.Layer, result.LayerObservationID,
)
}
outcome := clientSessionInfo{layerObservationID: result.LayerObservationID}
if isSupportedLayer(result.Layer) {
outcome.layer = result.Layer
} else if result.Layer != 0 {
outcome.layerBlocked = true
outcome.layerBlockedByAuthKey = true
}
r.clientInfoMu.Lock()
defer r.clientInfoMu.Unlock()
for _, authKeyID := range [][8]byte{rawAuthKeyID, permAuthKeyID} {
current := r.authInfo[authKeyID]
switch {
case current.layerObservationID > outcome.layerObservationID:
outcome.layer = current.layer
outcome.layerObservationID = current.layerObservationID
outcome.layerBlocked = current.layerBlocked
outcome.layerBlockedByAuthKey = current.layerBlockedByAuthKey
case current.layerObservationID == outcome.layerObservationID && outcome.layerObservationID > 0:
currentBlocked := current.layerBlocked || current.layerBlockedByAuthKey
outcomeBlocked := outcome.layerBlocked || outcome.layerBlockedByAuthKey
if current.layer != 0 && outcome.layer != 0 && current.layer != outcome.layer {
return 0, false, fmt.Errorf(
"conflicting cached bound auth-key Layer observation %d: %d != %d",
outcome.layerObservationID, current.layer, outcome.layer,
)
}
if currentBlocked != outcomeBlocked &&
(current.layer != 0 || outcome.layer != 0 || currentBlocked || outcomeBlocked) {
return 0, false, fmt.Errorf(
"conflicting cached bound auth-key blocked observation %d",
outcome.layerObservationID,
)
}
if outcome.layer == 0 {
outcome.layer = current.layer
}
}
}
if r.authInfo == nil {
r.authInfo = make(map[[8]byte]clientSessionInfo)
}
for _, authKeyID := range [][8]byte{rawAuthKeyID, permAuthKeyID} {
if _, exists := r.authInfo[authKeyID]; !exists {
evictMapEntryIfFullLocked(r.authInfo, maxAuthInfoEntries)
}
info := r.authInfo[authKeyID]
info.layer = outcome.layer
info.layerObservationID = outcome.layerObservationID
info.layerAdmissionSeq = 0
info.layerBlocked = outcome.layerBlocked
info.layerBlockedByAuthKey = outcome.layerBlockedByAuthKey
r.authInfo[authKeyID] = info
}
return outcome.layer, outcome.layerBlocked || outcome.layerBlockedByAuthKey, nil
}
// 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.tgSelfUserWithUsernames(ctx, 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()
}
// The public MTProto error intentionally stays opaque, but operators need
// the wrapped store/provider cause to repair an update-related failure.
// Hash the normalized phone so neither the number nor the OTP reaches logs.
phoneDigest := sha256.Sum256([]byte(domain.NormalizePhone(req.PhoneNumber)))
fields := append(r.contextLogFields(ctx),
zap.Int("api_id", req.APIID),
zap.String("phone_digest", hex.EncodeToString(phoneDigest[:8])),
zap.Error(err),
)
r.log.Error("auth.sendCode failed", fields...)
return nil, internalErr()
}
sent, err := r.tgSentCodeForHash(ctx, hash)
if err != nil {
fields := append(r.contextLogFields(ctx), zap.Error(err))
r.log.Error("auth.sendCode delivery lookup failed", fields...)
return nil, err
}
return sent, nil
}
func (r *Router) onAuthReportMissingCode(ctx context.Context, req *tg.AuthReportMissingCodeRequest) (bool, error) {
if req == nil || r.deps.AuthDeliveryReports == nil {
return false, inputRequestInvalidErr()
}
authKeyID, authKeyOK := AuthKeyIDFrom(ctx)
sessionID, sessionOK := SessionIDFrom(ctx)
if !authKeyOK || authKeyID == ([8]byte{}) || !sessionOK || sessionID == 0 {
return false, internalErr()
}
clientType := string(ClientTypeFrom(ctx))
if _, _, err := r.deps.AuthDeliveryReports.ReportMissingCode(ctx, domain.AuthMissingCodeReportRequest{
AuthKeyID: authKeyID, SessionID: sessionID, ClientType: clientType,
Phone: req.PhoneNumber, PhoneCodeHash: req.PhoneCodeHash,
MNC: req.Mnc, CreatedAt: r.clock.Now(),
}); err != nil {
switch {
case errors.Is(err, domain.ErrPhoneCodeExpired):
return false, phoneCodeExpiredErr()
case errors.Is(err, domain.ErrPhoneCodeInvalid),
errors.Is(err, domain.ErrAuthDeliveryReportInvalid):
return false, phoneCodeInvalidErr()
case errors.Is(err, domain.ErrAuthDeliveryRateLimited):
return false, floodWaitErr(60)
default:
return false, internalErr()
}
}
return true, nil
}
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, resetAvailable bool) tg.AuthSentCodeClass {
if length <= 0 {
length = devCodeLength
}
codeType := &tg.AuthSentCodeTypeEmailCode{
EmailPattern: emailPattern,
Length: length,
}
if resetAvailable {
// reset_available_period=0 表示可立即调用 auth.resetLoginEmail开发环境无等待期
// 让客户端的"无法访问邮箱?"逃生入口可用。留空(不调用 Set时该入口在客户端
// 完全不显示——见 auth.Service.LoginEmailResetAvailable这个逃生入口本来就
// 走不通(没有真实短信通道,或邮箱本身就是身份、没有"手机"可退回)时,不应该
// 让用户看到一个点了也没用、甚至只会用固定 dev code 顶替的按钮。
codeType.SetResetAvailablePeriod(0)
}
return &tg.AuthSentCode{
Type: codeType,
PhoneCodeHash: hash,
}
}
// loginEmailResetAvailabilityChecker lets tgSentCodeForHash ask whether
// auth.resetLoginEmail could actually succeed right now, so the client is
// never shown a "Can't access this email?" escape hatch it cannot use (see
// auth.Service.LoginEmailResetAvailable / ConsumeLoginEmailReset).
type loginEmailResetAvailabilityChecker interface {
LoginEmailResetAvailable() bool
}
func (r *Router) loginEmailResetAvailable() bool {
checker, ok := r.deps.Auth.(loginEmailResetAvailabilityChecker)
return ok && checker.LoginEmailResetAvailable()
}
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, r.loginEmailResetAvailable()), 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.tgSelfUserWithUsernames(ctx, 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
}
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
if svc, ok := r.deps.Account.(accountDeletionService); ok {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
hash, delivery, handled, err := svc.ResendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.PhoneNumber, req.PhoneCodeHash)
if handled {
if err != nil {
return nil, accountDeletionErr(err)
}
return tgSMSSentCode(hash, delivery.Length), nil
}
}
}
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) {
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
if svc, ok := r.deps.Account.(accountDeletionService); ok {
authKeyID, _ := AuthKeyIDFrom(ctx)
handled, err := svc.CancelConfirmPhoneCode(ctx, userID, authKeyID, req.PhoneNumber, req.PhoneCodeHash)
if handled {
if err != nil {
return false, accountDeletionErr(err)
}
return true, nil
}
}
}
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)
// 撤销其它会话会删除其业务 authorization协议 key 保留用于让客户端
// 重连后取得 AUTH_KEY_UNREGISTERED。密聊仍按设备授权边界 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.tgSelfUserWithUsernames(ctx, 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.tgSelfUserWithUsernames(ctx, 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, userID); 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.tgSelfUserWithUsernames(ctx, 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.tgSelfUserWithUsernames(ctx, 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.tgSelfUserWithUsernames(ctx, 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)
}
}
// 登出撤销本设备 authorization 后,级联 discard 其绑定的活跃密聊并通知对端
//否则对端继续往已退出设备投递成静默死链。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, branding.UserVisibleText(ci.DeviceModel, ""))
}
if ci.SystemVersion != "" {
parts = append(parts, branding.UserVisibleText(ci.SystemVersion, ""))
}
if ci.AppVersion != "" {
parts = append(parts, branding.UserVisibleText(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
}