Files
yolkbook/backend/auth.py
T
derekc 3e990cae7e Replace passlib with direct bcrypt calls, drop passlib dependency
passlib is unmaintained and incompatible with bcrypt 5.0.0 due to its
internal wrap-bug detection test exceeding bcrypt's 72-byte password limit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 01:41:27 -07:00

118 lines
3.6 KiB
Python

import os
from datetime import datetime, timedelta, timezone
from typing import Optional
import bcrypt
from jose import JWTError, jwt
from fastapi import Cookie, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from database import get_db
from models import User
SECRET_KEY = os.environ["JWT_SECRET"]
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 30
COOKIE_NAME = "token"
SECURE_COOKIES = os.environ.get("SECURE_COOKIES", "true").lower() == "true"
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def create_access_token(user_id: int, username: str, is_admin: bool, user_timezone: str = "UTC", admin_id: Optional[int] = None) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
payload = {
"sub": str(user_id),
"username": username,
"is_admin": is_admin,
"timezone": user_timezone,
"exp": expire,
}
if admin_id is not None:
payload["admin_id"] = admin_id
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def set_auth_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=COOKIE_NAME,
value=token,
httponly=True,
secure=SECURE_COOKIES,
samesite="strict",
max_age=ACCESS_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
path="/",
)
def clear_auth_cookie(response: Response) -> None:
response.delete_cookie(key=COOKIE_NAME, httponly=True, secure=SECURE_COOKIES, samesite="strict", path="/")
def token_to_user_payload(token: str) -> dict:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return {
"sub": payload["sub"],
"username": payload["username"],
"is_admin": payload["is_admin"],
"timezone": payload["timezone"],
"exp": payload["exp"],
"admin_id": payload.get("admin_id"),
}
async def get_current_user(
token: Optional[str] = Cookie(default=None, alias=COOKIE_NAME),
db: Session = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
if not token:
raise credentials_exception
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id_str: Optional[str] = payload.get("sub")
if user_id_str is None:
raise credentials_exception
user_id = int(user_id_str)
except (JWTError, ValueError):
raise credentials_exception
user = db.get(User, user_id)
if user is None or user.is_disabled:
raise credentials_exception
return user
async def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return current_user
async def get_token_payload(
token: Optional[str] = Cookie(default=None, alias=COOKIE_NAME),
) -> dict:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)