Files
family_home_lab/portal/auth.py

111 lines
3.4 KiB
Python

"""Authentication: bcrypt password hashing + signed HTTP-only session cookies.
Sessions are stateless — a signed, timestamped cookie (itsdangerous). No
server-side store needed for 4 users. The signature prevents forgery; the
timestamp bounds cookie lifetime.
NOTE: this module intentionally avoids a hard FastAPI dipendency import so it
can also be reused by workers/scripts.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import Depends, Request
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from itsdangerous import (
BadSignature,
SignatureExpired,
URLSafeTimedSerializer,
)
from .config import settings
from .database import SessionLocal, User, get_user_by_username
# ---- Password hashing (bcrypt directly; passlib is unmaintained and logs a
# bogus warning with bcrypt>=4.1) ----
import bcrypt
serializer = URLSafeTimedSerializer(settings.SECRET_KEY, salt="family-home-lab-session")
# Used to keep the HTTPBasic dependency from actually forcing a browser prompt;
# we implement form-based auth and only lean on the dep for docs/debugging.
_basic_auth = HTTPBasic(auto_error=False)
def hash_password(plain: str) -> str:
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
except ValueError:
return False
def create_session_token(username: str, *, ttl: int | None = None) -> str:
ttl = ttl or settings.SESSION_TTL
return serializer.dumps({"sub": username, "iat": int(datetime.now(timezone.utc).timestamp())})
def set_session_cookie(request: Request, response, username: str) -> None:
token = create_session_token(username)
max_age = settings.SESSION_TTL
response.set_cookie(
key=settings.SESSION_COOKIE,
value=token,
max_age=max_age,
httponly=True,
samesite="lax",
secure=settings.is_secure,
path="/",
)
def clear_session_cookie(request: Request, response) -> None:
response.delete_cookie(settings.SESSION_COOKIE, path="/")
def decode_session_token(token: str) -> dict | None:
try:
data = serializer.loads(token, max_age=settings.SESSION_TTL)
if isinstance(data, dict) and "sub" in data:
return data
except SignatureExpired:
return None
except BadSignature:
return None
return None
def get_username_from_request(request: Request) -> str | None:
token = request.cookies.get(settings.SESSION_COOKIE)
if not token:
return None
data = decode_session_token(token)
return data.get("sub") if data else None
async def current_user_dep(
request: Request,
basic: Annotated[HTTPBasicCredentials | None, Depends(_basic_auth)] = None,
) -> User:
"""Resolve the logged-in user from the session cookie.
Falls back to validating HTTP Basic credentials (useful for API/headless
clients and health checks).
"""
username = get_username_from_request(request)
if username is None and basic is not None:
username = basic.username
if username is None:
return None # handled as unauthenticated by caller
async with SessionLocal() as session:
user = await get_user_by_username(session, username)
if user is not None and user.is_active:
return user
return None