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}