Skip to main content

max / makenotwork

23.8 KB · 600 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::{LoginErrorTemplate, LoginTemplate, 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 Ok(Html(
129 LoginErrorTemplate {
130 message: msg.to_string(),
131 }
132 .render_string()?,
133 )
134 .into_response())
135 } else {
136 // Full-page POST: re-render the login form with the username/email
137 // value preserved and the error inlined, instead of bouncing the
138 // user to the global error page (which loses every field).
139 Ok(LoginTemplate {
140 csrf_token: recall_csrf_token.clone(),
141 prefill_login: submitted_login.clone(),
142 error: Some(msg.to_string()),
143 notice: None,
144 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 session: Session,
346 ) -> Result<impl IntoResponse> {
347 // Clean up tracking row before flushing session. We require the
348 // SessionUser to derive the user_id for the scoped delete; if it's
349 // gone (already-stale session), skip the row delete, the cache
350 // remove below is still safe and the row will get pruned by the
351 // expired-session sweeper.
352 let session_user = session
353 .get::<crate::auth::SessionUser>("user")
354 .await
355 .ok()
356 .flatten();
357 if let Ok(Some(tracking_id)) = session.get::<UserSessionId>(SESSION_TRACKING_KEY).await {
358 if let Some(ref u) = session_user
359 && let Err(e) = db::sessions::delete_session_by_id(&db, tracking_id, u.id).await
360 {
361 tracing::warn!(tracking_id = %tracking_id, error = ?e, "failed to delete session tracking row on logout");
362 }
363 caches.session_cache.remove(&tracking_id);
364 }
365 logout_user(&session).await?;
366 Ok(Redirect::to("/"))
367 }
368
369 /// Return the current session user as JSON, or 401 if not authenticated.
370 /// Uses `AuthUser` to validate session tracking (revocation check).
371 #[tracing::instrument(skip_all, name = "auth::me")]
372 async fn me_handler(AuthUser(user): AuthUser) -> Result<impl IntoResponse> {
373 Ok(axum::Json(user))
374 }
375
376 /// Form input for live username availability validation.
377 #[derive(Debug, Deserialize)]
378 pub struct ValidateUsernameForm {
379 pub username: String,
380 }
381
382 /// Check username availability and format, returning an HTMX status snippet.
383 #[tracing::instrument(skip_all, name = "auth::validate_username")]
384 async fn validate_username(
385 State(db): State<PgPool>,
386 Form(form): Form<ValidateUsernameForm>,
387 ) -> crate::error::Result<Html<String>> {
388 // Count characters, not bytes, Username::new uses chars().count() too,
389 // and `len()` on a multi-byte UTF-8 string over-counts (a 3-char ñ-bearing
390 // username trips the "too long" branch erroneously, and a 1-char emoji
391 // satisfies the `< 3` typing-guard with a single grapheme).
392 let char_count = form.username.chars().count();
393 if char_count < 3 {
394 return Ok(Html(String::new()));
395 }
396
397 // Check if username is too long
398 if char_count > 50 {
399 return Ok(Html(
400 SaveStatusTemplate {
401 success: false,
402 message: "Username too long".to_string(),
403 }
404 .render_string()?,
405 ));
406 }
407
408 // Check if username has invalid characters
409 if !form
410 .username
411 .chars()
412 .all(|c| c.is_ascii_alphanumeric() || c == '_')
413 {
414 return Ok(Html(
415 SaveStatusTemplate {
416 success: false,
417 message: "Only letters, numbers, and underscores".to_string(),
418 }
419 .render_string()?,
420 ));
421 }
422
423 // Anti-enumeration delay: every response takes >= 400ms regardless of outcome
424 tokio::time::sleep(std::time::Duration::from_millis(
425 constants::USERNAME_CHECK_DELAY_MS,
426 ))
427 .await;
428
429 // Validate and wrap the username (manual checks above cover most cases,
430 // but Username::new is the canonical validation boundary).
431 let Ok(username) = Username::new(&form.username) else {
432 return Ok(Html(
433 SaveStatusTemplate {
434 success: false,
435 message: "Invalid username format".to_string(),
436 }
437 .render_string()?,
438 ));
439 };
440 // Treat a DB error as "unavailable, retry" rather than "available". Failing
441 // open here previously let users proceed past a transient lookup error and
442 // hit a confusing signup-side rejection or race.
443 match db::users::get_user_by_username(&db, &username).await {
444 Ok(Some(_)) => Ok(Html(
445 UsernameStatusTemplate { available: false }.render_string()?,
446 )),
447 Ok(None) => Ok(Html(
448 UsernameStatusTemplate { available: true }.render_string()?,
449 )),
450 Err(e) => {
451 tracing::warn!(error = ?e, "username availability lookup failed");
452 Ok(Html(
453 SaveStatusTemplate {
454 success: false,
455 message: "Couldn't check availability, please try again".to_string(),
456 }
457 .render_string()?,
458 ))
459 }
460 }
461 }
462
463 // --- Passkey / WebAuthn authentication ---
464
465 /// Session key for in-flight passkey authentication challenge state.
466 const PASSKEY_AUTH_STATE_KEY: &str = "passkey_auth_state";
467
468 /// Start passkey authentication: discoverable flow (no username needed).
469 #[tracing::instrument(skip_all, name = "auth::passkey_start")]
470 async fn passkey_auth_start(
471 State(webauthn): State<std::sync::Arc<webauthn_rs::Webauthn>>,
472 session: Session,
473 ) -> Result<Response> {
474 let (rcr, auth_state) = webauthn
475 .start_discoverable_authentication()
476 .context("webauthn auth start")?;
477
478 session
479 .insert(PASSKEY_AUTH_STATE_KEY, &auth_state)
480 .await
481 .context("session error")?;
482
483 Ok(axum::Json(rcr).into_response())
484 }
485
486 /// Finish passkey authentication: verify assertion, create session.
487 #[tracing::instrument(skip_all, name = "auth::passkey_finish")]
488 #[allow(clippy::too_many_arguments)]
489 async fn passkey_auth_finish(
490 State(db): State<PgPool>,
491 State(config): State<Config>,
492 State(mailer): State<crate::email::EmailClient>,
493 State(bg): State<crate::background::BackgroundTx>,
494 State(webauthn): State<std::sync::Arc<webauthn_rs::Webauthn>>,
495 headers: HeaderMap,
496 session: Session,
497 axum::Json(auth): axum::Json<PublicKeyCredential>,
498 ) -> Result<Response> {
499 let auth_state: DiscoverableAuthentication = session
500 .get(PASSKEY_AUTH_STATE_KEY)
501 .await
502 .context("session error")?
503 .ok_or_else(|| AppError::BadRequest("No pending passkey authentication".to_string()))?;
504
505 // Clean up session state
506 session
507 .remove::<DiscoverableAuthentication>(PASSKEY_AUTH_STATE_KEY)
508 .await
509 .ok();
510
511 // Identify which credential responded (extracts user UUID + credential ID)
512 let (_user_uuid, cred_id_ref) = webauthn
513 .identify_discoverable_authentication(&auth)
514 .map_err(|e| AppError::BadRequest(format!("Passkey identification failed: {e}")))?;
515 let cred_id_bytes = cred_id_ref.to_vec();
516
517 // Look up user by credential ID
518 let (user_id, cred_json) = db::passkeys::find_user_by_credential_id(&db, &cred_id_bytes)
519 .await
520 .context("lookup user by passkey credential")?
521 .ok_or_else(|| AppError::BadRequest("Unknown credential".to_string()))?;
522
523 // Parse credential and convert for discoverable verification
524 let mut passkey: Passkey =
525 serde_json::from_value(cred_json).context("deserialize passkey credential")?;
526 let discoverable_key = DiscoverableKey::from(&passkey);
527
528 // Verify the authentication response. webauthn-rs rejects a non-zero
529 // signature-counter regression (CredentialPossibleCompromise) here, before
530 // we ever persist, surface that as a distinct, auditable security event
531 // rather than a generic verification failure (SEC-S2, Run #23).
532 let auth_result = webauthn
533 .finish_discoverable_authentication(&auth, auth_state, &[discoverable_key])
534 .map_err(|e| {
535 // A rejected assertion is a failed auth attempt, and it leaves as a
536 // 400, so nothing about the response says so either.
537 crate::security_signals::note_auth_failure(
538 crate::helpers::extract_client_ip(&headers).as_deref(),
539 );
540 if matches!(e, WebauthnError::CredentialPossibleCompromise) {
541 tracing::warn!(
542 user_id = %user_id,
543 event = "passkey_counter_regression",
544 "passkey sign-count regressed; authenticator may be cloned, authentication rejected"
545 );
546 }
547 AppError::BadRequest(format!("Passkey verification failed: {e}"))
548 })?;
549
550 // Persist any counter/backup-state advance. update_credential only ever
551 // raises the stored counter (never downgrades), and the regression case was
552 // already rejected above, so this cannot record a cloned counter.
553 passkey.update_credential(&auth_result);
554 let updated_json = serde_json::to_value(&passkey).context("serialize passkey credential")?;
555 db::passkeys::update_passkey_after_auth(&db, &cred_id_bytes, &updated_json)
556 .await
557 .context("update passkey counter after auth")?;
558
559 // Load full user data for session
560 let user = db::users::get_user_by_id(&db, user_id)
561 .await
562 .with_context(|| format!("fetch user {user_id} for passkey session"))?
563 .ok_or(AppError::Unauthorized)?;
564
565 if let Some(locked_until) = user.locked_until
566 && locked_until > chrono::Utc::now()
567 {
568 return Err(AppError::BadRequest("Account is locked".to_string()));
569 }
570
571 // Reset failed login attempts (successful passkey auth)
572 db::auth::reset_failed_login(&db, user.id)
573 .await
574 .context("reset failed login after passkey auth")?;
575
576 // Create session, passkeys skip TOTP (inherently two-factor)
577 let passkey_user_id = user.id;
578 let notify_email = user.email.clone();
579 let notify_name = user.display_name.clone();
580 let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await;
581
582 login_user(&session, session_user).await?;
583 track_session(&session, &db, passkey_user_id, &headers).await?;
584 tracing::info!(user_id = %passkey_user_id, event = "login_passkey_success", "User logged in via passkey");
585
586 crate::auth::maybe_send_login_notification(
587 &db,
588 &mailer,
589 &bg,
590 &config,
591 passkey_user_id,
592 &notify_email,
593 notify_name.as_deref(),
594 &headers,
595 )
596 .await;
597
598 Ok(axum::Json(serde_json::json!({"redirect": "/dashboard"})).into_response())
599 }
600