| 14 |
14 |
|
|
| 15 |
15 |
|
use crate::{
|
| 16 |
16 |
|
auth::{login_user, logout_user, track_session, verify_password_async, AuthUser, SessionUser, SESSION_TRACKING_KEY},
|
|
17 |
+ |
config::Config,
|
| 17 |
18 |
|
csrf::{post_csrf, with_csrf, with_csrf_manual, with_csrf_skip, CsrfRouter},
|
| 18 |
19 |
|
constants::{self, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES},
|
| 19 |
20 |
|
db::{self, UserSessionId, Username},
|
| 20 |
21 |
|
email,
|
| 21 |
22 |
|
error::{AppError, Result, ResultExt},
|
| 22 |
|
- |
helpers::{is_htmx_request, rate_limiter_ms, rate_limiter_per_sec, spawn_email},
|
|
23 |
+ |
helpers::{is_htmx_request, rate_limiter_ms, rate_limiter_per_sec},
|
| 23 |
24 |
|
templates::*,
|
| 24 |
25 |
|
AppCaches, AppState,
|
| 25 |
26 |
|
};
|
| 81 |
82 |
|
/// Authenticate a user via username/email and password with lockout protection.
|
| 82 |
83 |
|
#[tracing::instrument(skip_all, name = "auth::login")]
|
| 83 |
84 |
|
async fn login_handler(
|
| 84 |
|
- |
State(state): State<AppState>,
|
|
85 |
+ |
State(db): State<PgPool>,
|
|
86 |
+ |
State(config): State<Config>,
|
|
87 |
+ |
State(mailer): State<crate::email::EmailClient>,
|
|
88 |
+ |
State(bg): State<crate::background::BackgroundTx>,
|
| 85 |
89 |
|
headers: HeaderMap,
|
| 86 |
90 |
|
session: Session,
|
| 87 |
91 |
|
Form(form): Form<LoginForm>,
|
| 106 |
110 |
|
crate::helpers::get_csrf_token(&session).await
|
| 107 |
111 |
|
};
|
| 108 |
112 |
|
|
| 109 |
|
- |
let sso_enabled = state.config.sso.is_some();
|
|
113 |
+ |
let sso_enabled = config.sso.is_some();
|
| 110 |
114 |
|
let return_error = |msg: &str| -> Result<Response> {
|
| 111 |
115 |
|
if is_htmx {
|
| 112 |
116 |
|
Ok(Html(LoginErrorTemplate {
|
| 138 |
142 |
|
let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await;
|
| 139 |
143 |
|
return return_error("Invalid username or password");
|
| 140 |
144 |
|
};
|
| 141 |
|
- |
db::users::get_user_by_email(&state.db, &email)
|
|
145 |
+ |
db::users::get_user_by_email(&db, &email)
|
| 142 |
146 |
|
.await
|
| 143 |
147 |
|
.context("lookup user by email for login")?
|
| 144 |
148 |
|
} else {
|
| 151 |
155 |
|
return return_error("Invalid username/email or password");
|
| 152 |
156 |
|
}
|
| 153 |
157 |
|
};
|
| 154 |
|
- |
db::users::get_user_by_username(&state.db, &username)
|
|
158 |
+ |
db::users::get_user_by_username(&db, &username)
|
| 155 |
159 |
|
.await
|
| 156 |
160 |
|
.context("lookup user by username for login")?
|
| 157 |
161 |
|
};
|
| 188 |
192 |
|
if !verify_password_async(form.password.clone(), user.password_hash.clone()).await? {
|
| 189 |
193 |
|
// Atomically increment failed attempts and lock if threshold reached
|
| 190 |
194 |
|
let result = db::auth::increment_failed_login(
|
| 191 |
|
- |
&state.db, user.id, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES,
|
|
195 |
+ |
&db, user.id, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES,
|
| 192 |
196 |
|
)
|
| 193 |
197 |
|
.await
|
| 194 |
198 |
|
.context("increment failed login attempts")?;
|
| 200 |
204 |
|
// Generate and send one-time login link
|
| 201 |
205 |
|
let (token, token_hash) = email::generate_login_token();
|
| 202 |
206 |
|
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(LOCKOUT_MINUTES);
|
| 203 |
|
- |
db::auth::create_login_token(&state.db, user.id, &token_hash, expires_at)
|
|
207 |
+ |
db::auth::create_login_token(&db, user.id, &token_hash, expires_at)
|
| 204 |
208 |
|
.await
|
| 205 |
209 |
|
.context("create login token after lockout")?;
|
| 206 |
210 |
|
|
| 207 |
|
- |
let login_url = email::generate_login_link_url(&state.config.host_url, &token);
|
|
211 |
+ |
let login_url = email::generate_login_link_url(&config.host_url, &token);
|
| 208 |
212 |
|
// Send lockout notification with login link
|
| 209 |
213 |
|
let user_email = user.email.clone();
|
| 210 |
214 |
|
let user_display_name = user.display_name.clone();
|
| 211 |
|
- |
spawn_email!(state, "lockout notification", |email| {
|
| 212 |
|
- |
email.send_lockout_notification(&user_email, user_display_name.as_deref(), Some(&login_url))
|
|
215 |
+ |
// Inlined `spawn_email!` (the macro reads .email/.bg off AppState).
|
|
216 |
+ |
let email_client = mailer.clone();
|
|
217 |
+ |
bg.spawn("lockout notification", async move {
|
|
218 |
+ |
if let Err(e) = email_client
|
|
219 |
+ |
.send_lockout_notification(&user_email, user_display_name.as_deref(), Some(&login_url))
|
|
220 |
+ |
.await
|
|
221 |
+ |
{
|
|
222 |
+ |
tracing::error!(error = ?e, "failed to send lockout notification");
|
|
223 |
+ |
}
|
| 213 |
224 |
|
});
|
| 214 |
225 |
|
|
| 215 |
226 |
|
return return_error(&format!(
|
| 221 |
232 |
|
return return_error("Invalid username/email or password");
|
| 222 |
233 |
|
}
|
| 223 |
234 |
|
|
| 224 |
|
- |
db::auth::reset_failed_login(&state.db, user.id)
|
|
235 |
+ |
db::auth::reset_failed_login(&db, user.id)
|
| 225 |
236 |
|
.await
|
| 226 |
237 |
|
.context("reset failed login attempts")?;
|
| 227 |
238 |
|
|
| 247 |
258 |
|
.map(|s| s.chars().take(constants::USER_AGENT_MAX_LENGTH).collect::<String>());
|
| 248 |
259 |
|
let ip = crate::helpers::extract_client_ip(&headers);
|
| 249 |
260 |
|
let tracking_id = db::sessions::create_pending_2fa_session(
|
| 250 |
|
- |
&state.db, user.id, ua.as_deref(), ip.as_deref(),
|
|
261 |
+ |
&db, user.id, ua.as_deref(), ip.as_deref(),
|
| 251 |
262 |
|
).await?;
|
| 252 |
263 |
|
session.insert("pending_2fa_tracking_id", tracking_id).await.context("session insert")?;
|
| 253 |
264 |
|
|
| 265 |
276 |
|
let notify_name = user.display_name.clone();
|
| 266 |
277 |
|
let notify_enabled = user.login_notification_enabled;
|
| 267 |
278 |
|
|
| 268 |
|
- |
let session_user = SessionUser::from_db_user(user, &state.db, state.config.admin_user_id).await;
|
|
279 |
+ |
let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await;
|
| 269 |
280 |
|
|
| 270 |
281 |
|
login_user(&session, session_user).await?;
|
| 271 |
282 |
|
if !remember {
|
| 272 |
283 |
|
session.set_expiry(Some(Expiry::OnSessionEnd));
|
| 273 |
284 |
|
}
|
| 274 |
|
- |
track_session(&session, &state.db, user_id, &headers).await?;
|
|
285 |
+ |
track_session(&session, &db, user_id, &headers).await?;
|
| 275 |
286 |
|
tracing::info!(user_id = %user_id, event = "login_success", "User logged in");
|
| 276 |
287 |
|
|
| 277 |
288 |
|
crate::auth::maybe_send_login_notification(
|
| 278 |
|
- |
&state, user_id, ¬ify_email, notify_name.as_deref(), notify_enabled, &headers,
|
|
289 |
+ |
&db, &mailer, &bg, &config, user_id, ¬ify_email, notify_name.as_deref(), notify_enabled, &headers,
|
| 279 |
290 |
|
).await;
|
| 280 |
291 |
|
|
| 281 |
292 |
|
// For HTMX requests, return redirect header
|
| 396 |
407 |
|
/// Start passkey authentication: discoverable flow (no username needed).
|
| 397 |
408 |
|
#[tracing::instrument(skip_all, name = "auth::passkey_start")]
|
| 398 |
409 |
|
async fn passkey_auth_start(
|
| 399 |
|
- |
State(state): State<AppState>,
|
|
410 |
+ |
State(webauthn): State<std::sync::Arc<webauthn_rs::Webauthn>>,
|
| 400 |
411 |
|
session: Session,
|
| 401 |
412 |
|
) -> Result<Response> {
|
| 402 |
|
- |
let (rcr, auth_state) = state
|
| 403 |
|
- |
.webauthn
|
|
413 |
+ |
let (rcr, auth_state) = webauthn
|
| 404 |
414 |
|
.start_discoverable_authentication()
|
| 405 |
415 |
|
.context("webauthn auth start")?;
|
| 406 |
416 |
|
|
| 414 |
424 |
|
|
| 415 |
425 |
|
/// Finish passkey authentication: verify assertion, create session.
|
| 416 |
426 |
|
#[tracing::instrument(skip_all, name = "auth::passkey_finish")]
|
|
427 |
+ |
#[allow(clippy::too_many_arguments)]
|
| 417 |
428 |
|
async fn passkey_auth_finish(
|
| 418 |
|
- |
State(state): State<AppState>,
|
|
429 |
+ |
State(db): State<PgPool>,
|
|
430 |
+ |
State(config): State<Config>,
|
|
431 |
+ |
State(mailer): State<crate::email::EmailClient>,
|
|
432 |
+ |
State(bg): State<crate::background::BackgroundTx>,
|
|
433 |
+ |
State(webauthn): State<std::sync::Arc<webauthn_rs::Webauthn>>,
|
| 419 |
434 |
|
headers: HeaderMap,
|
| 420 |
435 |
|
session: Session,
|
| 421 |
436 |
|
axum::Json(auth): axum::Json<PublicKeyCredential>,
|
| 433 |
448 |
|
.ok();
|
| 434 |
449 |
|
|
| 435 |
450 |
|
// Identify which credential responded (extracts user UUID + credential ID)
|
| 436 |
|
- |
let (_user_uuid, cred_id_ref) = state
|
| 437 |
|
- |
.webauthn
|
|
451 |
+ |
let (_user_uuid, cred_id_ref) = webauthn
|
| 438 |
452 |
|
.identify_discoverable_authentication(&auth)
|
| 439 |
453 |
|
.map_err(|e| AppError::BadRequest(format!("Passkey identification failed: {}", e)))?;
|
| 440 |
454 |
|
let cred_id_bytes = cred_id_ref.to_vec();
|
| 441 |
455 |
|
|
| 442 |
456 |
|
// Look up user by credential ID
|
| 443 |
|
- |
let (user_id, cred_json) = db::passkeys::find_user_by_credential_id(&state.db, &cred_id_bytes)
|
|
457 |
+ |
let (user_id, cred_json) = db::passkeys::find_user_by_credential_id(&db, &cred_id_bytes)
|
| 444 |
458 |
|
.await
|
| 445 |
459 |
|
.context("lookup user by passkey credential")?
|
| 446 |
460 |
|
.ok_or_else(|| AppError::BadRequest("Unknown credential".to_string()))?;
|
| 454 |
468 |
|
// signature-counter regression (CredentialPossibleCompromise) here, before
|
| 455 |
469 |
|
// we ever persist — surface that as a distinct, auditable security event
|
| 456 |
470 |
|
// rather than a generic verification failure (SEC-S2, Run #23).
|
| 457 |
|
- |
let auth_result = state
|
| 458 |
|
- |
.webauthn
|
|
471 |
+ |
let auth_result = webauthn
|
| 459 |
472 |
|
.finish_discoverable_authentication(&auth, auth_state, &[discoverable_key])
|
| 460 |
473 |
|
.map_err(|e| {
|
| 461 |
474 |
|
if matches!(e, WebauthnError::CredentialPossibleCompromise) {
|
| 474 |
487 |
|
passkey.update_credential(&auth_result);
|
| 475 |
488 |
|
let updated_json = serde_json::to_value(&passkey)
|
| 476 |
489 |
|
.context("serialize passkey credential")?;
|
| 477 |
|
- |
db::passkeys::update_passkey_after_auth(&state.db, &cred_id_bytes, &updated_json)
|
|
490 |
+ |
db::passkeys::update_passkey_after_auth(&db, &cred_id_bytes, &updated_json)
|
| 478 |
491 |
|
.await
|
| 479 |
492 |
|
.context("update passkey counter after auth")?;
|
| 480 |
493 |
|
|
| 481 |
494 |
|
// Load full user data for session
|
| 482 |
|
- |
let user = db::users::get_user_by_id(&state.db, user_id)
|
|
495 |
+ |
let user = db::users::get_user_by_id(&db, user_id)
|
| 483 |
496 |
|
.await
|
| 484 |
497 |
|
.with_context(|| format!("fetch user {user_id} for passkey session"))?
|
| 485 |
498 |
|
.ok_or(AppError::Unauthorized)?;
|
| 491 |
504 |
|
}
|
| 492 |
505 |
|
|
| 493 |
506 |
|
// Reset failed login attempts (successful passkey auth)
|
| 494 |
|
- |
db::auth::reset_failed_login(&state.db, user.id)
|
|
507 |
+ |
db::auth::reset_failed_login(&db, user.id)
|
| 495 |
508 |
|
.await
|
| 496 |
509 |
|
.context("reset failed login after passkey auth")?;
|
| 497 |
510 |
|
|
| 500 |
513 |
|
let notify_email = user.email.clone();
|
| 501 |
514 |
|
let notify_name = user.display_name.clone();
|
| 502 |
515 |
|
let notify_enabled = user.login_notification_enabled;
|
| 503 |
|
- |
let session_user = SessionUser::from_db_user(user, &state.db, state.config.admin_user_id).await;
|
|
516 |
+ |
let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await;
|
| 504 |
517 |
|
|
| 505 |
518 |
|
login_user(&session, session_user).await?;
|
| 506 |
|
- |
track_session(&session, &state.db, passkey_user_id, &headers).await?;
|
|
519 |
+ |
track_session(&session, &db, passkey_user_id, &headers).await?;
|
| 507 |
520 |
|
tracing::info!(user_id = %passkey_user_id, event = "login_passkey_success", "User logged in via passkey");
|
| 508 |
521 |
|
|
| 509 |
522 |
|
crate::auth::maybe_send_login_notification(
|
| 510 |
|
- |
&state, passkey_user_id, ¬ify_email, notify_name.as_deref(), notify_enabled, &headers,
|
|
523 |
+ |
&db, &mailer, &bg, &config, passkey_user_id, ¬ify_email, notify_name.as_deref(), notify_enabled, &headers,
|
| 511 |
524 |
|
).await;
|
| 512 |
525 |
|
|
| 513 |
526 |
|
Ok(axum::Json(serde_json::json!({"redirect": "/dashboard"})).into_response())
|