Skip to main content

renox_core/auth/
second_factor.rs

1//! A second login step that a module provides, such as two-factor
2//! authentication (`renox-2fa`).
3//!
4//! A module registers it with [`Registry::second_factor`](crate::Registry::second_factor):
5//! the name of its challenge route, and whether a user must pass it. When a
6//! user who must logs in with the right password, the `Auth` module doesn't
7//! log them in: the login waits in the session ([`PendingLogin`], for ten
8//! minutes) and the browser goes to the challenge. The challenge's handler
9//! checks the code and calls [`complete_login`], which logs in exactly as the
10//! login page would have (and sends the user where they were going).
11//! Registration asks the same question about the new user, so a step every
12//! user must pass isn't skipped by signing up.
13//!
14//! ```
15//! use renox::auth::{complete_login, pending_login};
16//! use renox::prelude::*;
17//! use serde::Deserialize;
18//!
19//! struct Pin;
20//!
21//! impl Module for Pin {
22//!     fn name(&self) -> &'static str {
23//!         "pin"
24//!     }
25//!
26//!     fn register(&self, app: &mut Registry) {
27//!         // Whether `user` must pass the challenge (here: everyone with a PIN set).
28//!         app.second_factor("pin.challenge", |user, _state| async move {
29//!             Ok(user.extra.contains_key("pin"))
30//!         });
31//!     }
32//!
33//!     fn routes(&self) -> Routes {
34//!         Routes::new()
35//!             .get("/pin", show)
36//!             .post("/pin", check)
37//!             .name("pin.challenge")
38//!             .guest_only()
39//!     }
40//! }
41//!
42//! async fn show(session: Session) -> Result<Response> {
43//!     match pending_login(&session) {
44//!         Some(_) => Ok(view("pin.html", context! {}).into_response()),
45//!         None => Ok(Redirect::to("/login").into_response()), // expired, or never started
46//!     }
47//! }
48//!
49//! #[derive(Deserialize, Validate)]
50//! struct PinForm {
51//!     #[validate(required)]
52//!     pin: String,
53//! }
54//!
55//! async fn check(
56//!     State(state): State<AppState>,
57//!     session: Session,
58//!     htmx: Htmx,
59//!     ClientIp(ip): ClientIp,
60//!     Valid(form): Valid<PinForm>,
61//! ) -> Result<Response> {
62//!     let Some(pending) = pending_login(&session) else {
63//!         return Ok(Redirect::to("/login").into_response());
64//!     };
65//!     if pending.locked_out(&state, ip).await.is_some() {
66//!         return Err(Error::TooManyRequests);
67//!     }
68//!     let user = User::find_or_404(&state.db, pending.user_id).await?;
69//!     if user.extra.get("pin").and_then(|pin| pin.as_str()) != Some(form.pin.as_str()) {
70//!         pending.failed(&state, ip).await;
71//!         let mut errors = Errors::new();
72//!         errors.add("pin", "That PIN is wrong.");
73//!         return Err(ValidationError::new(errors).into());
74//!     }
75//!     let to = complete_login(&state, &session, &pending, ip).await?;
76//!     Ok(match to {
77//!         Some(to) if htmx.request => HxRedirect(to).into_response(),
78//!         Some(to) => Redirect::to(&to).into_response(),
79//!         None => Redirect::to("/login").into_response(), // the password changed meanwhile
80//!     })
81//! }
82//! ```
83
84use std::future::Future;
85use std::net::IpAddr;
86use std::pin::Pin;
87use std::sync::Arc;
88
89use serde::{Deserialize, Serialize};
90
91use super::events::{LoggedIn, LoginFailed, announce};
92use super::{User, fingerprint, login};
93use crate::db::Model;
94use crate::{AppState, Result, Session};
95
96/// How long a login waits for its second step.
97const PENDING_FOR: i64 = 10 * 60;
98/// Where the waiting login lives in the session.
99const PENDING: &str = "_auth_pending";
100
101pub(crate) type RequiredFn =
102    Arc<dyn Fn(User, AppState) -> Pin<Box<dyn Future<Output = Result<bool>> + Send>> + Send + Sync>;
103
104/// What a module registered with `Registry::second_factor`.
105#[derive(Clone)]
106pub(crate) struct SecondFactor {
107    /// The challenge's route name.
108    pub(crate) challenge: String,
109    pub(crate) required: RequiredFn,
110}
111
112pub(crate) fn second_factor<F, Fut>(challenge: &str, required: F) -> SecondFactor
113where
114    F: Fn(User, AppState) -> Fut + Send + Sync + 'static,
115    Fut: Future<Output = Result<bool>> + Send + 'static,
116{
117    SecondFactor {
118        challenge: challenge.to_owned(),
119        required: Arc::new(move |user, state| Box::pin(required(user, state))),
120    }
121}
122
123/// The waiting login, as kept in the session.
124#[derive(Serialize, Deserialize)]
125struct Stored {
126    user_id: i64,
127    email: String,
128    remember: bool,
129    /// The password's fingerprint: a new password ends the wait.
130    hash: String,
131    /// When the password was right (unix seconds).
132    at: i64,
133    /// Where the user goes once logged in.
134    to: String,
135}
136
137/// A login that had the right password and waits for its second step; from
138/// [`pending_login`].
139#[derive(Debug, Clone)]
140#[non_exhaustive]
141pub struct PendingLogin {
142    /// The user logging in.
143    pub user_id: i64,
144    /// The email address they typed, for the login throttle.
145    pub email: String,
146    /// Whether they ticked "remember me".
147    pub remember: bool,
148    hash: String,
149    to: String,
150}
151
152impl PendingLogin {
153    /// Seconds this login must wait after too many wrong codes (the login
154    /// throttle, as for wrong passwords); `None` when it may try.
155    pub async fn locked_out(&self, state: &AppState, ip: Option<IpAddr>) -> Option<u64> {
156        state.throttle.blocked_for(&self.email, ip).await
157    }
158
159    /// Counts a wrong code: towards the login throttle, and as a
160    /// `LoginFailed` event (which the `Audit` module records).
161    pub async fn failed(&self, state: &AppState, ip: Option<IpAddr>) {
162        state.throttle.fail(&self.email, ip).await;
163        let event = LoginFailed {
164            email: self.email.clone(),
165            ip: ip.map(|ip| ip.to_string()),
166        };
167        announce(state, event).await;
168    }
169}
170
171/// Starts waiting for the second step (the `Auth` module's login does it).
172pub(crate) fn begin(
173    session: &Session,
174    user: &User,
175    email: &str,
176    remember: bool,
177    to: String,
178) -> Result {
179    let stored = Stored {
180        user_id: user.id,
181        email: email.to_owned(),
182        remember,
183        hash: fingerprint(&user.password),
184        at: crate::clock::unix_secs(),
185        to,
186    };
187    session.put(PENDING, stored)
188}
189
190/// Drops a login waiting for its second step (another login replaced it).
191pub(crate) fn clear(session: &Session) {
192    session.remove(PENDING);
193}
194
195/// The login waiting for its second step in this session, if there is one
196/// and it is less than ten minutes old.
197pub fn pending_login(session: &Session) -> Option<PendingLogin> {
198    let stored: Stored = session.get(PENDING)?;
199    if crate::clock::unix_secs() - stored.at > PENDING_FOR {
200        session.remove(PENDING);
201        return None;
202    }
203    Some(PendingLogin {
204        user_id: stored.user_id,
205        email: stored.email,
206        remember: stored.remember,
207        hash: stored.hash,
208        to: stored.to,
209    })
210}
211
212/// Logs the pending user in, once the second step is passed: the session
213/// gets a new id, the password counts as just confirmed, the throttle is
214/// cleared and `LoggedIn` fires, as after a plain login. Returns where to
215/// send the user (the page that asked them to log in, else the `Auth`
216/// module's `redirect_to`, else `home`), or `None` when the login can't
217/// finish: the user is gone or changed their password meanwhile. Either way
218/// the wait is over.
219pub async fn complete_login(
220    state: &AppState,
221    session: &Session,
222    pending: &PendingLogin,
223    ip: Option<IpAddr>,
224) -> Result<Option<String>> {
225    session.remove(PENDING);
226    let Some(user) = User::find(&state.db, pending.user_id).await? else {
227        return Ok(None);
228    };
229    if fingerprint(&user.password) != pending.hash {
230        return Ok(None);
231    }
232    state.throttle.clear(&pending.email, ip).await;
233    let remember = pending.remember.then_some(state.config.remember_lifetime);
234    login(session, &user, remember)?;
235    super::account::mark_confirmed(session)?;
236    let event = LoggedIn {
237        user_id: user.id,
238        ip: ip.map(|ip| ip.to_string()),
239    };
240    announce(state, event).await;
241    Ok(Some(pending.to.clone()))
242}