Skip to content

Commit ee8ee7a

Browse files
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`
1 parent a2e323c commit ee8ee7a

File tree

4 files changed

+133
-48
lines changed

4 files changed

+133
-48
lines changed

apps/labrinth/.sqlx/query-889a4f79b7031436b3ed31d1005dc9b378ca9c97a128366cae97649503d5dfdf.json

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
CREATE INDEX users_lowercase_email ON users (LOWER(email));

apps/labrinth/src/database/models/user_item.rs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,24 +224,46 @@ impl DBUser {
224224
Ok(val)
225225
}
226226

227-
pub async fn get_email<'a, E>(
227+
pub async fn get_by_email<'a, E>(
228228
email: &str,
229229
exec: E,
230230
) -> Result<Option<DBUserId>, sqlx::Error>
231231
where
232232
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
233233
{
234-
let user_pass = sqlx::query!(
234+
let user = sqlx::query!(
235235
"
236236
SELECT id FROM users
237237
WHERE email = $1
238238
",
239239
email
240240
)
241+
.map(|row| DBUserId(row.id))
241242
.fetch_optional(exec)
242243
.await?;
243244

244-
Ok(user_pass.map(|x| DBUserId(x.id)))
245+
Ok(user)
246+
}
247+
248+
pub async fn get_by_case_insensitive_email<'a, E>(
249+
email: &str,
250+
exec: E,
251+
) -> Result<Vec<DBUserId>, sqlx::Error>
252+
where
253+
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
254+
{
255+
let users = sqlx::query!(
256+
"
257+
SELECT id FROM users
258+
WHERE LOWER(email) = LOWER($1)
259+
",
260+
email
261+
)
262+
.map(|row| DBUserId(row.id))
263+
.fetch_all(exec)
264+
.await?;
265+
266+
Ok(users)
245267
}
246268

247269
pub async fn get_projects<'a, E>(

apps/labrinth/src/routes/internal/flows.rs

Lines changed: 85 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::auth::email::send_email;
22
use crate::auth::validate::get_user_record_from_bearer_token;
33
use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers};
4+
use crate::database::models::DBUser;
45
use crate::database::models::flow_item::DBFlow;
56
use crate::database::redis::RedisPool;
67
use crate::file_hosting::FileHost;
@@ -76,7 +77,7 @@ impl TempUser {
7677
redis: &RedisPool,
7778
) -> Result<crate::database::models::DBUserId, AuthenticationError> {
7879
if let Some(email) = &self.email {
79-
if crate::database::models::DBUser::get_email(email, client)
80+
if crate::database::models::DBUser::get_by_email(email, client)
8081
.await?
8182
.is_some()
8283
{
@@ -1385,9 +1386,12 @@ pub async fn create_account_with_password(
13851386
.hash_password(new_account.password.as_bytes(), &salt)?
13861387
.to_string();
13871388

1388-
if crate::database::models::DBUser::get_email(&new_account.email, &**pool)
1389-
.await?
1390-
.is_some()
1389+
if crate::database::models::DBUser::get_by_email(
1390+
&new_account.email,
1391+
&**pool,
1392+
)
1393+
.await?
1394+
.is_some()
13911395
{
13921396
return Err(ApiError::InvalidInput(
13931397
"Email is already registered on Modrinth!".to_string(),
@@ -1450,7 +1454,8 @@ pub async fn create_account_with_password(
14501454

14511455
#[derive(Deserialize, Validate)]
14521456
pub struct Login {
1453-
pub username: String,
1457+
#[serde(rename = "username")]
1458+
pub username_or_email: String,
14541459
pub password: String,
14551460
pub challenge: String,
14561461
}
@@ -1466,14 +1471,17 @@ pub async fn login_password(
14661471
return Err(ApiError::Turnstile);
14671472
}
14681473

1469-
let user = if let Some(user) =
1470-
crate::database::models::DBUser::get(&login.username, &**pool, &redis)
1471-
.await?
1474+
let user = if let Some(user) = crate::database::models::DBUser::get(
1475+
&login.username_or_email,
1476+
&**pool,
1477+
&redis,
1478+
)
1479+
.await?
14721480
{
14731481
user
14741482
} else {
1475-
let user = crate::database::models::DBUser::get_email(
1476-
&login.username,
1483+
let user = crate::database::models::DBUser::get_by_email(
1484+
&login.username_or_email,
14771485
&**pool,
14781486
)
14791487
.await?
@@ -1903,7 +1911,8 @@ pub async fn remove_2fa(
19031911

19041912
#[derive(Deserialize)]
19051913
pub struct ResetPassword {
1906-
pub username: String,
1914+
#[serde(rename = "username")]
1915+
pub username_or_email: String,
19071916
pub challenge: String,
19081917
}
19091918

@@ -1918,46 +1927,77 @@ pub async fn reset_password_begin(
19181927
return Err(ApiError::Turnstile);
19191928
}
19201929

1921-
let user = if let Some(user_id) =
1922-
crate::database::models::DBUser::get_email(
1923-
&reset_password.username,
1930+
let user =
1931+
match crate::database::models::DBUser::get_by_case_insensitive_email(
1932+
&reset_password.username_or_email,
19241933
&**pool,
19251934
)
1926-
.await?
1927-
{
1928-
crate::database::models::DBUser::get_id(user_id, &**pool, &redis)
1929-
.await?
1930-
} else {
1931-
crate::database::models::DBUser::get(
1932-
&reset_password.username,
1933-
&**pool,
1934-
&redis,
1935-
)
1936-
.await?
1937-
};
1935+
.await?[..]
1936+
{
1937+
[] => {
1938+
// Try finding by username or ID
1939+
crate::database::models::DBUser::get(
1940+
&reset_password.username_or_email,
1941+
&**pool,
1942+
&redis,
1943+
)
1944+
.await?
1945+
}
1946+
[user_id] => {
1947+
// If there is only one user with the given email, ignoring case,
1948+
// we can assume it's the user we want to reset the password for
1949+
crate::database::models::DBUser::get_id(
1950+
user_id, &**pool, &redis,
1951+
)
1952+
.await?
1953+
}
1954+
_ => {
1955+
// When several users use variations of the same email with
1956+
// different cases, we cannot reliably tell which user should
1957+
// receive the password reset email, so fall back to case sensitive
1958+
// search to avoid spamming multiple users
1959+
if let Some(user_id) =
1960+
crate::database::models::DBUser::get_by_email(
1961+
&reset_password.username_or_email,
1962+
&**pool,
1963+
)
1964+
.await?
1965+
{
1966+
crate::database::models::DBUser::get_id(
1967+
user_id, &**pool, &redis,
1968+
)
1969+
.await?
1970+
} else {
1971+
None
1972+
}
1973+
}
1974+
};
19381975

1939-
if let Some(user) = user {
1940-
let flow = DBFlow::ForgotPassword { user_id: user.id }
1976+
if let Some(DBUser {
1977+
id: user_id,
1978+
email: Some(email),
1979+
..
1980+
}) = user
1981+
{
1982+
let flow = DBFlow::ForgotPassword { user_id }
19411983
.insert(Duration::hours(24), &redis)
19421984
.await?;
19431985

1944-
if let Some(email) = user.email {
1945-
send_email(
1946-
email,
1947-
"Reset your password",
1948-
"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.",
1949-
"If you did not request for your password to be reset, you can safely ignore this email.",
1950-
Some((
1951-
"Reset password",
1952-
&format!(
1953-
"{}/{}?flow={}",
1954-
dotenvy::var("SITE_URL")?,
1955-
dotenvy::var("SITE_RESET_PASSWORD_PATH")?,
1956-
flow
1957-
),
1958-
)),
1959-
)?;
1960-
}
1986+
send_email(
1987+
email,
1988+
"Reset your password",
1989+
"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.",
1990+
"If you did not request for your password to be reset, you can safely ignore this email.",
1991+
Some((
1992+
"Reset password",
1993+
&format!(
1994+
"{}/{}?flow={}",
1995+
dotenvy::var("SITE_URL")?,
1996+
dotenvy::var("SITE_RESET_PASSWORD_PATH")?,
1997+
flow
1998+
),
1999+
)),
2000+
)?;
19612001
}
19622002

19632003
Ok(HttpResponse::Ok().finish())

0 commit comments

Comments
 (0)