微前端 + 单点登录,最狠的坑从来不在鉴权,而在时序

39 阅读5分钟

微前端接入统一身份认证(4A/SSO)时,配置了半天登不进去,最后定位到的问题不是“验签失败”,而是“顺序错了”——路由守卫跑得太早,拿到了票据却没来得及解析;或者改了环境变量不重启,登录开关根本没生效。本文用一次真实的排障复盘,讲透单点登录集成里几个反直觉的时序问题。

一、背景:看似简单的“登录后跳转”

微前端应用要接入统一认证中心做单点登录,流程通常是:

访问页面  →  未登录  →  跳转登录页  →  携带票据回调  →  解析票据  →  换取会话  →  进入原页面

看起来没有难度:一个 beforeEach 路由守卫判断是否登录,未登录就去认证中心换取 ticket,回调后解析 ticket 就可以了。

但我告诉你:这条路每一步踩的坑,几乎都跟“时序”有关。

二、坑一:改 environment 变量后不重启,开关是假的

接单点登录的第一步通常是加个开关:.env 里配置 SSO_ENABLED=true 之类,用它决定走不走统一登录。

遇到的迷惑现场是:开关明明开了,页面还是走旧的账号密码登录。

排查结论很经典:这类构建工具(以 Vite 为例)是在 dev server 启动时一次性加载并注入 environment 变量的。运行中修改 .env,不会热更新生效——必须重启 dev server 开关才真正打开。

教训:动过 .env、.env.local 这类配置文件后,第一件事就是重启本地服务,不要浪费时间去查代码逻辑。

三、坑二:异步路由守卫,比“票据解析”跑得更快

这是最隐蔽、也最难定位的一个。

正常情况下,登录跳转后,票据会拼在 URL 的 query 参数里(比如 pname=<ticket>)回到应用。应用需要在合适时机把它解析出来去换会话。

但线上环境的诡异现象是:票据明明带着,认证却失败了。

根因是一条竞态链:

// 路由守卫是 async 的,它先跑了
router.beforeEach(async (to) => {
  if (!isLoggedIn()) {
    // 此时 if (票据还没被解析) 就走认证判断 → 认为未认证 → 提前跳走
    return toLogin(to) // 票据被“吞掉”
  }
})

具体到项目里,是因为应用启动时有个阻塞性的外部资源初始化(比如地图服务的 initAk),它让整个 app mount 被拉长;而路由守卫在异步初始化完成前就开始执行,导致守卫在“票据解析”之前就做了未登录判断,把带着票据的回调拦下了。

修复思路不是去缩短初始化,而是重新划分执行顺序:

// 把阻塞的初始化从 mount 的同步路径里挪出去
// 改成:登录成功后再异步触发外部资源初始化
await loginFlow()          // 先完成登录 → 票据已解析
initExternalSdk()          // 再去做地图/外设初始化(不阻塞登录判断)

要点是:登录/票据兑换必须是“先于”一切基于登录态的异步逻辑完成的动作,任何会抢跑的路由守卫、启动初始化都不该夹在中间。

四、坑三:redirect 与票据参数“并存”的二义性

为了“登录后跳回原页面”,常见做法是未登录时在 URL 追加 redirect=<原路径>。但 SSO 回调本身又会带上一串 query 参数,两个东西一旦同时出现在 URL 里,就会出现二义性:

  • 解析方不知道哪个是“目标页”,哪个是“票据”;
  • 跳转逻辑和票据兑换逻辑互相干扰,历史记录也会被无意义地塞满。

更干净的做法是:由守卫显式、只透传认证所需的票据参数,并用 replace 替换而不是 push,避免堆积不必要的 history 记录:

router.beforeEach((to) => {
  const ticket = to.query['pname'] // 只关心认证需要的参数
  if (needsAuth()) {
    return { path: '/login', query: ticket ? { pname: ticket } : {}, replace: true }
  }
})

五、坑四:外部凭证(AK)别每次都要,缓存 + 到期前刷新

单点登录之外,应用还依赖一个带过期的外部访问凭证(比如地图服务的 AK)去发业务请求。初版实现可能是每次请求前都现取,结果:

  1. 高频请求把换取凭证的接口打成热点;
  2. 换取过程带网络耗时,进一步加剧业务接口延迟。

优化方案就是经典的“本地缓存 + 提前续期”:

// 换取成功 → 把 AK 和过期时间一起缓存
cache.set('ak', ak)
cache.setExpireAt(timestampFor(expireTime))

// 任何要用 AK 的地方,先判断是否“即将过期”
ensureAk() {
  if (isExpiringIn(5 * 60 * 1000)) return refreshAk() // 到期前 5 分钟提前续
  return getCachedAk()
}

好处是凭证在有效期内被复用,只有临近过期才去换一次,既省请求又稳。

六、结语

这一轮排障下来,最大的体会是:单点登录集成的难点不在“鉴权算法”,而在“执行顺序”——谁先跑、谁后跑、谁在异步完成之前抢跑,决定了票据会不会被吞、登录会不会成功。

给所有要做微前端 + SSO 的团队三点建议:

  1. 动过环境变量先重启服务,别怀疑代码;
  2. 把“登录/票据兑换”放到一切基于登录态的异步逻辑之前,并显式只透传票据参数;
  3. 外部凭证用“缓存 + 到期前续期”,不要每次现取。

时序问题有个特点:单看每一步都对,组合起来就翻车。把执行顺序当成一等公民去设计,比多写十个判断条件都有效。