跳到主内容

OpenID Connect 中的 RP 发起注销和 SSO 注销:Casdoor 如何让用户退出登录

· 阅读时间:一分钟
Yang Luo
Casdoor Maintainer

让用户从 OpenID Connect 应用中退出登录不仅仅是删除你自己的会话 cookie。用户在身份提供商处仍然处于登录状态,因此下一次点击“Sign in”时会直接通过而无需输入密码,并且共享该身份提供商会话的其他应用也保持登录状态。正确的注销包含三个部分:清除你应用的本地会话,将浏览器重定向到提供商的 end_session_endpoint(RP 发起的注销)以结束提供商会话,并让提供商通过后通道注销通知其他应用。在 Casdoor 中,结束会话端点是 /api/logout,后通道注销按应用配置,刷新令牌可以在 /api/login/oauth/revoke 处撤销。

注销机制概览​

机制触发者清除内容Casdoor 支持情况
本地应用注销你的应用仅你的应用的会话 cookie 和存储的令牌你的代码
RP 发起的注销你的应用,通过重定向浏览器Casdoor 浏览器会话和 id_token_hint 对应的令牌是,GET 或 POST /api/logout,公告为 end_session_endpoint
后通道注销Casdoor,服务器到服务器其他应用的会话,每个应用自行结束其会话是,按应用配置 Backchannel logout URL
前通道注销提供商,通过隐藏 iframe其他应用的会话,通过浏览器否,Casdoor 不公告 frontchannel_logout_supported
令牌撤销 (RFC 7009)你的应用,服务器或客户端一个访问令牌或刷新令牌,以及与其共享刷新令牌的令牌是,POST /api/login/oauth/revoke,公告为 revocation_endpoint
Casdoor SSO 注销你的应用,使用用户的令牌或 cookie用户的所有会话和令牌,或仅当前会话是,/api/sso-logout,Casdoor 特有
SAML 单点登出Casdoor 或 SAML 服务提供商SAML 应用中的会话是,签名的 LogoutRequest

本地会话 vs. 身份提供商会话​

每次 OIDC 登录至少涉及两个会话,通常有三个:

  1. 你的应用会话。 通常是一个 cookie,映射到用户和你从令牌端点获取的令牌。
  2. Casdoor 会话。 Casdoor 域名上的一个 cookie。只要它有效,Casdoor 就会在单击账号选择器或通过自动登录静默处理后,让用户免密登录组织的任何应用。这就是单点登录的工作原理。
  3. 上游身份提供商会话,当用户通过 Google、GitHub、企业 IdP 等方式登录 Casdoor 时。

Auth0 在其注销文档中描述了相同的三层结构(应用层、Auth0 层和身份提供商会话层),该模型适用于任何 OIDC 提供商。

“本地化应用会话终止”仅结束第一层:你的应用忘记用户,但 Casdoor 会话依然存在。这就是当你仅仅清除 Cookie 时会发生的情况。“集中式单点登出”会结束 Casdoor 会话,并通知用户的每个应用结束其自身的会话。大多数点击“Sign out”的用户期望的是第二种行为,尤其是在共享计算机上。

对于第三层,当应用程序通过自定义 OAuth 提供商登录用户且该提供商设置了注销 URL 时,Casdoor 会调用该提供商的注销 URL。它不会将用户从 Google 或 GitHub 登出。

RP-initiated logout with the end-session endpoint​

OpenID Connect RP-Initiated Logout 1.0 定义了依赖方(你的应用)如何请求提供商结束其会话。提供商在其发现文档中将端点发布为 end_session_endpoint,请求携带以下参数:

  • id_token_hint:提供商颁发给你的应用的 ID 令牌。规范将其标记为 RECOMMENDED(推荐)。Casdoor 使用它来查找并使该特定令牌过期。
  • post_logout_redirect_uri:浏览器随后跳转到的地址。规范要求该地址必须预先注册。Casdoor 会根据应用的 Redirect URLs 进行检查,并拒绝重定向到其他任何地址。
  • state:一个不透明值,Casdoor 会将其附加到重定向中,以便你的应用区分自己的注销往返请求和伪造的请求。
  • client_id:在无法发送 ID 令牌时用于标识应用。

规范还定义了 logout_hint 和 ui_locales。Casdoor 不读取这些参数。

Casdoor 位于 /.well-known/openid-configuration 的发现文档将 end_session_endpoint 声明为 https://<your-casdoor>/api/logout,因此支持 RP 发起的注销的 OIDC 库无需配置即可识别该端点。根据规范要求,该端点接受 GET 和 POST 请求。

简而言之,Casdoor 如何处理该请求(RP-initiated logout reference 包含所有分支细节):

  • 带有 id_token_hint 时,Casdoor 查找令牌,向用户的应用程序发送后端通道注销令牌,使令牌过期(其刷新令牌也随之失效),清除浏览器的 Casdoor 会话,并重定向到 post_logout_redirect_uri 并附带 state。
  • 没有 id_token_hint 时,Casdoor 注销当前浏览器会话并使其中颁发的令牌过期。如果浏览器中没有剩余会话,只要知道应用信息(来自会话或 client_id),它仍然会进行重定向。
  • 没有 post_logout_redirect_uri 时,端点返回 JSON 响应而不是重定向。

Example: sign-out button in a browser app​

使用授权码流程和 PKCE 登录的单页应用在令牌交换后保留 ID 令牌。注销处理程序首先清除本地状态,然后将浏览器交给 Casdoor:

const ISSUER = "https://door.example.com";
const CLIENT_ID = "your-client-id";

async function signOut() {
const idToken = sessionStorage.getItem("id_token");

const discovery = await fetch(`${ISSUER}/.well-known/openid-configuration`)
.then((r) => r.json());

// 1. Forget the user locally, whatever happens next
sessionStorage.clear();

// 2. Remember a state value to check when Casdoor sends the browser back
const state = crypto.randomUUID();
sessionStorage.setItem("logout_state", state);

// 3. End the Casdoor session
const url = new URL(discovery.end_session_endpoint);
if (idToken) {
url.searchParams.set("id_token_hint", idToken);
} else {
url.searchParams.set("client_id", CLIENT_ID);
}
url.searchParams.set("post_logout_redirect_uri", "https://app.example.com/signed-out");
url.searchParams.set("state", state);
window.location.assign(url.toString());
}

// On https://app.example.com/signed-out
const returned = new URLSearchParams(window.location.search).get("state");
if (returned !== sessionStorage.getItem("logout_state")) {
console.warn("Unexpected logout redirect");
}
sessionStorage.removeItem("logout_state");

在 Casdoor 中将 https://app.example.com/signed-out 添加到应用的 Redirect URLs,否则 Casdoor 会返回错误而不进行重定向。服务器渲染的应用在其注销路由中执行相同操作:销毁服务器会话,然后以指向结束会话 URL 的 302 响应。有关 Spring Security 的信息,请参阅 Spring Boot SSO post。

Back-channel logout: telling the other apps​

结束 Casdoor 会话可阻止新的静默登录,但应用 B 仍持有其一小时前的自身会话 Cookie。必须有某种机制通知应用 B。OpenID Connect Back-Channel Logout 1.0 实现了这种服务器对服务器的通信:提供商向每个应用注册的后端通道 URI 发送一个 POST 请求,其中包含表单参数 logout_token,这是一个指定了用户身份的签名 JWT。

在 Casdoor 中,在应用的编辑页面设置 Backchannel logout URL。当用户通过 /api/logout 注销时,Casdoor 会向每个持有该用户活动令牌且设置了此 URL 的应用程序发送注销令牌。该令牌使用应用证书签名(与其 ID 令牌使用相同的密钥),并包含:

  • iss 和 aud:与应用 ID 令牌相同的发行者和客户端 ID
  • sub:用户 ID,与 ID 令牌中的 sub 值相同
  • iat 和 jti
  • events,成员为 http://schemas.openid.net/event/backchannel-logout
  • sid 仅在某些注销场景中出现,例如 SSO logout,而在 /api/logout 中不会出现

Casdoor's ID tokens don't carry a sid claim, so key your local sessions by sub and end all of them for that user. The logout token expires two minutes after iat, so a validator that requires exp, as the spec does, accepts it. Casdoor sends each token once, with a 30-second timeout, and doesn't retry. For applications outside the built-in organization, it refuses to call loopback and link-local addresses, so http://localhost won't receive logout tokens during development.

Example: a back-channel logout receiver​

这个 Express 处理程序遵循规范的验证步骤,使用 jose 库:

import express from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";

// Take both values from the discovery document of your Casdoor
const ISSUER = "https://door.example.com";
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks`));
const CLIENT_ID = process.env.CASDOOR_CLIENT_ID;
const EVENT = "http://schemas.openid.net/event/backchannel-logout";

const seenJti = new Map();
const app = express();

app.post(
"/oidc/backchannel-logout",
express.urlencoded({ extended: false }),
async (req, res) => {
res.set("Cache-Control", "no-store");
try {
const { payload } = await jwtVerify(req.body.logout_token, JWKS, {
issuer: ISSUER,
audience: CLIENT_ID,
maxTokenAge: "2m",
});

if (!payload.events || typeof payload.events[EVENT] !== "object") {
throw new Error("not a logout token");
}
if (!payload.sub && !payload.sid) {
throw new Error("no sub or sid");
}
if ("nonce" in payload) {
throw new Error("nonce is not allowed");
}
if (seenJti.has(payload.jti)) {
throw new Error("replayed token");
}
seenJti.set(payload.jti, Date.now());

// Your own helper: delete every session row that belongs to this user
await destroySessionsForUser(payload.sub);
res.sendStatus(200);
} catch (err) {
res.status(400).json({ error: "invalid_request", error_description: err.message });
}
}
);

jwtVerify 检查签名、iss、aud,并通过 maxTokenAge 检查 iat 声明。要按用户查找会话,请在登录时将 ID 令牌中的 sub 存储在每个会话记录中。定期清理 seenJti,或者将其存储在 Redis 中。

Revoking refresh tokens​

刷新令牌的生命周期长于创建它的会话。如果你的应用保留了刷新令牌,而注销过程未触及它,则会留下一个可以在数天内铸造新访问令牌的凭证。

带有 id_token_hint 的结束会话调用已经会使该令牌过期,包括刷新令牌。当注销不经过浏览器时(例如在 CLI、桌面或移动应用中,或者当用户仅离开你的应用并保留 Casdoor 会话时),请显式撤销令牌。Casdoor 在其发现文档的 revocation_endpoint 处实现了 RFC 7009:

curl -X POST https://door.example.com/api/login/oauth/revoke \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d token="$REFRESH_TOKEN" \
-d token_type_hint=refresh_token

使用 PKCE 的公共客户端可以在请求体中发送 client_id,而无需提供密钥。撤销刷新令牌也会导致与其一起签发的访问令牌过期。此后,刷新端点会响应 invalid_grant,内省接口返回 "active": false;当 Casdoor 撤销令牌时列出了所有触发此行为的事件。

适用于所有签发 JWT 访问令牌的提供商的一个限制是:仅检查签名和 exp 的 API 会继续接受已撤销的访问令牌,直到其过期。请保持访问令牌的有效期较短,或者在 API 需要立即感知登出操作时调用 令牌内省。

跨多个应用的单点登出​

结合上述机制,当用户在应用 A 中点击 "Sign out" 时,单点登出的流程如下:

  1. 应用 A 清除其会话,并重定向到 /api/logout,同时携带 id_token_hint。
  2. Casdoor 向该用户其他应用的后端通道 URL 发送登出令牌,使令牌过期,并结束浏览器中的 Casdoor 会话。
  3. 应用 B 和 C 收到登出令牌后,删除与该 sub 关联的会话。
  4. 浏览器最终落在应用 A 的已登出页面。此时打开应用 B,将显示 Casdoor 登录页面。

没有后端通道 URL 的应用在其自身会话或访问令牌过期之前仍保持登录状态。如果无法为某个应用添加接收器,请缩短其会话时长,或使其在每个请求中验证令牌。

对于“在所有地方登出我”(例如笔记本电脑丢失后)的场景,请使用 Casdoor 的 SSO 登出端点,并设置 logoutAll=true:它将使该用户的所有令牌过期,删除所有会话,并通过通知提供商向不支持 OIDC 的应用发送签名通知。管理员可以在 Sessions page 上结束单个会话。对于 SAML 应用,请参阅 SAML single logout。背景阅读:www.casdoor.com 上的 single logout 和 SAML single logout。

参考资料​