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
+11 -2
View File
@@ -10,7 +10,8 @@ from sqlalchemy.orm import Session
from passlib.context import CryptContext
from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event
from .db import SessionLocal
from .db import SessionLocal, engine
from .db_migrations import run_migrations
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -25,6 +26,14 @@ def table_has_column(engine, table_name, column_name):
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def init_db():
"""Initialize the database, applying migrations and seeding data."""
# First, run any needed migrations
run_migrations(engine)
# Then proceed with seeding if needed
seed_db()
def seed_db():
"""Seed the database with test data."""
db = SessionLocal()
@@ -288,4 +297,4 @@ def seed_db():
db.close()
if __name__ == "__main__":
seed_db()
init_db()
+86 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import text, inspect
from sqlalchemy import text, inspect, Column, String, JSON, MetaData, Table
from .db import engine
def table_exists(conn, table_name):
@@ -90,3 +90,88 @@ def apply_migrations():
except Exception as e:
print(f"Error applying migrations: {str(e)}")
def run_migrations(engine):
"""
Run database migrations that can't be handled by SQLAlchemy's create_all()
"""
# Create a MetaData object
metadata = MetaData()
metadata.bind = engine
connection = engine.connect()
try:
print("Running migrations...")
# Check if the columns already exist before adding them
# Add additional_oauth_providers column if it doesn't exist
add_oauth_providers_column(connection)
# Add first_name and last_name columns if they don't exist
add_name_columns(connection)
print("Migrations completed successfully")
except Exception as e:
print(f"Error during migrations: {str(e)}")
finally:
connection.close()
def add_oauth_providers_column(connection):
"""Add additional_oauth_providers column to users table"""
try:
# Use database-agnostic way to check if column exists
inspector = inspect(engine)
columns = [col['name'] for col in inspector.get_columns('users')]
if 'additional_oauth_providers' not in columns:
print("Adding additional_oauth_providers column to users table")
# Add column with database-specific syntax
if engine.name == 'sqlite':
connection.execute(text("""
ALTER TABLE users
ADD COLUMN additional_oauth_providers JSON
"""))
else: # MySQL
connection.execute(text("""
ALTER TABLE users
ADD COLUMN additional_oauth_providers JSON NULL
"""))
connection.commit()
else:
print("Column additional_oauth_providers already exists")
except Exception as e:
print(f"Error adding additional_oauth_providers column: {str(e)}")
def add_name_columns(connection):
"""Add first_name and last_name columns to users table"""
try:
# Use database-agnostic way to check if columns exist
inspector = inspect(engine)
columns = [col['name'] for col in inspector.get_columns('users')]
# Add first_name if needed
if 'first_name' not in columns:
print("Adding first_name column to users table")
connection.execute(text("""
ALTER TABLE users
ADD COLUMN first_name VARCHAR(50) NULL
"""))
connection.commit()
else:
print("Column first_name already exists")
# Add last_name if needed
if 'last_name' not in columns:
print("Adding last_name column to users table")
connection.execute(text("""
ALTER TABLE users
ADD COLUMN last_name VARCHAR(50) NULL
"""))
connection.commit()
else:
print("Column last_name already exists")
except Exception as e:
print(f"Error adding name columns: {str(e)}")
+17 -16
View File
@@ -7,16 +7,17 @@ from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path
import os
from starlette.middleware.sessions import SessionMiddleware
from starlette.middleware.authentication import AuthenticationMiddleware
from dotenv import load_dotenv
import logging
import contextlib
from .db import init_db, engine, get_db
from .db import engine, get_db
from . import models
from .templates_config import templates
from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth, convenience
from .db_init import seed_db
from .db_migrations import apply_migrations
from .db_init import init_db
from .auth.middleware import SessionAuthBackend, on_auth_error
# Configure logging
logging.basicConfig(level=logging.INFO)
@@ -37,8 +38,18 @@ app.add_middleware(
allow_headers=["*"],
)
# Add SessionMiddleware with a secure secret key
# Get secret key for the session
SECRET_KEY = os.getenv("SECRET_KEY", "a-very-secure-secret-key-for-development")
# Important: Order of middleware matters!
# First add AuthenticationMiddleware
app.add_middleware(
AuthenticationMiddleware,
backend=SessionAuthBackend(),
on_error=on_auth_error
)
# Then add SessionMiddleware (last added = first executed)
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
# Initialize database on startup
@@ -49,19 +60,9 @@ async def startup_db_client():
# Import models to ensure they're registered with Base before initialization
from . import models
# Create all tables first
# Initialize database (applies migrations and seeds data)
init_db()
logger.info("Base tables created successfully")
# Apply migrations to add additional columns and constraints
with contextlib.suppress(Exception):
apply_migrations()
logger.info("Migrations applied successfully")
# Seed the database with test data if needed
with contextlib.suppress(Exception):
seed_db()
logger.info("Database seeded successfully")
logger.info("Database initialized and migrated successfully")
except Exception as e:
logger.error(f"Database initialization error: {str(e)}")
+27 -3
View File
@@ -1,14 +1,15 @@
#!/usr/bin/env python3
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float, UniqueConstraint
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float, UniqueConstraint, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
from starlette.authentication import BaseUser
# This Base should be the single source of truth
Base = declarative_base()
class User(Base):
class User(Base, BaseUser):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
@@ -29,7 +30,14 @@ class User(Base):
# OAuth fields
is_oauth_user = Column(Boolean, default=False)
oauth_id = Column(String(255), nullable=True)
oauth_provider = Column(String(50), nullable=True)
oauth_provider = Column(String(50), nullable=True) # Primary OAuth provider
# New field for multiple providers: store as JSON {provider_name: provider_user_id}
additional_oauth_providers = Column(JSON, nullable=True)
# Profile fields
first_name = Column(String(50), nullable=True)
last_name = Column(String(50), nullable=True)
picture = Column(String(255), nullable=True) # URL to profile picture
# Relationships
@@ -38,6 +46,22 @@ class User(Base):
events_attended = relationship("EventAttendee", back_populates="user")
owned_teams = relationship("Team", back_populates="owner")
# BaseUser interface implementation
@property
def is_authenticated(self) -> bool:
"""Return True as this user is authenticated."""
return True
@property
def display_name(self) -> str:
"""Return the display name for this user."""
return self.username
@property
def identity(self) -> str:
"""Return the identity of this user."""
return str(self.id)
def __repr__(self):
return f"<User {self.username}>"
+24 -17
View File
@@ -87,27 +87,34 @@
</div>
<!-- OAuth login options -->
{% if show_oauth %}
<div class="mt-6 pt-6 border-t border-gray-200">
<p class="text-center text-gray-600 mb-4">Or sign in with</p>
<div class="flex justify-center">
<a href="/auth/oauth-login" class="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full flex items-center justify-center">
<i class="fas fa-sign-in-alt mr-2"></i> Authentik
</a>
</div>
</div>
{% else %}
{% if oauth_providers and oauth_providers|length > 0 %}
<div class="mt-6 pt-6 border-t border-gray-200">
<p class="text-center text-gray-600 mb-4">Or sign in with</p>
<!-- For 2 or fewer providers, show them side by side -->
{% if oauth_providers|length <= 2 %}
<div class="flex justify-center space-x-4">
<button class="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full disabled:opacity-50" disabled>
<i class="fab fa-google mr-2"></i> Google
</button>
<button class="bg-gray-800 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full disabled:opacity-50" disabled>
<i class="fab fa-github mr-2"></i> GitHub
</button>
{% for provider in oauth_providers %}
<a href="/auth/oauth-login/{{ provider.id }}"
class="flex items-center justify-center w-full py-2 px-4 rounded-md transition"
style="background-color: {{ provider.color }}; color: white;">
<i class="{{ provider.icon }} mr-2"></i> {{ provider.name }}
</a>
{% endfor %}
</div>
<p class="text-center text-gray-500 text-xs mt-2">OAuth login currently disabled</p>
<!-- For more than 2 providers, stack them vertically -->
{% else %}
<div class="space-y-3">
{% for provider in oauth_providers %}
<a href="/auth/oauth-login/{{ provider.id }}"
class="flex items-center justify-center w-full py-2 px-4 rounded-md transition"
style="background-color: {{ provider.color }}; color: white;">
<i class="{{ provider.icon }} mr-2"></i> {{ provider.name }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
</div>
+31 -2
View File
@@ -1,15 +1,19 @@
"""
Authentication utilities for LeagueLedger.
This module provides backward compatibility with the existing code while leveraging
the new Starlette authentication system.
"""
from typing import Optional
from fastapi import Request, Depends
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import User, TeamMembership
from starlette.authentication import UnauthenticatedUser
async def get_current_user(request: Request, db: Session = Depends(get_db)) -> Optional[User]:
"""
Get the current authenticated user from the session.
Get the current authenticated user from the request.
Now uses request.user from Starlette authentication with fallback to session.
Args:
request: The FastAPI request object
@@ -18,6 +22,11 @@ async def get_current_user(request: Request, db: Session = Depends(get_db)) -> O
Returns:
User object if authenticated, None otherwise
"""
# First try to get user from Starlette authentication
if hasattr(request, "user") and request.user.is_authenticated:
return request.user
# Fallback to session-based authentication (for backward compatibility)
user_id = request.session.get("user_id")
if not user_id:
@@ -61,7 +70,7 @@ async def is_team_captain(
is_captain = db.query(TeamMembership).filter(
TeamMembership.team_id == team_id,
TeamMembership.user_id == user.id,
TeamMembership.role == "captain"
TeamMembership.is_captain == True # Changed from role="captain" to is_captain=True
).first()
return bool(is_captain)
@@ -69,6 +78,7 @@ async def is_team_captain(
async def is_admin(request: Request, db: Session = Depends(get_db)) -> bool:
"""
Check if the current user is an admin.
Now checks Starlette auth scopes first.
Args:
request: FastAPI request object
@@ -77,6 +87,11 @@ async def is_admin(request: Request, db: Session = Depends(get_db)) -> bool:
Returns:
True if the user is an admin, False otherwise
"""
# Check for 'admin' scope in Starlette auth
if hasattr(request, "auth") and request.auth and "admin" in request.auth.scopes:
return True
# Fallback to user object check
user = await get_current_user(request, db)
if not user:
@@ -97,6 +112,11 @@ async def requires_login(request: Request, db: Session = Depends(get_db)) -> Opt
"""
from fastapi import HTTPException, status
# Check Starlette auth first
if hasattr(request, "user") and request.user.is_authenticated:
return request.user
# Fallback to session check
user = await get_current_user(request, db)
if not user:
@@ -121,6 +141,12 @@ async def requires_admin(request: Request, db: Session = Depends(get_db)) -> Use
"""
from fastapi import HTTPException, status
# Check for admin scope in Starlette auth
if hasattr(request, "auth") and request.auth and "admin" in request.auth.scopes:
if hasattr(request, "user") and request.user.is_authenticated:
return request.user
# Fallback to user object check
user = await get_current_user(request, db)
if not user or not user.is_admin:
@@ -130,3 +156,6 @@ async def requires_admin(request: Request, db: Session = Depends(get_db)) -> Use
)
return user
# Note: For new code, consider using the decorators in app.auth.permissions instead
# of these dependency functions directly
+13 -44
View File
@@ -16,6 +16,7 @@ from ..models import (
OAuthAccount, TeamJoinRequest, EventAttendee, UserPoints
)
from ..templates_config import templates
from ..auth.permissions import require_admin
router = APIRouter()
@@ -74,25 +75,18 @@ def get_relationships(model_class: Type[Base]) -> Dict[str, str]:
return relationships
@router.get("/", response_class=HTMLResponse)
@require_admin(redirect_url="/auth/login?next=/admin/")
async def admin_home(request: Request, db: Session = Depends(get_db)):
"""Admin dashboard home."""
# Get user from session for navbar
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(User).get(user_id)
# Check admin status
if not user or not user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden: Admin access required")
# Since we're using Starlette's authentication, the user is now available in request.user
model_list = [(key, name) for key, (_, name) in MODELS.items()]
return templates.TemplateResponse(
"admin/index.html",
{"request": request, "models": model_list, "user": user}
{"request": request, "models": model_list, "user": request.user}
)
@router.get("/{model_name}", response_class=HTMLResponse)
@require_admin(redirect_url="/auth/login")
async def list_records(
request: Request,
model_name: str,
@@ -101,16 +95,6 @@ async def list_records(
db: Session = Depends(get_db)
):
"""List records for a model with pagination."""
# Get user from session for navbar
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(User).get(user_id)
# Check admin status
if not user or not user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden: Admin access required")
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
@@ -150,27 +134,18 @@ async def list_records(
"per_page": per_page,
"total_pages": total_pages,
"total_records": total_records,
"user": user # Add user to the context
"user": request.user # Add user to the context
}
)
@router.get("/{model_name}/new", response_class=HTMLResponse)
@require_admin(redirect_url="/auth/login")
async def create_record_form(
request: Request,
model_name: str,
db: Session = Depends(get_db)
):
"""Show form for creating a new record."""
# Get user from session for navbar
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(User).get(user_id)
# Check admin status
if not user or not user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden: Admin access required")
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
@@ -201,11 +176,12 @@ async def create_record_form(
"record": None, # No record for new form
"foreign_key_options": foreign_key_options,
"is_new": True,
"user": user # Add user to the context
"user": request.user # Use request.user from Starlette authentication
}
)
@router.post("/{model_name}/new")
@require_admin(redirect_url="/auth/login")
async def create_record(
request: Request,
model_name: str,
@@ -254,6 +230,7 @@ async def create_record(
return RedirectResponse(f"/admin/{model_name}", status_code=303)
@router.get("/{model_name}/{record_id}", response_class=HTMLResponse)
@require_admin(redirect_url="/auth/login")
async def edit_record_form(
request: Request,
model_name: str,
@@ -261,16 +238,6 @@ async def edit_record_form(
db: Session = Depends(get_db)
):
"""Show form for editing an existing record."""
# Get user from session for navbar
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(User).get(user_id)
# Check admin status
if not user or not user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden: Admin access required")
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
@@ -311,11 +278,12 @@ async def edit_record_form(
"record": record_data,
"foreign_key_options": foreign_key_options,
"is_new": False,
"user": user # Add user to the context
"user": request.user # Use request.user from Starlette authentication
}
)
@router.post("/{model_name}/{record_id}")
@require_admin(redirect_url="/auth/login")
async def update_record(
request: Request,
model_name: str,
@@ -364,6 +332,7 @@ async def update_record(
return RedirectResponse(f"/admin/{model_name}", status_code=303)
@router.get("/{model_name}/{record_id}/delete")
@require_admin(redirect_url="/auth/login")
async def delete_record(
request: Request,
model_name: str,
+114 -36
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status, BackgroundTasks
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from typing import Optional
from typing import Optional, List, Dict, Any
import secrets
import os
import uuid
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta
from ..db import get_db
from ..models import User
from ..auth.oauth import authentik_oauth
from ..auth.oauth import oauth_manager
from ..templates_config import templates
from ..security import verify_password, get_password_hash
from ..utils.mail import send_password_reset_email
@@ -31,11 +31,18 @@ async def login_page(request: Request, error: Optional[str] = None, message: Opt
if not message:
message = "You are already logged in."
# Get available OAuth providers for the login screen
oauth_providers = oauth_manager.get_available_providers()
return templates.TemplateResponse(
"auth/login.html",
{"request": request, "error": error, "message": message,
"show_oauth": True, "oauth_provider_name": "Authentik",
"user": user} # Add user to context
{
"request": request,
"error": error,
"message": message,
"user": user,
"oauth_providers": oauth_providers
}
)
@router.post("/login", response_class=HTMLResponse)
@@ -71,8 +78,7 @@ async def login_post(
{
"request": request,
"error": "Please verify your email address before logging in",
"show_oauth": True,
"oauth_provider_name": "Authentik",
"oauth_providers": oauth_manager.get_available_providers(),
"unverified_user_id": user.id,
"unverified_email": user.email
}
@@ -85,8 +91,7 @@ async def login_post(
{
"request": request,
"error": error,
"show_oauth": True,
"oauth_provider_name": "Authentik"
"oauth_providers": oauth_manager.get_available_providers()
}
)
@@ -317,21 +322,21 @@ async def resend_verification(
status_code=HTTP_303_SEE_OTHER
)
@router.get("/oauth-login")
async def oauth_login(request: Request):
"""Start the OAuth login flow"""
@router.get("/oauth-login/{provider_id}")
async def oauth_login(request: Request, provider_id: str):
"""Start the OAuth login flow for the specified provider"""
# Generate the redirect URI
base_url = str(request.base_url)
redirect_uri = f"{base_url}auth/oauth-callback"
redirect_uri = f"{base_url}auth/oauth-callback/{provider_id}"
# Request a login URL from the Authentik provider
# Request a login URL from the provider
try:
# Set a session ID to validate the callback
if "session_id" not in request.session:
request.session["session_id"] = str(uuid.uuid4())
# Get the authorization URL - make sure to await it
auth_url = await authentik_oauth.get_login_url(request, redirect_uri)
# Get the authorization URL
auth_url = await oauth_manager.get_login_url(request, provider_id, redirect_uri)
# Redirect to the authorization URL
return RedirectResponse(auth_url)
@@ -342,9 +347,16 @@ async def oauth_login(request: Request):
status_code=HTTP_303_SEE_OTHER
)
@router.get("/oauth-callback")
async def oauth_callback(request: Request, code: Optional[str] = None, state: Optional[str] = None, error: Optional[str] = None, db: Session = Depends(get_db)):
"""Handle the OAuth callback"""
@router.get("/oauth-callback/{provider_id}")
async def oauth_callback(
request: Request,
provider_id: str,
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Handle the OAuth callback for the specified provider"""
if error:
return RedirectResponse(
f"/auth/login?error=OAuth+login+failed:+{error}",
@@ -359,11 +371,11 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
# Generate the redirect URI that matches the one used in the initial request
base_url = str(request.base_url)
redirect_uri = f"{base_url}auth/oauth-callback"
redirect_uri = f"{base_url}auth/oauth-callback/{provider_id}"
try:
# Get user info from the provider
user_info = await authentik_oauth.get_user_info(request, redirect_uri, code)
# Get normalized user info from the provider
user_info = await oauth_manager.get_user_info(request, provider_id, redirect_uri, code)
if not user_info:
return RedirectResponse(
@@ -371,24 +383,43 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
status_code=HTTP_303_SEE_OTHER
)
# Extract user details from OAuth info
sub = user_info.get("sub", "")
email = user_info.get("email", "")
name = user_info.get("preferred_username", "") or user_info.get("name", "") or email.split("@")[0]
picture = user_info.get("picture", None)
# Extract user details from the normalized OAuth info
provider_user_id = user_info.get("id")
email = user_info.get("email")
name = user_info.get("name") or (email.split("@")[0] if email else f"user_{provider_id}")
first_name = user_info.get("first_name")
last_name = user_info.get("last_name")
picture = user_info.get("picture")
# Check if the user already exists
if not email:
return RedirectResponse(
"/auth/login?error=Email+address+not+provided+by+the+OAuth+provider",
status_code=HTTP_303_SEE_OTHER
)
# Check if the user already exists with this email
user = db.query(User).filter(User.email == email).first()
if not user:
print(f"Creating new user with email {email} and username {name}")
# Set username to be unique if it already exists
username = name
base_username = username
counter = 1
# Check if username already exists
while db.query(User).filter(User.username == username).first():
username = f"{base_username}{counter}"
counter += 1
# Create a new user
user = User(
username=name,
username=username,
email=email,
oauth_id=sub,
oauth_id=provider_user_id,
is_oauth_user=True,
oauth_provider="authentik",
oauth_provider=provider_id,
first_name=first_name,
last_name=last_name,
picture=picture,
is_verified=True # OAuth users are considered verified
)
@@ -399,13 +430,27 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
# Update existing user's OAuth information
if not user.is_oauth_user:
user.is_oauth_user = True
user.oauth_id = sub
user.oauth_provider = "authentik"
user.oauth_id = provider_user_id
# Update profile picture if available
# If user has a different OAuth provider, add this one as additional
if user.oauth_provider and user.oauth_provider != provider_id:
if not user.additional_oauth_providers:
user.additional_oauth_providers = {}
user.additional_oauth_providers[provider_id] = provider_user_id
else:
user.oauth_provider = provider_id
# Update profile info if not already set
if first_name and not user.first_name:
user.first_name = first_name
if last_name and not user.last_name:
user.last_name = last_name
if picture and not user.picture:
user.picture = picture
# Update last login time
user.last_login = datetime.utcnow()
db.commit()
# Set session data
@@ -413,8 +458,11 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
request.session["username"] = user.username
request.session["is_authenticated"] = True
request.session["is_admin"] = user.is_admin
request.session["oauth_provider"] = provider_id # Store the provider for possible UI customization
return RedirectResponse("/dashboard", status_code=HTTP_303_SEE_OTHER)
# Redirect to dashboard or the requested next page
next_page = request.query_params.get("next", "/dashboard")
return RedirectResponse(next_page, status_code=HTTP_303_SEE_OTHER)
except Exception as e:
print(f"OAuth callback error: {str(e)}")
@@ -423,6 +471,36 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
status_code=HTTP_303_SEE_OTHER
)
# Legacy route for backward compatibility
@router.get("/oauth-login")
async def legacy_oauth_login(request: Request):
"""Redirect to Authentik OAuth login for backward compatibility"""
return RedirectResponse("/auth/oauth-login/authentik", status_code=HTTP_302_FOUND)
@router.get("/oauth-callback")
async def legacy_oauth_callback(
request: Request,
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Redirect to Authentik OAuth callback handler for backward compatibility"""
params = []
if code:
params.append(f"code={code}")
if state:
params.append(f"state={state}")
if error:
params.append(f"error={error}")
query_string = "&".join(params)
redirect_url = f"/auth/oauth-callback/authentik"
if query_string:
redirect_url = f"{redirect_url}?{query_string}"
return RedirectResponse(redirect_url, status_code=HTTP_302_FOUND)
@router.get("/logout")
async def logout(request: Request):
"""Log out the user"""
+59
View File
@@ -18,6 +18,7 @@ from ..schemas import TeamCreate
from ..templates_config import templates
from ..utils.auth import get_current_user, is_team_captain
from ..utils.mail import send_team_join_request_notification, send_join_request_response
from ..auth.permissions import require_auth, require_team_captain
router = APIRouter()
@@ -605,3 +606,61 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
"is_open": is_open # Pass is_open to the template
}
)
@router.get("/{team_id}/manage", response_class=HTMLResponse)
@require_team_captain()
async def manage_team(request: Request, team_id: int, db: Session = Depends(get_db)):
"""
Team management page for captains.
This route is protected by the require_team_captain decorator which
automatically checks if the user is a captain of the team.
"""
# Get team
team = db.query(Team).filter_by(id=team_id).first()
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Get pending join requests
join_requests = db.query(TeamJoinRequest).filter(
TeamJoinRequest.team_id == team_id,
TeamJoinRequest.status == "pending"
).all()
# Get requesters' info
pending_requests = []
for req in join_requests:
requester = db.query(User).filter(User.id == req.user_id).first()
if requester:
pending_requests.append({
"request": req,
"user": requester
})
# Get team members
memberships = db.query(TeamMembership).filter_by(team_id=team_id).all()
team_members = []
for membership in memberships:
member = db.query(User).filter_by(id=membership.user_id).first()
if member:
team_members.append({
"user": member,
"is_admin": membership.is_admin,
"is_captain": membership.is_captain
})
# Get current user from session for template
current_user = None
if hasattr(request, "session") and request.session.get("user_id"):
current_user = db.query(User).filter_by(id=request.session.get("user_id")).first()
return templates.TemplateResponse(
"teams/manage.html",
{
"request": request,
"team": team,
"pending_requests": pending_requests,
"team_members": team_members,
"user": current_user # Use current user from session instead of request.user
}
)