pounce/backend/app/api/oauth.py
yves.gugger cff0ba0984 feat: Add Admin Panel enhancements, Blog system, and OAuth
Admin Panel:
- User Detail Modal with full profile info
- Bulk tier upgrade for multiple users
- User export to CSV
- Price Alerts overview tab
- Domain Health Check trigger
- Email Test functionality
- Scheduler Status with job info and last runs
- Activity Log for admin actions
- Blog management tab with CRUD

Blog System:
- BlogPost model with full content management
- Public API: list, featured, categories, single post
- Admin API: create, update, delete, publish/unpublish
- Frontend blog listing page with categories
- Frontend blog detail page with styling
- View count tracking

OAuth:
- Google OAuth integration
- GitHub OAuth integration
- OAuth callback handling
- Provider selection on login/register

Other improvements:
- Domain checker with check_all_domains function
- Admin activity logging
- Breadcrumbs component
- Toast notification component
- Various UI/UX improvements
2025-12-09 16:52:54 +01:00

399 lines
12 KiB
Python

"""
OAuth authentication endpoints.
Supports:
- Google OAuth 2.0
- GitHub OAuth
"""
import os
import secrets
import logging
from datetime import datetime, timedelta
from typing import Optional
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, HTTPException, status, Query
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import select
from app.api.deps import Database
from app.config import get_settings
from app.models.user import User
from app.services.auth import AuthService
logger = logging.getLogger(__name__)
router = APIRouter()
settings = get_settings()
# ============== Config ==============
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8000/api/v1/oauth/google/callback")
GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID", "")
GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET", "")
GITHUB_REDIRECT_URI = os.getenv("GITHUB_REDIRECT_URI", "http://localhost:8000/api/v1/oauth/github/callback")
FRONTEND_URL = os.getenv("SITE_URL", "http://localhost:3000")
# ============== Schemas ==============
class OAuthProviderInfo(BaseModel):
"""OAuth provider availability."""
google_enabled: bool
github_enabled: bool
class OAuthToken(BaseModel):
"""OAuth response with JWT token."""
access_token: str
token_type: str = "bearer"
expires_in: int
is_new_user: bool = False
# ============== Helper Functions ==============
async def get_or_create_oauth_user(
db: Database,
email: str,
name: Optional[str],
provider: str,
oauth_id: str,
avatar: Optional[str] = None,
) -> tuple[User, bool]:
"""Get existing user or create new one from OAuth."""
is_new = False
# First, check if user with this OAuth ID exists
result = await db.execute(
select(User).where(
User.oauth_provider == provider,
User.oauth_id == oauth_id,
)
)
user = result.scalar_one_or_none()
if user:
return user, False
# Check if user with this email exists (link accounts)
result = await db.execute(
select(User).where(User.email == email.lower())
)
user = result.scalar_one_or_none()
if user:
# Link OAuth to existing account
user.oauth_provider = provider
user.oauth_id = oauth_id
if avatar:
user.oauth_avatar = avatar
user.is_verified = True # OAuth emails are verified
await db.commit()
return user, False
# Create new user
user = User(
email=email.lower(),
hashed_password=secrets.token_urlsafe(32), # Random password (won't be used)
name=name,
oauth_provider=provider,
oauth_id=oauth_id,
oauth_avatar=avatar,
is_verified=True, # OAuth emails are pre-verified
is_active=True,
)
# Auto-admin for specific email
ADMIN_EMAILS = ["guggeryves@hotmail.com"]
if user.email.lower() in [e.lower() for e in ADMIN_EMAILS]:
user.is_admin = True
db.add(user)
await db.commit()
await db.refresh(user)
return user, True
def create_jwt_for_user(user: User) -> tuple[str, int]:
"""Create JWT token for user."""
expires_minutes = settings.access_token_expire_minutes
access_token = AuthService.create_access_token(
data={"sub": str(user.id), "email": user.email},
expires_delta=timedelta(minutes=expires_minutes),
)
return access_token, expires_minutes * 60
# ============== Endpoints ==============
@router.get("/providers", response_model=OAuthProviderInfo)
async def get_oauth_providers():
"""Get available OAuth providers."""
return OAuthProviderInfo(
google_enabled=bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET),
github_enabled=bool(GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET),
)
# ============== Google OAuth ==============
@router.get("/google/login")
async def google_login(redirect: Optional[str] = Query(None)):
"""Redirect to Google OAuth."""
if not GOOGLE_CLIENT_ID:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Google OAuth not configured",
)
# Store redirect URL in state
state = secrets.token_urlsafe(16)
if redirect:
state = f"{state}:{redirect}"
params = {
"client_id": GOOGLE_CLIENT_ID,
"redirect_uri": GOOGLE_REDIRECT_URI,
"response_type": "code",
"scope": "openid email profile",
"state": state,
"access_type": "offline",
"prompt": "select_account",
}
url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}"
return RedirectResponse(url=url)
@router.get("/google/callback")
async def google_callback(
code: str = Query(...),
state: str = Query(""),
db: Database = None,
):
"""Handle Google OAuth callback."""
if not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Google OAuth not configured",
)
# Parse redirect from state
redirect_path = "/dashboard"
if ":" in state:
_, redirect_path = state.split(":", 1)
try:
# Exchange code for tokens
async with httpx.AsyncClient() as client:
token_response = await client.post(
"https://oauth2.googleapis.com/token",
data={
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"code": code,
"redirect_uri": GOOGLE_REDIRECT_URI,
"grant_type": "authorization_code",
},
)
if token_response.status_code != 200:
logger.error(f"Google token error: {token_response.text}")
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed"
)
tokens = token_response.json()
access_token = tokens.get("access_token")
# Get user info
user_response = await client.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
if user_response.status_code != 200:
logger.error(f"Google user info error: {user_response.text}")
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed"
)
user_info = user_response.json()
# Get or create user
user, is_new = await get_or_create_oauth_user(
db=db,
email=user_info.get("email"),
name=user_info.get("name"),
provider="google",
oauth_id=user_info.get("id"),
avatar=user_info.get("picture"),
)
# Create JWT
jwt_token, _ = create_jwt_for_user(user)
# Redirect to frontend with token
redirect_url = f"{FRONTEND_URL}/oauth/callback?token={jwt_token}&redirect={redirect_path}"
if is_new:
redirect_url += "&new=true"
return RedirectResponse(url=redirect_url)
except Exception as e:
logger.exception(f"Google OAuth error: {e}")
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed"
)
# ============== GitHub OAuth ==============
@router.get("/github/login")
async def github_login(redirect: Optional[str] = Query(None)):
"""Redirect to GitHub OAuth."""
if not GITHUB_CLIENT_ID:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="GitHub OAuth not configured",
)
# Store redirect URL in state
state = secrets.token_urlsafe(16)
if redirect:
state = f"{state}:{redirect}"
params = {
"client_id": GITHUB_CLIENT_ID,
"redirect_uri": GITHUB_REDIRECT_URI,
"scope": "user:email",
"state": state,
}
url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
return RedirectResponse(url=url)
@router.get("/github/callback")
async def github_callback(
code: str = Query(...),
state: str = Query(""),
db: Database = None,
):
"""Handle GitHub OAuth callback."""
if not GITHUB_CLIENT_ID or not GITHUB_CLIENT_SECRET:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="GitHub OAuth not configured",
)
# Parse redirect from state
redirect_path = "/dashboard"
if ":" in state:
_, redirect_path = state.split(":", 1)
try:
async with httpx.AsyncClient() as client:
# Exchange code for token
token_response = await client.post(
"https://github.com/login/oauth/access_token",
data={
"client_id": GITHUB_CLIENT_ID,
"client_secret": GITHUB_CLIENT_SECRET,
"code": code,
"redirect_uri": GITHUB_REDIRECT_URI,
},
headers={"Accept": "application/json"},
)
if token_response.status_code != 200:
logger.error(f"GitHub token error: {token_response.text}")
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed"
)
tokens = token_response.json()
access_token = tokens.get("access_token")
if not access_token:
logger.error(f"GitHub no access token: {tokens}")
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed"
)
# Get user info
user_response = await client.get(
"https://api.github.com/user",
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
)
if user_response.status_code != 200:
logger.error(f"GitHub user info error: {user_response.text}")
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed"
)
user_info = user_response.json()
# Get primary email (might need separate call)
email = user_info.get("email")
if not email:
emails_response = await client.get(
"https://api.github.com/user/emails",
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
)
if emails_response.status_code == 200:
emails = emails_response.json()
for e in emails:
if e.get("primary"):
email = e.get("email")
break
if not email and emails:
email = emails[0].get("email")
if not email:
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=no_email"
)
# Get or create user
user, is_new = await get_or_create_oauth_user(
db=db,
email=email,
name=user_info.get("name") or user_info.get("login"),
provider="github",
oauth_id=str(user_info.get("id")),
avatar=user_info.get("avatar_url"),
)
# Create JWT
jwt_token, _ = create_jwt_for_user(user)
# Redirect to frontend with token
redirect_url = f"{FRONTEND_URL}/oauth/callback?token={jwt_token}&redirect={redirect_path}"
if is_new:
redirect_url += "&new=true"
return RedirectResponse(url=redirect_url)
except Exception as e:
logger.exception(f"GitHub OAuth error: {e}")
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed"
)