//! Email verification and one-time login link handlers. use axum::{ extract::{Query, State}, response::{IntoResponse, Redirect, Response}, }; use serde::Deserialize; use tower_sessions::Session; use crate::{ auth::{login_user, track_session, SessionUser}, constants, db::{self, UserId}, email, error::{Result, ResultExt}, helpers::spawn_email, templates::*, AppState, }; /// Query parameters for the email verification link. #[derive(Debug, Deserialize)] pub 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(state): State, _session: Session, Query(query): Query, ) -> 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", )) } }; // Parse user ID 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", )) } }; // Get user let user = match db::users::get_user_by_id(&state.db, user_id).await { Ok(Some(u)) => u, _ => { return Ok(error_page( "Email Verification Failed", "User not found", "/dashboard", "Go to dashboard", )) } }; // Check if already verified if user.email_verified { return Ok(Redirect::to("/dashboard").into_response()); } // Verify HMAC signature if !email::verify_email_signature( user_id, expires, &user.email, &sig, &state.config.signing_secret, ) { return Ok(error_page( "Email Verification Failed", "Verification link has expired or is invalid. Please request a new one.", "/dashboard", "Go to dashboard", )); } // Mark email as verified db::users::verify_user_email(&state.db, user_id).await?; // Auto-attach any guest purchases made with this email before signup match db::transactions::attach_guest_purchases_by_email(&state.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 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(state): State, headers: axum::http::header::HeaderMap, session: Session, Query(query): Query, ) -> 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 login_token = match db::auth::consume_login_token(&state.db, &token_hash).await? { Some(t) => t, None => { return Ok(error_page( "This login link has expired or has already been used.", )) } }; // Get the user let user = match db::users::get_user_by_id(&state.db, login_token.user_id).await? { Some(u) => u, None => return Ok(error_page("User not found")), }; // Reset failed login attempts and unlock account db::auth::reset_failed_login(&state.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_enabled", user.login_notification_enabled) .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( &state.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(); let notify_enabled = user.login_notification_enabled; // Create session let session_user = SessionUser::from_db_user(user, &state.db, state.config.admin_user_id).await; login_user(&session, session_user).await?; track_session(&session, &state.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) if notify_enabled { let session_count = db::sessions::count_user_sessions(&state.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( &state.config.host_url, user_id, email::UnsubscribeAction::Login, &user_id.to_string(), &state.config.signing_secret, ); spawn_email!(state, "login notification", |email| { email.send_new_login_notification( ¬ify_email, notify_name.as_deref(), user_agent.as_deref(), ip.as_deref(), Some(&unsub_url), ) }); } } // Redirect to dashboard Ok(Redirect::to("/dashboard").into_response()) }