1use std::num::NonZeroUsize;
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16
17use koan_core::auth::{self, Role};
18use koan_core::db::pool::Pool;
19use koan_core::db::queries::auth as auth_queries;
20use lru::LruCache;
21use parking_lot::Mutex;
22use sha2::{Digest, Sha256};
23
24const REMEMBER: Duration = Duration::from_secs(600);
25
26pub struct PasswordVerifier {
27 pool: Arc<Pool>,
28 verified: Mutex<LruCache<[u8; 32], Instant>>,
29 sealing: Option<[u8; 32]>,
32}
33
34impl PasswordVerifier {
35 pub fn new(pool: Arc<Pool>) -> Self {
36 let sealing = auth::subsonic_key()
37 .inspect_err(|e| log::warn!("Subsonic token auth for accounts is off: {e}"))
38 .ok();
39 Self::with_key(pool, sealing)
40 }
41
42 pub fn with_key(pool: Arc<Pool>, sealing: Option<[u8; 32]>) -> Self {
43 Self {
44 pool,
45 verified: Mutex::new(LruCache::new(NonZeroUsize::new(256).expect("non-zero"))),
46 sealing,
47 }
48 }
49
50 pub fn verify_token(&self, username: &str, token: &str, salt: &str) -> Option<(i64, Role)> {
54 use subtle::ConstantTimeEq;
55 let key = self.sealing.as_ref()?;
56 let sealed =
57 auth_queries::sealed_password(&self.pool.get().ok()?.conn, username).ok()??;
58 let password = auth::open_password(key, username, &sealed)?;
59 let expected = format!("{:x}", md5::compute(format!("{password}{salt}")));
60 if !bool::from(
61 token
62 .to_ascii_lowercase()
63 .as_bytes()
64 .ct_eq(expected.as_bytes()),
65 ) {
66 return None;
67 }
68 self.verify(username, &password)
69 }
70
71 pub fn has_sealed(&self, username: &str) -> bool {
73 self.sealing.is_some()
74 && self
75 .pool
76 .get()
77 .ok()
78 .and_then(|db| auth_queries::sealed_password(&db.conn, username).ok())
79 .flatten()
80 .is_some()
81 }
82
83 pub fn verify(&self, username: &str, password: &str) -> Option<(i64, Role)> {
85 let db = self.pool.get().ok()?;
86 let Some(user) = auth_queries::get_user_by_username(&db.conn, username).ok()? else {
87 let _ = auth::verify_password(password, super::routes::dummy_password_hash());
90 return None;
91 };
92 let key: [u8; 32] = Sha256::new()
93 .chain_update(username)
94 .chain_update([0])
95 .chain_update(password)
96 .chain_update([0])
97 .chain_update(&user.password_hash)
98 .finalize()
99 .into();
100 let fresh = self
101 .verified
102 .lock()
103 .get(&key)
104 .is_some_and(|at| at.elapsed() < REMEMBER);
105 if !fresh {
106 auth::verify_password(password, &user.password_hash).ok()?;
107 self.verified.lock().put(key, Instant::now());
108 if let Some(k) = &self.sealing
109 && let Ok(sealed) = auth::seal_password(k, username, password)
110 {
111 let _ = auth_queries::set_sealed_password(&db.conn, username, &sealed);
112 }
113 }
114 Some((user.id, user.role))
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use koan_core::db::connection::Database;
122
123 fn verifier() -> (PasswordVerifier, tempfile::TempDir) {
124 let dir = tempfile::tempdir().unwrap();
125 let path = dir.path().join("test.db");
126 let db = Database::open(&path).unwrap();
127 koan_core::db::schema::create_tables(&db.conn).unwrap();
128 auth_queries::create_user(&db.conn, "mate", "hunter22", Role::Readonly).unwrap();
129 (
130 PasswordVerifier::with_key(Arc::new(Pool::new(path)), Some([7; 32])),
131 dir,
132 )
133 }
134
135 #[test]
136 fn right_password_gives_the_users_role() {
137 let (v, _dir) = verifier();
138 assert_eq!(v.verify("mate", "hunter22"), Some((1, Role::Readonly)));
139 assert_eq!(v.verify("mate", "hunter22"), Some((1, Role::Readonly)));
141 }
142
143 #[test]
144 fn token_auth_works_once_a_password_sign_in_sealed_it() {
145 let (v, _dir) = verifier();
146 let token = |pw: &str, salt: &str| format!("{:x}", md5::compute(format!("{pw}{salt}")));
147 assert_eq!(
148 v.verify_token("mate", &token("hunter22", "abc"), "abc"),
149 None
150 );
151 assert!(!v.has_sealed("mate"));
152 v.verify("mate", "hunter22").unwrap();
153 assert!(v.has_sealed("mate"));
154 assert_eq!(
155 v.verify_token("mate", &token("hunter22", "abc"), "abc"),
156 Some((1, Role::Readonly))
157 );
158 assert_eq!(
159 v.verify_token("mate", &token("hunter2", "abc"), "abc"),
160 None
161 );
162 assert_eq!(
163 v.verify_token("nobody", &token("hunter22", "abc"), "abc"),
164 None
165 );
166 }
167
168 #[test]
169 fn a_password_changed_elsewhere_makes_the_sealed_copy_fail() {
170 let (v, dir) = verifier();
171 v.verify("mate", "hunter22").unwrap();
172 let db = Database::open(&dir.path().join("test.db")).unwrap();
173 auth_queries::update_password(&db.conn, "mate", "correct horse").unwrap();
174 let token = format!("{:x}", md5::compute("hunter22salt"));
175 assert_eq!(v.verify_token("mate", &token, "salt"), None);
176 }
177
178 #[test]
179 fn a_sealed_password_opens_only_for_its_user_and_key() {
180 let sealed = auth::seal_password(&[1; 32], "mate", "hunter22").unwrap();
181 assert_eq!(
182 auth::open_password(&[1; 32], "mate", &sealed).as_deref(),
183 Some("hunter22")
184 );
185 assert_eq!(auth::open_password(&[1; 32], "owner", &sealed), None);
186 assert_eq!(auth::open_password(&[2; 32], "mate", &sealed), None);
187 }
188
189 #[test]
190 fn wrong_password_or_unknown_user_is_refused() {
191 let (v, _dir) = verifier();
192 assert_eq!(v.verify("mate", "hunter2"), None);
193 assert_eq!(v.verify("nobody", "hunter22"), None);
194 }
195
196 #[test]
197 fn a_changed_password_forgets_the_old_one() {
198 let (v, dir) = verifier();
199 assert!(v.verify("mate", "hunter22").is_some());
200 let db = Database::open(&dir.path().join("test.db")).unwrap();
201 auth_queries::update_password(&db.conn, "mate", "correct horse").unwrap();
202 assert_eq!(v.verify("mate", "hunter22"), None);
203 assert_eq!(v.verify("mate", "correct horse"), Some((1, Role::Readonly)));
204 }
205}