Răsfoiți Sursa

fix(admin): preserve sessions on ambiguous auth failures and stale tab events

彭峰 1 zi în urmă
părinte
comite
3a19e40081

+ 24 - 0
docs/operations/admin-session-stability-2026-10-16.md

@@ -0,0 +1,24 @@
+# 管理后台操作中自动退出:第二轮修复(2026-10-16)
+
+用户再次确认:正在操作时突然回到登录页。未取得该次失败请求的网络记录,因此以下是已复现的代码缺陷,不将其直接等同于用户当次退出的唯一原因。
+
+## 现场与复现
+
+- 正式站 `https://admin.xiaoshu.pro` 版本为 `3bf81740cc1ceca68b74da52bb8ba7baddc61fa7`。实际下载的 JavaScript 包含上次添加的会话复核,同时仍包含普通 HTTP 401 清除登录、直接使用 storage 事件旧值的逻辑。
+- 使用现有配置的运营会话只读请求 Parse `/sessions/me`,HTTP 200;创建时间 2026-09-09,到期时间 2027-09-09。只证明这条抽查会话有效,不代表受影响用户会话均有效。
+- 新增回归用例,在修改实现前运行:17 项中 5 项失败。复现了普通 401 误退出、非用户结构的成功响应误退出、旧请求清除另一标签页的新登录、排队的旧退出事件覆盖新登录,以及迟到的身份响应将旧登录写回共享存储。
+
+## 修复
+
+- Parse 复核只有 HTTP 400/401 且明确返回错误码 209 才判定 token 失效。单独的 HTTP 401、网络故障、异常响应结构保留登录,业务请求仍返回错误且不会自动重复写操作。
+- 读取到真实用户对象后仍核对账号一致性、停用和删除状态;有效的安全退出行为保留。
+- storage 通知到达时读取当前共享状态,避免重放排队中的旧登录/退出值;检查失败和清除会话之前同样核对共享状态。
+- 身份信息落盘前同步共享登录,避免迟到响应覆盖其他标签页的新登录。
+- 本地持久存储写入失败时保持标签页内会话可用,不因共享存储为空强迫退出。
+
+## 验证
+
+- ChromeHeadless 定向回归 36 项通过:会话服务、云请求、登录页、路由缓存、实时刷新。
+- `npm run build:admin` 通过;仅已有样式预算和 CommonJS 警告。
+- `git diff --check` 通过。
+- 发布结果另行追加;本节不将本地验证等同于正式部署,也不声称已完成受影响用户环境的持续使用验收。

+ 88 - 0
projects/xiaoshu-admin/src/app/admin-session.service.spec.ts

@@ -121,4 +121,92 @@ describe('AdminSessionService', () => {
     expect(service.session()?.objectId).toBe('new-admin');expect(service.session()?.isSuperAdmin).toBeUndefined();
     expect(service.session()?.objectId).toBe('new-admin');expect(service.session()?.isSuperAdmin).toBeUndefined();
   });
   });
 
 
+  it('keeps the login when the verification endpoint returns an unclassified 401', async () => {
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'valid' }));
+    const service = TestBed.inject(AdminSessionService);
+    const pending = service.verifySessionAfterFailure('valid');
+    http.expectOne(`${ADMIN_PARSE_URL}/users/me`).flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
+    await pending;
+    expect(service.isAuthenticated()).toBeTrue();
+    expect(localStorage.getItem(ADMIN_SESSION_KEY)).not.toBeNull();
+    expect(router.navigate).not.toHaveBeenCalled();
+  });
+
+  it('keeps the login when verification returns an unexpected success body', async () => {
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'valid' }));
+    const service = TestBed.inject(AdminSessionService);
+    const pending = service.verifySessionAfterFailure('valid');
+    http.expectOne(`${ADMIN_PARSE_URL}/users/me`).flush({ message: 'upstream unavailable' });
+    await pending;
+    expect(service.isAuthenticated()).toBeTrue();
+    expect(router.navigate).not.toHaveBeenCalled();
+  });
+
+  it('does not clear a newer shared login before its storage event arrives', async () => {
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'old' }));
+    const service = TestBed.inject(AdminSessionService);
+    const pending = service.verifySessionAfterFailure('old');
+    // Another tab logs in while this request is in flight, before storage events are delivered.
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'new' }));
+    http.expectOne(`${ADMIN_PARSE_URL}/users/me`).flush({ code: 209, error: 'invalid session token' }, { status: 400, statusText: 'Bad Request' });
+    await pending;
+    expect(service.session()?.sessionToken).toBe('new');
+    expect(JSON.parse(localStorage.getItem(ADMIN_SESSION_KEY)!).sessionToken).toBe('new');
+    expect(JSON.parse(sessionStorage.getItem(ADMIN_SESSION_KEY)!).sessionToken).toBe('new');
+    expect(router.navigate).not.toHaveBeenCalled();
+  });
+
+  it('reads the latest shared login instead of replaying a queued logout event', () => {
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'old' }));
+    const service = TestBed.inject(AdminSessionService);
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'new' }));
+    window.dispatchEvent(new StorageEvent('storage', { key: ADMIN_SESSION_KEY, newValue: null, storageArea: localStorage }));
+    expect(service.session()?.sessionToken).toBe('new');
+    expect(router.navigate).not.toHaveBeenCalled();
+  });
+
+  it('does not overwrite another tabs login with a late identity response', () => {
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'old' }));
+    const service = TestBed.inject(AdminSessionService);
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-2', sessionToken: 'new' }));
+    service.applyIdentity({ objectId: 'admin-1', username: 'old', displayName: 'old', roles: ['super-admin'], isSuperAdmin: true, operationsRole: 'ops-manager' });
+    expect(service.session()?.objectId).toBe('admin-2');
+    expect(JSON.parse(localStorage.getItem(ADMIN_SESSION_KEY)!).sessionToken).toBe('new');
+  });
+
+  it('still rejects a verified account mismatch', async () => {
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'invalid' }));
+    const service = TestBed.inject(AdminSessionService);
+    const pending = service.verifySessionAfterFailure('invalid');
+    http.expectOne(`${ADMIN_PARSE_URL}/users/me`).flush({ objectId: 'different-admin' });
+    await pending;
+    expect(service.isAuthenticated()).toBeFalse();
+    expect(router.navigate).toHaveBeenCalled();
+  });
+
+  it('preserves a tab login when persistent writes are unavailable', async () => {
+    const originalSetItem = Storage.prototype.setItem;
+    spyOn(Storage.prototype, 'setItem').and.callFake(function(this: Storage, key: string, value: string) {
+      if (this === localStorage) throw new DOMException('Storage full', 'QuotaExceededError');
+      originalSetItem.call(this, key, value);
+    });
+    sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'valid' }));
+    const service = TestBed.inject(AdminSessionService);
+    const pending = service.verifySessionAfterFailure('valid');
+    http.expectOne(`${ADMIN_PARSE_URL}/users/me`).flush({ objectId: 'admin-1' });
+    await pending;
+    expect(service.session()?.sessionToken).toBe('valid');
+    expect(router.navigate).not.toHaveBeenCalled();
+  });
+
+  it('still rejects a verified disabled account', async () => {
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', sessionToken: 'disabled' }));
+    const service = TestBed.inject(AdminSessionService);
+    const pending = service.verifySessionAfterFailure('disabled');
+    http.expectOne(`${ADMIN_PARSE_URL}/users/me`).flush({ objectId: 'admin-1', isDisabled: true });
+    await pending;
+    expect(service.isAuthenticated()).toBeFalse();
+    expect(router.navigate).toHaveBeenCalledTimes(1);
+  });
+
 });
 });

+ 29 - 12
projects/xiaoshu-admin/src/app/admin-session.service.ts

@@ -10,17 +10,14 @@ export class AdminSessionService {
   private readonly http = inject(HttpClient);
   private readonly http = inject(HttpClient);
   private readonly router = inject(Router);
   private readonly router = inject(Router);
   private readonly sessionChecks = new Map<string, Promise<void>>();
   private readonly sessionChecks = new Map<string, Promise<void>>();
+  private sharedStorageWritable = false;
   private readonly state = signal<AdminSession | null>(this.restore());
   private readonly state = signal<AdminSession | null>(this.restore());
 
 
   constructor() {
   constructor() {
     const onStorage = (event: StorageEvent) => {
     const onStorage = (event: StorageEvent) => {
-      if (event.storageArea !== localStorage || event.key !== ADMIN_SESSION_KEY) return;
-      const previous = this.state();
-      const next = this.decode(event.newValue);
-      this.state.set(next);
-      this.writeTabSession(next);
-      if (previous && !next) this.redirectToLogin();
-      else if (previous && next && previous.objectId !== next.objectId) void this.router.navigate(['/admin/dashboard']);
+      if (event.storageArea !== localStorage || (event.key !== ADMIN_SESSION_KEY && event.key !== null)) return;
+      // Storage events can be queued while another tab has already logged in again.
+      this.syncSharedSession();
     };
     };
     window.addEventListener('storage', onStorage);
     window.addEventListener('storage', onStorage);
     inject(DestroyRef).onDestroy(() => window.removeEventListener('storage', onStorage));
     inject(DestroyRef).onDestroy(() => window.removeEventListener('storage', onStorage));
@@ -59,8 +56,10 @@ export class AdminSessionService {
   }
   }
 
 
   applyIdentity(identity: AdminIdentity): void {
   applyIdentity(identity: AdminIdentity): void {
+    const previousToken = this.state()?.sessionToken;
+    this.syncSharedSession();
     const current = this.state();
     const current = this.state();
-    if (!current || current.objectId !== identity.objectId) return;
+    if (!current || current.sessionToken !== previousToken || current.objectId !== identity.objectId) return;
     const session: AdminSession = {
     const session: AdminSession = {
       ...current,
       ...current,
       displayName: identity.displayName,
       displayName: identity.displayName,
@@ -91,6 +90,7 @@ export class AdminSessionService {
 
 
   /** Confirm with Parse before turning one gateway failure into a global logout. */
   /** Confirm with Parse before turning one gateway failure into a global logout. */
   verifySessionAfterFailure(token: string): Promise<void> {
   verifySessionAfterFailure(token: string): Promise<void> {
+    this.syncSharedSession();
     if (this.state()?.sessionToken !== token) return Promise.resolve();
     if (this.state()?.sessionToken !== token) return Promise.resolve();
     const pending = this.sessionChecks.get(token);
     const pending = this.sessionChecks.get(token);
     if (pending) return pending;
     if (pending) return pending;
@@ -104,15 +104,19 @@ export class AdminSessionService {
       const user = await firstValueFrom(this.http.get<{ objectId: string; isDisabled?: boolean; isDeleted?: boolean }>(
       const user = await firstValueFrom(this.http.get<{ objectId: string; isDisabled?: boolean; isDeleted?: boolean }>(
         `${ADMIN_PARSE_URL}/users/me`, { headers: { 'X-Parse-Application-Id': PARSE_APP_ID, 'X-Parse-Session-Token': token } },
         `${ADMIN_PARSE_URL}/users/me`, { headers: { 'X-Parse-Application-Id': PARSE_APP_ID, 'X-Parse-Session-Token': token } },
       ).pipe(timeout(10000)));
       ).pipe(timeout(10000)));
-      if (this.state()?.sessionToken === token && (user.objectId !== this.state()?.objectId || user.isDisabled || user.isDeleted)) this.invalidateSession(token);
+      // A proxy may return an unexpected success body; only a real user is evidence.
+      if (typeof user?.objectId === 'string' && user.objectId && this.state()?.sessionToken === token &&
+        (user.objectId !== this.state()?.objectId || user.isDisabled === true || user.isDeleted === true)) this.invalidateSession(token);
     } catch (error) {
     } catch (error) {
-      if (error instanceof HttpErrorResponse && (error.status === 401 || Number(error.error?.code) === 209)) this.invalidateSession(token);
-      // Network errors and gateway outages are not evidence of an expired session.
+      if (error instanceof HttpErrorResponse && [400, 401].includes(error.status) && Number(error.error?.code) === 209) this.invalidateSession(token);
+      // An HTTP 401 alone can come from a proxy, not Parse's session validator.
     }
     }
   }
   }
 
 
   /** A late response for an older login must never clear a newer session. */
   /** A late response for an older login must never clear a newer session. */
   invalidateSession(requestToken: string): void {
   invalidateSession(requestToken: string): void {
+    // A newer login may already be stored even though its storage event has not run.
+    this.syncSharedSession();
     if (!requestToken || this.state()?.sessionToken !== requestToken) return;
     if (!requestToken || this.state()?.sessionToken !== requestToken) return;
     this.persist(null);
     this.persist(null);
     this.state.set(null);
     this.state.set(null);
@@ -133,6 +137,18 @@ export class AdminSessionService {
     } catch { return null; }
     } catch { return null; }
   }
   }
 
 
+  private syncSharedSession(): void {
+    if (!this.sharedStorageWritable) return;
+    let next: AdminSession | null;
+    try { next = this.decode(localStorage.getItem(ADMIN_SESSION_KEY)); }
+    catch { return; } // Keep the in-memory session when persistent storage is restricted.
+    const previous = this.state();
+    this.state.set(next);
+    this.writeTabSession(next);
+    if (previous && !next) this.redirectToLogin();
+    else if (previous && next && previous.objectId !== next.objectId) void this.router.navigate(['/admin/dashboard']);
+  }
+
   private writeTabSession(session: AdminSession | null): void {
   private writeTabSession(session: AdminSession | null): void {
     try {
     try {
       if (session) sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
       if (session) sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
@@ -144,7 +160,8 @@ export class AdminSessionService {
     try {
     try {
       if (session) localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
       if (session) localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
       else localStorage.removeItem(ADMIN_SESSION_KEY);
       else localStorage.removeItem(ADMIN_SESSION_KEY);
-    } catch { /* Fall back to the current tab when persistent storage is unavailable. */ }
+      this.sharedStorageWritable = true;
+    } catch { this.sharedStorageWritable = false; /* Fall back to the current tab. */ }
     this.writeTabSession(session);
     this.writeTabSession(session);
   }
   }