use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::auth::{Session, SessionError};
use crate::crypt::{Clock, SystemClock};
pub const CONFIRMATION_SESSION_KEY: &str = "arcature.password_confirmed";
const DEFAULT_WINDOW: Duration = Duration::from_secs(15 * 60);
#[derive(Serialize, Deserialize)]
struct Confirmation {
subject: String,
at: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ConfirmationState {
Fresh {
remaining: Duration,
},
Stale,
}
impl ConfirmationState {
#[must_use]
pub fn is_fresh(self) -> bool {
matches!(self, Self::Fresh { .. })
}
#[must_use]
pub fn remaining(self) -> Option<Duration> {
match self {
Self::Fresh { remaining } => Some(remaining),
Self::Stale => None,
}
}
}
#[derive(Clone)]
#[non_exhaustive]
pub struct PasswordConfirmation {
window: Duration,
key: Arc<str>,
clock: Arc<dyn Clock>,
}
impl Default for PasswordConfirmation {
fn default() -> Self {
Self::new()
}
}
impl PasswordConfirmation {
#[must_use]
pub fn new() -> Self {
Self {
window: DEFAULT_WINDOW,
key: Arc::from(CONFIRMATION_SESSION_KEY),
clock: Arc::new(SystemClock::new()),
}
}
#[must_use]
pub fn window(mut self, window: Duration) -> Self {
self.window = window;
self
}
#[must_use]
pub fn session_key(mut self, key: impl Into<Arc<str>>) -> Self {
self.key = key.into();
self
}
#[must_use]
pub fn clock(mut self, clock: impl Clock) -> Self {
self.clock = Arc::new(clock);
self
}
pub async fn record_verified(
&self,
session: &Session,
subject: &str,
) -> Result<(), SessionError> {
session
.put(
&self.key,
Confirmation {
subject: subject.to_owned(),
at: self.clock.now_unix(),
},
)
.await
}
pub async fn state(&self, session: &Session, subject: &str) -> ConfirmationState {
let stored = session.get::<Confirmation>(&self.key).await.ok().flatten();
self.state_of(stored.as_ref(), subject, self.clock.now_unix())
}
pub async fn is_fresh(&self, session: &Session, subject: &str) -> bool {
self.state(session, subject).await.is_fresh()
}
pub async fn forget(&self, session: &Session) -> Result<(), SessionError> {
session.forget::<serde_json::Value>(&self.key).await?;
Ok(())
}
fn state_of(
&self,
stored: Option<&Confirmation>,
subject: &str,
now: u64,
) -> ConfirmationState {
let Some(stored) = stored else {
return ConfirmationState::Stale;
};
if stored.subject != subject {
return ConfirmationState::Stale;
}
let Some(elapsed) = now.checked_sub(stored.at) else {
return ConfirmationState::Stale;
};
match self.window.checked_sub(Duration::from_secs(elapsed)) {
Some(remaining) if !remaining.is_zero() => ConfirmationState::Fresh { remaining },
_ => ConfirmationState::Stale,
}
}
}
impl fmt::Debug for PasswordConfirmation {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PasswordConfirmation")
.field("window", &self.window)
.field("session_key", &self.key)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::{CONFIRMATION_SESSION_KEY, ConfirmationState, PasswordConfirmation};
use crate::auth::Session;
use crate::crypt::Clock;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tower_sessions::Session as TowerSession;
use tower_sessions_memory_store::MemoryStore;
#[derive(Clone)]
struct Frozen(Arc<AtomicU64>);
impl Frozen {
fn at(seconds: u64) -> Self {
Self(Arc::new(AtomicU64::new(seconds)))
}
fn advance(&self, seconds: u64) {
self.0.fetch_add(seconds, Ordering::SeqCst);
}
fn set(&self, seconds: u64) {
self.0.store(seconds, Ordering::SeqCst);
}
}
impl Clock for Frozen {
fn now_unix(&self) -> u64 {
self.0.load(Ordering::SeqCst)
}
}
fn session() -> Session {
Session(TowerSession::new(
None,
Arc::new(MemoryStore::default()),
None,
))
}
fn confirmation(window_secs: u64) -> (PasswordConfirmation, Frozen) {
let clock = Frozen::at(1_700_000_000);
let handle = PasswordConfirmation::new()
.window(Duration::from_secs(window_secs))
.clock(clock.clone());
(handle, clock)
}
#[tokio::test]
async fn a_session_that_has_never_confirmed_is_stale() {
let (handle, _clock) = confirmation(900);
let session = session();
assert_eq!(
handle.state(&session, "user-1").await,
ConfirmationState::Stale
);
assert!(!handle.is_fresh(&session, "user-1").await);
}
#[tokio::test]
async fn a_recorded_confirmation_is_fresh_for_the_whole_window() {
let (handle, clock) = confirmation(900);
let session = session();
handle
.record_verified(&session, "user-1")
.await
.expect("record");
assert_eq!(
handle.state(&session, "user-1").await,
ConfirmationState::Fresh {
remaining: Duration::from_secs(900)
}
);
clock.advance(600);
assert_eq!(
handle.state(&session, "user-1").await,
ConfirmationState::Fresh {
remaining: Duration::from_secs(300)
},
"the remaining time did not count down"
);
}
#[tokio::test]
async fn a_confirmation_expires_exactly_at_the_window() {
let (handle, clock) = confirmation(900);
let session = session();
handle
.record_verified(&session, "user-1")
.await
.expect("record");
clock.advance(899);
assert!(
handle.is_fresh(&session, "user-1").await,
"expired a second early"
);
clock.advance(1);
assert!(
!handle.is_fresh(&session, "user-1").await,
"still standing at the end of the window"
);
}
#[tokio::test]
async fn a_confirmation_does_not_carry_over_to_another_subject() {
let (handle, _clock) = confirmation(900);
let session = session();
handle
.record_verified(&session, "user-1")
.await
.expect("record");
assert!(handle.is_fresh(&session, "user-1").await);
assert!(
!handle.is_fresh(&session, "user-2").await,
"one user's confirmation authorised another user's action"
);
}
#[tokio::test]
async fn reading_the_state_does_not_extend_the_window() {
let (handle, clock) = confirmation(900);
let session = session();
handle
.record_verified(&session, "user-1")
.await
.expect("record");
for _ in 0..9 {
assert!(handle.is_fresh(&session, "user-1").await);
clock.advance(100);
}
assert!(
!handle.is_fresh(&session, "user-1").await,
"the deadline was pushed out by reading it"
);
}
#[tokio::test]
async fn confirming_again_restarts_the_window() {
let (handle, clock) = confirmation(900);
let session = session();
handle
.record_verified(&session, "user-1")
.await
.expect("record");
clock.advance(1000);
assert!(!handle.is_fresh(&session, "user-1").await);
handle
.record_verified(&session, "user-1")
.await
.expect("re-record");
assert_eq!(
handle.state(&session, "user-1").await,
ConfirmationState::Fresh {
remaining: Duration::from_secs(900)
}
);
}
#[tokio::test]
async fn a_zero_window_asks_every_time() {
let (handle, _clock) = confirmation(0);
let session = session();
handle
.record_verified(&session, "user-1")
.await
.expect("record");
assert!(
!handle.is_fresh(&session, "user-1").await,
"a zero window read as `never expires`"
);
}
#[tokio::test]
async fn a_clock_that_has_gone_backwards_is_stale() {
let (handle, clock) = confirmation(900);
let session = session();
handle
.record_verified(&session, "user-1")
.await
.expect("record");
assert!(handle.is_fresh(&session, "user-1").await);
clock.set(1_600_000_000);
assert!(
!handle.is_fresh(&session, "user-1").await,
"a backwards clock left the confirmation standing"
);
}
#[tokio::test]
async fn forgetting_a_confirmation_makes_it_stale() {
let (handle, _clock) = confirmation(900);
let session = session();
handle
.record_verified(&session, "user-1")
.await
.expect("record");
assert!(handle.is_fresh(&session, "user-1").await);
handle.forget(&session).await.expect("forget");
assert!(!handle.is_fresh(&session, "user-1").await);
}
#[tokio::test]
async fn a_stored_value_that_is_not_a_confirmation_is_stale() {
let (handle, _clock) = confirmation(900);
let session = session();
session
.put(CONFIRMATION_SESSION_KEY, "true")
.await
.expect("seed");
assert_eq!(
handle.state(&session, "user-1").await,
ConfirmationState::Stale
);
handle.forget(&session).await.expect("forget");
handle
.record_verified(&session, "user-1")
.await
.expect("record");
assert!(handle.is_fresh(&session, "user-1").await);
}
#[tokio::test]
async fn the_default_key_is_the_published_constant() {
let (handle, _clock) = confirmation(900);
let session = session();
handle
.record_verified(&session, "user-1")
.await
.expect("record");
let raw: Option<serde_json::Value> =
session.get(CONFIRMATION_SESSION_KEY).await.expect("read");
assert!(raw.is_some(), "nothing was stored under the published key");
}
#[tokio::test]
async fn a_custom_key_is_independent_of_the_default_one() {
let (default, clock) = confirmation(900);
let billing = PasswordConfirmation::new()
.window(Duration::from_secs(900))
.session_key("billing.confirmed")
.clock(clock);
let session = session();
default
.record_verified(&session, "user-1")
.await
.expect("record");
assert!(default.is_fresh(&session, "user-1").await);
assert!(
!billing.is_fresh(&session, "user-1").await,
"confirming for one purpose authorised another"
);
}
#[test]
fn debug_names_the_window_and_the_key() {
let rendered = format!("{:?}", PasswordConfirmation::new());
assert!(rendered.contains("900s"), "{rendered}");
assert!(rendered.contains(CONFIRMATION_SESSION_KEY), "{rendered}");
}
}