Skip to main content

max / makenotwork

14.3 KB · 376 lines History Blame Raw
1 //! Two-factor authentication verification page (login flow).
2
3 use axum::{
4 Form,
5 extract::State,
6 http::{StatusCode, header::HeaderMap},
7 response::{Html, IntoResponse, Redirect, Response},
8 };
9 use serde::Deserialize;
10 use sqlx::PgPool;
11 use tower_sessions::{Expiry, Session};
12
13 use crate::{
14 auth::{SessionUser, login_user, track_session},
15 background::BackgroundTx,
16 config::Config,
17 constants,
18 db::{self, UserId},
19 email::EmailClient,
20 error::{AppError, Result, ResultExt},
21 helpers::{get_csrf_token, is_htmx_request},
22 quasi::auth_pages,
23 routes::api::totp::{build_totp, find_matching_step},
24 };
25
26 /// Session key for the pending 2FA user ID.
27 const PENDING_2FA_KEY: &str = "pending_2fa_user_id";
28 const PENDING_2FA_STARTED_AT: &str = "pending_2fa_started_at";
29 const PENDING_2FA_NOTIFY_EMAIL: &str = "pending_2fa_notify_email";
30 const PENDING_2FA_NOTIFY_NAME: &str = "pending_2fa_notify_name";
31 const PENDING_2FA_TRACKING_KEY: &str = "pending_2fa_tracking_id";
32
33 /// Clear every pending-2FA session key and delete the pending user_sessions
34 /// tracking row. Used both on successful login and when the pending state
35 /// expires, so a stale unattended browser can't sit "one TOTP away from
36 /// logged in" past `PENDING_2FA_TTL_SECS`.
37 async fn clear_pending_2fa(session: &Session, pool: &PgPool) {
38 let tracking_id = session
39 .get::<crate::db::UserSessionId>(PENDING_2FA_TRACKING_KEY)
40 .await
41 .ok()
42 .flatten();
43 let pending_user_id = session.get::<UserId>(PENDING_2FA_KEY).await.ok().flatten();
44 if let (Some(tracking_id), Some(pending_user_id)) = (tracking_id, pending_user_id)
45 && let Err(e) =
46 db::sessions::delete_pending_2fa_session(pool, tracking_id, pending_user_id).await
47 {
48 tracing::warn!(error = ?e, "failed to delete pending_2fa tracking row");
49 }
50 session.remove::<UserId>(PENDING_2FA_KEY).await.ok();
51 session.remove::<i64>(PENDING_2FA_STARTED_AT).await.ok();
52 session
53 .remove::<String>(PENDING_2FA_NOTIFY_EMAIL)
54 .await
55 .ok();
56 session.remove::<String>(PENDING_2FA_NOTIFY_NAME).await.ok();
57 session.remove::<bool>("pending_2fa_remember_me").await.ok();
58 session
59 .remove::<crate::db::UserSessionId>(PENDING_2FA_TRACKING_KEY)
60 .await
61 .ok();
62 }
63
64 /// Check whether the pending-2FA state has aged past `PENDING_2FA_TTL_SECS`.
65 /// Missing `started_at` (older session pre-TTL) is treated as expired.
66 async fn pending_2fa_expired(session: &Session) -> bool {
67 let started_at: Option<i64> = session.get(PENDING_2FA_STARTED_AT).await.ok().flatten();
68 match started_at {
69 Some(ts) => chrono::Utc::now().timestamp() - ts > constants::PENDING_2FA_TTL_SECS,
70 None => true,
71 }
72 }
73
74 /// Render the 2FA verification page (GET /auth/2fa).
75 #[tracing::instrument(skip_all, name = "two_factor::two_factor_page")]
76 pub(super) async fn two_factor_page(
77 State(db): State<PgPool>,
78 session: Session,
79 ) -> Result<Response> {
80 // Verify the user is in a valid 2FA flow
81 let user_id: UserId = session
82 .get(PENDING_2FA_KEY)
83 .await
84 .context("session error")?
85 .ok_or_else(|| AppError::BadRequest("No pending 2FA session".to_string()))?;
86
87 if pending_2fa_expired(&session).await {
88 clear_pending_2fa(&session, &db).await;
89 return Err(AppError::BadRequest(
90 "Your 2FA session expired. Please log in again.".to_string(),
91 ));
92 }
93
94 // Confirm the pending_2fa tracking row still exists. If it was swept by
95 // `delete_all_sessions_for_user` ("log out everywhere"), abort the flow.
96 if let Some(tracking_id) = session
97 .get::<crate::db::UserSessionId>(PENDING_2FA_TRACKING_KEY)
98 .await
99 .ok()
100 .flatten()
101 && !db::sessions::pending_2fa_session_exists(&db, tracking_id, user_id).await?
102 {
103 clear_pending_2fa(&session, &db).await;
104 return Err(AppError::BadRequest(
105 "Your session was revoked. Please log in again.".to_string(),
106 ));
107 }
108
109 let csrf_token = get_csrf_token(&session).await;
110
111 Ok(Html(auth_pages::document(
112 csrf_token.as_deref(),
113 &auth_pages::two_factor(None),
114 ))
115 .into_response())
116 }
117
118 /// Form input for 2FA verification.
119 #[derive(Deserialize)]
120 pub(super) struct VerifyTwoFactorForm {
121 code: String,
122 }
123
124 /// A refused code: the alert on its own for an htmx submit, the whole page for
125 /// a plain one.
126 ///
127 /// The htmx half is the change `c7b0d3c1`'s conversion made. The form targeted
128 /// `closest .login-container` with `outerHTML` and this answered with a whole
129 /// rendered page, so a mistyped digit swapped an entire document into a `div`.
130 /// The described form names a region -- `auth_pages::FEEDBACK`, the same one
131 /// `/forgot-password` and `/reset-password` already fill -- and a region takes
132 /// the alert those two answer with.
133 async fn refused(session: &Session, is_htmx: bool, message: &str) -> Response {
134 if is_htmx {
135 return Html(auth_pages::answered(
136 auth_pages::FEEDBACK,
137 makeover_layout::Tone::Danger,
138 message,
139 None,
140 ))
141 .into_response();
142 }
143 let csrf_token = get_csrf_token(session).await;
144 Html(auth_pages::document(
145 csrf_token.as_deref(),
146 &auth_pages::two_factor(Some(message)),
147 ))
148 .into_response()
149 }
150
151 /// Verify the TOTP or backup code and complete login (POST /auth/verify-2fa).
152 #[tracing::instrument(skip_all, name = "two_factor::verify_two_factor")]
153 pub(super) async fn verify_two_factor(
154 State(db): State<PgPool>,
155 State(config): State<Config>,
156 State(email): State<EmailClient>,
157 State(bg): State<BackgroundTx>,
158 headers: HeaderMap,
159 session: Session,
160 Form(form): Form<VerifyTwoFactorForm>,
161 ) -> Result<Response> {
162 let is_htmx = is_htmx_request(&headers);
163
164 let user_id: UserId = session
165 .get(PENDING_2FA_KEY)
166 .await
167 .context("session error")?
168 .ok_or_else(|| AppError::BadRequest("No pending 2FA session".to_string()))?;
169
170 if pending_2fa_expired(&session).await {
171 clear_pending_2fa(&session, &db).await;
172 return Err(AppError::BadRequest(
173 "Your 2FA session expired. Please log in again.".to_string(),
174 ));
175 }
176
177 // Confirm the pending_2fa tracking row still exists (see two_factor_page
178 // for rationale). Rejecting here closes the "phisher mid-2FA-prompt"
179 // window even when the legitimate user's "log out everywhere" landed
180 // between page render and code submission.
181 if let Some(tracking_id) = session
182 .get::<crate::db::UserSessionId>(PENDING_2FA_TRACKING_KEY)
183 .await
184 .ok()
185 .flatten()
186 && !db::sessions::pending_2fa_session_exists(&db, tracking_id, user_id).await?
187 {
188 clear_pending_2fa(&session, &db).await;
189 return Err(AppError::BadRequest(
190 "Your session was revoked. Please log in again.".to_string(),
191 ));
192 }
193
194 let user = db::users::get_user_by_id(&db, user_id)
195 .await?
196 .ok_or(AppError::Unauthorized)?;
197
198 // Re-check lockout status before attempting verification (account may have
199 // been locked by a concurrent session since the 2FA page was shown)
200 if let Some(locked_until) = user.locked_until
201 && locked_until > chrono::Utc::now()
202 {
203 clear_pending_2fa(&session, &db).await;
204 let remaining = (locked_until - chrono::Utc::now()).num_minutes() + 1;
205 return Ok(refused(
206 &session,
207 is_htmx,
208 &format!("Account is locked. Try again in {remaining} minute(s)."),
209 )
210 .await);
211 }
212
213 let code = form.code.trim().to_string();
214 let mut verified = false;
215
216 // Try TOTP verification first (6-digit numeric codes). The stored secret is
217 // encrypted at rest and MUST carry the `enc:v1:` prefix; there is no legacy
218 // plaintext fallback (see `crypto::decrypt_totp_secret`). A secret that fails
219 // to decrypt (un-migrated plaintext row, wrong key, tampering) must NOT 500
220 // the whole login, that would also skip the backup-code path below and lock
221 // the user out entirely. Log it and fall through to backup codes instead.
222 if let Some(ref stored_secret) = user.totp_secret {
223 match crate::crypto::decrypt_totp_secret(stored_secret, &config.signing_secret) {
224 Ok(secret) => {
225 let totp = build_totp(&secret, &user.email)?;
226 // Find the actual step that matched (not just wall-clock step) to
227 // prevent replay across the skew boundary.
228 let now = chrono::Utc::now().timestamp() as u64;
229 let matched_step = find_matching_step(&totp, &code, now);
230 if let Some(step) = matched_step {
231 // The guarded write is the authoritative replay gate: it
232 // advances the step only if strictly newer, atomically. A
233 // concurrent submission of the same code that already advanced
234 // the step returns false here and falls through to backup
235 // codes instead of being accepted twice.
236 verified = db::totp::set_totp_last_used_step(&db, user_id, step).await?;
237 }
238 }
239 Err(e) => {
240 // Decrypt failure is not a verification failure for the user to
241 // see, fall through to backup codes, which are the recovery path.
242 tracing::error!(
243 user_id = %user_id,
244 error = ?e,
245 "TOTP secret failed to decrypt; falling through to backup codes",
246 );
247 }
248 }
249 }
250
251 // If TOTP didn't match, try backup code. We pass both the raw code (for
252 // Argon2 verify of newer rows) and the legacy HMAC (for pre-migration
253 // rows that haven't been regenerated yet). Both are evaluated in
254 // `verify_and_consume_backup_code` per row.
255 if !verified {
256 let legacy_hmac =
257 crate::routes::api::totp::legacy_hmac_backup_code(&code, &config.signing_secret);
258 if db::totp::verify_and_consume_backup_code(&db, user_id, &code, &legacy_hmac).await? {
259 verified = true;
260 }
261 }
262
263 if !verified {
264 // Track failed 2FA attempts toward account lockout (same counter as
265 // failed password attempts, prevents brute-forcing 6-digit TOTP codes)
266 db::auth::increment_failed_login(
267 &db,
268 user_id,
269 constants::MAX_LOGIN_ATTEMPTS,
270 constants::LOCKOUT_MINUTES,
271 )
272 .await?;
273
274 // Check if this attempt triggered a lockout
275 let user_after = db::users::get_user_by_id(&db, user_id).await?;
276 if let Some(ref u) = user_after
277 && let Some(locked_until) = u.locked_until
278 && locked_until > chrono::Utc::now()
279 {
280 // Clear the 2FA flow, account is now locked
281 clear_pending_2fa(&session, &db).await;
282 let remaining = (locked_until - chrono::Utc::now()).num_minutes() + 1;
283 return Ok(refused(
284 &session,
285 is_htmx,
286 &format!("Too many failed attempts. Account locked for {remaining} minute(s)."),
287 )
288 .await);
289 }
290
291 return Ok(refused(&session, is_htmx, "Invalid code. Please try again.").await);
292 }
293
294 // Successful 2FA, reset failed login counter
295 db::auth::reset_failed_login(&db, user_id).await?;
296
297 // Retrieve stored notification info. Whether the user wants login
298 // notifications is no longer read here and carried through the session: the
299 // send path checks the Login preference itself, so the answer is read once,
300 // at send time, rather than before 2FA and possibly staler.
301 let notify_email: Option<String> = session.get(PENDING_2FA_NOTIFY_EMAIL).await.ok().flatten();
302 let notify_name: Option<String> = session.get(PENDING_2FA_NOTIFY_NAME).await.ok().flatten();
303
304 // Retrieve remember-me preference
305 let remember: bool = session
306 .get("pending_2fa_remember_me")
307 .await
308 .ok()
309 .flatten()
310 .unwrap_or(false);
311
312 // Clear pending 2FA state
313 clear_pending_2fa(&session, &db).await;
314
315 // Complete login
316 let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await;
317
318 login_user(&session, session_user).await?;
319 if !remember {
320 session.set_expiry(Some(Expiry::OnSessionEnd));
321 }
322 track_session(&session, &db, user_id, &headers).await?;
323 tracing::info!(user_id = %user_id, event = "login_2fa_success", "User completed 2FA login");
324
325 // Send login notification (same as in auth.rs)
326 if let Some(email_addr) = notify_email {
327 let session_count = match db::sessions::count_user_sessions(&db, user_id).await {
328 Ok(n) => n,
329 Err(e) => {
330 tracing::warn!("Failed to count sessions for login notification: {e}");
331 0
332 }
333 };
334 if session_count > 1 {
335 let user_agent = headers
336 .get("user-agent")
337 .and_then(|v| v.to_str().ok())
338 .map(|s| {
339 s.chars()
340 .take(constants::USER_AGENT_MAX_LENGTH)
341 .collect::<String>()
342 });
343 let ip = crate::helpers::extract_client_ip(&headers);
344 let unsub_url = crate::email::generate_unsubscribe_url(
345 &config.host_url,
346 user_id,
347 crate::email::UnsubscribeAction::Login,
348 &user_id.to_string(),
349 &config.signing_secret,
350 );
351 let email_client = email.clone();
352 bg.spawn("login notification", async move {
353 if let Err(e) = email_client
354 .send_new_login_notification(
355 user_id,
356 &email_addr,
357 notify_name.as_deref(),
358 user_agent.as_deref(),
359 ip.as_deref(),
360 Some(&unsub_url),
361 )
362 .await
363 {
364 tracing::error!(error = ?e, "failed to send login notification");
365 }
366 });
367 }
368 }
369
370 if is_htmx {
371 return Ok((StatusCode::OK, [("HX-Redirect", "/dashboard")], "").into_response());
372 }
373
374 Ok(Redirect::to("/dashboard").into_response())
375 }
376