You've already forked authentication-service
mirror of
https://github.com/matrix-org/matrix-authentication-service.git
synced 2025-08-07 17:03:01 +03:00
Add rate-limiting for account recovery and registration (#3093)
* Add rate-limiting for account recovery and registration * Rename login ratelimiter `per_address` to `per_ip` for consistency Co-authored-by: Quentin Gliech <quenting@element.io>
This commit is contained in:
@@ -23,21 +23,28 @@ use crate::ConfigurationSection;
|
|||||||
/// Configuration related to sending emails
|
/// Configuration related to sending emails
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||||
pub struct RateLimitingConfig {
|
pub struct RateLimitingConfig {
|
||||||
|
/// Account Recovery-specific rate limits
|
||||||
|
#[serde(default)]
|
||||||
|
pub account_recovery: AccountRecoveryRateLimitingConfig,
|
||||||
/// Login-specific rate limits
|
/// Login-specific rate limits
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub login: LoginRateLimitingConfig,
|
pub login: LoginRateLimitingConfig,
|
||||||
|
/// Controls how many registrations attempts are permitted
|
||||||
|
/// based on source address.
|
||||||
|
#[serde(default = "default_registration")]
|
||||||
|
pub registration: RateLimiterConfiguration,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||||
pub struct LoginRateLimitingConfig {
|
pub struct LoginRateLimitingConfig {
|
||||||
/// Controls how many login attempts are permitted
|
/// Controls how many login attempts are permitted
|
||||||
/// based on source address.
|
/// based on source IP address.
|
||||||
/// This can protect against brute force login attempts.
|
/// This can protect against brute force login attempts.
|
||||||
///
|
///
|
||||||
/// Note: this limit also applies to password checks when a user attempts to
|
/// Note: this limit also applies to password checks when a user attempts to
|
||||||
/// change their own password.
|
/// change their own password.
|
||||||
#[serde(default = "default_login_per_address")]
|
#[serde(default = "default_login_per_ip")]
|
||||||
pub per_address: RateLimiterConfiguration,
|
pub per_ip: RateLimiterConfiguration,
|
||||||
/// Controls how many login attempts are permitted
|
/// Controls how many login attempts are permitted
|
||||||
/// based on the account that is being attempted to be logged into.
|
/// based on the account that is being attempted to be logged into.
|
||||||
/// This can protect against a distributed brute force attack
|
/// This can protect against a distributed brute force attack
|
||||||
@@ -50,6 +57,24 @@ pub struct LoginRateLimitingConfig {
|
|||||||
pub per_account: RateLimiterConfiguration,
|
pub per_account: RateLimiterConfiguration,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||||
|
pub struct AccountRecoveryRateLimitingConfig {
|
||||||
|
/// Controls how many account recovery attempts are permitted
|
||||||
|
/// based on source IP address.
|
||||||
|
/// This can protect against causing e-mail spam to many targets.
|
||||||
|
///
|
||||||
|
/// Note: this limit also applies to re-sends.
|
||||||
|
#[serde(default = "default_account_recovery_per_ip")]
|
||||||
|
pub per_ip: RateLimiterConfiguration,
|
||||||
|
/// Controls how many account recovery attempts are permitted
|
||||||
|
/// based on the e-mail address entered into the recovery form.
|
||||||
|
/// This can protect against causing e-mail spam to one target.
|
||||||
|
///
|
||||||
|
/// Note: this limit also applies to re-sends.
|
||||||
|
#[serde(default = "default_account_recovery_per_address")]
|
||||||
|
pub per_address: RateLimiterConfiguration,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||||
pub struct RateLimiterConfiguration {
|
pub struct RateLimiterConfiguration {
|
||||||
/// A one-off burst of actions that the user can perform
|
/// A one-off burst of actions that the user can perform
|
||||||
@@ -66,6 +91,13 @@ impl ConfigurationSection for RateLimitingConfig {
|
|||||||
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
|
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
|
||||||
let metadata = figment.find_metadata(Self::PATH.unwrap());
|
let metadata = figment.find_metadata(Self::PATH.unwrap());
|
||||||
|
|
||||||
|
let error_on_field = |mut error: figment::error::Error, field: &'static str| {
|
||||||
|
error.metadata = metadata.cloned();
|
||||||
|
error.profile = Some(figment::Profile::Default);
|
||||||
|
error.path = vec![Self::PATH.unwrap().to_owned(), field.to_owned()];
|
||||||
|
error
|
||||||
|
};
|
||||||
|
|
||||||
let error_on_nested_field =
|
let error_on_nested_field =
|
||||||
|mut error: figment::error::Error, container: &'static str, field: &'static str| {
|
|mut error: figment::error::Error, container: &'static str, field: &'static str| {
|
||||||
error.metadata = metadata.cloned();
|
error.metadata = metadata.cloned();
|
||||||
@@ -92,8 +124,23 @@ impl ConfigurationSection for RateLimitingConfig {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(error) = error_on_limiter(&self.login.per_address) {
|
if let Some(error) = error_on_limiter(&self.account_recovery.per_ip) {
|
||||||
return Err(error_on_nested_field(error, "login", "per_address"));
|
return Err(error_on_nested_field(error, "account_recovery", "per_ip"));
|
||||||
|
}
|
||||||
|
if let Some(error) = error_on_limiter(&self.account_recovery.per_address) {
|
||||||
|
return Err(error_on_nested_field(
|
||||||
|
error,
|
||||||
|
"account_recovery",
|
||||||
|
"per_address",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(error) = error_on_limiter(&self.registration) {
|
||||||
|
return Err(error_on_field(error, "registration"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(error) = error_on_limiter(&self.login.per_ip) {
|
||||||
|
return Err(error_on_nested_field(error, "login", "per_ip"));
|
||||||
}
|
}
|
||||||
if let Some(error) = error_on_limiter(&self.login.per_account) {
|
if let Some(error) = error_on_limiter(&self.login.per_account) {
|
||||||
return Err(error_on_nested_field(error, "login", "per_account"));
|
return Err(error_on_nested_field(error, "login", "per_account"));
|
||||||
@@ -119,7 +166,7 @@ impl RateLimiterConfiguration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_login_per_address() -> RateLimiterConfiguration {
|
fn default_login_per_ip() -> RateLimiterConfiguration {
|
||||||
RateLimiterConfiguration {
|
RateLimiterConfiguration {
|
||||||
burst: NonZeroU32::new(3).unwrap(),
|
burst: NonZeroU32::new(3).unwrap(),
|
||||||
per_second: 3.0 / 60.0,
|
per_second: 3.0 / 60.0,
|
||||||
@@ -133,11 +180,33 @@ fn default_login_per_account() -> RateLimiterConfiguration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::derivable_impls)] // when we add some top-level ratelimiters this will not be derivable anymore
|
fn default_registration() -> RateLimiterConfiguration {
|
||||||
|
RateLimiterConfiguration {
|
||||||
|
burst: NonZeroU32::new(3).unwrap(),
|
||||||
|
per_second: 3.0 / 3600.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_account_recovery_per_ip() -> RateLimiterConfiguration {
|
||||||
|
RateLimiterConfiguration {
|
||||||
|
burst: NonZeroU32::new(3).unwrap(),
|
||||||
|
per_second: 3.0 / 3600.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_account_recovery_per_address() -> RateLimiterConfiguration {
|
||||||
|
RateLimiterConfiguration {
|
||||||
|
burst: NonZeroU32::new(3).unwrap(),
|
||||||
|
per_second: 1.0 / 3600.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for RateLimitingConfig {
|
impl Default for RateLimitingConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
RateLimitingConfig {
|
RateLimitingConfig {
|
||||||
login: LoginRateLimitingConfig::default(),
|
login: LoginRateLimitingConfig::default(),
|
||||||
|
registration: default_registration(),
|
||||||
|
account_recovery: AccountRecoveryRateLimitingConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,8 +214,17 @@ impl Default for RateLimitingConfig {
|
|||||||
impl Default for LoginRateLimitingConfig {
|
impl Default for LoginRateLimitingConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
LoginRateLimitingConfig {
|
LoginRateLimitingConfig {
|
||||||
per_address: default_login_per_address(),
|
per_ip: default_login_per_ip(),
|
||||||
per_account: default_login_per_account(),
|
per_account: default_login_per_account(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for AccountRecoveryRateLimitingConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
AccountRecoveryRateLimitingConfig {
|
||||||
|
per_ip: default_account_recovery_per_ip(),
|
||||||
|
per_address: default_account_recovery_per_address(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -19,6 +19,15 @@ use mas_config::RateLimitingConfig;
|
|||||||
use mas_data_model::User;
|
use mas_data_model::User;
|
||||||
use ulid::Ulid;
|
use ulid::Ulid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, thiserror::Error)]
|
||||||
|
pub enum AccountRecoveryLimitedError {
|
||||||
|
#[error("Too many account recovery requests for requester {0}")]
|
||||||
|
Requester(RequesterFingerprint),
|
||||||
|
|
||||||
|
#[error("Too many account recovery requests for e-mail {0}")]
|
||||||
|
Email(String),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, thiserror::Error)]
|
#[derive(Debug, Clone, Copy, thiserror::Error)]
|
||||||
pub enum PasswordCheckLimitedError {
|
pub enum PasswordCheckLimitedError {
|
||||||
#[error("Too many password checks for requester {0}")]
|
#[error("Too many password checks for requester {0}")]
|
||||||
@@ -28,6 +37,12 @@ pub enum PasswordCheckLimitedError {
|
|||||||
User(Ulid),
|
User(Ulid),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, thiserror::Error)]
|
||||||
|
pub enum RegistrationLimitedError {
|
||||||
|
#[error("Too many account registration requests for requester {0}")]
|
||||||
|
Requester(RequesterFingerprint),
|
||||||
|
}
|
||||||
|
|
||||||
/// Key used to rate limit requests per requester
|
/// Key used to rate limit requests per requester
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub struct RequesterFingerprint {
|
pub struct RequesterFingerprint {
|
||||||
@@ -66,15 +81,25 @@ type KeyedRateLimiter<K> = RateLimiter<K, DashMapStateStore<K>, QuantaClock>;
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct LimiterInner {
|
struct LimiterInner {
|
||||||
|
account_recovery_per_requester: KeyedRateLimiter<RequesterFingerprint>,
|
||||||
|
account_recovery_per_email: KeyedRateLimiter<String>,
|
||||||
password_check_for_requester: KeyedRateLimiter<RequesterFingerprint>,
|
password_check_for_requester: KeyedRateLimiter<RequesterFingerprint>,
|
||||||
password_check_for_user: KeyedRateLimiter<Ulid>,
|
password_check_for_user: KeyedRateLimiter<Ulid>,
|
||||||
|
registration_per_requester: KeyedRateLimiter<RequesterFingerprint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LimiterInner {
|
impl LimiterInner {
|
||||||
fn new(config: &RateLimitingConfig) -> Option<Self> {
|
fn new(config: &RateLimitingConfig) -> Option<Self> {
|
||||||
Some(Self {
|
Some(Self {
|
||||||
password_check_for_requester: RateLimiter::keyed(config.login.per_address.to_quota()?),
|
account_recovery_per_requester: RateLimiter::keyed(
|
||||||
|
config.account_recovery.per_ip.to_quota()?,
|
||||||
|
),
|
||||||
|
account_recovery_per_email: RateLimiter::keyed(
|
||||||
|
config.account_recovery.per_address.to_quota()?,
|
||||||
|
),
|
||||||
|
password_check_for_requester: RateLimiter::keyed(config.login.per_ip.to_quota()?),
|
||||||
password_check_for_user: RateLimiter::keyed(config.login.per_account.to_quota()?),
|
password_check_for_user: RateLimiter::keyed(config.login.per_account.to_quota()?),
|
||||||
|
registration_per_requester: RateLimiter::keyed(config.registration.to_quota()?),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,14 +130,44 @@ impl Limiter {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Call the retain_recent method on each rate limiter
|
// Call the retain_recent method on each rate limiter
|
||||||
|
this.inner.account_recovery_per_email.retain_recent();
|
||||||
|
this.inner.account_recovery_per_requester.retain_recent();
|
||||||
this.inner.password_check_for_requester.retain_recent();
|
this.inner.password_check_for_requester.retain_recent();
|
||||||
this.inner.password_check_for_user.retain_recent();
|
this.inner.password_check_for_user.retain_recent();
|
||||||
|
this.inner.registration_per_requester.retain_recent();
|
||||||
|
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if an account recovery can be performed
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the operation is rate limited.
|
||||||
|
pub fn check_account_recovery(
|
||||||
|
&self,
|
||||||
|
requester: RequesterFingerprint,
|
||||||
|
email_address: &str,
|
||||||
|
) -> Result<(), AccountRecoveryLimitedError> {
|
||||||
|
self.inner
|
||||||
|
.account_recovery_per_requester
|
||||||
|
.check_key(&requester)
|
||||||
|
.map_err(|_| AccountRecoveryLimitedError::Requester(requester))?;
|
||||||
|
|
||||||
|
// Convert to lowercase to prevent bypassing the limit by enumerating different
|
||||||
|
// case variations.
|
||||||
|
// A case-folding transformation may be more proper.
|
||||||
|
let canonical_email = email_address.to_lowercase();
|
||||||
|
self.inner
|
||||||
|
.account_recovery_per_email
|
||||||
|
.check_key(&canonical_email)
|
||||||
|
.map_err(|_| AccountRecoveryLimitedError::Email(canonical_email))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a password check can be performed
|
/// Check if a password check can be performed
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -135,6 +190,23 @@ impl Limiter {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if an account registration can be performed
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the operation is rate limited.
|
||||||
|
pub fn check_registration(
|
||||||
|
&self,
|
||||||
|
requester: RequesterFingerprint,
|
||||||
|
) -> Result<(), RegistrationLimitedError> {
|
||||||
|
self.inner
|
||||||
|
.registration_per_requester
|
||||||
|
.check_key(&requester)
|
||||||
|
.map_err(|_| RegistrationLimitedError::Requester(requester))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
@@ -17,6 +17,7 @@ use axum::{
|
|||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
Form,
|
Form,
|
||||||
};
|
};
|
||||||
|
use hyper::StatusCode;
|
||||||
use mas_axum_utils::{
|
use mas_axum_utils::{
|
||||||
cookies::CookieJar,
|
cookies::CookieJar,
|
||||||
csrf::{CsrfExt, ProtectedForm},
|
csrf::{CsrfExt, ProtectedForm},
|
||||||
@@ -31,7 +32,7 @@ use mas_storage::{
|
|||||||
use mas_templates::{EmptyContext, RecoveryProgressContext, TemplateContext, Templates};
|
use mas_templates::{EmptyContext, RecoveryProgressContext, TemplateContext, Templates};
|
||||||
use ulid::Ulid;
|
use ulid::Ulid;
|
||||||
|
|
||||||
use crate::PreferredLanguage;
|
use crate::{Limiter, PreferredLanguage, RequesterFingerprint};
|
||||||
|
|
||||||
pub(crate) async fn get(
|
pub(crate) async fn get(
|
||||||
mut rng: BoxRng,
|
mut rng: BoxRng,
|
||||||
@@ -74,7 +75,7 @@ pub(crate) async fn get(
|
|||||||
return Ok((cookie_jar, Html(rendered)).into_response());
|
return Ok((cookie_jar, Html(rendered)).into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
let context = RecoveryProgressContext::new(recovery_session)
|
let context = RecoveryProgressContext::new(recovery_session, false)
|
||||||
.with_csrf(csrf_token.form_value())
|
.with_csrf(csrf_token.form_value())
|
||||||
.with_language(locale);
|
.with_language(locale);
|
||||||
|
|
||||||
@@ -92,6 +93,7 @@ pub(crate) async fn post(
|
|||||||
State(site_config): State<SiteConfig>,
|
State(site_config): State<SiteConfig>,
|
||||||
State(templates): State<Templates>,
|
State(templates): State<Templates>,
|
||||||
State(url_builder): State<UrlBuilder>,
|
State(url_builder): State<UrlBuilder>,
|
||||||
|
(State(limiter), requester): (State<Limiter>, RequesterFingerprint),
|
||||||
PreferredLanguage(locale): PreferredLanguage,
|
PreferredLanguage(locale): PreferredLanguage,
|
||||||
cookie_jar: CookieJar,
|
cookie_jar: CookieJar,
|
||||||
Path(id): Path<Ulid>,
|
Path(id): Path<Ulid>,
|
||||||
@@ -130,6 +132,17 @@ pub(crate) async fn post(
|
|||||||
// Verify the CSRF token
|
// Verify the CSRF token
|
||||||
let () = cookie_jar.verify_form(&clock, form)?;
|
let () = cookie_jar.verify_form(&clock, form)?;
|
||||||
|
|
||||||
|
// Check the rate limit if we are about to process the form
|
||||||
|
if let Err(e) = limiter.check_account_recovery(requester, &recovery_session.email) {
|
||||||
|
tracing::warn!(error = &e as &dyn std::error::Error);
|
||||||
|
let context = RecoveryProgressContext::new(recovery_session, true)
|
||||||
|
.with_csrf(csrf_token.form_value())
|
||||||
|
.with_language(locale);
|
||||||
|
let rendered = templates.render_recovery_progress(&context)?;
|
||||||
|
|
||||||
|
return Ok((StatusCode::TOO_MANY_REQUESTS, (cookie_jar, Html(rendered))).into_response());
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule a new batch of emails
|
// Schedule a new batch of emails
|
||||||
repo.job()
|
repo.job()
|
||||||
.schedule_job(SendAccountRecoveryEmailsJob::new(&recovery_session))
|
.schedule_job(SendAccountRecoveryEmailsJob::new(&recovery_session))
|
||||||
@@ -137,7 +150,7 @@ pub(crate) async fn post(
|
|||||||
|
|
||||||
repo.save().await?;
|
repo.save().await?;
|
||||||
|
|
||||||
let context = RecoveryProgressContext::new(recovery_session)
|
let context = RecoveryProgressContext::new(recovery_session, false)
|
||||||
.with_csrf(csrf_token.form_value())
|
.with_csrf(csrf_token.form_value())
|
||||||
.with_language(locale);
|
.with_language(locale);
|
||||||
|
|
||||||
|
@@ -33,12 +33,12 @@ use mas_storage::{
|
|||||||
BoxClock, BoxRepository, BoxRng,
|
BoxClock, BoxRepository, BoxRng,
|
||||||
};
|
};
|
||||||
use mas_templates::{
|
use mas_templates::{
|
||||||
EmptyContext, FieldError, FormState, RecoveryStartContext, RecoveryStartFormField,
|
EmptyContext, FieldError, FormError, FormState, RecoveryStartContext, RecoveryStartFormField,
|
||||||
TemplateContext, Templates,
|
TemplateContext, Templates,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{BoundActivityTracker, PreferredLanguage};
|
use crate::{BoundActivityTracker, Limiter, PreferredLanguage, RequesterFingerprint};
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub(crate) struct StartRecoveryForm {
|
pub(crate) struct StartRecoveryForm {
|
||||||
@@ -90,6 +90,7 @@ pub(crate) async fn post(
|
|||||||
State(site_config): State<SiteConfig>,
|
State(site_config): State<SiteConfig>,
|
||||||
State(templates): State<Templates>,
|
State(templates): State<Templates>,
|
||||||
State(url_builder): State<UrlBuilder>,
|
State(url_builder): State<UrlBuilder>,
|
||||||
|
(State(limiter), requester): (State<Limiter>, RequesterFingerprint),
|
||||||
PreferredLanguage(locale): PreferredLanguage,
|
PreferredLanguage(locale): PreferredLanguage,
|
||||||
cookie_jar: CookieJar,
|
cookie_jar: CookieJar,
|
||||||
Form(form): Form<ProtectedForm<StartRecoveryForm>>,
|
Form(form): Form<ProtectedForm<StartRecoveryForm>>,
|
||||||
@@ -120,6 +121,14 @@ pub(crate) async fn post(
|
|||||||
form_state.with_error_on_field(RecoveryStartFormField::Email, FieldError::Invalid);
|
form_state.with_error_on_field(RecoveryStartFormField::Email, FieldError::Invalid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if form_state.is_valid() {
|
||||||
|
// Check the rate limit if we are about to process the form
|
||||||
|
if let Err(e) = limiter.check_account_recovery(requester, &form.email) {
|
||||||
|
tracing::warn!(error = &e as &dyn std::error::Error);
|
||||||
|
form_state.add_error_on_form(FormError::RateLimitExceeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !form_state.is_valid() {
|
if !form_state.is_valid() {
|
||||||
repo.save().await?;
|
repo.save().await?;
|
||||||
let context = RecoveryStartContext::new()
|
let context = RecoveryStartContext::new()
|
||||||
|
@@ -46,8 +46,8 @@ use zeroize::Zeroizing;
|
|||||||
|
|
||||||
use super::shared::OptionalPostAuthAction;
|
use super::shared::OptionalPostAuthAction;
|
||||||
use crate::{
|
use crate::{
|
||||||
captcha::Form as CaptchaForm, passwords::PasswordManager, BoundActivityTracker,
|
captcha::Form as CaptchaForm, passwords::PasswordManager, BoundActivityTracker, Limiter,
|
||||||
PreferredLanguage, SiteConfig,
|
PreferredLanguage, RequesterFingerprint, SiteConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
@@ -122,12 +122,15 @@ pub(crate) async fn post(
|
|||||||
State(site_config): State<SiteConfig>,
|
State(site_config): State<SiteConfig>,
|
||||||
State(homeserver): State<BoxHomeserverConnection>,
|
State(homeserver): State<BoxHomeserverConnection>,
|
||||||
State(http_client_factory): State<HttpClientFactory>,
|
State(http_client_factory): State<HttpClientFactory>,
|
||||||
|
(State(limiter), requester): (State<Limiter>, RequesterFingerprint),
|
||||||
mut policy: Policy,
|
mut policy: Policy,
|
||||||
mut repo: BoxRepository,
|
mut repo: BoxRepository,
|
||||||
activity_tracker: BoundActivityTracker,
|
(user_agent, activity_tracker): (
|
||||||
|
Option<TypedHeader<headers::UserAgent>>,
|
||||||
|
BoundActivityTracker,
|
||||||
|
),
|
||||||
Query(query): Query<OptionalPostAuthAction>,
|
Query(query): Query<OptionalPostAuthAction>,
|
||||||
cookie_jar: CookieJar,
|
cookie_jar: CookieJar,
|
||||||
user_agent: Option<TypedHeader<headers::UserAgent>>,
|
|
||||||
Form(form): Form<ProtectedForm<RegisterForm>>,
|
Form(form): Form<ProtectedForm<RegisterForm>>,
|
||||||
) -> Result<Response, FancyError> {
|
) -> Result<Response, FancyError> {
|
||||||
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
|
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
|
||||||
@@ -243,6 +246,14 @@ pub(crate) async fn post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if state.is_valid() {
|
||||||
|
// Check the rate limit if we are about to process the form
|
||||||
|
if let Err(e) = limiter.check_registration(requester) {
|
||||||
|
tracing::warn!(error = &e as &dyn std::error::Error);
|
||||||
|
state.add_error_on_form(FormError::RateLimitExceeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state
|
state
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -1056,13 +1056,18 @@ impl TemplateContext for RecoveryStartContext {
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct RecoveryProgressContext {
|
pub struct RecoveryProgressContext {
|
||||||
session: UserRecoverySession,
|
session: UserRecoverySession,
|
||||||
|
/// Whether resending the e-mail was denied because of rate limits
|
||||||
|
resend_failed_due_to_rate_limit: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RecoveryProgressContext {
|
impl RecoveryProgressContext {
|
||||||
/// Constructs a context for the recovery progress page
|
/// Constructs a context for the recovery progress page
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(session: UserRecoverySession) -> Self {
|
pub fn new(session: UserRecoverySession, resend_failed_due_to_rate_limit: bool) -> Self {
|
||||||
Self { session }
|
Self {
|
||||||
|
session,
|
||||||
|
resend_failed_due_to_rate_limit,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1081,7 +1086,16 @@ impl TemplateContext for RecoveryProgressContext {
|
|||||||
consumed_at: None,
|
consumed_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
vec![Self { session }]
|
vec![
|
||||||
|
Self {
|
||||||
|
session: session.clone(),
|
||||||
|
resend_failed_due_to_rate_limit: false,
|
||||||
|
},
|
||||||
|
Self {
|
||||||
|
session,
|
||||||
|
resend_failed_due_to_rate_limit: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1668,10 +1668,28 @@
|
|||||||
"description": "Configuration related to sending emails",
|
"description": "Configuration related to sending emails",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"account_recovery": {
|
||||||
|
"description": "Account Recovery-specific rate limits",
|
||||||
|
"default": {
|
||||||
|
"per_ip": {
|
||||||
|
"burst": 3,
|
||||||
|
"per_second": 0.0008333333333333334
|
||||||
|
},
|
||||||
|
"per_address": {
|
||||||
|
"burst": 3,
|
||||||
|
"per_second": 0.0002777777777777778
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/AccountRecoveryRateLimitingConfig"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"description": "Login-specific rate limits",
|
"description": "Login-specific rate limits",
|
||||||
"default": {
|
"default": {
|
||||||
"per_address": {
|
"per_ip": {
|
||||||
"burst": 3,
|
"burst": 3,
|
||||||
"per_second": 0.05
|
"per_second": 0.05
|
||||||
},
|
},
|
||||||
@@ -1685,17 +1703,29 @@
|
|||||||
"$ref": "#/definitions/LoginRateLimitingConfig"
|
"$ref": "#/definitions/LoginRateLimitingConfig"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"registration": {
|
||||||
|
"description": "Controls how many registrations attempts are permitted based on source address.",
|
||||||
|
"default": {
|
||||||
|
"burst": 3,
|
||||||
|
"per_second": 0.0008333333333333334
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/RateLimiterConfiguration"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"LoginRateLimitingConfig": {
|
"AccountRecoveryRateLimitingConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"per_address": {
|
"per_ip": {
|
||||||
"description": "Controls how many login attempts are permitted based on source address. This can protect against brute force login attempts.\n\nNote: this limit also applies to password checks when a user attempts to change their own password.",
|
"description": "Controls how many account recovery attempts are permitted based on source IP address. This can protect against causing e-mail spam to many targets.\n\nNote: this limit also applies to re-sends.",
|
||||||
"default": {
|
"default": {
|
||||||
"burst": 3,
|
"burst": 3,
|
||||||
"per_second": 0.05
|
"per_second": 0.0008333333333333334
|
||||||
},
|
},
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
@@ -1703,11 +1733,11 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"per_account": {
|
"per_address": {
|
||||||
"description": "Controls how many login attempts are permitted based on the account that is being attempted to be logged into. This can protect against a distributed brute force attack but should be set high enough to prevent someone's account being casually locked out.\n\nNote: this limit also applies to password checks when a user attempts to change their own password.",
|
"description": "Controls how many account recovery attempts are permitted based on the e-mail address entered into the recovery form. This can protect against causing e-mail spam to one target.\n\nNote: this limit also applies to re-sends.",
|
||||||
"default": {
|
"default": {
|
||||||
"burst": 1800,
|
"burst": 3,
|
||||||
"per_second": 0.5
|
"per_second": 0.0002777777777777778
|
||||||
},
|
},
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
@@ -1737,6 +1767,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"LoginRateLimitingConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"per_ip": {
|
||||||
|
"description": "Controls how many login attempts are permitted based on source IP address. This can protect against brute force login attempts.\n\nNote: this limit also applies to password checks when a user attempts to change their own password.",
|
||||||
|
"default": {
|
||||||
|
"burst": 3,
|
||||||
|
"per_second": 0.05
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/RateLimiterConfiguration"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"per_account": {
|
||||||
|
"description": "Controls how many login attempts are permitted based on the account that is being attempted to be logged into. This can protect against a distributed brute force attack but should be set high enough to prevent someone's account being casually locked out.\n\nNote: this limit also applies to password checks when a user attempts to change their own password.",
|
||||||
|
"default": {
|
||||||
|
"burst": 1800,
|
||||||
|
"per_second": 0.5
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/RateLimiterConfiguration"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"UpstreamOAuth2Config": {
|
"UpstreamOAuth2Config": {
|
||||||
"description": "Upstream OAuth 2.0 providers configuration",
|
"description": "Upstream OAuth 2.0 providers configuration",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
@@ -371,15 +371,32 @@ Each rate limiter consists of two options:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
rate_limiting:
|
rate_limiting:
|
||||||
|
# Limits how many account recovery attempts are allowed.
|
||||||
|
# These limits can protect against e-mail spam.
|
||||||
|
#
|
||||||
|
# Note: these limit also apply to recovery e-mail re-sends.
|
||||||
|
account_recovery:
|
||||||
|
# Controls how many account recovery attempts are permitted
|
||||||
|
# based on source IP address.
|
||||||
|
per_ip:
|
||||||
|
burst: 3
|
||||||
|
per_second: 0.0008
|
||||||
|
|
||||||
|
# Controls how many account recovery attempts are permitted
|
||||||
|
# based on the e-mail address that is being used for recovery.
|
||||||
|
per_address:
|
||||||
|
burst: 3
|
||||||
|
per_second: 0.0002
|
||||||
|
|
||||||
# Limits how many login attempts are allowed.
|
# Limits how many login attempts are allowed.
|
||||||
#
|
#
|
||||||
# Note: these limit also applies to password checks when a user attempts to
|
# Note: these limit also applies to password checks when a user attempts to
|
||||||
# change their own password.
|
# change their own password.
|
||||||
login:
|
login:
|
||||||
# Controls how many login attempts are permitted
|
# Controls how many login attempts are permitted
|
||||||
# based on source address.
|
# based on source IP address.
|
||||||
# This can protect against brute force login attempts.
|
# This can protect against brute force login attempts.
|
||||||
per_address:
|
per_ip:
|
||||||
burst: 3
|
burst: 3
|
||||||
per_second: 0.05
|
per_second: 0.05
|
||||||
|
|
||||||
@@ -391,6 +408,13 @@ rate_limiting:
|
|||||||
per_account:
|
per_account:
|
||||||
burst: 1800
|
burst: 1800
|
||||||
per_second: 0.5
|
per_second: 0.5
|
||||||
|
|
||||||
|
# Limits how many registrations attempts are allowed,
|
||||||
|
# based on source IP address.
|
||||||
|
# This limit can protect against e-mail spam and against people registering too many accounts.
|
||||||
|
registration:
|
||||||
|
burst: 3
|
||||||
|
per_second: 0.0008
|
||||||
```
|
```
|
||||||
|
|
||||||
## `telemetry`
|
## `telemetry`
|
||||||
|
@@ -29,6 +29,11 @@ limitations under the License.
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
|
{% if resend_failed_due_to_rate_limit | default(false) %}
|
||||||
|
<div class="text-critical font-medium">
|
||||||
|
{{ _("mas.errors.rate_limit_exceeded") }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<form class="cpd-form-root" method="POST">
|
<form class="cpd-form-root" method="POST">
|
||||||
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
|
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
|
||||||
|
|
||||||
|
@@ -296,7 +296,7 @@
|
|||||||
},
|
},
|
||||||
"rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again.",
|
"rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again.",
|
||||||
"@rate_limit_exceeded": {
|
"@rate_limit_exceeded": {
|
||||||
"context": "components/errors.html:23:7-42"
|
"context": "components/errors.html:23:7-42, pages/recovery/progress.html:34:11-46"
|
||||||
},
|
},
|
||||||
"username_taken": "This username is already taken",
|
"username_taken": "This username is already taken",
|
||||||
"@username_taken": {
|
"@username_taken": {
|
||||||
@@ -461,7 +461,7 @@
|
|||||||
"progress": {
|
"progress": {
|
||||||
"change_email": "Try a different email",
|
"change_email": "Try a different email",
|
||||||
"@change_email": {
|
"@change_email": {
|
||||||
"context": "pages/recovery/progress.html:38:33-72",
|
"context": "pages/recovery/progress.html:43:33-72",
|
||||||
"description": "Button to change the email address for the password recovery link"
|
"description": "Button to change the email address for the password recovery link"
|
||||||
},
|
},
|
||||||
"description": "We sent an email with a link to reset your password if there's an account using <span>%(email)s</span>.",
|
"description": "We sent an email with a link to reset your password if there's an account using <span>%(email)s</span>.",
|
||||||
@@ -476,7 +476,7 @@
|
|||||||
},
|
},
|
||||||
"resend_email": "Resend email",
|
"resend_email": "Resend email",
|
||||||
"@resend_email": {
|
"@resend_email": {
|
||||||
"context": "pages/recovery/progress.html:35:36-75",
|
"context": "pages/recovery/progress.html:40:36-75",
|
||||||
"description": "Button to resend the email with the password recovery link"
|
"description": "Button to resend the email with the password recovery link"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Reference in New Issue
Block a user