//! Email verification and one-time login link handlers. use crate::extractors::ValidatedQuery; use axum::{ extract::State, response::{IntoResponse, Redirect, Response}, }; use serde::Deserialize; use sqlx::PgPool; use tower_sessions::Session; use crate::{ auth::{SessionUser, login_user, track_session}, background::BackgroundTx, config::Config, constants, db::{self, UserId}, email::{self, EmailClient}, error::{Result, ResultExt}, templates::EmailResultTemplate, }; /// Query parameters for the email verification link. #[derive(Debug, Deserialize)] pub(super) struct VerifyEmailQuery { pub user: Option, pub expires: Option, pub sig: Option, } /// Verify a user's email address via a signed link. #[tracing::instrument(skip_all, name = "email_actions::verify_email_handler")] pub(super) async fn verify_email_handler( State(db): State, State(config): State, _session: Session, ValidatedQuery(query): ValidatedQuery, ) -> Result { let error_page = |title: &str, msg: &str, link_url: &str, link_text: &str| -> Response { EmailResultTemplate { csrf_token: None, title: title.to_string(), message: msg.to_string(), link_url: link_url.to_string(), link_text: link_text.to_string(), } .into_response() }; // Validate all required parameters are present let (user_id_str, expires, sig) = match (&query.user, query.expires, &query.sig) { (Some(u), Some(e), Some(s)) => (u.clone(), e, s.clone()), _ => { return Ok(error_page( "Email Verification Failed", "Invalid verification link", "/dashboard", "Go to dashboard", )); } }; let user_id: UserId = match user_id_str.parse() { Ok(id) => id, Err(_) => { return Ok(error_page( "Email Verification Failed", "Invalid verification link", "/dashboard", "Go to dashboard", )); } }; // Generic failure response reused for every pre-signature outcome so this // endpoint is not an account-enumeration oracle: "user not found", "already // verified", and "bad signature" must be indistinguishable to a caller // without a valid HMAC. UserIds are visible in `/feed/{id}` URLs, so any // observable difference would leak account existence + verified-state. let invalid_link = || { error_page( "Email Verification Failed", "Verification link has expired or is invalid. Please request a new one.", "/dashboard", "Go to dashboard", ) }; // Get user. A missing user yields the same generic response as a bad // signature (the signature is keyed on the user's email, so a genuine link // can only exist for a real user anyway). let Ok(Some(user)) = db::users::get_user_by_id(&db, user_id).await else { return Ok(invalid_link()); }; // Verify the HMAC signature (and embedded expiry) BEFORE revealing any // account state such as the already-verified redirect below. if !email::verify_email_signature(user_id, expires, &user.email, &sig, &config.signing_secret) { return Ok(invalid_link()); } // Signature is valid, only now is it safe to reveal verified state. if user.email_verified { return Ok(Redirect::to("/dashboard").into_response()); } // Mark email as verified db::users::verify_user_email(&db, user_id).await?; // Auto-attach any guest purchases made with this email before signup match db::transactions::attach_guest_purchases_by_email(&db, &user.email, user_id).await { Ok(0) => {} Ok(n) => { tracing::info!(user_id = %user_id, count = n, "auto-attached guest purchases on email verification"); } Err(e) => { tracing::warn!(user_id = %user_id, error = ?e, "failed to attach guest purchases"); } } tracing::info!(user_id = %user_id, event = "email_verified", "Email verified"); // Return success page Ok(EmailResultTemplate { csrf_token: None, title: "Email Verified".to_string(), message: "Your email has been verified successfully.".to_string(), link_url: "/dashboard".to_string(), link_text: "Go to dashboard".to_string(), } .into_response()) } /// Query parameters for one-time login links. #[derive(Debug, Deserialize)] pub(super) struct LoginLinkQuery { pub token: Option, } /// Authenticate a user via a one-time login link token. #[tracing::instrument(skip_all, name = "email_actions::login_link_handler")] pub(super) async fn login_link_handler( State(db): State, State(config): State, State(email): State, State(bg): State, headers: axum::http::header::HeaderMap, session: Session, ValidatedQuery(query): ValidatedQuery, ) -> Result { let error_page = |msg: &str| -> Response { EmailResultTemplate { csrf_token: None, title: "Login Link Invalid".to_string(), message: msg.to_string(), link_url: "/login".to_string(), link_text: "Go to login".to_string(), } .into_response() }; // Get token from query let token = match &query.token { Some(t) => t.clone(), None => return Ok(error_page("Invalid login link")), }; // Hash the provided token to look it up let token_hash = { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); hex::encode(hasher.finalize()) }; // Atomically consume the token (marks it used and returns it in one query) let Some(login_token) = db::auth::consume_login_token(&db, &token_hash).await? else { return Ok(error_page( "This login link has expired or has already been used.", )); }; let Some(user) = db::users::get_user_by_id(&db, login_token.user_id).await? else { return Ok(error_page("User not found")); }; // Reset failed login attempts and unlock account db::auth::reset_failed_login(&db, user.id).await?; // If user has TOTP 2FA enabled, redirect to 2FA verification instead of creating session if user.totp_enabled { session.cycle_id().await.context("session cycle")?; session .insert("pending_2fa_user_id", user.id) .await .context("session insert")?; session .insert("pending_2fa_started_at", chrono::Utc::now().timestamp()) .await .context("session insert")?; session .insert("pending_2fa_notify_email", &user.email) .await .context("session insert")?; session .insert("pending_2fa_notify_name", &user.display_name) .await .context("session insert")?; // Track the pending_2fa session so "log out everywhere" can sweep it. let ua = headers .get("user-agent") .and_then(|v| v.to_str().ok()) .map(|s| { s.chars() .take(crate::constants::USER_AGENT_MAX_LENGTH) .collect::() }); let ip = crate::helpers::extract_client_ip(&headers); let tracking_id = db::sessions::create_pending_2fa_session(&db, user.id, ua.as_deref(), ip.as_deref()) .await?; session .insert("pending_2fa_tracking_id", tracking_id) .await .context("session insert")?; tracing::info!(user_id = %user.id, event = "login_link_2fa_pending", "Login link used, 2FA verification required"); return Ok(Redirect::to("/auth/2fa").into_response()); } // Capture notification fields before moving user into session let user_id = user.id; let notify_email = user.email.clone(); let notify_name = user.display_name.clone(); // Create session let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await; login_user(&session, session_user).await?; track_session(&session, &db, user_id, &headers).await?; tracing::info!(user_id = %user_id, event = "login_link_used", "One-time login link used"); // Send new-device login notification (fire-and-forget). The Login // preference is checked by the send path. { let session_count = db::sessions::count_user_sessions(&db, user_id) .await .unwrap_or(0); if session_count > 1 { let user_agent = headers .get("user-agent") .and_then(|v| v.to_str().ok()) .map(|s| { s.chars() .take(constants::USER_AGENT_MAX_LENGTH) .collect::() }); // Use the trusted client-IP extractor (cf-connecting-ip only), same // as the 2FA-tracking path above. Reading raw X-Forwarded-For here // let an attacker triggering a login spoof the IP shown in the // victim's "new device" security email, the one IP most directly // presented to a human as a security signal. let ip = crate::helpers::extract_client_ip(&headers); let unsub_url = email::generate_unsubscribe_url( &config.host_url, user_id, email::UnsubscribeAction::Login, &user_id.to_string(), &config.signing_secret, ); let email = email.clone(); bg.spawn("login notification", async move { if let Err(e) = email .send_new_login_notification( user_id, ¬ify_email, notify_name.as_deref(), user_agent.as_deref(), ip.as_deref(), Some(&unsub_url), ) .await { tracing::error!(error = ?e, "failed to send login notification"); } }); } } Ok(Redirect::to("/dashboard").into_response()) }