feat(auth): switch to bcrypt primary scheme, keep sha256_crypt for migration
- database.py: CryptContext = ['bcrypt', 'sha256_crypt'] with sha256_crypt deprecated - database.py: add verify_and_update_password() returning (ok, new_hash) - main.py: login uses verify_and_update; on successful verify transparently commits new bcrypt hash when old hash is sha256_crypt - requirements.txt: passlib[bcrypt]==1.7.4 + bcrypt==4.0.1 (compat with passlib 1.7.4) Migration: zero-touch. Admin's first login after this deploy will auto-upgrade the stored hash from $5$... to $2b$... Avoids the passlib sha256_crypt cross-version verify bug that bit us this morning. bcrypt is OpenSSL-native and stable across passlib versions.
This commit is contained in:
+14
-1
@@ -15,7 +15,11 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
|||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
Base = declarative_base()
|
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
|
# JWT Settings
|
||||||
SECRET_KEY = secret.JWT_SECRET if hasattr(secret, 'JWT_SECRET') else "aars-dev-secret-change-in-production"
|
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)
|
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:
|
def get_password_hash(password: str) -> str:
|
||||||
return pwd_context.hash(password)
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import json
|
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 models import User, AttendanceRecord, Shift, Accident, Holiday, LeaveRecord
|
||||||
from sqlalchemy import func as sqlfunc
|
from sqlalchemy import func as sqlfunc
|
||||||
from schemas import *
|
from schemas import *
|
||||||
@@ -254,9 +254,20 @@ async def startup():
|
|||||||
@app.post("/api/auth/login", response_model=TokenResponse)
|
@app.post("/api/auth/login", response_model=TokenResponse)
|
||||||
async def login(form_data: LoginRequest, db: Session = Depends(get_db)):
|
async def login(form_data: LoginRequest, db: Session = Depends(get_db)):
|
||||||
user = db.query(User).filter(User.email == form_data.email).first()
|
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"}})
|
logger.warning("login failed", extra={"context": {"email": form_data.email, "reason": "invalid_credentials"}})
|
||||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
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)
|
set_user_id(user.id)
|
||||||
access_token = create_access_token({"sub": str(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}})
|
logger.info("login success", extra={"context": {"user_id": user.id, "email": form_data.email}})
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ uvicorn[standard]==0.30.6
|
|||||||
sqlalchemy==2.0.35
|
sqlalchemy==2.0.35
|
||||||
pydantic==2.9.2
|
pydantic==2.9.2
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.0.1
|
||||||
python-multipart==0.0.12
|
python-multipart==0.0.12
|
||||||
openpyxl==3.1.5
|
openpyxl==3.1.5
|
||||||
reportlab==4.2.5
|
reportlab==4.2.5
|
||||||
|
|||||||
Reference in New Issue
Block a user