chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -13,7 +13,7 @@ import (
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/app/auth"
"telesrv/internal/domain"
)
@ -37,20 +37,21 @@ func (r *Router) registerAuth(d *tg.ServerDispatcher) {
d.OnAuthDropTempAuthKeys(func(ctx context.Context, exceptauthkeys []int64) (bool, error) {
return true, nil
})
d.OnAuthInitPasskeyLogin(func(ctx context.Context, req *tg.AuthInitPasskeyLoginRequest) (*tg.AuthPasskeyLoginOptions, error) {
return &tg.AuthPasskeyLoginOptions{Options: tg.DataJSON{Data: "{}"}}, nil
})
d.OnAuthInitPasskeyLogin(r.onAuthInitPasskeyLogin)
d.OnAuthFinishPasskeyLogin(r.onAuthFinishPasskeyLogin)
d.OnAuthSendCode(r.onAuthSendCode)
d.OnAuthResendCode(r.onAuthResendCode)
d.OnAuthCancelCode(r.onAuthCancelCode)
d.OnAuthSignIn(r.onAuthSignIn)
d.OnAuthSignUp(r.onAuthSignUp)
d.OnAuthImportBotAuthorization(r.onAuthImportBotAuthorization)
d.OnAuthLogOut(r.onAuthLogOut)
d.OnAuthResetAuthorizations(r.onAuthResetAuthorizations)
d.OnAuthCheckPassword(r.onAuthCheckPassword)
d.OnAuthRequestPasswordRecovery(r.onAuthRequestPasswordRecovery)
d.OnAuthRecoverPassword(r.onAuthRecoverPassword)
d.OnAuthCheckRecoveryPassword(r.onAuthCheckRecoveryPassword)
d.OnAuthResetLoginEmail(r.onAuthResetLoginEmail)
}
// onAuthBindTempAuthKey 记录 TDesktop 的 PFS temp→perm auth key 绑定。
@ -72,6 +73,11 @@ func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTemp
}); err != nil {
return false, bindTempAuthKeyErr(err)
}
// temp key (re)bind 后立即作废其 temp→perm 解析缓存,确保下一帧按新绑定重新解析,
// 不被 TTL 内的旧 perm 缓存命中(防跨账号串号)。
if id != ([8]byte{}) {
r.tempKeyResolveCache.Delete(id)
}
if r.deps.Sessions != nil {
if scoped, ok := r.scopedSessions(); ok {
rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx)
@ -84,32 +90,185 @@ func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTemp
return true, nil
}
// onAuthExportLoginToken 给 TDesktop QR 登录页返回一个短期占位 token。
func (r *Router) onAuthExportLoginToken(ctx context.Context, _ *tg.AuthExportLoginTokenRequest) (tg.AuthLoginTokenClass, error) {
id, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
return tdesktop.LoginToken(r.clock.Now(), id, sessionID), nil
}
func (r *Router) onAuthImportLoginToken(ctx context.Context, token []byte) (tg.AuthLoginTokenClass, error) {
id, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
return tdesktop.LoginToken(r.clock.Now(), id, sessionID), nil
}
func (r *Router) onAuthAcceptLoginToken(ctx context.Context, token []byte) (*tg.Authorization, error) {
return nil, authTokenInvalidErr()
}
// onAuthSendCode 处理 auth.sendCode生成 phone_code_hash 并返回 sentCode。
func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest) (tg.AuthSentCodeClass, error) {
hash, err := r.deps.Auth.SendCode(ctx, req.PhoneNumber)
// 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
}
if scoped, ok := r.scopedSessions(); ok && target.rawAuthKeyID != ([8]byte{}) {
scoped.BindAuthKeyForSession(target.rawAuthKeyID, target.sessionID, target.authKeyID)
scoped.BindUserForAuthKey(target.rawAuthKeyID, target.sessionID, userID)
r.announceSessionOnline(loginTokenTargetContext(target, userID), userID)
return
}
r.deps.Sessions.BindAuthKey(target.sessionID, target.authKeyID)
r.deps.Sessions.BindUser(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.(ScopedImmediateSessionPusher); ok && target.rawAuthKeyID != ([8]byte{}) {
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 scoped, ok := r.scopedSessions(); ok && target.rawAuthKeyID != ([8]byte{}) {
if err := scoped.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))
}
return
}
if err := r.deps.Sessions.PushToSession(ctx, 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) {
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()
}
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
}
func tgSentCode(hash string) tg.AuthSentCodeClass {
return &tg.AuthSentCode{
Type: &tg.AuthSentCodeTypeApp{Length: devCodeLength},
@ -117,18 +276,46 @@ func tgSentCode(hash string) tg.AuthSentCodeClass {
}
}
func tgEmailSentCode(hash, emailPattern string) tg.AuthSentCodeClass {
codeType := &tg.AuthSentCodeTypeEmailCode{
EmailPattern: emailPattern,
Length: devCodeLength,
}
// reset_available_period=0 表示可立即调用 auth.resetLoginEmail开发环境无等待期
// 让客户端的"无法访问邮箱?"逃生入口可用。
codeType.SetResetAvailablePeriod(0)
return &tg.AuthSentCode{
Type: codeType,
PhoneCodeHash: hash,
}
}
// onAuthSignIn 处理 auth.signIn校验验证码用户不存在时返回 SignUpRequired。
// 带 email_verification 时走登录邮箱路径(验证码来自邮箱而非短信)。
func (r *Router) onAuthSignIn(ctx context.Context, req *tg.AuthSignInRequest) (tg.AuthAuthorizationClass, error) {
u, loginMessage, needSignUp, err := r.deps.Auth.SignIn(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.PhoneCode)
var (
u domain.User
loginMessage domain.Message
needSignUp bool
err error
)
if verification, ok := req.GetEmailVerification(); ok {
u, loginMessage, needSignUp, err = r.deps.Auth.SignInWithEmail(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, emailVerificationCode(verification))
} else {
u, loginMessage, needSignUp, err = r.deps.Auth.SignIn(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.PhoneCode)
}
if err != nil {
if errors.Is(err, domain.ErrSessionPasswordNeeded) && u.ID != 0 {
if err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil {
return nil, internalErr()
}
// 两步验证未完成:绝不能把 auth_key/session 标记为已登录,否则客户端忽略
// SESSION_PASSWORD_NEEDED、直接调用业务 RPC 即可绕过 2FA。失效缓存并把 session
// 置为未授权,让后续鉴权重新读到 password_pending 并拒绝;待 checkPassword 通过后再授权。
if id, ok := AuthKeyIDFrom(ctx); ok {
r.setAuthUserCache(id, u.ID, true)
r.invalidateAuthUserCache(id)
}
r.bindSessionUser(ctx, u.ID)
r.bindSessionUser(ctx, 0)
}
return nil, signInErr(err)
}
@ -173,24 +360,35 @@ func (r *Router) onAuthResetAuthorizations(ctx context.Context) (bool, error) {
return false, internalErr()
}
for _, a := range deleted {
r.invalidateAuthUserCache(a.AuthKeyID)
r.unbindAuthKey(a.AuthKeyID)
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) {
userID, _, err := r.currentUserID(ctx)
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()
@ -199,7 +397,7 @@ func (r *Router) onAuthCheckPassword(ctx context.Context, password tg.InputCheck
}
func (r *Router) onAuthRequestPasswordRecovery(ctx context.Context) (*tg.AuthPasswordRecovery, error) {
userID, _, err := r.currentUserID(ctx)
userID, _, _, err := r.currentOrPendingPasswordUserID(ctx)
if err != nil {
return nil, internalErr()
}
@ -211,7 +409,8 @@ func (r *Router) onAuthRequestPasswordRecovery(ctx context.Context) (*tg.AuthPas
}
func (r *Router) onAuthRecoverPassword(ctx context.Context, req *tg.AuthRecoverPasswordRequest) (tg.AuthAuthorizationClass, error) {
userID, _, err := r.currentUserID(ctx)
authKeyID, _ := AuthKeyIDFrom(ctx)
userID, _, pending, err := r.currentOrPendingPasswordUserID(ctx)
if err != nil {
return nil, internalErr()
}
@ -226,6 +425,11 @@ func (r *Router) onAuthRecoverPassword(ctx context.Context, req *tg.AuthRecoverP
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()
@ -234,7 +438,7 @@ func (r *Router) onAuthRecoverPassword(ctx context.Context, req *tg.AuthRecoverP
}
func (r *Router) onAuthCheckRecoveryPassword(ctx context.Context, code string) (bool, error) {
userID, _, err := r.currentUserID(ctx)
userID, _, _, err := r.currentOrPendingPasswordUserID(ctx)
if err != nil {
return false, internalErr()
}
@ -244,6 +448,143 @@ func (r *Router) onAuthCheckRecoveryPassword(ctx context.Context, code string) (
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 供其继续。
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.deps.Account.ClearLoginEmailByPhone(ctx, req.PhoneNumber); err != nil {
return nil, internalErr()
}
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 tgSentCode(hash), nil
}
// emailVerificationCode 从 emailVerification 取出可校验的字符串(验证码 / Google·Apple
// 令牌)。开发环境一律按"任意非空即通过"处理,故三者等价取值。
// 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 同构清水位→授权缓存→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 err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil {
return nil, internalErr()
}
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 同构清水位→授权缓存→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 err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil {
return nil, internalErr()
}
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)
@ -270,9 +611,21 @@ func (r *Router) onAuthLogOut(ctx context.Context) (*tg.AuthLoggedOut, error) {
}
r.invalidateAuthUserCache(id)
r.unbindAuthKey(id)
if userErr == nil && authorized && userID != 0 {
status := r.setPresenceFromContext(ctx, userID, true)
// 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()
@ -324,6 +677,38 @@ func (r *Router) unbindAuthKey(authKeyID [8]byte) {
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