1use std::sync::Arc;
35use std::time::Duration;
36
37use rainier_http::{IntoResponse, Request, Response};
38use rainier_middleware::{Middleware, MiddlewareStack, Next};
39use rainier_session::SessionRequestExt;
40use rainier_support::Error;
41
42use crate::guard::AuthManager;
43use crate::hashing::Hasher;
44use crate::middleware::AuthenticatedUser;
45use crate::user::Authenticatable;
46
47pub const CONFIRMED_AT: &str = "auth.password_confirmed_at";
49
50#[derive(Debug, Clone, Copy)]
57pub struct ConfirmPassword {
58 window: Duration,
59}
60
61impl ConfirmPassword {
62 pub fn within(window: Duration) -> Self {
68 Self { window }
69 }
70
71 pub fn recently() -> Self {
73 Self::within(Duration::from_secs(900))
74 }
75
76 pub fn window(&self) -> Duration {
78 self.window
79 }
80
81 pub fn mark_confirmed(request: &Request) -> Result<(), Error> {
87 let session = request.session().ok_or_else(|| {
88 Error::internal("confirming a password needs the `session` middleware")
89 })?;
90
91 session.put(CONFIRMED_AT, now())?;
92 Ok(())
93 }
94
95 pub fn forget(request: &Request) {
100 if let Some(session) = request.session() {
101 session.forget(CONFIRMED_AT);
102 }
103 }
104
105 pub fn confirmed_ago(request: &Request) -> Option<Duration> {
107 let confirmed_at: i64 = request.session()?.get(CONFIRMED_AT)?;
108 Some(Duration::from_secs(now().saturating_sub(confirmed_at).max(0) as u64))
109 }
110
111 pub fn is_confirmed(&self, request: &Request) -> bool {
113 Self::confirmed_ago(request).is_some_and(|ago| ago <= self.window)
114 }
115}
116
117#[async_trait::async_trait]
118impl Middleware for ConfirmPassword {
119 async fn handle(&self, request: Request, next: Next) -> Response {
120 if request.session().is_none() {
121 tracing::error!(
126 "`ConfirmPassword` ran without a session, so it cannot tell whether the password \
127 was confirmed — refusing. Is this route behind the `web` group?"
128 );
129 return Error::new(
130 rainier_support::ErrorKind::Status(423),
131 "Password confirmation is required.",
132 )
133 .into_response();
134 }
135
136 if self.is_confirmed(&request) {
137 return next.run(request).await;
138 }
139
140 Error::new(rainier_support::ErrorKind::Status(423), "Please confirm your password.")
141 .into_response()
142 }
143
144 fn name(&self) -> &'static str {
145 "ConfirmPassword"
146 }
147}
148
149pub fn confirm_password<U: Authenticatable>(
168 request: &Request,
169 password: &str,
170 hasher: &Arc<dyn Hasher>,
171) -> Result<(), Error> {
172 let user = request
173 .extension::<AuthenticatedUser<U>>()
174 .ok_or_else(|| Error::unauthenticated("Unauthenticated."))?;
175
176 let Some(stored) = user.auth_password_hash() else {
177 return Err(Error::bad_request("This account does not sign in with a password."));
181 };
182
183 if !hasher.verify(password, stored) {
184 return Err(Error::validation(serde_json::json!({
185 "password": ["That password is not correct."],
186 })));
187 }
188
189 ConfirmPassword::mark_confirmed(request)
190}
191
192pub fn confirm_password_within<U: Authenticatable>(window: Duration) -> MiddlewareStack {
195 MiddlewareStack::new().resolved(move |_: Arc<AuthManager<U>>| ConfirmPassword::within(window))
199}
200
201fn now() -> i64 {
203 std::time::SystemTime::now()
204 .duration_since(std::time::UNIX_EPOCH)
205 .map(|since| since.as_secs() as i64)
206 .unwrap_or(0)
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use rainier_http::{Method, StatusCode};
213 use rainier_middleware::Pipeline;
214 use rainier_session::{MemorySessionStore, StartSession};
215
216 fn with_session() -> StartSession {
217 StartSession::new(Arc::new(MemorySessionStore::default()))
218 }
219
220 async fn through(guard: ConfirmPassword, confirm_first: bool) -> StatusCode {
222 Pipeline::new()
223 .through(with_session())
224 .through(ConfirmFirst(confirm_first))
225 .through(guard)
226 .then(|_| async { Response::ok("through") })
227 .run(Request::builder().method(Method::POST).uri("/account/password").build())
228 .await
229 .status()
230 }
231
232 struct ConfirmFirst(bool);
234
235 #[async_trait::async_trait]
236 impl Middleware for ConfirmFirst {
237 async fn handle(&self, request: Request, next: Next) -> Response {
238 if self.0 {
239 ConfirmPassword::mark_confirmed(&request).expect("a session");
240 }
241 next.run(request).await
242 }
243
244 fn name(&self) -> &'static str {
245 "ConfirmFirst"
246 }
247 }
248
249 #[tokio::test]
250 async fn a_recent_confirmation_gets_through() {
251 assert_eq!(through(ConfirmPassword::recently(), true).await, StatusCode::OK);
252 }
253
254 #[tokio::test]
255 async fn no_confirmation_is_refused_with_423() {
256 assert_eq!(through(ConfirmPassword::recently(), false).await, StatusCode::LOCKED);
259 }
260
261 #[tokio::test]
262 async fn a_confirmation_older_than_the_window_is_refused() {
263 assert_eq!(
265 through(ConfirmPassword::within(Duration::ZERO), true).await,
266 StatusCode::OK,
267 "a confirmation made this instant is inside a zero window"
268 );
269
270 let guard = ConfirmPassword::within(Duration::from_secs(60));
272 let request = Request::builder().method(Method::POST).uri("/x").build();
273
274 let status = Pipeline::new()
275 .through(with_session())
276 .through(ConfirmedLongAgo)
277 .through(guard)
278 .then(|_| async { Response::ok("through") })
279 .run(request)
280 .await
281 .status();
282
283 assert_eq!(status, StatusCode::LOCKED);
284 }
285
286 struct ConfirmedLongAgo;
288
289 #[async_trait::async_trait]
290 impl Middleware for ConfirmedLongAgo {
291 async fn handle(&self, request: Request, next: Next) -> Response {
292 request.session().unwrap().put(CONFIRMED_AT, now() - 3600).unwrap();
293 next.run(request).await
294 }
295
296 fn name(&self) -> &'static str {
297 "ConfirmedLongAgo"
298 }
299 }
300
301 #[tokio::test]
302 async fn without_a_session_it_refuses_rather_than_passing() {
303 let status = Pipeline::new()
306 .through(ConfirmPassword::recently())
307 .then(|_| async { Response::ok("through") })
308 .run(Request::builder().method(Method::POST).uri("/x").build())
309 .await
310 .status();
311
312 assert_eq!(status, StatusCode::LOCKED);
313 }
314
315 #[tokio::test]
316 async fn forgetting_a_confirmation_closes_the_window_early() {
317 let status = Pipeline::new()
318 .through(with_session())
319 .through(ConfirmFirst(true))
320 .through(ForgetIt)
321 .through(ConfirmPassword::recently())
322 .then(|_| async { Response::ok("through") })
323 .run(Request::builder().method(Method::POST).uri("/x").build())
324 .await
325 .status();
326
327 assert_eq!(status, StatusCode::LOCKED);
328 }
329
330 struct ForgetIt;
331
332 #[async_trait::async_trait]
333 impl Middleware for ForgetIt {
334 async fn handle(&self, request: Request, next: Next) -> Response {
335 ConfirmPassword::forget(&request);
336 next.run(request).await
337 }
338
339 fn name(&self) -> &'static str {
340 "ForgetIt"
341 }
342 }
343
344 #[test]
345 fn the_default_window_is_fifteen_minutes() {
346 assert_eq!(ConfirmPassword::recently().window(), Duration::from_secs(900));
347 }
348}