Refactor authentication and session management

- Updated Dockerfile to use Python 3.13 and streamline system dependencies installation.
- Enhanced user session handling in auth.py, introducing a centralized method for retrieving the current user from the session.
- Removed deprecated auth routes and consolidated logic for user authentication and profile management.
- Improved error handling and user feedback in redeem.py and teams.py, ensuring users are redirected to login when not authenticated.
- Updated requirements.txt to include necessary packages for enhanced security and functionality.
- Added phpMyAdmin service to docker-compose for easier database management.
This commit is contained in:
Christian Krakau-Louis
2025-04-13 19:26:26 +02:00
parent 4a79c5cacb
commit 9a0479cb37
9 changed files with 255 additions and 703 deletions
+13 -21
View File
@@ -1,31 +1,23 @@
# Use Python 3.11 (or whichever version you prefer) FROM python:3.13
FROM python:3.11-slim
# Create working directory
WORKDIR /app WORKDIR /app
# Install system dependencies needed for mysqlclient # Install system dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && \
pkg-config \ apt-get install -y --no-install-recommends gcc libc-dev default-libmysqlclient-dev && \
default-libmysqlclient-dev \ apt-get clean && \
build-essential \ rm -rf /var/lib/apt/lists/*
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first (for caching) # Copy requirements first to leverage Docker cache
COPY requirements.txt . COPY requirements.txt .
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Explicitly install pymysql (in case it's missing from requirements.txt) # Copy application code
RUN pip install --no-cache-dir pymysql cryptography
# Copy the rest of the code
COPY . . COPY . .
# Expose port # Set environment variables
EXPOSE 8000 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Run the FastAPI app with Uvicorn # Command to run when container starts
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--reload"]
+54 -29
View File
@@ -16,6 +16,7 @@ from passlib.context import CryptContext
from .db import SessionLocal from .db import SessionLocal
from .templates_config import templates from .templates_config import templates
from . import models from . import models
from .dependencies import get_user_from_session
# Initialize OAuth # Initialize OAuth
oauth = OAuth() oauth = OAuth()
@@ -53,15 +54,9 @@ def verify_password(plain_password, hashed_password):
"""Verify password against hash""" """Verify password against hash"""
return pwd_context.verify(plain_password, hashed_password) return pwd_context.verify(plain_password, hashed_password)
def get_current_user(request: Request): def get_current_user(request: Request, db: Session = Depends(get_db)):
"""Get current user from session with improved error handling""" """Get current user from session"""
try: return get_user_from_session(request, db)
if "session" not in request.scope:
return None
return request.session.get("user")
except Exception as e:
print(f"Error getting user from session: {str(e)}")
return None
def get_gravatar_url(email): def get_gravatar_url(email):
"""Generate a Gravatar URL for the given email""" """Generate a Gravatar URL for the given email"""
@@ -74,10 +69,12 @@ def require_login(func):
@wraps(func) @wraps(func)
async def wrapper(request: Request, *args, **kwargs): async def wrapper(request: Request, *args, **kwargs):
try: try:
if "session" not in request.scope or not request.session.get("user"): db = next(get_db())
user = get_user_from_session(request, db)
if not user:
# Store the current URL for redirecting after login # Store the current URL for redirecting after login
if "session" in request.scope: request.session["redirect_after_login"] = str(request.url)
request.session["redirect_after_login"] = str(request.url)
return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND)
# Check if the wrapped function is a coroutine function # Check if the wrapped function is a coroutine function
@@ -96,15 +93,14 @@ def require_admin(func):
@wraps(func) @wraps(func)
async def wrapper(request: Request, *args, **kwargs): async def wrapper(request: Request, *args, **kwargs):
try: try:
if "session" not in request.scope: db = next(get_db())
return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND) user = get_user_from_session(request, db)
user = request.session.get("user")
if not user: if not user:
request.session["redirect_after_login"] = str(request.url) request.session["redirect_after_login"] = str(request.url)
return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND)
if not user.get("is_admin", False): if not user.is_admin:
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
# Check if the wrapped function is a coroutine function # Check if the wrapped function is a coroutine function
@@ -121,10 +117,10 @@ def require_admin(func):
# Routes for authentication # Routes for authentication
@router.get("/login") @router.get("/login")
async def login_page(request: Request): async def login_page(request: Request, db: Session = Depends(get_db)):
"""Show login page with appropriate authentication options""" """Show login page with appropriate authentication options"""
# If already logged in, redirect to home # If already logged in, redirect to home
if request.session.get("user"): if get_user_from_session(request, db):
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -153,17 +149,21 @@ async def login(request: Request, db: Session = Depends(get_db)):
status_code=status.HTTP_302_FOUND status_code=status.HTTP_302_FOUND
) )
# Create user session
# Make sure we use the actual is_admin field from the User model # Make sure we use the actual is_admin field from the User model
is_admin_value = False is_admin_value = False
if hasattr(user, "is_admin") and user.is_admin is not None: if hasattr(user, "is_admin") and user.is_admin is not None:
is_admin_value = user.is_admin is_admin_value = user.is_admin
# Store user info in session - STANDARDIZED APPROACH
# Store both the user_id (for new code) and the full user dict (for backward compatibility)
request.session["user_id"] = user.id
# Also store the legacy format for backward compatibility
request.session["user"] = { request.session["user"] = {
"id": user.id, "id": user.id,
"username": user.username, "username": user.username,
"email": user.email, "email": user.email,
"is_admin": is_admin_value, # Use the actual is_admin value from the database "is_admin": is_admin_value,
"picture": get_gravatar_url(user.email), "picture": get_gravatar_url(user.email),
"_permanent": True, "_permanent": True,
"created_at": str(user.created_at) "created_at": str(user.created_at)
@@ -229,15 +229,18 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
if hasattr(user, "is_admin") and user.is_admin is not None: if hasattr(user, "is_admin") and user.is_admin is not None:
is_admin_value = user.is_admin is_admin_value = user.is_admin
# Store user info in session # Store user ID in session (new standardized approach)
request.session["user_id"] = user.id
# Also store legacy user data format
user_data = { user_data = {
"id": user.id, "id": user.id,
"username": user.username, "username": user.username,
"email": user.email, "email": user.email,
"is_active": user.is_active, "is_active": getattr(user, "is_active", True),
"is_admin": is_admin_value, # Set the admin status correctly "is_admin": is_admin_value,
"_permanent": True, "_permanent": True,
"created_at": str(user.created_at), "created_at": str(getattr(user, "created_at", "")),
} }
# Add picture from OAuth or Gravatar # Add picture from OAuth or Gravatar
@@ -265,6 +268,7 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
async def logout(request: Request): async def logout(request: Request):
"""Handle user logout""" """Handle user logout"""
request.session.pop("user", None) request.session.pop("user", None)
request.session.pop("user_id", None)
return RedirectResponse( return RedirectResponse(
url="/auth/login?message=You+have+been+logged+out+successfully", url="/auth/login?message=You+have+been+logged+out+successfully",
status_code=status.HTTP_302_FOUND status_code=status.HTTP_302_FOUND
@@ -272,9 +276,9 @@ async def logout(request: Request):
@router.get("/profile") @router.get("/profile")
@require_login @require_login
async def profile_page(request: Request): async def profile_page(request: Request, db: Session = Depends(get_db)):
"""Show user profile page""" """Show user profile page"""
user = request.session.get("user") user = get_user_from_session(request, db)
return templates.TemplateResponse("auth/profile.html", {"request": request, "user": user}) return templates.TemplateResponse("auth/profile.html", {"request": request, "user": user})
@router.get("/register") @router.get("/register")
@@ -327,6 +331,19 @@ async def register(request: Request, db: Session = Depends(get_db)):
) )
db.add(user) db.add(user)
db.commit() db.commit()
db.refresh(user)
# Log the user in immediately after registration
request.session["user_id"] = user.id
request.session["user"] = {
"id": user.id,
"username": user.username,
"email": user.email,
"is_admin": False,
"picture": get_gravatar_url(user.email),
"_permanent": True,
"created_at": str(getattr(user, "created_at", ""))
}
# Redirect to registration success page # Redirect to registration success page
return RedirectResponse( return RedirectResponse(
@@ -343,7 +360,15 @@ async def registration_success(request: Request):
) )
@router.get("/api/whoami") @router.get("/api/whoami")
async def whoami(request: Request): async def whoami(request: Request, db: Session = Depends(get_db)):
"""API endpoint to get current user information""" """API endpoint to get current user information"""
user = request.session.get("user") user = get_user_from_session(request, db)
return user or {"error": "Not authenticated"} if not user:
return {"error": "Not authenticated"}
return {
"id": user.id,
"username": user.username,
"email": user.email,
"is_admin": user.is_admin if hasattr(user, "is_admin") else False
}
+54 -18
View File
@@ -3,24 +3,50 @@ from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .db import get_db from .db import get_db
from . import models from . import models
from .auth import get_current_user, require_login, require_admin
# Reuse functions from auth.py # OAuth2 configuration for API-based authentication
# This is just for backward compatibility with any code that imported these from dependencies
# Function to get a db session
def get_session_db():
return next(get_db())
# Function to get current authenticated user
def get_authenticated_user(request: Request):
return get_current_user(request)
# These are kept for API backward compatibility
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
def get_current_active_user(request: Request): # Function to get user from session
user = get_current_user(request) def get_user_from_session(request: Request, db: Session = Depends(get_db)):
"""
Get the current user from session.
This is the SINGLE source of truth for session-based auth.
"""
try:
# First check if request has session attribute and it's a dictionary
if not hasattr(request, "session"):
print("No session object found in request")
return None
# Make sure session is a dictionary before trying to access it
if not isinstance(request.session, dict):
print(f"Session is not a dictionary: {type(request.session)}")
return None
# Standard method - get user_id from session
user_id = request.session.get("user_id")
if user_id:
return db.query(models.User).filter(models.User.id == user_id).first()
# Legacy method - get user from session.user
user_dict = request.session.get("user")
if user_dict and isinstance(user_dict, dict) and "id" in user_dict:
return db.query(models.User).filter(models.User.id == user_dict["id"]).first()
return None
except Exception as e:
print(f"Error getting user from session: {str(e)}")
return None
# Function to get current authenticated user
def get_current_user(request: Request, db: Session = Depends(get_db)):
"""Get current authenticated user from session"""
return get_user_from_session(request, db)
def get_current_active_user(request: Request, db: Session = Depends(get_db)):
"""Get current user and ensure they're authenticated"""
user = get_user_from_session(request, db)
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -29,11 +55,21 @@ def get_current_active_user(request: Request):
) )
return user return user
def get_current_admin_user(request: Request): def get_current_admin_user(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request) """Get current user and ensure they're an admin"""
if not user or not user.get("is_admin"): user = get_user_from_session(request, db)
if not user or not user.is_admin:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions", detail="Not enough permissions",
) )
return user return user
# Keep for backward compatibility
def get_session_db():
"""Legacy function to get DB session directly"""
return next(get_db())
def get_authenticated_user(request: Request, db: Session = Depends(get_db)):
"""Legacy function for getting the current user"""
return get_user_from_session(request, db)
+35 -26
View File
@@ -10,7 +10,8 @@ from . import models
from .templates_config import templates from .templates_config import templates
from .views import qr, redeem, teams, admin, leaderboard, dashboard from .views import qr, redeem, teams, admin, leaderboard, dashboard
from .db_init import seed_db from .db_init import seed_db
from .auth import router as auth_router, get_current_user from .auth import router as auth_router
from .dependencies import get_user_from_session
# Create tables on startup # Create tables on startup
init_db() init_db()
@@ -19,24 +20,26 @@ init_db()
# In a production app, you would handle this differently # In a production app, you would handle this differently
seed_db() seed_db()
# Initialize FastAPI app
app = FastAPI() app = FastAPI()
# Configure session middleware with environment variables or defaults # Configure session middleware settings
secret_key = os.environ.get("SECRET_KEY", "a-default-secret-key-for-sessions-please-change-this") secret_key = os.environ.get("SECRET_KEY", "a-default-secret-key-for-sessions-please-change-this")
if len(secret_key) < 32: if len(secret_key) < 32:
print(f"WARNING: Secret key is too short ({len(secret_key)} chars). Recommended: 32+ chars") print(f"WARNING: Secret key is too short ({len(secret_key)} chars). Recommended: 32+ chars")
# Apply SessionMiddleware FIRST - it must be the first middleware in the stack # IMPORTANT: Add SessionMiddleware before anything else
# This must be the FIRST middleware in the stack
app.add_middleware( app.add_middleware(
SessionMiddleware, SessionMiddleware,
secret_key=secret_key, secret_key=secret_key,
max_age=int(os.environ.get("SESSION_MAX_AGE", "86400")), # 24 hours by default max_age=int(os.environ.get("SESSION_MAX_AGE", "86400")), # 24 hours by default
same_site="lax", # Important for security while allowing redirects same_site="lax",
https_only=os.environ.get("COOKIE_SECURE", "False").lower() == "true", https_only=os.environ.get("COOKIE_SECURE", "False").lower() == "true",
session_cookie="league_ledger_session", # Custom cookie name session_cookie="league_ledger_session",
) )
# Debug middleware to track session state # Debug middleware - ADDED AFTER SessionMiddleware
@app.middleware("http") @app.middleware("http")
async def debug_session_middleware(request, call_next): async def debug_session_middleware(request, call_next):
"""Debug middleware to track session state""" """Debug middleware to track session state"""
@@ -44,18 +47,16 @@ async def debug_session_middleware(request, call_next):
session_cookie = request.cookies.get("league_ledger_session") session_cookie = request.cookies.get("league_ledger_session")
print(f"Request path: {request.url.path}") print(f"Request path: {request.url.path}")
# Instead of checking request.scope, check if we can access the session dict # First check if the session attribute exists properly
has_session = hasattr(request, "session") and isinstance(request.session, dict) has_session = hasattr(request, "session")
is_dict = has_session and isinstance(request.session, dict)
print(f"Has session attribute: {has_session}") print(f"Has session attribute: {has_session}")
print(f"Session is dict: {is_dict}")
print(f"Has session cookie: {session_cookie is not None}") print(f"Has session cookie: {session_cookie is not None}")
# Check session data # Safely check session data
if hasattr(request, "session"): if has_session and is_dict:
try: print(f"Session data before: {dict(request.session)}")
print(f"Session data before: {dict(request.session)}")
except (TypeError, AttributeError):
# The session might not be dict-like
print(f"Session exists but isn't a dictionary")
except Exception as e: except Exception as e:
print(f"Error in debug middleware (pre): {str(e)}") print(f"Error in debug middleware (pre): {str(e)}")
@@ -63,22 +64,27 @@ async def debug_session_middleware(request, call_next):
try: try:
if hasattr(request, "session"): if hasattr(request, "session"):
try: print(f"Session data after: {dict(request.session)}")
print(f"Session data after: {dict(request.session)}")
except (TypeError, AttributeError):
print(f"Session exists but isn't a dictionary")
except Exception as e: except Exception as e:
print(f"Error in debug middleware (post): {str(e)}") print(f"Error in debug middleware (post): {str(e)}")
return response return response
# User context middleware to make user available in templates # User context middleware
@app.middleware("http") @app.middleware("http")
async def add_user_to_request(request: Request, call_next): async def add_user_to_request(request: Request, call_next):
"""Add user to request state and update template globals""" """Add user to request state and update template globals"""
try: try:
# Get user from session if available # Get database connection from context
user = get_current_user(request) from .db import SessionLocal
db = SessionLocal()
# Get user from session with safer approach
user = None
try:
user = get_user_from_session(request, db)
except Exception as e:
print(f"Error getting user for request context: {str(e)}")
# Store user in request.state for route handlers # Store user in request.state for route handlers
request.state.user = user request.state.user = user
@@ -88,17 +94,20 @@ async def add_user_to_request(request: Request, call_next):
# Debug output to check user and admin status # Debug output to check user and admin status
if user: if user:
print(f"User in context: {user.get('username')}, Admin: {user.get('is_admin', False)}") print(f"User in context: {user.username}, Admin: {getattr(user, 'is_admin', False)}")
except Exception as e: except Exception as e:
print(f"Error setting user context: {str(e)}") print(f"Error setting user context: {str(e)}")
finally:
# Always close the DB connection
if 'db' in locals():
db.close()
# Process the request # Process the request
response = await call_next(request) response = await call_next(request)
return response return response
# Routers # Routers - include after middleware setup is complete
app.include_router(auth_router, prefix="/auth", tags=["Auth"]) # Auth router should be first app.include_router(auth_router, prefix="/auth", tags=["Auth"])
app.include_router(qr.router, prefix="/qr", tags=["QR"]) app.include_router(qr.router, prefix="/qr", tags=["QR"])
app.include_router(redeem.router, prefix="/redeem", tags=["Redeem"]) app.include_router(redeem.router, prefix="/redeem", tags=["Redeem"])
app.include_router(teams.router, prefix="/teams", tags=["Teams"]) app.include_router(teams.router, prefix="/teams", tags=["Teams"])
-560
View File
@@ -1,560 +0,0 @@
#!/usr/bin/env python3
"""
Authentication routes for user login, registration, and management.
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from sqlalchemy import inspect
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import smtplib
from email.message import EmailMessage
import os
from ..db import SessionLocal, engine
from ..models import User
from ..security import (
verify_password, get_password_hash, create_access_token, generate_token,
SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
)
from ..dependencies import get_db, get_current_user, get_user_from_session
from ..templates_config import templates
from ..schemas import UserCreate, UserLogin, UserUpdate, PasswordReset
router = APIRouter()
# Check if all required user columns exist
def get_available_user_columns():
inspector = inspect(engine)
if 'users' in inspector.get_table_names():
return [col['name'] for col in inspector.get_columns('users')]
return []
# --- HTML ROUTES (Web UI) ---
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, next: str = "/"):
"""Display login page."""
# Debug print to check what's happening in the route
print("Login page accessed, checking session...")
# DO NOT redirect if user is logged in - there's likely an issue with session handling
# Just render the login page regardless of session state for now
return templates.TemplateResponse(
"auth/login.html",
{
"request": request,
"next": next,
"messages": []
}
)
@router.post("/login", response_class=HTMLResponse)
async def login(
request: Request,
response: Response,
db: Session = Depends(get_db),
username: str = Form(...),
password: str = Form(...),
remember: bool = Form(False),
next: str = Form("/")
):
"""Process login form."""
# Try to authenticate user
user = db.query(User).filter((User.username == username) | (User.email == username)).first()
if not user or not verify_password(password, user.hashed_password):
return templates.TemplateResponse(
"auth/login.html",
{
"request": request,
"next": next,
"messages": [{"type": "error", "text": "Invalid username or password"}],
"username": username
},
status_code=status.HTTP_401_UNAUTHORIZED
)
# Check if is_active column exists and if user is active
columns = get_available_user_columns()
if 'is_active' in columns and hasattr(user, 'is_active') and not user.is_active:
return templates.TemplateResponse(
"auth/login.html",
{
"request": request,
"next": next,
"messages": [{"type": "error", "text": "Account is deactivated"}],
"username": username
},
status_code=status.HTTP_401_UNAUTHORIZED
)
# Update last login time if column exists
if 'last_login' in columns and hasattr(user, 'last_login'):
user.last_login = datetime.utcnow()
db.commit()
# Debug info
print(f"User authenticated: {user.username}")
# Set session data with better error handling
try:
# Use directly accessible dictionary
request.session["user_id"] = user.id
request.session["username"] = user.username
request.session["_permanent"] = True
# Check if is_admin attribute exists
if hasattr(user, "is_admin"):
request.session["is_admin"] = user.is_admin
else:
request.session["is_admin"] = False
# Add timestamp for session creation
request.session["created_at"] = str(datetime.now())
# Debug session data
print(f"Session data set: {dict(request.session)}")
except Exception as e:
print(f"Error setting session: {str(e)}")
# Redirect to next page or home
return RedirectResponse(url=next, status_code=status.HTTP_303_SEE_OTHER)
@router.get("/register", response_class=HTMLResponse)
async def register_page(request: Request):
"""Display registration page."""
# Check if user is already logged in
user = await get_user_from_session(request)
if user:
return RedirectResponse(url="/")
return templates.TemplateResponse("auth/register.html", {"request": request})
@router.post("/register", response_class=HTMLResponse)
async def register(
request: Request,
db: Session = Depends(get_db),
username: str = Form(...),
email: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...),
):
"""Process registration form."""
# Validate the form data
if password != confirm_password:
return templates.TemplateResponse(
"auth/register.html",
{
"request": request,
"messages": [{"type": "error", "text": "Passwords do not match"}],
"username": username,
"email": email
}
)
# Check if username already exists
if db.query(User).filter(User.username == username).first():
return templates.TemplateResponse(
"auth/register.html",
{
"request": request,
"messages": [{"type": "error", "text": "Username already exists"}],
"username": username,
"email": email
}
)
# Check if email already exists
if db.query(User).filter(User.email == email).first():
return templates.TemplateResponse(
"auth/register.html",
{
"request": request,
"messages": [{"type": "error", "text": "Email already exists"}],
"username": username,
"email": email
}
)
# Create new user
hashed_password = get_password_hash(password)
# Get available columns
columns = get_available_user_columns()
user_data = {
"username": username,
"email": email,
"hashed_password": hashed_password
}
# Add optional fields only if they exist in the database
if 'is_active' in columns:
user_data["is_active"] = True
if 'is_verified' in columns:
user_data["is_verified"] = False
if 'verification_token' in columns:
user_data["verification_token"] = generate_token()
new_user = User(**user_data)
db.add(new_user)
db.commit()
db.refresh(new_user)
# In a real app, send verification email here
# For now, just redirect to a success page
# Log the user in
request.session["user_id"] = new_user.id
return RedirectResponse(
url="/auth/registration-success",
status_code=status.HTTP_303_SEE_OTHER
)
@router.get("/registration-success", response_class=HTMLResponse)
async def registration_success(request: Request):
"""Display registration success page."""
return templates.TemplateResponse("auth/registration_success.html", {"request": request})
@router.get("/logout")
async def logout(request: Request):
"""Log user out by clearing session."""
request.session.clear()
return RedirectResponse(url="/")
@router.get("/profile", response_class=HTMLResponse)
async def profile_page(
request: Request,
db: Session = Depends(get_db)
):
"""Display user profile page."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/profile")
# Get user's teams (using fresh user object from database to ensure relationships are loaded)
user = db.query(User).filter(User.id == user.id).first()
user_teams = []
for membership in user.memberships:
user_teams.append({
"team": membership.team,
"is_admin": membership.is_admin
})
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"user_teams": user_teams
}
)
@router.post("/update-profile", response_class=HTMLResponse)
async def update_profile(
request: Request,
db: Session = Depends(get_db),
username: str = Form(None),
email: str = Form(None),
):
"""Update user profile information."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/profile")
# Check for username collision
if username and username != user.username:
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "error", "text": "Username already exists"}]
}
)
user.username = username
# Check for email collision
if email and email != user.email:
existing_user = db.query(User).filter(User.email == email).first()
if existing_user:
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "error", "text": "Email already exists"}]
}
)
user.email = email
db.commit()
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "success", "text": "Profile updated successfully"}]
}
)
@router.post("/change-password", response_class=HTMLResponse)
async def change_password(
request: Request,
db: Session = Depends(get_db),
current_password: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
):
"""Change user password."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/profile")
# Verify current password
if not verify_password(current_password, user.hashed_password):
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "error", "text": "Current password is incorrect"}]
}
)
# Check if new passwords match
if new_password != confirm_password:
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "error", "text": "New passwords do not match"}]
}
)
# Update password
user.hashed_password = get_password_hash(new_password)
db.commit()
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "success", "text": "Password changed successfully"}]
}
)
@router.get("/forgot-password", response_class=HTMLResponse)
async def forgot_password_page(request: Request):
"""Display forgot password page."""
return templates.TemplateResponse("auth/forgot_password.html", {"request": request})
@router.post("/forgot-password")
async def forgot_password(
request: Request,
db: Session = Depends(get_db),
email: str = Form(...)
):
"""Process forgot password form."""
# Find user by email
user = db.query(User).filter(User.email == email).first()
# Always show success to prevent email enumeration
if not user:
return templates.TemplateResponse(
"auth/forgot_password_sent.html",
{"request": request}
)
# Generate reset token
reset_token = generate_token()
user.reset_token = reset_token
user.reset_token_expires_at = datetime.utcnow() + timedelta(hours=1)
db.commit()
# In a real app, send email with reset link
# For demo, just show the reset link on the success page
reset_url = f"/auth/reset-password?token={reset_token}"
return templates.TemplateResponse(
"auth/forgot_password_sent.html",
{
"request": request,
"reset_url": reset_url # Remove in production, just for demo
}
)
@router.get("/reset-password", response_class=HTMLResponse)
async def reset_password_page(
request: Request,
token: str,
db: Session = Depends(get_db)
):
"""Display reset password page."""
# Check if token exists and is valid
user = db.query(User).filter(
User.reset_token == token,
User.reset_token_expires_at > datetime.utcnow()
).first()
if not user:
return templates.TemplateResponse(
"auth/reset_password_error.html",
{"request": request}
)
return templates.TemplateResponse(
"auth/reset_password.html",
{"request": request, "token": token}
)
@router.post("/reset-password")
async def reset_password(
request: Request,
db: Session = Depends(get_db),
token: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...)
):
"""Process reset password form."""
# Check if token exists and is valid
user = db.query(User).filter(
User.reset_token == token,
User.reset_token_expires_at > datetime.utcnow()
).first()
if not user:
return templates.TemplateResponse(
"auth/reset_password_error.html",
{"request": request}
)
# Check if passwords match
if new_password != confirm_password:
return templates.TemplateResponse(
"auth/reset_password.html",
{
"request": request,
"token": token,
"messages": [{"type": "error", "text": "Passwords do not match"}]
}
)
# Update password
user.hashed_password = get_password_hash(new_password)
user.reset_token = None
user.reset_token_expires_at = None
db.commit()
return templates.TemplateResponse(
"auth/reset_password_success.html",
{"request": request}
)
@router.get("/delete-account", response_class=HTMLResponse)
async def delete_account_page(request: Request):
"""Display delete account confirmation page."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/delete-account")
return templates.TemplateResponse("auth/delete_account.html", {"request": request})
@router.post("/delete-account")
async def delete_account(
request: Request,
db: Session = Depends(get_db),
password: str = Form(...)
):
"""Process account deletion."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/delete-account")
# Verify password
if not verify_password(password, user.hashed_password):
return templates.TemplateResponse(
"auth/delete_account.html",
{
"request": request,
"messages": [{"type": "error", "text": "Incorrect password"}]
}
)
# In a real app, you might want to anonymize the user data instead
# of deleting it completely, but for this demo we'll delete
# Clear session
request.session.clear()
# Delete user
db.delete(user)
db.commit()
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
# --- API ROUTES (for potential SPA frontend) ---
@router.post("/token")
async def login_api(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""API login endpoint returning JWT token."""
# Authenticate user
user = db.query(User).filter((User.username == form_data.username) | (User.email == form_data.username)).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
columns = get_available_user_columns()
if 'is_active' in columns and hasattr(user, 'is_active') and not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user",
headers={"WWW-Authenticate": "Bearer"},
)
if 'last_login' in columns and hasattr(user, 'last_login'):
user.last_login = datetime.utcnow()
db.commit()
# Create access token
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username},
expires_delta=access_token_expires
)
return {
"access_token": access_token,
"token_type": "bearer",
"user_id": user.id,
"username": user.username
}
@router.get("/session-test")
async def test_session(request: Request):
"""Test endpoint to verify session data persistence"""
has_session = hasattr(request, "session")
session_data = dict(request.session) if has_session else {}
return {
"has_session": has_session,
"session_data": session_data,
"authenticated": "user_id" in session_data
}
+33 -10
View File
@@ -8,6 +8,8 @@ from sqlalchemy.orm import Session
from ..db import SessionLocal from ..db import SessionLocal
from ..models import QRTicket, User, Team, TeamMembership from ..models import QRTicket, User, Team, TeamMembership
from ..templates_config import templates from ..templates_config import templates
from ..dependencies import get_user_from_session
from ..auth import require_login
router = APIRouter() router = APIRouter()
@@ -24,19 +26,30 @@ def redeem_code(code: str, request: Request, db: Session = Depends(get_db)):
Display a page to let the user choose which team to apply points to. Display a page to let the user choose which team to apply points to.
If not logged in, prompt them. If not logged in, prompt them.
""" """
# In real app, you'd check user session or redirect to login
ticket = db.query(QRTicket).filter_by(code=code, used=False).first() ticket = db.query(QRTicket).filter_by(code=code, used=False).first()
if not ticket: if not ticket:
return "Invalid or already used code." return templates.TemplateResponse(
"error.html",
{
"request": request,
"error_title": "Invalid Code",
"error_message": "This code is invalid or has already been used."
}
)
# Skeleton: you'd get the user's teams from session user # Get the authenticated user
# For now, we mock a user ID = 1: user = get_user_from_session(request, db)
user = db.query(User).filter_by(id=1).first()
if not user: if not user:
return "User not found. Please log in." # Store the redeem URL for after login - with try/except
try:
if hasattr(request, "session") and isinstance(request.session, dict):
request.session["redirect_after_login"] = f"/redeem/{code}"
except Exception as e:
print(f"Error setting redirect URL in session: {str(e)}")
return RedirectResponse(url="/auth/login", status_code=302)
# This is where you'd show the team selection or "create new team" UI # Get user teams
user_teams = [m.team for m in user.memberships] user_teams = [m.team for m in user.memberships] if hasattr(user, 'memberships') else []
return templates.TemplateResponse("redeem.html", { return templates.TemplateResponse("redeem.html", {
"request": request, "request": request,
@@ -45,6 +58,7 @@ def redeem_code(code: str, request: Request, db: Session = Depends(get_db)):
}) })
@router.post("/apply/{code}") @router.post("/apply/{code}")
@require_login
async def apply_code( async def apply_code(
request: Request, request: Request,
code: str, code: str,
@@ -54,6 +68,11 @@ async def apply_code(
Apply the QR code to a selected team (if user is a member), Apply the QR code to a selected team (if user is a member),
or set to pending if user isn't a member yet. or set to pending if user isn't a member yet.
""" """
# Get the authenticated user
user = get_user_from_session(request, db)
if not user:
return RedirectResponse(url=f"/auth/login?next=/redeem/{code}", status_code=302)
# Get form data # Get form data
form_data = await request.form() form_data = await request.form()
team_id = int(form_data.get("team_id", 0)) team_id = int(form_data.get("team_id", 0))
@@ -79,8 +98,6 @@ async def apply_code(
} }
) )
# For skeleton, assume user = 1
user = db.query(User).filter_by(id=1).first()
team = db.query(Team).filter_by(id=team_id).first() team = db.query(Team).filter_by(id=team_id).first()
if not team: if not team:
@@ -129,6 +146,7 @@ async def apply_code(
) )
@router.post("/manual") @router.post("/manual")
@require_login
async def manual_code_entry( async def manual_code_entry(
request: Request, request: Request,
code: str = Form(...), code: str = Form(...),
@@ -138,6 +156,11 @@ async def manual_code_entry(
Handle manual code entry from the form. Handle manual code entry from the form.
This redirects to the normal redeem flow after validating the code. This redirects to the normal redeem flow after validating the code.
""" """
# Make sure the user is authenticated
user = get_user_from_session(request, db)
if not user:
return RedirectResponse(url="/auth/login?next=/dashboard/", status_code=302)
# Check if the code exists # Check if the code exists
ticket = db.query(QRTicket).filter_by(code=code, used=False).first() ticket = db.query(QRTicket).filter_by(code=code, used=False).first()
+24 -23
View File
@@ -13,6 +13,8 @@ from ..db import SessionLocal
from ..models import Team, TeamMembership, User, QRTicket, TeamAchievement from ..models import Team, TeamMembership, User, QRTicket, TeamAchievement
from ..schemas import TeamCreate from ..schemas import TeamCreate
from ..templates_config import templates from ..templates_config import templates
from ..dependencies import get_user_from_session
from ..auth import require_login
router = APIRouter() router = APIRouter()
@@ -23,27 +25,12 @@ def get_db():
finally: finally:
db.close() db.close()
def get_or_create_default_user(db: Session):
"""Get user ID 1 or create it if it doesn't exist."""
user = db.query(User).filter_by(id=1).first()
if not user:
# Create a default user
user = User(
username="default_user",
email="default@example.com",
hashed_password="placeholder"
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def list_teams(request: Request, db: Session = Depends(get_db)): def list_teams(request: Request, db: Session = Depends(get_db)):
teams = db.query(Team).all() teams = db.query(Team).all()
# Get the user's teams to highlight teams they're already in # Get the user's teams to highlight teams they're already in
user = db.query(User).filter_by(id=1).first() user = get_user_from_session(request, db)
user_team_ids = [] user_team_ids = []
if user: if user:
@@ -67,8 +54,11 @@ def list_teams(request: Request, db: Session = Depends(get_db)):
) )
@router.post("/create") @router.post("/create")
def create_team(name: str = Form(...), db: Session = Depends(get_db)): @require_login
user = get_or_create_default_user(db) def create_team(request: Request, name: str = Form(...), db: Session = Depends(get_db)):
user = get_user_from_session(request, db)
if not user:
return RedirectResponse("/auth/login?next=/teams/", status_code=302)
# Create team # Create team
new_team = Team(name=name) new_team = Team(name=name)
@@ -84,8 +74,11 @@ def create_team(name: str = Form(...), db: Session = Depends(get_db)):
return RedirectResponse("/teams/", status_code=303) return RedirectResponse("/teams/", status_code=303)
@router.post("/join/{team_id}") @router.post("/join/{team_id}")
def join_team(team_id: int, db: Session = Depends(get_db)): @require_login
user = get_or_create_default_user(db) def join_team(request: Request, team_id: int, db: Session = Depends(get_db)):
user = get_user_from_session(request, db)
if not user:
return RedirectResponse("/auth/login?next=/teams/", status_code=302)
team = db.query(Team).filter_by(id=team_id).first() team = db.query(Team).filter_by(id=team_id).first()
if not team: if not team:
@@ -111,8 +104,11 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
if not team: if not team:
raise HTTPException(status_code=404, detail="Team not found") raise HTTPException(status_code=404, detail="Team not found")
# Get current user - using a default user for now # Get current user from session
user = get_or_create_default_user(db) user = get_user_from_session(request, db)
if not user:
# If no authenticated user, redirect to login
return RedirectResponse("/auth/login?next=/teams/"+str(team_id), status_code=302)
# Get team members with admin status # Get team members with admin status
memberships = db.query(TeamMembership).filter_by(team_id=team_id).all() memberships = db.query(TeamMembership).filter_by(team_id=team_id).all()
@@ -248,14 +244,19 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
) )
@router.post("/{team_id}/update") @router.post("/{team_id}/update")
@require_login
def update_team( def update_team(
request: Request,
team_id: int, team_id: int,
team_name: str = Form(...), team_name: str = Form(...),
is_public: bool = Form(False), is_public: bool = Form(False),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Update team details.""" """Update team details."""
user = get_or_create_default_user(db) user = get_user_from_session(request, db)
if not user:
return RedirectResponse("/auth/login?next=/teams/"+str(team_id), status_code=302)
team = db.query(Team).filter_by(id=team_id).first() team = db.query(Team).filter_by(id=team_id).first()
if not team: if not team:
+16
View File
@@ -48,5 +48,21 @@ services:
volumes: volumes:
- ./:/app:delegated - ./:/app:delegated
phpmyadmin:
image: phpmyadmin/phpmyadmin
container_name: pubquiz_phpmyadmin
restart: unless-stopped
depends_on:
- db
environment:
PMA_HOST: db
PMA_PORT: 3306
PMA_USER: pubquiz_user
PMA_PASSWORD: pubquiz_pass
ports:
- "8001:80"
volumes: volumes:
db_data: db_data:
+25 -15
View File
@@ -1,18 +1,28 @@
fastapi>=0.95.0 # Web framework and server
uvicorn[standard]>=0.21.1 fastapi>=0.103.1
sqlalchemy>=2.0.9 uvicorn>=0.23.2
pymysql>=1.0.3
cryptography>=40.0.2
python-multipart>=0.0.6
authlib>=1.2.0
# Specify specific versions for passlib and bcrypt to avoid compatibility issues
passlib==1.7.4
bcrypt==4.0.1
starlette>=0.27.0 starlette>=0.27.0
jinja2>=3.1.2 httpx>=0.25.0 # HTTP client for making requests
# Database
sqlalchemy>=2.0.20
pymysql>=1.1.0
mysqlclient>=2.2.0
# Authentication and Security
authlib>=1.2.1
python-jose>=3.3.0
python-multipart>=0.0.6
passlib>=1.7.4
itsdangerous>=2.1.2 itsdangerous>=2.1.2
python-jose[cryptography]>=3.3.0 bcrypt>=4.0.1
# Templates and UI
jinja2>=3.1.2
aiofiles>=23.2.1
# Common utilities
python-dotenv>=1.0.0 python-dotenv>=1.0.0
qrcode email-validator>=2.0.0
pydantic[email]>=1.10.7 pydantic>=2.3.0
httpx qrcode>=7.4.2