From 301d7945d79a2fe8f20c03ce61e3830072ce32f0 Mon Sep 17 00:00:00 2001 From: Felix Ableitner Date: Tue, 14 Jan 2025 14:48:25 +0100 Subject: [PATCH] Really fix auth handling for duplicate cookies --- src/backend/api/mod.rs | 44 +------------------- src/backend/api/user.rs | 2 +- src/backend/database/user.rs | 4 +- src/backend/middleware.rs | 78 ++++++++++++++++++++++++++++++++++++ src/backend/mod.rs | 71 +++++++++----------------------- src/common/mod.rs | 2 +- 6 files changed, 103 insertions(+), 98 deletions(-) create mode 100644 src/backend/middleware.rs diff --git a/src/backend/api/mod.rs b/src/backend/api/mod.rs index cb029a9..2b17e12 100644 --- a/src/backend/api/mod.rs +++ b/src/backend/api/mod.rs @@ -13,31 +13,25 @@ use crate::{ search_article, }, instance::{follow_instance, get_instance, resolve_instance}, - user::{get_user, login_user, logout_user, register_user, validate}, + user::{get_user, login_user, logout_user, register_user}, }, database::IbisData, error::MyResult, }, - common::{DbEdit, EditView, GetEditList, LocalUserView, SiteView, AUTH_COOKIE}, + common::{DbEdit, EditView, GetEditList, LocalUserView, SiteView}, }; use activitypub_federation::config::Data; use anyhow::anyhow; use article::{approve_article, delete_conflict}; use axum::{ - body::Body, extract::Query, - http::{Request, StatusCode}, - middleware::{self, Next}, - response::Response, routing::{delete, get, post}, Extension, Json, Router, }; use axum_macros::debug_handler; -use http::header::COOKIE; use instance::list_remote_instances; -use std::collections::HashSet; use user::{count_notifications, list_notifications, update_user_profile}; pub mod article; @@ -70,40 +64,6 @@ pub fn api_routes() -> Router<()> { .route("/account/logout", post(logout_user)) .route("/account/update", post(update_user_profile)) .route("/site", get(site_view)) - .route_layer(middleware::from_fn(auth)) -} - -async fn auth( - data: Data, - mut request: Request, - next: Next, -) -> Result { - // Check all duplicate auth headers and cookies for the first valid one. - // We need to extract cookies manually because CookieJar ignores duplicates. - let cookies = request - .headers() - .get(COOKIE) - .and_then(|h| h.to_str().ok()) - .unwrap_or_default() - .split(';') - .flat_map(|s| s.split_once('=')) - .filter(|s| s.0 == AUTH_COOKIE) - .map(|s| s.1); - let headers = request - .headers() - .get_all(AUTH_COOKIE) - .into_iter() - .filter_map(|h| h.to_str().ok()); - let auth: HashSet<_> = headers.chain(cookies).map(|s| s.to_string()).collect(); - - for a in &auth { - if let Ok(user) = validate(a, &data).await { - request.extensions_mut().insert(user); - break; - } - } - let response = next.run(request).await; - Ok(response) } fn check_is_admin(user: &LocalUserView) -> MyResult<()> { diff --git a/src/backend/api/user.rs b/src/backend/api/user.rs index 0061ce9..36312f9 100644 --- a/src/backend/api/user.rs +++ b/src/backend/api/user.rs @@ -64,7 +64,7 @@ fn generate_login_token(person: &DbPerson, data: &Data) -> MyResult) -> MyResult { +pub async fn validate(jwt: &str, data: &IbisData) -> MyResult { let validation = Validation::default(); let secret = read_jwt_secret(data)?; let key = DecodingKey::from_secret(secret.as_bytes()); diff --git a/src/backend/database/user.rs b/src/backend/database/user.rs index 6b6203c..dd175e1 100644 --- a/src/backend/database/user.rs +++ b/src/backend/database/user.rs @@ -159,7 +159,7 @@ impl DbPerson { Ok(()) } - pub fn read_local_from_name(username: &str, data: &Data) -> MyResult { + pub fn read_local_from_name(username: &str, data: &IbisData) -> MyResult { let mut conn = data.db_pool.get()?; let (person, local_user) = person::table .inner_join(local_user::table) @@ -175,7 +175,7 @@ impl DbPerson { }) } - fn read_following(id_: PersonId, data: &Data) -> MyResult> { + fn read_following(id_: PersonId, data: &IbisData) -> MyResult> { use instance_follow::dsl::{follower_id, instance_id}; let mut conn = data.db_pool.get()?; Ok(instance_follow::table diff --git a/src/backend/middleware.rs b/src/backend/middleware.rs new file mode 100644 index 0000000..6d0b4f9 --- /dev/null +++ b/src/backend/middleware.rs @@ -0,0 +1,78 @@ +use super::api::user::validate; +use crate::{ + backend::database::IbisData, + common::{Auth, AUTH_COOKIE}, +}; +use axum::{body::Body, extract::State, http::Request, middleware::Next, response::Response}; +use axum_macros::debug_middleware; +use http::{header::COOKIE, HeaderValue}; +use std::{collections::HashSet, sync::Arc}; + +pub(super) const FEDERATION_ROUTES_PREFIX: &str = "/federation_routes"; + +/// Checks all headers and cookies (including duplicates) for first valid auth token. +/// We need to extract cookies manually because CookieJar ignores duplicates. +/// If user is authenticated sets extensions `Auth` and `LocalUserView`. +#[debug_middleware] +pub(super) async fn auth_middleware( + State(data): State>, + mut request: Request, + next: Next, +) -> Response { + let headers = request.headers(); + let cookies = headers + .get(COOKIE) + .and_then(|h| h.to_str().ok()) + .unwrap_or_default() + .split(';') + .flat_map(|s| s.split_once('=')) + .filter(|s| s.0.trim() == AUTH_COOKIE) + .map(|s| s.1); + let headers = headers + .get_all(AUTH_COOKIE) + .into_iter() + .filter_map(|h| h.to_str().ok()); + let auth: HashSet<_> = headers.chain(cookies).map(|s| s.to_string()).collect(); + + for auth in auth { + if let Ok(local_user) = validate(&auth, &data).await { + request.extensions_mut().insert(Auth(Some(auth))); + request.extensions_mut().insert(local_user); + } + } + next.run(request).await +} + +/// Rewrite federation routes to use `FEDERATION_ROUTES_PREFIX`, to avoid conflicts +/// with frontend routes. If a request is an Activitypub fetch as indicated by +/// `Accept: application/activity+json` header, use the federation routes. Otherwise +/// leave the path unchanged so it can go to frontend. +#[debug_middleware] +pub(super) async fn federation_routes_middleware(request: Request, next: Next) -> Response { + let (mut parts, body) = request.into_parts(); + // rewrite uri based on accept header + let mut uri = parts.uri.to_string(); + const VALUE1: HeaderValue = HeaderValue::from_static("application/activity+json"); + const VALUE2: HeaderValue = HeaderValue::from_static( + r#"application/ld+json; profile="https://www.w3.org/ns/activitystreams""#, + ); + let accept = parts.headers.get("Accept"); + let content_type = parts.headers.get("Content-Type"); + if Some(&VALUE1) == accept + || Some(&VALUE2) == accept + || Some(&VALUE1) == content_type + || Some(&VALUE2) == content_type + { + uri = format!("{FEDERATION_ROUTES_PREFIX}{uri}"); + } + // drop trailing slash + if uri.ends_with('/') && uri.len() > 1 { + uri.pop(); + } + parts.uri = uri + .parse() + .expect("can parse uri after dropping trailing slash"); + let request = Request::from_parts(parts, body); + + next.run(request).await +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 719eb2b..ed0d844 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -13,7 +13,6 @@ use crate::{ DbInstance, DbPerson, EditVersion, - AUTH_COOKIE, MAIN_PAGE_NAME, }, frontend::app::{shell, App}, @@ -27,15 +26,14 @@ use assets::file_and_error_handler; use axum::{ body::Body, extract::State, - http::{HeaderValue, Request}, - middleware::Next, + http::Request, + middleware::from_fn_with_state, response::{IntoResponse, Response}, routing::get, + Extension, Router, ServiceExt, }; -use axum_extra::extract::CookieJar; -use axum_macros::debug_middleware; use chrono::Utc; use diesel::{ r2d2::{ConnectionManager, Pool}, @@ -49,7 +47,8 @@ use federation::objects::{ use leptos::prelude::*; use leptos_axum::{generate_route_list, LeptosRoutes}; use log::info; -use std::{net::SocketAddr, thread}; +use middleware::{auth_middleware, federation_routes_middleware, FEDERATION_ROUTES_PREFIX}; +use std::{net::SocketAddr, sync::Arc, thread}; use tokio::{net::TcpListener, sync::oneshot}; use tower_http::{compression::CompressionLayer, cors::CorsLayer}; use tower_layer::Layer; @@ -61,14 +60,13 @@ pub mod config; pub mod database; pub mod error; pub mod federation; +mod middleware; mod nodeinfo; mod scheduled_tasks; mod utils; const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); -const FEDERATION_ROUTES_PREFIX: &str = "/federation_routes"; - pub async fn start( config: IbisConfig, override_hostname: Option, @@ -83,11 +81,11 @@ pub async fn start( .get()? .run_pending_migrations(MIGRATIONS) .expect("run migrations"); - let data = IbisData { db_pool, config }; + let ibis_data = IbisData { db_pool, config }; let data = FederationConfig::builder() - .domain(data.config.federation.domain.clone()) - .url_verifier(Box::new(VerifyUrlData(data.config.clone()))) - .app_data(data) + .domain(ibis_data.config.federation.domain.clone()) + .url_verifier(Box::new(VerifyUrlData(ibis_data.config.clone()))) + .app_data(ibis_data.clone()) .http_fetch_limit(1000) .debug(cfg!(debug_assertions)) .build() @@ -111,6 +109,7 @@ pub async fn start( let routes = generate_route_list(App); let config = data.clone(); + let arc_data = Arc::new(ibis_data); let app = Router::new() .leptos_routes_with_handler(routes, get(leptos_routes_handler)) .fallback(file_and_error_handler) @@ -120,7 +119,8 @@ pub async fn start( .nest("", nodeinfo::config()) .layer(FederationMiddleware::new(config)) .layer(CorsLayer::permissive()) - .layer(CompressionLayer::new()); + .layer(CompressionLayer::new()) + .route_layer(from_fn_with_state(arc_data.clone(), auth_middleware)); // Rewrite federation routes // https://docs.rs/axum/0.7.4/axum/middleware/index.html#rewriting-request-uri-in-middleware @@ -139,19 +139,20 @@ pub async fn start( /// Make auth token available in hydrate mode async fn leptos_routes_handler( - jar: CookieJar, + auth: Option>, State(leptos_options): State, - req: Request, + request: Request, ) -> Response { let handler = leptos_axum::render_app_async_with_context( move || { - let cookie = jar.get(AUTH_COOKIE).map(|c| c.value().to_string()); - provide_context(Auth(cookie)); + if let Some(auth) = &auth { + provide_context(auth.0.clone()); + } }, move || shell(leptos_options.clone()), ); - handler(req).await.into_response() + handler(request).await.into_response() } const MAIN_PAGE_DEFAULT_TEXT: &str = "Welcome to Ibis, the federated Wikipedia alternative! @@ -215,37 +216,3 @@ async fn setup(data: &Data) -> Result<(), Error> { Ok(()) } - -/// Rewrite federation routes to use `FEDERATION_ROUTES_PREFIX`, to avoid conflicts -/// with frontend routes. If a request is an Activitypub fetch as indicated by -/// `Accept: application/activity+json` header, use the federation routes. Otherwise -/// leave the path unchanged so it can go to frontend. -#[debug_middleware] -async fn federation_routes_middleware(request: Request, next: Next) -> Response { - let (mut parts, body) = request.into_parts(); - // rewrite uri based on accept header - let mut uri = parts.uri.to_string(); - const VALUE1: HeaderValue = HeaderValue::from_static("application/activity+json"); - const VALUE2: HeaderValue = HeaderValue::from_static( - r#"application/ld+json; profile="https://www.w3.org/ns/activitystreams""#, - ); - let accept = parts.headers.get("Accept"); - let content_type = parts.headers.get("Content-Type"); - if Some(&VALUE1) == accept - || Some(&VALUE2) == accept - || Some(&VALUE1) == content_type - || Some(&VALUE2) == content_type - { - uri = format!("{FEDERATION_ROUTES_PREFIX}{uri}"); - } - // drop trailing slash - if uri.ends_with('/') && uri.len() > 1 { - uri.pop(); - } - parts.uri = uri - .parse() - .expect("can parse uri after dropping trailing slash"); - let request = Request::from_parts(parts, body); - - next.run(request).await -} diff --git a/src/common/mod.rs b/src/common/mod.rs index 10ca752..406d09d 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -25,7 +25,7 @@ pub const MAIN_PAGE_NAME: &str = "Main_Page"; pub static AUTH_COOKIE: &str = "auth"; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct Auth(pub Option); /// Should be an enum Title/Id but fails due to https://github.com/nox/serde_urlencoded/issues/66