fix: sync remote authorization revoke logout

This commit is contained in:
iamxvbaba 2026-07-24 21:39:00 +08:00
parent b4aaf57d6b
commit 0dcfaf0a65
11 changed files with 216 additions and 142 deletions

View file

@ -505,7 +505,10 @@ FROM authorizations WHERE auth_key_id = $1 AND user_id = $2 FOR UPDATE`, id, use
if !found {
return nil, nil
}
if err := deleteRevocationTargetsTx(ctx, tx, []int64{id}); err != nil {
// Cancelling a pending account deletion deliberately retires the requester
// protocol identity; unlike remote device revocation, this path does not need
// to preserve the key for a client-visible RPC 401 transition.
if err := deleteProtocolAuthIdentitiesTx(ctx, tx, []int64{id}); err != nil {
return nil, err
}
return []domain.Authorization{a}, nil

View file

@ -195,8 +195,9 @@ WHERE auth_key_id = $1
//
// 同时清理把本 key 当作 perm key 的 temp auth key 行temp_auth_key_bindings.temp_auth_key_id
// 侧有外键 ON DELETE CASCADE删除 temp key 会自动清绑定perm_auth_key_id 侧由
// RESTRICT FK 防止悬空,因此被踢/销毁 perm key 时必须先把关联 temp key 一并删掉。否则 Web/上传连接用
// raw temp key 重连时仍能进入 RPC 层,只得到 AUTH_KEY_UNREGISTERED而不是连接层 404。
// RESTRICT FK 防止悬空,因此显式销毁 perm key 时必须先把关联 temp key 一并删掉。
// 远程撤销 authorization 不得调用本方法:被踢客户端必须保留协议 key重连进入 RPC
// 层后取得 AUTH_KEY_UNREGISTERED而不是只收到连接层 -404。
func (s *AuthKeyStore) Delete(ctx context.Context, id [8]byte) error {
return withAuthIdentityTx(ctx, s.db, "delete auth key", func(tx pgx.Tx) error {
return deleteAuthKeyTx(ctx, tx, authKeyIDToInt64(id))

View file

@ -252,9 +252,10 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
return a, true, nil
}
// RevokeByHash 删除协议 auth_key 作为远程踢设备的持久化事实入口。
// authorizations 通过 FK cascade 删除update_states 没有 auth_keys FK必须显式清理
// 关联 temp auth key 也显式删除,避免 raw temp key 重连。
// RevokeByHash 是远程踢设备的持久化事实入口:删除业务 authorization 与
// device update state但保留 permanent/temp 协议 key 和 binding。这样被踢客户端
// 重连后仍可完成 MTProto 解密,并由 RPC gate 返回 AUTH_KEY_UNREGISTERED若先删除
// 协议 key客户端只能收到 transport -404无法可靠清理本地登录态。
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
var (
a domain.Authorization
@ -315,7 +316,7 @@ FOR UPDATE`, candidate, userID, hash))
if !found {
return domain.Authorization{}, false, nil
}
if err := deleteRevocationTargetsTx(ctx, tx, []int64{candidate}); err != nil {
if err := deleteRevokedAuthorizationStateTx(ctx, tx, []int64{candidate}); err != nil {
return domain.Authorization{}, false, err
}
return a, true, nil
@ -349,7 +350,8 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
return out, nil
}
// RevokeByUserExcept 批量删除协议 auth_key保留 keepAuthKeyID 对应的当前设备。
// RevokeByUserExcept 批量删除业务 authorization保留 keepAuthKeyID 对应的当前设备;
// 被撤销设备的协议 key/binding 保留,以便重连后取得 RPC 401 并完成客户端退出。
func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
var out []domain.Authorization
err := withAuthIdentityTx(ctx, s.db, "revoke authorizations by user", func(tx pgx.Tx) error {
@ -449,13 +451,38 @@ FOR UPDATE`, userID, keepAuthKeyID, candidates)
for i := range out {
targets[i] = authKeyIDToInt64(out[i].AuthKeyID)
}
if err := deleteRevocationTargetsTx(ctx, tx, targets); err != nil {
if err := deleteRevokedAuthorizationStateTx(ctx, tx, targets); err != nil {
return nil, err
}
return out, nil
}
func deleteRevocationTargetsTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
func deleteRevokedAuthorizationStateTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
if len(authKeyIDs) == 0 {
return nil
}
if _, err := tx.Exec(ctx, `
DELETE FROM update_states
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs); err != nil {
return fmt.Errorf("delete revoked update states: %w", err)
}
tag, err := tx.Exec(ctx, `
DELETE FROM authorizations
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs)
if err != nil {
return fmt.Errorf("delete revoked authorizations: %w", err)
}
if tag.RowsAffected() != int64(len(authKeyIDs)) {
return fmt.Errorf("delete revoked authorizations: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
}
return nil
}
// deleteProtocolAuthIdentitiesTx permanently removes permanent identities and
// their derived temp keys. Remote account.resetAuthorization/
// auth.resetAuthorizations must not use this helper: those clients need the
// protocol key long enough to reconnect and receive an RPC-level 401.
func deleteProtocolAuthIdentitiesTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
if len(authKeyIDs) == 0 {
return nil
}
@ -466,21 +493,21 @@ WHERE auth_key_id IN (
FROM temp_auth_key_bindings
WHERE perm_auth_key_id = ANY($1::bigint[])
)`, authKeyIDs); err != nil {
return fmt.Errorf("delete revoked temporary auth keys: %w", err)
return fmt.Errorf("delete temporary auth keys: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM update_states
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs); err != nil {
return fmt.Errorf("delete revoked update states: %w", err)
return fmt.Errorf("delete protocol identity update states: %w", err)
}
tag, err := tx.Exec(ctx, `
DELETE FROM auth_keys
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs)
if err != nil {
return fmt.Errorf("delete revoked permanent auth keys: %w", err)
return fmt.Errorf("delete permanent auth keys: %w", err)
}
if tag.RowsAffected() != int64(len(authKeyIDs)) {
return fmt.Errorf("delete revoked permanent auth keys: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
return fmt.Errorf("delete permanent auth keys: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
}
return nil
}

View file

@ -2,7 +2,6 @@ package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
@ -13,7 +12,7 @@ import (
"telesrv/internal/store"
)
func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *testing.T) {
func TestAuthorizationStoreRevokeByHashKeepsProtocolIdentityPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "hash")
@ -43,14 +42,15 @@ func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *test
if err := NewUpdateStateStore(pool).Save(ctx, perm, userID, domain.UpdateState{Pts: 11, Date: int(time.Now().Unix())}); err != nil {
t.Fatalf("save update state: %v", err)
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
binding := domain.TempAuthKeyBinding{
TempAuthKeyID: temp,
PermAuthKeyID: authKeyIDToInt64(perm),
Nonce: 1,
TempSessionID: 2,
ExpiresAt: tempExpiry,
EncryptedMessage: []byte{1, 2, 3, 4},
}); err != nil {
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, binding); err != nil {
t.Fatalf("save temp binding: %v", err)
}
@ -61,14 +61,14 @@ func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *test
if deleted.AuthKeyID != perm || !deleted.PasswordPending {
t.Fatalf("deleted authorization = %+v, want perm key and password_pending", deleted)
}
assertRevokeTestMissingAuthKey(t, ctx, keys, perm)
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
assertRevokeTestPresentAuthKey(t, ctx, keys, temp)
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
assertRevokeTestTableCount(t, ctx, pool, "update_states", "auth_key_id", authKeyIDToInt64(perm), 0)
assertRevokeTestTableCount(t, ctx, pool, "temp_auth_key_bindings", "temp_auth_key_id", authKeyIDToInt64(temp), 0)
assertTempIdentityBinding(t, ctx, NewTempAuthKeyBindingStore(pool), binding)
}
func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *testing.T) {
func TestAuthorizationStoreRevokeByUserExceptKeepsRevokedProtocolIdentitiesPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "bulk")
@ -87,14 +87,15 @@ func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *t
}
tempExpiry := int(time.Now().Add(time.Hour).Unix())
saveRevokeTestAuthKey(t, ctx, keys, tempForTwo, tempExpiry)
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
binding := domain.TempAuthKeyBinding{
TempAuthKeyID: tempForTwo,
PermAuthKeyID: authKeyIDToInt64(revokedTwo),
Nonce: 3,
TempSessionID: 4,
ExpiresAt: tempExpiry,
EncryptedMessage: []byte{5, 6, 7, 8},
}); err != nil {
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, binding); err != nil {
t.Fatalf("save temp binding: %v", err)
}
@ -107,9 +108,12 @@ func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *t
}
assertRevokeTestPresentAuthKey(t, ctx, keys, keep)
assertRevokeTestPresentAuthorization(t, ctx, auths, keep)
assertRevokeTestMissingAuthKey(t, ctx, keys, revokedOne)
assertRevokeTestMissingAuthKey(t, ctx, keys, revokedTwo)
assertRevokeTestMissingAuthKey(t, ctx, keys, tempForTwo)
assertRevokeTestPresentAuthKey(t, ctx, keys, revokedOne)
assertRevokeTestPresentAuthKey(t, ctx, keys, revokedTwo)
assertRevokeTestPresentAuthKey(t, ctx, keys, tempForTwo)
assertRevokeTestNoAuthorization(t, ctx, auths, revokedOne)
assertRevokeTestNoAuthorization(t, ctx, auths, revokedTwo)
assertTempIdentityBinding(t, ctx, NewTempAuthKeyBindingStore(pool), binding)
}
func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
@ -155,7 +159,7 @@ func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
}
}
func TestAuthorizationStoreRevokeByHashConcurrentTempBindLeavesNoDanglingStatePostgres(t *testing.T) {
func TestAuthorizationStoreRevokeByHashConcurrentTempBindKeepsProtocolIdentityPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "bind-revoke-race")
@ -203,28 +207,17 @@ func TestAuthorizationStoreRevokeByHashConcurrentTempBindLeavesNoDanglingStatePo
close(start)
bindErr := <-bindResult
if bindErr != nil && !errors.Is(bindErr, store.ErrAuthKeyBindingInvalid) {
if bindErr != nil {
t.Fatalf("attempt %d bind/revoke race bind error = %v", attempt, bindErr)
}
revoked := <-revokeResults
if revoked.err != nil || !revoked.found {
t.Fatalf("attempt %d bind/revoke race found=%v err=%v", attempt, revoked.found, revoked.err)
}
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
t.Fatalf("attempt %d dangling binding found=%v err=%v", attempt, found, err)
}
assertRevokeTestMissingAuthKey(t, ctx, keys, perm)
assertTempIdentityBinding(t, ctx, bindings, candidate)
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
assertRevokeTestPresentAuthKey(t, ctx, keys, temp)
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
if bindErr == nil {
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
} else {
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, tempExpiry)
assertRevokeTestNoAuthorization(t, ctx, auths, temp)
if err := keys.Delete(ctx, temp); err != nil {
t.Fatalf("attempt %d clean unbound loser temp: %v", attempt, err)
}
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
}
}
}
@ -520,12 +513,10 @@ func TestAuthorizationStoreRevokeByUserExceptPartiallySkipsTransferredCandidateP
t.Fatalf("old A state for transferred key found=%v err=%v, want absent", found, err)
}
assertRevokeTestMissingAuthKey(t, testCtx, keys, revoked)
assertRevokeTestMissingAuthKey(t, testCtx, keys, revokedTemp)
assertRevokeTestPresentAuthKey(t, testCtx, keys, revoked)
assertRevokeTestPresentAuthKey(t, testCtx, keys, revokedTemp)
assertRevokeTestNoAuthorization(t, testCtx, auths, revoked)
if _, found, err := bindings.GetByTemp(testCtx, revokedTemp); err != nil || found {
t.Fatalf("revoked temp binding found=%v err=%v, want absent", found, err)
}
assertTempIdentityBinding(t, testCtx, bindings, revokedBinding)
if _, found, err := states.Get(testCtx, revoked, userA); err != nil || found {
t.Fatalf("revoked A state found=%v err=%v, want absent", found, err)
}