Skip to main content

fedimint_ui_common/
auth.rs

1use axum::extract::{FromRequestParts, Request};
2use axum::http::request::Parts;
3use axum::http::{Method, StatusCode, header};
4use axum::middleware::Next;
5use axum::response::{Redirect, Response};
6use axum_extra::extract::CookieJar;
7use fedimint_core::net::auth::GuardianAuthToken;
8
9use crate::{LOGIN_ROUTE, UiState};
10
11/// Extractor that validates user authentication
12pub struct UserAuth {
13    /// UserAuth is an axum extractor guaranteeing when the admin password was
14    /// verified. This implies we can grant logic holding it access to
15    /// fedimint-core internals that require `GuardianAuthToken`, which is a
16    /// very similar mechanism.
17    pub guardian_auth_token: GuardianAuthToken,
18}
19
20impl UserAuth {
21    fn authenticated() -> Self {
22        Self {
23            guardian_auth_token: GuardianAuthToken::new_unchecked(),
24        }
25    }
26}
27
28/// Middleware that rejects state-changing cross-origin browser requests,
29/// protecting cookie-authenticated UI routes against CSRF.
30///
31/// The auth cookie is `HttpOnly` + `SameSite=Lax`, which already keeps
32/// browsers from attaching it to cross-site POSTs. This adds an independent
33/// layer based on metadata browsers attach automatically:
34///
35/// - `Sec-Fetch-Site` (all modern browsers): only same-origin and
36///   user-initiated (`none`) requests are allowed. Unlike `SameSite=Lax`, this
37///   also rejects requests from sibling subdomains (`same-site`).
38/// - `Origin` (legacy browsers send it on all cross-site POSTs): its authority
39///   must match the request's `Host`.
40///
41/// Requests carrying neither header (curl and other non-browser clients)
42/// pass through; they don't attach cookies ambiently, so CSRF does not
43/// apply to them.
44pub async fn csrf_protection_middleware(
45    request: Request,
46    next: Next,
47) -> Result<Response, StatusCode> {
48    if is_request_origin_allowed(&request) {
49        Ok(next.run(request).await)
50    } else {
51        Err(StatusCode::FORBIDDEN)
52    }
53}
54
55fn is_request_origin_allowed(request: &Request) -> bool {
56    let method = request.method();
57    if method == Method::GET || method == Method::HEAD || method == Method::OPTIONS {
58        return true;
59    }
60
61    let headers = request.headers();
62
63    if let Some(site) = headers.get("sec-fetch-site") {
64        return site
65            .to_str()
66            .is_ok_and(|site| site == "same-origin" || site == "none");
67    }
68
69    if let Some(origin) = headers.get(header::ORIGIN) {
70        // An opaque origin ("null") or a non-http(s) scheme is never
71        // acceptable for a state-changing request
72        let Some(origin_authority) = origin.to_str().ok().and_then(|origin| {
73            origin
74                .strip_prefix("http://")
75                .or_else(|| origin.strip_prefix("https://"))
76        }) else {
77            return false;
78        };
79
80        let Some(host) = headers
81            .get(header::HOST)
82            .and_then(|host| host.to_str().ok())
83            .or_else(|| {
84                request
85                    .uri()
86                    .authority()
87                    .map(|authority| authority.as_str())
88            })
89        else {
90            return false;
91        };
92
93        return origin_authority.eq_ignore_ascii_case(host);
94    }
95
96    true
97}
98
99impl<Api> FromRequestParts<UiState<Api>> for UserAuth
100where
101    Api: Send + Sync + 'static,
102{
103    type Rejection = Redirect;
104
105    async fn from_request_parts(
106        parts: &mut Parts,
107        state: &UiState<Api>,
108    ) -> Result<Self, Self::Rejection> {
109        if !state.requires_auth {
110            return Ok(UserAuth::authenticated());
111        }
112
113        let jar = CookieJar::from_request_parts(parts, state)
114            .await
115            .map_err(|_| Redirect::to(LOGIN_ROUTE))?;
116
117        // Check if the auth cookie exists and has the correct value
118        match jar.get(&state.auth_cookie_name) {
119            Some(cookie) if cookie.value() == state.auth_cookie_value => {
120                Ok(UserAuth::authenticated())
121            }
122            _ => Err(Redirect::to(LOGIN_ROUTE)),
123        }
124    }
125}