auth.rs
⎇
Raw
1use std::collections::HashMap;
2use std::sync::LazyLock;
3use std::time::{Duration, Instant};
4
5use argon2::Argon2;
6use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
7
8pub const COOKIE_NAME: &str = "fbng_session";
9/// 30 days.
10pub const SESSION_MAX_AGE: u64 = 60 * 60 * 24 * 30;
11
12/// Concurrent Argon2 runs. Each one burns a blocking thread and ~19 MiB, so
13/// a burst of logins must not take the whole pool. Waiters queue here.
14pub(crate) static ARGON2_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
15
16pub fn hash_password(password: &str) -> anyhow::Result<String> {
17 let salt = SaltString::encode_b64(&random_bytes::<16>())
18 .map_err(|e| anyhow::anyhow!("salt encoding failed: {e}"))?;
19 let hash = Argon2::default()
20 .hash_password(password.as_bytes(), &salt)
21 .map_err(|e| anyhow::anyhow!("password hashing failed: {e}"))?;
22 Ok(hash.to_string())
23}
24
25/// [`verify_password`] off the async executor, behind [`ARGON2_SLOTS`].
26///
27/// Argon2 is slow and memory-hungry by design, so it must not run on a tokio
28/// worker. A join failure means the task panicked or the runtime is shutting
29/// down; either way nothing was verified, so the answer is `false`.
30pub async fn verify_password_async(password: &str, hash: &str) -> bool {
31 let (password, hash) = (password.to_string(), hash.to_string());
32 let _slot = ARGON2_SLOTS.acquire().await;
33 tokio::task::spawn_blocking(move || verify_password(&password, &hash))
34 .await
35 .unwrap_or(false)
36}
37
38pub fn verify_password(password: &str, hash: &str) -> bool {
39 let Ok(parsed) = PasswordHash::new(hash) else {
40 return false;
41 };
42 Argon2::default()
43 .verify_password(password.as_bytes(), &parsed)
44 .is_ok()
45}
46
47/// 32 random bytes, hex-encoded (64 chars).
48pub fn random_token() -> String {
49 hex_token(32)
50}
51
52/// 16 random bytes, hex-encoded (32 chars). Used for public share links and
53/// app password secrets.
54pub fn short_token() -> String {
55 hex_token(16)
56}
57
58/// The stored form of an app password secret.
59///
60/// SHA-256, not Argon2: the secret is 128 random bits, so no dictionary
61/// applies. A lookup by hash then replaces a per-request verification.
62pub fn app_password_hash(secret: &str) -> String {
63 use sha2::{Digest, Sha256};
64 crate::hex(&Sha256::digest(secret.as_bytes()))
65}
66
67fn hex_token(bytes: usize) -> String {
68 let mut b = vec![0u8; bytes];
69 getrandom::fill(&mut b).expect("OS random source");
70 crate::hex(&b)
71}
72
73fn random_bytes<const N: usize>() -> [u8; N] {
74 let mut b = [0u8; N];
75 getrandom::fill(&mut b).expect("OS random source");
76 b
77}
78
79/// Failed logins per name within the last [`FAILURE_WINDOW`].
80/// ponytail: process-wide map, keyed by name. Enough to blunt online guessing
81/// on a single-node deployment; move to the DB if the server is ever scaled out.
82static LOGIN_FAILURES: LazyLock<std::sync::Mutex<HashMap<String, (u32, Instant)>>> =
83 LazyLock::new(Default::default);
84const FAILURE_WINDOW: Duration = Duration::from_secs(15 * 60);
85
86/// How long a login attempt for `name` must wait before it is checked: 0 for
87/// the first few tries, then growing per failure, capped at a few seconds. A
88/// delay rather than a lockout, so an attacker cannot lock a real user out.
89pub fn login_delay(name: &str) -> Duration {
90 let map = LOGIN_FAILURES.lock().unwrap_or_else(|e| e.into_inner());
91 match map.get(&name.to_lowercase()) {
92 Some((n, at)) if at.elapsed() < FAILURE_WINDOW => delay_for(*n),
93 _ => Duration::ZERO,
94 }
95}
96
97/// Wait out [`login_delay`] for `key` before checking a credential.
98pub async fn throttle(key: &str) {
99 let delay = login_delay(key);
100 if !delay.is_zero() {
101 tokio::time::sleep(delay).await;
102 }
103}
104
105pub fn record_login(name: &str, ok: bool) {
106 let mut map = LOGIN_FAILURES.lock().unwrap_or_else(|e| e.into_inner());
107 map.retain(|_, (_, at)| at.elapsed() < FAILURE_WINDOW);
108 let key = name.to_lowercase();
109 if ok {
110 map.remove(&key);
111 } else {
112 let n = map.get(&key).map_or(0, |(n, _)| *n);
113 map.insert(key, (n + 1, Instant::now()));
114 }
115}
116
117fn delay_for(failures: u32) -> Duration {
118 Duration::from_millis(500 * u64::from(failures.saturating_sub(2)).min(10))
119}
120
121// ---------------------------------------------------------------------------
122// Verified HTTP Basic credentials
123// ---------------------------------------------------------------------------
124
125/// How long a verified Basic credential is trusted without re-running Argon2.
126///
127/// Only the WebDAV mount uses Basic, and a mount client re-sends the header on
128/// every request. Each verify costs ~100 ms and one of the four
129/// [`ARGON2_SLOTS`], which real logins queue for too.
130const VERIFIED_TTL: Duration = Duration::from_secs(300);
131
132type CredCache = HashMap<[u8; 32], (i64, Instant)>;
133
134/// Verified credentials: keyed hash of the credential → (subject id, when it
135/// was verified).
136///
137/// ponytail: process-wide map like [`LOGIN_FAILURES`]; move it to the DB if
138/// the server is ever scaled out.
139static VERIFIED: LazyLock<std::sync::Mutex<CredCache>> = LazyLock::new(Default::default);
140
141/// Look a credential up in the cache, falling back to `verify`, which returns
142/// the subject id on success.
143///
144/// `realm` separates the key spaces: `0` for accounts, a share id for that
145/// share's password, so a share password can never satisfy an account lookup.
146pub async fn verify_cached<F, Fut>(realm: i64, name: &str, password: &str, verify: F) -> Option<i64>
147where
148 F: FnOnce() -> Fut,
149 Fut: std::future::Future<Output = Option<i64>>,
150{
151 let key = cache_key(realm, name, password);
152 {
153 let mut map = VERIFIED.lock().unwrap_or_else(|e| e.into_inner());
154 map.retain(|_, (_, at)| at.elapsed() < VERIFIED_TTL);
155 if let Some((id, _)) = map.get(&key) {
156 return Some(*id);
157 }
158 }
159 let id = verify().await?;
160 VERIFIED
161 .lock()
162 .unwrap_or_else(|e| e.into_inner())
163 .insert(key, (id, Instant::now()));
164 Some(id)
165}
166
167/// Drop every cached credential.
168///
169/// Called whenever an account's password, active flag or roots change. Without
170/// it a changed password would keep working on an open mount until the entry
171/// aged out.
172pub fn forget_verified() {
173 VERIFIED.lock().unwrap_or_else(|e| e.into_inner()).clear();
174}
175
176/// Drop one subject's cached credentials.
177///
178/// The self-service credential routes use this rather than [`forget_verified`].
179/// Any user can reach them, and clearing the whole map would make every open
180/// mount on the server pay for Argon2 again.
181///
182/// A share whose id happens to equal `subject` is dropped too, because the
183/// realms share one value space. That costs one extra verification, nothing
184/// more.
185pub fn forget_verified_for(subject: i64) {
186 VERIFIED
187 .lock()
188 .unwrap_or_else(|e| e.into_inner())
189 .retain(|_, (id, _)| *id != subject);
190}
191
192/// A keyed hash of the credential, never the credential itself. The pepper is
193/// fresh per process, so a dump of the map alone yields no passwords.
194fn cache_key(realm: i64, name: &str, password: &str) -> [u8; 32] {
195 use sha2::{Digest, Sha256};
196 static PEPPER: LazyLock<[u8; 32]> = LazyLock::new(random_bytes);
197 let mut h = Sha256::new();
198 h.update(*PEPPER);
199 h.update(realm.to_le_bytes());
200 // Length-prefixed, so ("ab", "c") and ("a", "bc") cannot collide.
201 h.update((name.len() as u64).to_le_bytes());
202 h.update(name.as_bytes());
203 h.update(password.as_bytes());
204 h.finalize().into()
205}
206
207/// Parse `Authorization: Basic <base64(name:password)>`.
208pub fn basic_credentials(headers: &axum::http::HeaderMap) -> Option<(String, String)> {
209 use headers::HeaderMapExt as _;
210 use headers::authorization::{Authorization, Basic};
211 let auth = headers.typed_get::<Authorization<Basic>>()?;
212 Some((auth.username().to_string(), auth.password().to_string()))
213}
214
215/// `Max-Age` is `None` for a browser-session cookie.
216fn cookie(name: &str, value: &str, max_age: Option<u64>, https: bool) -> String {
217 let mut c = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
218 if let Some(age) = max_age {
219 c.push_str(&format!("; Max-Age={age}"));
220 }
221 if https {
222 c.push_str("; Secure");
223 }
224 c
225}
226
227pub fn session_cookie(token: &str, https: bool) -> String {
228 cookie(COOKIE_NAME, token, Some(SESSION_MAX_AGE), https)
229}
230
231pub fn clear_session_cookie(https: bool) -> String {
232 cookie(COOKIE_NAME, "", Some(0), https)
233}
234
235/// Cookie name proving that the visitor unlocked share `share_id`.
236///
237/// One cookie per share: a visitor may hold links to several protected
238/// shares, and one shared name would let each unlock evict the last.
239pub fn share_cookie_name(share_id: i64) -> String {
240 format!("fbng_share_{share_id}")
241}
242
243/// Session cookie for an unlocked share. A session cookie (no `Max-Age`), so
244/// the unlock lasts as long as the browser stays open and is not written to
245/// disk.
246pub fn share_cookie(share_id: i64, token: &str, https: bool) -> String {
247 cookie(&share_cookie_name(share_id), token, None, https)
248}
249
250/// Extract the unlock token for `share_id` from the Cookie header.
251pub fn parse_share_cookie(headers: &axum::http::HeaderMap, share_id: i64) -> Option<String> {
252 cookie_value(headers, &share_cookie_name(share_id))
253}
254
255/// Extract the session token from the Cookie header, if present.
256pub fn parse_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
257 cookie_value(headers, COOKIE_NAME)
258}
259
260/// One cookie's value out of the `Cookie` headers. Empty values are treated
261/// as absent: that is how a cleared cookie arrives before it expires.
262fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option<String> {
263 use headers::HeaderMapExt as _;
264 let cookies = headers.typed_get::<headers::Cookie>()?;
265 cookies
266 .iter()
267 .find(|&(k, v)| k == name && !v.is_empty())
268 .map(|(_, v)| v.to_string())
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use axum::http::{HeaderMap, header};
275
276 #[test]
277 fn share_cookie_is_per_share_and_session_scoped() {
278 let c = share_cookie(7, "tok", false);
279 assert!(c.starts_with("fbng_share_7=tok;"));
280 assert!(c.contains("HttpOnly"));
281 // No Max-Age: the unlock must not outlive the browser session.
282 assert!(!c.contains("Max-Age"));
283 assert!(!c.contains("Secure"));
284 assert!(share_cookie(7, "tok", true).contains("Secure"));
285
286 let mut h = HeaderMap::new();
287 h.insert(
288 header::COOKIE,
289 "fbng_share_7=abc; fbng_share_8=def".parse().unwrap(),
290 );
291 assert_eq!(parse_share_cookie(&h, 7).as_deref(), Some("abc"));
292 assert_eq!(parse_share_cookie(&h, 8).as_deref(), Some("def"));
293 assert_eq!(parse_share_cookie(&h, 9), None);
294 }
295
296 #[test]
297 fn password_hash_round_trip() {
298 let h = hash_password("hunter22").unwrap();
299 assert!(verify_password("hunter22", &h));
300 assert!(!verify_password("wrong-password", &h));
301 assert!(!verify_password("hunter23", &h));
302 // Fresh salt on every hash.
303 assert_ne!(h, hash_password("hunter22").unwrap());
304 // Argon2id marker is present.
305 assert!(h.starts_with("$argon2id$"));
306 }
307
308 #[test]
309 fn verify_rejects_garbage_hashes() {
310 assert!(!verify_password("x", ""));
311 assert!(!verify_password("x", "not-a-hash"));
312 assert!(!verify_password("x", "$argon2id$"));
313 }
314
315 #[test]
316 fn token_shapes_and_uniqueness() {
317 let t = random_token();
318 assert_eq!(t.len(), 64);
319 assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
320
321 let s = short_token();
322 assert_eq!(s.len(), 32);
323 assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
324
325 let mut seen = std::collections::HashSet::new();
326 for _ in 0..100 {
327 assert!(seen.insert(random_token()), "session token collision");
328 assert!(seen.insert(short_token()), "share token collision");
329 }
330 }
331
332 #[test]
333 fn login_delay_grows_after_free_tries() {
334 assert_eq!(delay_for(0), Duration::ZERO);
335 assert_eq!(delay_for(2), Duration::ZERO);
336 assert_eq!(delay_for(3), Duration::from_millis(500));
337 assert_eq!(delay_for(6), Duration::from_millis(2000));
338 assert_eq!(delay_for(100), Duration::from_millis(5000));
339
340 let name = "throttle-test-user";
341 assert_eq!(login_delay(name), Duration::ZERO);
342 for _ in 0..4 {
343 record_login(name, false);
344 }
345 assert_eq!(login_delay(name), Duration::from_millis(1000));
346 // Case-insensitive like the account names themselves.
347 assert_eq!(
348 login_delay("THROTTLE-test-USER"),
349 Duration::from_millis(1000)
350 );
351 record_login(name, true);
352 assert_eq!(login_delay(name), Duration::ZERO);
353 }
354
355 #[test]
356 fn session_cookie_shape() {
357 let c = session_cookie("tok123", false);
358 assert!(c.starts_with("fbng_session=tok123;"));
359 assert!(c.contains("Path=/"));
360 assert!(c.contains("HttpOnly"));
361 assert!(c.contains("SameSite=Lax"));
362 assert!(c.contains(&format!("Max-Age={SESSION_MAX_AGE}")));
363 assert!(!c.contains("Secure"));
364
365 let c = session_cookie("tok123", true);
366 assert!(c.ends_with("; Secure"));
367
368 let c = clear_session_cookie(true);
369 assert!(c.starts_with("fbng_session=;"));
370 assert!(c.contains("Max-Age=0"));
371 assert!(c.contains("Secure"));
372 assert!(!clear_session_cookie(false).contains("Secure"));
373 }
374
375 #[test]
376 fn parse_session_cookie_variants() {
377 let mut h = HeaderMap::new();
378 h.insert(
379 header::COOKIE,
380 "other=1; fbng_session=abc123; x=y".parse().unwrap(),
381 );
382 assert_eq!(parse_session_cookie(&h).as_deref(), Some("abc123"));
383
384 let mut h = HeaderMap::new();
385 h.insert(header::COOKIE, "other=1".parse().unwrap());
386 assert_eq!(parse_session_cookie(&h), None);
387
388 // Empty value → treated as absent.
389 let mut h = HeaderMap::new();
390 h.insert(header::COOKIE, "fbng_session=".parse().unwrap());
391 assert_eq!(parse_session_cookie(&h), None);
392
393 assert_eq!(parse_session_cookie(&HeaderMap::new()), None);
394
395 // First occurrence wins.
396 let mut h = HeaderMap::new();
397 h.insert(
398 header::COOKIE,
399 "fbng_session=first; fbng_session=second".parse().unwrap(),
400 );
401 assert_eq!(parse_session_cookie(&h).as_deref(), Some("first"));
402
403 // Cookie name must match exactly.
404 let mut h = HeaderMap::new();
405 h.insert(
406 header::COOKIE,
407 "fbng_session2=x; Xfbng_session=y".parse().unwrap(),
408 );
409 assert_eq!(parse_session_cookie(&h), None);
410 }
411}
412