auth.rs
⎇
Raw
1use std::collections::HashMap;
2use std::net::{IpAddr, Ipv6Addr, SocketAddr};
3use std::sync::{LazyLock, Mutex};
4
5use argon2::Argon2;
6use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash};
7use axum::extract::{ConnectInfo, FromRequestParts};
8use axum::http::request::Parts;
9use axum::http::{HeaderMap, header};
10use rusqlite::{OptionalExtension, params};
11use sha2::{Digest, Sha256};
12use tokio::sync::Semaphore;
13
14use crate::{AppState, Error, now};
15
16pub const SESSION_COOKIE: &str = "ot_session";
17pub const SESSION_SECS: i64 = 30 * 86400;
18
19pub fn hash_password(password: &str) -> String {
20 Argon2::default()
21 .hash_password(password.as_bytes())
22 .expect("argon2 with default parameters")
23 .to_string()
24}
25
26pub fn verify_password(password: &str, hash: &str) -> bool {
27 PasswordHash::new(hash).is_ok_and(|h| {
28 Argon2::default()
29 .verify_password(password.as_bytes(), &h)
30 .is_ok()
31 })
32}
33
34pub fn check_new_password(password: &str) -> Result<(), &'static str> {
35 if password.chars().count() < 8 {
36 return Err("password needs at least 8 characters");
37 }
38 Ok(())
39}
40
41/// A random secret for a session cookie or a device token, and the hash to store for it.
42pub fn new_secret() -> (String, Vec<u8>) {
43 let mut bytes = [0u8; 32];
44 getrandom::fill(&mut bytes).expect("OS random number generator");
45 let secret = hex(&bytes);
46 let hash = hash_secret(&secret);
47 (secret, hash)
48}
49
50pub fn hex(bytes: &[u8]) -> String {
51 bytes.iter().map(|b| format!("{b:02x}")).collect()
52}
53
54/// The secrets are 256 random bits, so a fast hash is enough. Argon2 is only for passwords.
55fn hash_secret(secret: &str) -> Vec<u8> {
56 Sha256::digest(secret.as_bytes()).to_vec()
57}
58
59static DUMMY_HASH: LazyLock<String> = LazyLock::new(|| hash_password("not a real password"));
60
61pub struct PasswordOk {
62 pub id: i64,
63 pub two_factor: bool,
64}
65
66/// Checks a username and password. An account without a password always fails.
67pub async fn check_password(
68 state: &AppState,
69 ip: IpAddr,
70 username: &str,
71 password: &str,
72) -> Result<PasswordOk, Error> {
73 let row: Option<(i64, Option<String>, bool)> = state
74 .db()
75 .query_row(
76 "SELECT id, pw_hash, two_factor FROM users WHERE username = ?1",
77 [username],
78 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
79 )
80 .optional()?;
81 let password = password.to_owned();
82 limited(state, ip, username, move || match row {
83 Some((id, Some(hash), two_factor)) => {
84 verify_password(&password, &hash).then_some(PasswordOk { id, two_factor })
85 }
86 _ => {
87 // Do the same work as for a real password, so timing does not reveal which accounts exist.
88 verify_password(&password, &DUMMY_HASH);
89 None
90 }
91 })
92 .await
93}
94
95/// Runs a password check for `name` from `ip` under the rate limits. None from `check` means a wrong password.
96pub async fn limited<T: Send + 'static>(
97 state: &AppState,
98 ip: IpAddr,
99 name: &str,
100 check: impl FnOnce() -> Option<T> + Send + 'static,
101) -> Result<T, Error> {
102 let single = ip_group(ip);
103 let name = name.to_lowercase();
104 let pair = format!("{single} {name}");
105 let any = format!("any {name}");
106 let mut keys = vec![
107 (pair.as_str(), MAX_FAILURES),
108 (single.as_str(), MAX_IP_FAILURES),
109 ];
110 // Guesses from many addresses lock the name for new addresses only, so a stranger cannot lock out the owner.
111 if !state.limiter.known(&pair) {
112 keys.push((&any, MAX_FAILURES));
113 }
114 state.limiter.attempt(&keys)?;
115 match argon(check).await? {
116 Some(v) => {
117 state.limiter.forgive(&pair, &[&single, &any]);
118 Ok(v)
119 }
120 None => Err(Error::Unauthorized),
121 }
122}
123
124/// The limits count an IPv6 /64 as one address, since one client usually holds the whole /64.
125pub fn ip_group(ip: IpAddr) -> String {
126 match ip.to_canonical() {
127 IpAddr::V4(v4) => v4.to_string(),
128 IpAddr::V6(v6) => format!(
129 "{}/64",
130 Ipv6Addr::from(u128::from(v6) & !(u64::MAX as u128))
131 ),
132 }
133}
134
135/// Each Argon2 run takes about 19 MiB. Without a bound, parallel requests could exhaust the memory.
136static ARGON: LazyLock<Semaphore> =
137 LazyLock::new(|| Semaphore::new(std::thread::available_parallelism().map_or(2, |n| n.get())));
138
139/// Runs Argon2 work on the blocking pool, at most one run per CPU at a time.
140pub async fn argon<T: Send + 'static>(
141 work: impl FnOnce() -> T + Send + 'static,
142) -> Result<T, Error> {
143 let _permit = ARGON
144 .acquire()
145 .await
146 .map_err(|e| Error::Internal(e.to_string()))?;
147 tokio::task::spawn_blocking(work)
148 .await
149 .map_err(|e| Error::Internal(e.to_string()))
150}
151
152pub async fn hash_password_async(password: String) -> Result<String, Error> {
153 argon(move || hash_password(&password)).await
154}
155
156fn cookie(state: &AppState, value: &str, max_age: i64) -> String {
157 let secure = if state.https() { "; Secure" } else { "" };
158 format!(
159 "{SESSION_COOKIE}={value}; Path=/; Max-Age={max_age}; HttpOnly; SameSite=Strict{secure}"
160 )
161}
162
163/// Creates a session and returns its Set-Cookie value.
164pub fn create_session(state: &AppState, user_id: i64) -> Result<String, Error> {
165 let (token, hash) = new_secret();
166 let now = now();
167 state.db().execute(
168 "INSERT INTO sessions (token_hash, user_id, expires_at, signed_in_at) VALUES (?1, ?2, ?3, ?4)",
169 params![hash, user_id, now + SESSION_SECS, now],
170 )?;
171 Ok(cookie(state, &token, SESSION_SECS))
172}
173
174pub fn clear_session(state: &AppState) -> String {
175 cookie(state, "", 0)
176}
177
178/// A credential changed. Sessions that existed before must not outlive it.
179pub fn end_other_sessions(state: &AppState, user: &User) -> Result<(), Error> {
180 state.db().execute(
181 "DELETE FROM sessions WHERE user_id = ?1 AND token_hash <> ?2",
182 params![user.id, user.session_hash],
183 )?;
184 Ok(())
185}
186
187/// Wrong passwords per name before a lockout: from one address, and from all addresses together.
188const MAX_FAILURES: u32 = 5;
189/// Wrong passwords per address across all names, so guessing one password for many users is limited too.
190const MAX_IP_FAILURES: u32 = 30;
191const LOCKOUT_SECS: i64 = 15 * 60;
192/// How long an address that passed a password check stays exempt from the lock across all addresses.
193const KNOWN_SECS: i64 = 30 * 86400;
194
195/// Attempts per key. An attempt counts before the check, so parallel requests cannot slip past the limit.
196#[derive(Default)]
197pub struct Limiter {
198 counts: Mutex<HashMap<String, (u32, i64)>>,
199 /// Address and name pairs that passed, with the time.
200 known: Mutex<HashMap<String, i64>>,
201}
202
203impl Limiter {
204 /// Counts one attempt for every key, or none if any key is at its limit.
205 pub fn attempt(&self, keys: &[(&str, u32)]) -> Result<(), Error> {
206 let now = now();
207 let mut map = self.counts.lock().unwrap();
208 let locked = keys.iter().any(|(key, max)| {
209 map.get(*key)
210 .is_some_and(|&(n, since)| n >= *max && now - since < LOCKOUT_SECS)
211 });
212 if locked {
213 return Err(Error::TooManyRequests);
214 }
215 for (key, _) in keys {
216 let entry = map.entry((*key).to_owned()).or_insert((0, now));
217 if now - entry.1 >= LOCKOUT_SECS {
218 *entry = (0, now);
219 }
220 entry.0 += 1;
221 }
222 Ok(())
223 }
224
225 /// The password was right: earlier failures for the pair are forgotten, and this attempt does not count for the other keys.
226 pub fn forgive(&self, pair: &str, others: &[&str]) {
227 let mut map = self.counts.lock().unwrap();
228 map.remove(pair);
229 for key in others {
230 if let Some(entry) = map.get_mut(*key) {
231 entry.0 = entry.0.saturating_sub(1);
232 }
233 }
234 self.known.lock().unwrap().insert(pair.to_owned(), now());
235 }
236
237 pub fn known(&self, pair: &str) -> bool {
238 let known = self.known.lock().unwrap();
239 known.get(pair).is_some_and(|&t| now() - t < KNOWN_SECS)
240 }
241
242 /// Attackers choose the names, so old entries must go.
243 pub fn prune(&self) {
244 let now = now();
245 self.counts
246 .lock()
247 .unwrap()
248 .retain(|_, (_, since)| now - *since < LOCKOUT_SECS);
249 self.known
250 .lock()
251 .unwrap()
252 .retain(|_, t| now - *t < KNOWN_SECS);
253 }
254}
255
256/// The client's address. With `--trusted-proxy`, the last `X-Forwarded-For` entry.
257pub struct ClientIp(pub IpAddr);
258
259/// The last `X-Forwarded-For` entry, across all header lines. The proxy appends the address it saw,
260/// so earlier entries may come from the client. Falls back to the peer, which then is the proxy.
261fn forwarded_ip(peer: IpAddr, headers: &HeaderMap) -> IpAddr {
262 headers
263 .get_all("x-forwarded-for")
264 .iter()
265 .filter_map(|v| v.to_str().ok())
266 .flat_map(|v| v.split(','))
267 .map(str::trim)
268 .rfind(|v| !v.is_empty())
269 .and_then(|v| {
270 v.parse::<IpAddr>()
271 .or_else(|_| v.parse::<SocketAddr>().map(|s| s.ip()))
272 .ok()
273 })
274 .unwrap_or(peer)
275}
276
277impl FromRequestParts<AppState> for ClientIp {
278 type Rejection = Error;
279
280 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
281 let peer = parts
282 .extensions
283 .get::<ConnectInfo<SocketAddr>>()
284 .map(|c| c.0.ip())
285 .ok_or_else(|| Error::Internal("no connect info".into()))?;
286 Ok(ClientIp(match state.trusted_proxy {
287 true => forwarded_ip(peer, &parts.headers),
288 false => peer,
289 }))
290 }
291}
292
293/// A logged-in web user, from the session cookie.
294pub struct User {
295 pub id: i64,
296 pub username: String,
297 pub is_admin: bool,
298 pub session_hash: Vec<u8>,
299 /// When this session proved a password or passkey.
300 pub signed_in_at: i64,
301}
302
303/// How long after sign-in a session may change credentials.
304const RECENT_SECS: i64 = 10 * 60;
305
306impl User {
307 /// Credential changes need a recent sign-in, so a stolen session cannot take over the account.
308 pub fn check_recent(&self) -> Result<(), Error> {
309 if now() - self.signed_in_at > RECENT_SECS {
310 return Err(Error::BadRequest(
311 "log out and log in again before you change this".into(),
312 ));
313 }
314 Ok(())
315 }
316}
317
318impl FromRequestParts<AppState> for User {
319 type Rejection = Error;
320
321 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
322 let token = parts
323 .headers
324 .get_all(header::COOKIE)
325 .iter()
326 .filter_map(|v| v.to_str().ok())
327 .flat_map(|v| v.split(';'))
328 .find_map(|c| c.trim().strip_prefix(SESSION_COOKIE)?.strip_prefix('='))
329 .ok_or(Error::Unauthorized)?;
330 let session_hash = hash_secret(token);
331 let (id, username, is_admin, signed_in_at) = state
332 .db()
333 .query_row(
334 "SELECT u.id, u.username, u.is_admin, s.signed_in_at FROM sessions s JOIN users u ON u.id = s.user_id
335 WHERE s.token_hash = ?1 AND s.expires_at > ?2",
336 params![session_hash, now()],
337 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
338 )
339 .optional()?
340 .ok_or(Error::Unauthorized)?;
341 Ok(User {
342 id,
343 username,
344 is_admin,
345 session_hash,
346 signed_in_at,
347 })
348 }
349}
350
351/// A logged-in admin.
352pub struct Admin(pub User);
353
354impl FromRequestParts<AppState> for Admin {
355 type Rejection = Error;
356
357 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
358 let user = User::from_request_parts(parts, state).await?;
359 if !user.is_admin {
360 return Err(Error::Forbidden);
361 }
362 Ok(Admin(user))
363 }
364}
365
366/// Who uploads points: a device with its `Authorization: Bearer` token, or the web UI with its session.
367pub struct Uploader {
368 pub device_id: i64,
369}
370
371impl FromRequestParts<AppState> for Uploader {
372 type Rejection = Error;
373
374 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
375 if parts.headers.contains_key(header::AUTHORIZATION) {
376 let d = Device::from_request_parts(parts, state).await?;
377 return Ok(Uploader { device_id: d.id });
378 }
379 let user = User::from_request_parts(parts, state).await?;
380 let db = state.db();
381 db.execute(
382 "INSERT OR IGNORE INTO devices (user_id, name, created_at) VALUES (?1, 'Web', ?2)",
383 params![user.id, now()],
384 )?;
385 let device_id = db.query_row(
386 "SELECT id FROM devices WHERE user_id = ?1 AND token_hash IS NULL",
387 [user.id],
388 |r| r.get(0),
389 )?;
390 Ok(Uploader { device_id })
391 }
392}
393
394/// A device, from its `Authorization: Bearer` token.
395pub struct Device {
396 pub id: i64,
397 pub user_id: i64,
398}
399
400impl FromRequestParts<AppState> for Device {
401 type Rejection = Error;
402
403 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
404 let token = parts
405 .headers
406 .get(header::AUTHORIZATION)
407 .and_then(|v| v.to_str().ok())
408 .and_then(|v| v.strip_prefix("Bearer "))
409 .ok_or(Error::Unauthorized)?;
410 let (id, user_id) = state
411 .db()
412 .query_row(
413 "SELECT id, user_id FROM devices WHERE token_hash = ?1",
414 [hash_secret(token)],
415 |r| Ok((r.get(0)?, r.get(1)?)),
416 )
417 .optional()?
418 .ok_or(Error::Unauthorized)?;
419 Ok(Device { id, user_id })
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 #[test]
428 fn limiter_locks_after_max_failures() {
429 let l = Limiter::default();
430 let keys = |pair| [(pair, MAX_FAILURES), ("ip", MAX_IP_FAILURES)];
431 for _ in 0..MAX_FAILURES {
432 assert!(l.attempt(&keys("ip a")).is_ok());
433 }
434 assert!(matches!(
435 l.attempt(&keys("ip a")),
436 Err(Error::TooManyRequests)
437 ));
438 assert!(l.attempt(&keys("ip b")).is_ok());
439 l.forgive("ip a", &["ip"]);
440 assert!(l.attempt(&keys("ip a")).is_ok());
441 for n in 0..MAX_IP_FAILURES {
442 let pair = format!("ip {n}");
443 let _ = l.attempt(&[(&pair, MAX_FAILURES), ("ip", MAX_IP_FAILURES)]);
444 }
445 assert!(matches!(
446 l.attempt(&keys("ip new")),
447 Err(Error::TooManyRequests)
448 ));
449 }
450
451 #[test]
452 fn a_name_locks_across_addresses_but_not_for_known_ones() {
453 let state = crate::test_state();
454 let rt = tokio::runtime::Runtime::new().unwrap();
455 let guess = |ip: &str, ok: bool| {
456 rt.block_on(limited(&state, ip.parse().unwrap(), "Alice", move || {
457 ok.then_some(())
458 }))
459 };
460 assert!(guess("198.51.100.1", true).is_ok());
461 for n in 0..MAX_FAILURES {
462 assert!(matches!(
463 guess(&format!("2001:db8:{n}::1"), false),
464 Err(Error::Unauthorized)
465 ));
466 }
467 assert!(matches!(
468 guess("2001:db8:99::1", true),
469 Err(Error::TooManyRequests)
470 ));
471 assert!(guess("198.51.100.1", true).is_ok());
472 }
473
474 #[test]
475 fn ipv6_addresses_group_by_64() {
476 let group = |s: &str| ip_group(s.parse().unwrap());
477 assert_eq!(group("2001:db8:1:2:aaaa::1"), group("2001:db8:1:2:bbbb::2"));
478 assert_ne!(group("2001:db8:1:2::1"), group("2001:db8:1:3::1"));
479 assert_eq!(group("::ffff:203.0.113.7"), "203.0.113.7");
480 }
481
482 #[test]
483 fn credential_changes_need_a_recent_sign_in() {
484 let user = |signed_in_at| User {
485 id: 1,
486 username: "a".into(),
487 is_admin: false,
488 session_hash: vec![],
489 signed_in_at,
490 };
491 assert!(user(now() - 60).check_recent().is_ok());
492 assert!(user(now() - RECENT_SECS - 1).check_recent().is_err());
493 assert!(user(0).check_recent().is_err());
494 }
495
496 #[test]
497 fn forwarded_ip_takes_the_entry_the_proxy_wrote() {
498 let proxy: IpAddr = "10.0.0.2".parse().unwrap();
499 let headers = |lines: &[&str]| {
500 let mut h = HeaderMap::new();
501 for l in lines {
502 h.append("x-forwarded-for", l.parse().unwrap());
503 }
504 h
505 };
506 let ip = |s: &str| s.parse::<IpAddr>().unwrap();
507 // nginx appends the real address to a value the client sent.
508 assert_eq!(
509 forwarded_ip(proxy, &headers(&["6.6.6.6, 203.0.113.7"])),
510 ip("203.0.113.7")
511 );
512 assert_eq!(
513 forwarded_ip(proxy, &headers(&["6.6.6.6", "203.0.113.7"])),
514 ip("203.0.113.7")
515 );
516 assert_eq!(
517 forwarded_ip(proxy, &headers(&["[2001:db8::1]:4711"])),
518 ip("2001:db8::1")
519 );
520 assert_eq!(
521 forwarded_ip(proxy, &headers(&["203.0.113.7,"])),
522 ip("203.0.113.7")
523 );
524 assert_eq!(
525 forwarded_ip(proxy, &headers(&["203.0.113.7, nonsense"])),
526 proxy
527 );
528 assert_eq!(forwarded_ip(proxy, &HeaderMap::new()), proxy);
529 }
530
531 #[test]
532 fn password_roundtrip() {
533 let h = hash_password("correct horse");
534 assert!(verify_password("correct horse", &h));
535 assert!(!verify_password("wrong horse", &h));
536 }
537}
538