Skip to main content

max / makenotwork

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