Implement OAuth login with Authentik integration, update user management, and enhance team functionalities

- Added OAuth login functionality using Authentik, allowing users to log in via OpenID.
- Updated user registration and login processes to handle OAuth users.
- Enhanced team management features, including joining and leaving teams, and displaying user-specific team information.
- Improved error handling and user feedback for team actions.
- Added new database migrations for OAuth-related fields in the users table.
- Updated templates to reflect changes in user authentication and team management.
- Refactored dashboard and leaderboard views to include user context and team memberships.
This commit is contained in:
Christian Krakau-Louis
2025-04-14 05:35:51 +02:00
parent fa41b24eac
commit 690065f788
15 changed files with 938 additions and 139 deletions
+208
View File
@@ -0,0 +1,208 @@
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from typing import Optional
import secrets
import os
import uuid
from starlette.status import HTTP_303_SEE_OTHER, HTTP_302_FOUND
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import User
from ..auth.oauth import authentik_oauth
from ..templates_config import templates
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, error: Optional[str] = None, message: Optional[str] = None):
"""Login page route"""
return templates.TemplateResponse(
"auth/login.html",
{"request": request, "error": error, "message": message,
"show_oauth": True, "oauth_provider_name": "Authentik"}
)
@router.post("/login", response_class=HTMLResponse)
async def login_post(
request: Request,
username: str = Form(...),
password: str = Form(...),
remember: Optional[bool] = Form(False),
db: Session = Depends(get_db)
):
"""Handle login form submission"""
# This is a placeholder - implement real login logic here
error = "This login method is not fully implemented yet"
return templates.TemplateResponse(
"auth/login.html",
{"request": request, "error": error, "show_oauth": True, "oauth_provider_name": "Authentik"}
)
@router.get("/register", response_class=HTMLResponse)
async def register_page(request: Request, error: Optional[str] = None):
"""Registration page route"""
return templates.TemplateResponse(
"auth/register.html",
{"request": request, "error": error}
)
@router.post("/register", response_class=HTMLResponse)
async def register_post(
request: Request,
username: str = Form(...),
email: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...),
db: Session = Depends(get_db)
):
"""Handle registration form submission"""
# This is a placeholder - implement real registration logic here
if password != confirm_password:
return templates.TemplateResponse(
"auth/register.html",
{"request": request, "error": "Passwords do not match"}
)
# Check username and email uniqueness, then create user
return templates.TemplateResponse(
"auth/registration_success.html",
{"request": request}
)
@router.get("/oauth-login")
async def oauth_login(request: Request):
"""Start the OAuth login flow"""
# Generate the redirect URI
base_url = str(request.base_url)
redirect_uri = f"{base_url}auth/oauth-callback"
# Request a login URL from the Authentik 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)
# Redirect to the authorization URL
return RedirectResponse(auth_url)
except Exception as e:
print(f"OAuth login error: {str(e)}")
return RedirectResponse(
f"/auth/login?error=OAuth+login+failed:+{str(e)}",
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"""
if error:
return RedirectResponse(
f"/auth/login?error=OAuth+login+failed:+{error}",
status_code=HTTP_303_SEE_OTHER
)
if not code:
return RedirectResponse(
"/auth/login?error=No+authorization+code+received",
status_code=HTTP_303_SEE_OTHER
)
# 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"
try:
# Get user info from the provider
user_info = await authentik_oauth.get_user_info(request, redirect_uri, code)
if not user_info:
return RedirectResponse(
"/auth/login?error=Could+not+retrieve+user+information",
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)
# Check if the user already exists
user = db.query(User).filter(User.email == email).first()
if not user:
print(f"Creating new user with email {email} and username {name}")
# Create a new user
user = User(
username=name,
email=email,
oauth_id=sub,
is_oauth_user=True,
oauth_provider="authentik",
picture=picture,
is_verified=True # OAuth users are considered verified
)
db.add(user)
db.commit()
db.refresh(user)
else:
# 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"
# Update profile picture if available
if picture and not user.picture:
user.picture = picture
db.commit()
# Set session data
request.session["user_id"] = user.id
request.session["username"] = user.username
request.session["is_authenticated"] = True
request.session["is_admin"] = user.is_admin
return RedirectResponse("/dashboard", status_code=HTTP_303_SEE_OTHER)
except Exception as e:
print(f"OAuth callback error: {str(e)}")
return RedirectResponse(
f"/auth/login?error=OAuth+login+failed:+{str(e)}",
status_code=HTTP_303_SEE_OTHER
)
@router.get("/logout")
async def logout(request: Request):
"""Log out the user"""
request.session.clear()
return RedirectResponse("/", status_code=HTTP_303_SEE_OTHER)
@router.get("/profile", response_class=HTMLResponse)
async def profile_page(request: Request):
"""User profile page"""
# Get the user ID from the session
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Mock user data - in a real app, you'd fetch this from the database
user = {
"id": user_id,
"username": request.session.get("username", "User"),
"email": "user@example.com",
"is_admin": request.session.get("is_admin", False),
"created_at": "2023-01-01 12:00:00",
"picture": None
}
return templates.TemplateResponse(
"auth/profile.html",
{"request": request, "user": user}
)
+38 -15
View File
@@ -14,13 +14,17 @@ router = APIRouter()
def user_dashboard(request: Request, db: Session = Depends(get_db)):
"""User dashboard showing teams, events and stats"""
try:
# Fetch user data directly from the database
user = db.query(models.User).filter_by(email="admin@example.com").first() # Example user lookup
# Get user ID from session instead of using hardcoded admin
user_id = request.session.get("user_id")
if not user_id:
# Redirect to login if not authenticated
return RedirectResponse("/auth/login", status_code=303)
# Fetch user data from the database using session user ID
user = db.query(models.User).get(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
user_id = user.id
# Initialize default values in case of errors
team_count = 0
total_points = 0
@@ -28,9 +32,20 @@ def user_dashboard(request: Request, db: Session = Depends(get_db)):
recent_events = []
user_teams = []
# Check if TeamMember model exists before querying
if hasattr(models, "TeamMember"):
# Get the team count for this user
# Get team memberships - check both TeamMember and TeamMembership models
if hasattr(models, "TeamMembership"):
# Primary check - use TeamMembership model
team_count = db.query(func.count(models.TeamMembership.team_id))\
.filter(models.TeamMembership.user_id == user_id)\
.scalar() or 0
# Get user teams
user_teams = db.query(models.Team)\
.join(models.TeamMembership)\
.filter(models.TeamMembership.user_id == user_id)\
.all()
elif hasattr(models, "TeamMember"):
# Fallback to TeamMember model if TeamMembership doesn't exist
team_count = db.query(func.count(models.TeamMember.team_id))\
.filter(models.TeamMember.user_id == user_id)\
.scalar() or 0
@@ -41,15 +56,23 @@ def user_dashboard(request: Request, db: Session = Depends(get_db)):
.filter(models.TeamMember.user_id == user_id)\
.all()
# Check if UserPoints model exists before querying
if hasattr(models, "UserPoints"):
# Get the total points safely
total_points_result = db.query(func.sum(models.UserPoints.points))\
.filter(models.UserPoints.user_id == user_id)\
.first()
# Get total points from QRCode redemptions
# First try directly from QRCodes tied to user
total_points_result = db.query(func.sum(models.QRCode.points))\
.filter(models.QRCode.redeemed_by == user_id, models.QRCode.used == True)\
.first()
if total_points_result and total_points_result[0]:
total_points = total_points_result[0]
if total_points_result and total_points_result[0]:
total_points = total_points_result[0]
else:
# Fallback to UserPoints model if available
if hasattr(models, "UserPoints"):
points_result = db.query(func.sum(models.UserPoints.points))\
.filter(models.UserPoints.user_id == user_id)\
.first()
if points_result and points_result[0]:
total_points = points_result[0]
# Check if EventAttendee model exists before querying
if hasattr(models, "EventAttendee") and hasattr(models, "Event"):
+9 -2
View File
@@ -9,7 +9,7 @@ from sqlalchemy import func, desc
from datetime import datetime, timedelta
from ..db import SessionLocal
from ..models import Team, TeamMembership, QRCode
from ..models import Team, TeamMembership, QRCode, User
from ..templates_config import templates
router = APIRouter()
@@ -29,6 +29,12 @@ async def show_leaderboard(
):
"""Show the leaderboard with team rankings."""
# 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)
# Define cutoff date based on timeframe
cutoff_date = None
if timeframe == "week":
@@ -81,6 +87,7 @@ async def show_leaderboard(
"teams": ranked_teams,
"top_teams": top_teams,
"timeframe": timeframe,
"time_label": time_label
"time_label": time_label,
"user": user # Add user to the context
}
)
+77 -11
View File
@@ -2,45 +2,111 @@
"""
Router for static content pages like about, contact, privacy, and terms.
"""
from fastapi import APIRouter, Request
from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse
from ..templates_config import templates
from datetime import datetime
from sqlalchemy.orm import Session
from ..db import get_db
from .. import models
router = APIRouter()
@router.get("/", response_class=HTMLResponse)
def index(request: Request):
def index(request: Request, db: Session = Depends(get_db)):
"""Home page."""
# Get user from session for navbar
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(models.User).get(user_id)
return templates.TemplateResponse("index.html", {
"request": request,
"now": datetime.now
"now": datetime.now,
"user": user
})
@router.get("/about", response_class=HTMLResponse)
def about(request: Request):
def about(request: Request, db: Session = Depends(get_db)):
"""About page."""
# Get user from session for navbar
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(models.User).get(user_id)
return templates.TemplateResponse("about.html", {
"request": request
"request": request,
"user": user
})
@router.get("/contact", response_class=HTMLResponse)
def contact(request: Request):
def contact(request: Request, db: Session = Depends(get_db)):
"""Contact page."""
# Get user from session for navbar
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(models.User).get(user_id)
return templates.TemplateResponse("contact.html", {
"request": request
"request": request,
"user": user
})
@router.get("/privacy", response_class=HTMLResponse)
def privacy(request: Request):
def privacy(request: Request, db: Session = Depends(get_db)):
"""Privacy policy page."""
# Get user from session for navbar
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(models.User).get(user_id)
return templates.TemplateResponse("privacy.html", {
"request": request
"request": request,
"user": user
})
@router.get("/terms", response_class=HTMLResponse)
def terms(request: Request):
def terms(request: Request, db: Session = Depends(get_db)):
"""Terms and conditions page."""
# Get user from session for navbar
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(models.User).get(user_id)
return templates.TemplateResponse("terms.html", {
"request": request
"request": request,
"user": user
})
@router.get("/cookies", response_class=HTMLResponse)
def cookies(request: Request, db: Session = Depends(get_db)):
"""Cookie policy page."""
# Get user from session for navbar
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(models.User).get(user_id)
return templates.TemplateResponse("cookies.html", {
"request": request,
"user": user
})
@router.get("/impressum", response_class=HTMLResponse)
def impressum(request: Request, db: Session = Depends(get_db)):
"""Imprint/Impressum page."""
# Get user from session for navbar
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(models.User).get(user_id)
return templates.TemplateResponse("impressum.html", {
"request": request,
"user": user
})
+195 -49
View File
@@ -10,7 +10,7 @@ from datetime import datetime, timedelta
import random # For demo data
from ..db import SessionLocal
from ..models import Team, TeamMembership, User, QRCode, TeamAchievement
from ..models import Team, TeamMembership, User, QRCode, TeamAchievement, TeamMember
from ..schemas import TeamCreate
from ..templates_config import templates
@@ -30,12 +30,26 @@ def list_teams(request: Request, db: Session = Depends(get_db)):
# Get the user's teams to highlight teams they're already in
user_team_ids = []
# 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)
# Get teams that user is a member of
memberships = db.query(TeamMembership).filter(TeamMembership.user_id == user_id).all()
user_team_ids = [membership.team_id for membership in memberships]
# Get error message if present
error = request.query_params.get("error")
return templates.TemplateResponse(
"teams.html",
{
"request": request,
"teams": teams,
"user_team_ids": user_team_ids,
"user": user,
"error": error,
"brand_colors": {
"irish_green": "#006837",
"golden_ale": "#FFB400",
@@ -48,31 +62,153 @@ def list_teams(request: Request, db: Session = Depends(get_db)):
@router.post("/create")
def create_team(request: Request, name: str = Form(...), db: Session = Depends(get_db)):
# Create team
new_team = Team(name=name)
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login?next=/teams", status_code=303)
# Get the user
user = db.query(User).get(user_id)
if not user:
return RedirectResponse("/auth/login", status_code=303)
# Check if team name already exists
existing_team = db.query(Team).filter(Team.name == name).first()
if existing_team:
# Return to teams page with error message
# In a real app, you'd add error handling/flash messages
return RedirectResponse("/teams/?error=Team+name+already+exists", status_code=303)
# Create team with the user as owner
new_team = Team(name=name, owner_id=user_id)
db.add(new_team)
db.commit()
db.refresh(new_team)
# Make the user an admin of the team in TeamMembership
team_membership = TeamMembership(
user_id=user_id,
team_id=new_team.id,
is_admin=True # User becomes admin of the team
)
db.add(team_membership)
# Check if TeamMember model exists in the database
try:
# Use a safer approach to check if the model exists and is usable
if 'team_members' in inspect(db.bind).get_table_names():
# Create TeamMember relationship as well
team_member = TeamMember(
user_id=user_id,
team_id=new_team.id,
is_captain=True # User becomes captain in TeamMember model
)
db.add(team_member)
except Exception as e:
print(f"Could not create TeamMember record: {str(e)}")
# Continue even if this fails - TeamMembership is primary relationship
db.commit()
return RedirectResponse("/teams/", status_code=303)
@router.post("/join/{team_id}")
def join_team(request: Request, team_id: int, db: Session = Depends(get_db)):
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login?next=/teams", status_code=303)
# Get user
user = db.query(User).get(user_id)
if not user:
return RedirectResponse("/auth/login", status_code=303)
# Find the team
team = db.query(Team).filter_by(id=team_id).first()
if not team:
return RedirectResponse("/teams/", status_code=303)
return RedirectResponse("/teams/?error=Team+not+found", status_code=303)
# Check if membership exists
existing = db.query(TeamMembership).filter_by(team_id=team.id).first()
existing = db.query(TeamMembership)\
.filter_by(user_id=user_id, team_id=team.id)\
.first()
if existing:
return RedirectResponse("/teams/", status_code=303)
return RedirectResponse("/teams/?error=You+are+already+a+member+of+this+team", status_code=303)
# Create membership
new_member = TeamMembership(team_id=team.id, is_admin=False)
new_member = TeamMembership(
user_id=user_id,
team_id=team.id,
is_admin=False
)
db.add(new_member)
db.commit()
return RedirectResponse("/teams/", status_code=303)
# Redirect to the team detail page
return RedirectResponse(f"/teams/{team_id}", status_code=303)
@router.post("/{team_id}/leave")
def leave_team(request: Request, team_id: int, db: Session = Depends(get_db)):
"""Allow a user to leave a team"""
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login?next=/teams", status_code=303)
# Get team
team = db.query(Team).filter_by(id=team_id).first()
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Can't leave if you're the owner
if team.owner_id == user_id:
return RedirectResponse(f"/teams/{team_id}?error=Team+owner+cannot+leave", status_code=303)
# Find membership
membership = db.query(TeamMembership)\
.filter(TeamMembership.user_id == user_id, TeamMembership.team_id == team_id)\
.first()
if not membership:
return RedirectResponse("/teams/?error=You+are+not+a+member+of+this+team", status_code=303)
# Delete the team membership
db.delete(membership)
db.commit()
return RedirectResponse("/teams/?message=Successfully+left+the+team", status_code=303)
@router.post("/{team_id}/update")
def update_team(
request: Request,
team_id: int,
team_name: str = Form(...),
is_public: bool = Form(False),
db: Session = Depends(get_db)
):
"""Update team details."""
team = db.query(Team).filter_by(id=team_id).first()
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Check if user is admin
membership = db.query(TeamMembership).filter_by(
team_id=team.id,
is_admin=True
).first()
if not membership:
raise HTTPException(status_code=403, detail="You don't have permission to update this team")
# Update team details
team.name = team_name
team.is_public = is_public
db.commit()
return RedirectResponse(f"/teams/{team_id}", status_code=303)
@router.get("/{team_id}", response_class=HTMLResponse)
def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
@@ -82,6 +218,21 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Get user from session for navbar
user = None
user_id = request.session.get("user_id")
is_team_member = False
if user_id:
user = db.query(User).get(user_id)
# Check if user is a team member
team_membership = db.query(TeamMembership)\
.filter(TeamMembership.user_id == user_id, TeamMembership.team_id == team_id)\
.first()
is_team_member = team_membership is not None
# Get team members with admin status
memberships = db.query(TeamMembership).filter_by(team_id=team_id).all()
team_members = []
@@ -90,6 +241,10 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
for membership in memberships:
member = db.query(User).filter_by(id=membership.user_id).first()
if member:
# Check if current user is admin of this team
if user and user.id == membership.user_id and membership.is_admin:
is_user_admin = True
# Use joined_at if available, otherwise use placeholder
joined_date = getattr(membership, 'joined_at', None) or datetime.now() - timedelta(days=random.randint(30, 180))
if isinstance(joined_date, datetime):
@@ -105,21 +260,39 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
"joined": f"{month_name} {year}"
})
# Also check if user is the owner
is_user_owner = user and team.owner_id == user.id
if is_user_owner:
is_user_admin = True # Owner has admin privileges
# Get total points
total_points = db.query(func.sum(QRCode.points)).filter(
QRCode.redeemed_at_team == team_id
).scalar() or 0
# Calculate rank based on points
higher_teams = db.query(func.count(Team.id)).join(
QRCode,
QRCode.redeemed_at_team == Team.id,
isouter=True
).group_by(Team.id).having(
func.sum(QRCode.points) > total_points
).scalar() or 0
team_rank = higher_teams + 1
# Calculate rank based on points - using a safer approach
try:
# First, get the aggregated points for all teams
team_points = db.query(
QRCode.redeemed_at_team,
func.sum(QRCode.points).label('total')
).filter(
QRCode.redeemed_at_team != None
).group_by(QRCode.redeemed_at_team).all()
# Sort them by points (descending)
sorted_teams = sorted(team_points, key=lambda x: x.total or 0, reverse=True)
# Find our team's position
team_rank = 1
for idx, team_data in enumerate(sorted_teams):
if team_data.redeemed_at_team == team_id:
team_rank = idx + 1
break
except Exception as e:
print(f"Error calculating team rank: {e}")
team_rank = 1 # Default to 1st place on error
# Generate points data (with fallbacks for missing columns)
points_this_month = 65 # Default value
@@ -205,37 +378,10 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
"activities": activities,
"performance": performance,
"is_user_admin": is_user_admin,
"is_user_owner": is_user_owner,
"is_team_member": is_team_member,
"days_ago": days_ago,
"founded_date": founded_date_str
"founded_date": founded_date_str,
"user": user # Add user to the context
}
)
@router.post("/{team_id}/update")
def update_team(
request: Request,
team_id: int,
team_name: str = Form(...),
is_public: bool = Form(False),
db: Session = Depends(get_db)
):
"""Update team details."""
team = db.query(Team).filter_by(id=team_id).first()
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Check if user is admin
membership = db.query(TeamMembership).filter_by(
team_id=team.id,
is_admin=True
).first()
if not membership:
raise HTTPException(status_code=403, detail="You don't have permission to update this team")
# Update team details
team.name = team_name
team.is_public = is_public
db.commit()
return RedirectResponse(f"/teams/{team_id}", status_code=303)