跨端: fix(客户端) 换身份必须清全部账号数据 + 鸿蒙管理台门禁改三态

两份审查报告(`docs/reviews/electron-gui-review.md` /
`harmony-client-review.md`)里两条**数据隔离**缺陷。

## ① 换身份不清数据 ⇒ 在新账号的界面下显示旧账号的邮件

`setActive` 之后 `api/config` 单例里的 API_BASE 与 bearer 就翻到了新账号,
于是此后每个请求都带**新账号**的凭证。而各 store 里还留着**旧**账号的:
  · mailStore.sent / currentMail
  · sessionStore.sessions / currentSession / currentSessionMails
  · contactStore.contacts / archivedContacts

⇒ 肉眼完全看不出来(不报错、不空屏),而**从旧视图发出的写操作**
(归档 / 转发 / 批准权限)改的是**新账号**。

新增 `src/lib/resetAccountData.ts` —— **一处实现,三个入口都调它**:

    ① 主动切账号(AccountSwitcher.pick)
    ② 登出(authStore.logout)
    ③ 任意接口 401(api/client.ts 的 unauthorized 回调 → markAnonymous)

只在 ① 里清是最容易漏的那种做法:② 和 ③ 各自还会重新泄露一次,
而它们都不在切换账号的代码路径上,grep 也找不到。
**身份变化有三条路径,清空也该有三条。**

★ 只碰**数据** store;`uiStore` 的 reset 仍由 App.tsx 负责
  (它还要复位窄屏分栏、写信态那些纯界面状态)。

判据:`test/stores/resetAccountData.test.ts`(4 格)。

## ② 鸿蒙管理台门禁**失败开放**(fail-open)

`AdminUsersPage.ets` 原来的条件是 `roleKnown && !this.isAdmin`,
于是 `roleKnown === false`(loadRole() 失败、**身份还没读到**)
落进 else 分支 ⇒ **把完整管理台整个渲染出来**。
一次网络抖动 = 管理入口对所有人可见。

讽刺的是该文件自己的头注释写的就是正确规则
(「不能把读不到当成是管理员」)—— 代码做的正是这条注释禁止的事。

⇒ 改三态:`!roleKnown` 显示「正在确认身份…」、`!isAdmin` 显示墙、
  否则管理台。

服务端 `middleware/user.go` 的 `AdminOnly` 仍在,所以**不是越权**;
但非管理员会看到完整用户列表、建号表单、改密入口 ——
属于客户端信息泄露 + 无意义的失败请求风暴。
This commit is contained in:
2026-09-28 08:27:01 +08:00
parent 044a664cc3
commit 18b148e476
28 changed files with 690 additions and 64 deletions

View File

@ -8,7 +8,7 @@ import {
uploadAppearanceImage,
type AppearanceAuth
} from '../lib/appearance';
import { useAccountStore } from './accountStore';
import { useAccountStore, isAggregate } from './accountStore';
import { useBackgroundStore } from './backgroundStore';
import { useThemeStore } from './themeStore';
@ -36,8 +36,27 @@ interface AppearanceSyncState {
push: () => Promise<void>;
}
/** 上一次成功上传的壁纸(data URL)。相同就不重复上传几 MB。 */
let lastUploadedImage = '';
/**
* 上一次成功上传的壁纸(data URL),**按账号分桶**。
*
* ★ 为什么必须带账号维度:这是本模块唯一没做账号隔离的状态,而它恰好是最不能
* 漏的那一个。A 账号设壁纸 X(上传成功 ⇒ 记下 "A"→X)→ 切到 B 账号,而 B 的
* 服务端记录是 `saved:false` ⇒ `pull()` 走"把本地这份推上去"的分支 ⇒ 但
* `push()` 的跳过判据被 **A** 留下的标记满足 ⇒ **B 的壁纸永远传不上去**,
* 而状态仍被置为 `'synced'`。现象是"B 的壁纸在其它所有设备上都缺,
* 界面上却显示已同步"。
*
* 本来这个文件把 localStorage 键按账号隔离了(见 `lib/appearance.ts`),
* 漏的只有这个内存变量。用 `Map` 而不是单串,是因为它的生命周期就等于
* 一次运行期,而账号数很小,不需要额外持久化。
*/
const lastUploadedImage = new Map<string, string>();
/** 当前身份的稳定键。聚合视图下用固定值(聚合本来就不该单独上传)。 */
function currentAccountKey(): string {
const s = useAccountStore.getState();
return isAggregate(s) ? '__aggregate__' : s.activeId;
}
let pushTimer: ReturnType<typeof setTimeout> | null = null;
function currentAuth(): AppearanceAuth | null {
@ -84,7 +103,7 @@ export const useAppearanceSync = create<AppearanceSyncState>((set) => ({
if (remote.snapshot.bgKind === 'preset') bg.setPreset(remote.snapshot.bgPresetId);
else if (remote.snapshot.bgKind === 'none') bg.setKind('none');
else if (remote.snapshot.bgKind === 'image' && remote.imageDataUrl) bg.setImage(remote.imageDataUrl);
if (remote.imageDataUrl) lastUploadedImage = remote.imageDataUrl;
if (remote.imageDataUrl) lastUploadedImage.set(currentAccountKey(), remote.imageDataUrl);
bg.setDim(remote.snapshot.bgDim);
bg.setBlur(remote.snapshot.bgBlur);
} finally {
@ -101,6 +120,7 @@ export const useAppearanceSync = create<AppearanceSyncState>((set) => ({
}
const theme = useThemeStore.getState().pref;
const bg = useBackgroundStore.getState();
const accountKey = currentAccountKey();
const payload = payloadFromLocal({
theme,
kind: bg.kind,
@ -112,9 +132,9 @@ export const useAppearanceSync = create<AppearanceSyncState>((set) => ({
let ok = await pushAppearance(auth, payload);
// 壁纸本体单独传:几 MB 的图不该每次都跟着 PUT 走,只在换图时传一次。
if (ok && bg.kind === 'image' && bg.imageDataUrl && bg.imageDataUrl !== lastUploadedImage) {
if (ok && bg.kind === 'image' && bg.imageDataUrl && bg.imageDataUrl !== lastUploadedImage.get(accountKey)) {
const uploaded = await uploadAppearanceImage(auth, bg.imageDataUrl);
if (uploaded) lastUploadedImage = bg.imageDataUrl;
if (uploaded) lastUploadedImage.set(accountKey, bg.imageDataUrl);
else ok = false;
}
set(ok ? { status: 'synced', lastSyncedAt: Date.now() } : { status: 'pending' });

View File

@ -2,6 +2,7 @@ import { create } from 'zustand';
import type { User } from '../types';
import * as api from '../api/client';
import { ApiError } from '../api/client';
import { resetAccountData } from '../lib/resetAccountData';
type Phase = 'checking' | 'anonymous' | 'authenticated';
@ -77,6 +78,12 @@ export const useAuthStore = create<AuthState>(set => ({
} catch {
/* 即使请求失败也在前端登出 */
}
/*
* ★ 登出也必须清账号数据:不清的话,下一个登录的人会先看到上一个人的
* 邮件、会话与联系人(`App.tsx` 的 `resetUI()` 只复位界面状态,不碰数据)。
* 走的是与切账号同一个 `resetAccountData` —— 身份变化的每条路径都要清。
*/
resetAccountData();
set({ phase: 'anonymous', user: null, error: null });
},
@ -102,7 +109,12 @@ export const useAuthStore = create<AuthState>(set => ({
clearError: () => set({ error: null, retryAfter: null }),
markAnonymous: () => set({ phase: 'anonymous', user: null })
markAnonymous: () => {
// 401 掉登录也是一次"身份没了",同样要清数据 —— 否则令牌失效后再登录
// 另一个账号,上一个账号的邮件/会话还会留在界面上。
resetAccountData();
set({ phase: 'anonymous', user: null });
}
}));
// 注册全局 401 处理:任何接口返回 401 即回到登录页

View File

@ -41,6 +41,13 @@ interface ContactState {
archive: (contact: Contact) => Promise<void>;
/** SSE session_archived 到达时本地即时移除 */
removeSessionLocally: (sessionId: string) => void;
/**
* 换身份(切账号 / 登出 / 401)时清空联系人数据。
*
* ★ 与 `mailStore.resetAll` 同一件事的另一半:`contacts` / `archivedContacts`
* 是**旧**账号的会话清单,而 `archive` 走的是新账号的凭证。
*/
resetAll: () => void;
}
export const useContactStore = create<ContactState>((set, get) => ({
@ -109,5 +116,14 @@ export const useContactStore = create<ContactState>((set, get) => ({
removeSessionLocally: sessionId =>
set(state => ({
contacts: state.contacts.filter(c => c.session_id !== sessionId)
}))
})),
resetAll: () =>
set({
contacts: [],
archivedContacts: [],
pendingArchive: null,
loading: false,
error: null
})
}));

View File

@ -33,6 +33,8 @@ interface MailState {
markRead: (id: string) => Promise<void>;
/** 某会话归档后,把它的邮件从列表与选中态里剔除 */
dropSession: (sessionId: string) => void;
/** 换身份(切账号 / 登出 / 401)时清空全部账号相关数据 */
resetAll: () => void;
}
export const useMailStore = create<MailState>((set, get) => ({
@ -124,6 +126,19 @@ export const useMailStore = create<MailState>((set, get) => ({
},
clearCurrentMail: () => set({ currentMail: null }),
/**
* 换身份(切账号 / 登出 / 401)时清空全部账号相关数据。
*
* ★ `setActive` 会翻 `api/config` 单例里的 API_BASE 与 bearer,此后 `getSent()` /
* `getMail()` / `archiveContact()` 全带**新**账号的凭证 —— 而界面上还留着**旧**账号
* 的数据,于是"从陈旧视图点归档/转发"改的是新账号。这是跨账号数据泄露,
* 而且不报错、不空屏,肉眼完全看不出来。
*
* ★ 为什么放在 store 上而不是由调用方各自清:同一件事有三个入口
* (切换 / 登出 / 401),漏一个就重新泄露一次。一处实现、三个入口都调它。
*/
resetAll: () =>
set({ inbox: [], sent: [], currentMail: null, accountErrors: [], error: null, loading: false }),
markRead: async id => {
try {

View File

@ -33,6 +33,14 @@ interface SessionState {
refreshBudget: () => Promise<void>;
/** 归档后若正查看该会话则退出 */
dropSessionIfCurrent: (sessionId: string) => void;
/**
* 换身份(切账号 / 登出 / 401)时清空全部会话数据。
*
* ★ 与 `mailStore.resetAll` 同一件事的另一半:`currentSession` + `currentSessionMails`
* 带着**旧**账号的会话全文留在内存里,而此后所有请求都带**新**账号的凭证
* (`getSessionDetail` / `archiveContact`)。不清就是跨账号泄露。
*/
resetAll: () => void;
}
export const useSessionStore = create<SessionState>((set, get) => ({
@ -164,5 +172,16 @@ export const useSessionStore = create<SessionState>((set, get) => ({
set(state => ({
sessions: state.sessions.filter(s => s.session_id !== sessionId)
}));
}
},
resetAll: () =>
set({
sessions: [],
currentSession: null,
currentSessionMails: [],
renameProposal: null,
budget: null,
loading: false,
error: null
})
}));