登录问题排查

记一次登录问题的排查思路

Posted by nolan on September 14, 2022

背景

昨天下午上线了 gas-admin 的 node 服务,今天上午 QA 在回归时发现一个问题,表现为,点击退出登录后,页面反复刷新,无法正常进入网站。

此时,赶紧采取降级策略, 将 web 页面调用的 node 接口,改为调用原后端接口。(代码改动量上,就是调用接口地址字符串的 v1 改为 v2,当然前端有接入配置中心的话,直接在配置中心修改会更方便)

做服务应用时,降级思维是多么重要!新服务上线或其他大的改动时,一旦出现问题,如果没有降级和回退方案,开发者很容易陷入手忙脚乱中。

登录流程梳理

切回原后端接口后,网站恢复正常了,接下来就可以慢慢排查了。

先分析一波登录流程。

我们的登录是借助 soup 中心鉴权服务接入 google Oauth 登录的,登录流程如下:

image

代码上,我们编写了一个 login 中间件 来,进行每次请求的登录鉴权:

export class LoginMiddleware implements NestMiddleware {

  async use(req: IRequest, res: Response, next: NextFunction) {

    const token = getCookie(req, SOUP_COOKIE_NAME);
    const userInfo = getCookie(req, 'user_info');

    if (!token) {
      logger.info('without token----');
      const createSessionResponse = await createSession(SOUP_TOKEN, CID);
      const session = createSessionResponse.data as string;
      const redirectUrl = getRedirectUrl(session, nextUrl);
      logger.info('redirectUrl--------', redirectUrl);
      logger.info('setSoupCookie', session, SOUP_COOKIE_EXPIRES);
      setCookie(res, {
        key: SOUP_COOKIE_NAME,
        session,
        expires: SOUP_COOKIE_EXPIRES
      });
      clearCookie(res, 'user_info');
      logger.info(req, 'no login, redirect to soup_login');

      throw new BaseException(
        HttpStatus.UNAUTHORIZED,
        BusinessCode.TEMP_REDIRECT,
        {
          login_url: redirectUrl
        }
      );
    }

    if (!userInfoStr || !userInfo) {
      logger.info('with out userinfo----');
      const ssoToken = getCookie(req, SOUP_COOKIE_NAME);
      const rsp = await getSession(SOUP_TOKEN, CID, {
        sso_key: ssoToken
      });
      logger.info('rsp -----', rsp);
      // 判断getSession error或者非soup用户登陆,则跳转首页重新登陆
      if (rsp?.data && Object.keys(rsp.data).length > 0) {
        const { sso_key: ssoKey, user } = rsp.data as SoupGetSessionData;
        const userIn = crypto(user, userCryptKey);
        req.userInfo = user;
        // 设置用户id供日志使用
        req.uid = user.id;
        logger.info(req, 'login success');
        logger.info('setSoupCookie', ssoKey, SOUP_COOKIE_EXPIRES);
        setCookie(res, {
          key: SOUP_COOKIE_NAME,
          session: ssoKey,
          expires: SOUP_COOKIE_EXPIRES
        });
        res.cookie('user_info', userIn, {
          expires: new Date(Date.now() + SOUP_COOKIE_EXPIRES * 1000)
        });
      } else {
        logger.info('clear cookie ------');
        clearCookie(res, SOUP_COOKIE_NAME);

        logger.error(req, 'get session error or no internal user');

        throw new BaseException(
          HttpStatus.UNAUTHORIZED,
          BusinessCode.TEMP_REDIRECT,
          {
            login_url: nextUrl
          }
        );
      }
    }

    next();
  }
}

总结下来,就是未登录状态下或登录态 token(cookie)过期后,

  1. node 会先调用 soup 生成一个临时 token 的 cookie,并清除掉 userInfo cookie;
  2. 跳转到谷歌登录页,用户点击登录,跳回网站,重新请求接口;
  3. userInfo cookie 已被清除,node 去 soup 查询 session 数据
    1. 若能拿到用户信息和常驻 token,种入 cookie;
    2. 若不能拿到,则清掉 token 信息,跳转回网站首页,重新走第一步;

尝试本地复现,观察日志:

image

可以看到,连续走到了 clear cookie ,即上述流程的第 3 步的第 2 分支。

抓取网络请求,会看到有连续接口的请求,点开后会看到每个接口请求里都带有 token cookie,这时,我们就怀疑没有清掉 token cookie,与上述 node 日志的现象吻合;

未进一步验证,在本地跳转前断点,观察此时浏览器 cookie:

image

确实,没有删除掉 token 的 cookie。

而且,观察到此时 token cookie 的 domain 前带有一个 “点” 的前缀。是不是就是这里的问题呢?

再回到清除 cookie 的代码实现:

export function clearCookie(res: Response, key: string) {
  res.cookie(key, '', {
    expires: new Date(Date.now() - 60 * 1000),
  });
  res.cookie(key, '', {
    expires: new Date(Date.now() - 60 * 1000),
    path: commonConfig.PUBLIC_API_PATH,
  });
}

可以看到,是将某 cookie 的值设为空字符串且即刻过期的方式,为了验证,我们设置稍后过期。这时观察浏览器的 cookie:

image

清楚的看到,node 设置的 token cookie 并没有覆盖之前的,且 node 设置的 token cookie 是不带“点”前缀的。

这下真相大白,因为 domain 不同,所以无法覆盖(即清除 token cookie)。

这与测试环境和 uat 环境没有复现,只在 live 环境上出现的现象不谋而合。live 环境上是原后端 go 服务设置的域为 .mkt-admin.shopee.sg 的 cookie, 刚上线的 node 服务清除 cookie 的逻辑未显式指明域,默认是不带点的域,这时就无法清除 token cookie,导致不断返回未登录,前端页面无限刷新的现象。

总结

这次解决 bug 的过程告诉我们:

  1. 敬畏线上环境,发布有降级方案;
  2. 复现后会降低排查解决的难度(这次因为 test、uat 环境无法复现,且 live 环境已回滚。最后通过 whistle 配置 live 的域名到本地才得以复现)
  3. 冷静分析,信心观察(后端观察日志,前端观察请求,资源状态等);
  4. 对自己的代码要有把控力,对复杂逻辑要通过梳理流程图等存档;