我需要让多个彼此独立的网站表现得像一个统一的账户系统,同时又不削弱浏览器正常的隔离机制。用户可以在一个域名上登录,之后直接打开另一个域名,点击 Log In,无需再次输入凭据即可继续使用。
关键限制在于,这些网站使用的是彼此独立的根域名。一个根域名创建的会话 Cookie,不能简单地将作用域扩展到另一个无关的根域名。把两个经常被混为一谈的概念分开之后,这套设计就容易理解得多:全局身份和本地浏览器会话。
最终的模型很直接:后端识别同一个共享账户身份,每个域名拥有自己的主机绑定会话,而一个短生命周期、一次性的交接凭证让已完成身份验证的可信源站能够授权另一个可信源站创建新的会话。
全局身份并不需要共享 Cookie
即使浏览器会话是本地的,账户身份仍然可以是全局的。
每个站点都保存一个仅限主机的会话 Cookie。一个根域名签发的 Cookie 无法通过 Domain 属性扩展到另一个无关的根域名。这个属性只能在签发主机获准使用的域名层级内扩大 Cookie 的作用域。
对于应当始终绑定到单一主机的会话,使用带 __Host- 前缀的 Cookie 很合适。概念上可以写成:
Set-Cookie: __Host-<session-cookie>=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=Lax这里最重要的是边界:
- Site A 无法读取 Site B 的仅限主机会话 Cookie。
- Site B 无法读取 Site A 的仅限主机会话 Cookie。
- 两个会话仍然可以解析到同一个后端身份。
因此,跨域 SSO 不应该尝试把 Cookie 从一个站点搬到另一个站点,而应该传递一个临时证明,用来说明某个可信来源已经完成了对用户的身份验证。
一次性交接模式
已经完成身份验证的来源提供一个很小的交接操作。浏览器只有在确实需要进行跨站身份验证切换时才调用它。
后端生成一个密码学安全的随机票据,只将原始值返回给浏览器一次,并且只保存它的哈希,以及验证这次交接所需的最少信息:
{
tokenHash,
userId,
sourceOrigin,
targetSite,
returnPath,
expiresAt
}票据应当生命周期很短,并且只能用于一个目标站点。来源端的签发请求本身应当已经完成身份验证,并受到 CSRF 防护。在签发任何内容之前,都应验证请求的目标站点和返回位置。
随后,浏览器通过顶层 POST 请求把票据发送到目标站点。目标站点消费该票据,解析共享的用户身份,创建自己的本地会话,然后把浏览器重定向到预期路径。
概念上:
record = consumeOnce({
tokenHash: hash(ticket),
targetSite: currentSite,
sourceOrigin: requestOrigin,
notExpired: true
})
if (!record) deny()
user = loadSharedUser(record.userId)
createLocalSession(user)
redirect(record.returnPath)这个票据并不是目标站点的会话。它只是一次性授权,用来允许创建该目标站点的会话。
原子消费可关闭重放窗口
较弱的实现会先读取票据、验证票据、创建会话,然后再删除票据。这样会产生一个窗口,使两个请求都有机会看到同一条仍然有效的记录。
更安全的设计是在一次原子消费操作中同时完成验证和失效处理。如果没有记录匹配,身份验证失败;如果返回了一条记录,那么在创建会话之前,该票据就已经从可用集合中移除了。
这样还可以让目标站点在同一次操作里绑定多个条件:预期目标、预期来源 Origin、令牌哈希以及过期时间。
Origin 验证应当默认拒绝
绑定来源还能带来一个有用的属性:通过某个可信来源签发的票据,不应当能从无关的来源被消费。
Origin 检查应当直接参与消费条件。这样,来自错误来源的请求不会消费掉有效票据,而合法来源之后仍然可以完成交接。
这里有一个重要的例外情况:在所有浏览器上下文中,Origin 请求头都不保证一定包含正常的来源字符串。有些请求可能根本没有可用的值,而不透明上下文可能产生 Origin: null。
我的规则是:除非应用明确设计了其他验证机制,否则缺失或不透明的 Origin 都视为 Origin 检查失败。不能仅仅为了兼容性,就把字面值 null 当成可信来源。
这是一种默认拒绝的选择。如果合法流程确实需要支持没有可用 Origin 的上下文,就应当明确设计这种例外,而不是悄悄削弱通用检查。
不要把交接凭据放进 URL
我更愿意通过顶层表单 POST 发送临时票据,而不是把它放进查询字符串。
一个通用化的客户端版本如下:
const form = document.createElement('form');
form.method = 'POST';
form.action = targetOrigin + '/auth/consume';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'ticket';
input.value = ticket;
form.append(input);
document.body.append(form);
form.submit();目标站点从预期的 POST 请求体中接收票据,并拒绝通过 URL 提供同等票据的尝试。
这并不会让凭据变得无害,但可以让它避开普通导航 URL,并减少通过 URL 历史记录、复制的链接、引用来源以及面向 URL 的日志记录而意外泄露的风险。
已验证身份的跨站链接可以自动升级
如果当前站点已经知道用户处于已登录状态,那么指向另一个可信站点的普通链接就可以在适当时机执行交接。
这个链接仍然应当包含真实的 href。SSO 是对正常导航的增强,而不是替代。
一个精简的客户端流程如下:
if (session.status !== 'authenticated') {
return; // ordinary browser navigation
}
if (!isTrustedTarget(destination)) {
return;
}
event.preventDefault();
const ticket = await requestHandoff({
targetSite,
returnPath: destinationPath
});
postTicketToTarget(ticket);生产版本还应防止重复提交,根据可信站点注册表验证目标,并在无法创建交接时回退到正常导航。
浏览器的原生行为应当保持原样。中键点击、带修饰键的点击、下载,以及原本就要在其他浏览上下文中打开的链接,都不应被悄悄转换成身份验证请求。
第二个站点上的 Log In 按钮需要一个已知签发方
更棘手的情况发生在用户直接打开 Site B 时。Site B 没有本地会话,所以即使浏览器在 Site A 上仍然持有有效会话,它也会正确地把这名访客视为匿名用户。
Site B 无法检查 Site A 的仅限主机 Cookie。它也不能随意重定向到某个 Site C,并期待该站点发现系统其余部分的会话。每个互不相关的根域名只能看到属于自己的 Cookie。
这意味着,匿名状态下的 Log In 流程需要一个已知签发方:可以是一个规范的身份验证源站,也可以是一个专门选定、预期会持有可复用登录会话的可信站点。
对于简单的 Site A → Site B 场景,顺序如下:
- 用户在 Site A 登录。
- 之后,用户直接打开 Site B。
- Site B 没有会话,因此显示 Log In。
- 用户点击 Log In。
- 浏览器进行顶层导航,前往作为已选签发方的 Site A。
- Site A 可以读取自己现有的仅限主机会话 Cookie。
- 由于该会话仍然有效,Site A 立即为 Site B 签发一次性交接票据。
- 浏览器通过 POST 把票据发送到 Site B。
- Site B 创建自己的本地会话,并把用户带回请求的页面。
从用户角度看,这只是快速跳转到 Site A 再返回。不需要输入密码,因为 Site A 已经持有它有权读取的会话。
这个限制同样重要:如果用户只在 Site C 上登录,那么把 Site B 重定向到 Site A 并没有帮助,除非 Site A 是真正的中央身份验证代理,并且自己独立掌握登录状态。共享用户数据库并不会让浏览器会话也变成共享的。
为什么规范签发方能简化系统
随着独立站点数量增加,使用规范签发方就不再需要跨任意域名去发现会话。
如果没有它,匿名状态的 Site B 就必须设法判断其他哪个站点当前可能持有可用 Cookie。Site A 无法检查 Site C 的 Cookie,Site C 无法检查 Site A 的 Cookie,而 Site B 两边都无法检查。
规范签发方会给每一次匿名登录尝试提供一条可预测的路径:
Site B
→ 身份验证签发方
→ 签发方会话是否存在?
是 → 创建一次性交接
否 → 显示正常登录
→ Site B 消费交接票据
→ Site B 创建本地会话签发方不需要承载应用的其他部分。它与这里相关的职责,是持有可复用的身份验证会话,并向可信站点签发作用域严格受限的交接凭证。
对于较小的系统,可以让现有站点承担这个角色。对于更大的架构,单独的身份验证源站可以让信任模型更容易理解。
不要让每位访客都绕经签发方
不应该在每次匿名页面访问时自动运行签发方流程。
为了检查是否存在已有会话,就把每位访客重定向到另一个域名,会带来不必要的导航、额外延迟、更多故障模式、更嘈杂的分析数据以及更复杂的缓存行为。
这对公开且重视 SEO 的页面也很不合适。爬虫或普通访客请求公开 URL 时,应当直接收到对应的公开页面,而不是先被强制绕过一段身份验证流程。
我的规则是:
不要在页面加载时探测签发方。只有在用户明确执行 Log In,或者有意进行已登录状态下的跨站导航时,才启动跨域身份验证。
这样可以让公开流量保持简单,同时对于已经在所选签发方持有有效会话的用户,身份验证仍然很快。
返回路径验证不能只做前缀检查
交接流程通常需要记住浏览器之后应该落到哪里。如果把这个值当成任意由客户端控制的 URL,它就可能变成开放重定向漏洞。
只要条件允许,最安全的设计是根本不接受完整的目标 URL。对于已知目标使用短标识符或服务器端映射,可以减少需要进行的 URL 解析和验证工作。
如果必须接受相对路径,就要仔细验证,并把它限制在目标站点内部。
下面是一个有意简化的示例——它并不是完整的重定向验证例程:
function isSafeReturnPath(value) {
if (!value.startsWith('/')) return false;
if (value.startsWith('//')) return false;
if (value.includes('\\\\')) return false;
const base = 'https://example.invalid';
const parsed = new URL(value, base);
return parsed.origin === base;
}真实实现还需要考虑解码行为、畸形输入、控制字符、规范化、应用路由规则,以及生成重定向之前代理或框架可能执行的任何转换。
安全不变量比解析器细节更简单:交接流程可以选择目标应用内允许的位置,但绝不能选择任意外部目的地。
失败场景比正常路径更重要
一次成功登录只能证明基本路径可用。更有价值的测试是去触碰信任边界。
我验证了以下这类场景的行为:
- 未完成身份验证的来源尝试签发交接票据;
- 缺少 CSRF 防护;
- Origin 缺失、不透明或不符合预期;
- 目标站点不受信任;
- 返回目的地不安全;
- 票据被提交给错误的目标;
- 从错误 Origin 发起消费尝试;
- 票据重放;
- 票据过期;
- 用户已不存在;
- 票据通过 URL 提供,而不是放在预期的 POST 请求体中。
有两个不变量尤其值得直接测试:被拒绝的请求不能意外销毁一个对合法流程仍然有效的票据;而一旦成功消费,票据必须立即变得不可再次使用。
协议可以扩展,而不需要两两集成
加入更多站点时,交接设计本身不需要发生根本变化。
每个参与站点都只需要遵守同一套小型契约:
- 稳定的逻辑标识符;
- 可信的公开 Origin;
- 访问共享账户身份的能力;
- 供已验证来源使用的交接签发操作;
- 交接消费操作;
- 可信站点注册表;
- 绑定主机的本地会话;
- 安全的返回目的地策略;
- 以及用于匿名 Log In 流程的已知签发方。
扩展时最重要的选择,是避免为站点对之间编写定制身份验证逻辑。已经完成身份验证的站点可以向另一个可信站点签发同一种交接票据;匿名站点则可以把浏览器发送到已知签发方。
这样,系统始终建立在一套协议之上,而不是不断膨胀的特殊情况矩阵。
登录可移植性与注销传播是两回事
彼此独立、绑定主机的会话还带来了另一个重要区分:让登录状态可移植,并不会自动定义全局注销。
如果用户从 Site A 注销,另一个站点的本地会话仍可能保持有效,除非后端有意撤销它。这是会话管理策略,而不是 SSO 交接机制失效。
系统可以选择本地注销、全局注销或显式会话管理。把这个决定与交接机制分开,会让两种机制都更容易理解。
我采用的模型
当我不再把这个问题描述成“跨域共享登录 Cookie”之后,整个架构就变得简单了。
只有一个全局身份,每个域名拥有自己的浏览器会话,而一个短生命周期、一次性的交接凭证让已完成身份验证的可信源站能够授权创建另一个本地会话。
对于直接进入匿名站点的情况,还要多一条规则:浏览器必须被发送到一个真正能够看到用户现有会话的签发方。如果用户之前已经在该签发方登录,这次往返几乎可以无感完成。如果签发方没有有效会话,就必须回退到正常身份验证,而不能假装自己能够看到另一个无关域名上的会话。
这个模型保留了浏览器的域名边界,避免让公开访客经历不必要的身份验证重定向,同时仍能提供预期体验:先在签发方登录,之后再打开另一个可信站点,点击 Log In,无需再次输入密码,就能带着新建的本地会话返回。