Skip to main content

rainier_auth/
confirm.rs

1//! Re-asking for the password before something serious — [`ConfirmPassword`].
2//!
3//! ```ignore
4//! // The gate.
5//! router.post("/account/password", change_password)
6//!     .middleware(ConfirmPassword::within(Duration::from_secs(900)));
7//!
8//! // And the endpoint that satisfies it.
9//! router.post("/account/confirm-password", confirm_password);
10//! ```
11//!
12//! **Not** the [`Confirmed`] validation rule,
13//! which asserts `password == password_confirmation` inside one submitted form
14//! — a different feature with a confusingly similar name, and the two get
15//! mixed up often enough to be worth saying twice.
16//!
17//! # Why holding a session is not enough
18//!
19//! A session says somebody logged in as this account at some point. It does
20//! not say the person at the keyboard *now* is them. Between those two facts
21//! sit an unlocked laptop, a borrowed phone, a shared desk, and a session
22//! token lifted from a machine somebody else has.
23//!
24//! So anything that would let an attacker keep the account — changing the
25//! password, changing the address it recovers to, enrolling or removing a
26//! second factor, issuing an API token — asks again. It is the difference
27//! between "somebody used this laptop" and "somebody knows the password".
28//!
29//! An identity provider has more of these than most applications, which is why
30//! writing it inline twice is how it usually goes.
31//!
32//! [`Confirmed`]: https://docs.rs/rainier-validation
33
34use 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
47/// Where the session records that the password was confirmed.
48pub const CONFIRMED_AT: &str = "auth.password_confirmed_at";
49
50/// Refuses until the password has been confirmed recently.
51///
52/// Answers `423 Locked` rather than `403`, so a client can tell "prove it is
53/// you again" apart from "you may never do this". A browser application
54/// redirects to its confirmation page; an API client asks for the password and
55/// posts it to the confirm endpoint.
56#[derive(Debug, Clone, Copy)]
57pub struct ConfirmPassword {
58    window: Duration,
59}
60
61impl ConfirmPassword {
62    /// Accept a confirmation made within `window`.
63    ///
64    /// Fifteen minutes is the conventional default and a reasonable one: long enough
65    /// that changing three settings does not mean typing the password three
66    /// times, short enough that a walked-away-from laptop is not an open door.
67    pub fn within(window: Duration) -> Self {
68        Self { window }
69    }
70
71    /// Fifteen minutes.
72    pub fn recently() -> Self {
73        Self::within(Duration::from_secs(900))
74    }
75
76    /// The window.
77    pub fn window(&self) -> Duration {
78        self.window
79    }
80
81    /// Record a confirmation on this request's session.
82    ///
83    /// What the confirm endpoint calls once it has checked the password.
84    /// Separate from the middleware so an application can confirm by some
85    /// other proof — a passkey, a second factor — and reuse the same gate.
86    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    /// Forget any confirmation, so the next guarded action asks again.
96    ///
97    /// Worth calling when the stakes change — after a failed second factor, or
98    /// when a session is adopted from somewhere new.
99    pub fn forget(request: &Request) {
100        if let Some(session) = request.session() {
101            session.forget(CONFIRMED_AT);
102        }
103    }
104
105    /// How long ago the password was confirmed on this request's session.
106    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    /// Whether this request's session has a confirmation inside `window`.
112    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            // Nothing to read a confirmation from. Refusing is the only safe
122            // answer: a guard that passes when it is misconfigured is not a
123            // guard, and this one stands in front of the account-takeover
124            // actions.
125            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
149/// Check a submitted password against the authenticated user, and record it.
150///
151/// The other half of [`ConfirmPassword`] — the endpoint its `423` is asking
152/// the client to visit.
153///
154/// ```ignore
155/// router.post("/account/confirm-password", |request: Req, Json(body): Json<Confirm>| async move {
156///     confirm_password::<User>(&request, &body.password)?;
157///     Ok(Response::no_content())
158/// });
159/// ```
160///
161/// # Errors
162///
163/// `401` when nobody is authenticated, `422` when the password is wrong. The
164/// wrong password is deliberately **not** a `403`: it is a failed check of
165/// submitted input, and rendering it as an authorization failure sends a
166/// browser to a login page it does not need.
167pub 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        // An account that authenticates some other way has no password to
178        // confirm, and pretending otherwise would lock it out of every
179        // guarded action with a message about a password it does not have.
180        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
192/// [`ConfirmPassword`] as a stack, for symmetry with the rest of the auth
193/// middleware.
194pub fn confirm_password_within<U: Authenticatable>(window: Duration) -> MiddlewareStack {
195    // The manager is not needed to *check* a confirmation — only to make one —
196    // but resolving it here keeps a misconfigured application failing at boot
197    // rather than at the first guarded request.
198    MiddlewareStack::new().resolved(move |_: Arc<AuthManager<U>>| ConfirmPassword::within(window))
199}
200
201/// Seconds since the epoch.
202fn 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    /// Runs `request` through a session and the guard, reporting the status.
221    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    /// Stands in for the confirm endpoint: marks the session confirmed.
233    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        // 423 rather than 403, so a client can tell "prove it is you again"
257        // apart from "you may never do this".
258        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        // A zero-length window: anything already recorded is too old.
264        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        // And one made in the past is not.
271        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    /// Records a confirmation from an hour ago.
287    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        // A misordered route. This guard stands in front of the actions that
304        // let an attacker keep the account, so failing open is not an option.
305        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}