fix(desktop): scope account access to owning client and extend identity verification
Backend CI / Backend (push) Successful in 14m18s
Backend CI / Backend (push) Successful in 14m18s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -222,6 +222,8 @@ type BilibiliSpaceInfoResponse = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type JuejinProfileResponse = {
|
type JuejinProfileResponse = {
|
||||||
|
err_no?: number
|
||||||
|
err_msg?: string
|
||||||
data?: {
|
data?: {
|
||||||
bui_user?: {
|
bui_user?: {
|
||||||
user_id?: string
|
user_id?: string
|
||||||
@@ -2540,6 +2542,16 @@ export async function silentRefreshPublishAccountSession(
|
|||||||
return await verifyZolConsoleAccess(window, handle.session, account)
|
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)) {
|
if (platformRequiresDetectedAccountForConsoleAccess(account.platform)) {
|
||||||
const detected = await definition
|
const detected = await definition
|
||||||
.detect({
|
.detect({
|
||||||
@@ -2860,7 +2872,14 @@ function platformRequiresDetectedAccountForConsoleAccess(platformId: string): bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function platformRequiresDetectedAccountIdentityMatch(platformId: string): boolean {
|
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(
|
async function verifyZolConsoleAccess(
|
||||||
@@ -2980,6 +2999,16 @@ async function verifyPublishAccountConsoleAccess(
|
|||||||
return await verifyZolConsoleAccess(window, handle.session, account)
|
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)) {
|
if (platformRequiresDetectedAccountForConsoleAccess(account.platform)) {
|
||||||
const detected = await definition
|
const detected = await definition
|
||||||
.detect({
|
.detect({
|
||||||
@@ -3207,6 +3236,10 @@ async function detectJuejin({ session }: DetectContext): Promise<DetectedAccount
|
|||||||
},
|
},
|
||||||
).catch(() => null)
|
).catch(() => null)
|
||||||
|
|
||||||
|
if (!response || (typeof response.err_no === 'number' && response.err_no !== 0)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const user = response?.data?.bui_user
|
const user = response?.data?.bui_user
|
||||||
if (!user?.user_id || !user.screen_name) {
|
if (!user?.user_id || !user.screen_name) {
|
||||||
return null
|
return null
|
||||||
@@ -3467,7 +3500,7 @@ async function detectSmzdm({
|
|||||||
return fromCookieRequest
|
return fromCookieRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
return await deriveSmzdmCookieAccount(session, pageState)
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectWeixinGzh({ session }: DetectContext): Promise<DetectedAccount | null> {
|
async function detectWeixinGzh({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||||||
@@ -4012,7 +4045,10 @@ export async function openPublishAccountConsole(account: PublishAccountIdentity)
|
|||||||
throw new Error(`desktop_platform_not_supported:${account.platform}`)
|
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)
|
const consoleURL = await resolvePublishAccountConsoleURL(definition, handle.session)
|
||||||
if (platformRequiresDetectedAccountForConsoleAccess(account.platform)) {
|
if (platformRequiresDetectedAccountForConsoleAccess(account.platform)) {
|
||||||
const detected = await definition
|
const detected = await definition
|
||||||
|
|||||||
@@ -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 {
|
function nextInitialProbeAt(now = Date.now()): number {
|
||||||
return now
|
return now
|
||||||
}
|
}
|
||||||
@@ -375,7 +379,19 @@ async function performProbeLocked(
|
|||||||
record.probeState = 'probing'
|
record.probeState = 'probing'
|
||||||
commitRecord(record, previousSignature)
|
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) {
|
if (options.allowSilentRefresh) {
|
||||||
const refreshed = await adapter.silentRefresh(account, partition).catch(() => false)
|
const refreshed = await adapter.silentRefresh(account, partition).catch(() => false)
|
||||||
@@ -748,6 +764,37 @@ export function markTrackedAccountBound(
|
|||||||
return snapshot
|
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(
|
export function reconcileTrackedAccountRemoteState(
|
||||||
account: PublishAccountIdentity,
|
account: PublishAccountIdentity,
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -214,6 +214,9 @@ async function fetchCurrentAccount(context: PublishAdapterContext): Promise<Smzd
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
|
||||||
|
/*
|
||||||
const cookieUid = normalizeSmzdmUid(
|
const cookieUid = normalizeSmzdmUid(
|
||||||
await sessionCookieValue(context.session, 'smzdm.com', 'smzdm_id').catch(() => ''),
|
await sessionCookieValue(context.session, 'smzdm.com', 'smzdm_id').catch(() => ''),
|
||||||
)
|
)
|
||||||
@@ -229,6 +232,7 @@ async function fetchCurrentAccount(context: PublishAdapterContext): Promise<Smzd
|
|||||||
uid: cookieUid,
|
uid: cookieUid,
|
||||||
name: cookieName || `值友${cookieUid}`,
|
name: cookieName || `值友${cookieUid}`,
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractSmzdmArticleIdFromHref(value: string | null | undefined): string {
|
export function extractSmzdmArticleIdFromHref(value: string | null | undefined): string {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
forgetTrackedAccountHealth,
|
forgetTrackedAccountHealth,
|
||||||
getAccountHealthSnapshot,
|
getAccountHealthSnapshot,
|
||||||
getProjectedAccountHealth,
|
getProjectedAccountHealth,
|
||||||
|
markTrackedAccountMissingPartition,
|
||||||
markTrackedAccountBound,
|
markTrackedAccountBound,
|
||||||
probeTrackedAccount,
|
probeTrackedAccount,
|
||||||
reconcileTrackedAccountRemoteState,
|
reconcileTrackedAccountRemoteState,
|
||||||
@@ -319,8 +320,24 @@ function hasLocalAccountSession(accountId: string): boolean {
|
|||||||
return Boolean(getSessionHandle(accountId) || getPersistedPartition(accountId))
|
return Boolean(getSessionHandle(accountId) || getPersistedPartition(accountId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAccountOwnedByCurrentClient(account: DesktopAccountInfo): boolean {
|
||||||
|
return Boolean(account.client_id && account.client_id === state.client?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLocalAccountAuthorization(account: DesktopAccountInfo): boolean {
|
||||||
|
return isAccountOwnedByCurrentClient(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountRequiresLocalAuthorization(account: DesktopAccountInfo): boolean {
|
||||||
|
return Boolean(account.client_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountRequiresStrictProbe(account: DesktopAccountInfo): boolean {
|
||||||
|
return ['bilibili', 'juejin', 'smzdm'].includes(account.platform)
|
||||||
|
}
|
||||||
|
|
||||||
function shouldReportAccountPresence(account: DesktopAccountInfo): boolean {
|
function shouldReportAccountPresence(account: DesktopAccountInfo): boolean {
|
||||||
return hasLocalAccountSession(account.id) || account.client_id === state.client?.id
|
return hasLocalAccountSession(account.id) && isAccountOwnedByCurrentClient(account)
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimePlatformLabel(platform: string): string {
|
function runtimePlatformLabel(platform: string): string {
|
||||||
@@ -955,7 +972,9 @@ async function syncAccounts(source: 'startup' | 'manual'): Promise<void> {
|
|||||||
noteSchedulerAccountsSync()
|
noteSchedulerAccountsSync()
|
||||||
setSchedulerError(null)
|
setSchedulerError(null)
|
||||||
|
|
||||||
const identities = accounts.map((account) => accountIdentityFromDesktopAccount(account))
|
const identities = accounts
|
||||||
|
.filter((account) => hasLocalAccountAuthorization(account))
|
||||||
|
.map((account) => accountIdentityFromDesktopAccount(account))
|
||||||
syncTrackedAccounts(identities)
|
syncTrackedAccounts(identities)
|
||||||
state.accountRemoteHealth = new Map(accounts.map((account) => [account.id, account.health]))
|
state.accountRemoteHealth = new Map(accounts.map((account) => [account.id, account.health]))
|
||||||
|
|
||||||
@@ -968,15 +987,19 @@ async function syncAccounts(source: 'startup' | 'manual'): Promise<void> {
|
|||||||
...account,
|
...account,
|
||||||
platform_uid: normalizedPlatformUid,
|
platform_uid: normalizedPlatformUid,
|
||||||
})
|
})
|
||||||
reconcileTrackedAccountRemoteState(accountIdentity, {
|
if (hasLocalAccountAuthorization(account) && !accountRequiresStrictProbe(account)) {
|
||||||
health: account.health,
|
reconcileTrackedAccountRemoteState(accountIdentity, {
|
||||||
verifiedAt: account.verified_at,
|
health: account.health,
|
||||||
profile: {
|
verifiedAt: account.verified_at,
|
||||||
subjectUid: normalizedPlatformUid,
|
profile: {
|
||||||
displayName: account.display_name,
|
subjectUid: normalizedPlatformUid,
|
||||||
avatarUrl: account.avatar_url,
|
displayName: account.display_name,
|
||||||
},
|
avatarUrl: account.avatar_url,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
} else if (accountRequiresLocalAuthorization(account)) {
|
||||||
|
markTrackedAccountMissingPartition(accountIdentity)
|
||||||
|
}
|
||||||
const projected = getProjectedAccountHealth({
|
const projected = getProjectedAccountHealth({
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
platform: account.platform,
|
platform: account.platform,
|
||||||
@@ -1140,6 +1163,9 @@ function enqueueAccountHealthReport(accountId: string): void {
|
|||||||
if (!account) {
|
if (!account) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!hasLocalAccountAuthorization(account)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const report = buildAccountHealthReport(account)
|
const report = buildAccountHealthReport(account)
|
||||||
if (!shouldQueueAccountHealthReport(account, report)) {
|
if (!shouldQueueAccountHealthReport(account, report)) {
|
||||||
@@ -1270,6 +1296,10 @@ export async function requestRuntimeAccountProbe(
|
|||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('runtime_account_not_found')
|
throw new Error('runtime_account_not_found')
|
||||||
}
|
}
|
||||||
|
if (existingAccount && !hasLocalAccountAuthorization(existingAccount)) {
|
||||||
|
markTrackedAccountMissingPartition(account)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await probeTrackedAccount(account, {
|
await probeTrackedAccount(account, {
|
||||||
@@ -1318,7 +1348,11 @@ export function noteRuntimeAccountBound(account: DesktopAccountInfo): void {
|
|||||||
avatarUrl: normalizedAccount.avatar_url,
|
avatarUrl: normalizedAccount.avatar_url,
|
||||||
})
|
})
|
||||||
state.lastAccountsSyncAt = Date.now()
|
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), {
|
markTrackedAccountBound(accountIdentityFromDesktopAccount(normalizedAccount), {
|
||||||
subjectUid: normalizedAccount.platform_uid,
|
subjectUid: normalizedAccount.platform_uid,
|
||||||
displayName: normalizedAccount.display_name,
|
displayName: normalizedAccount.display_name,
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ function createRuntimeAccountView(
|
|||||||
health: account.health,
|
health: account.health,
|
||||||
verifiedAt: account.verified_at,
|
verifiedAt: account.verified_at,
|
||||||
})
|
})
|
||||||
|
const belongsToCurrentClient = Boolean(
|
||||||
|
account.client_id && account.client_id === context.primaryClientId,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: account.id,
|
id: account.id,
|
||||||
@@ -70,7 +73,7 @@ function createRuntimeAccountView(
|
|||||||
authReason: projected.authReason,
|
authReason: projected.authReason,
|
||||||
tags: account.tags,
|
tags: account.tags,
|
||||||
syncVersion: account.sync_version,
|
syncVersion: account.sync_version,
|
||||||
clientId: account.client_id ?? context.primaryClientId,
|
clientId: account.client_id ?? '',
|
||||||
partition: projected.partition || sessionHandle?.partition || `persist:acc-${account.id}`,
|
partition: projected.partition || sessionHandle?.partition || `persist:acc-${account.id}`,
|
||||||
sessionState: isHot ? 'hot' : sessionHandle ? 'warm' : 'cold',
|
sessionState: isHot ? 'hot' : sessionHandle ? 'warm' : 'cold',
|
||||||
lastSyncAt: context.lastAccountsSyncAt || parseTimestamp(account.verified_at) || context.now,
|
lastSyncAt: context.lastAccountsSyncAt || parseTimestamp(account.verified_at) || context.now,
|
||||||
@@ -78,7 +81,7 @@ function createRuntimeAccountView(
|
|||||||
lastProbeAt: projected.lastProbeAt,
|
lastProbeAt: projected.lastProbeAt,
|
||||||
nextProbeAt: projected.nextProbeAt,
|
nextProbeAt: projected.nextProbeAt,
|
||||||
queueDepth: accountQueueDepth,
|
queueDepth: accountQueueDepth,
|
||||||
online: context.clientOnline,
|
online: context.clientOnline && belongsToCurrentClient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -557,26 +557,19 @@ func (s *DesktopAccountService) buildDesktopAccountView(
|
|||||||
var clientLastSeenAt *time.Time
|
var clientLastSeenAt *time.Time
|
||||||
var clientDeviceName *string
|
var clientDeviceName *string
|
||||||
|
|
||||||
resolvedClientID := account.ClientID
|
if account.ClientID != nil {
|
||||||
if presenceClientMap != nil {
|
value := account.ClientID.String()
|
||||||
if activeClientID, ok := presenceClientMap[account.DesktopID]; ok {
|
|
||||||
resolvedClientID = &activeClientID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if resolvedClientID != nil {
|
|
||||||
value := resolvedClientID.String()
|
|
||||||
clientID = &value
|
clientID = &value
|
||||||
|
|
||||||
online := false
|
online := false
|
||||||
clientOnline = &online
|
clientOnline = &online
|
||||||
|
|
||||||
if clientMap != nil {
|
if clientMap != nil {
|
||||||
if client, ok := clientMap[*resolvedClientID]; ok && client != nil {
|
if client, ok := clientMap[*account.ClientID]; ok && client != nil {
|
||||||
clientLastSeenAt = client.LastSeenAt
|
clientLastSeenAt = client.LastSeenAt
|
||||||
clientDeviceName = client.DeviceName
|
clientDeviceName = client.DeviceName
|
||||||
if presenceClientMap != nil {
|
if presenceClientMap != nil {
|
||||||
if activeClientID, ok := presenceClientMap[account.DesktopID]; ok && activeClientID == *resolvedClientID {
|
if activeClientID, ok := presenceClientMap[account.DesktopID]; ok && activeClientID == *account.ClientID {
|
||||||
online = true
|
online = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func TestResolveDesktopClientOnline_StaleHeartbeatIsOffline(t *testing.T) {
|
|||||||
assert.False(t, online)
|
assert.False(t, online)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDesktopAccountView_PrefersAccountPresenceClient(t *testing.T) {
|
func TestBuildDesktopAccountView_StoredClientOwnershipWinsOverPresence(t *testing.T) {
|
||||||
service := &DesktopAccountService{}
|
service := &DesktopAccountService{}
|
||||||
accountID := uuid.New()
|
accountID := uuid.New()
|
||||||
staleClientID := uuid.New()
|
staleClientID := uuid.New()
|
||||||
@@ -75,14 +75,14 @@ func TestBuildDesktopAccountView_PrefersAccountPresenceClient(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if assert.NotNil(t, view.ClientID) {
|
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) {
|
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, "4181862206", view.PlatformUID)
|
||||||
assert.Equal(t, &lastSeen, view.ClientLastSeenAt)
|
assert.Nil(t, view.ClientLastSeenAt)
|
||||||
assert.Equal(t, &deviceName, view.ClientDeviceName)
|
assert.Nil(t, view.ClientDeviceName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDesktopAccountView_FallsBackToStoredClientWhenNoPresence(t *testing.T) {
|
func TestBuildDesktopAccountView_FallsBackToStoredClientWhenNoPresence(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user