from __future__ import annotations
import hashlib
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
import jwt
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.models import User
DEFAULT_JWT_SECRET = "personal-lifecycle-tracker-jwt-secret-key-2026"
_jwt_env = os.getenv("JWT_SECRET")
if os.getenv("APP_ENV") == "production":
if not _jwt_env or _jwt_env == DEFAULT_JWT_SECRET:
SECRET_KEY = secrets.token_hex(32)
else:
SECRET_KEY = _jwt_env
else:
SECRET_KEY = _jwt_env or DEFAULT_JWT_SECRET
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
security = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
"""Hash a password using PBKDF2-HMAC-SHA256 with a random salt."""
salt = secrets.token_hex(16)
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 100_000)
return f"{salt}:{key.hex()}"
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against the stored salt:hash string."""
try:
if not hashed_password or ":" not in hashed_password:
return False
salt, key_hex = hashed_password.split(":")
expected_key = hashlib.pbkdf2_hmac(
"sha256", plain_password.encode("utf-8"), salt.encode("utf-8"), 100_000
)
return secrets.compare_digest(key_hex, expected_key.hex())
except Exception:
return False
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_access_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except jwt.PyJWTError:
return None
def _resolve_user_from_token(token: str) -> User:
"""Decode JWT token and retrieve corresponding user from database store."""
payload = decode_access_token(token)
if not payload or "sub" not in payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
from app.store import get_store
store = get_store()
user_in_db = store.get_user_by_id(payload["sub"])
if not user_in_db:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
return User(
id=user_in_db.id,
email=user_in_db.email,
name=user_in_db.name,
createdAt=user_in_db.createdAt,
)
def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Security(security),
) -> User:
"""
Enforces Bearer authentication strictly. Used for /auth/me and protected routes.
"""
if not credentials or not credentials.credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
return _resolve_user_from_token(credentials.credentials)
def get_current_user_optional(
credentials: Optional[HTTPAuthorizationCredentials] = Security(security),
) -> User:
"""
Resolves the authenticated user if Bearer token is provided.
If no token is supplied in development, falls back to the default seed user for dev/demo simplicity.
In production, authentication is strictly required.
If an invalid token is provided, raises 401.
"""
if credentials and credentials.credentials:
return _resolve_user_from_token(credentials.credentials)
if os.getenv("APP_ENV") == "production":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
from app.store import get_store
default_user = get_store().get_default_user()
return User(
id=default_user.id,
email=default_user.email,
name=default_user.name,
createdAt=default_user.createdAt,
)