From 9a0479cb3745b95cc56c855d00742590d997fff2 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Sun, 13 Apr 2025 19:26:26 +0200 Subject: [PATCH] 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. --- Dockerfile | 34 +-- app/auth.py | 85 ++++--- app/dependencies.py | 72 ++++-- app/main.py | 61 +++-- app/views/auth.py | 560 -------------------------------------------- app/views/redeem.py | 43 +++- app/views/teams.py | 47 ++-- docker-compose.yml | 16 ++ requirements.txt | 40 ++-- 9 files changed, 255 insertions(+), 703 deletions(-) delete mode 100644 app/views/auth.py diff --git a/Dockerfile b/Dockerfile index 8c11abf..06224fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/app/auth.py b/app/auth.py index 71b344a..733986f 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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) - - user = request.session.get("user") + db = next(get_db()) + user = get_user_from_session(request, db) + 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 + } diff --git a/app/dependencies.py b/app/dependencies.py index 0b4d7ca..22ff7be 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -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) diff --git a/app/main.py b/app/main.py index 5c1f7fa..e9db5f1 100644 --- a/app/main.py +++ b/app/main.py @@ -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"]) diff --git a/app/views/auth.py b/app/views/auth.py deleted file mode 100644 index 9a99030..0000000 --- a/app/views/auth.py +++ /dev/null @@ -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 - } diff --git a/app/views/redeem.py b/app/views/redeem.py index 5e44267..b0090c3 100644 --- a/app/views/redeem.py +++ b/app/views/redeem.py @@ -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() diff --git a/app/views/teams.py b/app/views/teams.py index 310a886..96c07b9 100644 --- a/app/views/teams.py +++ b/app/views/teams.py @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index ca6f548..d396487 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/requirements.txt b/requirements.txt index ff22cc0..f5cdf1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +email-validator>=2.0.0 +pydantic>=2.3.0 +qrcode>=7.4.2 \ No newline at end of file