From 680adf7b930333dae5e365d3952d20c1c1e3e624 Mon Sep 17 00:00:00 2001 From: Xu Liang Date: Tue, 5 May 2026 22:09:10 +0800 Subject: [PATCH] fix(desktop): scope account access to owning client and extend identity verification Restrict account tracking, probing, and health reporting to accounts owned by the current client (client_id match). Extend identity-match verification to bilibili, juejin, and smzdm so session validity is confirmed locally rather than deferred to remote health state. Server-side account view now uses stored client_id as authoritative owner instead of presence data. Co-Authored-By: Claude Sonnet 4.6 --- .../desktop-client/src/main/account-binder.ts | 42 +++++++++++++- .../desktop-client/src/main/account-health.ts | 49 +++++++++++++++- .../desktop-client/src/main/adapters/smzdm.ts | 4 ++ .../src/main/runtime-controller.ts | 58 +++++++++++++++---- .../src/main/runtime-snapshot.ts | 7 ++- .../tenant/app/desktop_account_service.go | 15 ++--- .../app/desktop_account_service_test.go | 10 ++-- 7 files changed, 151 insertions(+), 34 deletions(-) diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index 6e7c383..141d47f 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -222,6 +222,8 @@ type BilibiliSpaceInfoResponse = { } type JuejinProfileResponse = { + err_no?: number + err_msg?: string data?: { bui_user?: { user_id?: string @@ -2540,6 +2542,16 @@ export async function silentRefreshPublishAccountSession( return await verifyZolConsoleAccess(window, handle.session, account) } + if (platformRequiresDetectedAccountIdentityMatch(account.platform)) { + const detected = await definition + .detect({ + session: handle.session, + webContents: window.webContents, + }) + .catch(() => null) + return detected ? detectedAccountMatchesIdentity(detected, account) : false + } + if (platformRequiresDetectedAccountForConsoleAccess(account.platform)) { const detected = await definition .detect({ @@ -2860,7 +2872,14 @@ function platformRequiresDetectedAccountForConsoleAccess(platformId: string): bo } function platformRequiresDetectedAccountIdentityMatch(platformId: string): boolean { - return platformId === 'qiehao' || platformId === 'wangyihao' || platformId === 'zol' + return ( + platformId === 'qiehao' || + platformId === 'wangyihao' || + platformId === 'zol' || + platformId === 'bilibili' || + platformId === 'juejin' || + platformId === 'smzdm' + ) } async function verifyZolConsoleAccess( @@ -2980,6 +2999,16 @@ async function verifyPublishAccountConsoleAccess( return await verifyZolConsoleAccess(window, handle.session, account) } + if (platformRequiresDetectedAccountIdentityMatch(account.platform)) { + const detected = await definition + .detect({ + session: handle.session, + webContents: window.webContents, + }) + .catch(() => null) + return detected ? detectedAccountMatchesIdentity(detected, account) : false + } + if (platformRequiresDetectedAccountForConsoleAccess(account.platform)) { const detected = await definition .detect({ @@ -3207,6 +3236,10 @@ async function detectJuejin({ session }: DetectContext): Promise null) + if (!response || (typeof response.err_no === 'number' && response.err_no !== 0)) { + return null + } + const user = response?.data?.bui_user if (!user?.user_id || !user.screen_name) { return null @@ -3467,7 +3500,7 @@ async function detectSmzdm({ return fromCookieRequest } - return await deriveSmzdmCookieAccount(session, pageState) + return null } async function detectWeixinGzh({ session }: DetectContext): Promise { @@ -4012,7 +4045,10 @@ export async function openPublishAccountConsole(account: PublishAccountIdentity) throw new Error(`desktop_platform_not_supported:${account.platform}`) } - const handle = await ensurePublishAccountSessionHandle(account) + const handle = await findLocalPublishAccountSessionHandle(account) + if (!handle) { + throw new Error(`desktop_account_session_expired:${account.platform}`) + } const consoleURL = await resolvePublishAccountConsoleURL(definition, handle.session) if (platformRequiresDetectedAccountForConsoleAccess(account.platform)) { const detected = await definition diff --git a/apps/desktop-client/src/main/account-health.ts b/apps/desktop-client/src/main/account-health.ts index 50d8119..02decd8 100644 --- a/apps/desktop-client/src/main/account-health.ts +++ b/apps/desktop-client/src/main/account-health.ts @@ -98,6 +98,10 @@ function resolvePartition(accountId: string): string { ) } +function resolveKnownPartition(accountId: string): string | null { + return getSessionHandle(accountId)?.partition ?? getPersistedPartition(accountId) ?? null +} + function nextInitialProbeAt(now = Date.now()): number { return now } @@ -375,7 +379,19 @@ async function performProbeLocked( record.probeState = 'probing' commitRecord(record, previousSignature) - const partition = resolvePartition(account.id) + const partition = resolveKnownPartition(account.id) + if (!partition) { + return applyExpiredResult( + record, + { + verdict: 'expired', + reason: 'missing_partition', + profile: null, + sessionFingerprint: null, + }, + Date.now(), + ) + } if (options.allowSilentRefresh) { const refreshed = await adapter.silentRefresh(account, partition).catch(() => false) @@ -748,6 +764,37 @@ export function markTrackedAccountBound( return snapshot } +export function markTrackedAccountMissingPartition( + account: PublishAccountIdentity, +): AccountHealthSnapshot { + const record = ensureRecord(account) + const previousSignature = snapshotSignature(record) + const now = Date.now() + + trackedAccounts.delete(account.id) + record.authRevision += 1 + record.platform = account.platform + record.authState = 'expired' + record.probeState = 'idle' + record.authReason = 'missing_partition' + record.lastProbeAt = now + record.nextProbeAt = null + record.lastAuthFailureAt = now + record.profile = null + record.sessionFingerprint = null + record.suspectedExpiredAt = null + record.confirmFailureCount = 0 + + const snapshot = commitRecord(record, previousSignature) + const queuedJob = probeJobs.get(account.id) + if (queuedJob?.status === 'queued') { + removeQueuedProbeJob(account.id) + queuedJob.resolve(snapshot) + } + + return snapshot +} + export function reconcileTrackedAccountRemoteState( account: PublishAccountIdentity, input: { diff --git a/apps/desktop-client/src/main/adapters/smzdm.ts b/apps/desktop-client/src/main/adapters/smzdm.ts index 7bfe0ff..ac54196 100644 --- a/apps/desktop-client/src/main/adapters/smzdm.ts +++ b/apps/desktop-client/src/main/adapters/smzdm.ts @@ -214,6 +214,9 @@ async function fetchCurrentAccount(context: PublishAdapterContext): Promise ''), ) @@ -229,6 +232,7 @@ async function fetchCurrentAccount(context: PublishAdapterContext): Promise { noteSchedulerAccountsSync() setSchedulerError(null) - const identities = accounts.map((account) => accountIdentityFromDesktopAccount(account)) + const identities = accounts + .filter((account) => hasLocalAccountAuthorization(account)) + .map((account) => accountIdentityFromDesktopAccount(account)) syncTrackedAccounts(identities) state.accountRemoteHealth = new Map(accounts.map((account) => [account.id, account.health])) @@ -968,15 +987,19 @@ async function syncAccounts(source: 'startup' | 'manual'): Promise { ...account, platform_uid: normalizedPlatformUid, }) - reconcileTrackedAccountRemoteState(accountIdentity, { - health: account.health, - verifiedAt: account.verified_at, - profile: { - subjectUid: normalizedPlatformUid, - displayName: account.display_name, - avatarUrl: account.avatar_url, - }, - }) + if (hasLocalAccountAuthorization(account) && !accountRequiresStrictProbe(account)) { + reconcileTrackedAccountRemoteState(accountIdentity, { + health: account.health, + verifiedAt: account.verified_at, + profile: { + subjectUid: normalizedPlatformUid, + displayName: account.display_name, + avatarUrl: account.avatar_url, + }, + }) + } else if (accountRequiresLocalAuthorization(account)) { + markTrackedAccountMissingPartition(accountIdentity) + } const projected = getProjectedAccountHealth({ accountId: account.id, platform: account.platform, @@ -1140,6 +1163,9 @@ function enqueueAccountHealthReport(accountId: string): void { if (!account) { return } + if (!hasLocalAccountAuthorization(account)) { + return + } const report = buildAccountHealthReport(account) if (!shouldQueueAccountHealthReport(account, report)) { @@ -1270,6 +1296,10 @@ export async function requestRuntimeAccountProbe( if (!account) { throw new Error('runtime_account_not_found') } + if (existingAccount && !hasLocalAccountAuthorization(existingAccount)) { + markTrackedAccountMissingPartition(account) + return + } try { await probeTrackedAccount(account, { @@ -1318,7 +1348,11 @@ export function noteRuntimeAccountBound(account: DesktopAccountInfo): void { avatarUrl: normalizedAccount.avatar_url, }) state.lastAccountsSyncAt = Date.now() - syncTrackedAccounts(state.accounts.map((item) => accountIdentityFromDesktopAccount(item))) + syncTrackedAccounts( + state.accounts + .filter((item) => hasLocalAccountAuthorization(item)) + .map((item) => accountIdentityFromDesktopAccount(item)), + ) markTrackedAccountBound(accountIdentityFromDesktopAccount(normalizedAccount), { subjectUid: normalizedAccount.platform_uid, displayName: normalizedAccount.display_name, diff --git a/apps/desktop-client/src/main/runtime-snapshot.ts b/apps/desktop-client/src/main/runtime-snapshot.ts index b82a635..d4321da 100644 --- a/apps/desktop-client/src/main/runtime-snapshot.ts +++ b/apps/desktop-client/src/main/runtime-snapshot.ts @@ -57,6 +57,9 @@ function createRuntimeAccountView( health: account.health, verifiedAt: account.verified_at, }) + const belongsToCurrentClient = Boolean( + account.client_id && account.client_id === context.primaryClientId, + ) return { id: account.id, @@ -70,7 +73,7 @@ function createRuntimeAccountView( authReason: projected.authReason, tags: account.tags, syncVersion: account.sync_version, - clientId: account.client_id ?? context.primaryClientId, + clientId: account.client_id ?? '', partition: projected.partition || sessionHandle?.partition || `persist:acc-${account.id}`, sessionState: isHot ? 'hot' : sessionHandle ? 'warm' : 'cold', lastSyncAt: context.lastAccountsSyncAt || parseTimestamp(account.verified_at) || context.now, @@ -78,7 +81,7 @@ function createRuntimeAccountView( lastProbeAt: projected.lastProbeAt, nextProbeAt: projected.nextProbeAt, queueDepth: accountQueueDepth, - online: context.clientOnline, + online: context.clientOnline && belongsToCurrentClient, } } diff --git a/server/internal/tenant/app/desktop_account_service.go b/server/internal/tenant/app/desktop_account_service.go index d777ecb..08f2f55 100644 --- a/server/internal/tenant/app/desktop_account_service.go +++ b/server/internal/tenant/app/desktop_account_service.go @@ -557,26 +557,19 @@ func (s *DesktopAccountService) buildDesktopAccountView( var clientLastSeenAt *time.Time var clientDeviceName *string - resolvedClientID := account.ClientID - if presenceClientMap != nil { - if activeClientID, ok := presenceClientMap[account.DesktopID]; ok { - resolvedClientID = &activeClientID - } - } - - if resolvedClientID != nil { - value := resolvedClientID.String() + if account.ClientID != nil { + value := account.ClientID.String() clientID = &value online := false clientOnline = &online if clientMap != nil { - if client, ok := clientMap[*resolvedClientID]; ok && client != nil { + if client, ok := clientMap[*account.ClientID]; ok && client != nil { clientLastSeenAt = client.LastSeenAt clientDeviceName = client.DeviceName if presenceClientMap != nil { - if activeClientID, ok := presenceClientMap[account.DesktopID]; ok && activeClientID == *resolvedClientID { + if activeClientID, ok := presenceClientMap[account.DesktopID]; ok && activeClientID == *account.ClientID { online = true } } diff --git a/server/internal/tenant/app/desktop_account_service_test.go b/server/internal/tenant/app/desktop_account_service_test.go index 68c1133..8e13b31 100644 --- a/server/internal/tenant/app/desktop_account_service_test.go +++ b/server/internal/tenant/app/desktop_account_service_test.go @@ -45,7 +45,7 @@ func TestResolveDesktopClientOnline_StaleHeartbeatIsOffline(t *testing.T) { assert.False(t, online) } -func TestBuildDesktopAccountView_PrefersAccountPresenceClient(t *testing.T) { +func TestBuildDesktopAccountView_StoredClientOwnershipWinsOverPresence(t *testing.T) { service := &DesktopAccountService{} accountID := uuid.New() staleClientID := uuid.New() @@ -75,14 +75,14 @@ func TestBuildDesktopAccountView_PrefersAccountPresenceClient(t *testing.T) { ) if assert.NotNil(t, view.ClientID) { - assert.Equal(t, activeClientID.String(), *view.ClientID) + assert.Equal(t, staleClientID.String(), *view.ClientID) } if assert.NotNil(t, view.ClientOnline) { - assert.True(t, *view.ClientOnline) + assert.False(t, *view.ClientOnline) } assert.Equal(t, "4181862206", view.PlatformUID) - assert.Equal(t, &lastSeen, view.ClientLastSeenAt) - assert.Equal(t, &deviceName, view.ClientDeviceName) + assert.Nil(t, view.ClientLastSeenAt) + assert.Nil(t, view.ClientDeviceName) } func TestBuildDesktopAccountView_FallsBackToStoredClientWhenNoPresence(t *testing.T) {