253 lines
8.4 KiB
Rust
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");
|