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:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}>"
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user