Skip to main content

max / makenotwork

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