diff options
Diffstat (limited to 'src/api/auth.rs')
| -rw-r--r-- | src/api/auth.rs | 173 |
1 files changed, 156 insertions, 17 deletions
diff --git a/src/api/auth.rs b/src/api/auth.rs index d58640e..48ec02d 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,10 +1,10 @@ use anyhow::Context; use argon2::{Argon2, password_hash::PasswordVerifier}; -use axum::extract::State; +use axum::extract::{ConnectInfo, State}; use axum::{ body::Body, http::{ - HeaderMap, Request, StatusCode, + HeaderMap, HeaderName, Request, StatusCode, header::{AUTHORIZATION, WWW_AUTHENTICATE}, }, response::{IntoResponse, Response}, @@ -16,6 +16,7 @@ use std::{ collections::HashMap, fs::File, io::{BufRead, BufReader}, + net::{IpAddr, SocketAddr}, sync::Arc, time::{Duration, Instant}, }; @@ -49,26 +50,111 @@ fn load_users(path: &str) -> anyhow::Result<HashMap<String, String>> { Ok(users) } +/// Who may use the HTTP API: the users of an htpasswd file (basic authentication), +/// and the user a reverse proxy in front names (`--user-header`), if it is one of +/// `--trusted-proxies`. #[derive(Clone)] pub(crate) struct AuthState { idle_timeout: Duration, - users: Arc<HashMap<String, String>>, // user -> PHC hash string + /// None without an htpasswd file: then only the proxy's users. + users: Option<Arc<HashMap<String, String>>>, // user -> PHC hash string + proxy: Option<Arc<ProxyAuth>>, cache: Arc<DashMap<[u8; 32], Instant>>, /// Bounds concurrent Argon2 verifications (CPU and ~19 MiB each). verify_slots: Arc<Semaphore>, } -impl AuthState { - pub(crate) fn new(path: &str) -> anyhow::Result<Self> { - let users = load_users(path)?; +/// A reverse proxy authenticates, and names the user in `header`. +struct ProxyAuth { + header: HeaderName, + trusted: Vec<Range>, +} + +/// An address, or the addresses sharing its first `bits` bits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Range { + address: IpAddr, + bits: u32, +} + +impl Range { + fn parse(text: &str) -> anyhow::Result<Self> { + let (address, bits) = match text.trim().split_once('/') { + Some((address, bits)) => (address, Some(bits)), + None => (text.trim(), None), + }; + let address: IpAddr = address + .parse() + .with_context(|| format!("trusted-proxies: {text:?} is no IP address"))?; + let all = if address.is_ipv4() { 32 } else { 128 }; + let bits = match bits { + None => all, + Some(bits) => bits + .parse() + .ok() + .filter(|bits| *bits <= all) + .with_context(|| format!("trusted-proxies: {text:?}: not 0 to {all} bits"))?, + }; + Ok(Self { address, bits }) + } - Ok(Self { + fn contains(&self, address: IpAddr) -> bool { + // An IPv4 address as IPv6 (`::ffff:a.b.c.d`) is that IPv4 address. + let address = match address { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(address, IpAddr::V4), + v4 => v4, + }; + let leading = + |a: u128, b: u128, width: u32| self.bits == 0 || (a ^ b) >> (width - self.bits) == 0; + match (self.address, address) { + (IpAddr::V4(range), IpAddr::V4(address)) => { + leading(u32::from(range).into(), u32::from(address).into(), 32) + } + (IpAddr::V6(range), IpAddr::V6(address)) => { + leading(u128::from(range), u128::from(address), 128) + } + _ => false, + } + } +} + +impl AuthState { + /// From the options; None if there is neither an htpasswd file nor a user header + /// (no users: no HTTP API, for a relay). + pub(crate) fn from_settings( + settings: &crate::config::Settings, + ) -> anyhow::Result<Option<Self>> { + let users = settings + .htpasswd + .as_deref() + .map(load_users) + .transpose()? + .map(Arc::new); + let proxy = settings + .user_header + .as_deref() + .map(|header| { + let header = HeaderName::try_from(header.trim()) + .with_context(|| format!("user-header: {header:?} is no header name"))?; + let trusted = settings + .trusted_proxies + .iter() + .map(|range| Range::parse(range)) + .collect::<anyhow::Result<Vec<_>>>()?; + Ok::<_, anyhow::Error>(Arc::new(ProxyAuth { header, trusted })) + }) + .transpose()?; + if users.is_none() && proxy.is_none() { + return Ok(None); + } + Ok(Some(Self { idle_timeout: Duration::from_secs(300), cache: Arc::new(DashMap::new()), - users: Arc::new(users), + users, + proxy, verify_slots: Arc::new(Semaphore::new(2)), - }) + })) } } @@ -87,10 +173,25 @@ fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> { #[derive(Clone, Debug)] pub(crate) struct AuthenticatedUser(pub(crate) String); +/// The user the trusted proxy the request came from names, if it does. +fn proxied_user(state: &AuthState, headers: &HeaderMap, from: Option<IpAddr>) -> Option<String> { + let proxy = state.proxy.as_ref()?; + let user = headers.get(&proxy.header)?.to_str().ok()?.trim(); + if user.is_empty() { + return None; + } + let trusted = from.is_some_and(|from| proxy.trusted.iter().any(|range| range.contains(from))); + if !trusted { + tracing::debug!(from = ?from, header = %proxy.header, "User header from an address not in trusted-proxies; ignored"); + return None; + } + Some(user.to_owned()) +} + /// The user, if the credentials are valid. async fn authenticate(state: &AuthState, headers: &HeaderMap) -> Option<String> { let (user, pass) = parse_basic(headers)?; - let hash = state.users.get(&user).cloned()?; + let hash = state.users.as_ref()?.get(&user).cloned()?; verify(state, &user, pass, hash).await.then_some(user) } @@ -136,22 +237,60 @@ async fn verify(state: &AuthState, user: &str, pass: String, hash: String) -> bo verified } +/// The user of a request: as the trusted proxy it came from names it, else by basic +/// authentication; `401` if neither. pub(crate) async fn basic_auth( State(state): State<AuthState>, headers: HeaderMap, mut request: Request<Body>, next: axum::middleware::Next, ) -> Response { - let Some(user) = authenticate(&state, &headers).await else { + let from = request + .extensions() + .get::<ConnectInfo<SocketAddr>>() + .map(|ConnectInfo(address)| address.ip()); + let user = match proxied_user(&state, &headers, from) { + Some(user) => Some(user), + None => authenticate(&state, &headers).await, + }; + let Some(user) = user else { sleep(Duration::from_millis(1500)).await; - return ( - StatusCode::UNAUTHORIZED, - [(WWW_AUTHENTICATE, r#"Basic realm="Restricted""#)], - "Unauthorized", - ) - .into_response(); + // A challenge only where basic authentication can answer it. + return match state.users { + Some(_) => ( + StatusCode::UNAUTHORIZED, + [(WWW_AUTHENTICATE, r#"Basic realm="Restricted""#)], + "Unauthorized", + ) + .into_response(), + None => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), + }; }; request.extensions_mut().insert(AuthenticatedUser(user)); next.run(request).await } + +#[cfg(test)] +mod tests { + use super::Range; + + #[test] + fn ranges_hold_their_addresses() { + let range = |text: &str| Range::parse(text).unwrap(); + let ip = |text: &str| text.parse().unwrap(); + assert!(range("127.0.0.1").contains(ip("127.0.0.1"))); + assert!(!range("127.0.0.1").contains(ip("127.0.0.2"))); + assert!(range("10.0.0.0/8").contains(ip("10.1.2.3"))); + assert!(!range("10.0.0.0/8").contains(ip("11.0.0.1"))); + assert!(range("::1").contains(ip("::1"))); + assert!(range("fd00::/8").contains(ip("fd12::1"))); + assert!(range("0.0.0.0/0").contains(ip("192.0.2.1"))); + // IPv4 as IPv6, as a dual-stack listener sees it. + assert!(range("127.0.0.1").contains(ip("::ffff:127.0.0.1"))); + assert!(!range("127.0.0.1").contains(ip("::1"))); + for bad in ["localhost", "10.0.0.0/33", "::/129", "10.0.0.0/x"] { + assert!(Range::parse(bad).is_err(), "{bad}"); + } + } +} |
