b6f7f73
ZhenYi
refactor: reorganize configuration files and update module paths for improved structure
b6f7f73
//! 单点登出:`GET|POST /api/v1/oauth/logout`(RP-Initiated Logout)+ 反向通道通知。
//!
//! 一个 RP 发起点登出,认证中心会:
//! 1. 撤销当前会话(`user_sessions.revoked_at`)
//! 2. 撤销该会话在所有客户端下的刷新令牌
//! 3. 向注册了 `backchannel_logout_uris` 的客户端 POST `logout_token`
//! 4. 302 回 `post_logout_redirect_uri`(必须已注册)
use std::time::Duration;
use actix_web::{HttpRequest, HttpResponse, web};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::server::AuthSvc;
use crate::session::{RequestId, Session};
/// 反向通道注销的 events 键(OIDC Back-Channel Logout 1.0 §2.4)。
const BACKCHANNEL_EVENT: &str = "http://schemas.openid.net/event/backchannel-logout";
#[derive(Debug, Default, Deserialize)]
pub struct LogoutRequest {
pub id_token_hint: Option<String>,
pub post_logout_redirect_uri: Option<String>,
pub state: Option<String>,
pub client_id: Option<String>,
}
/// id_token_hint 只取定位客户端与会话需要的字段;签名必须有效,过期仍然接受(规范允许)。
#[derive(Deserialize)]
struct HintClaims {
aud: String,
#[serde(default)]
sub: Option<String>,
#[serde(default)]
sid: Option<String>,
}
/// hint 必须绑定到当前会话(`sid` 或 `sub` 匹配)才算验证通过;
/// 否则任何人都能拿自己的合法 hint 给受害者发强制登出链接。
fn hint_matches_session(
claims: &HintClaims,
session_id: Option<&str>,
user_id: Option<i64>,
) -> bool {
if let (Some(hint_sid), Some(sid)) = (claims.sid.as_deref(), session_id)
&& hint_sid == sid
{
return true;
}
match (claims.sub.as_deref(), user_id) {
(Some(sub), Some(user_id)) => sub == user_id.to_string(),
_ => false,
}
}
/// GET 且没有验证通过的 hint:返回确认页,确认后 POST 回本端点完成登出。
fn confirm_html(action_query: &str) -> String {
let action = format!("/api/v1/oauth/logout?{action_query}");
maud::html! {
(maud::DOCTYPE)
html lang="zh" {
head { meta charset="utf-8"; title { "确认登出" } }
body {
h1 { "确认登出" }
p { "确认要退出当前登录吗?" }
form method="post" action=(action) {
button type="submit" { "确认登出" }
}
a href="/" { "取消" }
}
}
}
.into_string()
}
fn confirm_page(action_query: &str) -> HttpResponse {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.insert_header(("cache-control", "no-store"))
.body(confirm_html(action_query))
}
#[derive(Serialize)]
struct LogoutToken<'a> {
iss: &'a str,
aud: &'a str,
iat: i64,
exp: i64,
jti: String,
sub: String,
sid: &'a str,
events: serde_json::Value,
}
fn redirect(location: &str) -> HttpResponse {
HttpResponse::Found()
.insert_header(("location", location))
.insert_header(("cache-control", "no-store"))
.insert_header(("referrer-policy", "no-referrer"))
.finish()
}
fn hint_client(svc: &AuthSvc, hint: &str) -> Option<HintClaims> {
crate::jwt::verify_id_token(svc.signing().jwks(), hint).ok()
}
/// `GET|POST /api/v1/oauth/logout` 共用实现(规范两种方法都允许)。
#[utoipa::path(
get,
path = "/api/v1/oauth/logout",
tag = "oauth",
operation_id = "oauth_logout",
summary = "单点登出(RP-Initiated Logout)",
description = "撤销当前会话与其全客户端刷新令牌,并向 `backchannel_logout_uris` 发 `logout_token`;最后回跳已注册的 `post_logout_redirect_uri`。GET 带验证通过的 `id_token_hint`(签名有效且绑定当前会话)时免确认直接登出,否则返回确认页,确认后 POST 完成登出。",
params(
("id_token_hint" = Option<String>, Query, description = "之前签发的 id_token(校验签名且须绑定当前会话)"),
("post_logout_redirect_uri" = Option<String>, Query, description = "必须在客户端注册过"),
("state" = Option<String>, Query, description = "原样回传"),
("client_id" = Option<String>, Query, description = "无 id_token_hint 时可用")
),
responses(
(status = 200, description = "GET 未验证 hint 时返回确认页"),
(status = 302, description = "回到 `post_logout_redirect_uri`,否则回站点首页")
)
)]
#[tracing::instrument(skip_all, fields(request_id = %request_id.as_str()))]
pub async fn logout(
svc: web::Data<AuthSvc>,
session: Session,
request_id: RequestId,
req: HttpRequest,
query: Result<web::Query<LogoutRequest>, actix_web::Error>,
) -> HttpResponse {
let Ok(query) = query else {
return redirect("/");
};
// 0. 会话与用户先取出来:登出会清 cookie,之后就读不到了。
let session_id = session
.get::<String>("auth_session_id")
.ok()
.flatten()
.and_then(|value| Uuid::parse_str(&value).ok());
let user_id = session.user_id().ok().flatten();
// 1. 定位客户端:id_token_hint 优先,其次 client_id。
let hint = query
.id_token_hint
.as_deref()
.and_then(|hint| hint_client(&svc, hint));
let session_key = session_id.map(|id| id.to_string());
let hint_ok = hint
.as_ref()
.is_some_and(|claims| hint_matches_session(claims, session_key.as_deref(), user_id));
// 2. GET 且 hint 未验证通过 → 确认页:防跨站链接强制登出(CSRF)。
// POST 是确认页表单提交(SameSite cookie 挡住跨站自动提交),或已验证的 RP 发起。
if req.method() == actix_web::http::Method::GET && !hint_ok {
return confirm_page(req.query_string());
}
let client = hint
.as_ref()
.map(|claims| claims.aud.clone())
.or_else(|| query.client_id.clone())
.and_then(|id| svc.clients().get(&id));
// 3. 撤销会话与刷新令牌。
if let Some(id) = session_id
&& let Err(error) =
crate::server::oauth::refresh::revoke_session(&svc, id, Some(request_id.as_str())).await
{
tracing::warn!(%error, %id, "failed to revoke oauth session tokens during logout");
}
let response = match svc.logout(session, request_id.as_str().to_owned()).await {
Ok(response) => response,
Err(error) => {
tracing::warn!(%error, "logout failed while revoking the session");
return redirect("/");
}
};
// 4. 反向通道通知:只发给注册了端点且当前用户属于该客户端的调用方。
if let (Some(user_id), Some(sid)) = (response.user_id.or(user_id), session_id) {
notify_backchannel(
&svc,
&sid.to_string(),
user_id,
client.as_deref(),
Some(request_id.as_str()),
)
.await;
}
// 5. 回跳:只有注册过的 post_logout_redirect_uri 才允许,否则回首页。
let wildcard = svc.auth().oauth.allow_wildcard_redirect;
if let (Some(target), Some(record)) = (query.post_logout_redirect_uri.as_deref(), &client)
&& record.post_logout_allowed(target, wildcard)
&& let Ok(mut url) = Url::parse(target)
{
if let Some(state) = &query.state {
url.query_pairs_mut().append_pair("state", state);
}
return redirect(url.as_str());
}
redirect("/")
}
#[tracing::instrument(skip_all, fields(session_id = %session_id, user_id = user_id, request_id = %request_id.unwrap_or_default()))]
async fn notify_backchannel(
svc: &AuthSvc,
session_id: &str,
user_id: i64,
client: Option<&crate::server::oauth::client::ClientRecord>,
request_id: Option<&str>,
) {
let targets: Vec<(String, Vec<String>)> = svc
.clients()
.list()
.iter()
.filter(|record| client.is_none_or(|only| only.client_id == record.client_id))
.filter(|record| !record.backchannel_logout_uris.is_empty())
.map(|record| {
(
record.client_id.clone(),
record.backchannel_logout_uris.clone(),
)
})
.collect();
if targets.is_empty() {
return;
}
let now = crate::jwt::now_secs();
let http = match reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
{
Ok(client) => client,
Err(error) => {
tracing::warn!(%error, "backchannel logout client could not be built");
return;
}
};
for (client_id, uris) in targets {
let claims = LogoutToken {
iss: svc.signing().issuer(),
aud: &client_id,
iat: now,
exp: now + 120,
jti: Uuid::now_v7().to_string(),
sub: user_id.to_string(),
sid: session_id,
events: serde_json::json!({ BACKCHANNEL_EVENT: {} }),
};
let token = match svc.signing().sign_id_token(&claims) {
Ok(token) => token,
Err(error) => {
tracing::warn!(%error, %client_id, "backchannel logout token could not be signed");
continue;
}
};
for uri in uris {
let result = http
.post(&uri)
.form(&[("logout_token", token.as_str())])
.send()
.await;
match result {
Ok(response) if response.status().is_success() => {
tracing::info!(%client_id, %uri, "backchannel logout delivered");
}
Ok(response) => {
tracing::warn!(%client_id, %uri, status = %response.status(), "backchannel logout rejected");
}
Err(error) => {
tracing::warn!(%client_id, %uri, %error, "backchannel logout failed");
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn claims(sub: Option<&str>, sid: Option<&str>) -> HintClaims {
HintClaims {
aud: "api".into(),
sub: sub.map(str::to_owned),
sid: sid.map(str::to_owned),
}
}
#[test]
fn hint_must_bind_to_the_current_session() {
assert!(hint_matches_session(
&claims(None, Some("s1")),
Some("s1"),
Some(7)
));
assert!(hint_matches_session(
&claims(Some("7"), None),
Some("s1"),
Some(7)
));
// 不同会话/不同用户:验证不通过,必须走确认页。
assert!(!hint_matches_session(
&claims(None, Some("s2")),
Some("s1"),
Some(7)
));
assert!(!hint_matches_session(
&claims(Some("8"), None),
Some("s1"),
Some(7)
));
// 非法输入:hint 或会话缺少绑定字段。
assert!(!hint_matches_session(
&claims(None, None),
Some("s1"),
Some(7)
));
assert!(!hint_matches_session(
&claims(Some("7"), Some("s1")),
None,
None
));
}
#[test]
fn confirm_page_reposts_the_original_query() {
let html = confirm_html("client_id=api&state=xyz");
assert!(html.contains("method=\"post\""));
assert!(html.contains("/api/v1/oauth/logout?client_id=api&state=xyz"));
}
}