Skip to main content

max / makenotwork

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