@cryptotaxi247 / CoPilot / commits / 00899a94

fix: replace passlib with direct bcrypt — eliminate `(trapped)` warning (#853)

Drops the passlib dependency entirely. passlib has been unmaintained since August 2020 (last release 1.7.4) and doesn't know about bcrypt 4.1+ — it tries to read `bcrypt.__about__.__version__`, gets an AttributeError, traps it internally, and prints this on every login: (trapped) error reading bcrypt version Traceback (most recent call last): File "/opt/venv/lib/python3.11/site-packages/passlib/handlers/bcrypt.py", line 620, in _load_backend_mixin version = _bcrypt.__about__.__version__ ^^^^^^^^^^^^^^^^^ AttributeError: module 'bcrypt' has no attribute '__about__' The warning is cosmetic (passlib falls back to default behavior, auth keeps working — the hotfix in #851 was the actual blocker), but it's log noise on every login. Going forward, every bcrypt release widens the gap and the chance of a behavioral break grows. Fix: replace `passlib.context.CryptContext` with direct bcrypt calls in the 4 places that used it. The hash format is identical (`$2b$NN$...` — passlib's bcrypt scheme writes the same format raw bcrypt does), so existing DB hashes for users and TOTP backup codes continue to verify. Touched: - app/auth/utils.py: AuthHandler.get_password_hash + verify_password now use bcrypt.hashpw / bcrypt.checkpw directly. Drops the pwd_context class attribute. - app/auth/services/totp.py: backup code hash + verify also direct bcrypt. Drops the _pwd_ctx module-level CryptContext. - app/auth/services/sso.py: SSO auto-provisioning random-password generation + hash now direct bcrypt. Reduced secrets.token_urlsafe(64) → token_urlsafe(48) (~64 chars) so the generated random_pw stays under bcrypt's 72-byte limit explicitly rather than relying on silent truncation. - requirements.in: dropped `passlib` and `passlib[bcrypt]`. Updated the bcrypt<5 cap comment (the original reason — passlib incompatibility — is gone; the cap now exists only because bcrypt 5 raises on >72-byte passwords instead of silently truncating, which is a separate concern). Verified locally: - login HTTP 200 - no `(trapped)` warning, no `passlib` reference anywhere in logs - 2FA setup returns 8 backup codes; the new bcrypt-direct path generates the same `$2b$` hash format - format-compatibility check: bcrypt.hashpw produces the exact same `$2b$NN$...` envelope passlib's bcrypt scheme produced, so all pre-existing user passwords and backup codes in production DBs will continue to verify without any migration Follow-up (out of scope here): with passlib gone, the bcrypt<5 cap exists only to dodge bcrypt 5's strict-error-on->72-byte behavior. A separate PR can lift it after enforcing a 72-byte (or pre-hash- with-SHA-256) limit at the model layer for password fields like `max_length=256` in PasswordReset. Co-authored-by: taylor_socfortress <taylor.walton@socfortress.co> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

taylorcopilot committed May 8, 2026 at 10:06 UTC 00899a94308bdbac0cbd41dde9dd0fb1936321a6
5 files changed +15 -20
backend/app/auth/services/sso.py
+5 -5
@@ -143,7 +143,7 @@ async def find_user_by_email(email: str) -> Optional[User]:
143
144 async def get_or_create_sso_user(email: str, role_id: int = 2) -> User:
145 """Find existing user by email or create a new SSO‑managed user."""
146 - from passlib.context import CryptContext
146 + import bcrypt
147
148 async with AsyncSession(async_engine) as session:
149 result = await session.execute(select(User).where(User.email == email))
@@ -151,10 +151,10 @@ async def get_or_create_sso_user(email: str, role_id: int = 2) -> User:
151 if user:
152 return user
153
154 - # Create a new user with a random unusable password
155 - pwd_ctx = CryptContext(schemes=["bcrypt"])
156 - random_pw = secrets.token_urlsafe(64)
157 - hashed = pwd_ctx.hash(random_pw)
154 + # Create a new user with a random unusable password.
155 + # token_urlsafe(48) → 64 chars, safely under bcrypt's 72-byte limit.
156 + random_pw = secrets.token_urlsafe(48)
157 + hashed = bcrypt.hashpw(random_pw.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
158
159 username = email.split("@")[0]
160 # Ensure unique username
backend/app/auth/services/totp.py
+4 -5
@@ -11,11 +11,11 @@ import string
11 import time
12 from typing import Optional
13
14 +import bcrypt
15 import pyotp
16 import qrcode
17 from cryptography.fernet import Fernet
18 from loguru import logger
18 -from passlib.context import CryptContext
19 from sqlalchemy.ext.asyncio import AsyncSession
20 from sqlalchemy.orm.attributes import flag_modified
21 from sqlmodel import select
@@ -54,8 +54,6 @@ except (ValueError, TypeError) as e:
54 f"Failed to initialise TOTP Fernet from derived JWT_SECRET key. (underlying error: {e})",
55 ) from e
56
57 -_pwd_ctx = CryptContext(schemes=["bcrypt"])
58 -
57 # ── Brute-force protection ───────────────────────────────────────────────────
58 # Per-user: {user_id: (fail_count, first_fail_time)}
59 _2fa_attempts: dict[int, tuple[int, float]] = {}
@@ -115,7 +113,8 @@ def _generate_backup_codes() -> tuple[list[str], list[dict]]:
113 for _ in range(_BACKUP_CODE_COUNT):
114 code = "".join(secrets.choice(alphabet) for _ in range(_BACKUP_CODE_LENGTH))
115 codes.append(code)
118 - hashed.append({"hash": _pwd_ctx.hash(code), "used": False})
116 + hashed_code = bcrypt.hashpw(code.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
117 + hashed.append({"hash": hashed_code, "used": False})
118 return codes, hashed
119
120
@@ -124,7 +123,7 @@ def _verify_backup_code(backup_code: str, stored_codes: list[dict]) -> int:
123 for i, entry in enumerate(stored_codes):
124 if entry.get("used"):
125 continue
127 - if _pwd_ctx.verify(backup_code.upper().strip(), entry["hash"]):
126 + if bcrypt.checkpw(backup_code.upper().strip().encode("utf-8"), entry["hash"].encode("utf-8")):
127 return i
128 return -1
129
backend/app/auth/utils.py
+5 -6
@@ -2,13 +2,13 @@ import os
2 from datetime import datetime
3 from datetime import timedelta
4
5 +import bcrypt
6 import jwt
7 from fastapi import Depends
8 from fastapi import HTTPException
9 from fastapi.security import OAuth2PasswordBearer
10 from fastapi.security import SecurityScopes
11 from loguru import logger
11 -from passlib.context import CryptContext
12
13 from app.auth.services.universal import find_user
14 from app.auth.services.universal import get_role
@@ -44,14 +44,13 @@ class AuthHandler:
44 "customer_user": "Customer portal users",
45 },
46 )
47 - pwd_context = CryptContext(schemes=["bcrypt"])
47 secret = _load_jwt_secret()
48
50 - def get_password_hash(self, password):
51 - return self.pwd_context.hash(password)
49 + def get_password_hash(self, password: str) -> str:
50 + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
51
53 - def verify_password(self, plain_password, hashed_password):
54 - return self.pwd_context.verify(plain_password, hashed_password)
52 + def verify_password(self, plain_password: str, hashed_password: str) -> bool:
53 + return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
54
55 # ! TODO: HAVE LOGIC TO HANDLE PASSWORD RESET VIA A TOKEN BUT NOT IMPLEMENTED YET ! #
56 def generate_reset_token(
backend/requirements.in
+1 -3
@@ -5,7 +5,7 @@ aiosqlite
5 alembic
6 apscheduler
7 asyncgelf
8 -bcrypt<5 # passlib 1.7.4 (last released 2020) is incompatible with bcrypt 5+; remove this cap when passlib gets replaced
8 +bcrypt<5 # bcrypt 5 errors (instead of silently truncating) on passwords >72 bytes; lift after enforcing length limit at the model layer
9 cortex4py
10 cryptography
11 docxtpl
@@ -21,8 +21,6 @@ Jinja2
21 loguru
22 miniopy-async
23 packaging
24 -passlib
25 -passlib[bcrypt]
24 pdfkit
25 Pillow # transitive runtime dep of qrcode for 2FA QR generation (qrcode.make() uses qrcode.image.pil)
26 playwright
backend/requirements.txt
-1
@@ -132,7 +132,6 @@ oauthlib==3.3.1
132 oci==2.168.1
133 oss2==2.19.1
134 packaging==26.2
135 -passlib[bcrypt]==1.7.4
135 pdfkit==1.0.0
136 pillow==12.2.0
137 playwright==1.59.0