0%

同域名共享登录状态问题溯源&总结

同域名共享登录状态问题溯源&总结

需求

存在 A 和 B 两个挂载在同一个域名下的系统,希望可以一次登录、处处通行,指域名下共享登录状态。两个系统均为 papaya模板项目,走标准的内置中间件 papaya-cas登录流程。
问题现状是,两个系统配置了相同的 session key,配置了同一个 redis client ,出现了登录状态共享但没完全共享的问题,表现为 A 系统登录后,B 系统需要重新走登录流程,已登录的情况下就会出现自动 302重定向并跳转回来的情况,未登录就会直接跳到登录页面。但是 cookie确实是同一个。下面列出问题如何溯源以及解决,先介绍一下前置的信息。

文中不会详细介绍部分基础知识,如 session/cookie、koa、中间件等知识

登录流程

先看下 papaya-cas中间件是如何实现登录登出的。该模块有一份官方文档可供参考,但是让人感觉不够直观,接下来做一下梳理。
每一个系统并没有单独实现登录这个功能,而是统一重定向到了 https://cas.wacai.com/login 这个网站并携带一个原系统地址的 service 参数。
例如本地运行了一个 http://localhost:3000 的服务,打开时可以感知的就是系统自动跳转到了 https://cas.wacai.com/login?service=http://localhost:3000 ,点击登录后又跳转回了 http://localhost:3000,而此时该服务上就可以拿到登录信息。
这里推荐一个 chrome 插件: HTTP Status,可以方便地看到每个网站打开后的 HTTP 请求链路以及状态。

未登录 -> 登录

当我们运行一个未登录的系统,请求链路如图所示:

image.png

可以看到,在正常请求后,会被重定向至 cas 服务网站。下面简单看下这一段的代码逻辑:

module.exports = (...) => {
  // 检验参数
  // ...
  return async (ctx, next) => {
    const ticket = ctx.query.ticket
    // 读取 session,判定无登录状态
    // 重新登录
    if (!ticket) {
      return ctx.redirect(formatcasUrl(casBaseURI, 'login', logInService, queryOtherParams))
    }
  }
}

先忽略其他的代码逻辑,只看中间件中判定无登录状态后的操作,看是否携带了 ticket,我们的请求中很显然没有这个参数,于是进入重定向逻辑,根据 casBaseURIhttps://cas.wacai.com 和服务参数,重定向至登录页面。
这里登录的具体逻辑大致猜测为登录接口传递用户账号和密码,服务端校验后确认登录成功,这里基本没有疑问,但是如何保持住登录状态呢?如何把这个状态传递回对应的页面呢?捋清楚逻辑后再看官方文档的图就可以理解,其实是通过一个凭证 ticket的传递,可以再看下点击登录后的请求链路。

image.png

先看个大概,请求从登录网站重定向到了原系统,原系统一番操作后再次重定向至自身。
点开中间的重定向看一下:

image.png
跳转回来的 url 上携带了一个 ticket 参数,对照官方文档提的流程,可以明白登录状态依靠 ticket 作为登录凭证,此时的重定向请求再次打入原系统的 node 层并进入 papaya-cas 中间件,对照上方未登录的代码流程,我们依然是未登录状态,但此时拿到了 ticket 参数,可以跳过重定向走至后续的逻辑:

// 重新登录
if (!ticket) {
  // 存在 ticket 不会进入这里
}

// 验证ticket的有效性,即登录状态
let body = await validate({ casBaseURI, ticket, service: logInService })

const [isLogin, userEmail, wupsUid = ''] = body.split('\n')
if (isLogin !== 'yes') {
  // 失败的情况下
  return ctx.redirect(formatcasUrl(casBaseURI, 'login', logInService, queryOtherParams))
} else {
  // 拿到 session 所需保存的信息并存储
  // ...

  ctx.redirect(logInService)
}

再次省略了具体的代码细节,只看一下大概的流程。ticket参数有值因此进入了后续的校验,papaya-cas 会使用这个凭证向登录服务发起校验请求,校验会给出一个结果表明是否有效以及登录的用户邮箱。失败的话需要再次重定向回登录页面进行登录。成功的情况下就可以走向后续的流程,发起请求等等操作获取 session 中需要存储的信息,写入 session 并存储至外部 store (这一部分放在后面 session 介绍)。接着再重定向,发起一个新请求运行网站。逻辑如下:

// 已经登录
const userName = ctx.session.userName

if (userName) {
  // 单用户登录逻辑,省略

  // 保持登录状态
  ctx.session.last_accessed_at = Date.now()
  return next()
}

因此重定向回来之前已经在 ctx.session 中写入了用户名,所以此时会进入已登录逻辑,记录一些信息,并调用 next 进行后续逻辑,也就是通过了登录校验。

登录 -> 退出登录

这里介绍的是主动退出登录的逻辑,一般是用户手动点击退出的交互位置,在系统中一般是跳转至 http://your-service.com/logout ,这个请求会进入 papaya-cas 的登出逻辑。

// 登出
if (logoutUrl && ctx.path === logoutUrl) {
  ctx.session = null
  return ctx.redirect(formatcasUrl(casBaseURI, 'logout', logOutService, queryOtherParams))
}

逻辑非常简单,请求的 path 命中登出时,清空 session (对网站的影响就是清空了 cookie ),并重定向至登录网站的登出服务,用于真正的登录服务的登出操作,后续登录服务的逻辑大致猜测为根据登录时设置到 https://cas.wacai.comcookieservice 查找登录用户,失效对应派发的凭证,并清除该 cookie 实现登出。表现在各个系统的话就是旧凭证不再有效,且 cookie 清空无法查询到登录信息,被判定为未登录状态。登录服务网站实现登出后又再次重定向回原网站,将后续的处理逻辑又交还给了原网站。

koa-session

官方文档

很显然 papaya-cas 的逻辑判断依赖了 ctx.session 来确认已有的登录信息,而这个对象来自于 koa-sessionpapaya 模版项目中使用了封装的二方包 @wac/koa-session ,是因为 koa-session 可提供外部存储机制,二方包中封装了一个提供所需方法的外部存储对象,用于将用户信息存储至 redis ,而用户浏览器只存储去 redis 拿数据的 key ,在 koa-session 中称作 externalKey
在网站请求进入中间件时先进入 koa-session ,根据请求携带的 cookie 读取为 session ,后续的中间件读取并处理即可。逻辑上没有什么问题,那么看下无法实现登录共享的问题出在哪里。

单步调试

直观的排错方式就是使用单步调试,一步步跟着程序走来找到问题所在的位置。
直接看到的结果是 B 系统登录后,A 系统重走了登录流程,A B 已经正确共享了 cookie ,那么被判定为未登录的原因在哪?根据登录流程的梳理,很有可能是 ctx.session 中不存在用户信息,接下来先确定这个点。
在本地同时启动 AB 两个系统,选择 A 系统来做单步调试,那么首先需要在排查的位置打上断点,这里选择了在 papaya-cas 中打了几个断点:
image.png
行号 96 与 121 处打了两个断点,确认一下 ctx.session的信息以及流程走向。
接下来用 vscodedebug terminal 启动 A 系统,直接启动 B 系统就可以。A 系统启动时会触发调试,但因为断点位置没有走到,所以直接启动了。
两个系统都编译好后,先打开 B 系统做一个登录,确认 cookie 设置上。
image.png
登录后可以看到已经在 localhost 下设置好了 cookie ,接着打开 A 系统,不出意外,这个请求会被断点拦截。先走到 96 行的断点处,左侧的变量节目可以查看当前上下文中的变量运行在此时的值,让我们确认一下浏览器中的请求是否携带了 cookie
image.png
可以看到, A 系统请求携带的 cookie 和 B 系统登录后的 cookie 一致,接着看下断点处的变量值。此处 ctx.session.userName变成了 undefined ,因为 ctx.session 中根本没有任何用户信息。
image.png
可以预料到,没有用户信息会再判断是否携带 ticket,如果携带就进行登录状态的校验来决定是否通过,但这次请求并不是从登录服务网站重定向过来,因此直接进入了 122 行的重定向,跳向了登录服务网站进行登录。
走到这里可以明白了,问题出在 ctx.session 上,明明携带了 cookie ,为什么没有读取到用户信息。下面再调试一下 koa-session 部分,梳理一下 session 读取的逻辑。
AB 系统的服务类似,统一采用了二方封装包进行 session 管理,本质上是给 koa-session 提供了一个外部存储对象用于读取设置 redis上的 session 信息,这一部分不详细展开,简而言之就是本文所讨论的系统对于 koa-session 都采用了外部存储的形式,将真正的信息存储在外部,浏览器的 cookie 只是读取外部信息的凭证。在配置的时候会传入 redis client 的配置信息,配置信息相同就可以读取到同一份数据。
先看一下 koa-session 的初始化:

module.exports = function(opts, app) {
  // ... 参数校验,省略细节

  opts = formatOpts(opts);
  extendContext(app.context, opts);

  return async function session(ctx, next) {
    const sess = ctx[CONTEXT_SESSION];
    if (sess.store) await sess.initFromExternal();
    try {
      await next();
    } finally {
      if (opts.autoCommit) {
        await sess.commit();
      }
    }
  };
};

function extendContext(context, opts) {
  Object.defineProperties(context, {
    [CONTEXT_SESSION]: {
      get() {
        if (this[_CONTEXT_SESSION]) return this[_CONTEXT_SESSION];
        this[_CONTEXT_SESSION] = new ContextSession(this, opts);
        return this[_CONTEXT_SESSION];
      },
    },
    session: {
      get() {
        return this[CONTEXT_SESSION].get();
      },
      set(val) {
        this[CONTEXT_SESSION].set(val);
      },
      configurable: true,
    }
  });
}

总体流程并不复杂,初始化时先对配置参数调用 formatOpts,大致理解为类型校验和默认值设置。接着处理了应用的上下文,extendContext 本质是对 ctx 做一些访问处理,读取 ctx.session时可以访问到正确的值。
const sess = ctx[CONTEXT_SESSION];也进入了访问处理,存在则读取,不存在就新建一个 ContextSession对象,读取 ctx.session 时也是来读取了这个对象。取到 sess 后看看是不是设置了外部存储,是的话从外部存储来初始化 session 的值,按照配置,我们的系统也会进入 initFromExternal 函数。看一下函数逻辑:

async initFromExternal() {
  const ctx = this.ctx;
  const opts = this.opts;

  /** 从 cookie 中读取外部存储凭证 */
  let externalKey = ctx.cookies.get(opts.key, opts);

  /** 不存在外部凭证时新建一个空 session */
  if (!externalKey) {
    // create a new `externalKey`
    this.create();
    return;
  }

  /** 取到外部凭证时向外部存储发起查询,拿到需要的值 */
  const json = await this.store.get(externalKey, opts.maxAge, { rolling: opts.rolling });

  /** 将最新的值更新到 session 当中 */
  this.create(json, externalKey);
}

这里依然是省略了大部分细节以及一些不会涉及到的逻辑,主要流程在注释中,首先从 cookies 中读取外部存储的凭证,用来到外部存储拿 session 中(写入 session 时会更新到外部存储上),接着更新到 ctx.session上,流程上也没有问题。但依照前面调试到结果来看,这里的流程走向可能是不如预期的,并没有写入 session 而是新建了一个。在行号 9 处打个断点,查看一下 externalKey 是否取到。
image.png
走到此处,查看变量值,会发现执行完 ctx.cookies.get 的值居然是 undefined ,也就是没有取到 cookie,不难推测后续的逻辑因为不存在外部凭证,也就返回了一个空 session ,那么也就不存在用户信息,被判定为未登录。接下来具体的逻辑就要查看 ctx.cookies.get 方法。
先说 ctx.cookies 这个属性,它来自 koa :

get cookies() {
  if (!this[COOKIES]) {
    this[COOKIES] = new Cookies(this.req, this.res, {
      keys: this.app.keys,
      secure: this.request.secure
    });
  }
  return this[COOKIES];
}

在取 cookies 时,有则返回,无则新建。Cookies 类来自 npm 包 cookies ,到这个包里查看一下新建和读取的逻辑。

function Cookies(request, response, options) {
  // ...
  this.keys = new Keygrip(options.keys)
}

Cookies.prototype.get = function(name, opts) {
  var sigName = name + ".sig"
    , signed = opts && opts.signed !== undefined ? opts.signed : !!this.keys

  const header = this.request.headers["cookie"]
  const match = header.match(getPattern(name))
  const value = match[1]
  if (!opts || !signed) return value

  const remote = this.get(sigName)
  if (!remote) return

  const data = name + "=" + value
  const index = this.keys.index(data, remote)

  if (index < 0) {
    this.set(sigName, null, {path: "/", signed: false })
  } else {
    return value
  }
};

依然是省略了一些不必要的细节代码,先看构造函数,使用 Keygripoptions.keys 进行处理,这个包用于对传入数据进行签名以及验证,例如对 cookie 进行签名,再对签名进行验证。签名的参数就是 new Keygrip(options.keys),对照 Cookies 实例化的地方可以看出这个 keys 来自于 koa 应用的 app.keys,而 AB 系统的 app.keys 则是各自的应用名。
再看取 cookie 的逻辑,整体流程是从 headers 中取出 cookie ,再使用匹配取出对应 name 的值,如果设置了不需要签名,那么直接返回这个值即可,否则需要继续取出 cookie 中的签名,调用 this.keys 即签名工具的 index 方法,传入原 cookie 值和签名,得到一个索引值,如果这个索引值小于 0 ,一般来说小于 0 代表未查找到,那么清空 cookie 中的签名值,然后就退出了函数。但如果索引值大于等于 0 ,就把对应 namecookie 值也就是我们的 externalKey 返回,也就是在前面提到的 koa-session 中拿到,就可以去外部存储取到用户信息写入 session,后续判定登录流程就可以走通。
走到这一步大概逻辑已经理顺了,因为在 AB 系统中配置的 session 中间件没有设置 signed 参数,默认值为 true,可以合理推测取 cookie 时会比对原 cookie 值与签名是否一致,不一致的话会清空签名,且不返回值,也就是没有拿到 cookie,接下来只要看看写 cookie 的函数逻辑以及 this.keys.index 的逻辑来验证这个想法即可。

Cookies.prototype.set = function(name, value, opts) {
  var cookie = new Cookie(name, value, opts)
    , signed = opts && opts.signed !== undefined ? opts.signed : !!this.keys

  pushCookie(headers, cookie)

  if (opts && signed) {
    cookie.value = this.keys.sign(cookie.toString())
    cookie.name += ".sig"
    pushCookie(headers, cookie)
  }

  // ...
};

省略了一部分细节,写 cookie的操作是在登录成功后,会给 ctx.session 写入一部分信息,最终会来到 this.cookies.set(xxx, yyy) 这样的操作。写 cookie 的流程很清晰,首先新建一个根据 name & valuecookie,将它用 pushCookie 操作写入 header,如果设置了需要签名,那么会给写入的 cookie 如名字为 foo ,再写入一个 foo.sigcookie 项,它的值是用 foocookie 进行签名,也就是调用了 this.keys.sign来进行签名,随后写入 header。对照前面请求时携带的 cookie 以及登录后的 cookie 可以发现这个逻辑是没有问题,AB 两个系统都有两条 cookie
keys.index & keys.sign 的逻辑参考文档也好,查看源码也好,可以知道它的签名逻辑就是拿 keys 数组的第一项作为签名参数,验证逻辑就是比对所有 key,找到一个 key 可以使得签名后的值与待验证的值相等,那么返回这个索引,一般来说,验证没问题的话返回的索引值会等于 0。-1 代表没有一个 key 可以让待验证的值通过,也就是验证失败。

总结

走到这一步,整体的逻辑已经清楚了。A B 系统都会对 cookie 进行加密,共享 cookie 后,B 系统登录时写入了新的 cookie&cookie.sig ,这个签名值是 B 系统独有的,因为共享所以一起携带给了 A 系统,A 系统打开时取 cookie 会走签名验证的流程,它的签名参数和 B 系统不一样,签名的结果也就不一样,那么就无法通过签名验证,被认定 cookie 失效,那么无法取到 session 值,也就被认定未登录。
因此想要实现登录状态共享的第一步是确保 cookie 同值共享,且外部存储取的客户端是同一个,确保可以取到同一个用户信息。或者不采用外部存储,直接存储在 cookie 上(会比较大)。第二步是签名参数确保一致,或者强制关闭签名,来确保取 cookie 正确。

如何解决

  1. AB 系统的 app.keys 取同一个值
  2. session({ ... }) 配置设置 signed: false