Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Comment on lines +169 to +180
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PasswordResetService::new constructor expects user_profile_repository, authorization_service, and password_reset_repository as parameters, but this is passing configuration as the first argument. The arguments don't match the constructor signature.

Suggested change
configuration.clone(),
user_profile_repository.clone(),
authorization_service.clone(),
user_profile_repository.clone(),
authorization_service.clone(),
password_reset_repository.clone(),

Copilot uses AI. Check for mistakes.
));

// Build app container

let app_data = Arc::new(AppData::new(
Expand Down Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions src/bootstrap/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
6 changes: 6 additions & 0 deletions src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ pub struct AppData {
pub ban_service: Arc<user::BanService>,
pub about_service: Arc<about::Service>,
pub listing_service: Arc<user::ListingService>,
pub password_reset_service: Arc<user::PasswordResetService>,
pub email_verification_service: Arc<user::EmailVerificationService>,
}

impl AppData {
Expand Down Expand Up @@ -92,6 +94,8 @@ impl AppData {
ban_service: Arc<user::BanService>,
about_service: Arc<about::Service>,
listing_service: Arc<user::ListingService>,
password_reset_service: Arc<user::PasswordResetService>,
email_verification_service: Arc<user::EmailVerificationService>,
) -> Self {
Self {
cfg,
Expand Down Expand Up @@ -128,6 +132,8 @@ impl AppData {
ban_service,
about_service,
listing_service,
password_reset_service,
email_verification_service,
}
}
}
16 changes: 6 additions & 10 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 {
Expand All @@ -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",
Expand Down
67 changes: 67 additions & 0 deletions src/config/v2/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,23 @@ 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 {
fn default() -> Self {
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(),
}
}
}
Expand All @@ -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)]
Expand Down
24 changes: 24 additions & 0 deletions src/config/v2/website.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/<token>` 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<String>,

/// The URL prefix the frontend uses for password reset links.
/// The backend appends `/<token>` 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<String>,
}

impl Default for Website {
Expand All @@ -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(),
}
}
}
Expand All @@ -38,6 +54,14 @@ impl Website {
fn default_terms() -> Terms {
Terms::default()
}

const fn default_email_verification_url_prefix() -> Option<String> {
None
}

const fn default_password_reset_url_prefix() -> Option<String> {
None
}
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
Expand Down
6 changes: 6 additions & 0 deletions src/databases/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,12 @@ pub trait Database: Sync + Send {
/// Get `UserProfile` from `username`.
async fn get_user_profile_from_username(&self, username: &str) -> Result<UserProfile, Error>;

/// Get `UserProfile` from `email`.
async fn get_user_profile_from_email(&self, email: &str) -> Result<UserProfile, Error>;

/// Get `UserProfile` from `user_id`.
async fn get_user_profile_from_id(&self, user_id: UserId) -> Result<UserProfile, Error>;

/// 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,
Expand Down
16 changes: 16 additions & 0 deletions src/databases/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,22 @@ impl Database for Mysql {
.map_err(|_| database::Error::UserNotFound)
}

async fn get_user_profile_from_email(&self, email: &str) -> Result<UserProfile, database::Error> {
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<UserProfile, database::Error> {
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<String>,
Expand Down
16 changes: 16 additions & 0 deletions src/databases/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,22 @@ impl Database for Sqlite {
.map_err(|_| database::Error::UserNotFound)
}

async fn get_user_profile_from_email(&self, email: &str) -> Result<UserProfile, database::Error> {
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<UserProfile, database::Error> {
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<String>,
Expand Down
20 changes: 20 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading