fix(rmi-backend): drop passlib — route backup codes through direct bcrypt
Some checks failed
CI / build (pull_request) Failing after 2s

The reported symptom (AttributeError: module 'bcrypt' has no attribute
'__about__') was misleading. passlib 1.7.4 (the latest released version
— the project is unmaintained) actually traps that AttributeError with
a bare `except:` and continues. The real failure occurs later in
`passlib.handlers.bcrypt._finalize_backend_mixin`:

  detect_wrap_bug -> verify(secret, bug_hash)
    where secret = (b"0123456789"*26)[:255]   # 255-byte test secret

bcrypt 4.0+ removed silent 72-byte truncation and now raises
`ValueError: password cannot be longer than 72 bytes`, breaking the
backend probe and crashing every `pwd_context.hash()/verify()` call.

Three options were considered:

  A. Upgrade passlib — impossible, 1.7.4 is the latest PyPI release.
  B. Pin bcrypt<4.0  — conflicts with chromadb 1.1.1 (>=4.0.1 required),
     even though chromadb's bcrypt usage is optional and still works
     at runtime in practice.
  C. Shim bcrypt.__about__ — doesn't fix the actual truncation error.

Chosen: refactor app/auth.py to use bcrypt directly for backup-code
hashing too (the main `hash_password`/`verify_password` already did).
Drops the passlib dependency entirely, restoring bcrypt 5.x for chromadb
compatibility and eliminating the unmaintained passlib pin.

Verified:
  * hash_password / verify_password round-trip OK
  * _hash_backup_code / _verify_backup_code round-trip OK on
    freshly generated 8-digit backup codes
  * `pytest tests/unit` -> 717 passed, 32 warnings, 60 subtests passed
This commit is contained in:
opencode 2026-07-06 16:17:47 +02:00
parent 6b29227131
commit 1171a35d9f
3 changed files with 4 additions and 9 deletions

View file

@ -12,9 +12,9 @@ import secrets
from datetime import datetime, timedelta
from typing import Any
import bcrypt
from fastapi import APIRouter, HTTPException, Request
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel, Field
from app.core.redis import get_redis
@ -86,12 +86,12 @@ def _generate_backup_codes(count: int = 8) -> list:
def _hash_backup_code(code: str) -> str:
"""Hash a backup code for storage (same as password hashing)."""
return pwd_context.hash(code)
return hash_password(code)
def _verify_backup_code(code: str, hashed: str) -> bool:
"""Verify a backup code against its hash."""
return pwd_context.verify(code, hashed)
return verify_password(code, hashed)
# ── Config ──
@ -99,10 +99,6 @@ JWT_SECRET = os.getenv("JWT_SECRET", "rmi-jwt-secret-change-me")
JWT_ALGORITHM = "HS256"
JWT_EXPIRY_DAYS = 7
import bcrypt # noqa: E402
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""Hash password using bcrypt directly."""

View file

@ -31,7 +31,7 @@ from app.services.supabase_service import ( # noqa: E402
)
# Lazy auth dependency - avoids importing passlib+jose+bcrypt at module load
# Lazy auth dependency - avoids importing jose+bcrypt at module load
async def _require_public_profile(request: Request):
from app.auth import require_public_profile

View file

@ -6,7 +6,6 @@ pydantic>=2.9.0
python-telegram-bot>=21.0
supabase>=2.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.17
aiohttp>=3.11.0
feedparser>=6.0.11