Commit 6e42a683 by luoqi

fix(auth): code 由 TTL 控失效(去消费即删)+ 嵌入首开失效诊断/企微告警

- exchange-code 由 getDel(消费一次即删)改为 get:code 在 60s TTL 内可重复换、
  返同一票,到点自然失效 → 根治宿主重载/双开的「登录已过期」(旧逻辑消费即删
  + 20s 宽限,超窗重载即 MISS)。去掉 grace 副本。
- 新增 bootstrap 全分支诊断埋点(前端 client-diag 信标 → 服务端 [client-diag] 日志),
  带存储/环境探针 storageWritable/isEmbedded/hostOrigin/isReload/ua。
- 会话失效(no-credentials/refresh-fail)推企微监控群(复用 AlertService,不去重):
  四类根因自动判(存储分区/重载复用/旧码/全新码到手即死),含 code 足迹
  (最新那张/已消费过/code 龄);全码只落服务端 MISS 日志(脱敏,不进群)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent d5ee4979
Pipeline #3356 failed in 0 seconds
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
} from '../../common/decorators/tenant-scope.decorator'; } from '../../common/decorators/tenant-scope.decorator';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { import {
ClientDiagRequestDto,
ExchangeCodeRequestDto, ExchangeCodeRequestDto,
ExchangeCodeResponseDto, ExchangeCodeResponseDto,
MockLoginRequestDto, MockLoginRequestDto,
...@@ -55,6 +56,17 @@ export class AuthController { ...@@ -55,6 +56,17 @@ export class AuthController {
} }
@Public() @Public()
@Post('client-diag')
@ApiOperation({
summary:
'前端 bootstrap 诊断信标 — 把嵌入首开鉴权走的分支(有无 code / store 有无 token / 结局)落一行服务端日志,定位「登录已过期」',
})
clientDiag(@Body() dto: ClientDiagRequestDto) {
this.auth.clientDiag(dto);
return { ok: true };
}
@Public()
@Post('mock-login') @Post('mock-login')
@ZodResponse({ status: 200, type: MockLoginResponseDto }) @ZodResponse({ status: 200, type: MockLoginResponseDto })
@ApiOperation({ @ApiOperation({
......
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
import { import {
ExchangeCodeRequestSchema, ExchangeCodeRequestSchema,
ExchangeCodeResponseSchema, ExchangeCodeResponseSchema,
...@@ -22,3 +23,27 @@ export class MockLoginRequestDto extends createZodDto(MockLoginRequestSchema) {} ...@@ -22,3 +23,27 @@ export class MockLoginRequestDto extends createZodDto(MockLoginRequestSchema) {}
export class MockLoginResponseDto extends createZodDto(MockLoginResponseSchema) {} export class MockLoginResponseDto extends createZodDto(MockLoginResponseSchema) {}
export class MockUsersResponseDto extends createZodDto(MockUsersResponseSchema) {} export class MockUsersResponseDto extends createZodDto(MockUsersResponseSchema) {}
export class SessionResponseDto extends createZodDto(SessionResponseSchema) {} export class SessionResponseDto extends createZodDto(SessionResponseSchema) {}
/**
* 前端 bootstrap 诊断信标(嵌入首开鉴权路径定位用)。
* 全字段可选 + passthrough:只为落一行服务端日志,不做业务校验,尽量不因字段挑剔而丢诊断。
*/
export const ClientDiagRequestSchema = z
.object({
phase: z.string().max(40).optional(),
ev: z.string().max(40).optional(),
hadCode: z.boolean().optional(),
codeTag: z.string().max(16).nullable().optional(),
storedAccess: z.boolean().optional(),
storedRefresh: z.boolean().optional(),
storedAccessValid: z.boolean().optional(),
err: z.string().max(120).nullable().optional(),
// 环境/存储探针
storageWritable: z.boolean().optional(),
isEmbedded: z.boolean().optional(),
hostOrigin: z.string().max(200).nullable().optional(),
isReload: z.boolean().optional(),
ua: z.string().max(200).optional(),
})
.passthrough();
export class ClientDiagRequestDto extends createZodDto(ClientDiagRequestSchema) {}
...@@ -3,9 +3,110 @@ ...@@ -3,9 +3,110 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { exchangeCode, refreshToken } from '@/lib/auth-api'; import { exchangeCode, refreshToken } from '@/lib/auth-api';
import { env } from '@/lib/env';
const REFRESH_LEAD_MS = 60_000; const REFRESH_LEAD_MS = 60_000;
/** 当前 store 的 token 快照(反水合后)—— 诊断嵌入重载时"localStorage 到底读没读到" */
function storeSnap() {
const s = useAuthStore.getState();
return {
storedAccess: Boolean(s.accessToken),
storedRefresh: Boolean(s.refreshToken),
storedAccessValid: Boolean(s.accessToken && s.expiresAt && Date.now() < s.expiresAt),
};
}
/**
* 环境/存储探针 —— 定位「登录已过期」根因的关键。
* storedRefresh=N 分不清"真没登录过"(正常)vs"存储被 iframe 分区/ITP 清了"(bug);
* 这几个字段才能证死:storageWritable(存储到底能不能用)+ isEmbedded(是否在 iframe)
* + hostOrigin(哪个宿主)+ isReload(是不是重载)+ ua(Safari→ITP)。
*/
function envSnap() {
let storageWritable = false;
try {
const k = '__pac_probe__';
localStorage.setItem(k, '1');
storageWritable = localStorage.getItem(k) === '1';
localStorage.removeItem(k);
} catch {
storageWritable = false; // 抛错 = 存储被禁/分区(隐私模式 / 第三方 iframe 被拦)
}
let isEmbedded = false;
try {
isEmbedded = window.self !== window.top;
} catch {
isEmbedded = true; // 跨源访问 window.top 抛错 = 确实被跨源嵌入
}
let hostOrigin: string | null = null;
try {
const anc = window.location.ancestorOrigins;
hostOrigin =
(anc && anc.length ? anc[0] : null) ??
(document.referrer ? new URL(document.referrer).origin : null);
} catch {
hostOrigin = null;
}
let isReload = false;
try {
const nav = performance.getEntriesByType('navigation')[0] as
| PerformanceNavigationTiming
| undefined;
isReload = nav?.type === 'reload';
} catch {
/* noop */
}
const ua = typeof navigator !== 'undefined' ? navigator.userAgent.slice(0, 160) : '';
return { storageWritable, isEmbedded, hostOrigin, isReload, ua };
}
/**
* bootstrap 诊断信标 —— 同时打 console(本地/开 devtools 可看)+ 打服务端日志
* (navigator.sendBeacon,远端对接方复现时我们 grep pac-service 日志即可,不用他们开 devtools)。
* 纯诊断,失败静默,绝不影响鉴权主流程。
*/
function reportDiag(payload: {
ev: string;
hadCode?: boolean;
codeTag?: string | null;
storedAccess?: boolean;
storedRefresh?: boolean;
storedAccessValid?: boolean;
err?: string | null;
// 环境/存储探针(失败事件带上,server 据此判"存储被清 vs 真没登录 + 哪个宿主/浏览器")
storageWritable?: boolean;
isEmbedded?: boolean;
hostOrigin?: string | null;
isReload?: boolean;
ua?: string;
}) {
try {
// eslint-disable-next-line no-console
console.info('[auth-bootstrap]', payload);
} catch {
/* noop */
}
try {
const url = new URL('/pac/v1/auth/client-diag', env.apiBaseUrl).toString();
const body = JSON.stringify({ phase: 'bootstrap', ...payload });
// 用 fetch(不用 sendBeacon):sendBeacon 强制带凭据,遇到服务端 CORS='*' 会被浏览器拦
// (凭据+通配 origin 不允许)。fetch 默认 same-origin 凭据 → 跨源即不带,与 api-client 一致。
// keepalive 保证即使随后页面跳转/卸载,这条诊断也能发出。
void fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
credentials: 'omit',
}).catch(() => {
/* 诊断失败静默 */
});
} catch {
/* noop */
}
}
/** /**
* 鉴权启动 — 三路兜底: * 鉴权启动 — 三路兜底:
* 1. URL 有 ?code= → 优先 exchange 拿新 token(host 刚跳来 / 用户手动重开) * 1. URL 有 ?code= → 优先 exchange 拿新 token(host 刚跳来 / 用户手动重开)
...@@ -30,6 +131,11 @@ export function useAuthBootstrap() { ...@@ -30,6 +131,11 @@ export function useAuthBootstrap() {
const url = new URL(window.location.href); const url = new URL(window.location.href);
const code = url.searchParams.get('code'); const code = url.searchParams.get('code');
const codeTag = code ? code.slice(0, 8) : null;
const envInfo = envSnap(); // 存储/环境探针,一次算好,失败事件带上
// 入口快照:URL 有没有 code + 反水合后 store 有没有 token + 环境(嵌入首开路径起点证据)
reportDiag({ ev: 'start', hadCode: Boolean(code), codeTag, ...storeSnap(), ...envInfo });
const run = async () => { const run = async () => {
// ─── 1. URL 有 code → 优先(新人接管,覆盖旧 token)─── // ─── 1. URL 有 code → 优先(新人接管,覆盖旧 token)───
...@@ -40,12 +146,21 @@ export function useAuthBootstrap() { ...@@ -40,12 +146,21 @@ export function useAuthBootstrap() {
setTokens(r); setTokens(r);
url.searchParams.delete('code'); url.searchParams.delete('code');
window.history.replaceState({}, '', url.toString()); window.history.replaceState({}, '', url.toString());
reportDiag({ ev: 'code-ok', codeTag });
setStatus('ready'); setStatus('ready');
return; return;
} catch { } catch (e) {
// code 失效(过期 / 重放) — 不一定要崩,localStorage 可能还有效 // code 失效(过期 / 重放) — 不一定要崩,localStorage 可能还有效
url.searchParams.delete('code'); url.searchParams.delete('code');
window.history.replaceState({}, '', url.toString()); window.history.replaceState({}, '', url.toString());
// ⭐ 关键证据:换 code 失败瞬间,store 里到底有没有 token(有→能兜住;无→必弹登录已过期)
reportDiag({
ev: 'code-fail',
codeTag,
err: e instanceof Error ? e.message.slice(0, 100) : String(e).slice(0, 100),
...storeSnap(),
...envInfo,
});
// fall through 试 store // fall through 试 store
} }
} }
...@@ -53,6 +168,7 @@ export function useAuthBootstrap() { ...@@ -53,6 +168,7 @@ export function useAuthBootstrap() {
// ─── 2. localStorage 反水合的 access token 还活着 → ready ─── // ─── 2. localStorage 反水合的 access token 还活着 → ready ───
const s = useAuthStore.getState(); const s = useAuthStore.getState();
if (s.accessToken && s.expiresAt && Date.now() < s.expiresAt) { if (s.accessToken && s.expiresAt && Date.now() < s.expiresAt) {
reportDiag({ ev: 'access-ok', codeTag, ...storeSnap() });
setStatus('ready'); setStatus('ready');
return; return;
} }
...@@ -63,16 +179,25 @@ export function useAuthBootstrap() { ...@@ -63,16 +179,25 @@ export function useAuthBootstrap() {
try { try {
const r = await refreshToken(s.refreshToken); const r = await refreshToken(s.refreshToken);
setTokens(r); setTokens(r);
reportDiag({ ev: 'refresh-ok', codeTag });
setStatus('ready'); setStatus('ready');
return; return;
} catch { } catch {
reportDiag({ ev: 'refresh-fail', codeTag, ...storeSnap(), ...envInfo });
clear(); clear();
setStatus('error'); setStatus('error');
return; return;
} }
} }
// ─── 4. 全没了 ─── // ─── 4. 全没了 → 弹「登录已过期」───
reportDiag({
ev: 'no-credentials',
codeTag,
hadCode: Boolean(code),
...storeSnap(),
...envInfo,
});
setStatus('error'); setStatus('error');
}; };
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment