use std::collections::HashMap;
use std::net::IpAddr;
use std::num::NonZeroUsize;
use std::sync::Arc;
use std::time::{Duration, Instant};
use koan_core::auth;
use koan_core::db::pool::Pool;
use koan_core::db::queries::auth::{self as auth_queries, UserRow};
use lru::LruCache;
use parking_lot::{Condvar, Mutex};
use sha2::{Digest, Sha256};
const REMEMBER: Duration = Duration::from_secs(600);
const WAIT_FOR_CHECK: Duration = Duration::from_secs(5);
pub(crate) const FAILURES_PER_USERNAME_PER_MINUTE: u32 = 60;
pub(crate) const FAILURE_WINDOW: Duration = Duration::from_secs(60);
const KNOWN_FOR: Duration = Duration::from_secs(7 * 24 * 3600);
pub(crate) struct FailureLimiter<K> {
limit: u32,
windows: Mutex<HashMap<K, (Instant, u32)>>,
}
impl<K: std::hash::Hash + Eq> FailureLimiter<K> {
pub(crate) fn new(limit: u32) -> Self {
Self {
limit,
windows: Default::default(),
}
}
pub(crate) fn exhausted(&self, key: &K) -> bool {
let windows = self.windows.lock();
windows
.get(key)
.is_some_and(|(start, count)| start.elapsed() < FAILURE_WINDOW && *count >= self.limit)
}
pub(crate) fn record(&self, key: K) {
let mut windows = self.windows.lock();
if windows.len() > 4096 {
windows.retain(|_, (start, _)| start.elapsed() < FAILURE_WINDOW);
}
let entry = windows.entry(key).or_insert((Instant::now(), 0));
if entry.0.elapsed() >= FAILURE_WINDOW {
*entry = (Instant::now(), 0);
}
entry.1 += 1;
}
}
fn max_checks() -> usize {
std::thread::available_parallelism().map_or(2, |n| n.get().clamp(2, 8))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refused {
Wrong,
Busy,
}
#[derive(Default)]
struct Check {
outcome: Mutex<Option<bool>>,
done: Condvar,
}
pub struct PasswordVerifier {
pool: Arc<Pool>,
verified: Mutex<LruCache<[u8; 32], Instant>>,
checking: Mutex<HashMap<[u8; 32], Arc<Check>>>,
max_checks: usize,
failures: FailureLimiter<String>,
known: Mutex<LruCache<(String, IpAddr), Instant>>,
passes: Mutex<LruCache<[u8; 32], ()>>,
}
impl PasswordVerifier {
pub fn new(pool: Arc<Pool>) -> Self {
Self {
pool,
verified: Mutex::new(LruCache::new(NonZeroUsize::new(256).expect("non-zero"))),
checking: Mutex::new(HashMap::new()),
max_checks: max_checks(),
failures: FailureLimiter::new(FAILURES_PER_USERNAME_PER_MINUTE),
known: Mutex::new(LruCache::new(NonZeroUsize::new(4096).expect("non-zero"))),
passes: Mutex::new(LruCache::new(NonZeroUsize::new(256).expect("non-zero"))),
}
}
pub(crate) fn spent(&self, username: &str, from: IpAddr) -> bool {
self.failures.exhausted(&username.to_owned())
&& !self
.known
.lock()
.get(&(username.to_owned(), super::routes::network(from)))
.is_some_and(|at| at.elapsed() < KNOWN_FOR)
}
pub(crate) fn exhausted(&self, username: &str) -> bool {
self.failures.exhausted(&username.to_owned())
}
pub(crate) fn failed(&self, username: &str) {
self.failures.record(username.to_owned());
}
pub(crate) fn signed_in(&self, username: &str, from: IpAddr) {
self.known.lock().put(
(username.to_owned(), super::routes::network(from)),
Instant::now(),
);
}
pub(crate) fn passed(&self, digest: [u8; 32]) {
self.passes.lock().put(digest, ());
}
pub(crate) fn took_pass(&self, digest: &[u8; 32]) -> bool {
self.passes.lock().pop(digest).is_some()
}
pub fn verify(&self, username: &str, password: &str) -> Result<UserRow, Refused> {
let db = self.pool.get().map_err(|_| Refused::Wrong)?;
let user =
auth_queries::get_user_by_username(&db.conn, username).map_err(|_| Refused::Wrong)?;
let hash = user.as_ref().map_or_else(
|| super::routes::dummy_password_hash(),
|u| u.password_hash.as_str(),
);
let key: [u8; 32] = Sha256::new()
.chain_update(username)
.chain_update([0])
.chain_update(password)
.chain_update([0])
.chain_update(hash)
.finalize()
.into();
let fresh = self
.verified
.lock()
.get(&key)
.is_some_and(|at| at.elapsed() < REMEMBER);
if !fresh && !self.check(key, password, hash)? {
return Err(Refused::Wrong);
}
user.ok_or(Refused::Wrong)
}
fn check(&self, key: [u8; 32], password: &str, hash: &str) -> Result<bool, Refused> {
let (check, running) = {
let mut checking = self.checking.lock();
match checking.get(&key) {
Some(check) => (check.clone(), true),
None if checking.len() >= self.max_checks => return Err(Refused::Busy),
None => {
let check = Arc::new(Check::default());
checking.insert(key, check.clone());
(check, false)
}
}
};
if running {
let deadline = Instant::now() + WAIT_FOR_CHECK;
let mut outcome = check.outcome.lock();
while outcome.is_none() && !check.done.wait_until(&mut outcome, deadline).timed_out() {}
return outcome.ok_or(Refused::Busy);
}
let ok = auth::verify_password(password, hash).is_ok();
if ok {
self.verified.lock().put(key, Instant::now());
}
*check.outcome.lock() = Some(ok);
check.done.notify_all();
self.checking.lock().remove(&key);
Ok(ok)
}
}
#[cfg(test)]
mod tests {
use super::*;
use koan_core::auth::Role;
use koan_core::db::connection::Database;
impl PasswordVerifier {
fn check_as(&self, username: &str, password: &str) -> Result<(i64, Role), Refused> {
self.verify(username, password).map(|u| (u.id, u.role))
}
}
fn verifier() -> (PasswordVerifier, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.db");
let db = Database::open(&path).unwrap();
koan_core::db::schema::create_tables(&db.conn).unwrap();
auth_queries::create_user(&db.conn, "mate", "hunter22", Role::Readonly).unwrap();
(PasswordVerifier::new(Arc::new(Pool::new(path))), dir)
}
#[test]
fn right_password_gives_the_users_role() {
let (v, _dir) = verifier();
assert_eq!(v.check_as("mate", "hunter22"), Ok((1, Role::Readonly)));
assert_eq!(v.check_as("mate", "hunter22"), Ok((1, Role::Readonly)));
}
#[test]
fn wrong_password_or_unknown_user_is_refused() {
let (v, _dir) = verifier();
assert_eq!(v.check_as("mate", "hunter2"), Err(Refused::Wrong));
assert_eq!(v.check_as("nobody", "hunter22"), Err(Refused::Wrong));
}
#[test]
fn checks_beyond_the_ceiling_are_refused_without_running() {
let (mut v, _dir) = verifier();
v.max_checks = 1;
assert!(v.check_as("mate", "hunter22").is_ok());
v.checking.lock().insert([0; 32], Arc::default());
assert_eq!(v.check_as("nobody", "guess"), Err(Refused::Busy));
assert_eq!(v.check_as("mate", "hunter2"), Err(Refused::Busy));
assert_eq!(v.check_as("mate", "hunter22"), Ok((1, Role::Readonly)));
}
#[test]
fn a_burst_of_one_sign_in_waits_for_a_single_check() {
let (mut v, _dir) = verifier();
v.max_checks = 1;
let v = Arc::new(v);
let burst: Vec<_> = (0..8)
.map(|_| {
let v = v.clone();
std::thread::spawn(move || v.check_as("mate", "hunter22"))
})
.collect();
for t in burst {
assert_eq!(t.join().unwrap(), Ok((1, Role::Readonly)));
}
}
#[test]
fn a_changed_password_forgets_the_old_one() {
let (v, dir) = verifier();
assert!(v.check_as("mate", "hunter22").is_ok());
let db = Database::open(&dir.path().join("test.db")).unwrap();
auth_queries::update_password(&db.conn, "mate", "correct horse").unwrap();
assert_eq!(v.check_as("mate", "hunter22"), Err(Refused::Wrong));
assert_eq!(v.check_as("mate", "correct horse"), Ok((1, Role::Readonly)));
}
}