Files
gh-christianlouis-leagueledger/app/views/auth.py
T
Christian Krakau-Louis 0de2eb5830 Implement user authentication and management system with FastAPI
- Added authentication routes for login, registration, password reset, and account deletion in `auth.py`.
- Implemented user profile management, including updating user information and changing passwords.
- Created dashboard views to display user-specific information and recent activity in `dashboard.py`.
- Developed leaderboard views to show team rankings and points in `leaderboard.py`.
- Added QR code generation and redemption functionality in `qr.py` and `redeem.py`.
- Implemented team management features, allowing users to create and join teams in `teams.py`.
- Introduced session debugging utility to assist with session-related issues in `debug_session.py`.
- Configured Docker Compose for MySQL database and FastAPI application with environment variables.
- Updated requirements.txt to include necessary dependencies for the application.
2025-04-11 17:46:59 +02:00

561 lines
18 KiB
Python

#!/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
}