Skip to main content

max / makenotwork

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