Skip to content

Commit 05eec31

Browse files
dependabot[bot]Mateo Lostanlen
andauthored
fix: drop passlib, hash with bcrypt directly (#595)
passlib 1.7.4 imports bcrypt.__about__ at module load to detect the bcrypt version. That attribute was removed in bcrypt 4.x, so once the dependabot bcrypt 5.0.0 bump lands, passlib raises AttributeError on import and the backend fails its health check at startup. passlib is effectively unmaintained (last release 2020). Rather than work around the import, replace the CryptContext usage in src/app/core/security.py with direct bcrypt calls — the API surface we use is just hashpw/checkpw plus a salt. To stay drop-in compatible with hashes generated by the previous passlib-based code, hash_password / verify_password silently truncate the password to 72 bytes (bcrypt's hard limit, which passlib used to swallow) and verify_password catches the ValueError that bcrypt 5.x now raises on malformed stored hashes, returning False instead — so a corrupt DB row produces 401, not 500. Both behaviours are covered by new regression tests in src/tests/test_security.py. Co-authored-by: Mateo Lostanlen <mateo@pyronear.org>
1 parent bf42eed commit 05eec31

4 files changed

Lines changed: 100 additions & 126 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ server = [
2222
"pydantic-settings>=2.0.0,<3.0.0",
2323
"requests>=2.32.0,<3.0.0",
2424
"PyJWT>=2.12.0,<3.0.0",
25-
"passlib[bcrypt]>=1.7.4,<2.0.0",
26-
"bcrypt==3.1.7",
25+
"bcrypt==5.0.0",
2726
"uvicorn>=0.11.1,<1.0.0",
2827
"asyncpg>=0.25.0,<1.0.0",
2928
"alembic>=1.8.1,<2.0.0",
@@ -49,7 +48,6 @@ quality = [
4948
"types-requests>=2.33.0.20260513",
5049
"types-python-dateutil>=2.8.0,<3.0.0",
5150
"sqlalchemy-stubs>=0.4,<0.5",
52-
"types-passlib>=1.7.0",
5351
]
5452
test = [
5553
"pytest==9.0.3",

src/app/core/security.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,14 @@
66
from datetime import timedelta
77
from typing import Any, Dict, Optional
88

9+
import bcrypt
910
import jwt
10-
from passlib.context import CryptContext
1111

1212
from app.core.config import settings
1313
from app.core.time import utcnow
1414

1515
__all__ = ["create_access_token", "hash_password", "verify_password"]
1616

17-
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
18-
1917

2018
def create_access_token(content: Dict[str, Any], expires_minutes: Optional[int] = None) -> str:
2119
"""Encode content dict using security algorithm, setting expiration."""
@@ -24,9 +22,21 @@ def create_access_token(content: Dict[str, Any], expires_minutes: Optional[int]
2422
return jwt.encode({**content, "exp": expire}, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
2523

2624

25+
def _encode_password(password: str) -> bytes:
26+
# bcrypt only hashes the first 72 bytes of the password. passlib's
27+
# CryptContext used to silently truncate; mirror that here so hashes
28+
# generated under the previous implementation keep verifying.
29+
return password.encode("utf-8")[:72]
30+
31+
2732
def verify_password(plain_password: str, hashed_password: str) -> bool:
28-
return pwd_context.verify(plain_password, hashed_password)
33+
try:
34+
return bcrypt.checkpw(_encode_password(plain_password), hashed_password.encode("utf-8"))
35+
except ValueError:
36+
# Malformed stored hash — match the previous CryptContext.verify
37+
# behaviour which returned False rather than raising.
38+
return False
2939

3040

3141
def hash_password(password: str) -> str:
32-
return pwd_context.hash(password)
42+
return bcrypt.hashpw(_encode_password(password), bcrypt.gensalt()).decode("utf-8")

src/tests/test_security.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ def test_verify_password():
2626
assert not verify_password("another_try", hash_pwd1)
2727

2828

29+
def test_password_longer_than_72_bytes_is_truncated():
30+
# bcrypt only hashes the first 72 bytes. The previous passlib
31+
# implementation silently truncated, so we keep the same contract to
32+
# stay backward-compatible with hashes already in the DB.
33+
base = "a" * 72
34+
hashed = hash_password(base)
35+
assert verify_password(base + "extra_ignored_bytes", hashed)
36+
assert not verify_password("a" * 71 + "b", hashed)
37+
38+
39+
def test_verify_password_returns_false_on_malformed_hash():
40+
# bcrypt.checkpw raises ValueError on malformed hashes; passlib used
41+
# to swallow it and return False. We preserve that behaviour so a
42+
# corrupt DB row produces 401, not 500.
43+
assert not verify_password("anything", "not-a-bcrypt-hash")
44+
45+
2946
@pytest.mark.parametrize(
3047
("content", "expires_minutes", "expected_delta"),
3148
[

0 commit comments

Comments
 (0)