feat(labrinth): ignore email case differences in password recovery flow (#3771)

* feat(labrinth): ignore email case differences in password recovery flow

* chore(labrinth): run `sqlx prepare`
This commit is contained in:
Alejandro González 2025-06-11 23:59:21 +02:00 committed by GitHub
parent a2e323c9ee
commit ee8ee7af82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 133 additions and 48 deletions

View File

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id FROM users\n WHERE LOWER(email) = LOWER($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "889a4f79b7031436b3ed31d1005dc9b378ca9c97a128366cae97649503d5dfdf"
}

View File

@ -0,0 +1 @@
CREATE INDEX users_lowercase_email ON users (LOWER(email));

View File

@ -224,24 +224,46 @@ impl DBUser {
Ok(val) Ok(val)
} }
pub async fn get_email<'a, E>( pub async fn get_by_email<'a, E>(
email: &str, email: &str,
exec: E, exec: E,
) -> Result<Option<DBUserId>, sqlx::Error> ) -> Result<Option<DBUserId>, sqlx::Error>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{ {
let user_pass = sqlx::query!( let user = sqlx::query!(
" "
SELECT id FROM users SELECT id FROM users
WHERE email = $1 WHERE email = $1
", ",
email email
) )
.map(|row| DBUserId(row.id))
.fetch_optional(exec) .fetch_optional(exec)
.await?; .await?;
Ok(user_pass.map(|x| DBUserId(x.id))) Ok(user)
}
pub async fn get_by_case_insensitive_email<'a, E>(
email: &str,
exec: E,
) -> Result<Vec<DBUserId>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let users = sqlx::query!(
"
SELECT id FROM users
WHERE LOWER(email) = LOWER($1)
",
email
)
.map(|row| DBUserId(row.id))
.fetch_all(exec)
.await?;
Ok(users)
} }
pub async fn get_projects<'a, E>( pub async fn get_projects<'a, E>(

View File

@ -1,6 +1,7 @@
use crate::auth::email::send_email; use crate::auth::email::send_email;
use crate::auth::validate::get_user_record_from_bearer_token; use crate::auth::validate::get_user_record_from_bearer_token;
use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers}; use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers};
use crate::database::models::DBUser;
use crate::database::models::flow_item::DBFlow; use crate::database::models::flow_item::DBFlow;
use crate::database::redis::RedisPool; use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost; use crate::file_hosting::FileHost;
@ -76,7 +77,7 @@ impl TempUser {
redis: &RedisPool, redis: &RedisPool,
) -> Result<crate::database::models::DBUserId, AuthenticationError> { ) -> Result<crate::database::models::DBUserId, AuthenticationError> {
if let Some(email) = &self.email { if let Some(email) = &self.email {
if crate::database::models::DBUser::get_email(email, client) if crate::database::models::DBUser::get_by_email(email, client)
.await? .await?
.is_some() .is_some()
{ {
@ -1385,9 +1386,12 @@ pub async fn create_account_with_password(
.hash_password(new_account.password.as_bytes(), &salt)? .hash_password(new_account.password.as_bytes(), &salt)?
.to_string(); .to_string();
if crate::database::models::DBUser::get_email(&new_account.email, &**pool) if crate::database::models::DBUser::get_by_email(
.await? &new_account.email,
.is_some() &**pool,
)
.await?
.is_some()
{ {
return Err(ApiError::InvalidInput( return Err(ApiError::InvalidInput(
"Email is already registered on Modrinth!".to_string(), "Email is already registered on Modrinth!".to_string(),
@ -1450,7 +1454,8 @@ pub async fn create_account_with_password(
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
pub struct Login { pub struct Login {
pub username: String, #[serde(rename = "username")]
pub username_or_email: String,
pub password: String, pub password: String,
pub challenge: String, pub challenge: String,
} }
@ -1466,14 +1471,17 @@ pub async fn login_password(
return Err(ApiError::Turnstile); return Err(ApiError::Turnstile);
} }
let user = if let Some(user) = let user = if let Some(user) = crate::database::models::DBUser::get(
crate::database::models::DBUser::get(&login.username, &**pool, &redis) &login.username_or_email,
.await? &**pool,
&redis,
)
.await?
{ {
user user
} else { } else {
let user = crate::database::models::DBUser::get_email( let user = crate::database::models::DBUser::get_by_email(
&login.username, &login.username_or_email,
&**pool, &**pool,
) )
.await? .await?
@ -1903,7 +1911,8 @@ pub async fn remove_2fa(
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ResetPassword { pub struct ResetPassword {
pub username: String, #[serde(rename = "username")]
pub username_or_email: String,
pub challenge: String, pub challenge: String,
} }
@ -1918,46 +1927,77 @@ pub async fn reset_password_begin(
return Err(ApiError::Turnstile); return Err(ApiError::Turnstile);
} }
let user = if let Some(user_id) = let user =
crate::database::models::DBUser::get_email( match crate::database::models::DBUser::get_by_case_insensitive_email(
&reset_password.username, &reset_password.username_or_email,
&**pool, &**pool,
) )
.await? .await?[..]
{ {
crate::database::models::DBUser::get_id(user_id, &**pool, &redis) [] => {
.await? // Try finding by username or ID
} else { crate::database::models::DBUser::get(
crate::database::models::DBUser::get( &reset_password.username_or_email,
&reset_password.username, &**pool,
&**pool, &redis,
&redis, )
) .await?
.await? }
}; [user_id] => {
// If there is only one user with the given email, ignoring case,
// we can assume it's the user we want to reset the password for
crate::database::models::DBUser::get_id(
user_id, &**pool, &redis,
)
.await?
}
_ => {
// When several users use variations of the same email with
// different cases, we cannot reliably tell which user should
// receive the password reset email, so fall back to case sensitive
// search to avoid spamming multiple users
if let Some(user_id) =
crate::database::models::DBUser::get_by_email(
&reset_password.username_or_email,
&**pool,
)
.await?
{
crate::database::models::DBUser::get_id(
user_id, &**pool, &redis,
)
.await?
} else {
None
}
}
};
if let Some(user) = user { if let Some(DBUser {
let flow = DBFlow::ForgotPassword { user_id: user.id } id: user_id,
email: Some(email),
..
}) = user
{
let flow = DBFlow::ForgotPassword { user_id }
.insert(Duration::hours(24), &redis) .insert(Duration::hours(24), &redis)
.await?; .await?;
if let Some(email) = user.email { send_email(
send_email( email,
email, "Reset your password",
"Reset your password", "Please visit the following link below to reset your password. If the button does not work, you can copy the link and paste it into your browser.",
"Please visit the following link below to reset your password. If the button does not work, you can copy the link and paste it into your browser.", "If you did not request for your password to be reset, you can safely ignore this email.",
"If you did not request for your password to be reset, you can safely ignore this email.", Some((
Some(( "Reset password",
"Reset password", &format!(
&format!( "{}/{}?flow={}",
"{}/{}?flow={}", dotenvy::var("SITE_URL")?,
dotenvy::var("SITE_URL")?, dotenvy::var("SITE_RESET_PASSWORD_PATH")?,
dotenvy::var("SITE_RESET_PASSWORD_PATH")?, flow
flow ),
), )),
)), )?;
)?;
}
} }
Ok(HttpResponse::Ok().finish()) Ok(HttpResponse::Ok().finish())