跨站点授权

2021-08-09 | 52分钟 | yrobot | cross-site,auth,server,跨站点,授权,login,登陆,账户体系,iframe

前言

本文主要是为了调研实现跨站点认证的技术基础。
包括是否必须 iframe 来实现跨站携带同一认证,iframe 中跨站认证的实现方案可行性。

如,auth.com 作为统一认证服务,b.com 作为业务一的域名,c.com 作为业务二的域名,而且 b 和 c 的业务共用 auth 账号体系。

  • 是否可以直接在 b.com 中通过请求 auth.com 的接口获取认证 token,同理 c.com。业务 server 层直接请求 auth.com 获取 key 之后对 token 进行校验,管理账号直接请求 auth 服务 来实现账号体系的复用。

  • 在业务 server 中,遇到需要更多用户信息的场景时,支持从 Auth Server 获取所需数据

对内的认证系统,可以直接暴露内网接口,不用担心信息的泄露。对外的认证系统,需要更细致全面的权限认证。
本文主要探讨对内的认证系统,即对于业务服务具有较高的信任度。

跨域问题

涉及到多个域名,当然离不开浏览器跨域问题。
关于跨域问题和解决方案请参看之前的 blog 《跨域问题》

强烈建议先搞清楚跨域问题后再进行下文的阅读

由于跨域导致的问题

无法在 b.comc.com 直接请求 auth.com,且浏览器本地登录状态无法跨域共享

可行方案罗列与优缺点分析

1. CORS + 正向代理,利用 cookies 等手段储存登录状态

此方案思路是让业务网站可以直接请求 auth server

方案流程:

  1. auth server 配置 cors 白名单支持业务网站跨域请求。
  2. 业务网站正向代理 auth 请求

利用 auth 请求的 cookies 储存登录状态和信息

优势

  1. 方案直观:配置跨域之后,可以像正常请求一样和 auth server 进行通信

劣势

  1. 跨域 cookies 存在限制:参看 《跨域问题 #浏览器的跨域限制有哪些》
  2. 业务和认证耦合:认证能力升级后需要业务项目更新
  3. 认证数据存储受限:由于通过 cookies 储存认证信息,所以认证信息存储受限于 cookies
  4. 流程偏长:后续网站自动登录需要通过再次请求

2. 利用 iframe 作为中间人,使用 window.postMessage 实现跨域通讯,避免直接跨域请求

此方案利用 iframe + postMessage,避免跨域请求,并利用 auth 域的 localStorage 进行认证信息存储

方案流程:

  1. 业务网站实例化 auth iframe
  2. iframe 处理所有 auth 相关的数据请求和存储
  3. 业务页面 通过 postMessage 和 iframe 进行跨域信息互通

优势

  1. 方案安全:postMessage 的作用就是安全地实现跨源通信,且方案中请求不跨域。不用担心方案失效
  2. 存储无限制:直接利用 auth origin 下的 localStorage 进行存储
  3. 认证系统可以无感升级:auth iframe 的升级不会影响业务逻辑
  4. 实时同步认证信息:多个 auth iframe 间可以通过 window.addEventListener('storage')进行消息同步

劣势

  1. 理解成本高:引入了 iframe,不直观
  2. auth iframe 样式难以个性化:业务逻辑难以个性化 auth iframe 里的节点样式

3. 业务 server 反向代理 auth 服务

利用业务 server 方向代理 auth 请求,来规避跨域请求问题

方案流程:

  1. 业务 server 对于某个规则的请求进行转发到 auth server。
  2. 业务 client 直接利用业务域名进行认证请求

优势

  1. 方案直观:client 直接和业务 server 通讯

劣势

  1. 无法同步认证状态:所有的 auth 请求都合并到了业务请求中,client 端无法共享认证信息
  2. 业务和认证耦合:认证能力升级后需要业务项目更新

最优方案选择和细节设计

综上所述,我更偏向于使用 iframe + postMessage 的方案。
此方案的缺点影响对于我来说影响是最低的。

整体方案定了之后,需要对实现细节进行设计,来实现业务端最优的使用体验

认证系统需处理的内容

从几个业务场景出发:

  1. 登录、注册、等:

    • 将用户输入作为凭证发送请求换取认证信息
    • 缓存认证信息
    • 推送业务层更新认证信息
  2. 主动退出、刷新认证信息:

    • 业务逻辑向 认证系统 iframe 推消息
    • iframe 接受消息,并分发动作(清除本地认证信息,发起对应请求等)
    • 缓存最新的认证信息
    • 推送更新认证信息到业务层
  3. a 业务更新认证信息后,b 业务同步获得最新的认证信息

    • a 业务向 认证系统 iframe 推消息,需要更新认证信息
    • iframe 接受消息,并分发动作(清除本地认证信息,发起对应请求等)
    • 缓存最新的认证信息
    • a 和 b 的 iframe 都接收到认证信息的更新,并推送到业务层

从技术角度出发:

  1. 认证系统更新时,应做到业务无感
  2. 一些配置化的逻辑应该被封装(如 Event Key 等)

责任罗列

  1. 输入交互
  2. 请求认证
  3. 认证数据缓存
  4. 认证数据更新广播
  5. 跨域通信
  6. 错误处理

责任划分探讨

1. 输入交互应该归由那部分负责?业务、auth 组件、iframe

业务:业务获取用户输入 -> 调用 auth 组件 postMessage 到 iframe 中 -> iframe 发起认证请求
auth 组件:auth 组件负责用户输入并将输入 postMessage 到 iframe -> iframe 发起认证请求
iframe:iframe 负责用户输入并发起认证请求

考虑到 认证系统更新升级 让业务无感,前两者划分方案会要求业务同步更新。
所以建议将所有认证相关的交互内容都放到 iframe 内部
这样,每次 auth 系统添加新的登录方式时,只需独立更新 auth server 和 iframe 输入,业务层面对于 iframe 的引用地址和 message 监听还是不变的。从而达到业务无感。

责任总结

  1. 业务逻辑

    • 监听 auth 组件的数据更新通知
    • 主动调用 auth 组件操作认证数据
    • 调用 auth 组件实例化 iframe
  2. auth 组件

    • 处理跨域通信:处理所有 postMessage 的收发和转译
    • 优化 auth 组件的使用:业务层一处监听 + 适当实例化
  3. iframe 页面

    • 输入交互处理
    • 认证请求通信
    • 错误提示
    • 认证数据 缓存
    • 认证数据 更新广播
    • 跨域通信:
      • 接受:动作分发
      • 发送:认证更新,错误通知,

流程解析

关键环节技术调研

浏览器跨域数据交互

即实现 业务页面 和 auth iframe 之间的数据交互

postMessage

父 向 iframe 发消息

document
  .getElementById("iframe")
  .contentWindow.postMessage("parent message", "*");

iframe 向 父 发消息

window.parent.postMessage("child message", "*");

监听消息

function receiveMessage({ origin, data }) {
  console.log({ origin, data });
}
window.addEventListener("message", receiveMessage, false);

同源 iframe 数据广播

在一个 业务 iframe 更新 token 后,另一个业务的 auth iframe 被通知 token 已被更新

localStorage

一个窗口更新 localStorage,另一个窗口监听 window 对象的”storage”事件,来实现通信。

消息发送端:

localStorage.setItem("token", "**********");

消息监听端:

window.addEventListener("storage", function (e) {
  // e.key
  // e.oldValue
  // e.newValue
});

安全控制

1. postMessage 跨域消息安全

相关知识《postMessage 安全》

由于 auth 系统将被多个业务系统引用,
所以域名白名单是无法避免的,
而由于严格指定targetOrigin只能指定一个
所以使用 whitelist 方案:

const getParentOrigin = () => {
  var url = null;
  if (parent !== window) {
    try {
      url = parent.location.origin;
    } catch (e) {
      url = document.referrer;
    }
  }
  return url && /^https?:\/\/[\w-.]+(:\d+)?/i.exec(url)[0];
};

const whitelist = ["https://yrobot.top"];
if (whitelist.includes(getParentOrigin())) {
  window.parent.postMessage("secret", getParentOrigin());
}