1use std::num::NonZeroUsize;
14use std::path::PathBuf;
15use std::time::{Duration, Instant};
16
17use koan_core::auth::{self, Role};
18use koan_core::db::connection::Database;
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 db_path: PathBuf,
28 verified: Mutex<LruCache<[u8; 32], Instant>>,
29 sealing: Option<[u8; 32]>,
32}
33
34impl PasswordVerifier {
35 pub fn new(db_path: PathBuf) -> 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(db_path, sealing)
40 }
41
42 pub fn with_key(db_path: PathBuf, sealing: Option<[u8; 32]>) -> Self {
43 Self {
44 db_path,
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<Role> {
54 use subtle::ConstantTimeEq;
55 let key = self.sealing.as_ref()?;
56 let db = Database::open(&self.db_path).ok()?;
57 let sealed = auth_queries::sealed_password(&db.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 && Database::open(&self.db_path)
75 .ok()
76 .and_then(|db| auth_queries::sealed_password(&db.conn, username).ok())
77 .flatten()
78 .is_some()
79 }
80
81 pub fn verify(&self, username: &str, password: &str) -> Option<Role> {
83 let db = Database::open(&self.db_path).ok()?;
84 let Some(user) = auth_queries::get_user_by_username(&db.conn, username).ok()? else {
85 let _ = auth::verify_password(password, super::routes::dummy_password_hash());
88 return None;
89 };
90 let key: [u8; 32] = Sha256::new()
91 .chain_update(username)
92 .chain_update([0])
93 .chain_update(password)
94 .chain_update([0])
95 .chain_update(&user.password_hash)
96 .finalize()
97 .into();
98 let fresh = self
99 .verified
100 .lock()
101 .get(&key)
102 .is_some_and(|at| at.elapsed() < REMEMBER);
103 if !fresh {
104 auth::verify_password(password, &user.password_hash).ok()?;
105 self.verified.lock().put(key, Instant::now());
106 if let Some(k) = &self.sealing
107 && let Ok(sealed) = auth::seal_password(k, username, password)
108 {
109 let _ = auth_queries::set_sealed_password(&db.conn, username, &sealed);
110 }
111 }
112 Some(user.role)
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 fn verifier() -> (PasswordVerifier, tempfile::TempDir) {
121 let dir = tempfile::tempdir().unwrap();
122 let path = dir.path().join("test.db");
123 let db = Database::open(&path).unwrap();
124 koan_core::db::schema::create_tables(&db.conn).unwrap();
125 auth_queries::create_user(&db.conn, "mate", "hunter22", Role::Readonly).unwrap();
126 (PasswordVerifier::with_key(path, Some([7; 32])), dir)
127 }
128
129 #[test]
130 fn right_password_gives_the_users_role() {
131 let (v, _dir) = verifier();
132 assert_eq!(v.verify("mate", "hunter22"), Some(Role::Readonly));
133 assert_eq!(v.verify("mate", "hunter22"), Some(Role::Readonly));
135 }
136
137 #[test]
138 fn token_auth_works_once_a_password_sign_in_sealed_it() {
139 let (v, _dir) = verifier();
140 let token = |pw: &str, salt: &str| format!("{:x}", md5::compute(format!("{pw}{salt}")));
141 assert_eq!(
142 v.verify_token("mate", &token("hunter22", "abc"), "abc"),
143 None
144 );
145 assert!(!v.has_sealed("mate"));
146 v.verify("mate", "hunter22").unwrap();
147 assert!(v.has_sealed("mate"));
148 assert_eq!(
149 v.verify_token("mate", &token("hunter22", "abc"), "abc"),
150 Some(Role::Readonly)
151 );
152 assert_eq!(
153 v.verify_token("mate", &token("hunter2", "abc"), "abc"),
154 None
155 );
156 assert_eq!(
157 v.verify_token("nobody", &token("hunter22", "abc"), "abc"),
158 None
159 );
160 }
161
162 #[test]
163 fn a_password_changed_elsewhere_makes_the_sealed_copy_fail() {
164 let (v, dir) = verifier();
165 v.verify("mate", "hunter22").unwrap();
166 let db = Database::open(&dir.path().join("test.db")).unwrap();
167 auth_queries::update_password(&db.conn, "mate", "correct horse").unwrap();
168 let token = format!("{:x}", md5::compute("hunter22salt"));
169 assert_eq!(v.verify_token("mate", &token, "salt"), None);
170 }
171
172 #[test]
173 fn a_sealed_password_opens_only_for_its_user_and_key() {
174 let sealed = auth::seal_password(&[1; 32], "mate", "hunter22").unwrap();
175 assert_eq!(
176 auth::open_password(&[1; 32], "mate", &sealed).as_deref(),
177 Some("hunter22")
178 );
179 assert_eq!(auth::open_password(&[1; 32], "owner", &sealed), None);
180 assert_eq!(auth::open_password(&[2; 32], "mate", &sealed), None);
181 }
182
183 #[test]
184 fn wrong_password_or_unknown_user_is_refused() {
185 let (v, _dir) = verifier();
186 assert_eq!(v.verify("mate", "hunter2"), None);
187 assert_eq!(v.verify("nobody", "hunter22"), None);
188 }
189
190 #[test]
191 fn a_changed_password_forgets_the_old_one() {
192 let (v, dir) = verifier();
193 assert!(v.verify("mate", "hunter22").is_some());
194 let db = Database::open(&dir.path().join("test.db")).unwrap();
195 auth_queries::update_password(&db.conn, "mate", "correct horse").unwrap();
196 assert_eq!(v.verify("mate", "hunter22"), None);
197 assert_eq!(v.verify("mate", "correct horse"), Some(Role::Readonly));
198 }
199}