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:
IT狗
2026-07-22 12:07:51 +08:00
parent f6712d9d0e
commit acbc767247
3 changed files with 29 additions and 4 deletions
+14 -1
View File
@@ -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
View File
@@ -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}})
+2 -1
View File
@@ -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