Skip to main content

max / makenotwork

24.5 KB · 609 lines History Blame Raw
1 //! Login, logout, passkey assertion, session lookup, and username validation.
2 //!
3 //! Password reset and email verification live in `routes::pages::email_actions`.
4
5 use axum::{
6 Form,
7 extract::State,
8 handler::Handler,
9 http::{StatusCode, header::HeaderMap},
10 response::{Html, IntoResponse, Redirect, Response},
11 routing::{get, post},
12 };
13 use serde::Deserialize;
14 use tower_governor::GovernorLayer;
15 use tower_sessions::{Expiry, Session};
16
17 use crate::{
18 AppCaches, AppState,
19 auth::{
20 AuthUser, SESSION_TRACKING_KEY, SessionUser, login_user, logout_user, track_session,
21 verify_password_async,
22 },
23 config::Config,
24 constants::{self, LOCKOUT_MINUTES, MAX_LOGIN_ATTEMPTS},
25 csrf::{CsrfRouter, post_csrf, with_csrf, with_csrf_manual, with_csrf_skip},
26 db::{self, UserSessionId, Username},
27 email,
28 error::{AppError, Result, ResultExt},
29 helpers::{is_htmx_request, rate_limiter_ms, rate_limiter_per_sec},
30 templates::{SaveStatusTemplate, UsernameStatusTemplate},
31 };
32 use sqlx::PgPool;
33 use webauthn_rs::prelude::*;
34
35 /// Pre-computed Argon2id hash for timing-safe user-not-found responses.
36 /// verify_password() against this takes the same time as a real hash check.
37 static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
38 crate::auth::hash_password("anti-timing-dummy").expect("dummy hash")
39 });
40
41 /// Register authentication routes with rate limiting.
42 pub fn auth_routes(limits: constants::RateLimits) -> CsrfRouter<AppState> {
43 let auth_rate_limit = rate_limiter_ms(limits.auth_ms, limits.auth_burst);
44 let validate_rate_limit = rate_limiter_per_sec(
45 constants::VALIDATE_RATE_LIMIT_PER_SEC,
46 constants::VALIDATE_RATE_LIMIT_BURST,
47 );
48
49 CsrfRouter::new()
50 // GET /login is NOT rate-limited (page render for CSRF tokens).
51 // POST /login and passkey routes ARE rate-limited.
52 .route("/login", with_csrf_manual(
53 "POST validates via validate_token_consuming (defense-in-depth on top of SameSite=Lax)",
54 get(crate::routes::pages::public::landing::login_page)
55 .post(login_handler.layer(GovernorLayer::new(auth_rate_limit.clone()))),
56 ))
57 .route("/auth/passkey/start", with_csrf_skip(
58 "pre-auth WebAuthn challenge",
59 post(passkey_auth_start)
60 .layer(GovernorLayer::new(auth_rate_limit.clone())),
61 ))
62 .route("/auth/passkey/finish", with_csrf_skip(
63 "pre-auth WebAuthn assertion",
64 post(passkey_auth_finish)
65 .layer(GovernorLayer::new(auth_rate_limit)),
66 ))
67 // Routes without auth rate limiting
68 .route("/logout", post_csrf(logout_handler))
69 .route_get("/auth/me", get(me_handler))
70 // Username validation with its own rate limit
71 .route(
72 "/api/validate/username",
73 with_csrf(post(validate_username).layer(GovernorLayer::new(validate_rate_limit))),
74 )
75 }
76
77 /// Form input for login (accepts username or email).
78 #[derive(Debug, Deserialize)]
79 pub struct LoginForm {
80 pub login: String, // Can be username or email
81 pub password: String,
82 #[serde(default)]
83 pub remember_me: Option<String>,
84 #[serde(default, rename = "_csrf")]
85 pub csrf: Option<String>,
86 }
87
88 /// Authenticate a user via username/email and password with lockout protection.
89 #[tracing::instrument(skip_all, name = "auth::login")]
90 async fn login_handler(
91 State(db): State<PgPool>,
92 State(config): State<Config>,
93 State(mailer): State<crate::email::EmailClient>,
94 State(bg): State<crate::background::BackgroundTx>,
95 headers: HeaderMap,
96 session: Session,
97 Form(form): Form<LoginForm>,
98 ) -> Result<Response> {
99 let is_htmx = is_htmx_request(&headers);
100
101 // Manual-posture CSRF: defense-in-depth over the SameSite=Lax cookie. Run
102 // before any state-changing work (lockout increments, login-link emails,
103 // session creation). Match the standard validator's header-then-form
104 // precedence so HTMX callers and vanilla form posts both pass.
105 let token =
106 crate::csrf::token_from_header_or_field(&headers, form.csrf.as_deref()).unwrap_or_default();
107 let _validated = crate::csrf::validate_token_consuming(&session, &token).await?;
108
109 let submitted_login = form.login.clone();
110 // Pre-fetch the CSRF token so the sync error closure can recall it without
111 // awaiting (closures can't be async). On the happy path this is a single
112 // session read; on the error path it's already paid for.
113 let recall_csrf_token = if is_htmx {
114 None
115 } else {
116 crate::helpers::get_csrf_token(&session).await
117 };
118
119 let sso_enabled = config.sso.is_some();
120 // Every failed login leaves through here, whatever the reason, and none of
121 // them is visible in the response status: a wrong password re-renders the
122 // form with a 200. Counting at the exit is what makes a stuffing run
123 // countable at all.
124 let failure_ip = crate::helpers::extract_client_ip(&headers);
125 let return_error = |msg: &str| -> Result<Response> {
126 crate::security_signals::note_auth_failure(failure_ip.as_deref());
127 if is_htmx {
128 // The region the described form aims at, with its id: see
129 // `auth_pages::answered`. A bare alert would replace the element a
130 // second attempt has to land in.
131 Ok(Html(crate::quasi::auth_pages::answered(
132 crate::quasi::auth_pages::LOGIN_FEEDBACK,
133 makeover_layout::Tone::Danger,
134 msg,
135 None,
136 ))
137 .into_response())
138 } else {
139 // Full-page POST: re-render the login form with the username/email
140 // value preserved and the error inlined, instead of bouncing the
141 // user to the global error page (which loses every field).
142 Ok(axum::response::Html(crate::quasi::auth_pages::document(
143 recall_csrf_token.as_deref(),
144 &crate::quasi::auth_pages::login(&submitted_login, Some(msg), None, sso_enabled),
145 ))
146 .into_response())
147 }
148 };
149
150 let user = if form.login.contains('@') {
151 // Login form accepts username OR email. If '@' is present, try email lookup;
152 // a malformed value just fails the lookup (None), same generic error as wrong creds.
153 let Ok(email) = db::Email::new(&form.login) else {
154 // Run the DUMMY_HASH equalizer before returning so this branch's
155 // timing matches the valid-email-unknown-user path below. Without
156 // it, a malformed-email submit completes ~2 orders of magnitude
157 // faster and lets an attacker distinguish "you typed something
158 // not email-shaped" from "valid email, unknown account."
159 let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await;
160 return return_error("Invalid username or password");
161 };
162 db::users::get_user_by_email(&db, &email)
163 .await
164 .context("lookup user by email for login")?
165 } else {
166 // Validate username format; if invalid, return the same generic error
167 // as invalid credentials to avoid leaking that the format was wrong.
168 let Ok(username) = Username::new(&form.login) else {
169 let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await;
170 return return_error("Invalid username/email or password");
171 };
172 db::users::get_user_by_username(&db, &username)
173 .await
174 .context("lookup user by username for login")?
175 };
176
177 let Some(user) = user else {
178 // Run a dummy Argon2 verify to equalize timing with the "wrong password"
179 // path, preventing user enumeration via response time differences.
180 let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await;
181 tracing::info!(login = %form.login, event = "login_unknown_user", "Login attempt for non-existent account");
182 return return_error("Invalid username/email or password");
183 };
184
185 if let Some(locked_until) = user.locked_until
186 && locked_until > chrono::Utc::now()
187 {
188 let remaining = (locked_until - chrono::Utc::now()).num_minutes() + 1;
189 tracing::warn!(user_id = %user.id, event = "login_locked_account", "Login attempt on locked account");
190 return return_error(&format!(
191 "Account is locked. Try again in {remaining} minute(s), or use the login link sent to your email."
192 ));
193 }
194
195 // Cap password length to match signup validation (prevents Argon2 DoS with
196 // huge inputs). MUST use the same char-count metric as signup, or a valid
197 // multibyte password (<=128 chars, >128 bytes) is silently rejected here.
198 if crate::validation::password_too_long(&form.password) {
199 return return_error("Invalid username/email or password");
200 }
201
202 if !verify_password_async(form.password.clone(), user.password_hash.clone()).await? {
203 // Atomically increment failed attempts and lock if threshold reached
204 let result =
205 db::auth::increment_failed_login(&db, user.id, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES)
206 .await
207 .context("increment failed login attempts")?;
208 tracing::warn!(user_id = %user.id, attempts = result.attempts, event = "login_failed", "Failed login attempt");
209
210 if result.just_locked {
211 tracing::warn!(user_id = %user.id, attempts = result.attempts, lockout_minutes = LOCKOUT_MINUTES, event = "account_locked", "Account locked after repeated failures");
212
213 // Generate and send one-time login link
214 let (token, token_hash) = email::generate_login_token();
215 let expires_at = chrono::Utc::now() + chrono::Duration::minutes(LOCKOUT_MINUTES);
216 db::auth::create_login_token(&db, user.id, &token_hash, expires_at)
217 .await
218 .context("create login token after lockout")?;
219
220 let login_url = email::generate_login_link_url(&config.host_url, &token);
221 // Send lockout notification with login link
222 let user_email = user.email.clone();
223 let user_display_name = user.display_name.clone();
224 // Inlined `spawn_email!` (the macro reads .email/.bg off AppState).
225 let email_client = mailer.clone();
226 bg.spawn("lockout notification", async move {
227 if let Err(e) = email_client
228 .send_lockout_notification(
229 &user_email,
230 user_display_name.as_deref(),
231 Some(&login_url),
232 )
233 .await
234 {
235 tracing::error!(error = ?e, "failed to send lockout notification");
236 }
237 });
238
239 return return_error(&format!(
240 "Too many failed attempts. Account locked for {LOCKOUT_MINUTES} minutes. A login link has been sent to your email."
241 ));
242 }
243
244 return return_error("Invalid username/email or password");
245 }
246
247 db::auth::reset_failed_login(&db, user.id)
248 .await
249 .context("reset failed login attempts")?;
250
251 let remember = form.remember_me.as_deref() == Some("on");
252
253 // Check if user has 2FA enabled, redirect to verification page if so
254 if user.totp_enabled {
255 session.cycle_id().await.context("session cycle")?;
256 session
257 .insert("pending_2fa_user_id", user.id)
258 .await
259 .context("session insert")?;
260 session
261 .insert("pending_2fa_started_at", chrono::Utc::now().timestamp())
262 .await
263 .context("session insert")?;
264 session
265 .insert("pending_2fa_notify_email", &user.email)
266 .await
267 .context("session insert")?;
268 session
269 .insert("pending_2fa_notify_name", &user.display_name)
270 .await
271 .context("session insert")?;
272 session
273 .insert("pending_2fa_remember_me", remember)
274 .await
275 .context("session insert")?;
276
277 // Insert a `pending_2fa` user_sessions row so the intermediate state
278 // is visible to `delete_all_sessions_for_user` ("log out everywhere").
279 // Without this, a phisher mid-TOTP-prompt holds an authenticated
280 // session-storage entry the sweep can't see.
281 let ua = headers
282 .get("user-agent")
283 .and_then(|v| v.to_str().ok())
284 .map(|s| {
285 s.chars()
286 .take(constants::USER_AGENT_MAX_LENGTH)
287 .collect::<String>()
288 });
289 let ip = crate::helpers::extract_client_ip(&headers);
290 let tracking_id =
291 db::sessions::create_pending_2fa_session(&db, user.id, ua.as_deref(), ip.as_deref())
292 .await?;
293 session
294 .insert("pending_2fa_tracking_id", tracking_id)
295 .await
296 .context("session insert")?;
297
298 tracing::info!(user_id = %user.id, event = "login_2fa_pending", "User requires 2FA verification");
299
300 if is_htmx {
301 return Ok((StatusCode::OK, [("HX-Redirect", "/auth/2fa")], "").into_response());
302 }
303 return Ok(Redirect::to("/auth/2fa").into_response());
304 }
305
306 // Capture notification fields before moving user into session
307 let user_id = user.id;
308 let notify_email = user.email.clone();
309 let notify_name = user.display_name.clone();
310
311 let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await;
312
313 login_user(&session, session_user).await?;
314 if !remember {
315 session.set_expiry(Some(Expiry::OnSessionEnd));
316 }
317 track_session(&session, &db, user_id, &headers).await?;
318 tracing::info!(user_id = %user_id, event = "login_success", "User logged in");
319
320 crate::auth::maybe_send_login_notification(
321 &db,
322 &mailer,
323 &bg,
324 &config,
325 user_id,
326 &notify_email,
327 notify_name.as_deref(),
328 &headers,
329 )
330 .await;
331
332 // For HTMX requests, return redirect header
333 if is_htmx {
334 return Ok((StatusCode::OK, [("HX-Redirect", "/dashboard")], "").into_response());
335 }
336
337 Ok(Redirect::to("/dashboard").into_response())
338 }
339
340 /// Log out the current user and redirect to the landing page.
341 #[tracing::instrument(skip_all, name = "auth::logout")]
342 async fn logout_handler(
343 State(db): State<PgPool>,
344 State(caches): State<AppCaches>,
345 headers: HeaderMap,
346 session: Session,
347 ) -> Result<impl IntoResponse> {
348 // Clean up tracking row before flushing session. We require the
349 // SessionUser to derive the user_id for the scoped delete; if it's
350 // gone (already-stale session), skip the row delete, the cache
351 // remove below is still safe and the row will get pruned by the
352 // expired-session sweeper.
353 let session_user = session
354 .get::<crate::auth::SessionUser>("user")
355 .await
356 .ok()
357 .flatten();
358 if let Ok(Some(tracking_id)) = session.get::<UserSessionId>(SESSION_TRACKING_KEY).await {
359 if let Some(ref u) = session_user
360 && let Err(e) = db::sessions::delete_session_by_id(&db, tracking_id, u.id).await
361 {
362 tracing::warn!(tracking_id = %tracking_id, error = ?e, "failed to delete session tracking row on logout");
363 }
364 caches.session_cache.remove(&tracking_id);
365 }
366 logout_user(&session).await?;
367 // The header's Log Out is a `quasi_router::Place` carrying a POST since
368 // `c7b0d3c1`, so it arrives as an htmx request and a 303 would be followed
369 // by the browser and swapped into the control that sent it. `HX-Redirect`
370 // is what the verify handler above already answers with, and it navigates
371 // the document rather than replacing an element with a whole page.
372 if is_htmx_request(&headers) {
373 return Ok((StatusCode::OK, [("HX-Redirect", "/")], "").into_response());
374 }
375 Ok(Redirect::to("/").into_response())
376 }
377
378 /// Return the current session user as JSON, or 401 if not authenticated.
379 /// Uses `AuthUser` to validate session tracking (revocation check).
380 #[tracing::instrument(skip_all, name = "auth::me")]
381 async fn me_handler(AuthUser(user): AuthUser) -> Result<impl IntoResponse> {
382 Ok(axum::Json(user))
383 }
384
385 /// Form input for live username availability validation.
386 #[derive(Debug, Deserialize)]
387 pub struct ValidateUsernameForm {
388 pub username: String,
389 }
390
391 /// Check username availability and format, returning an HTMX status snippet.
392 #[tracing::instrument(skip_all, name = "auth::validate_username")]
393 async fn validate_username(
394 State(db): State<PgPool>,
395 Form(form): Form<ValidateUsernameForm>,
396 ) -> crate::error::Result<Html<String>> {
397 // Count characters, not bytes, Username::new uses chars().count() too,
398 // and `len()` on a multi-byte UTF-8 string over-counts (a 3-char ñ-bearing
399 // username trips the "too long" branch erroneously, and a 1-char emoji
400 // satisfies the `< 3` typing-guard with a single grapheme).
401 let char_count = form.username.chars().count();
402 if char_count < 3 {
403 return Ok(Html(String::new()));
404 }
405
406 // Check if username is too long
407 if char_count > 50 {
408 return Ok(Html(
409 SaveStatusTemplate {
410 success: false,
411 message: "Username too long".to_string(),
412 }
413 .render_string()?,
414 ));
415 }
416
417 // Check if username has invalid characters
418 if !form
419 .username
420 .chars()
421 .all(|c| c.is_ascii_alphanumeric() || c == '_')
422 {
423 return Ok(Html(
424 SaveStatusTemplate {
425 success: false,
426 message: "Only letters, numbers, and underscores".to_string(),
427 }
428 .render_string()?,
429 ));
430 }
431
432 // Anti-enumeration delay: every response takes >= 400ms regardless of outcome
433 tokio::time::sleep(std::time::Duration::from_millis(
434 constants::USERNAME_CHECK_DELAY_MS,
435 ))
436 .await;
437
438 // Validate and wrap the username (manual checks above cover most cases,
439 // but Username::new is the canonical validation boundary).
440 let Ok(username) = Username::new(&form.username) else {
441 return Ok(Html(
442 SaveStatusTemplate {
443 success: false,
444 message: "Invalid username format".to_string(),
445 }
446 .render_string()?,
447 ));
448 };
449 // Treat a DB error as "unavailable, retry" rather than "available". Failing
450 // open here previously let users proceed past a transient lookup error and
451 // hit a confusing signup-side rejection or race.
452 match db::users::get_user_by_username(&db, &username).await {
453 Ok(Some(_)) => Ok(Html(
454 UsernameStatusTemplate { available: false }.render_string()?,
455 )),
456 Ok(None) => Ok(Html(
457 UsernameStatusTemplate { available: true }.render_string()?,
458 )),
459 Err(e) => {
460 tracing::warn!(error = ?e, "username availability lookup failed");
461 Ok(Html(
462 SaveStatusTemplate {
463 success: false,
464 message: "Couldn't check availability, please try again".to_string(),
465 }
466 .render_string()?,
467 ))
468 }
469 }
470 }
471
472 // --- Passkey / WebAuthn authentication ---
473
474 /// Session key for in-flight passkey authentication challenge state.
475 const PASSKEY_AUTH_STATE_KEY: &str = "passkey_auth_state";
476
477 /// Start passkey authentication: discoverable flow (no username needed).
478 #[tracing::instrument(skip_all, name = "auth::passkey_start")]
479 async fn passkey_auth_start(
480 State(webauthn): State<std::sync::Arc<webauthn_rs::Webauthn>>,
481 session: Session,
482 ) -> Result<Response> {
483 let (rcr, auth_state) = webauthn
484 .start_discoverable_authentication()
485 .context("webauthn auth start")?;
486
487 session
488 .insert(PASSKEY_AUTH_STATE_KEY, &auth_state)
489 .await
490 .context("session error")?;
491
492 Ok(axum::Json(rcr).into_response())
493 }
494
495 /// Finish passkey authentication: verify assertion, create session.
496 #[tracing::instrument(skip_all, name = "auth::passkey_finish")]
497 #[allow(clippy::too_many_arguments)]
498 async fn passkey_auth_finish(
499 State(db): State<PgPool>,
500 State(config): State<Config>,
501 State(mailer): State<crate::email::EmailClient>,
502 State(bg): State<crate::background::BackgroundTx>,
503 State(webauthn): State<std::sync::Arc<webauthn_rs::Webauthn>>,
504 headers: HeaderMap,
505 session: Session,
506 axum::Json(auth): axum::Json<PublicKeyCredential>,
507 ) -> Result<Response> {
508 let auth_state: DiscoverableAuthentication = session
509 .get(PASSKEY_AUTH_STATE_KEY)
510 .await
511 .context("session error")?
512 .ok_or_else(|| AppError::BadRequest("No pending passkey authentication".to_string()))?;
513
514 // Clean up session state
515 session
516 .remove::<DiscoverableAuthentication>(PASSKEY_AUTH_STATE_KEY)
517 .await
518 .ok();
519
520 // Identify which credential responded (extracts user UUID + credential ID)
521 let (_user_uuid, cred_id_ref) = webauthn
522 .identify_discoverable_authentication(&auth)
523 .map_err(|e| AppError::BadRequest(format!("Passkey identification failed: {e}")))?;
524 let cred_id_bytes = cred_id_ref.to_vec();
525
526 // Look up user by credential ID
527 let (user_id, cred_json) = db::passkeys::find_user_by_credential_id(&db, &cred_id_bytes)
528 .await
529 .context("lookup user by passkey credential")?
530 .ok_or_else(|| AppError::BadRequest("Unknown credential".to_string()))?;
531
532 // Parse credential and convert for discoverable verification
533 let mut passkey: Passkey =
534 serde_json::from_value(cred_json).context("deserialize passkey credential")?;
535 let discoverable_key = DiscoverableKey::from(&passkey);
536
537 // Verify the authentication response. webauthn-rs rejects a non-zero
538 // signature-counter regression (CredentialPossibleCompromise) here, before
539 // we ever persist, surface that as a distinct, auditable security event
540 // rather than a generic verification failure (SEC-S2, Run #23).
541 let auth_result = webauthn
542 .finish_discoverable_authentication(&auth, auth_state, &[discoverable_key])
543 .map_err(|e| {
544 // A rejected assertion is a failed auth attempt, and it leaves as a
545 // 400, so nothing about the response says so either.
546 crate::security_signals::note_auth_failure(
547 crate::helpers::extract_client_ip(&headers).as_deref(),
548 );
549 if matches!(e, WebauthnError::CredentialPossibleCompromise) {
550 tracing::warn!(
551 user_id = %user_id,
552 event = "passkey_counter_regression",
553 "passkey sign-count regressed; authenticator may be cloned, authentication rejected"
554 );
555 }
556 AppError::BadRequest(format!("Passkey verification failed: {e}"))
557 })?;
558
559 // Persist any counter/backup-state advance. update_credential only ever
560 // raises the stored counter (never downgrades), and the regression case was
561 // already rejected above, so this cannot record a cloned counter.
562 passkey.update_credential(&auth_result);
563 let updated_json = serde_json::to_value(&passkey).context("serialize passkey credential")?;
564 db::passkeys::update_passkey_after_auth(&db, &cred_id_bytes, &updated_json)
565 .await
566 .context("update passkey counter after auth")?;
567
568 // Load full user data for session
569 let user = db::users::get_user_by_id(&db, user_id)
570 .await
571 .with_context(|| format!("fetch user {user_id} for passkey session"))?
572 .ok_or(AppError::Unauthorized)?;
573
574 if let Some(locked_until) = user.locked_until
575 && locked_until > chrono::Utc::now()
576 {
577 return Err(AppError::BadRequest("Account is locked".to_string()));
578 }
579
580 // Reset failed login attempts (successful passkey auth)
581 db::auth::reset_failed_login(&db, user.id)
582 .await
583 .context("reset failed login after passkey auth")?;
584
585 // Create session, passkeys skip TOTP (inherently two-factor)
586 let passkey_user_id = user.id;
587 let notify_email = user.email.clone();
588 let notify_name = user.display_name.clone();
589 let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await;
590
591 login_user(&session, session_user).await?;
592 track_session(&session, &db, passkey_user_id, &headers).await?;
593 tracing::info!(user_id = %passkey_user_id, event = "login_passkey_success", "User logged in via passkey");
594
595 crate::auth::maybe_send_login_notification(
596 &db,
597 &mailer,
598 &bg,
599 &config,
600 passkey_user_id,
601 &notify_email,
602 notify_name.as_deref(),
603 &headers,
604 )
605 .await;
606
607 Ok(axum::Json(serde_json::json!({"redirect": "/dashboard"})).into_response())
608 }
609