PawTrace / backend /app /security.py
Elliott Duke
HomingPet: lost-dog reunification (FastAPI + React) with Render deploy
de1e3fc
Raw
History Blame Contribute Delete
2.57 kB
"""Password hashing + JWT helpers and FastAPI auth dependencies (spec §11).
Auth = JWT access tokens (Bearer), passwords hashed with bcrypt via passlib (DECISIONS.md).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from .config import settings
from .db import get_db
from .models import User
from .models.base import UserRole
# auto_error=False so endpoints can support optional auth (anonymous finders).
_bearer = HTTPBearer(auto_error=False)
# bcrypt operates on at most 72 bytes; truncate consistently for hash + verify.
_BCRYPT_MAX = 72
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8")[:_BCRYPT_MAX], bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(plain.encode("utf-8")[:_BCRYPT_MAX], hashed.encode("utf-8"))
except (ValueError, TypeError):
return False
def create_access_token(user_id: int) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_ttl_minutes
)
payload = {"sub": str(user_id), "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def _decode_user_id(token: str) -> int | None:
try:
payload = jwt.decode(
token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
)
return int(payload["sub"])
except (jwt.PyJWTError, KeyError, ValueError):
return None
def get_current_user_optional(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
db: Session = Depends(get_db),
) -> User | None:
if creds is None:
return None
user_id = _decode_user_id(creds.credentials)
if user_id is None:
return None
return db.get(User, user_id)
def get_current_user(
user: User | None = Depends(get_current_user_optional),
) -> User:
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def require_admin(user: User = Depends(get_current_user)) -> User:
if user.role != UserRole.admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Admin only"
)
return user