1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
|
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
}
|