Add comprehensive documentation for LeagueLedger

- Created architecture overview in development/architecture.md
- Added installation guide in getting-started/installation.md
- Developed user guide with detailed instructions in user-guide/overview.md, user-guide/teams.md, user-guide/qr-codes.md
- Implemented social login setup documentation in social_login_setup.md
- Updated index.md to include links to new documentation sections
- Configured mkdocs.yml for site structure and theme
- Added requirements.txt for documentation dependencies
This commit is contained in:
Christian Krakau-Louis
2025-04-15 12:32:03 +02:00
parent 7323c12168
commit 6306abf6d9
26 changed files with 3350 additions and 132 deletions
+74
View File
@@ -0,0 +1,74 @@
from starlette.authentication import (
AuthCredentials, AuthenticationBackend, UnauthenticatedUser
)
from sqlalchemy.orm import Session
from ..db import SessionLocal
from ..models import User
class SessionAuthBackend(AuthenticationBackend):
"""
Authentication backend that uses session data to authenticate users.
This maintains compatibility with the existing session-based authentication
while providing the structure of Starlette's authentication system.
"""
async def authenticate(self, request):
"""
Authenticate the user from the session.
Args:
request: The FastAPI/Starlette request object.
Returns:
Tuple of (AuthCredentials, User) if authenticated,
or None if not authenticated.
"""
# Check for user_id in session
user_id = request.session.get("user_id")
if not user_id:
# Return None to indicate no authentication
return None
# Get database connection
db = SessionLocal()
try:
# Fetch user from database
user = db.query(User).filter(User.id == user_id).first()
# If user exists, set credentials and return user
if user:
# Base credentials for all authenticated users
scopes = ["authenticated"]
# Add admin scope if user is admin
if user.is_admin:
scopes.append("admin")
# Add verified scope if user is verified
if user.is_verified:
scopes.append("verified")
# Add OAuth provider scope if it exists
# This allows policies to be set based on authentication source
oauth_provider = request.session.get("oauth_provider")
if oauth_provider:
scopes.append(f"oauth:{oauth_provider}")
# Return credentials and user
return AuthCredentials(scopes), user
finally:
db.close()
# If we get here, user not found but session exists
# Clear session on next request (handled in middleware)
return None
def on_auth_error(request, exc):
"""Handle authentication errors by redirecting to login"""
from fastapi.responses import RedirectResponse
# Build the redirect URL with the original requested path as 'next'
login_url = f"/auth/login?next={request.url.path}"
# Return redirect response
return RedirectResponse(url=login_url, status_code=303)
+802 -9
View File
@@ -1,22 +1,119 @@
import os
from abc import ABC, abstractmethod
from httpx_oauth.clients.google import GoogleOAuth2
from httpx_oauth.clients.github import GitHubOAuth2
from httpx_oauth.clients.facebook import FacebookOAuth2
from httpx_oauth.clients.discord import DiscordOAuth2
# LinkedIn OAuth client
from httpx_oauth.clients.linkedin import LinkedInOAuth2
# Microsoft client import may not be available in all httpx_oauth versions
try:
from httpx_oauth.clients.microsoft import MicrosoftOAuth2
MICROSOFT_AVAILABLE = True
except ImportError:
# Custom implementation if the package doesn't have it
from httpx_oauth.oauth2 import OAuth2, GetAccessTokenError
MICROSOFT_AVAILABLE = False
# Basic Microsoft OAuth2 implementation if not available in the library
class MicrosoftOAuth2(OAuth2):
def __init__(
self,
client_id: str,
client_secret: str,
tenant: str = "common",
):
super().__init__(
client_id=client_id,
client_secret=client_secret,
authorize_endpoint=f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
access_token_endpoint=f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
refresh_token_endpoint=f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
base_scopes=["openid", "profile", "email"],
)
from httpx_oauth.clients.openid import OpenID
from httpx_oauth.oauth2 import GetAccessTokenError
from fastapi import HTTPException, Request
from starlette.responses import RedirectResponse
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List, Type
import json
import httpx
from urllib.parse import urlencode
class AuthentikOAuth:
class OAuthProvider(ABC):
"""Base class for all OAuth providers"""
# Provider identifier - should be unique and lowercase
provider_id = "base"
# Display name for UI
display_name = "Base Provider"
# Icon class (for UI rendering, e.g. Font Awesome)
icon_class = "fas fa-sign-in-alt"
# Default button color (hex or valid CSS color name)
button_color = "#333333"
def __init__(self):
self.client_id = os.getenv("AUTHENTIK_CLIENT_ID", "dRXLBdTdG6JSHqkcM0ZQBPwBVMBrG6SF32LZ1XAT")
self.client_secret = os.getenv("AUTHENTIK_CLIENT_SECRET",
"hn1aKecLeYj1tVc7QtsavrWjSOF4t7Ty1akVTmUqvIFJF1y0H3Myv7InUxAX6E2GLpMxxhhZZ2aUSJ9VEQz7zGcMbgUeMStxx2U7bEQxmuOGjZf0XJbOBGjdwGZYJlz7")
self.config_url = os.getenv("AUTHENTIK_CONFIG_URL",
"https://authentik.hosterra.net/application/o/leagueledger/.well-known/openid-configuration")
self.client = None
self.initialize_client()
@abstractmethod
def initialize_client(self):
"""Initialize the specific OAuth client"""
pass
@abstractmethod
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
"""Get the authorization URL for this provider"""
pass
@abstractmethod
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
"""Get user information from the provider"""
pass
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
"""
Normalize provider-specific user data into a standard format
Returns:
Dict with standard fields:
- id: Unique identifier from provider
- email: User email (if available)
- name: User's full/display name
- first_name: User's first name (if available)
- last_name: User's last name (if available)
- picture: URL to user's avatar/picture (if available)
- raw: The original user_info dict
"""
# Default implementation - should be overridden by providers
return {
"id": str(user_info.get("id", "")),
"email": user_info.get("email"),
"name": user_info.get("name"),
"first_name": user_info.get("given_name"),
"last_name": user_info.get("family_name"),
"picture": user_info.get("picture"),
"raw": user_info
}
class AuthentikOAuth(OAuthProvider):
"""Authentik OpenID Connect OAuth provider"""
provider_id = "authentik"
display_name = "Authentik"
icon_class = "fas fa-shield-alt"
button_color = "#fd4b2d"
def __init__(self):
self.client_id = os.getenv("AUTHENTIK_CLIENT_ID", "yourAuthentikClientID")
self.client_secret = os.getenv("AUTHENTIK_CLIENT_SECRET", "yourAuthentikClientSecret")
self.config_url = os.getenv("AUTHENTIK_CONFIG_URL",
"https://authentik.example.com/application/o/leagueledger/.well-known/openid-configuration")
super().__init__()
def initialize_client(self):
try:
@@ -94,5 +191,701 @@ class AuthentikOAuth:
print(f"Error getting user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
# Instantiate the OAuth client for the application to use
authentik_oauth = AuthentikOAuth()
class GoogleOAuth(OAuthProvider):
"""Google OAuth provider implementation"""
provider_id = "google"
display_name = "Google"
icon_class = "fab fa-google"
button_color = "#4285F4"
def __init__(self):
self.client_id = os.getenv("GOOGLE_CLIENT_ID", "")
self.client_secret = os.getenv("GOOGLE_CLIENT_SECRET", "")
super().__init__()
def initialize_client(self):
if not self.client_id or not self.client_secret:
self.client = None
return
try:
self.client = GoogleOAuth2(
client_id=self.client_id,
client_secret=self.client_secret
)
except Exception as e:
print(f"Error initializing Google OAuth client: {str(e)}")
self.client = None
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
if not self.client:
self.initialize_client()
if not self.client:
raise HTTPException(status_code=500, detail="Google OAuth client could not be initialized")
try:
authorization_url = await self.client.get_authorization_url(
redirect_uri=redirect_uri,
scope=["openid", "email", "profile"],
state=str(request.session.get("session_id", "")),
extras_params={"access_type": "offline", "prompt": "select_account"}
)
return authorization_url
except Exception as e:
print(f"Error getting Google authorization URL: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
if not self.client:
self.initialize_client()
if not self.client:
raise HTTPException(status_code=500, detail="Google OAuth client could not be initialized")
try:
# Exchange code for token
token = await self.client.get_access_token(
code=code,
redirect_uri=redirect_uri
)
access_token = token.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="Could not get Google access token")
# Get user info from Google
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get(
"https://www.googleapis.com/oauth2/v3/userinfo",
headers=headers
)
if response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Error fetching Google user info: {response.text}")
return response.json()
except GetAccessTokenError as e:
error_description = e.args[0]
raise HTTPException(status_code=400, detail=f"Google OAuth error: {error_description}")
except Exception as e:
print(f"Error getting Google user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
# Google specific normalization
return {
"id": user_info.get("sub", ""),
"email": user_info.get("email"),
"name": user_info.get("name"),
"first_name": user_info.get("given_name"),
"last_name": user_info.get("family_name"),
"picture": user_info.get("picture"),
"raw": user_info
}
class GitHubOAuth(OAuthProvider):
"""GitHub OAuth provider implementation"""
provider_id = "github"
display_name = "GitHub"
icon_class = "fab fa-github"
button_color = "#171515"
def __init__(self):
self.client_id = os.getenv("GITHUB_CLIENT_ID", "")
self.client_secret = os.getenv("GITHUB_CLIENT_SECRET", "")
super().__init__()
def initialize_client(self):
if not self.client_id or not self.client_secret:
self.client = None
return
try:
self.client = GitHubOAuth2(
client_id=self.client_id,
client_secret=self.client_secret
)
except Exception as e:
print(f"Error initializing GitHub OAuth client: {str(e)}")
self.client = None
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
if not self.client:
self.initialize_client()
if not self.client:
raise HTTPException(status_code=500, detail="GitHub OAuth client could not be initialized")
try:
authorization_url = await self.client.get_authorization_url(
redirect_uri=redirect_uri,
scope=["user:email"],
state=str(request.session.get("session_id", "")),
)
return authorization_url
except Exception as e:
print(f"Error getting GitHub authorization URL: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
if not self.client:
self.initialize_client()
if not self.client:
raise HTTPException(status_code=500, detail="GitHub OAuth client could not be initialized")
try:
# Exchange code for token
token = await self.client.get_access_token(
code=code,
redirect_uri=redirect_uri
)
access_token = token.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="Could not get GitHub access token")
# Get user info from GitHub
user_data = {}
async with httpx.AsyncClient() as client:
headers = {
"Authorization": f"token {access_token}",
"Accept": "application/vnd.github.v3+json"
}
# Get user profile
user_response = await client.get(
"https://api.github.com/user",
headers=headers
)
if user_response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Error fetching GitHub user info: {user_response.text}")
user_data = user_response.json()
# Get user emails
email_response = await client.get(
"https://api.github.com/user/emails",
headers=headers
)
if email_response.status_code == 200:
emails = email_response.json()
primary_email = next((email for email in emails if email.get("primary") is True), None)
if primary_email:
user_data["email"] = primary_email.get("email")
return user_data
except GetAccessTokenError as e:
error_description = e.args[0]
raise HTTPException(status_code=400, detail=f"GitHub OAuth error: {error_description}")
except Exception as e:
print(f"Error getting GitHub user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
# GitHub specific normalization
name_parts = (user_info.get("name") or "").split(" ", 1)
first_name = name_parts[0] if name_parts else ""
last_name = name_parts[1] if len(name_parts) > 1 else ""
return {
"id": str(user_info.get("id", "")),
"email": user_info.get("email"),
"name": user_info.get("name") or user_info.get("login"),
"first_name": first_name,
"last_name": last_name,
"picture": user_info.get("avatar_url"),
"raw": user_info
}
class FacebookOAuth(OAuthProvider):
"""Facebook OAuth provider implementation"""
provider_id = "facebook"
display_name = "Facebook"
icon_class = "fab fa-facebook"
button_color = "#1877F2"
def __init__(self):
self.client_id = os.getenv("FACEBOOK_CLIENT_ID", "")
self.client_secret = os.getenv("FACEBOOK_CLIENT_SECRET", "")
super().__init__()
def initialize_client(self):
if not self.client_id or not self.client_secret:
self.client = None
return
try:
self.client = FacebookOAuth2(
client_id=self.client_id,
client_secret=self.client_secret
)
except Exception as e:
print(f"Error initializing Facebook OAuth client: {str(e)}")
self.client = None
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
if not self.client:
self.initialize_client()
if not self.client:
raise HTTPException(status_code=500, detail="Facebook OAuth client could not be initialized")
try:
authorization_url = await self.client.get_authorization_url(
redirect_uri=redirect_uri,
scope=["email", "public_profile"],
state=str(request.session.get("session_id", ""))
)
return authorization_url
except Exception as e:
print(f"Error getting Facebook authorization URL: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
if not self.client:
self.initialize_client()
if not self.client:
raise HTTPException(status_code=500, detail="Facebook OAuth client could not be initialized")
try:
# Exchange code for token
token = await self.client.get_access_token(
code=code,
redirect_uri=redirect_uri
)
access_token = token.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="Could not get Facebook access token")
# Get user info from Facebook
async with httpx.AsyncClient() as client:
response = await client.get(
"https://graph.facebook.com/me",
params={
"access_token": access_token,
"fields": "id,name,email,first_name,last_name,picture"
}
)
if response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Error fetching Facebook user info: {response.text}")
return response.json()
except GetAccessTokenError as e:
error_description = e.args[0]
raise HTTPException(status_code=400, detail=f"Facebook OAuth error: {error_description}")
except Exception as e:
print(f"Error getting Facebook user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
# Facebook specific normalization
picture_url = None
if "picture" in user_info and "data" in user_info["picture"]:
picture_url = user_info["picture"]["data"].get("url")
return {
"id": user_info.get("id", ""),
"email": user_info.get("email"),
"name": user_info.get("name"),
"first_name": user_info.get("first_name"),
"last_name": user_info.get("last_name"),
"picture": picture_url,
"raw": user_info
}
class MicrosoftOAuth(OAuthProvider):
"""Microsoft OAuth provider implementation"""
provider_id = "microsoft"
display_name = "Microsoft"
icon_class = "fab fa-microsoft"
button_color = "#00A4EF"
def __init__(self):
self.client_id = os.getenv("MICROSOFT_CLIENT_ID", "")
self.client_secret = os.getenv("MICROSOFT_CLIENT_SECRET", "")
self.tenant = os.getenv("MICROSOFT_TENANT", "common") # "common" for multi-tenant apps
super().__init__()
def initialize_client(self):
if not self.client_id or not self.client_secret:
self.client = None
return
try:
self.client = MicrosoftOAuth2(
client_id=self.client_id,
client_secret=self.client_secret,
tenant=self.tenant
)
except Exception as e:
print(f"Error initializing Microsoft OAuth client: {str(e)}")
self.client = None
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
if not self.client:
self.initialize_client()
if not self.client:
raise HTTPException(status_code=500, detail="Microsoft OAuth client could not be initialized")
try:
authorization_url = await self.client.get_authorization_url(
redirect_uri=redirect_uri,
scope=["User.Read", "email", "profile", "openid"],
state=str(request.session.get("session_id", ""))
)
return authorization_url
except Exception as e:
print(f"Error getting Microsoft authorization URL: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
if not self.client:
self.initialize_client()
if not self.client:
raise HTTPException(status_code=500, detail="Microsoft OAuth client could not be initialized")
try:
# Exchange code for token
token = await self.client.get_access_token(
code=code,
redirect_uri=redirect_uri
)
access_token = token.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="Could not get Microsoft access token")
# Get user info from Microsoft Graph API
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get(
"https://graph.microsoft.com/v1.0/me",
headers=headers
)
if response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Error fetching Microsoft user info: {response.text}")
return response.json()
except GetAccessTokenError as e:
error_description = e.args[0]
raise HTTPException(status_code=400, detail=f"Microsoft OAuth error: {error_description}")
except Exception as e:
print(f"Error getting Microsoft user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
return {
"id": user_info.get("id", ""),
"email": user_info.get("mail") or user_info.get("userPrincipalName"),
"name": user_info.get("displayName"),
"first_name": user_info.get("givenName"),
"last_name": user_info.get("surname"),
"picture": None, # Microsoft Graph doesn't include photo in basic profile
"raw": user_info
}
class DiscordOAuth(OAuthProvider):
"""Discord OAuth provider implementation"""
provider_id = "discord"
display_name = "Discord"
icon_class = "fab fa-discord"
button_color = "#5865F2"
def __init__(self):
self.client_id = os.getenv("DISCORD_CLIENT_ID", "")
self.client_secret = os.getenv("DISCORD_CLIENT_SECRET", "")
super().__init__()
def initialize_client(self):
if not self.client_id or not self.client_secret:
self.client = None
return
try:
self.client = DiscordOAuth2(
client_id=self.client_id,
client_secret=self.client_secret
)
except Exception as e:
print(f"Error initializing Discord OAuth client: {str(e)}")
self.client = None
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
if not self.client:
self.initialize_client()
if not self.client:
raise HTTPException(status_code=500, detail="Discord OAuth client could not be initialized")
try:
authorization_url = await self.client.get_authorization_url(
redirect_uri=redirect_uri,
scope=["identify", "email"],
state=str(request.session.get("session_id", ""))
)
return authorization_url
except Exception as e:
print(f"Error getting Discord authorization URL: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
if not self.client:
self.initialize_client()
if not self.client:
raise HTTPException(status_code=500, detail="Discord OAuth client could not be initialized")
try:
# Exchange code for token
token = await self.client.get_access_token(
code=code,
redirect_uri=redirect_uri
)
access_token = token.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="Could not get Discord access token")
# Get user info from Discord API
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get(
"https://discord.com/api/users/@me",
headers=headers
)
if response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Error fetching Discord user info: {response.text}")
return response.json()
except GetAccessTokenError as e:
error_description = e.args[0]
raise HTTPException(status_code=400, detail=f"Discord OAuth error: {error_description}")
except Exception as e:
print(f"Error getting Discord user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
avatar_url = None
if user_info.get("avatar"):
user_id = user_info.get("id")
avatar_hash = user_info.get("avatar")
avatar_url = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png"
# Discord doesn't split names, just has a username
return {
"id": user_info.get("id", ""),
"email": user_info.get("email"),
"name": user_info.get("username") or user_info.get("global_name"),
"first_name": user_info.get("username", "").split("#", 1)[0],
"last_name": "",
"picture": avatar_url,
"raw": user_info
}
class LinkedInOAuth(OAuthProvider):
"""LinkedIn OAuth provider implementation using OpenID Connect"""
provider_id = "linkedin"
display_name = "LinkedIn"
icon_class = "fab fa-linkedin"
button_color = "#0077B5"
# LinkedIn OIDC endpoints
AUTHORIZATION_URL = "https://www.linkedin.com/oauth/v2/authorization"
TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"
USERINFO_URL = "https://api.linkedin.com/v2/userinfo"
def __init__(self):
self.client_id = os.getenv("LINKEDIN_CLIENT_ID", "")
self.client_secret = os.getenv("LINKEDIN_CLIENT_SECRET", "")
super().__init__()
def initialize_client(self):
if not self.client_id or not self.client_secret:
self.client = None
return
try:
# Create a custom OAuth2 client for LinkedIn OpenID Connect
from httpx_oauth.oauth2 import OAuth2
self.client = OAuth2(
client_id=self.client_id,
client_secret=self.client_secret,
authorize_endpoint=self.AUTHORIZATION_URL,
access_token_endpoint=self.TOKEN_URL,
refresh_token_endpoint=self.TOKEN_URL,
base_scopes=["openid", "profile", "email"]
)
except Exception as e:
print(f"Error initializing LinkedIn OAuth client: {str(e)}")
self.client = None
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
if not self.client:
self.initialize_client()
if not self.client:
raise HTTPException(status_code=500, detail="LinkedIn OAuth client could not be initialized")
try:
authorization_url = await self.client.get_authorization_url(
redirect_uri=redirect_uri,
# Using the required OpenID Connect scopes
scope=["openid", "profile", "email"],
state=str(request.session.get("session_id", ""))
)
return authorization_url
except Exception as e:
print(f"Error getting LinkedIn authorization URL: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
if not self.client:
self.initialize_client()
if not self.client:
raise HTTPException(status_code=500, detail="LinkedIn OAuth client could not be initialized")
try:
# Exchange code for token
token = await self.client.get_access_token(
code=code,
redirect_uri=redirect_uri
)
access_token = token.get("access_token")
id_token = token.get("id_token") # JWT token containing basic user info
if not access_token:
raise HTTPException(status_code=400, detail="Could not get LinkedIn access token")
# Get user info from LinkedIn UserInfo endpoint (OIDC standard endpoint)
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get(
self.USERINFO_URL,
headers=headers
)
if response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Error fetching LinkedIn user info: {response.text}")
user_info = response.json()
# The user info from the OIDC userinfo endpoint should already contain the
# email if requested in the scope, no need for a separate call
return user_info
except GetAccessTokenError as e:
error_description = e.args[0]
raise HTTPException(status_code=400, detail=f"LinkedIn OAuth error: {error_description}")
except Exception as e:
print(f"Error getting LinkedIn user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
# LinkedIn OpenID Connect response normalization
return {
"id": user_info.get("sub", ""), # 'sub' is the standard OIDC subject identifier
"email": user_info.get("email"),
"name": user_info.get("name"),
"first_name": user_info.get("given_name"),
"last_name": user_info.get("family_name"),
"picture": user_info.get("picture"),
"raw": user_info
}
class OAuthManager:
"""
Manager class for handling multiple OAuth providers
"""
def __init__(self):
self.providers: Dict[str, OAuthProvider] = {}
self.register_default_providers()
def register_default_providers(self):
"""Register all available providers"""
self.register_provider(AuthentikOAuth())
self.register_provider(GoogleOAuth())
self.register_provider(FacebookOAuth())
self.register_provider(GitHubOAuth())
self.register_provider(MicrosoftOAuth())
self.register_provider(DiscordOAuth())
self.register_provider(LinkedInOAuth())
def register_provider(self, provider: OAuthProvider):
"""Register a new provider"""
self.providers[provider.provider_id] = provider
def get_provider(self, provider_id: str) -> Optional[OAuthProvider]:
"""Get a provider by ID"""
return self.providers.get(provider_id)
def get_available_providers(self) -> List[Dict[str, Any]]:
"""
Get list of available providers (those with credentials configured)
Returns a list of provider details for UI rendering
"""
available_providers = []
for provider_id, provider in self.providers.items():
if provider.client is not None:
available_providers.append({
"id": provider.provider_id,
"name": provider.display_name,
"icon": provider.icon_class,
"color": provider.button_color
})
return available_providers
async def get_login_url(self, request: Request, provider_id: str, redirect_uri: str) -> str:
"""Get login URL for a specific provider"""
provider = self.get_provider(provider_id)
if not provider:
raise HTTPException(status_code=400, detail=f"Unknown provider: {provider_id}")
return await provider.get_login_url(request, redirect_uri)
async def get_user_info(self, request: Request, provider_id: str,
redirect_uri: str, code: str) -> Dict[str, Any]:
"""Get user info from a specific provider"""
provider = self.get_provider(provider_id)
if not provider:
raise HTTPException(status_code=400, detail=f"Unknown provider: {provider_id}")
# Get raw user info
user_info = await provider.get_user_info(request, redirect_uri, code)
# Normalize it
return provider.get_normalized_user_data(user_info)
# Create the OAuth manager for the application to use
oauth_manager = OAuthManager()
# For backwards compatibility
authentik_oauth = oauth_manager.get_provider("authentik")
+137
View File
@@ -0,0 +1,137 @@
from functools import wraps
from typing import List, Optional, Callable, Union
from starlette.authentication import requires
from fastapi import Request, HTTPException, status
from fastapi.responses import RedirectResponse
def require_auth(redirect_url: Optional[str] = None):
"""
Decorator to require authentication for FastAPI route handlers.
This uses Starlette's requires decorator with the "authenticated" scope.
Args:
redirect_url: URL to redirect to if user is not authenticated (optional).
If not provided, returns a 401 Unauthorized error.
Example:
@router.get("/protected")
@require_auth(redirect_url="/auth/login")
async def protected_route(request: Request):
return {"user": request.user.display_name}
"""
return requires(
"authenticated",
status_code=status.HTTP_401_UNAUTHORIZED,
redirect=redirect_url
)
def require_admin(redirect_url: Optional[str] = None):
"""
Decorator to require admin privileges for FastAPI route handlers.
This uses Starlette's requires decorator with the "admin" scope.
Args:
redirect_url: URL to redirect to if user is not an admin (optional).
If not provided, returns a 403 Forbidden error.
Example:
@router.get("/admin-only")
@require_admin(redirect_url="/auth/login")
async def admin_only_route(request: Request):
return {"message": "You are an admin"}
"""
return requires(
"admin",
status_code=status.HTTP_403_FORBIDDEN,
redirect=redirect_url
)
def require_verified(redirect_url: Optional[str] = None):
"""
Decorator to require verified users for FastAPI route handlers.
This uses Starlette's requires decorator with the "verified" scope.
Args:
redirect_url: URL to redirect to if user is not verified (optional).
If not provided, returns a 403 Forbidden error.
Example:
@router.get("/verified-only")
@require_verified(redirect_url="/auth/verify-email")
async def verified_only_route(request: Request):
return {"message": "Your email is verified"}
"""
return requires(
"verified",
status_code=status.HTTP_403_FORBIDDEN,
redirect=redirect_url
)
def require_team_captain(team_id_param: str = "team_id"):
"""
Decorator to require team captain privileges for a specific team.
This decorator doesn't use Starlette's requires as it needs access to
path parameters and the database to check team captain status.
Args:
team_id_param: Name of the path parameter that contains the team ID.
Example:
@router.get("/teams/{team_id}/manage")
@require_team_captain()
async def manage_team(request: Request, team_id: int):
return {"message": f"You are the captain of team {team_id}"}
"""
def decorator(func: Callable):
@wraps(func)
async def wrapper(*args, **kwargs):
request = kwargs.get("request") or next(
(arg for arg in args if isinstance(arg, Request)), None
)
if not request:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Request object not found in endpoint arguments"
)
# Check if user is authenticated
if not request.user.is_authenticated:
return RedirectResponse(
url=f"/auth/login?next={request.url.path}",
status_code=status.HTTP_303_SEE_OTHER
)
# Get team_id from path parameters
team_id = kwargs.get(team_id_param)
if not team_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Team ID parameter '{team_id_param}' not found"
)
# Get database session
from sqlalchemy.orm import Session
from ..db import SessionLocal
from ..models import TeamMembership
db = SessionLocal()
try:
# Check if user is team captain
membership = db.query(TeamMembership).filter(
TeamMembership.team_id == team_id,
TeamMembership.user_id == int(request.user.identity),
TeamMembership.is_captain == True
).first()
if not membership:
return RedirectResponse(
url=f"/teams/{team_id}",
status_code=status.HTTP_303_SEE_OTHER
)
finally:
db.close()
return await func(*args, **kwargs)
return wrapper
return decorator