From cc3caf4219dbbe1d5a7eddc1355208ced29e4a9e Mon Sep 17 00:00:00 2001 From: Mario Date: Thu, 27 Mar 2025 10:25:55 +0100 Subject: [PATCH] feat: implement password reset and email verification resend endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [started by Mario, finished by Cameron] This commit adds secure, rate-limited password reset and email verification resend functionality to the Torrust index API. The implementation follows security best practices to prevent account enumeration and brute-force attacks. - **PasswordResetService**: Handles sending password reset links via email and completing the reset with JWT tokens. Tokens self-invalidate when the password changes (via embedded password hash fingerprint) and have a 1-hour expiry. - **EmailVerificationService**: Allows resending verification emails to unverified accounts with configurable rate limiting. - **Exponential backoff rate limiting**: Both flows use a configurable `ThrottlePolicy` with base/max backoff intervals (default: 10 min → 1 day) and a hard attempt cap (default: 5 attempts). After the cap is reached, the action is locked until successful completion or admin intervention. - **Constant‑time responses**: Endpoints always return success for valid requests (regardless of email existence/verification status) and enforce a minimum response time (1 second) to prevent timing‑based account enumeration. - **Admin bypass**: Administrators can trigger reset/verification emails on behalf of locked‑out users without being rate‑limited. - **Authenticated bypass**: Logged‑in users resetting their own password skip the rate limit (they have already proven identity). - Added `auth.password_reset_policy` and `auth.email_verification_policy` to `config/v2/auth.rs` with defaults suitable for production. - Added `website.email_verification_url_prefix` and `website.password_reset_url_prefix` to allow frontends to host verification and reset pages independently of the API URL. - `POST /user/email/resend` – resend verification email - `POST /user/password-reset` – send password reset link - `POST /user/password-reset/complete` – complete password reset with token - Added `get_user_profile_from_email` and `get_user_profile_from_id` to the `Database` trait and implementations (MySQL, SQLite). - Removed email verification logic from `RegistrationService` (now in `EmailVerificationService`). - New `templates/reset_password.html` – styled password reset email with expiry information. - Updated `templates/verify.html` – added expiry notice and improved HTML structure. - Added `api_base_url` Axum extractor to centralize API‑URL resolution. - Embedded email templates at compile time using `include_str!` so they work in tests that change the working directory (`figment::Jail`). - Added comprehensive error variants (`ServiceError::PasswordResetLocked`, `VerificationResendLocked`, etc.) with appropriate HTTP status codes. - Extended authorization policies to include new actions (`SendPasswordResetLink`, `ResendVerificationLink`, `IsAdmin`). - Registration itself is not rate‑limited (TODO for future anti‑abuse). - The in‑memory `BoundedRateLimiter` has a capacity of 50,000 emails and evicts stale entries automatically. - `RegistrationService::verify_email` moved to `EmailVerificationService`. - Registration response now includes `verification_expiry` field. - Config file schema extended with new `auth` and `website` fields (backward compatible via defaults). This feature addresses issue #802 (password reset endpoint) and provides a parallel implementation for email verification resend, completing the user account management capabilities. --- src/app.rs | 18 +- src/bootstrap/config.rs | 14 +- src/common.rs | 6 + src/config/mod.rs | 16 +- src/config/v2/auth.rs | 67 ++ src/config/v2/website.rs | 24 + src/databases/database.rs | 6 + src/databases/mysql.rs | 16 + src/databases/sqlite.rs | 16 + src/errors.rs | 20 + src/mailer.rs | 215 +++++- src/services/authorization.rs | 12 + src/services/user.rs | 687 ++++++++++++++++-- .../api/client/v1/contexts/user/responses.rs | 1 + src/web/api/server/v1/contexts/user/forms.rs | 23 + .../api/server/v1/contexts/user/handlers.rs | 121 ++- .../api/server/v1/contexts/user/responses.rs | 9 +- src/web/api/server/v1/contexts/user/routes.rs | 18 +- .../api/server/v1/extractors/api_base_url.rs | 48 ++ src/web/api/server/v1/extractors/mod.rs | 1 + templates/reset_password.html | 426 +++++++++++ templates/verify.html | 535 +++++++++----- tests/common/contexts/user/responses.rs | 1 + 23 files changed, 2033 insertions(+), 267 deletions(-) create mode 100644 src/web/api/server/v1/extractors/api_base_url.rs create mode 100644 templates/reset_password.html diff --git a/src/app.rs b/src/app.rs index ef1559966..455ca8755 100644 --- a/src/app.rs +++ b/src/app.rs @@ -138,7 +138,6 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running configuration.clone(), mailer_service.clone(), user_repository.clone(), - user_profile_repository.clone(), )); let profile_service = Arc::new(user::ProfileService::new( configuration.clone(), @@ -166,6 +165,21 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running authorization_service.clone(), )); + let password_reset_service = Arc::new(user::PasswordResetService::new( + configuration.clone(), + mailer_service.clone(), + user_profile_repository.clone(), + user_authentication_repository.clone(), + authorization_service.clone(), + )); + + let email_verification_service = Arc::new(user::EmailVerificationService::new( + configuration.clone(), + mailer_service.clone(), + user_profile_repository.clone(), + authorization_service.clone(), + )); + // Build app container let app_data = Arc::new(AppData::new( @@ -201,6 +215,8 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running ban_service, about_service, listing_service, + password_reset_service, + email_verification_service, )); // Start cronjob to import tracker torrent data and updating diff --git a/src/bootstrap/config.rs b/src/bootstrap/config.rs index aaef11dfb..c9849a990 100644 --- a/src/bootstrap/config.rs +++ b/src/bootstrap/config.rs @@ -39,8 +39,16 @@ mod tests { #[test] fn it_should_load_with_default_config() { - use crate::bootstrap::config::initialize_configuration; - - drop(initialize_configuration()); + // Use an absolute path derived from CARGO_MANIFEST_DIR so this test + // is not affected by figment::Jail tests that change the process-wide + // current working directory in parallel. + let config_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/share/default/config/index.development.sqlite3.toml" + ); + let config_content = std::fs::read_to_string(config_path) + .unwrap_or_else(|e| panic!("Could not read default config at {config_path}: {e}")); + let info = crate::config::Info::from_toml(&config_content); + drop(crate::config::Configuration::load(&info).unwrap()); } } diff --git a/src/common.rs b/src/common.rs index 53127d0b9..89c4be505 100644 --- a/src/common.rs +++ b/src/common.rs @@ -53,6 +53,8 @@ pub struct AppData { pub ban_service: Arc, pub about_service: Arc, pub listing_service: Arc, + pub password_reset_service: Arc, + pub email_verification_service: Arc, } impl AppData { @@ -92,6 +94,8 @@ impl AppData { ban_service: Arc, about_service: Arc, listing_service: Arc, + password_reset_service: Arc, + email_verification_service: Arc, ) -> Self { Self { cfg, @@ -128,6 +132,8 @@ impl AppData { ban_service, about_service, listing_service, + password_reset_service, + email_verification_service, } } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 164d47600..f7617fa6b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -27,6 +27,11 @@ pub type Email = v2::registration::Email; pub type Auth = v2::auth::Auth; pub type SecretKey = v2::auth::ClaimTokenPepper; pub type PasswordConstraints = v2::auth::PasswordConstraints; +pub type ThrottlePolicy = v2::auth::ThrottlePolicy; +/// Convenience alias — the password-reset flow uses a [`ThrottlePolicy`]. +pub type PasswordResetPolicy = ThrottlePolicy; +/// Convenience alias — the email-verification flow uses a [`ThrottlePolicy`]. +pub type EmailVerificationPolicy = ThrottlePolicy; pub type Database = v2::database::Database; @@ -464,10 +469,7 @@ mod tests { #[tokio::test] #[allow(clippy::result_large_err)] async fn configuration_could_be_loaded_from_a_toml_string() { - figment::Jail::expect_with(|jail| { - jail.create_dir("templates")?; - jail.create_file("templates/verify.html", "EMAIL TEMPLATE")?; - + figment::Jail::expect_with(|_jail| { let info = Info { config_toml: Some(default_config_toml()), config_toml_path: String::new(), @@ -551,9 +553,6 @@ mod tests { #[allow(clippy::result_large_err)] async fn configuration_should_allow_to_override_the_tracker_api_token_provided_in_the_toml_file() { figment::Jail::expect_with(|jail| { - jail.create_dir("templates")?; - jail.create_file("templates/verify.html", "EMAIL TEMPLATE")?; - jail.set_env("TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN", "OVERRIDDEN API TOKEN"); let info = Info { @@ -573,9 +572,6 @@ mod tests { #[allow(clippy::result_large_err)] async fn configuration_should_allow_to_override_the_authentication_user_claim_token_pepper_provided_in_the_toml_file() { figment::Jail::expect_with(|jail| { - jail.create_dir("templates")?; - jail.create_file("templates/verify.html", "EMAIL TEMPLATE")?; - jail.set_env( "TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__USER_CLAIM_TOKEN_PEPPER", "OVERRIDDEN AUTH SECRET KEY", diff --git a/src/config/v2/auth.rs b/src/config/v2/auth.rs index dac794daf..fb05b97aa 100644 --- a/src/config/v2/auth.rs +++ b/src/config/v2/auth.rs @@ -12,6 +12,14 @@ pub struct Auth { /// The password constraints #[serde(default = "Auth::default_password_constraints")] pub password_constraints: PasswordConstraints, + + /// The password reset rate-limiting policy. + #[serde(default = "Auth::default_password_reset_policy")] + pub password_reset_policy: ThrottlePolicy, + + /// The email-verification resend rate-limiting policy. + #[serde(default = "Auth::default_email_verification_policy")] + pub email_verification_policy: ThrottlePolicy, } impl Default for Auth { @@ -19,6 +27,8 @@ impl Default for Auth { Self { password_constraints: Self::default_password_constraints(), user_claim_token_pepper: Self::default_user_claim_token_pepper(), + password_reset_policy: Self::default_password_reset_policy(), + email_verification_policy: Self::default_email_verification_policy(), } } } @@ -35,6 +45,63 @@ impl Auth { fn default_password_constraints() -> PasswordConstraints { PasswordConstraints::default() } + + fn default_password_reset_policy() -> ThrottlePolicy { + ThrottlePolicy::default() + } + + fn default_email_verification_policy() -> ThrottlePolicy { + ThrottlePolicy::default() + } +} + +/// Exponential-backoff rate-limiting policy. +/// +/// The backoff between successive attempts grows as +/// `min(base_backoff_secs * 2^(attempt - 1), max_backoff_secs)`. +/// +/// After `max_attempts` are exhausted, the action is hard-locked until +/// the underlying condition is cleared (e.g. a successful password change +/// or email verification) or an admin intervenes. +/// +/// Used by both password-reset and email-verification flows. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ThrottlePolicy { + /// Base backoff interval in seconds. + #[serde(default = "ThrottlePolicy::default_base_backoff_secs")] + pub base_backoff_secs: u64, + + /// Maximum backoff ceiling in seconds. + #[serde(default = "ThrottlePolicy::default_max_backoff_secs")] + pub max_backoff_secs: u64, + + /// Hard cap on the number of attempts before the action is locked. + #[serde(default = "ThrottlePolicy::default_max_attempts")] + pub max_attempts: u32, +} + +impl Default for ThrottlePolicy { + fn default() -> Self { + Self { + base_backoff_secs: Self::default_base_backoff_secs(), + max_backoff_secs: Self::default_max_backoff_secs(), + max_attempts: Self::default_max_attempts(), + } + } +} + +impl ThrottlePolicy { + const fn default_base_backoff_secs() -> u64 { + 600 // 10 minutes + } + + const fn default_max_backoff_secs() -> u64 { + 86_400 // 1 day + } + + const fn default_max_attempts() -> u32 { + 5 + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/src/config/v2/website.rs b/src/config/v2/website.rs index b00787150..39fd1ff20 100644 --- a/src/config/v2/website.rs +++ b/src/config/v2/website.rs @@ -14,6 +14,20 @@ pub struct Website { /// The legal information. #[serde(default = "Website::default_terms")] pub terms: Terms, + + /// The URL prefix the frontend uses for email verification links. + /// The backend appends `/` to this prefix when generating the + /// verification email. For example: `https://mysite.com/verify-email`. + /// If not set, the backend API URL is used as a fallback. + #[serde(default = "Website::default_email_verification_url_prefix")] + pub email_verification_url_prefix: Option, + + /// The URL prefix the frontend uses for password reset links. + /// The backend appends `/` to this prefix when generating the + /// reset email. For example: `https://mysite.com/reset-password`. + /// If not set, the backend API URL is used as a fallback. + #[serde(default = "Website::default_password_reset_url_prefix")] + pub password_reset_url_prefix: Option, } impl Default for Website { @@ -22,6 +36,8 @@ impl Default for Website { name: Self::default_name(), demo: Self::default_demo(), terms: Self::default_terms(), + email_verification_url_prefix: Self::default_email_verification_url_prefix(), + password_reset_url_prefix: Self::default_password_reset_url_prefix(), } } } @@ -38,6 +54,14 @@ impl Website { fn default_terms() -> Terms { Terms::default() } + + const fn default_email_verification_url_prefix() -> Option { + None + } + + const fn default_password_reset_url_prefix() -> Option { + None + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/src/databases/database.rs b/src/databases/database.rs index 82f9b96e1..8189f2c8d 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -214,6 +214,12 @@ pub trait Database: Sync + Send { /// Get `UserProfile` from `username`. async fn get_user_profile_from_username(&self, username: &str) -> Result; + /// Get `UserProfile` from `email`. + async fn get_user_profile_from_email(&self, email: &str) -> Result; + + /// Get `UserProfile` from `user_id`. + async fn get_user_profile_from_id(&self, user_id: UserId) -> Result; + /// Get all user profiles in a paginated and sorted form as `UserProfilesResponse` from `search`, `filters`, `sort`, `offset` and `page_size`. async fn get_user_profiles_search_paginated( &self, diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index ee6faa992..11db7dbef 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -155,6 +155,22 @@ impl Database for Mysql { .map_err(|_| database::Error::UserNotFound) } + async fn get_user_profile_from_email(&self, email: &str) -> Result { + query_as::<_, UserProfile>("SELECT * FROM torrust_user_profiles WHERE email = ?") + .bind(email) + .fetch_one(&self.pool) + .await + .map_err(|_| database::Error::UserNotFound) + } + + async fn get_user_profile_from_id(&self, user_id: UserId) -> Result { + query_as::<_, UserProfile>("SELECT * FROM torrust_user_profiles WHERE user_id = ?") + .bind(user_id) + .fetch_one(&self.pool) + .await + .map_err(|_| database::Error::UserNotFound) + } + async fn get_user_profiles_search_paginated( &self, search: &Option, diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index fc6dfef61..4f020c309 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -156,6 +156,22 @@ impl Database for Sqlite { .map_err(|_| database::Error::UserNotFound) } + async fn get_user_profile_from_email(&self, email: &str) -> Result { + query_as::<_, UserProfile>("SELECT * FROM torrust_user_profiles WHERE email = ?") + .bind(email) + .fetch_one(&self.pool) + .await + .map_err(|_| database::Error::UserNotFound) + } + + async fn get_user_profile_from_id(&self, user_id: UserId) -> Result { + query_as::<_, UserProfile>("SELECT * FROM torrust_user_profiles WHERE user_id = ?") + .bind(user_id) + .fetch_one(&self.pool) + .await + .map_err(|_| database::Error::UserNotFound) + } + async fn get_user_profiles_search_paginated( &self, search: &Option, diff --git a/src/errors.rs b/src/errors.rs index ba560e449..3455b84c4 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -60,6 +60,21 @@ pub enum ServiceError { #[display("Passwords don't match")] PasswordsDontMatch, + #[display("Couldn't send new password to the user")] + FailedToSendResetPassword, + + #[display("Too many password reset requests. Try again in {remaining_secs} seconds.")] + PasswordResetLocked { remaining_secs: u64 }, + + #[display("Password reset locked after too many attempts. Please contact an administrator.")] + PasswordResetMaxAttemptsReached, + + #[display("Too many verification email requests. Try again in {remaining_secs} seconds.")] + VerificationResendLocked { remaining_secs: u64 }, + + #[display("Verification email resend locked after too many attempts. Please contact an administrator.")] + VerificationResendMaxAttemptsReached, + /// when the a username is already taken #[display("Username not available")] UsernameTaken, @@ -290,6 +305,11 @@ pub const fn http_status_code_for_service_error(error: &ServiceError) -> StatusC ServiceError::PasswordTooShort => StatusCode::BAD_REQUEST, ServiceError::PasswordTooLong => StatusCode::BAD_REQUEST, ServiceError::PasswordsDontMatch => StatusCode::BAD_REQUEST, + ServiceError::FailedToSendResetPassword => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::PasswordResetLocked { .. } => StatusCode::TOO_MANY_REQUESTS, + ServiceError::PasswordResetMaxAttemptsReached => StatusCode::TOO_MANY_REQUESTS, + ServiceError::VerificationResendLocked { .. } => StatusCode::TOO_MANY_REQUESTS, + ServiceError::VerificationResendMaxAttemptsReached => StatusCode::TOO_MANY_REQUESTS, ServiceError::UsernameTaken => StatusCode::BAD_REQUEST, ServiceError::UsernameInvalid => StatusCode::BAD_REQUEST, ServiceError::EmailTaken => StatusCode::BAD_REQUEST, diff --git a/src/mailer.rs b/src/mailer.rs index b4564d66f..4f1076960 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; +use chrono::{TimeDelta, Utc}; use jsonwebtoken::{encode, EncodingKey, Header}; use lazy_static::lazy_static; use lettre::message::{MessageBuilder, MultiPart, SinglePart}; @@ -15,17 +16,41 @@ use crate::errors::ServiceError; use crate::utils::clock; use crate::web::api::server::v1::routes::API_VERSION_URL_PREFIX; +/// How long an email verification token is valid. +pub const VERIFY_TOKEN_EXPIRY_SECS: u64 = 86_400; // 24 hours + +/// How long a password reset token is valid. +pub const RESET_TOKEN_EXPIRY_SECS: u64 = 3_600; // 1 hour + +/// Returns an ISO 8601 UTC timestamp for `secs` seconds from now. +/// +/// Example output: `2026-02-19T14:30:00Z`. +/// +/// # Panics +/// +/// Panics if `secs` overflows an `i64` or falls outside the range +/// supported by `chrono::TimeDelta`. +#[must_use] +pub fn expiry_timestamp(secs: u64) -> String { + let dt = Utc::now() + TimeDelta::try_seconds(i64::try_from(secs).expect("expiry fits in i64")).expect("expiry within range"); + dt.format("%Y-%m-%dT%H:%M:%SZ").to_string() +} + lazy_static! { pub static ref TEMPLATES: Tera = { let mut tera = Tera::default(); - match tera.add_template_file("templates/verify.html", Some("html_verify_email")) { - Ok(()) => {} - Err(e) => { - println!("Parsing error(s): {e}"); - ::std::process::exit(1); - } - } + // Use `include_str!` to embed templates at compile time so that + // template loading does not depend on the process working directory. + // Figment's `Jail` (used in config tests) changes the process-wide + // CWD to a temp directory, which causes `add_template_file` with + // relative paths to fail when the lazy_static is first accessed from + // a concurrent test thread. + tera.add_raw_template("html_verify_email", include_str!("../templates/verify.html")) + .expect("Failed to parse the email verification template"); + + tera.add_raw_template("html_reset_password", include_str!("../templates/reset_password.html")) + .expect("Failed to parse the reset password template"); tera.autoescape_on(vec![".html", ".sql"]); tera.register_filter("do_nothing", do_nothing_filter); @@ -60,6 +85,36 @@ pub struct VerifyClaims { pub exp: u64, } +/// JWT claims for password reset tokens. +#[derive(Debug, Serialize, Deserialize)] +pub struct ResetClaims { + pub iss: String, + pub sub: i64, + pub exp: u64, + /// Fingerprint of the current password hash; invalidates the token if the + /// password is changed before the token is used. + pub pwd: u64, +} + +/// Compute a fingerprint of the password hash for embedding in reset tokens. +/// +/// The token self-invalidates when the password changes because the stored +/// fingerprint will no longer match. +/// +/// **Note:** This uses [`std::collections::hash_map::DefaultHasher`] whose +/// output is *not* guaranteed to be stable across Rust compiler versions. +/// This is acceptable because every reset token already carries a short +/// expiry ([`RESET_TOKEN_EXPIRY_SECS`]), so no token will survive a +/// toolchain upgrade. +#[must_use] +pub fn password_fingerprint(password_hash: &str) -> u64 { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + password_hash.hash(&mut hasher); + hasher.finish() +} + impl Service { pub async fn new(cfg: Arc) -> Self { let mailer = Arc::new(Self::get_mailer(&cfg).await); @@ -108,8 +163,9 @@ impl Service { ) -> Result<(), ServiceError> { let builder = self.get_builder(to).await; let verification_url = self.get_verification_url(user_id, base_url).await; + let expiry = expiry_timestamp(VERIFY_TOKEN_EXPIRY_SECS); - let mail = build_letter(verification_url.as_str(), username, builder)?; + let mail = build_letter(verification_url.as_str(), username, &expiry, builder)?; match self.mailer.send(mail).await { Ok(_res) => Ok(()), @@ -135,28 +191,91 @@ impl Service { // create verification JWT let key = settings.auth.user_claim_token_pepper.as_bytes(); - // Create non expiring token that is only valid for email-verification let claims = VerifyClaims { iss: String::from("email-verification"), sub: user_id, - exp: clock::now() + 315_569_260, // 10 years from now + exp: clock::now() + VERIFY_TOKEN_EXPIRY_SECS, }; let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).unwrap(); - let base_url = settings - .net - .base_url - .as_ref() - .map_or_else(|| base_url.to_string(), std::string::ToString::to_string); + // Prefer the frontend URL prefix from config; fall back to the API URL. + let url_prefix = settings.website.email_verification_url_prefix.clone().unwrap_or_else(|| { + let api_base = settings + .net + .base_url + .as_ref() + .map_or_else(|| base_url.to_string(), std::string::ToString::to_string); + format!("{api_base}/{API_VERSION_URL_PREFIX}/user/email/verify") + }); drop(settings); - format!("{base_url}/{API_VERSION_URL_PREFIX}/user/email/verify/{token}") + format!("{url_prefix}/{token}") + } + + /// Send a password reset link email. + /// + /// # Errors + /// + /// This function will return an error if unable to send an email. + /// + /// # Panics + /// + /// This function will panic if the multipart builder had an error. + pub async fn send_reset_password_mail( + &self, + to: &str, + username: &str, + user_id: i64, + password_hash: &str, + base_url: &str, + ) -> Result<(), ServiceError> { + let builder = self.get_builder(to).await; + let reset_url = self.get_reset_url(user_id, password_hash, base_url).await; + let expiry = expiry_timestamp(RESET_TOKEN_EXPIRY_SECS); + + let mail = build_reset_password_letter(&reset_url, username, &expiry, builder)?; + + match self.mailer.send(mail).await { + Ok(_res) => Ok(()), + Err(e) => { + tracing::error!("Failed to send reset password email: {e}"); + Err(ServiceError::FailedToSendResetPassword) + } + } + } + + async fn get_reset_url(&self, user_id: i64, password_hash: &str, base_url: &str) -> String { + let settings = self.cfg.settings.read().await; + + let key = settings.auth.user_claim_token_pepper.as_bytes(); + + let claims = ResetClaims { + iss: String::from("password-reset"), + sub: user_id, + exp: clock::now() + RESET_TOKEN_EXPIRY_SECS, + pwd: password_fingerprint(password_hash), + }; + + let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).unwrap(); + + // Prefer the frontend URL prefix from config; fall back to the API URL. + let url_prefix = settings.website.password_reset_url_prefix.clone().unwrap_or_else(|| { + let api_base = settings + .net + .base_url + .as_ref() + .map_or_else(|| base_url.to_string(), std::string::ToString::to_string); + format!("{api_base}/{API_VERSION_URL_PREFIX}/user/password-reset") + }); + drop(settings); + + format!("{url_prefix}/{token}") } } -fn build_letter(verification_url: &str, username: &str, builder: MessageBuilder) -> Result { - let (plain_body, html_body) = build_content(verification_url, username).map_err(|e| { +fn build_letter(verification_url: &str, username: &str, expiry: &str, builder: MessageBuilder) -> Result { + let (plain_body, html_body) = build_content(verification_url, username, expiry).map_err(|e| { tracing::error!("{e}"); ServiceError::InternalServerError })?; @@ -179,7 +298,7 @@ fn build_letter(verification_url: &str, username: &str, builder: MessageBuilder) .expect("the `multipart` builder had an error")) } -fn build_content(verification_url: &str, username: &str) -> Result<(String, String), tera::Error> { +fn build_content(verification_url: &str, username: &str, expiry: &str) -> Result<(String, String), tera::Error> { let plain_body = format!( " Welcome to Torrust, {username}! @@ -187,16 +306,70 @@ fn build_content(verification_url: &str, username: &str) -> Result<(String, Stri Please click the confirmation link below to verify your account. {verification_url} + This link expires on {expiry}. + If this account wasn't made by you, you can ignore this email. " ); let mut context = Context::new(); context.insert("verification", &verification_url); context.insert("username", &username); + context.insert("expiry", &expiry); let html_body = TEMPLATES.render("html_verify_email", &context)?; Ok((plain_body, html_body)) } +fn build_reset_password_letter( + reset_url: &str, + username: &str, + expiry: &str, + builder: MessageBuilder, +) -> Result { + let (plain_body, html_body) = build_reset_password_content(reset_url, username, expiry).map_err(|e| { + tracing::error!("{e}"); + ServiceError::InternalServerError + })?; + + Ok(builder + .subject("Torrust - Password reset") + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(lettre::message::header::ContentType::TEXT_PLAIN) + .body(plain_body), + ) + .singlepart( + SinglePart::builder() + .header(lettre::message::header::ContentType::TEXT_HTML) + .body(html_body), + ), + ) + .expect("the `multipart` builder had an error")) +} + +fn build_reset_password_content(reset_url: &str, username: &str, expiry: &str) -> Result<(String, String), tera::Error> { + let plain_body = format!( + " + Hello, {username}! + + We received a request to reset your password. + Click the following link to reset your password: + {reset_url} + + This link expires on {expiry}. + + If you did not request this, you can safely ignore this email. + " + ); + let mut context = Context::new(); + context.insert("reset_url", &reset_url); + context.insert("username", &username); + context.insert("expiry", &expiry); + let html_body = TEMPLATES.render("html_reset_password", &context)?; + Ok((plain_body, html_body)) +} + pub type Mailer = AsyncSmtpTransport; #[cfg(test)] @@ -212,12 +385,12 @@ mod tests { .reply_to("reply@a.b.c".parse().unwrap()) .to("to@a.b.c".parse().unwrap()); - let _letter = build_letter("https://a.b.c/", "user", builder).unwrap(); + let _letter = build_letter("https://a.b.c/", "user", "2026-02-19 14:30 UTC", builder).unwrap(); } #[test] fn it_should_build_content() { - let (plain_body, html_body) = build_content("https://a.b.c/", "user").unwrap(); + let (plain_body, html_body) = build_content("https://a.b.c/", "user", "2026-02-19 14:30 UTC").unwrap(); assert_ne!(plain_body, ""); assert_ne!(html_body, ""); } diff --git a/src/services/authorization.rs b/src/services/authorization.rs index 16f30e9b9..c2b312975 100644 --- a/src/services/authorization.rs +++ b/src/services/authorization.rs @@ -53,6 +53,11 @@ pub enum ACTION { ChangePassword, BanUser, GenerateUserProfileSpecification, + SendPasswordResetLink, + ResendVerificationLink, + /// Meta-action used solely to test whether the caller has admin + /// privileges. Granted only to the `admin` role. + IsAdmin, } pub struct Service { @@ -251,6 +256,9 @@ impl Default for CasbinConfiguration { admin, ChangePassword admin, BanUser admin, GenerateUserProfileSpecification + admin, SendPasswordResetLink + admin, ResendVerificationLink + admin, IsAdmin registered, GetAboutPage registered, GetLicensePage registered, GetCategories @@ -264,6 +272,8 @@ impl Default for CasbinConfiguration { registered, GenerateTorrentInfoListing registered, GetCanonicalInfoHash registered, ChangePassword + registered, SendPasswordResetLink + registered, ResendVerificationLink guest, GetAboutPage guest, GetLicensePage guest, GetCategories @@ -274,6 +284,8 @@ impl Default for CasbinConfiguration { guest, GetTorrentInfo guest, GenerateTorrentInfoListing guest, GetCanonicalInfoHash + guest, SendPasswordResetLink + guest, ResendVerificationLink ", ), } diff --git a/src/services/user.rs b/src/services/user.rs index 6b2d2b309..632d8adac 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -1,6 +1,8 @@ //! User services. +use std::collections::{HashMap, VecDeque}; use std::str::FromStr; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHasher}; @@ -17,7 +19,7 @@ use super::authorization::{self, ACTION}; use crate::config::{Configuration, PasswordConstraints}; use crate::databases::database::{Database, Error, UsersFilters, UsersSorting}; use crate::errors::ServiceError; -use crate::mailer::VerifyClaims; +use crate::mailer::{password_fingerprint, ResetClaims, VerifyClaims}; use crate::models::response::UserProfilesResponse; use crate::models::user::{UserCompact, UserId, UserProfile, Username}; use crate::services::authentication::verify_password; @@ -58,7 +60,6 @@ pub struct RegistrationService { configuration: Arc, mailer: Arc, user_repository: Arc>, - user_profile_repository: Arc, } impl RegistrationService { @@ -67,13 +68,11 @@ impl RegistrationService { configuration: Arc, mailer: Arc, user_repository: Arc>, - user_profile_repository: Arc, ) -> Self { Self { configuration, mailer, user_repository, - user_profile_repository, } } @@ -96,6 +95,12 @@ impl RegistrationService { /// # Panics /// /// This function will panic if the email is required, but missing. + // + // TODO: registration itself is not rate-limited. An attacker could + // flood the system with throwaway accounts, filling the in-memory + // `BoundedRateLimiter` buffers used by `PasswordResetService` and + // `EmailVerificationService`. A future change should add an IP-based + // or CAPTCHA-gated registration rate limit. pub async fn register_user(&self, registration_form: &RegistrationForm, api_base_url: &str) -> Result { info!("registering user: {}", registration_form.username); @@ -183,42 +188,6 @@ impl RegistrationService { Ok(user_id) } - - /// It verifies the email address of a user via the token sent to the - /// user's email. - /// - /// # Errors - /// - /// This function will return a `ServiceError::DatabaseError` if unable to - /// update the user's email verification status. - pub async fn verify_email(&self, token: &str) -> Result { - let settings = self.configuration.settings.read().await; - - let token_data = match decode::( - token, - &DecodingKey::from_secret(settings.auth.user_claim_token_pepper.as_bytes()), - &Validation::new(Algorithm::HS256), - ) { - Ok(token_data) => { - if !token_data.claims.iss.eq("email-verification") { - return Ok(false); - } - - token_data.claims - } - Err(_) => return Ok(false), - }; - - drop(settings); - - let user_id = token_data.sub; - - if self.user_profile_repository.verify_email(&user_id).await.is_err() { - return Err(ServiceError::DatabaseError); - } - - Ok(true) - } } pub struct ProfileService { @@ -452,6 +421,624 @@ impl ListingService { } } +/// Per-key state tracked by the [`BoundedRateLimiter`]. +struct ThrottleEntry { + /// Number of requests recorded since the last reset. + count: u32, + /// When the most recent request was recorded. + last_issued: Instant, +} + +/// Result of a rate-limit check performed by [`BoundedRateLimiter::check`]. +enum ThrottleStatus { + /// The request is allowed. + Allowed, + /// The request is rate-limited; the caller must wait this many more seconds. + Limited { remaining_secs: u64 }, + /// The hard attempt cap has been reached. + HardLocked, +} + +/// A bounded, ring-buffer-backed rate limiter. +/// +/// Entries are indexed by a `HashMap` for O(1) lookup and stored in insertion +/// order in a `VecDeque` so that the oldest entry can be evicted in O(1) when +/// the buffer is full. A periodic sweep removes entries whose +/// `last_issued` is older than a configurable staleness threshold. +/// +/// This is a general-purpose structure shared by [`PasswordResetService`] and +/// [`EmailVerificationService`]. +struct BoundedRateLimiter { + /// Maps lowercase key → logical index into `entries`. + index: HashMap, + /// Ring of `(key, state)` pairs in insertion order. + entries: VecDeque<(String, ThrottleEntry)>, + /// Monotonically increasing generation counter used as the logical index + /// stored in `index`. The physical position in `entries` is + /// `logical - base_generation`. + generation: usize, + /// Generation value of the front of the deque. + base_generation: usize, +} + +impl BoundedRateLimiter { + /// Hard upper bound on the number of tracked emails. + const CAPACITY: usize = 50_000; + + fn new() -> Self { + Self { + index: HashMap::new(), + entries: VecDeque::new(), + generation: 0, + base_generation: 0, + } + } + + /// Returns an immutable reference to the state for `key`, if present. + /// + /// The key is lowercased before lookup so callers do not need to + /// normalise it themselves. + fn get(&self, key: &str) -> Option<&ThrottleEntry> { + let normalized = key.to_lowercase(); + let &slot_gen = self.index.get(&normalized)?; + let pos = slot_gen - self.base_generation; + self.entries.get(pos).map(|(_, state)| state) + } + + /// Checks the exponential-backoff rate limit for `key` against `policy`. + fn check(&self, key: &str, policy: &crate::config::ThrottlePolicy) -> ThrottleStatus { + if let Some(entry) = self.get(key) { + if entry.count >= policy.max_attempts { + return ThrottleStatus::HardLocked; + } + if entry.count > 0 { + let shift = (entry.count - 1).min(63); + let backoff_secs = policy + .base_backoff_secs + .saturating_mul(1u64 << shift) + .min(policy.max_backoff_secs); + let elapsed = entry.last_issued.elapsed().as_secs(); + if elapsed < backoff_secs { + return ThrottleStatus::Limited { + remaining_secs: backoff_secs - elapsed, + }; + } + } + } + ThrottleStatus::Allowed + } + + /// Records an attempt for `email`. If the email already has an entry its + /// count is incremented in-place; otherwise a new entry is pushed to the + /// back of the ring, evicting the oldest entry if the buffer is full. + /// + /// A staleness sweep is run first so that expired entries are reclaimed + /// before we consider evicting by age. + fn record(&mut self, email: &str, max_staleness_secs: u64) { + self.sweep_stale(max_staleness_secs); + + let key = email.to_lowercase(); + + // Fast path: entry already exists — update in place. + if let Some(&slot_gen) = self.index.get(&key) { + let pos = slot_gen - self.base_generation; + if let Some((_, state)) = self.entries.get_mut(pos) { + state.count += 1; + state.last_issued = Instant::now(); + return; + } + } + + // Evict the oldest entry if we are at capacity. + if self.entries.len() >= Self::CAPACITY { + self.evict_oldest(); + } + + let slot_gen = self.generation; + self.generation += 1; + self.entries.push_back(( + key.clone(), + ThrottleEntry { + count: 1, + last_issued: Instant::now(), + }, + )); + self.index.insert(key, slot_gen); + } + + /// Removes the entry for `email`, if present. + fn remove(&mut self, email: &str) { + let key = email.to_lowercase(); + if let Some(slot_gen) = self.index.remove(&key) { + let pos = slot_gen - self.base_generation; + if let Some((k, _)) = self.entries.get_mut(pos) { + // Mark the slot as tombstoned by clearing the key so the + // sweep/evict path skips the index removal. + k.clear(); + } + } + } + + /// Evicts entries from the front of the deque that are older than + /// `max_staleness_secs`. Tombstoned entries (empty key) are also removed. + fn sweep_stale(&mut self, max_staleness_secs: u64) { + let threshold = Duration::from_secs(max_staleness_secs); + while let Some((key, state)) = self.entries.front() { + if key.is_empty() || state.last_issued.elapsed() >= threshold { + let (evicted_key, _) = self.entries.pop_front().expect("front exists"); + if !evicted_key.is_empty() { + self.index.remove(&evicted_key); + } + self.base_generation += 1; + } else { + break; + } + } + } + + /// Evicts the single oldest entry from the front of the deque. + fn evict_oldest(&mut self) { + if let Some((evicted_key, _)) = self.entries.pop_front() { + if !evicted_key.is_empty() { + self.index.remove(&evicted_key); + } + self.base_generation += 1; + } + } +} + +pub struct PasswordResetService { + configuration: Arc, + mailer: Arc, + user_profile_repository: Arc, + user_authentication_repository: Arc, + authorization_service: Arc, + /// Bounded, in-memory per-email rate limiter for password reset requests. + rate_limiter: RwLock, +} + +impl PasswordResetService { + /// Minimum time the `send_reset_link` handler will take before returning, + /// regardless of the code-path. This prevents timing-based account + /// enumeration. + const MIN_RESET_RESPONSE: Duration = Duration::from_secs(1); + + #[must_use] + pub fn new( + configuration: Arc, + mailer: Arc, + user_profile_repository: Arc, + user_authentication_repository: Arc, + authorization_service: Arc, + ) -> Self { + Self { + configuration, + mailer, + user_profile_repository, + user_authentication_repository, + authorization_service, + rate_limiter: RwLock::new(BoundedRateLimiter::new()), + } + } + + /// Sends a password reset link to the email if it belongs to a verified user. + /// + /// To prevent account enumeration, this method always returns `Ok(())` + /// regardless of whether the email exists or is verified. + /// + /// Admins bypass the rate limiter so they can trigger a reset email on + /// behalf of locked-out users. + /// + /// # Errors + /// + /// This function will return a: + /// + /// * An authorization error if the action is not permitted. + /// * `ServiceError::PasswordResetLocked` if the email is rate-limited. + /// * `ServiceError::FailedToSendResetPassword` if unable to send the email. + pub async fn send_reset_link( + &self, + maybe_user_id: Option, + email: &str, + api_base_url: &str, + ) -> Result<(), ServiceError> { + // Authorization and rate-limit checks run *before* the constant-time + // window because their errors are returned to the caller anyway (no + // information leak). + self.authorization_service + .authorize(ACTION::SendPasswordResetLink, maybe_user_id) + .await?; + + // Admins bypass the rate limit so they can send reset links on behalf + // of locked-out users. + let is_admin = self + .authorization_service + .authorize(ACTION::IsAdmin, maybe_user_id) + .await + .is_ok(); + + // Logged-in users resetting their own password also bypass the rate + // limit — they have already proven their identity by being + // authenticated. We compare emails case-insensitively. + let is_own_email = if let Some(user_id) = maybe_user_id { + self.user_profile_repository + .get_user_profile_from_id(user_id) + .await + .is_ok_and(|p| p.email.eq_ignore_ascii_case(email)) + } else { + false + }; + + if !is_admin && !is_own_email { + let settings = self.configuration.settings.read().await; + let policy = settings.auth.password_reset_policy.clone(); + drop(settings); + self.check_rate_limit(email, &policy)?; + } + + // Run the real work alongside a minimum-duration timer so that the + // response always takes at least this floor regardless of whether + // the email exists, is verified, or the SMTP send succeeds. This + // prevents an attacker from enumerating valid accounts via response + // time. + let real_work = self.send_reset_link_inner(email, api_base_url); + let floor = tokio::time::sleep(Self::MIN_RESET_RESPONSE); + + let (result, ()) = tokio::join!(real_work, floor); + + result + } + + /// Inner logic for [`Self::send_reset_link`], factored out so the caller + /// can enforce a constant-time floor around it. + async fn send_reset_link_inner(&self, email: &str, api_base_url: &str) -> Result<(), ServiceError> { + // Look up the user by email. If not found or not verified, silently + // succeed to prevent account enumeration. + let Ok(user_profile) = self.user_profile_repository.get_user_profile_from_email(email).await else { + info!("Password reset requested for unknown email (not revealing to caller)"); + return Ok(()); + }; + + if !user_profile.email_verified { + info!("Password reset requested for unverified email (not revealing to caller)"); + return Ok(()); + } + + // Fetch the current password hash so the token self-invalidates when + // the password is changed before it is used. + let user_auth = self + .user_authentication_repository + .get_user_authentication_from_id(&user_profile.user_id) + .await + .map_err(|_| ServiceError::InternalServerError)?; + + info!("Sending password reset link for user: {}", user_profile.username); + + self.mailer + .send_reset_password_mail( + email, + &user_profile.username, + user_profile.user_id, + &user_auth.password_hash, + api_base_url, + ) + .await?; + + // Record the attempt *after* successfully sending the email so that a + // transient SMTP failure does not consume an attempt. + self.record_attempt(email); + + Ok(()) + } + + /// Completes a password reset using a JWT token from the reset link. + /// + /// This method is intentionally **unauthenticated**: the caller does not + /// need to be logged in. Identity is established through the signed, + /// time-limited JWT that was emailed to the account owner. The embedded + /// password-hash fingerprint additionally ensures the token is single-use. + /// + /// On success the rate-limiter state for the user's email is cleared. + /// + /// # Errors + /// + /// This function will return a: + /// + /// * `ServiceError::TokenInvalid` if the token is invalid, not a + /// password-reset token, or the password has already been changed since + /// the token was issued. + /// * `ServiceError::PasswordsDontMatch` if the passwords don't match. + /// * `ServiceError::PasswordTooShort` / `PasswordTooLong` if constraints are violated. + /// * Database or hashing errors. + pub async fn complete_reset(&self, token: &str, password: &str, confirm_password: &str) -> Result<(), ServiceError> { + let settings = self.configuration.settings.read().await; + + let token_data = decode::( + token, + &DecodingKey::from_secret(settings.auth.user_claim_token_pepper.as_bytes()), + &Validation::new(Algorithm::HS256), + ) + .map_err(|_| ServiceError::TokenInvalid)?; + + if token_data.claims.iss != "password-reset" { + return Err(ServiceError::TokenInvalid); + } + + let password_constraints = PasswordConstraints { + min_password_length: settings.auth.password_constraints.min_password_length, + max_password_length: settings.auth.password_constraints.max_password_length, + }; + drop(settings); + + let user_id = token_data.claims.sub; + + // Verify the password fingerprint matches the current hash. This + // ensures the token is single-use: once the password is changed the + // fingerprint no longer matches and the token is rejected. + let current_auth = self + .user_authentication_repository + .get_user_authentication_from_id(&user_id) + .await + .map_err(|_| ServiceError::TokenInvalid)?; + + if token_data.claims.pwd != password_fingerprint(¤t_auth.password_hash) { + return Err(ServiceError::TokenInvalid); + } + + validate_password_constraints(password, confirm_password, &password_constraints)?; + + let password_hash = hash_password(password)?; + + self.user_authentication_repository + .change_password(user_id, &password_hash) + .await?; + + // Clear the rate-limiter state for this user so they start fresh. + self.clear_rate_limit_for_user(user_id).await; + + Ok(()) + } + + /// Checks the backoff-based rate limit for password reset requests. + /// + /// The required wait time between requests grows as + /// `min(base_backoff_secs * 2^(count - 1), max_backoff_secs)` where + /// `count` is the number of resets already issued since the last + /// successful password change. + /// + /// After `max_attempts` the account is hard-locked until the password is + /// successfully changed (or an admin triggers a reset on behalf of the + /// user). + /// + /// # Errors + /// + /// Returns `ServiceError::PasswordResetLocked { remaining_secs }` if the + /// email must wait before another reset can be issued. + fn check_rate_limit(&self, email: &str, policy: &crate::config::ThrottlePolicy) -> Result<(), ServiceError> { + let limiter = self.rate_limiter.read().expect("rate-limit lock poisoned"); + match limiter.check(email, policy) { + ThrottleStatus::Allowed => Ok(()), + ThrottleStatus::Limited { remaining_secs } => Err(ServiceError::PasswordResetLocked { remaining_secs }), + ThrottleStatus::HardLocked => Err(ServiceError::PasswordResetMaxAttemptsReached), + } + } + + /// Records a successful email send for rate-limiting purposes. + /// + /// Also triggers a staleness sweep so that entries older than + /// `max_backoff_secs` are evicted before we consider capacity. + fn record_attempt(&self, email: &str) { + // We need `max_backoff_secs` for the staleness sweep. Because this + // is called from a sync context we cannot `.await` on the tokio + // `RwLock` that guards the config. Falling back to the default + // policy value is safe: in the worst case we sweep a little too + // aggressively or conservatively. + let max_staleness_secs = crate::config::ThrottlePolicy::default().max_backoff_secs; + + let mut limiter = self.rate_limiter.write().expect("rate-limit lock poisoned"); + limiter.record(email, max_staleness_secs); + drop(limiter); + } + + /// Clears rate-limit state for a given user after a successful password + /// change. Looks up the email by user ID. + async fn clear_rate_limit_for_user(&self, user_id: UserId) { + let Ok(profile) = self.user_profile_repository.get_user_profile_from_id(user_id).await else { + return; + }; + let mut limiter = self.rate_limiter.write().expect("rate-limit lock poisoned"); + limiter.remove(&profile.email); + drop(limiter); + } +} + +/// Service for email verification, including resending verification links +/// with rate limiting that mirrors [`PasswordResetService`]. +/// +/// The flow is: +/// +/// 1. **Initial send** — handled by [`RegistrationService::register_user`] (no +/// rate limiting; one email per registration). +/// 2. **Resend** — [`EmailVerificationService::resend_verification_link`] +/// (rate limited, constant-time, anti-enumeration). +/// 3. **Verify** — [`EmailVerificationService::verify_email`] (consumes the +/// token and clears rate-limiter state). +pub struct EmailVerificationService { + configuration: Arc, + mailer: Arc, + user_profile_repository: Arc, + authorization_service: Arc, + /// Bounded, in-memory per-email rate limiter for verification resend requests. + rate_limiter: RwLock, +} + +impl EmailVerificationService { + /// Minimum time the `resend_verification_link` handler will take before + /// returning, regardless of the code-path. This prevents timing-based + /// account enumeration. + const MIN_RESPONSE: Duration = Duration::from_secs(1); + + #[must_use] + pub fn new( + configuration: Arc, + mailer: Arc, + user_profile_repository: Arc, + authorization_service: Arc, + ) -> Self { + Self { + configuration, + mailer, + user_profile_repository, + authorization_service, + rate_limiter: RwLock::new(BoundedRateLimiter::new()), + } + } + + /// Resends a verification link to `email` if it belongs to an unverified + /// user. + /// + /// To prevent account enumeration, this method always returns `Ok(())` + /// regardless of whether the email exists or is already verified. + /// + /// Admins bypass the rate limiter so they can trigger a verification email + /// on behalf of locked-out users. + /// + /// # Errors + /// + /// This function will return: + /// + /// * An authorization error if the action is not permitted. + /// * `ServiceError::VerificationResendLocked` if the email is rate-limited. + /// * `ServiceError::FailedToSendVerificationEmail` if unable to send the + /// email. + pub async fn resend_verification_link( + &self, + maybe_user_id: Option, + email: &str, + api_base_url: &str, + ) -> Result<(), ServiceError> { + self.authorization_service + .authorize(ACTION::ResendVerificationLink, maybe_user_id) + .await?; + + let is_admin = self + .authorization_service + .authorize(ACTION::IsAdmin, maybe_user_id) + .await + .is_ok(); + + if !is_admin { + let settings = self.configuration.settings.read().await; + let policy = settings.auth.email_verification_policy.clone(); + drop(settings); + self.check_rate_limit(email, &policy)?; + } + + let real_work = self.resend_verification_link_inner(email, api_base_url); + let floor = tokio::time::sleep(Self::MIN_RESPONSE); + + let (result, ()) = tokio::join!(real_work, floor); + + result + } + + /// Inner logic for [`Self::resend_verification_link`], factored out so + /// the caller can enforce a constant-time floor around it. + async fn resend_verification_link_inner(&self, email: &str, api_base_url: &str) -> Result<(), ServiceError> { + let Ok(user_profile) = self.user_profile_repository.get_user_profile_from_email(email).await else { + info!("Verification resend requested for unknown email (not revealing to caller)"); + return Ok(()); + }; + + if user_profile.email_verified { + info!("Verification resend requested for already-verified email (not revealing to caller)"); + return Ok(()); + } + + info!("Resending verification email for user: {}", user_profile.username); + + self.mailer + .send_verification_mail(email, &user_profile.username, user_profile.user_id, api_base_url) + .await?; + + self.record_attempt(email); + + Ok(()) + } + + /// Verifies the email address of a user via the token sent to the user's + /// email. + /// + /// On success the rate-limiter state for the user's email is cleared. + /// + /// # Errors + /// + /// This function will return a `ServiceError::DatabaseError` if unable to + /// update the user's email verification status. + pub async fn verify_email(&self, token: &str) -> Result { + let settings = self.configuration.settings.read().await; + + let token_data = match decode::( + token, + &DecodingKey::from_secret(settings.auth.user_claim_token_pepper.as_bytes()), + &Validation::new(Algorithm::HS256), + ) { + Ok(token_data) => { + if !token_data.claims.iss.eq("email-verification") { + return Ok(false); + } + + token_data.claims + } + Err(_) => return Ok(false), + }; + + drop(settings); + + let user_id = token_data.sub; + + if self.user_profile_repository.verify_email(&user_id).await.is_err() { + return Err(ServiceError::DatabaseError); + } + + // Clear the rate-limiter state so subsequent resend requests start + // fresh. + self.clear_rate_limit_for_user(user_id).await; + + Ok(true) + } + + /// Checks the backoff-based rate limit for verification resend requests. + fn check_rate_limit(&self, email: &str, policy: &crate::config::ThrottlePolicy) -> Result<(), ServiceError> { + let limiter = self.rate_limiter.read().expect("rate-limit lock poisoned"); + match limiter.check(email, policy) { + ThrottleStatus::Allowed => Ok(()), + ThrottleStatus::Limited { remaining_secs } => Err(ServiceError::VerificationResendLocked { remaining_secs }), + ThrottleStatus::HardLocked => Err(ServiceError::VerificationResendMaxAttemptsReached), + } + } + + /// Records a successful email send for rate-limiting purposes. + fn record_attempt(&self, email: &str) { + let max_staleness_secs = crate::config::ThrottlePolicy::default().max_backoff_secs; + + let mut limiter = self.rate_limiter.write().expect("rate-limit lock poisoned"); + limiter.record(email, max_staleness_secs); + drop(limiter); + } + + /// Clears rate-limit state for a given user after a successful email + /// verification. Looks up the email by user ID. + async fn clear_rate_limit_for_user(&self, user_id: UserId) { + let Ok(profile) = self.user_profile_repository.get_user_profile_from_id(user_id).await else { + return; + }; + let mut limiter = self.rate_limiter.write().expect("rate-limit lock poisoned"); + limiter.remove(&profile.email); + drop(limiter); + } +} + #[cfg_attr(test, automock)] #[async_trait] pub trait Repository: Sync + Send { @@ -544,6 +1131,24 @@ impl DbUserProfileRepository { self.database.get_user_profile_from_username(username).await } + /// It get the user profile from the email address. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn get_user_profile_from_email(&self, email: &str) -> Result { + self.database.get_user_profile_from_email(email).await + } + + /// It gets the user profile from a user ID. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn get_user_profile_from_id(&self, user_id: UserId) -> Result { + self.database.get_user_profile_from_id(user_id).await + } + /// It gets all the user profiles for all the users. /// /// # Errors diff --git a/src/web/api/client/v1/contexts/user/responses.rs b/src/web/api/client/v1/contexts/user/responses.rs index f8a986a5d..263656c91 100644 --- a/src/web/api/client/v1/contexts/user/responses.rs +++ b/src/web/api/client/v1/contexts/user/responses.rs @@ -8,6 +8,7 @@ pub struct AddedUserResponse { #[derive(Deserialize, Debug)] pub struct NewUserData { pub user_id: i64, + pub verification_expiry: String, } #[derive(Deserialize, Debug)] diff --git a/src/web/api/server/v1/contexts/user/forms.rs b/src/web/api/server/v1/contexts/user/forms.rs index 282385392..20ee4a667 100644 --- a/src/web/api/server/v1/contexts/user/forms.rs +++ b/src/web/api/server/v1/contexts/user/forms.rs @@ -31,3 +31,26 @@ pub struct ChangePasswordForm { pub password: String, pub confirm_password: String, } + +// Email verification resend + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ResendVerificationForm { + pub email: String, +} + +// Password reset + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SendPasswordLinkForm { + pub email: String, +} + +// Password reset completion + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ResetPasswordForm { + pub token: String, + pub password: String, + pub confirm_password: String, +} diff --git a/src/web/api/server/v1/contexts/user/handlers.rs b/src/web/api/server/v1/contexts/user/handlers.rs index f46e27864..7752184d2 100644 --- a/src/web/api/server/v1/contexts/user/handlers.rs +++ b/src/web/api/server/v1/contexts/user/handlers.rs @@ -3,15 +3,19 @@ use std::sync::Arc; use axum::extract::{self, Path, Query, State}; -use axum::http::HeaderMap; use axum::response::{IntoResponse, Response}; use axum::Json; use serde::Deserialize; -use super::forms::{ChangePasswordForm, JsonWebToken, LoginForm, RegistrationForm}; +use super::forms::{ + ChangePasswordForm, JsonWebToken, LoginForm, RegistrationForm, ResendVerificationForm, ResetPasswordForm, + SendPasswordLinkForm, +}; use super::responses::{self}; use crate::common::AppData; +use crate::mailer::{expiry_timestamp, RESET_TOKEN_EXPIRY_SECS, VERIFY_TOKEN_EXPIRY_SECS}; use crate::services::user::ListingRequest; +use crate::web::api::server::v1::extractors::api_base_url::ExtractApiBaseUrl; use crate::web::api::server::v1::extractors::optional_user_id::ExtractOptionalLoggedInUser; use crate::web::api::server::v1::responses::OkResponseData; @@ -25,27 +29,15 @@ use crate::web::api::server::v1::responses::OkResponseData; #[allow(clippy::unused_async)] pub async fn registration_handler( State(app_data): State>, - headers: HeaderMap, + ExtractApiBaseUrl(api_base_url): ExtractApiBaseUrl, extract::Json(registration_form): extract::Json, ) -> Response { - let host_from_header = headers - .get("host") - .and_then(|v| v.to_str().ok()) - .unwrap_or_default() - .to_string(); - - let api_base_url = app_data - .cfg - .get_api_base_url() - .await - .unwrap_or_else(|| api_base_url(&host_from_header)); - match app_data .registration_service .register_user(®istration_form, &api_base_url) .await { - Ok(user_id) => responses::added_user(user_id).into_response(), + Ok(user_id) => responses::added_user(user_id, expiry_timestamp(VERIFY_TOKEN_EXPIRY_SECS)).into_response(), Err(error) => error.into_response(), } } @@ -56,12 +48,43 @@ pub struct TokenParam(String); /// It handles the verification of the email verification token. #[allow(clippy::unused_async)] pub async fn email_verification_handler(State(app_data): State>, Path(token): Path) -> String { - match app_data.registration_service.verify_email(&token.0).await { + match app_data.email_verification_service.verify_email(&token.0).await { Ok(_) => String::from("Email verified, you can close this page."), Err(error) => error.to_string(), } } +/// Resends a verification link to the given email address. +/// +/// To prevent account enumeration, this endpoint always returns a success +/// response regardless of whether the email exists or is already verified. +/// +/// # Errors +/// +/// It returns an error only for rate-limiting or authorization failures. +#[allow(clippy::unused_async)] +pub async fn resend_verification_handler( + State(app_data): State>, + ExtractApiBaseUrl(api_base_url): ExtractApiBaseUrl, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, + extract::Json(form): extract::Json, +) -> Response { + match app_data + .email_verification_service + .resend_verification_link(maybe_user_id, &form.email, &api_base_url) + .await + { + Ok(()) => Json(OkResponseData { + data: serde_json::json!({ + "message": "If the email is associated with an unverified account, a verification link has been sent.", + "expiry": expiry_timestamp(VERIFY_TOKEN_EXPIRY_SECS) + }), + }) + .into_response(), + Err(error) => error.into_response(), + } +} + // Authentication /// It handles the user login. @@ -158,6 +181,63 @@ pub async fn change_password_handler( } } +/// It sends a password reset link to the user's email address. +/// +/// To prevent account enumeration, this endpoint always returns a success +/// response regardless of whether the email exists or is verified. +/// +/// # Errors +/// +/// It returns an error only for server-side failures (e.g., authorization). +#[allow(clippy::unused_async)] +pub async fn send_reset_password_link_handler( + State(app_data): State>, + ExtractApiBaseUrl(api_base_url): ExtractApiBaseUrl, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, + extract::Json(send_password_link_form): extract::Json, +) -> Response { + match app_data + .password_reset_service + .send_reset_link(maybe_user_id, &send_password_link_form.email, &api_base_url) + .await + { + Ok(()) => Json(OkResponseData { + data: serde_json::json!({ + "message": "If the email is associated with an account, a reset link has been sent.", + "expiry": expiry_timestamp(RESET_TOKEN_EXPIRY_SECS) + }), + }) + .into_response(), + Err(error) => error.into_response(), + } +} + +/// It completes a password reset using a token from a reset link. +/// +/// # Errors +/// +/// It returns an error if: +/// +/// - The reset token is invalid or expired. +/// - The new password does not meet constraints. +#[allow(clippy::unused_async)] +pub async fn complete_password_reset_handler( + State(app_data): State>, + extract::Json(reset_form): extract::Json, +) -> Response { + match app_data + .password_reset_service + .complete_reset(&reset_form.token, &reset_form.password, &reset_form.confirm_password) + .await + { + Ok(()) => Json(OkResponseData { + data: "Password has been reset successfully.".to_string(), + }) + .into_response(), + Err(error) => error.into_response(), + } +} + /// It bans a user from the index. /// /// # Errors @@ -183,13 +263,6 @@ pub async fn ban_handler( } } -/// It returns the base API URL without the port. For example: `http://localhost`. -fn api_base_url(host: &str) -> String { - // HTTPS is not supported yet. - // See https://github.com/torrust/torrust-index/issues/131 - format!("http://{host}") -} - /// It handles the request to get all the user profiles. /// ///It returns a list of user profiles matching the search criteria. diff --git a/src/web/api/server/v1/contexts/user/responses.rs b/src/web/api/server/v1/contexts/user/responses.rs index b4ef4d79d..3ec2385d4 100644 --- a/src/web/api/server/v1/contexts/user/responses.rs +++ b/src/web/api/server/v1/contexts/user/responses.rs @@ -9,12 +9,17 @@ use crate::web::api::server::v1::responses::OkResponseData; #[derive(Serialize, Deserialize, Debug)] pub struct NewUser { pub user_id: UserId, + /// ISO 8601 UTC expiry timestamp for the email-verification token. + pub verification_expiry: String, } /// Response after successfully creating a new user. -pub const fn added_user(user_id: i64) -> Json> { +pub const fn added_user(user_id: i64, verification_expiry: String) -> Json> { Json(OkResponseData { - data: NewUser { user_id }, + data: NewUser { + user_id, + verification_expiry, + }, }) } diff --git a/src/web/api/server/v1/contexts/user/routes.rs b/src/web/api/server/v1/contexts/user/routes.rs index a26c3ce02..9fd1945ce 100644 --- a/src/web/api/server/v1/contexts/user/routes.rs +++ b/src/web/api/server/v1/contexts/user/routes.rs @@ -7,8 +7,9 @@ use axum::routing::{delete, get, post}; use axum::Router; use super::handlers::{ - ban_handler, change_password_handler, email_verification_handler, get_user_profiles_handler, login_handler, - registration_handler, renew_token_handler, verify_token_handler, + ban_handler, change_password_handler, complete_password_reset_handler, email_verification_handler, get_user_profiles_handler, + login_handler, registration_handler, renew_token_handler, resend_verification_handler, send_reset_password_link_handler, + verify_token_handler, }; use crate::common::AppData; @@ -25,6 +26,10 @@ pub fn router(app_data: Arc) -> Router { "/email/verify/{token}", get(email_verification_handler).with_state(app_data.clone()), ) + .route( + "/email/resend", + post(resend_verification_handler).with_state(app_data.clone()), + ) // Authentication .route("/login", post(login_handler).with_state(app_data.clone())) .route("/token/verify", post(verify_token_handler).with_state(app_data.clone())) @@ -34,6 +39,15 @@ pub fn router(app_data: Arc) -> Router { "/{user}/change-password", post(change_password_handler).with_state(app_data.clone()), ) + // Password reset + .route( + "/password-reset", + post(send_reset_password_link_handler).with_state(app_data.clone()), + ) + .route( + "/password-reset/complete", + post(complete_password_reset_handler).with_state(app_data.clone()), + ) // User ban // code-review: should not this be a POST method? We add the user to the blacklist. We do not delete the user. .route("/ban/{user}", delete(ban_handler).with_state(app_data)) diff --git a/src/web/api/server/v1/extractors/api_base_url.rs b/src/web/api/server/v1/extractors/api_base_url.rs new file mode 100644 index 000000000..4f113b45b --- /dev/null +++ b/src/web/api/server/v1/extractors/api_base_url.rs @@ -0,0 +1,48 @@ +//! Axum extractor that resolves the API base URL for the current request. +//! +//! The URL is determined by: +//! +//! 1. The configured `net.base_url` if set, or +//! 2. Falling back to the `Host` header from the incoming request. +use std::sync::Arc; + +use axum::extract::{FromRef, FromRequestParts}; +use axum::http::request::Parts; +use axum::response::Response; + +use crate::common::AppData; + +/// Extractor that resolves the API base URL for the current request. +/// +/// # Example +/// +/// ```ignore +/// async fn handler(ExtractApiBaseUrl(base_url): ExtractApiBaseUrl) { +/// // base_url is something like "http://localhost:3001" +/// } +/// ``` +pub struct ExtractApiBaseUrl(pub String); + +impl FromRequestParts for ExtractApiBaseUrl +where + Arc: FromRef, + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_data = Arc::from_ref(state); + + let base_url = if let Some(url) = app_data.cfg.get_api_base_url().await { + url + } else { + let host = parts.headers.get("host").and_then(|v| v.to_str().ok()).unwrap_or_default(); + + // HTTPS is not supported yet. + // See https://github.com/torrust/torrust-index/issues/131 + format!("http://{host}") + }; + + Ok(Self(base_url)) + } +} diff --git a/src/web/api/server/v1/extractors/mod.rs b/src/web/api/server/v1/extractors/mod.rs index acf2d6895..ee4d9728e 100644 --- a/src/web/api/server/v1/extractors/mod.rs +++ b/src/web/api/server/v1/extractors/mod.rs @@ -1,3 +1,4 @@ +pub mod api_base_url; pub mod bearer_token; pub mod optional_user_id; pub mod user_id; diff --git a/templates/reset_password.html b/templates/reset_password.html new file mode 100644 index 000000000..e4644fb28 --- /dev/null +++ b/templates/reset_password.html @@ -0,0 +1,426 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Torrust +
+
+

+ +
+
+ Hello, {{ username }}. +
+
+
+ We received a request to reset your password. +
+
+
+ Click the link below to reset your password: +
+
+ + + + +
+ + Reset password + +
+
+
+ Or copy and paste the following link into your + browser: +
+
+ +
+
+ This link expires on {{ expiry }}. +
+
+
+ If you did not request this, you can safely ignore + this email. +
+
+
+ +
+
+ +
+ + diff --git a/templates/verify.html b/templates/verify.html index c54bbfb4a..d05331b43 100644 --- a/templates/verify.html +++ b/templates/verify.html @@ -1,176 +1,385 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + - - - + + + - -
- -
- - - - - - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - -
-
- Torrust
-
-

-

- -
-
- Welcome to Torrust, {{ username }}.
-
-
- Please click the confirmation link below to verify your account.
-
- - - - -
- Verify account -
-
-
- Or copy and paste the following link into your browser:
-
- -
-
- -
+ +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Torrust +
+
+

+ +
+
+ Welcome to Torrust, {{ username }}. +
+
+
+ Please click the confirmation link below to verify + your account. +
+
+ + + + +
+ + Verify account + +
+
+
+ Or copy and paste the following link into your + browser: +
+
+ +
+
+ This link expires on {{ expiry }}. +
+
+
+ +
+
+
- -
- - - \ No newline at end of file + + diff --git a/tests/common/contexts/user/responses.rs b/tests/common/contexts/user/responses.rs index 6ca00cd03..dd0afc7bc 100644 --- a/tests/common/contexts/user/responses.rs +++ b/tests/common/contexts/user/responses.rs @@ -10,6 +10,7 @@ pub struct AddedUserResponse { #[derive(Deserialize, Debug)] pub struct NewUserData { pub user_id: i64, + pub verification_expiry: String, } #[derive(Deserialize, Debug)]