Skip to main content

max / makenotwork

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