auth.rs
⎇
Raw
1use std::collections::HashMap;
2use std::sync::{LazyLock, Mutex};
3
4use argon2::Argon2;
5use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash};
6use axum::extract::FromRequestParts;
7use axum::http::header;
8use axum::http::request::Parts;
9use rusqlite::{OptionalExtension, params};
10use sha2::{Digest, Sha256};
11
12use crate::{AppState, Error, now};
13
14pub const SESSION_COOKIE: &str = "ot_session";
15pub const SESSION_SECS: i64 = 30 * 86400;
16
17pub fn hash_password(password: &str) -> String {
18 Argon2::default()
19 .hash_password(password.as_bytes())
20 .expect("argon2 with default parameters")
21 .to_string()
22}
23
24pub fn verify_password(password: &str, hash: &str) -> bool {
25 PasswordHash::new(hash).is_ok_and(|h| {
26 Argon2::default()
27 .verify_password(password.as_bytes(), &h)
28 .is_ok()
29 })
30}
31
32pub fn check_new_password(password: &str) -> Result<(), &'static str> {
33 if password.chars().count() < 8 {
34 return Err("password needs at least 8 characters");
35 }
36 Ok(())
37}
38
39/// A random secret for a session cookie or a device token, and the hash to store for it.
40pub fn new_secret() -> (String, Vec<u8>) {
41 let mut bytes = [0u8; 32];
42 getrandom::fill(&mut bytes).expect("OS random number generator");
43 let secret = hex::encode(bytes);
44 let hash = hash_secret(&secret);
45 (secret, hash)
46}
47
48/// The secrets are 256 random bits, so a fast hash is enough. Argon2 is only for passwords.
49fn hash_secret(secret: &str) -> Vec<u8> {
50 Sha256::digest(secret.as_bytes()).to_vec()
51}
52
53static DUMMY_HASH: LazyLock<String> = LazyLock::new(|| hash_password("not a real password"));
54
55pub struct PasswordOk {
56 pub id: i64,
57 pub two_factor: bool,
58}
59
60/// Checks a username and password. An account without a password always fails.
61pub async fn check_password(
62 state: &AppState,
63 username: &str,
64 password: &str,
65) -> Result<PasswordOk, Error> {
66 let key = username.to_lowercase();
67 state.limiter.check(&key)?;
68 let row: Option<(i64, Option<String>, bool)> = state
69 .db()
70 .query_row(
71 "SELECT id, pw_hash, two_factor FROM users WHERE username = ?1",
72 [username],
73 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
74 )
75 .optional()?;
76
77 let password = password.to_owned();
78 let user = tokio::task::spawn_blocking(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 .map_err(|e| Error::Internal(e.to_string()))?;
90
91 match user {
92 Some(user) => {
93 state.limiter.clear(&key);
94 Ok(user)
95 }
96 None => {
97 state.limiter.fail(&key);
98 Err(Error::Unauthorized)
99 }
100 }
101}
102
103pub async fn hash_password_async(password: String) -> Result<String, Error> {
104 tokio::task::spawn_blocking(move || hash_password(&password))
105 .await
106 .map_err(|e| Error::Internal(e.to_string()))
107}
108
109fn cookie(state: &AppState, value: &str, max_age: i64) -> String {
110 let secure = if state.https() { "; Secure" } else { "" };
111 format!(
112 "{SESSION_COOKIE}={value}; Path=/; Max-Age={max_age}; HttpOnly; SameSite=Strict{secure}"
113 )
114}
115
116/// Creates a session and returns its Set-Cookie value.
117pub fn create_session(state: &AppState, user_id: i64) -> Result<String, Error> {
118 let (token, hash) = new_secret();
119 state.db().execute(
120 "INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?1, ?2, ?3)",
121 params![hash, user_id, now() + SESSION_SECS],
122 )?;
123 Ok(cookie(state, &token, SESSION_SECS))
124}
125
126pub fn clear_session(state: &AppState) -> String {
127 cookie(state, "", 0)
128}
129
130/// A credential changed. Sessions that existed before must not outlive it.
131pub fn end_other_sessions(state: &AppState, user: &User) -> Result<(), Error> {
132 state.db().execute(
133 "DELETE FROM sessions WHERE user_id = ?1 AND token_hash <> ?2",
134 params![user.id, user.session_hash],
135 )?;
136 Ok(())
137}
138
139const MAX_FAILURES: u32 = 5;
140const LOCKOUT_SECS: i64 = 15 * 60;
141
142/// Failed login attempts per username.
143#[derive(Default)]
144pub struct Limiter(Mutex<HashMap<String, (u32, i64)>>);
145
146impl Limiter {
147 pub fn check(&self, key: &str) -> Result<(), Error> {
148 let map = self.0.lock().unwrap();
149 if let Some(&(failures, since)) = map.get(key)
150 && failures >= MAX_FAILURES
151 && now() - since < LOCKOUT_SECS
152 {
153 return Err(Error::TooManyRequests);
154 }
155 Ok(())
156 }
157
158 pub fn fail(&self, key: &str) {
159 let now = now();
160 let mut map = self.0.lock().unwrap();
161 let entry = map.entry(key.to_owned()).or_insert((0, now));
162 if now - entry.1 >= LOCKOUT_SECS {
163 *entry = (0, now);
164 }
165 entry.0 += 1;
166 }
167
168 pub fn clear(&self, key: &str) {
169 self.0.lock().unwrap().remove(key);
170 }
171
172 /// Attackers choose the usernames, so old entries must go.
173 pub fn prune(&self) {
174 let now = now();
175 self.0
176 .lock()
177 .unwrap()
178 .retain(|_, (_, since)| now - *since < LOCKOUT_SECS);
179 }
180}
181
182/// A logged-in web user, from the session cookie.
183pub struct User {
184 pub id: i64,
185 pub username: String,
186 pub is_admin: bool,
187 pub session_hash: Vec<u8>,
188}
189
190impl FromRequestParts<AppState> for User {
191 type Rejection = Error;
192
193 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
194 let token = parts
195 .headers
196 .get_all(header::COOKIE)
197 .iter()
198 .filter_map(|v| v.to_str().ok())
199 .flat_map(|v| v.split(';'))
200 .find_map(|c| c.trim().strip_prefix(SESSION_COOKIE)?.strip_prefix('='))
201 .ok_or(Error::Unauthorized)?;
202 let session_hash = hash_secret(token);
203 let (id, username, is_admin) = state
204 .db()
205 .query_row(
206 "SELECT u.id, u.username, u.is_admin FROM sessions s JOIN users u ON u.id = s.user_id
207 WHERE s.token_hash = ?1 AND s.expires_at > ?2",
208 params![session_hash, now()],
209 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
210 )
211 .optional()?
212 .ok_or(Error::Unauthorized)?;
213 Ok(User {
214 id,
215 username,
216 is_admin,
217 session_hash,
218 })
219 }
220}
221
222/// A logged-in admin.
223pub struct Admin(pub User);
224
225impl FromRequestParts<AppState> for Admin {
226 type Rejection = Error;
227
228 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
229 let user = User::from_request_parts(parts, state).await?;
230 if !user.is_admin {
231 return Err(Error::Forbidden);
232 }
233 Ok(Admin(user))
234 }
235}
236
237/// Who uploads points: a device with its `Authorization: Bearer` token, or the web UI with its session.
238pub struct Uploader {
239 pub user_id: i64,
240 pub device_id: i64,
241}
242
243impl FromRequestParts<AppState> for Uploader {
244 type Rejection = Error;
245
246 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
247 if parts.headers.contains_key(header::AUTHORIZATION) {
248 let d = Device::from_request_parts(parts, state).await?;
249 return Ok(Uploader {
250 user_id: d.user_id,
251 device_id: d.id,
252 });
253 }
254 {
255 let user = User::from_request_parts(parts, state).await?;
256 let db = state.db();
257 db.execute(
258 "INSERT OR IGNORE INTO devices (user_id, name, created_at) VALUES (?1, 'Web', ?2)",
259 params![user.id, now()],
260 )?;
261 let device_id = db.query_row(
262 "SELECT id FROM devices WHERE user_id = ?1 AND token_hash IS NULL",
263 [user.id],
264 |r| r.get(0),
265 )?;
266 Ok(Uploader {
267 user_id: user.id,
268 device_id,
269 })
270 }
271 }
272}
273
274/// A device, from its `Authorization: Bearer` token.
275pub struct Device {
276 pub id: i64,
277 pub user_id: i64,
278}
279
280impl FromRequestParts<AppState> for Device {
281 type Rejection = Error;
282
283 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
284 let token = parts
285 .headers
286 .get(header::AUTHORIZATION)
287 .and_then(|v| v.to_str().ok())
288 .and_then(|v| v.strip_prefix("Bearer "))
289 .ok_or(Error::Unauthorized)?;
290 let (id, user_id) = state
291 .db()
292 .query_row(
293 "SELECT id, user_id FROM devices WHERE token_hash = ?1",
294 [hash_secret(token)],
295 |r| Ok((r.get(0)?, r.get(1)?)),
296 )
297 .optional()?
298 .ok_or(Error::Unauthorized)?;
299 Ok(Device { id, user_id })
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn limiter_locks_after_max_failures() {
309 let l = Limiter::default();
310 for _ in 0..MAX_FAILURES {
311 assert!(l.check("a").is_ok());
312 l.fail("a");
313 }
314 assert!(matches!(l.check("a"), Err(Error::TooManyRequests)));
315 assert!(l.check("b").is_ok());
316 l.clear("a");
317 assert!(l.check("a").is_ok());
318 }
319
320 #[test]
321 fn password_roundtrip() {
322 let h = hash_password("correct horse");
323 assert!(verify_password("correct horse", &h));
324 assert!(!verify_password("wrong horse", &h));
325 }
326}
327