auth.rs
⎇
Raw
1use std::collections::HashMap;
2use std::net::{IpAddr, 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::encode(bytes);
46 let hash = hash_secret(&secret);
47 (secret, hash)
48}
49
50/// The secrets are 256 random bits, so a fast hash is enough. Argon2 is only for passwords.
51fn hash_secret(secret: &str) -> Vec<u8> {
52 Sha256::digest(secret.as_bytes()).to_vec()
53}
54
55static DUMMY_HASH: LazyLock<String> = LazyLock::new(|| hash_password("not a real password"));
56
57pub struct PasswordOk {
58 pub id: i64,
59 pub two_factor: bool,
60}
61
62/// Checks a username and password. An account without a password always fails.
63pub async fn check_password(
64 state: &AppState,
65 ip: IpAddr,
66 username: &str,
67 password: &str,
68) -> Result<PasswordOk, Error> {
69 let row: Option<(i64, Option<String>, bool)> = state
70 .db()
71 .query_row(
72 "SELECT id, pw_hash, two_factor FROM users WHERE username = ?1",
73 [username],
74 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
75 )
76 .optional()?;
77 let password = password.to_owned();
78 limited(state, ip, username, move || match row {
79 Some((id, Some(hash), two_factor)) => {
80 verify_password(&password, &hash).then_some(PasswordOk { id, two_factor })
81 }
82 _ => {
83 // Do the same work as for a real password, so timing does not reveal which accounts exist.
84 verify_password(&password, &DUMMY_HASH);
85 None
86 }
87 })
88 .await
89}
90
91/// Runs a password check for `name` from `ip` under the rate limits. None from `check` means a wrong password.
92pub async fn limited<T: Send + 'static>(
93 state: &AppState,
94 ip: IpAddr,
95 name: &str,
96 check: impl FnOnce() -> Option<T> + Send + 'static,
97) -> Result<T, Error> {
98 let pair = format!("{ip} {}", name.to_lowercase());
99 let single = ip.to_string();
100 state
101 .limiter
102 .attempt(&[(&pair, MAX_FAILURES), (&single, MAX_IP_FAILURES)])?;
103 match argon(check).await? {
104 Some(v) => {
105 state.limiter.forgive(&pair, &single);
106 Ok(v)
107 }
108 None => Err(Error::Unauthorized),
109 }
110}
111
112/// Each Argon2 run takes about 19 MiB. Without a bound, parallel requests could exhaust the memory.
113static ARGON: LazyLock<Semaphore> =
114 LazyLock::new(|| Semaphore::new(std::thread::available_parallelism().map_or(2, |n| n.get())));
115
116/// Runs Argon2 work on the blocking pool, at most one run per CPU at a time.
117pub async fn argon<T: Send + 'static>(
118 work: impl FnOnce() -> T + Send + 'static,
119) -> Result<T, Error> {
120 let _permit = ARGON
121 .acquire()
122 .await
123 .map_err(|e| Error::Internal(e.to_string()))?;
124 tokio::task::spawn_blocking(work)
125 .await
126 .map_err(|e| Error::Internal(e.to_string()))
127}
128
129pub async fn hash_password_async(password: String) -> Result<String, Error> {
130 argon(move || hash_password(&password)).await
131}
132
133fn cookie(state: &AppState, value: &str, max_age: i64) -> String {
134 let secure = if state.https() { "; Secure" } else { "" };
135 format!(
136 "{SESSION_COOKIE}={value}; Path=/; Max-Age={max_age}; HttpOnly; SameSite=Strict{secure}"
137 )
138}
139
140/// Creates a session and returns its Set-Cookie value.
141pub fn create_session(state: &AppState, user_id: i64) -> Result<String, Error> {
142 let (token, hash) = new_secret();
143 state.db().execute(
144 "INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?1, ?2, ?3)",
145 params![hash, user_id, now() + SESSION_SECS],
146 )?;
147 Ok(cookie(state, &token, SESSION_SECS))
148}
149
150pub fn clear_session(state: &AppState) -> String {
151 cookie(state, "", 0)
152}
153
154/// A credential changed. Sessions that existed before must not outlive it.
155pub fn end_other_sessions(state: &AppState, user: &User) -> Result<(), Error> {
156 state.db().execute(
157 "DELETE FROM sessions WHERE user_id = ?1 AND token_hash <> ?2",
158 params![user.id, user.session_hash],
159 )?;
160 Ok(())
161}
162
163/// Wrong passwords per address and name before a lockout.
164const MAX_FAILURES: u32 = 5;
165/// Wrong passwords per address across all names, so guessing one password for many users is limited too.
166const MAX_IP_FAILURES: u32 = 30;
167const LOCKOUT_SECS: i64 = 15 * 60;
168
169/// Password attempts per key. An attempt counts before the check, so parallel requests cannot slip past the limit.
170#[derive(Default)]
171pub struct Limiter(Mutex<HashMap<String, (u32, i64)>>);
172
173impl Limiter {
174 /// Counts one attempt for every key, or none if any key is at its limit.
175 pub fn attempt(&self, keys: &[(&str, u32)]) -> Result<(), Error> {
176 let now = now();
177 let mut map = self.0.lock().unwrap();
178 let locked = keys.iter().any(|(key, max)| {
179 map.get(*key)
180 .is_some_and(|&(n, since)| n >= *max && now - since < LOCKOUT_SECS)
181 });
182 if locked {
183 return Err(Error::TooManyRequests);
184 }
185 for (key, _) in keys {
186 let entry = map.entry((*key).to_owned()).or_insert((0, now));
187 if now - entry.1 >= LOCKOUT_SECS {
188 *entry = (0, now);
189 }
190 entry.0 += 1;
191 }
192 Ok(())
193 }
194
195 /// The password was right: earlier failures for the pair are forgotten, and this attempt does not count for the address.
196 pub fn forgive(&self, pair: &str, single: &str) {
197 let mut map = self.0.lock().unwrap();
198 map.remove(pair);
199 if let Some(entry) = map.get_mut(single) {
200 entry.0 = entry.0.saturating_sub(1);
201 }
202 }
203
204 /// Attackers choose the names, so old entries must go.
205 pub fn prune(&self) {
206 let now = now();
207 self.0
208 .lock()
209 .unwrap()
210 .retain(|_, (_, since)| now - *since < LOCKOUT_SECS);
211 }
212}
213
214/// The client's address. With `--behind-proxy`, the last `X-Forwarded-For` entry.
215pub struct ClientIp(pub IpAddr);
216
217/// The last `X-Forwarded-For` entry, across all header lines. The proxy appends the address it saw,
218/// so earlier entries may come from the client. Falls back to the peer, which then is the proxy.
219fn forwarded_ip(peer: IpAddr, headers: &HeaderMap) -> IpAddr {
220 headers
221 .get_all("x-forwarded-for")
222 .iter()
223 .filter_map(|v| v.to_str().ok())
224 .flat_map(|v| v.split(','))
225 .map(str::trim)
226 .rfind(|v| !v.is_empty())
227 .and_then(|v| {
228 v.parse::<IpAddr>()
229 .or_else(|_| v.parse::<SocketAddr>().map(|s| s.ip()))
230 .ok()
231 })
232 .unwrap_or(peer)
233}
234
235impl FromRequestParts<AppState> for ClientIp {
236 type Rejection = Error;
237
238 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
239 let peer = parts
240 .extensions
241 .get::<ConnectInfo<SocketAddr>>()
242 .map(|c| c.0.ip())
243 .ok_or_else(|| Error::Internal("no connect info".into()))?;
244 Ok(ClientIp(match state.behind_proxy {
245 true => forwarded_ip(peer, &parts.headers),
246 false => peer,
247 }))
248 }
249}
250
251/// A logged-in web user, from the session cookie.
252pub struct User {
253 pub id: i64,
254 pub username: String,
255 pub is_admin: bool,
256 pub session_hash: Vec<u8>,
257}
258
259impl FromRequestParts<AppState> for User {
260 type Rejection = Error;
261
262 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
263 let token = parts
264 .headers
265 .get_all(header::COOKIE)
266 .iter()
267 .filter_map(|v| v.to_str().ok())
268 .flat_map(|v| v.split(';'))
269 .find_map(|c| c.trim().strip_prefix(SESSION_COOKIE)?.strip_prefix('='))
270 .ok_or(Error::Unauthorized)?;
271 let session_hash = hash_secret(token);
272 let (id, username, is_admin) = state
273 .db()
274 .query_row(
275 "SELECT u.id, u.username, u.is_admin FROM sessions s JOIN users u ON u.id = s.user_id
276 WHERE s.token_hash = ?1 AND s.expires_at > ?2",
277 params![session_hash, now()],
278 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
279 )
280 .optional()?
281 .ok_or(Error::Unauthorized)?;
282 Ok(User {
283 id,
284 username,
285 is_admin,
286 session_hash,
287 })
288 }
289}
290
291/// A logged-in admin.
292pub struct Admin(pub User);
293
294impl FromRequestParts<AppState> for Admin {
295 type Rejection = Error;
296
297 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
298 let user = User::from_request_parts(parts, state).await?;
299 if !user.is_admin {
300 return Err(Error::Forbidden);
301 }
302 Ok(Admin(user))
303 }
304}
305
306/// Who uploads points: a device with its `Authorization: Bearer` token, or the web UI with its session.
307pub struct Uploader {
308 pub device_id: i64,
309}
310
311impl FromRequestParts<AppState> for Uploader {
312 type Rejection = Error;
313
314 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
315 if parts.headers.contains_key(header::AUTHORIZATION) {
316 let d = Device::from_request_parts(parts, state).await?;
317 return Ok(Uploader { device_id: d.id });
318 }
319 {
320 let user = User::from_request_parts(parts, state).await?;
321 let db = state.db();
322 db.execute(
323 "INSERT OR IGNORE INTO devices (user_id, name, created_at) VALUES (?1, 'Web', ?2)",
324 params![user.id, now()],
325 )?;
326 let device_id = db.query_row(
327 "SELECT id FROM devices WHERE user_id = ?1 AND token_hash IS NULL",
328 [user.id],
329 |r| r.get(0),
330 )?;
331 Ok(Uploader { device_id })
332 }
333 }
334}
335
336/// A device, from its `Authorization: Bearer` token.
337pub struct Device {
338 pub id: i64,
339 pub user_id: i64,
340}
341
342impl FromRequestParts<AppState> for Device {
343 type Rejection = Error;
344
345 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
346 let token = parts
347 .headers
348 .get(header::AUTHORIZATION)
349 .and_then(|v| v.to_str().ok())
350 .and_then(|v| v.strip_prefix("Bearer "))
351 .ok_or(Error::Unauthorized)?;
352 let (id, user_id) = state
353 .db()
354 .query_row(
355 "SELECT id, user_id FROM devices WHERE token_hash = ?1",
356 [hash_secret(token)],
357 |r| Ok((r.get(0)?, r.get(1)?)),
358 )
359 .optional()?
360 .ok_or(Error::Unauthorized)?;
361 Ok(Device { id, user_id })
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn limiter_locks_after_max_failures() {
371 let l = Limiter::default();
372 let keys = |pair| [(pair, MAX_FAILURES), ("ip", MAX_IP_FAILURES)];
373 for _ in 0..MAX_FAILURES {
374 assert!(l.attempt(&keys("ip a")).is_ok());
375 }
376 assert!(matches!(
377 l.attempt(&keys("ip a")),
378 Err(Error::TooManyRequests)
379 ));
380 assert!(l.attempt(&keys("ip b")).is_ok());
381 l.forgive("ip a", "ip");
382 assert!(l.attempt(&keys("ip a")).is_ok());
383 for n in 0..MAX_IP_FAILURES {
384 let pair = format!("ip {n}");
385 let _ = l.attempt(&[(&pair, MAX_FAILURES), ("ip", MAX_IP_FAILURES)]);
386 }
387 assert!(matches!(
388 l.attempt(&keys("ip new")),
389 Err(Error::TooManyRequests)
390 ));
391 }
392
393 #[test]
394 fn forwarded_ip_takes_the_entry_the_proxy_wrote() {
395 let proxy: IpAddr = "10.0.0.2".parse().unwrap();
396 let headers = |lines: &[&str]| {
397 let mut h = HeaderMap::new();
398 for l in lines {
399 h.append("x-forwarded-for", l.parse().unwrap());
400 }
401 h
402 };
403 let ip = |s: &str| s.parse::<IpAddr>().unwrap();
404 // nginx appends the real address to a value the client sent.
405 assert_eq!(
406 forwarded_ip(proxy, &headers(&["6.6.6.6, 203.0.113.7"])),
407 ip("203.0.113.7")
408 );
409 assert_eq!(
410 forwarded_ip(proxy, &headers(&["6.6.6.6", "203.0.113.7"])),
411 ip("203.0.113.7")
412 );
413 assert_eq!(
414 forwarded_ip(proxy, &headers(&["[2001:db8::1]:4711"])),
415 ip("2001:db8::1")
416 );
417 assert_eq!(
418 forwarded_ip(proxy, &headers(&["203.0.113.7,"])),
419 ip("203.0.113.7")
420 );
421 assert_eq!(
422 forwarded_ip(proxy, &headers(&["203.0.113.7, nonsense"])),
423 proxy
424 );
425 assert_eq!(forwarded_ip(proxy, &HeaderMap::new()), proxy);
426 }
427
428 #[test]
429 fn password_roundtrip() {
430 let h = hash_password("correct horse");
431 assert!(verify_password("correct horse", &h));
432 assert!(!verify_password("wrong horse", &h));
433 }
434}
435