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.11-slim
FROM python:3.13
# Create working directory
WORKDIR /app
# Install system dependencies needed for mysqlclient
RUN apt-get update && apt-get install -y \
pkg-config \
default-libmysqlclient-dev \
build-essential \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install system dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc libc-dev default-libmysqlclient-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Copy requirements first (for caching)
# Copy requirements first to leverage Docker cache
COPY requirements.txt .
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Explicitly install pymysql (in case it's missing from requirements.txt)
RUN pip install --no-cache-dir pymysql cryptography
# Copy the rest of the code
# Copy application code
COPY . .
# Expose port
EXPOSE 8000
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Run the FastAPI app with Uvicorn
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Command to run when container starts
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 .templates_config import templates
from . import models
from .dependencies import get_user_from_session
# Initialize OAuth
oauth = OAuth()
@@ -53,15 +54,9 @@ def verify_password(plain_password, hashed_password):
"""Verify password against hash"""
return pwd_context.verify(plain_password, hashed_password)
def get_current_user(request: Request):
"""Get current user from session with improved error handling"""
try:
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_current_user(request: Request, db: Session = Depends(get_db)):
"""Get current user from session"""
return get_user_from_session(request, db)
def get_gravatar_url(email):
"""Generate a Gravatar URL for the given email"""
@@ -74,10 +69,12 @@ def require_login(func):
@wraps(func)
async def wrapper(request: Request, *args, **kwargs):
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
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)
# Check if the wrapped function is a coroutine function
@@ -96,15 +93,14 @@ def require_admin(func):
@wraps(func)
async def wrapper(request: Request, *args, **kwargs):
try:
if "session" not in request.scope:
return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND)
db = next(get_db())
user = get_user_from_session(request, db)
user = request.session.get("user")
if not user:
request.session["redirect_after_login"] = str(request.url)
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)
# Check if the wrapped function is a coroutine function
@@ -121,10 +117,10 @@ def require_admin(func):
# Routes for authentication
@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"""
# 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 templates.TemplateResponse(
@@ -153,17 +149,21 @@ async def login(request: Request, db: Session = Depends(get_db)):
status_code=status.HTTP_302_FOUND
)
# Create user session
# Make sure we use the actual is_admin field from the User model
is_admin_value = False
if hasattr(user, "is_admin") and user.is_admin is not None:
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"] = {
"id": user.id,
"username": user.username,
"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),
"_permanent": True,
"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:
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 = {
"id": user.id,
"username": user.username,
"email": user.email,
"is_active": user.is_active,
"is_admin": is_admin_value, # Set the admin status correctly
"is_active": getattr(user, "is_active", True),
"is_admin": is_admin_value,
"_permanent": True,
"created_at": str(user.created_at),
"created_at": str(getattr(user, "created_at", "")),
}
# 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):
"""Handle user logout"""
request.session.pop("user", None)
request.session.pop("user_id", None)
return RedirectResponse(
url="/auth/login?message=You+have+been+logged+out+successfully",
status_code=status.HTTP_302_FOUND
@@ -272,9 +276,9 @@ async def logout(request: Request):
@router.get("/profile")
@require_login
async def profile_page(request: Request):
async def profile_page(request: Request, db: Session = Depends(get_db)):
"""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})
@router.get("/register")
@@ -327,6 +331,19 @@ async def register(request: Request, db: Session = Depends(get_db)):
)
db.add(user)
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
return RedirectResponse(
@@ -343,7 +360,15 @@ async def registration_success(request: Request):
)
@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"""
user = request.session.get("user")
return user or {"error": "Not authenticated"}
user = get_user_from_session(request, db)
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 .db import get_db
from . import models
from .auth import get_current_user, require_login, require_admin
# Reuse functions from auth.py
# 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 configuration for API-based authentication
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
def get_current_active_user(request: Request):
user = get_current_user(request)
# Function to get user from session
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:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -29,11 +55,21 @@ def get_current_active_user(request: Request):
)
return user
def get_current_admin_user(request: Request):
user = get_current_user(request)
if not user or not user.get("is_admin"):
def get_current_admin_user(request: Request, db: Session = Depends(get_db)):
"""Get current user and ensure they're an admin"""
user = get_user_from_session(request, db)
if not user or not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
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 .views import qr, redeem, teams, admin, leaderboard, dashboard
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
init_db()
@@ -19,24 +20,26 @@ init_db()
# In a production app, you would handle this differently
seed_db()
# Initialize FastAPI app
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")
if len(secret_key) < 32:
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(
SessionMiddleware,
secret_key=secret_key,
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",
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")
async def debug_session_middleware(request, call_next):
"""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")
print(f"Request path: {request.url.path}")
# Instead of checking request.scope, check if we can access the session dict
has_session = hasattr(request, "session") and isinstance(request.session, dict)
# First check if the session attribute exists properly
has_session = hasattr(request, "session")
is_dict = has_session and isinstance(request.session, dict)
print(f"Has session attribute: {has_session}")
print(f"Session is dict: {is_dict}")
print(f"Has session cookie: {session_cookie is not None}")
# Check session data
if hasattr(request, "session"):
try:
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")
# Safely check session data
if has_session and is_dict:
print(f"Session data before: {dict(request.session)}")
except Exception as e:
print(f"Error in debug middleware (pre): {str(e)}")
@@ -63,22 +64,27 @@ async def debug_session_middleware(request, call_next):
try:
if hasattr(request, "session"):
try:
print(f"Session data after: {dict(request.session)}")
except (TypeError, AttributeError):
print(f"Session exists but isn't a dictionary")
print(f"Session data after: {dict(request.session)}")
except Exception as e:
print(f"Error in debug middleware (post): {str(e)}")
return response
# User context middleware to make user available in templates
# User context middleware
@app.middleware("http")
async def add_user_to_request(request: Request, call_next):
"""Add user to request state and update template globals"""
try:
# Get user from session if available
user = get_current_user(request)
# Get database connection from context
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
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
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:
print(f"Error setting user context: {str(e)}")
finally:
# Always close the DB connection
if 'db' in locals():
db.close()
# Process the request
response = await call_next(request)
return response
# Routers
app.include_router(auth_router, prefix="/auth", tags=["Auth"]) # Auth router should be first
# Routers - include after middleware setup is complete
app.include_router(auth_router, prefix="/auth", tags=["Auth"])
app.include_router(qr.router, prefix="/qr", tags=["QR"])
app.include_router(redeem.router, prefix="/redeem", tags=["Redeem"])
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 ..models import QRTicket, User, Team, TeamMembership
from ..templates_config import templates
from ..dependencies import get_user_from_session
from ..auth import require_login
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.
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()
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
# For now, we mock a user ID = 1:
user = db.query(User).filter_by(id=1).first()
# Get the authenticated user
user = get_user_from_session(request, db)
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
user_teams = [m.team for m in user.memberships]
# Get user teams
user_teams = [m.team for m in user.memberships] if hasattr(user, 'memberships') else []
return templates.TemplateResponse("redeem.html", {
"request": request,
@@ -45,6 +58,7 @@ def redeem_code(code: str, request: Request, db: Session = Depends(get_db)):
})
@router.post("/apply/{code}")
@require_login
async def apply_code(
request: Request,
code: str,
@@ -54,6 +68,11 @@ async def apply_code(
Apply the QR code to a selected team (if user is a member),
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
form_data = await request.form()
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()
if not team:
@@ -129,6 +146,7 @@ async def apply_code(
)
@router.post("/manual")
@require_login
async def manual_code_entry(
request: Request,
code: str = Form(...),
@@ -138,6 +156,11 @@ async def manual_code_entry(
Handle manual code entry from the form.
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
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 ..schemas import TeamCreate
from ..templates_config import templates
from ..dependencies import get_user_from_session
from ..auth import require_login
router = APIRouter()
@@ -23,27 +25,12 @@ def get_db():
finally:
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)
def list_teams(request: Request, db: Session = Depends(get_db)):
teams = db.query(Team).all()
# 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 = []
if user:
@@ -67,8 +54,11 @@ def list_teams(request: Request, db: Session = Depends(get_db)):
)
@router.post("/create")
def create_team(name: str = Form(...), db: Session = Depends(get_db)):
user = get_or_create_default_user(db)
@require_login
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
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)
@router.post("/join/{team_id}")
def join_team(team_id: int, db: Session = Depends(get_db)):
user = get_or_create_default_user(db)
@require_login
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()
if not team:
@@ -111,8 +104,11 @@ 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 current user - using a default user for now
user = get_or_create_default_user(db)
# Get current user from session
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
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")
@require_login
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."""
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()
if not team:
+16
View File
@@ -48,5 +48,21 @@ services:
volumes:
- ./:/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:
db_data:
+25 -15
View File
@@ -1,18 +1,28 @@
fastapi>=0.95.0
uvicorn[standard]>=0.21.1
sqlalchemy>=2.0.9
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
# Web framework and server
fastapi>=0.103.1
uvicorn>=0.23.2
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
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
qrcode
pydantic[email]>=1.10.7
httpx
email-validator>=2.0.0
pydantic>=2.3.0
qrcode>=7.4.2