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,7 +1386,10 @@ 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(
&new_account.email,
&**pool,
)
.await? .await?
.is_some() .is_some()
{ {
@ -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,
&**pool,
&redis,
)
.await? .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,30 +1927,62 @@ 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, &reset_password.username_or_email,
&**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",
@ -1958,7 +1999,6 @@ pub async fn reset_password_begin(
)), )),
)?; )?;
} }
}
Ok(HttpResponse::Ok().finish()) Ok(HttpResponse::Ok().finish())
} }