use crate::db;
use crate::ratelimit::{Limiter, Policy};
use crate::secret::{random_bytes, sha256_hex};
use anyhow::{Context, Result};
use rusqlite::{Connection, OptionalExtension, params};
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
pub const SETUP_TOKEN_TTL_SECS: i64 = 60 * 60;
pub const MIN_PASSWORD_CHARS: usize = 12;
pub const MAX_PASSWORD_BYTES: usize = 1024;
pub const MAX_CONCURRENT_HASHES: usize = 2;
pub const SETUP_URL_FILE: &str = "setup-url";
const PUBLIC_URL_KEY: &str = "public_url";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublicUrl {
origin: String,
https: bool,
rp_id: Option<String>,
}
impl PublicUrl {
pub fn parse(input: &str) -> Result<Self, String> {
let url = url::Url::parse(input.trim()).map_err(|err| format!("public_url is not a URL: {err}"))?;
if !url.username().is_empty() || url.password().is_some() {
return Err("public_url must not hold credentials".into());
}
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
return Err("public_url must be an origin only, with no path, query or fragment".into());
}
let https = match url.scheme() {
"https" => true,
"http" if is_loopback_host(&url) => false,
"http" => return Err("public_url must be https://, or http:// to a loopback address".into()),
_ => return Err("public_url must be https:// or http://".into()),
};
let origin = url.origin().ascii_serialization();
let rp_id = url.domain().map(str::to_string);
Ok(Self { origin, https, rp_id })
}
pub fn origin(&self) -> &str {
&self.origin
}
pub fn is_https(&self) -> bool {
self.https
}
pub fn rp_id(&self) -> Option<&str> {
self.rp_id.as_deref()
}
}
fn is_loopback_host(url: &url::Url) -> bool {
match url.host() {
Some(url::Host::Domain(domain)) => domain == "localhost",
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SetupOutcome {
Done {
owner_id: String,
phc: String,
},
AlreadySetUp,
InvalidToken,
Invalid(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Reset {
Done {
sessions_ended: usize,
passkeys_removed: usize,
},
NotSetUp,
Invalid(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PublicUrlChange {
Done {
from: Option<PublicUrl>,
to: PublicUrl,
sessions_ended: usize,
passkeys_removed: usize,
},
NotSetUp,
Invalid(String),
SignedOut,
StepUpRequired,
}
struct SetupToken {
hash: String,
expires_at: i64,
}
#[derive(Clone, PartialEq, Eq)]
pub struct SetupLink {
pub url: String,
pub file: PathBuf,
}
impl std::fmt::Debug for SetupLink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let base = self.url.split('#').next().unwrap_or_default();
f.debug_struct("SetupLink")
.field("url", &format_args!("{base}#<redacted>"))
.field("file", &self.file)
.finish()
}
}
pub struct Operator {
conn: crate::db::CoordinatedConnection,
owner: String,
public_url: RwLock<Option<PublicUrl>>,
setup: Mutex<Option<SetupToken>>,
announced: Mutex<Option<SetupLink>>,
hashing: Arc<tokio::sync::Semaphore>,
verifications: AtomicU64,
pub login_limiter: Limiter,
pub step_up_limiter: Limiter,
pub passkey_limiter: Limiter,
ended: tokio::sync::watch::Sender<u64>,
pub ceremonies: crate::passkeys::Ceremonies,
#[cfg(test)]
in_flight: std::sync::atomic::AtomicUsize,
#[cfg(test)]
max_in_flight: std::sync::atomic::AtomicUsize,
#[cfg(test)]
reset_hashes: AtomicU64,
}
impl Operator {
pub fn open(path: &Path) -> Result<Self> {
Self::init(db::open(path)?)
}
pub fn open_in_memory() -> Result<Self> {
Self::init(db::open_in_memory()?)
}
fn init(mut conn: crate::db::OwnedConnection) -> Result<Self> {
let owner = db::kernel_owner(&mut conn)?;
let public_url = load_public_url(&conn, &owner)?;
Ok(Self {
conn: crate::db::CoordinatedConnection::new(conn),
owner,
public_url: RwLock::new(public_url),
setup: Mutex::new(None),
announced: Mutex::new(None),
hashing: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_HASHES)),
verifications: AtomicU64::new(0),
login_limiter: Limiter::new(Policy::LOGIN),
step_up_limiter: Limiter::new(Policy::LOGIN),
passkey_limiter: Limiter::new(Policy::PASSKEY_LOGIN),
ended: tokio::sync::watch::Sender::new(0),
ceremonies: Default::default(),
#[cfg(test)]
in_flight: Default::default(),
#[cfg(test)]
max_in_flight: Default::default(),
#[cfg(test)]
reset_hashes: Default::default(),
})
}
pub(crate) fn conn(&self) -> crate::db::ConnectionGuard<'_> {
self.conn.lock().expect("operator lock")
}
pub fn owner_id(&self) -> &str {
&self.owner
}
pub fn is_set_up(&self) -> Result<bool> {
Ok(self.conn().query_row(
"SELECT set_up_at IS NOT NULL FROM owners WHERE id = ?1",
[&self.owner],
|r| r.get(0),
)?)
}
pub fn ping(&self) -> Result<()> {
self.conn().query_row("SELECT 1", [], |_| Ok(()))?;
Ok(())
}
pub fn public_url(&self) -> Option<PublicUrl> {
self.public_url.read().expect("public_url lock").clone()
}
pub fn issue_setup_token(&self, now: i64) -> Result<Option<String>> {
if self.is_set_up()? {
return Ok(None);
}
let token = hex::encode(random_bytes::<32>());
*self.setup.lock().expect("setup lock") = Some(SetupToken {
hash: sha256_hex(token.as_bytes()),
expires_at: now + SETUP_TOKEN_TTL_SECS,
});
Ok(Some(token))
}
pub fn announce_setup(&self, dir: &Path, base_url: &str, now: i64) -> Result<Option<SetupLink>> {
let file = dir.join(SETUP_URL_FILE);
let Some(token) = self.issue_setup_token(now)? else {
remove_setup_file(&file);
return Ok(None);
};
let url = format!("{}/setup#{token}", base_url.trim_end_matches('/'));
let temp = dir.join(format!(".{SETUP_URL_FILE}.{}.tmp", hex::encode(random_bytes::<8>())));
let written = (|| {
let mut out = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temp)?;
writeln!(out, "{url}")?;
out.sync_all()?;
std::fs::rename(&temp, &file)
})();
if let Err(err) = written {
let _ = std::fs::remove_file(&temp);
return Err(err).with_context(|| format!("write {}", file.display()));
}
let link = SetupLink { url, file };
*self.announced.lock().expect("announced lock") = Some(link.clone());
Ok(Some(link))
}
pub fn setup_link(&self, dir: &Path, base_url: &str, now: i64) -> Result<Option<SetupLink>> {
if self.is_set_up()? {
return Ok(None);
}
let live = self
.setup
.lock()
.expect("setup lock")
.as_ref()
.is_some_and(|t| t.expires_at > now);
if live && let Some(link) = self.announced.lock().expect("announced lock").clone() {
return Ok(Some(link));
}
self.announce_setup(dir, base_url, now)
}
pub fn set_up(&self, token: &str, password: &str, public_url: &str, now: i64) -> Result<SetupOutcome> {
self.set_up_naming_hat(token, password, public_url, None, now)
}
pub fn set_up_naming_hat(
&self,
token: &str,
password: &str,
public_url: &str,
hat_name: Option<&str>,
now: i64,
) -> Result<SetupOutcome> {
let mut setup = self.setup.lock().expect("setup lock");
if self.is_set_up()? {
return Ok(SetupOutcome::AlreadySetUp);
}
let live = setup
.as_ref()
.is_some_and(|t| t.expires_at > now && t.hash == sha256_hex(token.as_bytes()));
if !live {
return Ok(SetupOutcome::InvalidToken);
}
if let Some(problem) = password_problem(password) {
return Ok(SetupOutcome::Invalid(problem));
}
let public_url = match PublicUrl::parse(public_url) {
Ok(url) => url,
Err(problem) => return Ok(SetupOutcome::Invalid(problem)),
};
if let Some(problem) = hat_name.and_then(crate::hats::hat_name_problem) {
return Ok(SetupOutcome::Invalid(problem));
}
let phc = password_auth::generate_hash(password);
let owner_id = self.owner.clone();
{
let mut conn = self.conn();
let tx = conn.transaction()?;
let marked = tx.execute(
"UPDATE owners SET set_up_at = ?2 WHERE id = ?1 AND set_up_at IS NULL",
params![owner_id, now],
)?;
if marked == 0 {
return Ok(SetupOutcome::AlreadySetUp);
}
tx.execute(
"INSERT INTO password_credentials(owner_id, phc, updated_at) VALUES (?1, ?2, ?3)",
params![owner_id, phc, now],
)?;
tx.execute(
"INSERT INTO settings(owner_id, key, value) VALUES (?1, ?2, ?3)",
params![owner_id, PUBLIC_URL_KEY, public_url.origin()],
)?;
if let Some(name) = hat_name {
let renamed = tx.execute(
"UPDATE hats SET name = ?2 WHERE owner_id = ?1
AND id = (SELECT value FROM settings WHERE owner_id = ?1 AND key = ?3)",
params![owner_id, name.trim(), crate::hats::DEFAULT_HAT_KEY],
)?;
anyhow::ensure!(renamed == 1, "the owner has no default hat to name");
}
tx.commit()?;
*self.public_url.write().expect("public_url lock") = Some(public_url);
}
*setup = None;
if let Some(link) = self.announced.lock().expect("announced lock").take() {
remove_setup_file(&link.file);
}
Ok(SetupOutcome::Done { owner_id, phc })
}
pub fn verify_password(&self, password: &str) -> Result<Option<String>> {
#[cfg(test)]
let _gauge = InFlight::enter(self);
self.verifications.fetch_add(1, Ordering::Relaxed);
let phc: Option<String> = self
.conn()
.query_row(
"SELECT phc FROM password_credentials WHERE owner_id = ?1",
[&self.owner],
|r| r.get(0),
)
.optional()?;
let Some(phc) = phc else {
let _ = password_auth::verify_password(password, dummy_hash());
return Ok(None);
};
let password = if password.len() > MAX_PASSWORD_BYTES {
""
} else {
password
};
Ok(password_auth::verify_password(password, &phc).is_ok().then_some(phc))
}
pub async fn check_password(self: &Arc<Self>, password: String) -> Result<Option<String>> {
let permit = self
.hashing
.clone()
.acquire_owned()
.await
.context("the hashing semaphore is closed")?;
let this = self.clone();
tokio::task::spawn_blocking(move || {
let _permit = permit;
this.verify_password(&password)
})
.await?
}
pub fn verifications(&self) -> u64 {
self.verifications.load(Ordering::Relaxed)
}
pub async fn reset_password(self: &Arc<Self>, password: String, now: i64) -> Result<Reset> {
if let Some(problem) = password_problem(&password) {
return Ok(Reset::Invalid(problem));
}
if !self.is_set_up()? {
return Ok(Reset::NotSetUp);
}
let permit = self
.hashing
.clone()
.acquire_owned()
.await
.context("the hashing semaphore is closed")?;
#[cfg(test)]
let this = self.clone();
let phc = tokio::task::spawn_blocking(move || {
let _permit = permit;
#[cfg(test)]
this.reset_hashes.fetch_add(1, Ordering::SeqCst);
password_auth::generate_hash(password)
})
.await?;
let (ended, passkeys_removed) = {
let mut conn = self.conn();
let tx = conn.transaction()?;
let replaced = tx.execute(
"UPDATE password_credentials SET phc = ?2, updated_at = ?3 WHERE owner_id = ?1",
params![self.owner, phc, now],
)?;
anyhow::ensure!(replaced == 1, "the owner has no password to reset");
let ended = tx.execute("DELETE FROM auth_sessions WHERE owner_id = ?1", [&self.owner])?;
tx.execute("DELETE FROM push_subscriptions WHERE owner_id = ?1", [&self.owner])?;
let passkeys_removed = tx.execute("DELETE FROM passkeys WHERE owner_id = ?1", [&self.owner])?;
tx.commit()?;
self.ceremonies.clear();
(ended, passkeys_removed)
};
self.sessions_ended();
self.login_limiter.clear();
self.step_up_limiter.clear();
self.passkey_limiter.clear();
Ok(Reset::Done {
sessions_ended: ended,
passkeys_removed,
})
}
pub fn reset_public_url(&self, input: &str) -> Result<Reset> {
Ok(match self.change_public_url(input, None, None)? {
PublicUrlChange::Done {
sessions_ended,
passkeys_removed,
..
} => Reset::Done {
sessions_ended,
passkeys_removed,
},
PublicUrlChange::NotSetUp => Reset::NotSetUp,
PublicUrlChange::Invalid(problem) => Reset::Invalid(problem),
PublicUrlChange::SignedOut | PublicUrlChange::StepUpRequired => {
anyhow::bail!("the admin socket's reset re-checked a session")
}
})
}
pub fn change_public_url(
&self,
input: &str,
contact: Option<Option<&str>>,
caller: Option<(&str, i64)>,
) -> Result<PublicUrlChange> {
let public_url = match PublicUrl::parse(input) {
Ok(url) => url,
Err(problem) => return Ok(PublicUrlChange::Invalid(problem)),
};
if let Some(problem) = contact.flatten().and_then(crate::push::contact_problem) {
return Ok(PublicUrlChange::Invalid(problem));
}
if !self.is_set_up()? {
return Ok(PublicUrlChange::NotSetUp);
}
let (from, ended, passkeys_removed) = {
let mut conn = self.conn();
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
if let Some((session_id, now)) = caller {
let stepped_up_at: Option<Option<i64>> = tx
.query_row(
"SELECT last_step_up_at FROM auth_sessions
WHERE id_hash = ?1 AND owner_id = ?2 AND expires_at > ?3",
params![session_id, self.owner, now],
|r| r.get(0),
)
.optional()?;
match stepped_up_at {
None => return Ok(PublicUrlChange::SignedOut),
Some(at) if !at.is_some_and(|at| now - at < STEP_UP_SECS) => {
return Ok(PublicUrlChange::StepUpRequired);
}
Some(_) => {}
}
}
if let Some(contact) = contact {
tx.execute(
"UPDATE owners SET contact = ?2 WHERE id = ?1",
params![self.owner, contact],
)?;
}
tx.execute(
"INSERT INTO settings(owner_id, key, value) VALUES (?1, ?2, ?3)
ON CONFLICT(owner_id, key) DO UPDATE SET value = excluded.value",
params![self.owner, PUBLIC_URL_KEY, public_url.origin()],
)?;
let ended = tx.execute("DELETE FROM auth_sessions WHERE owner_id = ?1", [&self.owner])?;
tx.execute("DELETE FROM push_subscriptions WHERE owner_id = ?1", [&self.owner])?;
let from = self.public_url();
let same_host = from.as_ref().and_then(PublicUrl::rp_id) == public_url.rp_id();
let passkeys_removed = if same_host {
0
} else {
tx.execute("DELETE FROM passkeys WHERE owner_id = ?1", [&self.owner])?
};
tx.commit()?;
*self.public_url.write().expect("public_url lock") = Some(public_url.clone());
self.ceremonies.clear();
(from, ended, passkeys_removed)
};
self.sessions_ended();
Ok(PublicUrlChange::Done {
from,
to: public_url,
sessions_ended: ended,
passkeys_removed,
})
}
pub fn contact(&self) -> Result<Option<String>> {
Ok(self
.conn()
.query_row("SELECT contact FROM owners WHERE id = ?1", [&self.owner], |r| r.get(0))?)
}
pub fn set_contact(&self, contact: Option<&str>) -> Result<Result<(), String>> {
if let Some(problem) = contact.and_then(crate::push::contact_problem) {
return Ok(Err(problem));
}
self.conn().execute(
"UPDATE owners SET contact = ?2 WHERE id = ?1",
params![self.owner, contact],
)?;
Ok(Ok(()))
}
fn sessions_ended(&self) {
self.ended.send_modify(|generation| *generation += 1);
}
}
fn remove_setup_file(file: &Path) {
match std::fs::remove_file(file) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => tracing::warn!(file = %file.display(), error = %err, "could not remove the setup link"),
}
}
fn load_public_url(conn: &Connection, owner: &str) -> Result<Option<PublicUrl>> {
let stored: Option<String> = conn
.query_row(
"SELECT value FROM settings WHERE owner_id = ?1 AND key = ?2",
[owner, PUBLIC_URL_KEY],
|r| r.get(0),
)
.optional()?;
stored
.map(|s| PublicUrl::parse(&s).map_err(|why| anyhow::anyhow!("the stored public_url is invalid: {why}")))
.transpose()
}
fn password_problem(password: &str) -> Option<String> {
if password.chars().count() < MIN_PASSWORD_CHARS {
return Some(format!("the password must be at least {MIN_PASSWORD_CHARS} characters"));
}
if password.len() > MAX_PASSWORD_BYTES {
return Some(format!("the password must be at most {MAX_PASSWORD_BYTES} bytes"));
}
None
}
fn dummy_hash() -> &'static str {
static HASH: OnceLock<String> = OnceLock::new();
HASH.get_or_init(|| password_auth::generate_hash(hex::encode(random_bytes::<16>())))
}
pub const SESSION_TTL_SECS: i64 = 30 * 24 * 60 * 60;
pub const SESSION_SLIDE_SECS: i64 = 60;
pub const STEP_UP_SECS: i64 = 5 * 60;
pub const SESSION_COOKIE: &str = "hennery_session";
const MAX_USER_AGENT: usize = 256;
pub(crate) fn kept_user_agent(user_agent: &str) -> String {
user_agent
.chars()
.filter(|c| !c.is_control())
.take(MAX_USER_AGENT)
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Authenticated {
pub session_id: String,
pub owner_id: String,
pub last_step_up_at: Option<i64>,
pub expires_at: i64,
pub slid: bool,
}
impl Authenticated {
pub fn stepped_up(&self, now: i64) -> bool {
self.last_step_up_at.is_some_and(|at| now - at < STEP_UP_SECS)
}
}
pub(crate) fn insert_auth_session(
conn: &Connection,
owner: &str,
token: &str,
user_agent: &str,
verified: Option<&str>,
now: i64,
) -> Result<bool> {
conn.execute(
"DELETE FROM auth_sessions WHERE expires_at <= ?1 AND owner_id = ?2",
params![now, owner],
)?;
let opened = conn.execute(
"INSERT INTO auth_sessions(id_hash, owner_id, user_agent, created_at, last_seen_at, last_step_up_at, expires_at)
SELECT ?1, ?2, ?3, ?4, ?4, ?4, ?5
WHERE ?6 IS NULL OR EXISTS (SELECT 1 FROM password_credentials WHERE owner_id = ?2 AND phc = ?6)",
params![
sha256_hex(token.as_bytes()),
owner,
kept_user_agent(user_agent),
now,
now + SESSION_TTL_SECS,
verified
],
)?;
Ok(opened > 0)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthSession {
pub id: String,
pub user_agent: String,
pub created_at: i64,
pub last_seen_at: i64,
pub last_step_up_at: Option<i64>,
pub expires_at: i64,
}
impl Operator {
pub fn open_session(&self, user_agent: &str, verified: &str, now: i64) -> Result<Option<String>> {
let token = hex::encode(random_bytes::<32>());
let opened = insert_auth_session(&self.conn(), &self.owner, &token, user_agent, Some(verified), now)?;
Ok(opened.then_some(token))
}
pub fn authenticate(&self, token: &str, now: i64) -> Result<Option<Authenticated>> {
if token.len() != 64 {
return Ok(None);
}
let id = sha256_hex(token.as_bytes());
let conn = self.conn();
let row: Option<(i64, Option<i64>, i64)> = conn
.query_row(
"SELECT last_seen_at, last_step_up_at, expires_at FROM auth_sessions
WHERE id_hash = ?1 AND expires_at > ?2 AND owner_id = ?3",
params![id, now, self.owner],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?;
let Some((last_seen_at, last_step_up_at, mut expires_at)) = row else {
return Ok(None);
};
let slid = now - last_seen_at >= SESSION_SLIDE_SECS;
if slid {
expires_at = now + SESSION_TTL_SECS;
conn.execute(
"UPDATE auth_sessions SET last_seen_at = ?2, expires_at = ?3 WHERE id_hash = ?1 AND owner_id = ?4",
params![id, now, expires_at, self.owner],
)?;
}
Ok(Some(Authenticated {
session_id: id,
owner_id: self.owner.clone(),
last_step_up_at,
expires_at,
slid,
}))
}
pub fn step_up(&self, session_id: &str, now: i64) -> Result<bool> {
let changed = self.conn().execute(
"UPDATE auth_sessions SET last_step_up_at = ?2 WHERE id_hash = ?1 AND owner_id = ?3",
params![session_id, now, self.owner],
)?;
Ok(changed > 0)
}
pub fn sessions(&self, now: i64) -> Result<Vec<AuthSession>> {
let conn = self.conn();
let mut stmt = conn.prepare(
"SELECT id_hash, user_agent, created_at, last_seen_at, last_step_up_at, expires_at
FROM auth_sessions WHERE expires_at > ?1 AND owner_id = ?2 ORDER BY last_seen_at DESC, id_hash",
)?;
let rows = stmt.query_map(params![now, self.owner], |r| {
Ok(AuthSession {
id: r.get(0)?,
user_agent: r.get(1)?,
created_at: r.get(2)?,
last_seen_at: r.get(3)?,
last_step_up_at: r.get(4)?,
expires_at: r.get(5)?,
})
})?;
Ok(rows.collect::<rusqlite::Result<_>>()?)
}
pub fn revoke_session(&self, session_id: &str, now: i64) -> Result<bool> {
let mut conn = self.conn();
let tx = conn.transaction()?;
let changed = tx.execute(
"DELETE FROM auth_sessions WHERE id_hash = ?1 AND expires_at > ?2 AND owner_id = ?3",
params![session_id, now, self.owner],
)?;
if changed > 0 {
tx.execute(
"DELETE FROM push_subscriptions WHERE auth_session = ?1 AND owner_id = ?2",
params![session_id, self.owner],
)?;
}
tx.commit()?;
drop(conn);
if changed > 0 {
self.sessions_ended();
}
Ok(changed > 0)
}
pub fn session_ends(&self) -> tokio::sync::watch::Receiver<u64> {
self.ended.subscribe()
}
pub fn session_expires_at(&self, session_id: &str, now: i64) -> Result<Option<i64>> {
Ok(self
.conn()
.query_row(
"SELECT expires_at FROM auth_sessions WHERE id_hash = ?1 AND expires_at > ?2 AND owner_id = ?3",
params![session_id, now, self.owner],
|r| r.get(0),
)
.optional()?)
}
pub fn authenticate_cookies(
&self,
headers: &axum::http::HeaderMap,
now: i64,
) -> Result<Option<(String, Authenticated)>> {
for token in session_tokens(headers) {
if let Some(session) = self.authenticate(token, now)? {
return Ok(Some((token.to_string(), session)));
}
}
Ok(None)
}
}
pub fn session_cookie(token: &str, secure: bool) -> String {
let secure = if secure { "; Secure" } else { "" };
format!("{SESSION_COOKIE}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={SESSION_TTL_SECS}{secure}")
}
pub fn cleared_cookie(secure: bool) -> String {
let secure = if secure { "; Secure" } else { "" };
format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0{secure}")
}
pub const MAX_SESSION_COOKIES: usize = 4;
pub fn session_tokens(headers: &axum::http::HeaderMap) -> Vec<&str> {
headers
.get_all(axum::http::header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|pair| {
let (name, value) = pair.trim().split_once('=')?;
(name == SESSION_COOKIE).then_some(value)
})
.take(MAX_SESSION_COOKIES)
.collect()
}
#[cfg(test)]
struct InFlight<'a>(&'a Operator);
#[cfg(test)]
impl<'a> InFlight<'a> {
fn enter(op: &'a Operator) -> Self {
let now = op.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
op.max_in_flight.fetch_max(now, Ordering::SeqCst);
Self(op)
}
}
#[cfg(test)]
impl Drop for InFlight<'_> {
fn drop(&mut self) {
self.0.in_flight.fetch_sub(1, Ordering::SeqCst);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
const NOW: i64 = 1_800_000_000;
const PASSWORD: &str = "correct horse battery";
fn set_up() -> Arc<Operator> {
let op = Arc::new(Operator::open_in_memory().unwrap());
let token = op.issue_setup_token(NOW).unwrap().unwrap();
op.set_up(&token, PASSWORD, "https://hennery.example", NOW).unwrap();
op
}
async fn until(what: &str, done: impl Fn() -> bool) {
let deadline = Instant::now() + Duration::from_secs(30);
while !done() {
assert!(Instant::now() < deadline, "timed out waiting until {what}");
tokio::time::sleep(Duration::from_millis(1)).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn at_most_two_checks_verify_at_once() {
let op = set_up();
let checks: Vec<_> = (0..8)
.map(|_| {
let op = op.clone();
tokio::spawn(async move { op.check_password(PASSWORD.to_string()).await.unwrap() })
})
.collect();
for check in checks {
assert!(check.await.unwrap().is_some());
}
assert_eq!(op.verifications(), 8);
assert!(op.max_in_flight.load(Ordering::SeqCst) <= MAX_CONCURRENT_HASHES);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_password_reset_hashes_only_with_a_hashing_slot() {
let op = set_up();
let held = op
.hashing
.clone()
.acquire_many_owned(MAX_CONCURRENT_HASHES as u32)
.await
.unwrap();
let reset = {
let op = op.clone();
tokio::spawn(async move { op.reset_password("a new long password".into(), NOW).await })
};
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(op.reset_hashes.load(Ordering::SeqCst), 0, "hashed without a slot");
drop(held);
assert_eq!(
reset.await.unwrap().unwrap(),
Reset::Done {
sessions_ended: 0,
passkeys_removed: 0
}
);
assert_eq!(op.reset_hashes.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_abandoned_check_keeps_its_slot_until_its_verify_ends() {
let op = set_up();
for round in 1..=8u64 {
let checks: Vec<_> = (0..2)
.map(|_| {
let op = op.clone();
tokio::spawn(async move { op.check_password(PASSWORD.to_string()).await })
})
.collect();
until("both checks of the round verify", || op.verifications() == 2 * round).await;
for check in &checks {
check.abort();
}
}
until("every verify ends", || op.in_flight.load(Ordering::SeqCst) == 0).await;
let max = op.max_in_flight.load(Ordering::SeqCst);
assert!(max <= MAX_CONCURRENT_HASHES, "{max} verifies ran at once");
}
}