1use crate::db;
20use crate::ratelimit::{Limiter, Policy};
21use crate::secret::{random_bytes, sha256_hex};
22use anyhow::{Context, Result};
23use rusqlite::{Connection, OptionalExtension, params};
24use std::io::Write;
25use std::os::unix::fs::OpenOptionsExt;
26use std::path::{Path, PathBuf};
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::{Arc, Mutex, OnceLock, RwLock};
29
30pub const SETUP_TOKEN_TTL_SECS: i64 = 60 * 60;
32
33pub const MIN_PASSWORD_CHARS: usize = 12;
35
36pub const MAX_PASSWORD_BYTES: usize = 1024;
38
39pub const MAX_CONCURRENT_HASHES: usize = 2;
41
42pub const SETUP_URL_FILE: &str = "setup-url";
45
46const PUBLIC_URL_KEY: &str = "public_url";
48
49#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct PublicUrl {
56 origin: String,
57 https: bool,
58 rp_id: Option<String>,
60}
61
62impl PublicUrl {
63 pub fn parse(input: &str) -> Result<Self, String> {
64 let url = url::Url::parse(input.trim()).map_err(|err| format!("public_url is not a URL: {err}"))?;
65 if !url.username().is_empty() || url.password().is_some() {
66 return Err("public_url must not hold credentials".into());
67 }
68 if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
69 return Err("public_url must be an origin only, with no path, query or fragment".into());
70 }
71 let https = match url.scheme() {
72 "https" => true,
73 "http" if is_loopback_host(&url) => false,
74 "http" => return Err("public_url must be https://, or http:// to a loopback address".into()),
75 _ => return Err("public_url must be https:// or http://".into()),
76 };
77 let origin = url.origin().ascii_serialization();
78 let rp_id = url.domain().map(str::to_string);
79 Ok(Self { origin, https, rp_id })
80 }
81
82 pub fn origin(&self) -> &str {
84 &self.origin
85 }
86
87 pub fn is_https(&self) -> bool {
90 self.https
91 }
92
93 pub fn rp_id(&self) -> Option<&str> {
97 self.rp_id.as_deref()
98 }
99}
100
101fn is_loopback_host(url: &url::Url) -> bool {
102 match url.host() {
103 Some(url::Host::Domain(domain)) => domain == "localhost",
104 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
105 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
106 None => false,
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum SetupOutcome {
113 Done {
114 owner_id: String,
115 phc: String,
118 },
119 AlreadySetUp,
121 InvalidToken,
123 Invalid(String),
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum Reset {
131 Done {
136 sessions_ended: usize,
137 passkeys_removed: usize,
138 },
139 NotSetUp,
141 Invalid(String),
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum PublicUrlChange {
149 Done {
152 from: Option<PublicUrl>,
153 to: PublicUrl,
154 sessions_ended: usize,
155 passkeys_removed: usize,
156 },
157 NotSetUp,
159 Invalid(String),
162 SignedOut,
164 StepUpRequired,
166}
167
168struct SetupToken {
169 hash: String,
170 expires_at: i64,
171}
172
173#[derive(Clone, PartialEq, Eq)]
176pub struct SetupLink {
177 pub url: String,
179 pub file: PathBuf,
181}
182
183impl std::fmt::Debug for SetupLink {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 let base = self.url.split('#').next().unwrap_or_default();
186 f.debug_struct("SetupLink")
187 .field("url", &format_args!("{base}#<redacted>"))
188 .field("file", &self.file)
189 .finish()
190 }
191}
192
193pub struct Operator {
194 conn: crate::db::CoordinatedConnection,
195 owner: String,
197 public_url: RwLock<Option<PublicUrl>>,
200 setup: Mutex<Option<SetupToken>>,
201 announced: Mutex<Option<SetupLink>>,
204 hashing: Arc<tokio::sync::Semaphore>,
206 verifications: AtomicU64,
207 pub login_limiter: Limiter,
209 pub step_up_limiter: Limiter,
213 pub passkey_limiter: Limiter,
216 ended: tokio::sync::watch::Sender<u64>,
221 pub ceremonies: crate::passkeys::Ceremonies,
224 #[cfg(test)]
227 in_flight: std::sync::atomic::AtomicUsize,
228 #[cfg(test)]
229 max_in_flight: std::sync::atomic::AtomicUsize,
230 #[cfg(test)]
233 reset_hashes: AtomicU64,
234}
235
236impl Operator {
237 pub fn open(path: &Path) -> Result<Self> {
240 Self::init(db::open(path)?)
241 }
242
243 pub fn open_in_memory() -> Result<Self> {
244 Self::init(db::open_in_memory()?)
245 }
246
247 fn init(mut conn: crate::db::OwnedConnection) -> Result<Self> {
248 let owner = db::kernel_owner(&mut conn)?;
249 let public_url = load_public_url(&conn, &owner)?;
250 Ok(Self {
251 conn: crate::db::CoordinatedConnection::new(conn),
252 owner,
253 public_url: RwLock::new(public_url),
254 setup: Mutex::new(None),
255 announced: Mutex::new(None),
256 hashing: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_HASHES)),
257 verifications: AtomicU64::new(0),
258 login_limiter: Limiter::new(Policy::LOGIN),
259 step_up_limiter: Limiter::new(Policy::LOGIN),
260 passkey_limiter: Limiter::new(Policy::PASSKEY_LOGIN),
261 ended: tokio::sync::watch::Sender::new(0),
262 ceremonies: Default::default(),
263 #[cfg(test)]
264 in_flight: Default::default(),
265 #[cfg(test)]
266 max_in_flight: Default::default(),
267 #[cfg(test)]
268 reset_hashes: Default::default(),
269 })
270 }
271
272 pub(crate) fn conn(&self) -> crate::db::ConnectionGuard<'_> {
273 self.conn.lock().expect("operator lock")
274 }
275
276 pub fn owner_id(&self) -> &str {
279 &self.owner
280 }
281
282 pub fn is_set_up(&self) -> Result<bool> {
284 Ok(self.conn().query_row(
285 "SELECT set_up_at IS NOT NULL FROM owners WHERE id = ?1",
286 [&self.owner],
287 |r| r.get(0),
288 )?)
289 }
290
291 pub fn ping(&self) -> Result<()> {
293 self.conn().query_row("SELECT 1", [], |_| Ok(()))?;
294 Ok(())
295 }
296
297 pub fn public_url(&self) -> Option<PublicUrl> {
298 self.public_url.read().expect("public_url lock").clone()
299 }
300
301 pub fn issue_setup_token(&self, now: i64) -> Result<Option<String>> {
305 if self.is_set_up()? {
306 return Ok(None);
307 }
308 let token = hex::encode(random_bytes::<32>());
309 *self.setup.lock().expect("setup lock") = Some(SetupToken {
310 hash: sha256_hex(token.as_bytes()),
311 expires_at: now + SETUP_TOKEN_TTL_SECS,
312 });
313 Ok(Some(token))
314 }
315
316 pub fn announce_setup(&self, dir: &Path, base_url: &str, now: i64) -> Result<Option<SetupLink>> {
323 let file = dir.join(SETUP_URL_FILE);
324 let Some(token) = self.issue_setup_token(now)? else {
325 remove_setup_file(&file);
326 return Ok(None);
327 };
328 let url = format!("{}/setup#{token}", base_url.trim_end_matches('/'));
329 let temp = dir.join(format!(".{SETUP_URL_FILE}.{}.tmp", hex::encode(random_bytes::<8>())));
330 let written = (|| {
331 let mut out = std::fs::OpenOptions::new()
332 .write(true)
333 .create_new(true)
334 .mode(0o600)
335 .open(&temp)?;
336 writeln!(out, "{url}")?;
337 out.sync_all()?;
338 std::fs::rename(&temp, &file)
339 })();
340 if let Err(err) = written {
341 let _ = std::fs::remove_file(&temp);
342 return Err(err).with_context(|| format!("write {}", file.display()));
343 }
344 let link = SetupLink { url, file };
345 *self.announced.lock().expect("announced lock") = Some(link.clone());
346 Ok(Some(link))
347 }
348
349 pub fn setup_link(&self, dir: &Path, base_url: &str, now: i64) -> Result<Option<SetupLink>> {
354 if self.is_set_up()? {
355 return Ok(None);
356 }
357 let live = self
358 .setup
359 .lock()
360 .expect("setup lock")
361 .as_ref()
362 .is_some_and(|t| t.expires_at > now);
363 if live && let Some(link) = self.announced.lock().expect("announced lock").clone() {
364 return Ok(Some(link));
365 }
366 self.announce_setup(dir, base_url, now)
367 }
368
369 pub fn set_up(&self, token: &str, password: &str, public_url: &str, now: i64) -> Result<SetupOutcome> {
373 self.set_up_naming_hat(token, password, public_url, None, now)
374 }
375
376 pub fn set_up_naming_hat(
384 &self,
385 token: &str,
386 password: &str,
387 public_url: &str,
388 hat_name: Option<&str>,
389 now: i64,
390 ) -> Result<SetupOutcome> {
391 let mut setup = self.setup.lock().expect("setup lock");
393 if self.is_set_up()? {
394 return Ok(SetupOutcome::AlreadySetUp);
395 }
396 let live = setup
397 .as_ref()
398 .is_some_and(|t| t.expires_at > now && t.hash == sha256_hex(token.as_bytes()));
399 if !live {
400 return Ok(SetupOutcome::InvalidToken);
401 }
402 if let Some(problem) = password_problem(password) {
403 return Ok(SetupOutcome::Invalid(problem));
404 }
405 let public_url = match PublicUrl::parse(public_url) {
406 Ok(url) => url,
407 Err(problem) => return Ok(SetupOutcome::Invalid(problem)),
408 };
409 if let Some(problem) = hat_name.and_then(crate::hats::hat_name_problem) {
410 return Ok(SetupOutcome::Invalid(problem));
411 }
412 let phc = password_auth::generate_hash(password);
413 let owner_id = self.owner.clone();
414 {
415 let mut conn = self.conn();
416 let tx = conn.transaction()?;
417 let marked = tx.execute(
420 "UPDATE owners SET set_up_at = ?2 WHERE id = ?1 AND set_up_at IS NULL",
421 params![owner_id, now],
422 )?;
423 if marked == 0 {
424 return Ok(SetupOutcome::AlreadySetUp);
425 }
426 tx.execute(
427 "INSERT INTO password_credentials(owner_id, phc, updated_at) VALUES (?1, ?2, ?3)",
428 params![owner_id, phc, now],
429 )?;
430 tx.execute(
431 "INSERT INTO settings(owner_id, key, value) VALUES (?1, ?2, ?3)",
432 params![owner_id, PUBLIC_URL_KEY, public_url.origin()],
433 )?;
434 if let Some(name) = hat_name {
435 let renamed = tx.execute(
438 "UPDATE hats SET name = ?2 WHERE owner_id = ?1
439 AND id = (SELECT value FROM settings WHERE owner_id = ?1 AND key = ?3)",
440 params![owner_id, name.trim(), crate::hats::DEFAULT_HAT_KEY],
441 )?;
442 anyhow::ensure!(renamed == 1, "the owner has no default hat to name");
443 }
444 tx.commit()?;
445 *self.public_url.write().expect("public_url lock") = Some(public_url);
448 }
449 *setup = None;
450 if let Some(link) = self.announced.lock().expect("announced lock").take() {
451 remove_setup_file(&link.file);
452 }
453 Ok(SetupOutcome::Done { owner_id, phc })
454 }
455
456 pub fn verify_password(&self, password: &str) -> Result<Option<String>> {
463 #[cfg(test)]
465 let _gauge = InFlight::enter(self);
466 self.verifications.fetch_add(1, Ordering::Relaxed);
467 let phc: Option<String> = self
468 .conn()
469 .query_row(
470 "SELECT phc FROM password_credentials WHERE owner_id = ?1",
471 [&self.owner],
472 |r| r.get(0),
473 )
474 .optional()?;
475 let Some(phc) = phc else {
476 let _ = password_auth::verify_password(password, dummy_hash());
477 return Ok(None);
478 };
479 let password = if password.len() > MAX_PASSWORD_BYTES {
482 ""
483 } else {
484 password
485 };
486 Ok(password_auth::verify_password(password, &phc).is_ok().then_some(phc))
487 }
488
489 pub async fn check_password(self: &Arc<Self>, password: String) -> Result<Option<String>> {
494 let permit = self
495 .hashing
496 .clone()
497 .acquire_owned()
498 .await
499 .context("the hashing semaphore is closed")?;
500 let this = self.clone();
501 tokio::task::spawn_blocking(move || {
502 let _permit = permit;
503 this.verify_password(&password)
504 })
505 .await?
506 }
507
508 pub fn verifications(&self) -> u64 {
512 self.verifications.load(Ordering::Relaxed)
513 }
514
515 pub async fn reset_password(self: &Arc<Self>, password: String, now: i64) -> Result<Reset> {
529 if let Some(problem) = password_problem(&password) {
530 return Ok(Reset::Invalid(problem));
531 }
532 if !self.is_set_up()? {
533 return Ok(Reset::NotSetUp);
534 }
535 let permit = self
536 .hashing
537 .clone()
538 .acquire_owned()
539 .await
540 .context("the hashing semaphore is closed")?;
541 #[cfg(test)]
542 let this = self.clone();
543 let phc = tokio::task::spawn_blocking(move || {
544 let _permit = permit;
545 #[cfg(test)]
546 this.reset_hashes.fetch_add(1, Ordering::SeqCst);
547 password_auth::generate_hash(password)
548 })
549 .await?;
550 let (ended, passkeys_removed) = {
551 let mut conn = self.conn();
552 let tx = conn.transaction()?;
553 let replaced = tx.execute(
554 "UPDATE password_credentials SET phc = ?2, updated_at = ?3 WHERE owner_id = ?1",
555 params![self.owner, phc, now],
556 )?;
557 anyhow::ensure!(replaced == 1, "the owner has no password to reset");
559 let ended = tx.execute("DELETE FROM auth_sessions WHERE owner_id = ?1", [&self.owner])?;
560 tx.execute("DELETE FROM push_subscriptions WHERE owner_id = ?1", [&self.owner])?;
564 let passkeys_removed = tx.execute("DELETE FROM passkeys WHERE owner_id = ?1", [&self.owner])?;
565 tx.commit()?;
566 self.ceremonies.clear();
569 (ended, passkeys_removed)
570 };
571 self.sessions_ended();
572 self.login_limiter.clear();
573 self.step_up_limiter.clear();
574 self.passkey_limiter.clear();
575 Ok(Reset::Done {
576 sessions_ended: ended,
577 passkeys_removed,
578 })
579 }
580
581 pub fn reset_public_url(&self, input: &str) -> Result<Reset> {
591 Ok(match self.change_public_url(input, None, None)? {
592 PublicUrlChange::Done {
593 sessions_ended,
594 passkeys_removed,
595 ..
596 } => Reset::Done {
597 sessions_ended,
598 passkeys_removed,
599 },
600 PublicUrlChange::NotSetUp => Reset::NotSetUp,
601 PublicUrlChange::Invalid(problem) => Reset::Invalid(problem),
602 PublicUrlChange::SignedOut | PublicUrlChange::StepUpRequired => {
605 anyhow::bail!("the admin socket's reset re-checked a session")
606 }
607 })
608 }
609
610 pub fn change_public_url(
623 &self,
624 input: &str,
625 contact: Option<Option<&str>>,
626 caller: Option<(&str, i64)>,
627 ) -> Result<PublicUrlChange> {
628 let public_url = match PublicUrl::parse(input) {
629 Ok(url) => url,
630 Err(problem) => return Ok(PublicUrlChange::Invalid(problem)),
631 };
632 if let Some(problem) = contact.flatten().and_then(crate::push::contact_problem) {
633 return Ok(PublicUrlChange::Invalid(problem));
634 }
635 if !self.is_set_up()? {
636 return Ok(PublicUrlChange::NotSetUp);
637 }
638 let (from, ended, passkeys_removed) = {
639 let mut conn = self.conn();
640 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
641 if let Some((session_id, now)) = caller {
642 let stepped_up_at: Option<Option<i64>> = tx
643 .query_row(
644 "SELECT last_step_up_at FROM auth_sessions
645 WHERE id_hash = ?1 AND owner_id = ?2 AND expires_at > ?3",
646 params![session_id, self.owner, now],
647 |r| r.get(0),
648 )
649 .optional()?;
650 match stepped_up_at {
651 None => return Ok(PublicUrlChange::SignedOut),
652 Some(at) if !at.is_some_and(|at| now - at < STEP_UP_SECS) => {
654 return Ok(PublicUrlChange::StepUpRequired);
655 }
656 Some(_) => {}
657 }
658 }
659 if let Some(contact) = contact {
660 tx.execute(
661 "UPDATE owners SET contact = ?2 WHERE id = ?1",
662 params![self.owner, contact],
663 )?;
664 }
665 tx.execute(
666 "INSERT INTO settings(owner_id, key, value) VALUES (?1, ?2, ?3)
667 ON CONFLICT(owner_id, key) DO UPDATE SET value = excluded.value",
668 params![self.owner, PUBLIC_URL_KEY, public_url.origin()],
669 )?;
670 let ended = tx.execute("DELETE FROM auth_sessions WHERE owner_id = ?1", [&self.owner])?;
671 tx.execute("DELETE FROM push_subscriptions WHERE owner_id = ?1", [&self.owner])?;
674 let from = self.public_url();
676 let same_host = from.as_ref().and_then(PublicUrl::rp_id) == public_url.rp_id();
677 let passkeys_removed = if same_host {
678 0
679 } else {
680 tx.execute("DELETE FROM passkeys WHERE owner_id = ?1", [&self.owner])?
681 };
682 tx.commit()?;
683 *self.public_url.write().expect("public_url lock") = Some(public_url.clone());
686 self.ceremonies.clear();
687 (from, ended, passkeys_removed)
688 };
689 self.sessions_ended();
690 Ok(PublicUrlChange::Done {
691 from,
692 to: public_url,
693 sessions_ended: ended,
694 passkeys_removed,
695 })
696 }
697
698 pub fn contact(&self) -> Result<Option<String>> {
701 Ok(self
702 .conn()
703 .query_row("SELECT contact FROM owners WHERE id = ?1", [&self.owner], |r| r.get(0))?)
704 }
705
706 pub fn set_contact(&self, contact: Option<&str>) -> Result<Result<(), String>> {
710 if let Some(problem) = contact.and_then(crate::push::contact_problem) {
711 return Ok(Err(problem));
712 }
713 self.conn().execute(
714 "UPDATE owners SET contact = ?2 WHERE id = ?1",
715 params![self.owner, contact],
716 )?;
717 Ok(Ok(()))
718 }
719
720 fn sessions_ended(&self) {
722 self.ended.send_modify(|generation| *generation += 1);
723 }
724}
725
726fn remove_setup_file(file: &Path) {
727 match std::fs::remove_file(file) {
728 Ok(()) => {}
729 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
730 Err(err) => tracing::warn!(file = %file.display(), error = %err, "could not remove the setup link"),
731 }
732}
733
734fn load_public_url(conn: &Connection, owner: &str) -> Result<Option<PublicUrl>> {
735 let stored: Option<String> = conn
736 .query_row(
737 "SELECT value FROM settings WHERE owner_id = ?1 AND key = ?2",
738 [owner, PUBLIC_URL_KEY],
739 |r| r.get(0),
740 )
741 .optional()?;
742 stored
743 .map(|s| PublicUrl::parse(&s).map_err(|why| anyhow::anyhow!("the stored public_url is invalid: {why}")))
744 .transpose()
745}
746
747fn password_problem(password: &str) -> Option<String> {
749 if password.chars().count() < MIN_PASSWORD_CHARS {
750 return Some(format!("the password must be at least {MIN_PASSWORD_CHARS} characters"));
751 }
752 if password.len() > MAX_PASSWORD_BYTES {
753 return Some(format!("the password must be at most {MAX_PASSWORD_BYTES} bytes"));
754 }
755 None
756}
757
758fn dummy_hash() -> &'static str {
761 static HASH: OnceLock<String> = OnceLock::new();
762 HASH.get_or_init(|| password_auth::generate_hash(hex::encode(random_bytes::<16>())))
763}
764
765pub const SESSION_TTL_SECS: i64 = 30 * 24 * 60 * 60;
767
768pub const SESSION_SLIDE_SECS: i64 = 60;
771
772pub const STEP_UP_SECS: i64 = 5 * 60;
775
776pub const SESSION_COOKIE: &str = "hennery_session";
778
779const MAX_USER_AGENT: usize = 256;
781
782pub(crate) fn kept_user_agent(user_agent: &str) -> String {
785 user_agent
786 .chars()
787 .filter(|c| !c.is_control())
788 .take(MAX_USER_AGENT)
789 .collect()
790}
791
792#[derive(Debug, Clone, PartialEq, Eq)]
794pub struct Authenticated {
795 pub session_id: String,
797 pub owner_id: String,
798 pub last_step_up_at: Option<i64>,
799 pub expires_at: i64,
800 pub slid: bool,
802}
803
804impl Authenticated {
805 pub fn stepped_up(&self, now: i64) -> bool {
807 self.last_step_up_at.is_some_and(|at| now - at < STEP_UP_SECS)
808 }
809}
810
811pub(crate) fn insert_auth_session(
819 conn: &Connection,
820 owner: &str,
821 token: &str,
822 user_agent: &str,
823 verified: Option<&str>,
824 now: i64,
825) -> Result<bool> {
826 conn.execute(
827 "DELETE FROM auth_sessions WHERE expires_at <= ?1 AND owner_id = ?2",
828 params![now, owner],
829 )?;
830 let opened = conn.execute(
831 "INSERT INTO auth_sessions(id_hash, owner_id, user_agent, created_at, last_seen_at, last_step_up_at, expires_at)
832 SELECT ?1, ?2, ?3, ?4, ?4, ?4, ?5
833 WHERE ?6 IS NULL OR EXISTS (SELECT 1 FROM password_credentials WHERE owner_id = ?2 AND phc = ?6)",
834 params![
835 sha256_hex(token.as_bytes()),
836 owner,
837 kept_user_agent(user_agent),
838 now,
839 now + SESSION_TTL_SECS,
840 verified
841 ],
842 )?;
843 Ok(opened > 0)
844}
845
846#[derive(Debug, Clone, PartialEq, Eq)]
848pub struct AuthSession {
849 pub id: String,
850 pub user_agent: String,
851 pub created_at: i64,
852 pub last_seen_at: i64,
853 pub last_step_up_at: Option<i64>,
854 pub expires_at: i64,
855}
856
857impl Operator {
858 pub fn open_session(&self, user_agent: &str, verified: &str, now: i64) -> Result<Option<String>> {
866 let token = hex::encode(random_bytes::<32>());
867 let opened = insert_auth_session(&self.conn(), &self.owner, &token, user_agent, Some(verified), now)?;
868 Ok(opened.then_some(token))
869 }
870
871 pub fn authenticate(&self, token: &str, now: i64) -> Result<Option<Authenticated>> {
874 if token.len() != 64 {
875 return Ok(None);
876 }
877 let id = sha256_hex(token.as_bytes());
878 let conn = self.conn();
879 let row: Option<(i64, Option<i64>, i64)> = conn
880 .query_row(
881 "SELECT last_seen_at, last_step_up_at, expires_at FROM auth_sessions
882 WHERE id_hash = ?1 AND expires_at > ?2 AND owner_id = ?3",
883 params![id, now, self.owner],
884 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
885 )
886 .optional()?;
887 let Some((last_seen_at, last_step_up_at, mut expires_at)) = row else {
888 return Ok(None);
889 };
890 let slid = now - last_seen_at >= SESSION_SLIDE_SECS;
891 if slid {
892 expires_at = now + SESSION_TTL_SECS;
893 conn.execute(
894 "UPDATE auth_sessions SET last_seen_at = ?2, expires_at = ?3 WHERE id_hash = ?1 AND owner_id = ?4",
895 params![id, now, expires_at, self.owner],
896 )?;
897 }
898 Ok(Some(Authenticated {
899 session_id: id,
900 owner_id: self.owner.clone(),
901 last_step_up_at,
902 expires_at,
903 slid,
904 }))
905 }
906
907 pub fn step_up(&self, session_id: &str, now: i64) -> Result<bool> {
910 let changed = self.conn().execute(
911 "UPDATE auth_sessions SET last_step_up_at = ?2 WHERE id_hash = ?1 AND owner_id = ?3",
912 params![session_id, now, self.owner],
913 )?;
914 Ok(changed > 0)
915 }
916
917 pub fn sessions(&self, now: i64) -> Result<Vec<AuthSession>> {
919 let conn = self.conn();
920 let mut stmt = conn.prepare(
921 "SELECT id_hash, user_agent, created_at, last_seen_at, last_step_up_at, expires_at
922 FROM auth_sessions WHERE expires_at > ?1 AND owner_id = ?2 ORDER BY last_seen_at DESC, id_hash",
923 )?;
924 let rows = stmt.query_map(params![now, self.owner], |r| {
925 Ok(AuthSession {
926 id: r.get(0)?,
927 user_agent: r.get(1)?,
928 created_at: r.get(2)?,
929 last_seen_at: r.get(3)?,
930 last_step_up_at: r.get(4)?,
931 expires_at: r.get(5)?,
932 })
933 })?;
934 Ok(rows.collect::<rusqlite::Result<_>>()?)
935 }
936
937 pub fn revoke_session(&self, session_id: &str, now: i64) -> Result<bool> {
942 let mut conn = self.conn();
943 let tx = conn.transaction()?;
944 let changed = tx.execute(
945 "DELETE FROM auth_sessions WHERE id_hash = ?1 AND expires_at > ?2 AND owner_id = ?3",
946 params![session_id, now, self.owner],
947 )?;
948 if changed > 0 {
949 tx.execute(
950 "DELETE FROM push_subscriptions WHERE auth_session = ?1 AND owner_id = ?2",
951 params![session_id, self.owner],
952 )?;
953 }
954 tx.commit()?;
955 drop(conn);
956 if changed > 0 {
957 self.sessions_ended();
958 }
959 Ok(changed > 0)
960 }
961
962 pub fn session_ends(&self) -> tokio::sync::watch::Receiver<u64> {
965 self.ended.subscribe()
966 }
967
968 pub fn session_expires_at(&self, session_id: &str, now: i64) -> Result<Option<i64>> {
971 Ok(self
972 .conn()
973 .query_row(
974 "SELECT expires_at FROM auth_sessions WHERE id_hash = ?1 AND expires_at > ?2 AND owner_id = ?3",
975 params![session_id, now, self.owner],
976 |r| r.get(0),
977 )
978 .optional()?)
979 }
980
981 pub fn authenticate_cookies(
985 &self,
986 headers: &axum::http::HeaderMap,
987 now: i64,
988 ) -> Result<Option<(String, Authenticated)>> {
989 for token in session_tokens(headers) {
990 if let Some(session) = self.authenticate(token, now)? {
991 return Ok(Some((token.to_string(), session)));
992 }
993 }
994 Ok(None)
995 }
996}
997
998pub fn session_cookie(token: &str, secure: bool) -> String {
1002 let secure = if secure { "; Secure" } else { "" };
1003 format!("{SESSION_COOKIE}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={SESSION_TTL_SECS}{secure}")
1004}
1005
1006pub fn cleared_cookie(secure: bool) -> String {
1008 let secure = if secure { "; Secure" } else { "" };
1009 format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0{secure}")
1010}
1011
1012pub const MAX_SESSION_COOKIES: usize = 4;
1015
1016pub fn session_tokens(headers: &axum::http::HeaderMap) -> Vec<&str> {
1021 headers
1022 .get_all(axum::http::header::COOKIE)
1023 .iter()
1024 .filter_map(|v| v.to_str().ok())
1025 .flat_map(|v| v.split(';'))
1026 .filter_map(|pair| {
1027 let (name, value) = pair.trim().split_once('=')?;
1028 (name == SESSION_COOKIE).then_some(value)
1029 })
1030 .take(MAX_SESSION_COOKIES)
1031 .collect()
1032}
1033
1034#[cfg(test)]
1036struct InFlight<'a>(&'a Operator);
1037
1038#[cfg(test)]
1039impl<'a> InFlight<'a> {
1040 fn enter(op: &'a Operator) -> Self {
1041 let now = op.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1042 op.max_in_flight.fetch_max(now, Ordering::SeqCst);
1043 Self(op)
1044 }
1045}
1046
1047#[cfg(test)]
1048impl Drop for InFlight<'_> {
1049 fn drop(&mut self) {
1050 self.0.in_flight.fetch_sub(1, Ordering::SeqCst);
1051 }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::*;
1057 use std::time::{Duration, Instant};
1058
1059 const NOW: i64 = 1_800_000_000;
1060 const PASSWORD: &str = "correct horse battery";
1061
1062 fn set_up() -> Arc<Operator> {
1063 let op = Arc::new(Operator::open_in_memory().unwrap());
1064 let token = op.issue_setup_token(NOW).unwrap().unwrap();
1065 op.set_up(&token, PASSWORD, "https://hennery.example", NOW).unwrap();
1066 op
1067 }
1068
1069 async fn until(what: &str, done: impl Fn() -> bool) {
1071 let deadline = Instant::now() + Duration::from_secs(30);
1072 while !done() {
1073 assert!(Instant::now() < deadline, "timed out waiting until {what}");
1074 tokio::time::sleep(Duration::from_millis(1)).await;
1075 }
1076 }
1077
1078 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1079 async fn at_most_two_checks_verify_at_once() {
1080 let op = set_up();
1081 let checks: Vec<_> = (0..8)
1082 .map(|_| {
1083 let op = op.clone();
1084 tokio::spawn(async move { op.check_password(PASSWORD.to_string()).await.unwrap() })
1085 })
1086 .collect();
1087 for check in checks {
1088 assert!(check.await.unwrap().is_some());
1089 }
1090 assert_eq!(op.verifications(), 8);
1091 assert!(op.max_in_flight.load(Ordering::SeqCst) <= MAX_CONCURRENT_HASHES);
1092 }
1093
1094 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1098 async fn a_password_reset_hashes_only_with_a_hashing_slot() {
1099 let op = set_up();
1100 let held = op
1101 .hashing
1102 .clone()
1103 .acquire_many_owned(MAX_CONCURRENT_HASHES as u32)
1104 .await
1105 .unwrap();
1106 let reset = {
1107 let op = op.clone();
1108 tokio::spawn(async move { op.reset_password("a new long password".into(), NOW).await })
1109 };
1110 tokio::time::sleep(Duration::from_millis(200)).await;
1113 assert_eq!(op.reset_hashes.load(Ordering::SeqCst), 0, "hashed without a slot");
1114 drop(held);
1115 assert_eq!(
1116 reset.await.unwrap().unwrap(),
1117 Reset::Done {
1118 sessions_ended: 0,
1119 passkeys_removed: 0
1120 }
1121 );
1122 assert_eq!(op.reset_hashes.load(Ordering::SeqCst), 1);
1123 }
1124
1125 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1128 async fn an_abandoned_check_keeps_its_slot_until_its_verify_ends() {
1129 let op = set_up();
1130 for round in 1..=8u64 {
1131 let checks: Vec<_> = (0..2)
1132 .map(|_| {
1133 let op = op.clone();
1134 tokio::spawn(async move { op.check_password(PASSWORD.to_string()).await })
1135 })
1136 .collect();
1137 until("both checks of the round verify", || op.verifications() == 2 * round).await;
1138 for check in &checks {
1139 check.abort();
1140 }
1141 }
1142 until("every verify ends", || op.in_flight.load(Ordering::SeqCst) == 0).await;
1143 let max = op.max_in_flight.load(Ordering::SeqCst);
1144 assert!(max <= MAX_CONCURRENT_HASHES, "{max} verifies ran at once");
1145 }
1146}