Skip to content

Commit 730c6e9

Browse files
✨ Use pwdlib with Argon2 by default, adding logic (and tests) to autoupdate old passwords using Bcrypt (#2104)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent a0fe8a2 commit 730c6e9

File tree

8 files changed

+304
-47
lines changed

8 files changed

+304
-47
lines changed

backend/app/api/routes/users.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ def update_password_me(
104104
"""
105105
Update own password.
106106
"""
107-
if not verify_password(body.current_password, current_user.hashed_password):
107+
verified, _ = verify_password(body.current_password, current_user.hashed_password)
108+
if not verified:
108109
raise HTTPException(status_code=400, detail="Incorrect password")
109110
if body.current_password == body.new_password:
110111
raise HTTPException(

backend/app/core/security.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@
22
from typing import Any
33

44
import jwt
5-
from passlib.context import CryptContext
5+
from pwdlib import PasswordHash
6+
from pwdlib.hashers.argon2 import Argon2Hasher
7+
from pwdlib.hashers.bcrypt import BcryptHasher
68

79
from app.core.config import settings
810

9-
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
11+
password_hash = PasswordHash(
12+
(
13+
Argon2Hasher(),
14+
BcryptHasher(),
15+
)
16+
)
1017

1118

1219
ALGORITHM = "HS256"
@@ -19,9 +26,11 @@ def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
1926
return encoded_jwt
2027

2128

22-
def verify_password(plain_password: str, hashed_password: str) -> bool:
23-
return pwd_context.verify(plain_password, hashed_password)
29+
def verify_password(
30+
plain_password: str, hashed_password: str
31+
) -> tuple[bool, str | None]:
32+
return password_hash.verify_and_update(plain_password, hashed_password)
2433

2534

2635
def get_password_hash(password: str) -> str:
27-
return pwd_context.hash(password)
36+
return password_hash.hash(password)

backend/app/crud.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,14 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None:
4141
db_user = get_user_by_email(session=session, email=email)
4242
if not db_user:
4343
return None
44-
if not verify_password(password, db_user.hashed_password):
44+
verified, updated_password_hash = verify_password(password, db_user.hashed_password)
45+
if not verified:
4546
return None
47+
if updated_password_hash:
48+
db_user.hashed_password = updated_password_hash
49+
session.add(db_user)
50+
session.commit()
51+
session.refresh(db_user)
4652
return db_user
4753

4854

backend/pyproject.toml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ dependencies = [
77
"fastapi[standard]<1.0.0,>=0.114.2",
88
"python-multipart<1.0.0,>=0.0.7",
99
"email-validator<3.0.0.0,>=2.1.0.post1",
10-
"passlib[bcrypt]<2.0.0,>=1.7.4",
1110
"tenacity<9.0.0,>=8.2.3",
1211
"pydantic>2.0",
1312
"emails<1.0,>=0.6",
@@ -16,11 +15,10 @@ dependencies = [
1615
"httpx<1.0.0,>=0.25.1",
1716
"psycopg[binary]<4.0.0,>=3.1.13",
1817
"sqlmodel<1.0.0,>=0.0.21",
19-
# Pin bcrypt until passlib supports the latest
20-
"bcrypt==4.3.0",
2118
"pydantic-settings<3.0.0,>=2.2.1",
2219
"sentry-sdk[fastapi]<2.0.0,>=1.40.6",
2320
"pyjwt<3.0.0,>=2.8.0",
21+
"pwdlib[argon2,bcrypt]>=0.3.0",
2422
]
2523

2624
[dependency-groups]
@@ -29,7 +27,6 @@ dev = [
2927
"mypy<2.0.0,>=1.8.0",
3028
"ruff<1.0.0,>=0.2.2",
3129
"prek>=0.2.24,<1.0.0",
32-
"types-passlib<2.0.0.0,>=1.7.7.20240106",
3330
"coverage<8.0.0,>=7.4.3",
3431
]
3532

backend/tests/api/routes/test_login.py

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from unittest.mock import patch
22

33
from fastapi.testclient import TestClient
4+
from pwdlib.hashers.bcrypt import BcryptHasher
45
from sqlmodel import Session
56

67
from app.core.config import settings
7-
from app.core.security import verify_password
8+
from app.core.security import get_password_hash, verify_password
89
from app.crud import create_user
9-
from app.models import UserCreate
10+
from app.models import User, UserCreate
1011
from app.utils import generate_password_reset_token
1112
from tests.utils.user import user_authentication_headers
1213
from tests.utils.utils import random_email, random_lower_string
@@ -99,7 +100,8 @@ def test_reset_password(client: TestClient, db: Session) -> None:
99100
assert r.json() == {"message": "Password updated successfully"}
100101

101102
db.refresh(user)
102-
assert verify_password(new_password, user.hashed_password)
103+
verified, _ = verify_password(new_password, user.hashed_password)
104+
assert verified
103105

104106

105107
def test_reset_password_invalid_token(
@@ -116,3 +118,68 @@ def test_reset_password_invalid_token(
116118
assert "detail" in response
117119
assert r.status_code == 400
118120
assert response["detail"] == "Invalid token"
121+
122+
123+
def test_login_with_bcrypt_password_upgrades_to_argon2(
124+
client: TestClient, db: Session
125+
) -> None:
126+
"""Test that logging in with a bcrypt password hash upgrades it to argon2."""
127+
email = random_email()
128+
password = random_lower_string()
129+
130+
# Create a bcrypt hash directly (simulating legacy password)
131+
bcrypt_hasher = BcryptHasher()
132+
bcrypt_hash = bcrypt_hasher.hash(password)
133+
assert bcrypt_hash.startswith("$2") # bcrypt hashes start with $2
134+
135+
user = User(email=email, hashed_password=bcrypt_hash, is_active=True)
136+
db.add(user)
137+
db.commit()
138+
db.refresh(user)
139+
140+
assert user.hashed_password.startswith("$2")
141+
142+
login_data = {"username": email, "password": password}
143+
r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
144+
assert r.status_code == 200
145+
tokens = r.json()
146+
assert "access_token" in tokens
147+
148+
db.refresh(user)
149+
150+
# Verify the hash was upgraded to argon2
151+
assert user.hashed_password.startswith("$argon2")
152+
153+
verified, updated_hash = verify_password(password, user.hashed_password)
154+
assert verified
155+
# Should not need another update since it's already argon2
156+
assert updated_hash is None
157+
158+
159+
def test_login_with_argon2_password_keeps_hash(client: TestClient, db: Session) -> None:
160+
"""Test that logging in with an argon2 password hash does not update it."""
161+
email = random_email()
162+
password = random_lower_string()
163+
164+
# Create an argon2 hash (current default)
165+
argon2_hash = get_password_hash(password)
166+
assert argon2_hash.startswith("$argon2")
167+
168+
# Create user with argon2 hash
169+
user = User(email=email, hashed_password=argon2_hash, is_active=True)
170+
db.add(user)
171+
db.commit()
172+
db.refresh(user)
173+
174+
original_hash = user.hashed_password
175+
176+
login_data = {"username": email, "password": password}
177+
r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
178+
assert r.status_code == 200
179+
tokens = r.json()
180+
assert "access_token" in tokens
181+
182+
db.refresh(user)
183+
184+
assert user.hashed_password == original_hash
185+
assert user.hashed_password.startswith("$argon2")

backend/tests/api/routes/test_users.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,8 @@ def test_update_password_me(
242242
user_db = db.exec(user_query).first()
243243
assert user_db
244244
assert user_db.email == settings.FIRST_SUPERUSER
245-
assert verify_password(new_password, user_db.hashed_password)
245+
verified, _ = verify_password(new_password, user_db.hashed_password)
246+
assert verified
246247

247248
# Revert to the old password to keep consistency in test
248249
old_data = {
@@ -257,7 +258,10 @@ def test_update_password_me(
257258
db.refresh(user_db)
258259

259260
assert r.status_code == 200
260-
assert verify_password(settings.FIRST_SUPERUSER_PASSWORD, user_db.hashed_password)
261+
verified, _ = verify_password(
262+
settings.FIRST_SUPERUSER_PASSWORD, user_db.hashed_password
263+
)
264+
assert verified
261265

262266

263267
def test_update_password_me_incorrect_password(
@@ -331,7 +335,8 @@ def test_register_user(client: TestClient, db: Session) -> None:
331335
assert user_db
332336
assert user_db.email == username
333337
assert user_db.full_name == full_name
334-
assert verify_password(password, user_db.hashed_password)
338+
verified, _ = verify_password(password, user_db.hashed_password)
339+
assert verified
335340

336341

337342
def test_register_user_already_exists_error(client: TestClient) -> None:

backend/tests/crud/test_user.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from fastapi.encoders import jsonable_encoder
2+
from pwdlib.hashers.bcrypt import BcryptHasher
23
from sqlmodel import Session
34

45
from app import crud
@@ -88,4 +89,42 @@ def test_update_user(db: Session) -> None:
8889
user_2 = db.get(User, user.id)
8990
assert user_2
9091
assert user.email == user_2.email
91-
assert verify_password(new_password, user_2.hashed_password)
92+
verified, _ = verify_password(new_password, user_2.hashed_password)
93+
assert verified
94+
95+
96+
def test_authenticate_user_with_bcrypt_upgrades_to_argon2(db: Session) -> None:
97+
"""Test that a user with bcrypt password hash gets upgraded to argon2 on login."""
98+
email = random_email()
99+
password = random_lower_string()
100+
101+
# Create a bcrypt hash directly (simulating legacy password)
102+
bcrypt_hasher = BcryptHasher()
103+
bcrypt_hash = bcrypt_hasher.hash(password)
104+
assert bcrypt_hash.startswith("$2") # bcrypt hashes start with $2
105+
106+
# Create user with bcrypt hash directly in the database
107+
user = User(email=email, hashed_password=bcrypt_hash)
108+
db.add(user)
109+
db.commit()
110+
db.refresh(user)
111+
112+
# Verify the hash is bcrypt before authentication
113+
assert user.hashed_password.startswith("$2")
114+
115+
# Authenticate - this should upgrade the hash to argon2
116+
authenticated_user = crud.authenticate(session=db, email=email, password=password)
117+
assert authenticated_user
118+
assert authenticated_user.email == email
119+
120+
db.refresh(authenticated_user)
121+
122+
# Verify the hash was upgraded to argon2
123+
assert authenticated_user.hashed_password.startswith("$argon2")
124+
125+
verified, updated_hash = verify_password(
126+
password, authenticated_user.hashed_password
127+
)
128+
assert verified
129+
# Should not need another update since it's already argon2
130+
assert updated_hash is None

0 commit comments

Comments
 (0)