feat(auth): add session-based login/logout with bcrypt hashing, seed default admin, templates and navbar updates; add auth middleware; pin SQLAlchemy 1.4.x for Py3.13; update TODOs

This commit is contained in:
HotSwapp
2025-10-06 19:04:36 -05:00
parent 227c74294f
commit 6aa4d59a25
14 changed files with 466 additions and 17 deletions

View File

@@ -10,15 +10,18 @@ import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, Request
from fastapi.middleware.sessions import SessionMiddleware
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from dotenv import load_dotenv
from starlette.middleware.base import BaseHTTPMiddleware
from .database import create_tables, get_db, get_database_url
from .models import User
from .auth import authenticate_user, get_current_user_from_session
# Load environment variables
load_dotenv()
@@ -36,6 +39,34 @@ logger = logging.getLogger(__name__)
templates = Jinja2Templates(directory="app/templates")
class AuthMiddleware(BaseHTTPMiddleware):
"""
Simple session-based authentication middleware.
Redirects unauthenticated users to /login for protected routes.
"""
def __init__(self, app, exempt_paths: list[str] | None = None):
super().__init__(app)
self.exempt_paths = exempt_paths or []
async def dispatch(self, request, call_next):
path = request.url.path
# Allow exempt paths and static assets
if (
path in self.exempt_paths
or path.startswith("/static")
or path.startswith("/favicon")
):
return await call_next(request)
# Enforce authentication for other paths
if not request.session.get("user_id"):
return RedirectResponse(url="/login", status_code=302)
return await call_next(request)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
@@ -79,7 +110,11 @@ app.add_middleware(
allow_headers=["*"],
)
# Add SessionMiddleware for session management
# Register authentication middleware with exempt paths
EXEMPT_PATHS = ["/", "/health", "/login", "/logout"]
app.add_middleware(AuthMiddleware, exempt_paths=EXEMPT_PATHS)
# Add SessionMiddleware for session management (must be added LAST so it runs FIRST)
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
# Mount static files directory
@@ -114,3 +149,105 @@ async def health_check(db: Session = Depends(get_db)):
"database": "error",
"error": str(e)
}
@app.get("/login")
async def login_form(request: Request):
"""
Display login form.
If user is already logged in, redirect to dashboard.
"""
# Check if user is already logged in
user = get_current_user_from_session(request.session)
if user:
return RedirectResponse(url="/dashboard", status_code=302)
return templates.TemplateResponse("login.html", {"request": request})
@app.post("/login")
async def login_submit(request: Request, db: Session = Depends(get_db)):
"""
Handle login form submission.
Authenticates user credentials and sets up session.
"""
form = await request.form()
username = form.get("username")
password = form.get("password")
if not username or not password:
error_message = "Username and password are required"
return templates.TemplateResponse("login.html", {
"request": request,
"error": error_message
})
# Authenticate user
user = authenticate_user(username, password)
if not user:
error_message = "Invalid username or password"
return templates.TemplateResponse("login.html", {
"request": request,
"error": error_message
})
# Set up user session
request.session["user_id"] = user.id
request.session["user"] = {"id": user.id, "username": user.username}
logger.info(f"User '{username}' logged in successfully")
# Redirect to dashboard after successful login
return RedirectResponse(url="/dashboard", status_code=302)
@app.get("/logout")
async def logout(request: Request):
"""
Handle user logout.
Clears user session and redirects to home page.
"""
username = request.session.get("user", {}).get("username", "unknown")
request.session.clear()
logger.info(f"User '{username}' logged out")
return RedirectResponse(url="/", status_code=302)
@app.get("/dashboard")
async def dashboard(request: Request, db: Session = Depends(get_db)):
"""
Dashboard page - requires authentication.
Shows an overview of the system and provides navigation to main features.
"""
# Check authentication
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
return templates.TemplateResponse("dashboard.html", {
"request": request,
"user": user
})
@app.get("/admin")
async def admin_panel(request: Request, db: Session = Depends(get_db)):
"""
Admin panel - requires authentication.
Provides administrative functions like data import and system management.
"""
# Check authentication
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
return templates.TemplateResponse("admin.html", {
"request": request,
"user": user
})