Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Request limiting for logging in #100

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
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
138 changes: 69 additions & 69 deletions Cargo.lock

Large diffs are not rendered by default.

116 changes: 115 additions & 1 deletion backend/integration-tests/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
use std::time::Duration;

use anyhow::{bail, Context};
use api_commands::{CreateAccount, Login};
use log::{debug, error, info};
use reqwest::StatusCode;
use secure_string::SecureBytes;
use simplelog::TermLogger;
use tokio::spawn;
use tokio::{spawn, time::sleep};

use crate::util::{assert_response_status, HttpClient};

Expand Down Expand Up @@ -38,6 +40,8 @@ async fn main() -> anyhow::Result<()> {
spawn(test_too_short_password()),
spawn(test_wrong_username_login()),
spawn(test_too_long_password_login()),
spawn(test_login_rate_limit()),
spawn(test_failed_login_rate_limit()),
];
let test_amount = tasks.len();

Expand Down Expand Up @@ -374,3 +378,113 @@ async fn test_too_long_password_login() -> anyhow::Result<()> {

assert_response_status!(response, StatusCode::BAD_REQUEST)
}

async fn test_login_rate_limit() -> anyhow::Result<()> {
let client = HttpClient::new().await?;
let response = client
.post(
"/accounts/create",
CreateAccount {
username: "alexander".to_owned(),
password: "abusch-abusch".to_owned().into(),
},
)
.await?;

assert_response_status!(response, StatusCode::CREATED)?;

for _ in 0..10 {
let response = client
.post(
"/accounts/login",
Login {
username: "alexander".to_owned(),
password: "abusch-abusch".to_owned().into(),
},
)
.await?;

assert_response_status!(response, StatusCode::NO_CONTENT)?;
}

let response = client
.post(
"/accounts/login",
Login {
username: "alexander".to_owned(),
password: "abusch-abusch".to_owned().into(),
},
)
.await?;

assert_response_status!(response, StatusCode::TOO_MANY_REQUESTS)?;

sleep(Duration::from_secs(10)).await;

let response = client
.post(
"/accounts/login",
Login {
username: "alexander".to_owned(),
password: "abusch-abusch".to_owned().into(),
},
)
.await?;

assert_response_status!(response, StatusCode::NO_CONTENT)
}

async fn test_failed_login_rate_limit() -> anyhow::Result<()> {
let client = HttpClient::new().await?;
let response = client
.post(
"/accounts/create",
CreateAccount {
username: "edgar".to_owned(),
password: "andré-andré".to_owned().into(),
},
)
.await?;

assert_response_status!(response, StatusCode::CREATED)?;

for _ in 0..5 {
let response = client
.post(
"/accounts/login",
Login {
username: "edgar".to_owned(),
password: "andré-edgar".to_owned().into(),
},
)
.await?;

assert_response_status!(response, StatusCode::BAD_REQUEST)?;
}

let response = client
.post(
"/accounts/login",
Login {
username: "edgar".to_owned(),
password: "andré-edgar".to_owned().into(),
},
)
.await?;

assert_response_status!(response, StatusCode::TOO_MANY_REQUESTS)?;

sleep(Duration::from_secs(10)).await;

let response = client
.post(
"/accounts/login",
Login {
username: "edgar".to_owned(),
password: "andré-edgar".to_owned().into(),
},
)
.await?;

assert_response_status!(response, StatusCode::BAD_REQUEST)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ALTER TABLE users
DROP COLUMN login_attempt_count,
DROP COLUMN failed_login_attempt_count,
DROP COLUMN next_login_attempt_count_reset;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ALTER TABLE users
ADD COLUMN login_attempt_count INT NOT NULL DEFAULT(0),
ADD COLUMN failed_login_attempt_count INT NOT NULL DEFAULT(0),
ADD COLUMN next_login_attempt_count_reset TIMESTAMPTZ NOT NULL DEFAULT(NOW());
34 changes: 28 additions & 6 deletions backend/rvoc-backend/src/configuration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ pub struct Configuration {
/// If more tries happen than this number, the request will fail.
pub maximum_session_id_generation_retry_count: u32,

/// The duration after which the number of login attempts and failed login attempts will be reset.
pub login_attempt_counting_interval: Duration,

/// The maximum number of login attempts per interval specified by [`login_attempt_counting_interval`](Configuration::login_attempt_counting_interval).
pub max_login_attempts_per_interval: i32,

/// The maximum number of failed login attempts per interval specified by [`login_attempt_counting_interval`](Configuration::login_attempt_counting_interval).
pub max_failed_login_attempts_per_interval: i32,

/// The base directory where wiktionary dumps are stored in.
pub wiktionary_temporary_data_directory: PathBuf,

Expand Down Expand Up @@ -97,11 +106,11 @@ impl Configuration {
opentelemetry_url: read_optional_env_var("OPENTELEMETRY_URL")?,
shutdown_timeout: Duration::seconds(read_env_var_with_default_as_type(
"RVOC_SHUTDOWN_TIMEOUT",
30i64,
30,
)?),
job_queue_poll_interval: Duration::seconds(read_env_var_with_default_as_type(
"JOB_QUEUE_POLL_INTERVAL_SECONDS",
60i64,
60,
)?),
maximum_transaction_retry_count: read_env_var_with_default_as_type(
"MAXIMUM_TRANSACTION_RETRY_COUNT",
Expand Down Expand Up @@ -144,6 +153,18 @@ impl Configuration {
"MAXIMUM_SESSION_ID_GENERATION_RETRY_COUNT",
10u32,
)?,
login_attempt_counting_interval: Duration::seconds(read_env_var_with_default_as_type(
"LOGIN_ATTEMPT_COUNTING_INTERVAL_SECONDS",
300u32,
)?),
max_login_attempts_per_interval: read_env_var_with_default_as_type(
"MAX_LOGIN_ATTEMPTS_PER_INTERVAL",
10,
)?,
max_failed_login_attempts_per_interval: read_env_var_with_default_as_type(
"MAX_FAILED_LOGIN_ATTEMPTS_PER_INTERVAL",
5,
)?,
wiktionary_temporary_data_directory: read_env_var_with_default_as_type(
"WIKTIONARY_TEMPORARY_DATA_DIRECTORY",
"data/wiktionary_data",
Expand All @@ -152,13 +173,11 @@ impl Configuration {
"WIKTIONARY_DUMP_INSERTION_BATCH_SIZE",
1000usize,
)?,
wiktionary_update_interval: Duration::hours(read_env_var_with_default_as_type::<i64>(
wiktionary_update_interval: Duration::hours(read_env_var_with_default_as_type(
"WIKTIONARY_POLL_INTERVAL_HOURS",
24,
)?),
delete_expired_sessions_interval: Duration::hours(read_env_var_with_default_as_type::<
i64,
>(
delete_expired_sessions_interval: Duration::hours(read_env_var_with_default_as_type(
"DELETE_EXPIRED_SESSIONS_INTERVAL_HOURS",
24,
)?),
Expand Down Expand Up @@ -217,6 +236,9 @@ impl Configuration {
password_argon2id_minimum_iterations: 2,
password_argon2id_parallelism: 1,
maximum_session_id_generation_retry_count: 10,
login_attempt_counting_interval: Duration::seconds(10),
max_login_attempts_per_interval: 10,
max_failed_login_attempts_per_interval: 5,
wiktionary_temporary_data_directory: "wiktionary_data".into(),
wiktionary_dump_insertion_batch_size: 1000,
wiktionary_update_interval: Duration::hours(24),
Expand Down
18 changes: 18 additions & 0 deletions backend/rvoc-backend/src/database/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,24 @@ diesel::table! {
///
/// (Automatically generated by Diesel.)
password_hash -> Nullable<Text>,
/// The `login_attempt_count` column of the `users` table.
///
/// Its SQL type is `Int4`.
///
/// (Automatically generated by Diesel.)
login_attempt_count -> Int4,
/// The `failed_login_attempt_count` column of the `users` table.
///
/// Its SQL type is `Int4`.
///
/// (Automatically generated by Diesel.)
failed_login_attempt_count -> Int4,
/// The `next_login_attempt_count_reset` column of the `users` table.
///
/// Its SQL type is `Timestamptz`.
///
/// (Automatically generated by Diesel.)
next_login_attempt_count_reset -> Timestamptz,
}
}

Expand Down
12 changes: 6 additions & 6 deletions backend/rvoc-backend/src/database/transactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,13 @@ impl RVocAsyncDatabaseConnectionPool {
max_retries: u64,
isolation_level: TransactionIsolationLevel,
) -> Result<ReturnType, PermanentErrorType> {
let mut database_connection = self.implementation.get().await.map_err(|error| {
PermanentErrorType::permanent_error(Box::new(RVocError::DatabaseConnection {
source: Box::new(error),
}))
})?;

for _ in 0..max_retries.saturating_add(1) {
let mut database_connection = self.implementation.get().await.map_err(|error| {
PermanentErrorType::permanent_error(Box::new(RVocError::DatabaseConnection {
source: Box::new(error),
}))
})?;

let transaction_result = match isolation_level {
TransactionIsolationLevel::Serializable => {
database_connection.build_transaction().serializable()
Expand Down
6 changes: 6 additions & 0 deletions backend/rvoc-backend/src/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ pub enum UserError {

#[error("the username or password did not match")]
InvalidUsernamePassword,

#[error("the username has no password (logins are disabled for this user)")]
UserHasNoPassword,

#[error("the user's login rate limit was reached")]
UserLoginRateLimitReached,
}

trait RequireSendAndSync: Send + Sync {}
Expand Down
69 changes: 67 additions & 2 deletions backend/rvoc-backend/src/model/user/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
use diesel::prelude::Insertable;
use chrono::{DateTime, Utc};
use diesel::{deserialize::Queryable, prelude::Insertable, AsChangeset, Identifiable, Selectable};
use tracing::trace;

use crate::configuration::Configuration;

use self::{password_hash::PasswordHash, username::Username};

Expand All @@ -10,9 +14,70 @@ pub mod username;
#[diesel(primary_key(name))]
#[diesel(check_for_backend(diesel::pg::Pg))]
#[diesel(treat_none_as_default_value = false)]
pub struct User {
pub struct NewUser {
#[diesel(serialize_as = String)]
pub name: Username,
#[diesel(serialize_as = Option<String>)]
pub password_hash: PasswordHash,
}

#[derive(Insertable, Clone, Debug, Selectable, Queryable, Identifiable, AsChangeset)]
#[diesel(table_name = crate::database::schema::users)]
#[diesel(primary_key(name))]
#[diesel(check_for_backend(diesel::pg::Pg))]
#[diesel(treat_none_as_default_value = false)]
pub struct UserLoginInfo {
#[diesel(serialize_as = String, deserialize_as = String)]
pub name: Username,
#[diesel(serialize_as = Option<String>, deserialize_as = Option<String>)]
pub password_hash: PasswordHash,
login_attempt_count: i32,
failed_login_attempt_count: i32,
next_login_attempt_count_reset: DateTime<Utc>,
}

impl NewUser {
pub fn new(name: Username, password_hash: PasswordHash) -> Self {
Self {
name,
password_hash,
}
}
}

impl UserLoginInfo {
/// Checks if a login attempt can be made and increments the number of login attempts if yes.
/// Returns `true` if a login attempt can be made.
pub fn try_login_attempt(&mut self, now: DateTime<Utc>, configuration: &Configuration) -> bool {
trace!("Trying login attempt with {self:?}");
if self.can_attempt_to_login(now, configuration) {
if self.login_attempt_count == 0 && self.failed_login_attempt_count == 0 {
self.next_login_attempt_count_reset =
now + configuration.login_attempt_counting_interval;
}
self.login_attempt_count += 1;
true
} else {
false
}
}

/// Record a failed login attempt.
pub fn fail_login_attempt(&mut self) {
assert!(self.login_attempt_count > 0);
self.failed_login_attempt_count += 1;
}

/// Returns `true` if it is currently possible to attempt a login.
fn can_attempt_to_login(&mut self, now: DateTime<Utc>, configuration: &Configuration) -> bool {
if now >= self.next_login_attempt_count_reset {
self.login_attempt_count = 0;
self.failed_login_attempt_count = 0;
true
} else {
self.login_attempt_count < configuration.max_login_attempts_per_interval
&& self.failed_login_attempt_count
< configuration.max_failed_login_attempts_per_interval
}
}
}
8 changes: 7 additions & 1 deletion backend/rvoc-backend/src/model/user/username.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{configuration::Configuration, error::RVocResult};

#[derive(Debug, Clone)]
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct Username {
name: String,
}
Expand All @@ -24,3 +24,9 @@ impl From<Username> for String {
value.name
}
}

impl From<String> for Username {
fn from(name: String) -> Self {
Self { name }
}
}
Loading
Loading