Sign out through the proxy when the proxy signed you in
Sign out cleared ipx's cookies and showed its password page, while Cloudflare Access still vouched for the person: nothing was signed out, and the page looked like the wrong login. /api/me now says, for someone the proxy signed in, where to go instead ([web] sign_out_url, which is /cdn-cgi/access/logout behind Access), and /login sends anyone the proxy vouches for on to their feeds. The header check both use is one function. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
This commit is contained in:
@@ -80,6 +80,10 @@ pub struct Web {
|
||||
pub trusted_proxies: Vec<String>,
|
||||
/// Create an account the first time the proxy vouches for a name it has not seen.
|
||||
pub auto_create_users: bool,
|
||||
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
||||
/// while the proxy still vouches for them, so this is the proxy's own sign-out:
|
||||
/// `/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to /login.
|
||||
pub sign_out_url: String,
|
||||
/// Sign a session out after this long without a request.
|
||||
pub session_days: i64,
|
||||
}
|
||||
@@ -93,6 +97,7 @@ impl Default for Web {
|
||||
trusted_header: String::new(),
|
||||
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
||||
auto_create_users: true,
|
||||
sign_out_url: String::new(),
|
||||
session_days: 30,
|
||||
}
|
||||
}
|
||||
|
||||
64
src/web.rs
64
src/web.rs
@@ -91,23 +91,8 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
||||
let cfg = state.ctx.cfg();
|
||||
let token = cfg.web.token.clone();
|
||||
|
||||
let peer = req
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|c| c.0.ip().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 1. A header, but only from a hop we were told to believe. Anyone able to reach the
|
||||
// port could otherwise send it and be whoever they liked.
|
||||
let vouched = (!cfg.web.trusted_header.is_empty()
|
||||
&& cfg.web.trusted_proxies.iter().any(|p| p == &peer))
|
||||
.then(|| {
|
||||
req.headers()
|
||||
.get(&cfg.web.trusted_header)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(crate::auth::name_from_header)
|
||||
})
|
||||
.flatten();
|
||||
// 1. A header, but only from a hop we were told to believe.
|
||||
let vouched = vouched_name(&cfg, &req);
|
||||
|
||||
let mut set_cookie: Option<String> = None;
|
||||
let mut user = None;
|
||||
@@ -139,6 +124,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
||||
let _ = state.ctx.db.signed_in(u.id);
|
||||
}
|
||||
}
|
||||
let by_proxy = user.is_some();
|
||||
|
||||
// 2. A session cookie from signing in here.
|
||||
if user.is_none() {
|
||||
@@ -189,6 +175,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
||||
};
|
||||
|
||||
req.extensions_mut().insert(user);
|
||||
req.extensions_mut().insert(Proxied(by_proxy));
|
||||
let mut resp = next.run(req).await;
|
||||
if let Some(c) = set_cookie {
|
||||
if let Ok(v) = header::HeaderValue::from_str(&c) {
|
||||
@@ -200,6 +187,29 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
||||
|
||||
const SESSION_COOKIE: &str = "ipx_session";
|
||||
|
||||
/// Whether the proxy signed this request in, rather than a session or the token: signing out
|
||||
/// has to go through the proxy then, or its next request signs the person straight back in.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Proxied(bool);
|
||||
|
||||
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
||||
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
||||
/// be whoever they liked.
|
||||
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> {
|
||||
let peer = req
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|c| c.0.ip().to_string())
|
||||
.unwrap_or_default();
|
||||
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
||||
return None;
|
||||
}
|
||||
req.headers()
|
||||
.get(&cfg.web.trusted_header)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(crate::auth::name_from_header)
|
||||
}
|
||||
|
||||
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
||||
/// reaches a handler without passing through it.
|
||||
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for crate::db::User {
|
||||
@@ -293,8 +303,15 @@ async fn logout(State(state): State<WebState>, req: Request) -> Response {
|
||||
resp
|
||||
}
|
||||
|
||||
async fn me(user: crate::db::User) -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
|
||||
/// Who is signed in, and, for someone the proxy signed in, where Sign out should send them.
|
||||
async fn me(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
axum::Extension(Proxied(by_proxy)): axum::Extension<Proxied>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let url = state.ctx.cfg().web.sign_out_url.clone();
|
||||
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
||||
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin, "sign_out": sign_out }))
|
||||
}
|
||||
|
||||
// ---- accounts: admin only ----
|
||||
@@ -417,8 +434,13 @@ async fn remove_user(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn login_page() -> Html<&'static str> {
|
||||
Html(include_str!("../web/login.html"))
|
||||
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
||||
/// the form only made it look as if they were not.
|
||||
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
||||
if vouched_name(&state.ctx.cfg(), &req).is_some() {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
Html(include_str!("../web/login.html")).into_response()
|
||||
}
|
||||
|
||||
/// The 2004 icon, served once for both pages rather than inlined as base64 into each. The
|
||||
|
||||
Reference in New Issue
Block a user