2c6062a53e
The /web/ prefix predated the /api/ split; today it just clutters every URL
without disambiguating anything. All 16 browser routes move to the apex
(/cases, /login, /magic, /events, /audio/...). The 6 /api/* routes are
unchanged. A new /->>/cases redirect closes the apex 404.
The open-redirect guard in magic.rs and case_actions.rs flips from a
positive whitelist (starts_with("/web/")) to a deny-list: same-origin path,
not protocol-relative, not under /api/, no \. The /api/ exclusion is now
load-bearing and covered by tests.
Pre-production: no transition redirects.
85 lines
2.8 KiB
Rust
85 lines
2.8 KiB
Rust
//! Session cookie extraction + login flows.
|
||
//!
|
||
//! These were previously copy-pasted 4–6 times across the integration
|
||
//! tests. Centralising them here means: a change to the cookie format
|
||
//! or the login form field names affects one file, not six.
|
||
|
||
use axum::Router;
|
||
use axum::body::Body;
|
||
use axum::http::{Request, header};
|
||
use axum::response::Response;
|
||
use tower::util::ServiceExt;
|
||
|
||
use doctate_server::web_session::SessionStore;
|
||
|
||
use crate::common::paths;
|
||
|
||
/// Extract the freshly-minted `session=<value>` cookie from a login
|
||
/// response's `Set-Cookie` headers. Returns the whole `name=value` pair
|
||
/// (no attributes like `; Path=/`) so it can be sent back verbatim in a
|
||
/// `Cookie` header.
|
||
pub fn extract_session_cookie(resp: &Response) -> Option<String> {
|
||
for v in resp.headers().get_all(header::SET_COOKIE).iter() {
|
||
let s = v.to_str().ok()?;
|
||
if let Some(pair) = s.split(';').next()
|
||
&& pair.starts_with("session=")
|
||
{
|
||
return Some(pair.to_string());
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Build the `POST /login` request with a URL-encoded form body.
|
||
/// Pulled out so tests that need to *inspect* the login response
|
||
/// (e.g. session-fixation tests) can reuse the exact wire shape.
|
||
pub fn login_request(slug: &str, password: &str) -> Request<Body> {
|
||
let body = format!("slug={slug}&password={password}");
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri(paths::LOGIN)
|
||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||
.body(Body::from(body))
|
||
.unwrap()
|
||
}
|
||
|
||
/// Log in, return the resulting `session=...` cookie string.
|
||
///
|
||
/// Panics if the server didn't set a session cookie — meaning the
|
||
/// credentials were wrong or the route shape has changed. Tests that
|
||
/// deliberately exercise the failure path should use [`login_request`]
|
||
/// directly.
|
||
pub async fn login(app: &Router, slug: &str, password: &str) -> String {
|
||
let resp = app
|
||
.clone()
|
||
.oneshot(login_request(slug, password))
|
||
.await
|
||
.unwrap();
|
||
extract_session_cookie(&resp).expect("login did not set a session cookie")
|
||
}
|
||
|
||
/// Log in and additionally fetch the session's CSRF token directly from
|
||
/// the session store. Pairs with
|
||
/// `doctate_server::create_router_and_session_store` — the returned
|
||
/// token is what the `CsrfForm<T>` extractor validates on every
|
||
/// state-changing POST under `/`.
|
||
///
|
||
/// Returns `(cookie_header_value, csrf_token)`.
|
||
pub async fn login_with_csrf(
|
||
app: &Router,
|
||
store: &SessionStore,
|
||
slug: &str,
|
||
password: &str,
|
||
) -> (String, String) {
|
||
let cookie = login(app, slug, password).await;
|
||
let token = cookie.trim_start_matches("session=");
|
||
let csrf = store
|
||
.read()
|
||
.await
|
||
.get(token)
|
||
.expect("session must be in the store right after login")
|
||
.csrf_token
|
||
.clone();
|
||
(cookie, csrf)
|
||
}
|