summaryrefslogtreecommitdiffstats
path: root/src/auth.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/auth.rs')
-rw-r--r--src/auth.rs141
1 files changed, 0 insertions, 141 deletions
diff --git a/src/auth.rs b/src/auth.rs
deleted file mode 100644
index 3926c70..0000000
--- a/src/auth.rs
+++ /dev/null
@@ -1,141 +0,0 @@
-use argon2::{password_hash::PasswordVerifier, Argon2};
-use axum::extract::State;
-use axum::{
- body::Body,
- http::{
- header::{AUTHORIZATION, WWW_AUTHENTICATE},
- HeaderMap, Request, StatusCode,
- },
- response::{IntoResponse, Response},
-};
-use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
-use dashmap::DashMap;
-use password_hash::phc::PasswordHash;
-use std::{
- collections::HashMap,
- fs::File,
- io::{BufRead, BufReader},
- sync::Arc,
- time::{Duration, Instant},
-};
-use tokio::sync::Semaphore;
-use tokio::time::sleep;
-
-fn load_users(path: &str) -> std::io::Result<HashMap<String, String>> {
- let file = File::open(path)?;
- let reader = BufReader::new(file);
-
- let mut users = HashMap::new();
-
- for line in reader.lines() {
- let line = line?;
-
- if let Some((user, hash)) = line.split_once(':') {
- users.insert(user.to_owned(), hash.to_owned());
- }
- }
-
- Ok(users)
-}
-
-#[derive(Clone)]
-pub struct AuthState {
- idle_timeout: Duration,
-
- users: Arc<HashMap<String, String>>, // user -> PHC hash string
- cache: Arc<DashMap<[u8; 32], Instant>>,
- /// Bounds concurrent Argon2 verifications (CPU and ~19 MiB each).
- verify_slots: Arc<Semaphore>,
-}
-
-impl AuthState {
- pub fn new(path: &str) -> Self {
- let users = load_users(path).unwrap_or_default();
-
- Self {
- idle_timeout: Duration::from_secs(300),
- cache: Arc::new(DashMap::new()),
- users: Arc::new(users),
- verify_slots: Arc::new(Semaphore::new(2)),
- }
- }
-}
-
-fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> {
- let encoded = headers
- .get(AUTHORIZATION)?
- .to_str()
- .ok()?
- .strip_prefix("Basic ")?;
- let decoded = String::from_utf8(BASE64.decode(encoded).ok()?).ok()?;
- let (user, pass) = decoded.split_once(':')?;
- Some((user.to_owned(), pass.to_owned()))
-}
-
-async fn authenticate(state: &AuthState, headers: &HeaderMap) -> bool {
- let Some((user, pass)) = parse_basic(headers) else {
- return false;
- };
- let Some(hash) = state.users.get(&user).cloned() else {
- return false;
- };
-
- let key = *blake3::hash(format!("{user}\0{pass}\0{hash}").as_bytes()).as_bytes();
-
- // Fast path. The shard guard lives only until the end of this `match`;
- // never call into the map while holding it.
- let fresh = match state.cache.get_mut(&key) {
- Some(mut last_seen) if last_seen.elapsed() < state.idle_timeout => {
- *last_seen = Instant::now(); // slide
- true
- }
- _ => false,
- };
- if fresh {
- return true;
- }
-
- // Slow path: Argon2 is deliberately expensive, so keep it off the runtime
- // threads and bound how many run at once.
- let Ok(_permit) = state.verify_slots.acquire().await else {
- return false;
- };
-
- let verified = tokio::task::spawn_blocking(move || {
- PasswordHash::new(&hash)
- .map(|ph| {
- Argon2::default()
- .verify_password(pass.as_bytes(), &ph)
- .is_ok()
- })
- .unwrap_or(false)
- })
- .await
- .unwrap_or(false);
-
- if verified {
- // Overwrites an expired entry; no separate remove needed.
- state.cache.insert(key, Instant::now());
- }
-
- verified
-}
-
-pub async fn basic_auth(
- State(state): State<AuthState>,
- headers: HeaderMap,
- request: Request<Body>,
- next: axum::middleware::Next,
-) -> Response {
- if !authenticate(&state, &headers).await {
- sleep(Duration::from_millis(1500)).await;
- return (
- StatusCode::UNAUTHORIZED,
- [(WWW_AUTHENTICATE, r#"Basic realm="Restricted""#)],
- "Unauthorized",
- )
- .into_response();
- }
-
- next.run(request).await
-}