fix: address multiple code audit findings

- CORS: replace wildcard with explicit origin list from CORS_ORIGINS env
- Auth: enforce strong defaults, JWT blacklist (RevokedToken model), login rate limiting
- Auth: validate password length before bcrypt (72-byte limit)
- Scheduler: single-threaded worker to mitigate SQLite write contention
- Scheduler: graceful shutdown (wait=True)
- Snapshots: add prune_snapshots() with configurable retention count
- Storage: isolate localStorage keys via VITE_APP_KEY prefix
- Config: add cors_origins, login_rate_limit, snapshot_retention_count settings
This commit is contained in:
SmartUp Developer
2026-05-17 10:52:18 +08:00
parent a42ecf7bcc
commit ad16618406
25 changed files with 792 additions and 165 deletions
+34 -12
View File
@@ -1,25 +1,37 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from uuid import uuid4
from jose import JWTError, jwt
import bcrypt
from fastapi import Depends, HTTPException, Query, Request, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.config import get_settings
from app.database import get_db
from app.models.admin_user import AdminUser
from app.models.revoked_token import RevokedToken
ALGORITHM = "HS256"
BCRYPT_MAX_PASSWORD_BYTES = 72
bearer_scheme = HTTPBearer(auto_error=False)
def validate_password_supported(password: str) -> None:
if len(password.encode("utf-8")) > BCRYPT_MAX_PASSWORD_BYTES:
raise ValueError("password must be at most 72 bytes when UTF-8 encoded")
def hash_password(password: str) -> str:
pw = password.encode("utf-8")[:72]
validate_password_supported(password)
pw = password.encode("utf-8")
return bcrypt.hashpw(pw, bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
pw = plain.encode("utf-8")[:72]
validate_password_supported(plain)
pw = plain.encode("utf-8")
return bcrypt.checkpw(pw, hashed.encode("utf-8"))
@@ -27,19 +39,29 @@ def create_access_token(email: str, expires_hours: Optional[int] = None) -> str:
settings = get_settings()
hours = expires_hours or settings.jwt_expire_hours
expire = datetime.now(timezone.utc) + timedelta(hours=hours)
data = {"sub": email, "exp": expire}
data = {"sub": email, "exp": expire, "jti": uuid4().hex}
return jwt.encode(data, settings.jwt_secret, algorithm=ALGORITHM)
def decode_token(token: str) -> Optional[str]:
def decode_token_payload(token: str) -> Optional[dict]:
settings = get_settings()
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
return payload.get("sub")
return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
except JWTError:
return None
def decode_token(token: str) -> Optional[str]:
payload = decode_token_payload(token)
return payload.get("sub") if payload else None
def _is_revoked(db: Session, jti: str | None) -> bool:
if not jti:
return True
return db.query(RevokedToken).filter(RevokedToken.jti == jti).first() is not None
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: Session = Depends(get_db),
@@ -47,10 +69,10 @@ def get_current_user(
token = credentials.credentials if credentials else None
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
email = decode_token(token)
if not email:
payload = decode_token_payload(token)
if not payload or _is_revoked(db, payload.get("jti")):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.query(AdminUser).filter(AdminUser.email == email).first()
user = db.query(AdminUser).filter(AdminUser.email == payload.get("sub")).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
@@ -65,10 +87,10 @@ def get_user_from_token_param(
raw = token or (credentials.credentials if credentials else None)
if not raw:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
email = decode_token(raw)
if not email:
payload = decode_token_payload(raw)
if not payload or _is_revoked(db, payload.get("jti")):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.query(AdminUser).filter(AdminUser.email == email).first()
user = db.query(AdminUser).filter(AdminUser.email == payload.get("sub")).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user