Commit c8592cdc by luoqi

fix(auth): exchange-code 幂等(短宽限窗),根治首开「登录已过期」

根因:全新浏览器(无 localStorage)下,幸存 iframe 拿到的 code 已被消费(宿主预取/
首屏双渲染中首个已消费但没落地/宿主自己先验一次)→ 第二次 exchange 得 10102 → 无票
可退 → SessionExpired。90aefc7d 只挡了"首个成功落地"的情况,挡不住这种。

改:exchangeCode 首次消费后落一份短期幂等副本(CODE_RESULT_PREFIX,EXCHANGE_GRACE_SECONDS=20s);
同一 code 在宽限窗内重复换返回同一票,不报 10102;过窗即真失效,把可重放窗口压到最小。

模拟宿主(4100)复现 + 验证:已消费 code + 清 localStorage 首开,修复前报「登录已过期」,
修复后工作台正常加载;API 层同一 code 两次 exchange 均返回 code=0。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent c3a8f1a5
Pipeline #3350 failed in 0 seconds
...@@ -24,6 +24,11 @@ import { parseDurationToSeconds } from './duration'; ...@@ -24,6 +24,11 @@ import { parseDurationToSeconds } from './duration';
import { loadMockUsers } from './mock-users'; import { loadMockUsers } from './mock-users';
const CODE_PREFIX = 'pac:exchange-code:'; const CODE_PREFIX = 'pac:exchange-code:';
/// 幂等副本:code 首次消费后短暂保留结果,让"同一 code 首屏被换两次"的竞态
/// (宿主预取 / iframe 双渲染 / 首个加载被打断但已消费)第二次换仍返回同一票,不报 10102。
const CODE_RESULT_PREFIX = 'pac:exchange-code:result:';
/// 幂等宽限窗(秒)—— 只需覆盖首屏两次加载的间隔;过窗即真失效,把可重放窗口压到最小。
const EXCHANGE_GRACE_SECONDS = 20;
const REFRESH_PREFIX = 'pac:refresh:'; const REFRESH_PREFIX = 'pac:refresh:';
/** /**
...@@ -117,10 +122,19 @@ export class AuthService { ...@@ -117,10 +122,19 @@ export class AuthService {
} }
async exchangeCode(code: string): Promise<ExchangeCodeResponse> { async exchangeCode(code: string): Promise<ExchangeCodeResponse> {
const expiresIn = this.accessExpiresInSeconds;
const raw = await this.redis.getDel(CODE_PREFIX + code); const raw = await this.redis.getDel(CODE_PREFIX + code);
if (!raw) throw new BizError(ApiCode.AUTH_INVALID_CODE); if (raw) {
// 首次消费:落一份短期幂等副本,覆盖宿主首屏"同一 code 被换两次"的竞态。
await this.redis.setEx(CODE_RESULT_PREFIX + code, raw, EXCHANGE_GRACE_SECONDS);
} else {
// 已被消费:宽限窗内同一 code 返回同一票(幂等,不报 10102);过窗 → 真失效。
const cached = await this.redis.get(CODE_RESULT_PREFIX + code);
if (!cached) throw new BizError(ApiCode.AUTH_INVALID_CODE);
const p = JSON.parse(cached) as { accessToken: string; refreshToken: string };
return { accessToken: p.accessToken, refreshToken: p.refreshToken, expiresIn };
}
const parsed = JSON.parse(raw) as { accessToken: string; refreshToken: string }; const parsed = JSON.parse(raw) as { accessToken: string; refreshToken: string };
const expiresIn = this.accessExpiresInSeconds;
return { return {
accessToken: parsed.accessToken, accessToken: parsed.accessToken,
refreshToken: parsed.refreshToken, refreshToken: parsed.refreshToken,
......
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