diff --git a/backend/database.py b/backend/database.py index 5ec9af3..e06b964 100644 --- a/backend/database.py +++ b/backend/database.py @@ -15,7 +15,11 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() -pwd_context = CryptContext(schemes=["sha256_crypt"], deprecated="auto") +# bcrypt is the primary scheme (cross-version stable, OpenSSL-native). +# sha256_crypt is kept as deprecated fallback so legacy hashes still +# verify; on first successful login the password is re-hashed to bcrypt +# via verify_and_update(). +pwd_context = CryptContext(schemes=["bcrypt", "sha256_crypt"], deprecated=["sha256_crypt"]) # JWT Settings SECRET_KEY = secret.JWT_SECRET if hasattr(secret, 'JWT_SECRET') else "aars-dev-secret-change-in-production" @@ -34,6 +38,15 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) +def verify_and_update_password(plain_password: str, hashed_password: str) -> tuple[bool, str | None]: + """Verify password and return new hash if rehash is needed. + + Returns (ok, new_hash_or_none). Caller is responsible for committing + the new hash to the DB when new_hash is not None. + """ + return pwd_context.verify_and_update(plain_password, hashed_password) + + def get_password_hash(password: str) -> str: return pwd_context.hash(password) diff --git a/backend/main.py b/backend/main.py index 9503259..93225e2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Optional, List import json -from database import get_db, create_access_token, verify_password, get_password_hash, init_db, Base, engine +from database import get_db, create_access_token, verify_password, verify_and_update_password, get_password_hash, init_db, Base, engine from models import User, AttendanceRecord, Shift, Accident, Holiday, LeaveRecord from sqlalchemy import func as sqlfunc from schemas import * @@ -254,9 +254,20 @@ async def startup(): @app.post("/api/auth/login", response_model=TokenResponse) async def login(form_data: LoginRequest, db: Session = Depends(get_db)): user = db.query(User).filter(User.email == form_data.email).first() - if not user or not verify_password(form_data.password, user.password_hash): + if not user: + logger.warning("login failed", extra={"context": {"email": form_data.email, "reason": "user_not_found"}}) + raise HTTPException(status_code=401, detail="Invalid email or password") + # verify_and_update returns (ok, new_hash_if_needs_rehash) + ok, new_hash = verify_and_update_password(form_data.password, user.password_hash) + if not ok: logger.warning("login failed", extra={"context": {"email": form_data.email, "reason": "invalid_credentials"}}) raise HTTPException(status_code=401, detail="Invalid email or password") + # Migrate hash scheme in place (e.g. sha256_crypt -> bcrypt) without forcing logout + if new_hash: + user.password_hash = new_hash + db.commit() + db.refresh(user) + logger.info("password hash upgraded", extra={"context": {"user_id": user.id, "email": form_data.email}}) set_user_id(user.id) access_token = create_access_token({"sub": str(user.id)}) logger.info("login success", extra={"context": {"user_id": user.id, "email": form_data.email}}) diff --git a/backend/requirements.txt b/backend/requirements.txt index 3ecddf2..8abd1fb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,8 @@ uvicorn[standard]==0.30.6 sqlalchemy==2.0.35 pydantic==2.9.2 python-jose[cryptography]==3.3.0 -passlib==1.7.4 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 python-multipart==0.0.12 openpyxl==3.1.5 reportlab==4.2.5