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.
This commit is contained in:
@@ -0,0 +1,560 @@
|
||||
#!/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
|
||||
}
|
||||
Reference in New Issue
Block a user