Skip to content

Commit 689d710

Browse files
🔒️ Ensure authentication takes constant time, to avoid enumeration attacks (#2105)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 7107f7e commit 689d710

File tree

3 files changed

+31
-19
lines changed

3 files changed

+31
-19
lines changed

‎backend/app/api/routes/login.py‎

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,21 @@ def recover_password(email: str, session: SessionDep) -> Message:
5757
"""
5858
user = crud.get_user_by_email(session=session, email=email)
5959

60-
if not user:
61-
raise HTTPException(
62-
status_code=404,
63-
detail="The user with this email does not exist in the system.",
60+
# Always return the same response to prevent email enumeration attacks
61+
# Only send email if user actually exists
62+
if user:
63+
password_reset_token = generate_password_reset_token(email=email)
64+
email_data = generate_reset_password_email(
65+
email_to=user.email, email=email, token=password_reset_token
6466
)
65-
password_reset_token = generate_password_reset_token(email=email)
66-
email_data = generate_reset_password_email(
67-
email_to=user.email, email=email, token=password_reset_token
68-
)
69-
send_email(
70-
email_to=user.email,
71-
subject=email_data.subject,
72-
html_content=email_data.html_content,
67+
send_email(
68+
email_to=user.email,
69+
subject=email_data.subject,
70+
html_content=email_data.html_content,
71+
)
72+
return Message(
73+
message="If that email is registered, we sent a password recovery link"
7374
)
74-
return Message(message="Password recovery email sent")
7575

7676

7777
@router.post("/reset-password/")
@@ -84,10 +84,8 @@ def reset_password(session: SessionDep, body: NewPassword) -> Message:
8484
raise HTTPException(status_code=400, detail="Invalid token")
8585
user = crud.get_user_by_email(session=session, email=email)
8686
if not user:
87-
raise HTTPException(
88-
status_code=404,
89-
detail="The user with this email does not exist in the system.",
90-
)
87+
# Don't reveal that the user doesn't exist - use same error as invalid token
88+
raise HTTPException(status_code=400, detail="Invalid token")
9189
elif not user.is_active:
9290
raise HTTPException(status_code=400, detail="Inactive user")
9391
user_in_update = UserUpdate(password=body.new_password)

‎backend/app/crud.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,17 @@ def get_user_by_email(*, session: Session, email: str) -> User | None:
3737
return session_user
3838

3939

40+
# Dummy hash to use for timing attack prevention when user is not found
41+
# This is an Argon2 hash of a random password, used to ensure constant-time comparison
42+
DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=4$MjQyZWE1MzBjYjJlZTI0Yw$YTU4NGM5ZTZmYjE2NzZlZjY0ZWY3ZGRkY2U2OWFjNjk"
43+
44+
4045
def authenticate(*, session: Session, email: str, password: str) -> User | None:
4146
db_user = get_user_by_email(session=session, email=email)
4247
if not db_user:
48+
# Prevent timing attacks by running password verification even when user doesn't exist
49+
# This ensures the response time is similar whether or not the email exists
50+
verify_password(password, DUMMY_HASH)
4351
return None
4452
verified, updated_password_hash = verify_password(password, db_user.hashed_password)
4553
if not verified:

‎backend/tests/api/routes/test_login.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ def test_recovery_password(
5959
headers=normal_user_token_headers,
6060
)
6161
assert r.status_code == 200
62-
assert r.json() == {"message": "Password recovery email sent"}
62+
assert r.json() == {
63+
"message": "If that email is registered, we sent a password recovery link"
64+
}
6365

6466

6567
def test_recovery_password_user_not_exits(
@@ -70,7 +72,11 @@ def test_recovery_password_user_not_exits(
7072
f"{settings.API_V1_STR}/password-recovery/{email}",
7173
headers=normal_user_token_headers,
7274
)
73-
assert r.status_code == 404
75+
# Should return 200 with generic message to prevent email enumeration attacks
76+
assert r.status_code == 200
77+
assert r.json() == {
78+
"message": "If that email is registered, we sent a password recovery link"
79+
}
7480

7581

7682
def test_reset_password(client: TestClient, db: Session) -> None:

0 commit comments

Comments
 (0)