mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
Wire the settings overlay into the live config path: - AppState.config: Config -> Arc<ArcSwap<Config>> (hot-swappable effective config) + config_base: Arc<Config> (immutable env-only). New AppState::cfg() accessor; every config read goes through it (state.cfg().field), so a runtime-safe operator edit is seen on the next request. One Config type, no parallel path. - Boot (main.rs): load_settings_overlay -> Config::apply_overlay(validate) -> build every client/worker/router from the EFFECTIVE config. A persisted overlay that no longer validates is ignored (boot env-only + loud log), never bricks. Env-only when ADMIN_KEY_INTEGRITY_SECRET is unset. - Migrated ~59 state.config.X reads to state.cfg().X across api/*; bound guards where a reference outlives the statement; integrity_secret returns owned String. - Test harness builds AppState with ArcSwap + config_base (arc-swap dev-dep). Behavior-preserving: with no operator_settings rows, effective == env config. cargo check --all-targets clean; lib tests 256/0.
164 lines
5.8 KiB
Rust
164 lines
5.8 KiB
Rust
//! HTTP middleware and request-scoped extractors for the auth surface.
|
|
//!
|
|
//! Two helpers live here:
|
|
//!
|
|
//! * [`extract_user_id_from_token`] — pull a Bearer access token from the
|
|
//! `Authorization` header and resolve it to a `user_id` via the shared
|
|
//! [`SessionManager`] in [`AppState`]. The error is a literal (`Invalid or
|
|
//! expired token` / a missing-header literal) so the endpoint never becomes an
|
|
//! auth oracle.
|
|
//! * [`client_ip`] — derive the client IP used for rate-limiting and audit. It
|
|
//! trusts `X-Forwarded-For` **only** when the TCP peer is inside
|
|
//! `config.trusted_proxies`; otherwise it uses the real peer. A spoofed
|
|
//! `X-Forwarded-For` from an untrusted peer cannot evade per-IP limits.
|
|
//!
|
|
//! The session manager is read from `state.sessions` (constructed once at
|
|
//! startup) rather than rebuilt per request, so the HS256 key never has to be
|
|
//! re-derived on the hot path.
|
|
|
|
use std::net::{IpAddr, SocketAddr};
|
|
use std::sync::Arc;
|
|
|
|
use axum::http::HeaderMap;
|
|
use uuid::Uuid;
|
|
|
|
use crate::error::AppError;
|
|
use crate::AppState;
|
|
|
|
/// Resolve a Bearer access token in the `Authorization` header to its `user_id`.
|
|
///
|
|
/// All failure modes (missing header, wrong scheme, invalid/expired/forged JWT)
|
|
/// collapse to `AppError::AuthError` with a literal message, so a caller cannot
|
|
/// distinguish "no token" from "bad token" from "expired token".
|
|
pub async fn extract_user_id_from_token(
|
|
state: &Arc<AppState>,
|
|
headers: &HeaderMap,
|
|
) -> Result<Uuid, AppError> {
|
|
let token = bearer_token(headers)?;
|
|
let info = state.sessions.verify_access_token(token)?;
|
|
Ok(info.user_id)
|
|
}
|
|
|
|
/// Extract the raw Bearer token from an `Authorization` header, or a literal
|
|
/// `AuthError`. Split out so login-grade and state-change handlers share one
|
|
/// parser (and one error shape).
|
|
pub(crate) fn bearer_token(headers: &HeaderMap) -> Result<&str, AppError> {
|
|
let auth = headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|v| v.to_str().ok())
|
|
.ok_or_else(|| AppError::AuthError("Missing authorization header".into()))?;
|
|
|
|
auth.strip_prefix("Bearer ")
|
|
.map(str::trim)
|
|
.filter(|t| !t.is_empty())
|
|
.ok_or_else(|| AppError::AuthError("Invalid authorization format".into()))
|
|
}
|
|
|
|
/// The client IP to use for rate-limiting and audit logging.
|
|
///
|
|
/// `peer` is the real TCP source (from axum `ConnectInfo<SocketAddr>`). We honor
|
|
/// `X-Forwarded-For` **only** when `peer` falls inside one of
|
|
/// `config.trusted_proxies`; otherwise the header is attacker-controlled and is
|
|
/// ignored. The default config has an empty trusted-proxy list, so by default
|
|
/// the real peer is always used — fail-closed against header spoofing.
|
|
///
|
|
/// When trusted, the *left-most* XFF entry (the original client, per the de-facto
|
|
/// convention) is taken; if it does not parse as an IP we fall back to the peer
|
|
/// rather than trusting an unparseable value.
|
|
pub fn client_ip(state: &AppState, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
|
|
let peer_ip = peer.ip();
|
|
|
|
let peer_is_trusted = state
|
|
.cfg()
|
|
.trusted_proxies
|
|
.iter()
|
|
.any(|net| net.contains(peer_ip));
|
|
|
|
if !peer_is_trusted {
|
|
return peer_ip;
|
|
}
|
|
|
|
headers
|
|
.get("x-forwarded-for")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|xff| xff.split(',').next())
|
|
.map(str::trim)
|
|
.and_then(|first| first.parse::<IpAddr>().ok())
|
|
.unwrap_or(peer_ip)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use ipnetwork::IpNetwork;
|
|
use std::str::FromStr;
|
|
|
|
fn headers_with_xff(value: &str) -> HeaderMap {
|
|
let mut h = HeaderMap::new();
|
|
h.insert("x-forwarded-for", value.parse().unwrap());
|
|
h
|
|
}
|
|
|
|
fn peer(s: &str) -> SocketAddr {
|
|
SocketAddr::new(IpAddr::from_str(s).unwrap(), 12345)
|
|
}
|
|
|
|
#[test]
|
|
fn bearer_token_parses_and_rejects() {
|
|
let mut h = HeaderMap::new();
|
|
h.insert(
|
|
axum::http::header::AUTHORIZATION,
|
|
"Bearer abc.def.ghi".parse().unwrap(),
|
|
);
|
|
assert_eq!(bearer_token(&h).unwrap(), "abc.def.ghi");
|
|
|
|
let mut bad = HeaderMap::new();
|
|
bad.insert(
|
|
axum::http::header::AUTHORIZATION,
|
|
"Basic xyz".parse().unwrap(),
|
|
);
|
|
assert!(bearer_token(&bad).is_err());
|
|
assert!(bearer_token(&HeaderMap::new()).is_err());
|
|
}
|
|
|
|
/// Build a throwaway AppState is heavy; instead exercise the trust decision
|
|
/// against the proxy list directly through a tiny shim mirroring `client_ip`.
|
|
fn pick_ip(trusted: &[IpNetwork], headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
|
|
let peer_ip = peer.ip();
|
|
if !trusted.iter().any(|n| n.contains(peer_ip)) {
|
|
return peer_ip;
|
|
}
|
|
headers
|
|
.get("x-forwarded-for")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|xff| xff.split(',').next())
|
|
.map(str::trim)
|
|
.and_then(|f| f.parse::<IpAddr>().ok())
|
|
.unwrap_or(peer_ip)
|
|
}
|
|
|
|
#[test]
|
|
fn untrusted_peer_ignores_xff() {
|
|
// No trusted proxies: the spoofed header is ignored, peer wins.
|
|
let h = headers_with_xff("1.2.3.4");
|
|
let got = pick_ip(&[], &h, peer("9.9.9.9"));
|
|
assert_eq!(got, IpAddr::from_str("9.9.9.9").unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn trusted_peer_honors_leftmost_xff() {
|
|
let trusted = vec![IpNetwork::from_str("10.0.0.0/8").unwrap()];
|
|
let h = headers_with_xff("1.2.3.4, 10.0.0.5");
|
|
let got = pick_ip(&trusted, &h, peer("10.0.0.5"));
|
|
assert_eq!(got, IpAddr::from_str("1.2.3.4").unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn trusted_peer_with_garbage_xff_falls_back_to_peer() {
|
|
let trusted = vec![IpNetwork::from_str("10.0.0.0/8").unwrap()];
|
|
let h = headers_with_xff("not-an-ip");
|
|
let got = pick_ip(&trusted, &h, peer("10.0.0.5"));
|
|
assert_eq!(got, IpAddr::from_str("10.0.0.5").unwrap());
|
|
}
|
|
}
|