Files
RustProxy/src/basic_auth.rs
T
2026-10-10 23:41:54 +02:00

253 lines
8.4 KiB
Rust

//! Per-vhost RFC 7617 gate. Only password hashes enter desired configuration.
//! ACLs run first; credentials are never cached with content or written to logs.
use anyhow::{ensure, Result};
use argon2::{
password_hash::{phc::PasswordHash, PasswordVerifier},
Argon2,
};
use axum::response::{IntoResponse, Response};
use base64::{engine::general_purpose::STANDARD, Engine};
use http::{HeaderMap, HeaderValue, StatusCode};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
net::IpAddr,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use tokio::sync::Semaphore;
use zeroize::Zeroizing;
fn realm() -> String {
"Restricted".into()
}
fn yes() -> bool {
true
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub enabled: bool,
pub realm: String,
pub require_tls: bool,
pub forward_credentials: bool,
pub users: Vec<User>,
}
impl Default for Settings {
fn default() -> Self {
Self {
enabled: false,
realm: realm(),
require_tls: yes(),
forward_credentials: false,
users: vec![],
}
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct User {
pub username: String,
pub password_hash: String,
}
impl std::fmt::Debug for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("User")
.field("username", &self.username)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub struct Compiled {
settings: Settings,
challenge: HeaderValue,
}
impl Settings {
pub fn compile(&self) -> Result<Option<Compiled>> {
ensure!(
!self.realm.is_empty()
&& self.realm.len() <= 128
&& self
.realm
.bytes()
.all(|c| c >= 32 && c < 127 && c != b'"' && c != b'\\'),
"basic.invalid_realm"
);
ensure!(self.users.len() <= 64, "basic.too_many_users");
let mut names = std::collections::HashSet::new();
for user in &self.users {
validate_username(&user.username)?;
ensure!(names.insert(&user.username), "basic.duplicate_username");
// Bound KDF cost before accepting imported hashes. No arbitrary PHC parameters.
ensure!(
user.password_hash.len() <= 256
&& user
.password_hash
.starts_with("$argon2id$v=19$m=19456,t=2,p=1$"),
"basic.unsupported_hash"
);
let hash = PasswordHash::new(&user.password_hash)
.map_err(|_| anyhow::anyhow!("basic.invalid_hash"))?;
// Match the former 16-character minimum for the PHC-encoded salt.
// phc 0.6 Salt::len() now measures decoded bytes instead.
ensure!(
hash.salt.is_some_and(|s| s.to_salt_string().len() >= 16)
&& hash.hash.is_some_and(|h| h.len() == 32),
"basic.invalid_hash"
);
}
if !self.enabled {
return Ok(None);
}
ensure!(!self.users.is_empty(), "basic.users_required");
Ok(Some(Compiled {
settings: self.clone(),
challenge: format!("Basic realm=\"{}\", charset=\"UTF-8\"", self.realm).parse()?,
}))
}
}
pub fn validate_username(name: &str) -> Result<()> {
ensure!(
!name.is_empty()
&& name.len() <= 128
&& !name.contains(':')
&& !name.chars().any(char::is_control),
"basic.invalid_username"
);
Ok(())
}
#[derive(Default)]
pub struct Limits {
failures: Mutex<HashMap<IpAddr, (Instant, u32)>>,
}
impl Limits {
fn blocked(&self, ip: IpAddr) -> bool {
let mut map = self.failures.lock().unwrap_or_else(|e| e.into_inner());
map.retain(|_, (time, _)| time.elapsed() < Duration::from_secs(60));
map.get(&ip).is_some_and(|(_, count)| *count >= 10)
|| (map.len() >= 4096 && !map.contains_key(&ip))
}
fn failed(&self, ip: IpAddr) {
let mut map = self.failures.lock().unwrap_or_else(|e| e.into_inner());
map.retain(|_, (time, _)| time.elapsed() < Duration::from_secs(60));
if map.len() < 4096 || map.contains_key(&ip) {
let entry = map.entry(ip).or_insert((Instant::now(), 0));
entry.1 = entry.1.saturating_add(1);
}
}
}
fn denied(status: StatusCode, challenge: Option<&HeaderValue>) -> Response {
let mut response = (status, "RustProxy: authentication required\n").into_response();
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("private, no-store"),
);
if let Some(challenge) = challenge {
response
.headers_mut()
.insert("www-authenticate", challenge.clone());
}
if status == StatusCode::TOO_MANY_REQUESTS {
response
.headers_mut()
.insert("retry-after", HeaderValue::from_static("60"));
}
// Custom error pages must not turn an authentication failure into success.
response
.extensions_mut()
.insert(crate::tinyauth::AuthenticationFailure);
response
}
fn credentials(headers: &HeaderMap) -> Option<(String, Zeroizing<String>, HeaderValue)> {
if headers.get_all("authorization").iter().count() != 1 {
return None;
}
let value = headers.get("authorization")?;
let raw = value.to_str().ok()?;
if raw.len() > 1024 {
return None;
}
let (scheme, token) = raw.split_once(' ')?;
if !scheme.eq_ignore_ascii_case("basic")
|| token.is_empty()
|| token.contains(char::is_whitespace)
{
return None;
}
let bytes = Zeroizing::new(STANDARD.decode(token).ok()?);
let decoded = std::str::from_utf8(&bytes).ok()?;
let (name, password) = decoded.split_once(':')?;
if validate_username(name).is_err()
|| password.len() > 128
|| password.chars().any(char::is_control)
{
return None;
}
Some((
name.to_owned(),
Zeroizing::new(password.to_owned()),
value.clone(),
))
}
impl Compiled {
pub async fn authorize(
&self,
headers: &mut HeaderMap,
tls: bool,
ip: IpAddr,
workers: Arc<Semaphore>,
limits: &Limits,
) -> Result<Option<HeaderValue>, Response> {
let input = credentials(headers);
headers.remove("authorization");
headers.remove("remote-user");
headers.remove("remote-email");
if self.settings.require_tls && !tls {
return Err(denied(StatusCode::FORBIDDEN, None));
}
if limits.blocked(ip) {
return Err(denied(StatusCode::TOO_MANY_REQUESTS, None));
}
let Some((name, password, original)) = input else {
// A browser's first request normally has no Authorization; do not count that as a bad password.
return Err(denied(StatusCode::UNAUTHORIZED, Some(&self.challenge)));
};
let found = self.settings.users.iter().find(|u| u.username == name);
let known = found.is_some();
let encoded = found
.unwrap_or(&self.settings.users[0])
.password_hash
.clone();
let permit = workers
.try_acquire_owned()
.map_err(|_| denied(StatusCode::SERVICE_UNAVAILABLE, None))?;
let verified = tokio::task::spawn_blocking(move || {
let _permit = permit;
PasswordHash::new(&encoded).is_ok()
&& Argon2::default()
.verify_password(password.as_bytes(), encoded.as_str())
.is_ok()
})
.await
.unwrap_or(false);
if !known || !verified {
limits.failed(ip);
return Err(denied(StatusCode::UNAUTHORIZED, Some(&self.challenge)));
}
Ok(self.settings.forward_credentials.then_some(original))
}
pub fn apply_upstream(&self, headers: &mut HeaderMap, verified: Option<HeaderValue>) {
headers.remove("authorization");
headers.remove("remote-user");
headers.remove("remote-email");
if self.settings.forward_credentials {
if let Some(value) = verified {
headers.insert("authorization", value);
}
}
}
}
#[cfg(test)]
include!("basic_auth/tests.rs");