| from __future__ import annotations |
|
|
| import base64 |
| import hashlib |
| import hmac |
| import os |
| from datetime import datetime, timedelta, timezone |
| from typing import Any |
|
|
| import jwt |
| from fastapi import Depends, HTTPException, Request, status |
| from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer |
| from sqlalchemy import select |
| from sqlalchemy.orm import Session |
|
|
| from app.core.config import get_settings |
| from app.core.database import get_db |
| from app.models.user import User |
|
|
|
|
| bearer_scheme = HTTPBearer(auto_error=False) |
| MEDIA_AUTH_COOKIE = "docdoe_media_token" |
|
|
|
|
| def get_current_user_optional( |
| request: Request, |
| credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), |
| db: Session = Depends(get_db), |
| ) -> User | None: |
| settings = get_settings() |
| if not settings.auth_enabled: |
| if not ( |
| settings.environment == "development" |
| and settings.allow_insecure_dev_auth |
| ): |
| raise HTTPException( |
| status_code=status.HTTP_503_SERVICE_UNAVAILABLE, |
| detail="Authentication is not configured for this deployment.", |
| ) |
| return get_or_create_dev_user(db) |
|
|
| token = ( |
| credentials.credentials |
| if credentials is not None and credentials.scheme.lower() == "bearer" |
| else request.cookies.get(MEDIA_AUTH_COOKIE) |
| ) |
| if not token: |
| return None |
| auth_provider = (settings.auth_provider or "jwt").strip().lower() |
| if auth_provider == "supabase": |
| return _user_from_supabase_token(db, token) |
| if auth_provider == "jwt": |
| return _user_from_local_jwt(db, token) |
| return None |
|
|
|
|
| def get_current_user( |
| user: User | None = Depends(get_current_user_optional), |
| ) -> User | None: |
| return user |
|
|
|
|
| def require_user(user: User | None = Depends(get_current_user_optional)) -> User: |
| if user is None: |
| raise HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail="Authentication required", |
| headers={"WWW-Authenticate": "Bearer"}, |
| ) |
| return user |
|
|
|
|
| def get_or_create_dev_user(db: Session) -> User: |
| user = db.get(User, "usr_demo_student") |
| if user is not None: |
| if user.name != "Asnan" or user.email != "asnan@example.com": |
| user.name = "Asnan" |
| user.email = "asnan@example.com" |
| db.commit() |
| db.refresh(user) |
| return user |
|
|
| user = User( |
| id="usr_demo_student", |
| name="Asnan", |
| email="asnan@example.com", |
| role="student", |
| class_level="Plus Two", |
| syllabus="Kerala HSE", |
| preferred_language="English", |
| ) |
| db.add(user) |
| db.commit() |
| db.refresh(user) |
| return user |
|
|
|
|
| def hash_password(password: str) -> str: |
| salt = os.urandom(16) |
| digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 390_000) |
| return "pbkdf2_sha256$390000${}${}".format( |
| base64.b64encode(salt).decode("ascii"), |
| base64.b64encode(digest).decode("ascii"), |
| ) |
|
|
|
|
| def verify_password(password: str, password_hash: str | None) -> bool: |
| if not password_hash: |
| return False |
| try: |
| algorithm, iterations_text, salt_text, digest_text = password_hash.split("$", 3) |
| if algorithm != "pbkdf2_sha256": |
| return False |
| salt = base64.b64decode(salt_text.encode("ascii")) |
| expected_digest = base64.b64decode(digest_text.encode("ascii")) |
| actual_digest = hashlib.pbkdf2_hmac( |
| "sha256", |
| password.encode("utf-8"), |
| salt, |
| int(iterations_text), |
| ) |
| return hmac.compare_digest(actual_digest, expected_digest) |
| except Exception: |
| return False |
|
|
|
|
| def create_access_token(user: User) -> tuple[str, int]: |
| settings = get_settings() |
| expires_in_seconds = settings.access_token_expire_minutes * 60 |
| expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds) |
| payload: dict[str, Any] = { |
| "sub": user.id, |
| "email": user.email, |
| "role": user.role, |
| "ver": user.auth_version, |
| "exp": expires_at, |
| "iat": datetime.now(timezone.utc), |
| } |
| token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) |
| return token, expires_in_seconds |
|
|
|
|
| def get_verified_auth_subject(token: str) -> str: |
| """Return the verified identity-provider subject for an access token. |
| |
| Most application routes only need the hydrated local ``User``. Destructive |
| identity operations also need the original provider subject so a legacy |
| email-matched local row cannot accidentally be used as a Supabase user ID. |
| """ |
| settings = get_settings() |
| auth_provider = (settings.auth_provider or "jwt").strip().lower() |
| if auth_provider == "supabase": |
| payload = _decode_supabase_jwt(token) |
| elif auth_provider == "jwt": |
| try: |
| payload = jwt.decode( |
| token, |
| settings.jwt_secret_key, |
| algorithms=[settings.jwt_algorithm], |
| options={"verify_aud": False}, |
| ) |
| except jwt.PyJWTError as exc: |
| raise _auth_error() from exc |
| else: |
| raise _auth_error() |
|
|
| subject = str(payload.get("sub") or "") |
| if not subject: |
| raise _auth_error() |
| return subject |
|
|
|
|
| def _user_from_local_jwt(db: Session, token: str) -> User | None: |
| settings = get_settings() |
| try: |
| payload = jwt.decode( |
| token, |
| settings.jwt_secret_key, |
| algorithms=[settings.jwt_algorithm], |
| options={"verify_aud": False}, |
| ) |
| except jwt.PyJWTError as exc: |
| raise _auth_error() from exc |
|
|
| user_id = str(payload.get("sub") or "") |
| if not user_id: |
| raise _auth_error() |
| user = db.get(User, user_id) |
| if user is None: |
| raise _auth_error() |
| try: |
| token_version = int(payload.get("ver", 1)) |
| except (TypeError, ValueError) as exc: |
| raise _auth_error() from exc |
| if token_version != user.auth_version: |
| raise _auth_error() |
| return user |
|
|
|
|
| def _user_from_supabase_token(db: Session, token: str) -> User | None: |
| payload = _decode_supabase_jwt(token) |
|
|
| user_id = str(payload.get("sub") or "") |
| email = str(payload.get("email") or "").strip().lower() |
| if not user_id: |
| raise _auth_error() |
|
|
| user = db.get(User, user_id) |
| if user is not None: |
| return user |
| if email: |
| |
| |
| |
| existing_email_user = db.scalar(select(User).where(User.email == email)) |
| if existing_email_user is not None: |
| raise _auth_error() |
|
|
| user = User( |
| id=user_id, |
| name=email.split("@")[0] if email else "Student", |
| email=email or f"{user_id}@supabase.local", |
| role="student", |
| preferred_language="English", |
| ) |
| db.add(user) |
| db.commit() |
| db.refresh(user) |
| return user |
|
|
|
|
| def _decode_supabase_jwt(token: str) -> dict[str, Any]: |
| """Verify the exact Supabase issuer and audience before trusting ``sub``. |
| |
| DocDoe's current Supabase integration uses the configured legacy HS256 JWT |
| secret. A project using asymmetric signing must add JWKS verification before |
| changing the Supabase signing-key configuration. |
| """ |
| settings = get_settings() |
| if not settings.supabase_jwt_secret or not settings.supabase_url: |
| raise HTTPException( |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
| detail="Supabase auth is enabled but SUPABASE_URL or SUPABASE_JWT_SECRET is not configured.", |
| ) |
| try: |
| payload = jwt.decode( |
| token, |
| settings.supabase_jwt_secret, |
| algorithms=["HS256"], |
| audience="authenticated", |
| issuer=f"{settings.supabase_url.rstrip('/')}/auth/v1", |
| ) |
| except jwt.PyJWTError as exc: |
| raise _auth_error() from exc |
| return payload |
|
|
|
|
| def _auth_error() -> HTTPException: |
| return HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail="Invalid or expired authentication token", |
| headers={"WWW-Authenticate": "Bearer"}, |
| ) |
|
|