Skip to main content

max / makenotwork

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