use std::sync::Arc;
use std::time::Duration;
use rainier_http::{IntoResponse, Request, Response};
use rainier_middleware::{Middleware, MiddlewareStack, Next};
use rainier_session::SessionRequestExt;
use rainier_support::Error;
use crate::guard::AuthManager;
use crate::hashing::Hasher;
use crate::middleware::AuthenticatedUser;
use crate::user::Authenticatable;
pub const CONFIRMED_AT: &str = "auth.password_confirmed_at";
#[derive(Debug, Clone, Copy)]
pub struct ConfirmPassword {
window: Duration,
}
impl ConfirmPassword {
pub fn within(window: Duration) -> Self {
Self { window }
}
pub fn recently() -> Self {
Self::within(Duration::from_secs(900))
}
pub fn window(&self) -> Duration {
self.window
}
pub fn mark_confirmed(request: &Request) -> Result<(), Error> {
let session = request.session().ok_or_else(|| {
Error::internal("confirming a password needs the `session` middleware")
})?;
session.put(CONFIRMED_AT, now())?;
Ok(())
}
pub fn forget(request: &Request) {
if let Some(session) = request.session() {
session.forget(CONFIRMED_AT);
}
}
pub fn confirmed_ago(request: &Request) -> Option<Duration> {
let confirmed_at: i64 = request.session()?.get(CONFIRMED_AT)?;
Some(Duration::from_secs(now().saturating_sub(confirmed_at).max(0) as u64))
}
pub fn is_confirmed(&self, request: &Request) -> bool {
Self::confirmed_ago(request).is_some_and(|ago| ago <= self.window)
}
}
#[async_trait::async_trait]
impl Middleware for ConfirmPassword {
async fn handle(&self, request: Request, next: Next) -> Response {
if request.session().is_none() {
tracing::error!(
"`ConfirmPassword` ran without a session, so it cannot tell whether the password \
was confirmed — refusing. Is this route behind the `web` group?"
);
return Error::new(
rainier_support::ErrorKind::Status(423),
"Password confirmation is required.",
)
.into_response();
}
if self.is_confirmed(&request) {
return next.run(request).await;
}
Error::new(rainier_support::ErrorKind::Status(423), "Please confirm your password.")
.into_response()
}
fn name(&self) -> &'static str {
"ConfirmPassword"
}
}
pub fn confirm_password<U: Authenticatable>(
request: &Request,
password: &str,
hasher: &Arc<dyn Hasher>,
) -> Result<(), Error> {
let user = request
.extension::<AuthenticatedUser<U>>()
.ok_or_else(|| Error::unauthenticated("Unauthenticated."))?;
let Some(stored) = user.auth_password_hash() else {
return Err(Error::bad_request("This account does not sign in with a password."));
};
if !hasher.verify(password, stored) {
return Err(Error::validation(serde_json::json!({
"password": ["That password is not correct."],
})));
}
ConfirmPassword::mark_confirmed(request)
}
pub fn confirm_password_within<U: Authenticatable>(window: Duration) -> MiddlewareStack {
MiddlewareStack::new().resolved(move |_: Arc<AuthManager<U>>| ConfirmPassword::within(window))
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use rainier_http::{Method, StatusCode};
use rainier_middleware::Pipeline;
use rainier_session::{MemorySessionStore, StartSession};
fn with_session() -> StartSession {
StartSession::new(Arc::new(MemorySessionStore::default()))
}
async fn through(guard: ConfirmPassword, confirm_first: bool) -> StatusCode {
Pipeline::new()
.through(with_session())
.through(ConfirmFirst(confirm_first))
.through(guard)
.then(|_| async { Response::ok("through") })
.run(Request::builder().method(Method::POST).uri("/account/password").build())
.await
.status()
}
struct ConfirmFirst(bool);
#[async_trait::async_trait]
impl Middleware for ConfirmFirst {
async fn handle(&self, request: Request, next: Next) -> Response {
if self.0 {
ConfirmPassword::mark_confirmed(&request).expect("a session");
}
next.run(request).await
}
fn name(&self) -> &'static str {
"ConfirmFirst"
}
}
#[tokio::test]
async fn a_recent_confirmation_gets_through() {
assert_eq!(through(ConfirmPassword::recently(), true).await, StatusCode::OK);
}
#[tokio::test]
async fn no_confirmation_is_refused_with_423() {
assert_eq!(through(ConfirmPassword::recently(), false).await, StatusCode::LOCKED);
}
#[tokio::test]
async fn a_confirmation_older_than_the_window_is_refused() {
assert_eq!(
through(ConfirmPassword::within(Duration::ZERO), true).await,
StatusCode::OK,
"a confirmation made this instant is inside a zero window"
);
let guard = ConfirmPassword::within(Duration::from_secs(60));
let request = Request::builder().method(Method::POST).uri("/x").build();
let status = Pipeline::new()
.through(with_session())
.through(ConfirmedLongAgo)
.through(guard)
.then(|_| async { Response::ok("through") })
.run(request)
.await
.status();
assert_eq!(status, StatusCode::LOCKED);
}
struct ConfirmedLongAgo;
#[async_trait::async_trait]
impl Middleware for ConfirmedLongAgo {
async fn handle(&self, request: Request, next: Next) -> Response {
request.session().unwrap().put(CONFIRMED_AT, now() - 3600).unwrap();
next.run(request).await
}
fn name(&self) -> &'static str {
"ConfirmedLongAgo"
}
}
#[tokio::test]
async fn without_a_session_it_refuses_rather_than_passing() {
let status = Pipeline::new()
.through(ConfirmPassword::recently())
.then(|_| async { Response::ok("through") })
.run(Request::builder().method(Method::POST).uri("/x").build())
.await
.status();
assert_eq!(status, StatusCode::LOCKED);
}
#[tokio::test]
async fn forgetting_a_confirmation_closes_the_window_early() {
let status = Pipeline::new()
.through(with_session())
.through(ConfirmFirst(true))
.through(ForgetIt)
.through(ConfirmPassword::recently())
.then(|_| async { Response::ok("through") })
.run(Request::builder().method(Method::POST).uri("/x").build())
.await
.status();
assert_eq!(status, StatusCode::LOCKED);
}
struct ForgetIt;
#[async_trait::async_trait]
impl Middleware for ForgetIt {
async fn handle(&self, request: Request, next: Next) -> Response {
ConfirmPassword::forget(&request);
next.run(request).await
}
fn name(&self) -> &'static str {
"ForgetIt"
}
}
#[test]
fn the_default_window_is_fifteen_minutes() {
assert_eq!(ConfirmPassword::recently().window(), Duration::from_secs(900));
}
}