Skip to content

Commit

Permalink
Rate limit login attempts.
Browse files Browse the repository at this point in the history
  • Loading branch information
ISibboI committed Dec 14, 2023
1 parent c279c4e commit 702bdb5
Show file tree
Hide file tree
Showing 7 changed files with 112 additions and 42 deletions.
8 changes: 4 additions & 4 deletions backend/rvoc-backend/src/configuration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,10 @@ pub struct Configuration {
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: u32,
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: u32,
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 @@ -159,11 +159,11 @@ impl Configuration {
)?),
max_login_attempts_per_interval: read_env_var_with_default_as_type(
"MAX_LOGIN_ATTEMPTS_PER_INTERVAL",
10u32,
10,
)?,
max_failed_login_attempts_per_interval: read_env_var_with_default_as_type(
"MAX_FAILED_LOGIN_ATTEMPTS_PER_INTERVAL",
5u32,
5,
)?,
wiktionary_temporary_data_directory: read_env_var_with_default_as_type(
"WIKTIONARY_TEMPORARY_DATA_DIRECTORY",
Expand Down
3 changes: 3 additions & 0 deletions backend/rvoc-backend/src/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ pub enum UserError {

#[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
67 changes: 65 additions & 2 deletions backend/rvoc-backend/src/model/user/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use diesel::prelude::Insertable;
use chrono::{DateTime, Utc};
use diesel::{deserialize::Queryable, prelude::Insertable, AsChangeset, Identifiable, Selectable};

use crate::configuration::Configuration;

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

Expand All @@ -10,9 +13,69 @@ 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 {
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 }
}
}
60 changes: 30 additions & 30 deletions backend/rvoc-backend/src/web/authentication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ use axum::{
response::{IntoResponse, Response},
Extension, Json,
};
use diesel::QueryDsl;
use chrono::Utc;
use tracing::{info, instrument};
use typed_session_axum::{SessionHandle, WritableSession};

use crate::{
error::{RVocError, RVocResult, UserError},
model::user::{password_hash::PasswordHash, username::Username},
model::user::{username::Username, UserLoginInfo},
};

use super::{session::RVocSessionData, WebConfiguration, WebDatabaseConnectionPool};
Expand Down Expand Up @@ -53,54 +53,54 @@ pub async fn login(
use crate::database::schema::users;
use diesel::ExpressionMethods;
use diesel::OptionalExtension;
use diesel::{QueryDsl, SelectableHelper};
use diesel_async::RunQueryDsl;

let configuration = configuration.clone();
let now = Utc::now();

// get password hash
let password_hash: String = if let Some(password_hash) = users::table
.select(users::password_hash)
// get user login info
let Some(mut user_login_info) = users::table
.select(UserLoginInfo::as_select())
.filter(users::name.eq(username.as_ref()))
.first(database_connection)
.await
.optional()?
{
if let Some(password_hash) = password_hash {
password_hash
} else {
// Here the optional() returned a row, but with a null password hash.
info!("User has no password: {:?}", username);
return Err(UserError::UserHasNoPassword.into());
}
} else {
else {
// Here the optional() returned None, i.e. no row was found.
info!("User not found: {:?}", username);
return Err(UserError::InvalidUsernamePassword.into());
};

// check and update rate limit
if !user_login_info.try_login_attempt(now, configuration.as_ref()) {
// The user's login rate limit was reached.
info!("User login rate limit reached: {:?}", username);
return Err(UserError::UserLoginRateLimitReached.into());
}

// verify password hash
let mut password_hash = PasswordHash::from(password_hash);
let verify_result =
password_hash.verify(password.clone(), configuration)?;
let verify_result = user_login_info
.password_hash
.verify(password.clone(), configuration)?;

if !verify_result.matches {
info!("Wrong password for user: {:?}", username);
return Err(UserError::InvalidUsernamePassword.into());
}

// update password hash if modified
if verify_result.modified {
let affected_rows = diesel::update(users::table)
.filter(users::name.eq(username.as_ref()))
.set(users::password_hash.eq(Option::<String>::from(password_hash)))
.execute(database_connection)
.await?;

if affected_rows != 1 {
unreachable!(
"Updated exactly one existing row, but {affected_rows} were affected"
);
}
// update login info
let username = user_login_info.name.clone();
let affected_rows = diesel::update(users::table)
.set(user_login_info)
.filter(users::name.eq(username.as_ref()))
.execute(database_connection)
.await?;

if affected_rows != 1 {
unreachable!(
"Updated exactly one existing row, but {affected_rows} were affected"
);
}

Ok(())
Expand Down
1 change: 1 addition & 0 deletions backend/rvoc-backend/src/web/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ impl UserError {
UserError::UsernameDoesNotExist { .. } => StatusCode::BAD_REQUEST,
UserError::InvalidUsernamePassword => StatusCode::BAD_REQUEST,
UserError::UserHasNoPassword => StatusCode::BAD_REQUEST,
UserError::UserLoginRateLimitReached => StatusCode::TOO_MANY_REQUESTS,
}
}
}
Expand Down
7 changes: 2 additions & 5 deletions backend/rvoc-backend/src/web/user.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{
error::{RVocError, RVocResult, UserError},
model::user::{password_hash::PasswordHash, username::Username, User},
model::user::{password_hash::PasswordHash, username::Username, NewUser},
};
use api_commands::CreateAccount;
use axum::{http::StatusCode, Extension, Json};
Expand All @@ -21,10 +21,7 @@ pub async fn create_account(
let CreateAccount { username, password } = create_account;
let username = Username::new(username, &configuration)?;

let user = User {
name: username,
password_hash: PasswordHash::new(password, &configuration)?,
};
let user = NewUser::new(username, PasswordHash::new(password, &configuration)?);

database_connection_pool
.execute_transaction::<_, RVocError>(
Expand Down

0 comments on commit 702bdb5

Please sign in to comment.