Skip to main content

koan_server/auth/
password.rs

1//! Username and password checks for transports that send them with every
2//! request, such as Subsonic's `p=`.
3//!
4//! argon2 is deliberately slow, and a Subsonic client authenticates each call,
5//! so a successful check is remembered for a while. The key is a digest of the
6//! username, the password and the stored hash, so changing the password or
7//! deleting the user ends it; the role is read afresh every time.
8//!
9//! A check that misses the cache runs argon2, which costs ~19 MiB and a core
10//! for tens of milliseconds, and anyone may ask for one — an unknown username
11//! still pays, so response time does not say which exist. So only a few run at
12//! once, and a request that finds them all busy is refused rather than queued.
13//! Requests carrying the same credentials while one is being checked wait for
14//! that check instead, which is what a client's burst of requests on first
15//! contact looks like.
16//!
17//! Subsonic token auth (`t`/`s`) is not checked here: it needs the plaintext
18//! password, and koan keeps only the hash.
19//!
20//! One verifier serves every door a password comes through — Subsonic, the
21//! JSON login and the web UI's form — so the ceiling on argon2 and the
22//! per-username budget on failures hold across all of them.
23//!
24//! The budget stops a guesser with many addresses, and on its own would let
25//! anyone with two keep an account locked out. So the networks an account has
26//! recently signed in from are remembered, and the budget does not apply to
27//! them: an outsider can spend it, but not for the account's own people.
28
29use std::collections::HashMap;
30use std::net::IpAddr;
31use std::num::NonZeroUsize;
32use std::sync::Arc;
33use std::time::{Duration, Instant};
34
35use koan_core::auth;
36use koan_core::db::pool::Pool;
37use koan_core::db::queries::auth::{self as auth_queries, UserRow};
38use lru::LruCache;
39use parking_lot::{Condvar, Mutex};
40use sha2::{Digest, Sha256};
41
42const REMEMBER: Duration = Duration::from_secs(600);
43
44/// How long a request waits on another's check of the same credentials.
45const WAIT_FOR_CHECK: Duration = Duration::from_secs(5);
46
47/// Failed password sign-ins allowed for one username in a minute, from every
48/// address together. Per-address limits do not stop a guesser with many
49/// addresses; this does, at a rate no person typing would reach.
50pub(crate) const FAILURES_PER_USERNAME_PER_MINUTE: u32 = 60;
51pub(crate) const FAILURE_WINDOW: Duration = Duration::from_secs(60);
52
53/// How long a network an account signed in from is spared its spent budget.
54/// A browser that keeps its session refreshes it every few minutes, which
55/// renews this.
56const KNOWN_FOR: Duration = Duration::from_secs(7 * 24 * 3600);
57
58/// Failures by key, in fixed windows of `FAILURE_WINDOW`.
59pub(crate) struct FailureLimiter<K> {
60    limit: u32,
61    windows: Mutex<HashMap<K, (Instant, u32)>>,
62}
63
64impl<K: std::hash::Hash + Eq> FailureLimiter<K> {
65    pub(crate) fn new(limit: u32) -> Self {
66        Self {
67            limit,
68            windows: Default::default(),
69        }
70    }
71
72    pub(crate) fn exhausted(&self, key: &K) -> bool {
73        let windows = self.windows.lock();
74        windows
75            .get(key)
76            .is_some_and(|(start, count)| start.elapsed() < FAILURE_WINDOW && *count >= self.limit)
77    }
78
79    pub(crate) fn record(&self, key: K) {
80        let mut windows = self.windows.lock();
81        if windows.len() > 4096 {
82            windows.retain(|_, (start, _)| start.elapsed() < FAILURE_WINDOW);
83        }
84        let entry = windows.entry(key).or_insert((Instant::now(), 0));
85        if entry.0.elapsed() >= FAILURE_WINDOW {
86            *entry = (Instant::now(), 0);
87        }
88        entry.1 += 1;
89    }
90}
91
92/// argon2 checks allowed at once.
93fn max_checks() -> usize {
94    std::thread::available_parallelism().map_or(2, |n| n.get().clamp(2, 8))
95}
96
97/// Why a password was not accepted.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum Refused {
100    /// Not this account's password, or no such account.
101    Wrong,
102    /// Every argon2 slot was taken; nothing was checked.
103    Busy,
104}
105
106/// A check in progress: its outcome once known, and a signal for those waiting.
107#[derive(Default)]
108struct Check {
109    outcome: Mutex<Option<bool>>,
110    done: Condvar,
111}
112
113pub struct PasswordVerifier {
114    pool: Arc<Pool>,
115    verified: Mutex<LruCache<[u8; 32], Instant>>,
116    /// Checks running now, by the same key as `verified`.
117    checking: Mutex<HashMap<[u8; 32], Arc<Check>>>,
118    max_checks: usize,
119    /// Failed sign-ins by username, whatever the address, counted by the
120    /// doors that take passwords. Only those: API keys and the Subsonic shared
121    /// secret are random and not worth guessing, so a flood of wrong passwords
122    /// for an account never locks out the apps signed in with either.
123    failures: FailureLimiter<String>,
124    /// When each username last signed in from each network (`routes::network`).
125    known: Mutex<LruCache<(String, IpAddr), Instant>>,
126    /// Subsonic credentials that just signed in, for the throttle to read back
127    /// once the request is answered; see `passed`.
128    passes: Mutex<LruCache<[u8; 32], ()>>,
129}
130
131impl PasswordVerifier {
132    pub fn new(pool: Arc<Pool>) -> Self {
133        Self {
134            pool,
135            verified: Mutex::new(LruCache::new(NonZeroUsize::new(256).expect("non-zero"))),
136            checking: Mutex::new(HashMap::new()),
137            max_checks: max_checks(),
138            failures: FailureLimiter::new(FAILURES_PER_USERNAME_PER_MINUTE),
139            known: Mutex::new(LruCache::new(NonZeroUsize::new(4096).expect("non-zero"))),
140            passes: Mutex::new(LruCache::new(NonZeroUsize::new(256).expect("non-zero"))),
141        }
142    }
143
144    /// Whether `username` has spent its failures for the minute, as far as a
145    /// sign-in from `from` is concerned: never from a network it recently
146    /// signed in from, and never for a demo's account, whose password is
147    /// public and which strangers would otherwise lock for everyone. The
148    /// per-address limits still apply.
149    pub(crate) fn spent(&self, username: &str, from: IpAddr) -> bool {
150        if crate::demo::enabled() && username == crate::demo::USERNAME {
151            return false;
152        }
153        self.failures.exhausted(&username.to_owned())
154            && !self
155                .known
156                .lock()
157                .get(&(username.to_owned(), super::routes::network(from)))
158                .is_some_and(|at| at.elapsed() < KNOWN_FOR)
159    }
160
161    /// Whether `username` has spent its failures for the minute, from any
162    /// network: for a password check that is not a sign-in, which the sparing
163    /// of known networks would otherwise let a guesser on one of them repeat
164    /// without limit.
165    pub(crate) fn exhausted(&self, username: &str) -> bool {
166        self.failures.exhausted(&username.to_owned())
167    }
168
169    /// Count a failed sign-in for `username`.
170    pub(crate) fn failed(&self, username: &str) {
171        self.failures.record(username.to_owned());
172    }
173
174    /// Remember that `username` signed in from `from`'s network.
175    pub(crate) fn signed_in(&self, username: &str, from: IpAddr) {
176        self.known.lock().put(
177            (username.to_owned(), super::routes::network(from)),
178            Instant::now(),
179        );
180    }
181
182    /// Note that the Subsonic credential `digest` signed in. The check runs
183    /// inside the handler, which knows nothing of the client's address; the
184    /// throttle around it does, and takes this back with `took_pass`.
185    pub(crate) fn passed(&self, digest: [u8; 32]) {
186        self.passes.lock().put(digest, ());
187    }
188
189    /// Whether `digest` signed in since it was last asked.
190    pub(crate) fn took_pass(&self, digest: &[u8; 32]) -> bool {
191        self.passes.lock().pop(digest).is_some()
192    }
193
194    /// The account, as it stands, when the password is its.
195    pub fn verify(&self, username: &str, password: &str) -> Result<UserRow, Refused> {
196        let db = self.pool.get().map_err(|_| Refused::Wrong)?;
197        let user =
198            auth_queries::get_user_by_username(&db.conn, username).map_err(|_| Refused::Wrong)?;
199        // An unknown username is checked against the dummy hash, so response
200        // time doesn't say which usernames exist.
201        let hash = user.as_ref().map_or_else(
202            || super::routes::dummy_password_hash(),
203            |u| u.password_hash.as_str(),
204        );
205        let key: [u8; 32] = Sha256::new()
206            .chain_update(username)
207            .chain_update([0])
208            .chain_update(password)
209            .chain_update([0])
210            .chain_update(hash)
211            .finalize()
212            .into();
213        let fresh = self
214            .verified
215            .lock()
216            .get(&key)
217            .is_some_and(|at| at.elapsed() < REMEMBER);
218        if !fresh && !self.check(key, password, hash)? {
219            return Err(Refused::Wrong);
220        }
221        user.ok_or(Refused::Wrong)
222    }
223
224    /// Run argon2 for `key`, or wait on the check already running for it.
225    fn check(&self, key: [u8; 32], password: &str, hash: &str) -> Result<bool, Refused> {
226        let (check, running) = {
227            let mut checking = self.checking.lock();
228            match checking.get(&key) {
229                Some(check) => (check.clone(), true),
230                None if checking.len() >= self.max_checks => return Err(Refused::Busy),
231                None => {
232                    let check = Arc::new(Check::default());
233                    checking.insert(key, check.clone());
234                    (check, false)
235                }
236            }
237        };
238        if running {
239            let deadline = Instant::now() + WAIT_FOR_CHECK;
240            let mut outcome = check.outcome.lock();
241            while outcome.is_none() && !check.done.wait_until(&mut outcome, deadline).timed_out() {}
242            return outcome.ok_or(Refused::Busy);
243        }
244        let ok = auth::verify_password(password, hash).is_ok();
245        if ok {
246            self.verified.lock().put(key, Instant::now());
247        }
248        *check.outcome.lock() = Some(ok);
249        check.done.notify_all();
250        self.checking.lock().remove(&key);
251        Ok(ok)
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use koan_core::auth::Role;
259    use koan_core::db::connection::Database;
260
261    impl PasswordVerifier {
262        fn check_as(&self, username: &str, password: &str) -> Result<(i64, Role), Refused> {
263            self.verify(username, password).map(|u| (u.id, u.role))
264        }
265    }
266
267    fn verifier() -> (PasswordVerifier, tempfile::TempDir) {
268        let dir = tempfile::tempdir().unwrap();
269        let path = dir.path().join("test.db");
270        let db = Database::open(&path).unwrap();
271        koan_core::db::schema::create_tables(&db.conn).unwrap();
272        auth_queries::create_user(&db.conn, "mate", "hunter22", Role::Readonly).unwrap();
273        (PasswordVerifier::new(Arc::new(Pool::new(path))), dir)
274    }
275
276    #[test]
277    fn right_password_gives_the_users_role() {
278        let (v, _dir) = verifier();
279        assert_eq!(v.check_as("mate", "hunter22"), Ok((1, Role::Readonly)));
280        // Remembered, and still answered from the database's role.
281        assert_eq!(v.check_as("mate", "hunter22"), Ok((1, Role::Readonly)));
282    }
283
284    #[test]
285    fn wrong_password_or_unknown_user_is_refused() {
286        let (v, _dir) = verifier();
287        assert_eq!(v.check_as("mate", "hunter2"), Err(Refused::Wrong));
288        assert_eq!(v.check_as("nobody", "hunter22"), Err(Refused::Wrong));
289    }
290
291    #[test]
292    fn checks_beyond_the_ceiling_are_refused_without_running() {
293        let (mut v, _dir) = verifier();
294        v.max_checks = 1;
295        assert!(v.check_as("mate", "hunter22").is_ok());
296        v.checking.lock().insert([0; 32], Arc::default());
297        assert_eq!(v.check_as("nobody", "guess"), Err(Refused::Busy));
298        assert_eq!(v.check_as("mate", "hunter2"), Err(Refused::Busy));
299        // A remembered sign-in needs no check, so it still works.
300        assert_eq!(v.check_as("mate", "hunter22"), Ok((1, Role::Readonly)));
301    }
302
303    #[test]
304    fn a_burst_of_one_sign_in_waits_for_a_single_check() {
305        let (mut v, _dir) = verifier();
306        v.max_checks = 1;
307        let v = Arc::new(v);
308        let burst: Vec<_> = (0..8)
309            .map(|_| {
310                let v = v.clone();
311                std::thread::spawn(move || v.check_as("mate", "hunter22"))
312            })
313            .collect();
314        for t in burst {
315            assert_eq!(t.join().unwrap(), Ok((1, Role::Readonly)));
316        }
317    }
318
319    #[test]
320    fn a_changed_password_forgets_the_old_one() {
321        let (v, dir) = verifier();
322        assert!(v.check_as("mate", "hunter22").is_ok());
323        let db = Database::open(&dir.path().join("test.db")).unwrap();
324        auth_queries::update_password(&db.conn, "mate", "correct horse").unwrap();
325        assert_eq!(v.check_as("mate", "hunter22"), Err(Refused::Wrong));
326        assert_eq!(v.check_as("mate", "correct horse"), Ok((1, Role::Readonly)));
327    }
328}