0de2eb5830
- 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.
561 lines
18 KiB
Python
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
|
|
}
|