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)
}
pub async fn get_email<'a, E>(
pub async fn get_by_email<'a, E>(
email: &str,
exec: E,
) -> Result<Option<DBUserId>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let user_pass = sqlx::query!(
let user = sqlx::query!(
"
SELECT id FROM users
WHERE email = $1
",
email
)
.map(|row| DBUserId(row.id))
.fetch_optional(exec)
.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>(

View File

@ -1,6 +1,7 @@
use crate::auth::email::send_email;
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers};
use crate::database::models::DBUser;
use crate::database::models::flow_item::DBFlow;
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
@ -76,7 +77,7 @@ impl TempUser {
redis: &RedisPool,
) -> Result<crate::database::models::DBUserId, AuthenticationError> {
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?
.is_some()
{
@ -1385,7 +1386,10 @@ pub async fn create_account_with_password(
.hash_password(new_account.password.as_bytes(), &salt)?
.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?
.is_some()
{
@ -1450,7 +1454,8 @@ pub async fn create_account_with_password(
#[derive(Deserialize, Validate)]
pub struct Login {
pub username: String,
#[serde(rename = "username")]
pub username_or_email: String,
pub password: String,
pub challenge: String,
}
@ -1466,14 +1471,17 @@ pub async fn login_password(
return Err(ApiError::Turnstile);
}
let user = if let Some(user) =
crate::database::models::DBUser::get(&login.username, &**pool, &redis)
let user = if let Some(user) = crate::database::models::DBUser::get(
&login.username_or_email,
&**pool,
&redis,
)
.await?
{
user
} else {
let user = crate::database::models::DBUser::get_email(
&login.username,
let user = crate::database::models::DBUser::get_by_email(
&login.username_or_email,
&**pool,
)
.await?
@ -1903,7 +1911,8 @@ pub async fn remove_2fa(
#[derive(Deserialize)]
pub struct ResetPassword {
pub username: String,
#[serde(rename = "username")]
pub username_or_email: String,
pub challenge: String,
}
@ -1918,30 +1927,62 @@ pub async fn reset_password_begin(
return Err(ApiError::Turnstile);
}
let user = if let Some(user_id) =
crate::database::models::DBUser::get_email(
&reset_password.username,
let user =
match crate::database::models::DBUser::get_by_case_insensitive_email(
&reset_password.username_or_email,
&**pool,
)
.await?
.await?[..]
{
crate::database::models::DBUser::get_id(user_id, &**pool, &redis)
.await?
} else {
[] => {
// Try finding by username or ID
crate::database::models::DBUser::get(
&reset_password.username,
&reset_password.username_or_email,
&**pool,
&redis,
)
.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 {
let flow = DBFlow::ForgotPassword { user_id: user.id }
if let Some(DBUser {
id: user_id,
email: Some(email),
..
}) = user
{
let flow = DBFlow::ForgotPassword { user_id }
.insert(Duration::hours(24), &redis)
.await?;
if let Some(email) = user.email {
send_email(
email,
"Reset your password",
@ -1958,7 +1999,6 @@ pub async fn reset_password_begin(
)),
)?;
}
}
Ok(HttpResponse::Ok().finish())
}