206 lines
7.4 KiB
Python
206 lines
7.4 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import secrets
|
||
|
|
from urllib.parse import urlencode
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||
|
|
from fastapi.responses import RedirectResponse
|
||
|
|
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||
|
|
from sqlalchemy import func, select
|
||
|
|
from sqlalchemy.orm import Session
|
||
|
|
|
||
|
|
from app.auth import (
|
||
|
|
SESSION_COOKIE_NAME,
|
||
|
|
create_session_token,
|
||
|
|
get_or_create_user_from_identity,
|
||
|
|
)
|
||
|
|
from app.config import Settings, get_settings
|
||
|
|
from app.database import get_db
|
||
|
|
from app.models import User
|
||
|
|
from app.schemas import AuthConfigOut, DevLoginIn, TokenSessionOut, UserOut
|
||
|
|
|
||
|
|
router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"])
|
||
|
|
|
||
|
|
|
||
|
|
def _state_serializer(settings: Settings) -> URLSafeTimedSerializer:
|
||
|
|
return URLSafeTimedSerializer(settings.session_secret, salt="auth0-oauth-state")
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/config", response_model=AuthConfigOut)
|
||
|
|
def get_auth_config(
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
settings: Settings = Depends(get_settings),
|
||
|
|
) -> AuthConfigOut:
|
||
|
|
admin_count = db.scalar(select(func.count(User.id)).where(User.is_admin.is_(True))) or 0
|
||
|
|
admin_unclaimed = (admin_count == 0) and not bool(settings.admin_user.strip())
|
||
|
|
return AuthConfigOut(
|
||
|
|
auth0_enabled=settings.auth0_configured,
|
||
|
|
auth0_domain=settings.auth0_domain,
|
||
|
|
auth0_client_id=settings.auth0_client_id,
|
||
|
|
auth0_audience=settings.auth0_audience,
|
||
|
|
dev_auth_enabled=settings.dev_auth_enabled,
|
||
|
|
admin_unclaimed=admin_unclaimed,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/login")
|
||
|
|
def auth0_login(
|
||
|
|
return_to: str = Query(default="/"),
|
||
|
|
settings: Settings = Depends(get_settings),
|
||
|
|
) -> RedirectResponse:
|
||
|
|
if not settings.auth0_configured:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
|
|
detail="Auth0 federated login is not configured on this server. Set AUTH0_DOMAIN and AUTH0_CLIENT_ID.",
|
||
|
|
)
|
||
|
|
domain = settings.auth0_domain.strip().rstrip("/")
|
||
|
|
base = domain if domain.startswith("http") else f"https://{domain}"
|
||
|
|
state = _state_serializer(settings).dumps(
|
||
|
|
{"nonce": secrets.token_hex(8), "return_to": return_to}
|
||
|
|
)
|
||
|
|
params = {
|
||
|
|
"response_type": "code",
|
||
|
|
"client_id": settings.auth0_client_id,
|
||
|
|
"redirect_uri": settings.auth0_callback_url,
|
||
|
|
"scope": "openid profile email",
|
||
|
|
"state": state,
|
||
|
|
}
|
||
|
|
if settings.auth0_audience:
|
||
|
|
params["audience"] = settings.auth0_audience
|
||
|
|
return RedirectResponse(url=f"{base}/authorize?{urlencode(params)}")
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/callback")
|
||
|
|
async def auth0_callback(
|
||
|
|
code: str = Query(...),
|
||
|
|
state: str = Query(...),
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
settings: Settings = Depends(get_settings),
|
||
|
|
) -> RedirectResponse:
|
||
|
|
if not settings.auth0_configured or not settings.auth0_client_secret:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
|
|
detail="Auth0 callback requires AUTH0_DOMAIN, AUTH0_CLIENT_ID, and AUTH0_CLIENT_SECRET.",
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
state_data = _state_serializer(settings).loads(state, max_age=600)
|
||
|
|
except BadSignature as exc:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||
|
|
detail="Invalid or expired OAuth state parameter.",
|
||
|
|
) from exc
|
||
|
|
|
||
|
|
domain = settings.auth0_domain.strip().rstrip("/")
|
||
|
|
base = domain if domain.startswith("http") else f"https://{domain}"
|
||
|
|
|
||
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
|
|
token_resp = await client.post(
|
||
|
|
f"{base}/oauth/token",
|
||
|
|
json={
|
||
|
|
"grant_type": "authorization_code",
|
||
|
|
"client_id": settings.auth0_client_id,
|
||
|
|
"client_secret": settings.auth0_client_secret,
|
||
|
|
"code": code,
|
||
|
|
"redirect_uri": settings.auth0_callback_url,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
if token_resp.status_code >= 400:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
|
|
detail="Failed to exchange Auth0 authorization code.",
|
||
|
|
)
|
||
|
|
tokens = token_resp.json()
|
||
|
|
userinfo_resp = await client.get(
|
||
|
|
f"{base}/userinfo",
|
||
|
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||
|
|
)
|
||
|
|
if userinfo_resp.status_code >= 400:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
|
|
detail="Failed to retrieve user profile from Auth0.",
|
||
|
|
)
|
||
|
|
profile = userinfo_resp.json()
|
||
|
|
|
||
|
|
user = get_or_create_user_from_identity(
|
||
|
|
db,
|
||
|
|
settings,
|
||
|
|
auth_sub=str(profile["sub"]),
|
||
|
|
email=profile.get("email"),
|
||
|
|
preferred_nickname=profile.get("nickname"),
|
||
|
|
first_name=profile.get("given_name", "") or "",
|
||
|
|
last_name=profile.get("family_name", "") or "",
|
||
|
|
)
|
||
|
|
session_jwt = create_session_token(user.id, user.auth_sub, settings)
|
||
|
|
return_to = str(state_data.get("return_to") or "/")
|
||
|
|
if not return_to.startswith("/"):
|
||
|
|
return_to = "/"
|
||
|
|
target = f"{settings.frontend_base_url.rstrip('/')}{return_to}"
|
||
|
|
redirect = RedirectResponse(url=target, status_code=status.HTTP_302_FOUND)
|
||
|
|
redirect.set_cookie(
|
||
|
|
key=SESSION_COOKIE_NAME,
|
||
|
|
value=session_jwt,
|
||
|
|
httponly=True,
|
||
|
|
samesite="lax",
|
||
|
|
max_age=settings.session_ttl_hours * 3600,
|
||
|
|
)
|
||
|
|
return redirect
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/dev-login", response_model=TokenSessionOut)
|
||
|
|
def dev_login(
|
||
|
|
payload: DevLoginIn,
|
||
|
|
response: Response,
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
settings: Settings = Depends(get_settings),
|
||
|
|
) -> TokenSessionOut:
|
||
|
|
if not settings.dev_auth_enabled:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
||
|
|
detail="Local development login is disabled. Please sign in with Auth0.",
|
||
|
|
)
|
||
|
|
handle = payload.nickname.strip().lower().replace(" ", "_")
|
||
|
|
auth_sub = f"dev|{handle}"
|
||
|
|
user = get_or_create_user_from_identity(
|
||
|
|
db,
|
||
|
|
settings,
|
||
|
|
auth_sub=auth_sub,
|
||
|
|
email=payload.email or f"{handle}@example.com",
|
||
|
|
preferred_nickname=payload.nickname.strip(),
|
||
|
|
)
|
||
|
|
if payload.as_moderator and not user.is_admin:
|
||
|
|
# In dev mode, allow moderator login when either ADMIN_USER is unset or matches
|
||
|
|
configured_admin = settings.admin_user.strip().lower()
|
||
|
|
if not configured_admin or configured_admin in {
|
||
|
|
user.auth_sub.lower(),
|
||
|
|
user.nickname.lower(),
|
||
|
|
(user.email or "").lower(),
|
||
|
|
}:
|
||
|
|
user.is_admin = True
|
||
|
|
db.commit()
|
||
|
|
db.refresh(user)
|
||
|
|
|
||
|
|
if user.is_banned:
|
||
|
|
reason_suffix = f" Reason: {user.ban_reason}" if user.ban_reason else ""
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
||
|
|
detail=f"Your account has been suspended by a moderator.{reason_suffix}",
|
||
|
|
)
|
||
|
|
|
||
|
|
token = create_session_token(user.id, user.auth_sub, settings)
|
||
|
|
response.set_cookie(
|
||
|
|
key=SESSION_COOKIE_NAME,
|
||
|
|
value=token,
|
||
|
|
httponly=True,
|
||
|
|
samesite="lax",
|
||
|
|
max_age=settings.session_ttl_hours * 3600,
|
||
|
|
)
|
||
|
|
return TokenSessionOut(access_token=token, user=UserOut.model_validate(user))
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||
|
|
def logout(response: Response) -> Response:
|
||
|
|
response.delete_cookie(key=SESSION_COOKIE_NAME)
|
||
|
|
response.status_code = status.HTTP_204_NO_CONTENT
|
||
|
|
return response
|