Squash merge feature/library-reorganization
This commit is contained in:
160
backend/app/auth.py
Normal file
160
backend/app/auth.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import settings
|
||||
from .database import get_db
|
||||
from .models import UserSession
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def create_session_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, session_token: str) -> None:
|
||||
response.set_cookie(
|
||||
settings.session_cookie_name,
|
||||
session_token,
|
||||
httponly=True,
|
||||
secure=settings.session_cookie_secure,
|
||||
samesite="lax",
|
||||
max_age=60 * 60 * 24 * 14,
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(settings.session_cookie_name)
|
||||
|
||||
|
||||
def get_current_session(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
required: bool = False,
|
||||
) -> UserSession | None:
|
||||
session_token = request.cookies.get(settings.session_cookie_name)
|
||||
if not session_token:
|
||||
if required:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
return None
|
||||
|
||||
session = db.scalar(select(UserSession).where(UserSession.session_token == session_token))
|
||||
if session is None and required:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session")
|
||||
return session
|
||||
|
||||
|
||||
def require_session(session: UserSession | None = Depends(get_current_session)) -> UserSession:
|
||||
if session is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
return session
|
||||
|
||||
|
||||
def require_admin(session: UserSession = Depends(require_session)) -> UserSession:
|
||||
if not session.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return session
|
||||
|
||||
|
||||
def build_teamsnap_authorize_url(state: str) -> str:
|
||||
params = urlencode(
|
||||
{
|
||||
"client_id": settings.teamsnap_client_id,
|
||||
"redirect_uri": settings.teamsnap_redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": settings.teamsnap_scope,
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
return f"{settings.teamsnap_auth_url}?{params}"
|
||||
|
||||
|
||||
def _extract_collection_item(payload: dict) -> dict[str, object] | None:
|
||||
items = payload.get("collection", {}).get("items", [])
|
||||
if not items:
|
||||
return None
|
||||
|
||||
values: dict[str, object] = {}
|
||||
for field in items[0].get("data", []):
|
||||
name = field.get("name")
|
||||
if isinstance(name, str):
|
||||
values[name] = field.get("value")
|
||||
return values
|
||||
|
||||
|
||||
async def fetch_teamsnap_user_id(access_token: str) -> str | None:
|
||||
headers = {
|
||||
"Accept": "application/vnd.collection+json",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
root_response = await client.get(settings.teamsnap_api_root, headers=headers)
|
||||
root_response.raise_for_status()
|
||||
queries = root_response.json().get("collection", {}).get("queries", [])
|
||||
me_href = next((query.get("href") for query in queries if query.get("rel") == "me"), None)
|
||||
if not isinstance(me_href, str) or not me_href:
|
||||
return None
|
||||
|
||||
me_response = await client.get(me_href, headers=headers)
|
||||
me_response.raise_for_status()
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
|
||||
me_item = _extract_collection_item(me_response.json())
|
||||
if not me_item:
|
||||
return None
|
||||
user_id = me_item.get("id")
|
||||
return str(user_id) if user_id is not None else None
|
||||
|
||||
|
||||
async def exchange_code_for_token(code: str) -> dict:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(
|
||||
settings.teamsnap_token_url,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": settings.teamsnap_redirect_uri,
|
||||
"client_id": settings.teamsnap_client_id,
|
||||
"client_secret": settings.teamsnap_client_secret,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="TeamSnap token exchange failed")
|
||||
return response.json()
|
||||
|
||||
|
||||
async def refresh_access_token(refresh_token: str) -> dict:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(
|
||||
settings.teamsnap_token_url,
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": settings.teamsnap_client_id,
|
||||
"client_secret": settings.teamsnap_client_secret,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="TeamSnap token refresh failed")
|
||||
return response.json()
|
||||
|
||||
|
||||
def update_session_tokens(session: UserSession, token_payload: dict) -> None:
|
||||
session.access_token = token_payload.get("access_token")
|
||||
session.refresh_token = token_payload.get("refresh_token", session.refresh_token)
|
||||
expires_in = token_payload.get("expires_in")
|
||||
session.token_expires_at = utcnow() + timedelta(seconds=int(expires_in)) if expires_in else None
|
||||
Reference in New Issue
Block a user