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

Binary file not shown.

Binary file not shown.

186
app/auth.py Normal file
View File

@@ -0,0 +1,186 @@
"""
Authentication utilities for Delphi Database application.
This module provides password hashing, user authentication, and session management
functions for secure user login/logout functionality.
"""
import logging
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from .models import User
from .database import SessionLocal
# Configure password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
logger = logging.getLogger(__name__)
def hash_password(password: str) -> str:
"""
Hash a password using bcrypt.
Args:
password (str): Plain text password to hash
Returns:
str: Hashed password
"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against its hash.
Args:
plain_password (str): Plain text password to verify
hashed_password (str): Hashed password to check against
Returns:
bool: True if password matches hash, False otherwise
"""
return pwd_context.verify(plain_password, hashed_password)
def authenticate_user(username: str, password: str) -> User | None:
"""
Authenticate a user with username and password.
Args:
username (str): Username to authenticate
password (str): Password to verify
Returns:
User | None: User object if authentication successful, None otherwise
"""
db = SessionLocal()
try:
user = db.query(User).filter(User.username == username).first()
if not user:
logger.warning(f"Authentication failed: User '{username}' not found")
return None
if not verify_password(password, user.password_hash):
logger.warning(f"Authentication failed: Invalid password for user '{username}'")
return None
if not user.is_active:
logger.warning(f"Authentication failed: User '{username}' is inactive")
return None
logger.info(f"User '{username}' authenticated successfully")
return user
except Exception as e:
logger.error(f"Authentication error for user '{username}': {e}")
return None
finally:
db.close()
def create_user(username: str, password: str, is_active: bool = True) -> User | None:
"""
Create a new user with hashed password.
Args:
username (str): Username for the new user
password (str): Plain text password (will be hashed)
is_active (bool): Whether the user should be active
Returns:
User | None: Created user object if successful, None if user already exists
"""
db = SessionLocal()
try:
# Check if user already exists
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
logger.warning(f"User creation failed: User '{username}' already exists")
return None
# Hash the password
password_hash = hash_password(password)
# Create new user
new_user = User(
username=username,
password_hash=password_hash,
is_active=is_active
)
db.add(new_user)
db.commit()
db.refresh(new_user)
logger.info(f"User '{username}' created successfully")
return new_user
except Exception as e:
db.rollback()
logger.error(f"Error creating user '{username}': {e}")
return None
finally:
db.close()
def seed_admin_user() -> None:
"""
Create a default admin user if one doesn't exist.
This function should be called during application startup to ensure
there's at least one admin user for initial access.
"""
admin_username = "admin"
admin_password = "admin123" # In production, use a more secure default
db = SessionLocal()
try:
# Check if admin user already exists
existing_admin = db.query(User).filter(User.username == admin_username).first()
if existing_admin:
logger.info(f"Admin user '{admin_username}' already exists")
return
# Create admin user
admin_user = create_user(admin_username, admin_password)
if admin_user:
logger.info(f"Default admin user '{admin_username}' created successfully")
else:
logger.error("Failed to create default admin user")
except Exception as e:
logger.error(f"Error seeding admin user: {e}")
finally:
db.close()
def get_current_user_from_session(session_data: dict) -> User | None:
"""
Get current user from session data.
Args:
session_data (dict): Session data dictionary
Returns:
User | None: Current user if session is valid, None otherwise
"""
user_id = session_data.get("user_id")
if not user_id:
return None
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
if not user:
logger.warning(f"No active user found for session user_id: {user_id}")
return None
return user
except Exception as e:
logger.error(f"Error retrieving current user from session: {e}")
return None
finally:
db.close()

View File

@@ -33,8 +33,8 @@ engine = create_engine(
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create declarative base for models
Base = declarative_base()
# Import Base from models for SQLAlchemy 1.x compatibility
from .models import Base
def get_db() -> Generator[Session, None, None]:
@@ -68,6 +68,14 @@ def create_tables() -> None:
"""
Base.metadata.create_all(bind=engine)
# Seed default admin user after creating tables
try:
from .auth import seed_admin_user
seed_admin_user()
except ImportError:
# Handle case where auth module isn't available yet during initial import
pass
def get_database_url() -> str:
"""

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
})

View File

@@ -6,8 +6,11 @@ All models inherit from Base which is configured in the database module.
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float, Text, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
from .database import Base
# Create Base for SQLAlchemy 1.x compatibility
Base = declarative_base()
class User(Base):

View File

@@ -43,10 +43,10 @@
</ul>
<ul class="navbar-nav">
{% if 'user' in session %}
{% if request.session.get('user') %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-person-circle me-1"></i>{{ session.user.username }}
<i class="bi bi-person-circle me-1"></i>{{ request.session.get('user').username if request.session.get('user') else '' }}
</a>
<ul class="dropdown-menu" aria-labelledby="userDropdown">
<li><a class="dropdown-item" href="/profile">Profile</a></li>

View File

@@ -1 +1,111 @@
<!-- Login form template -->
{% extends "base.html" %}
{% block title %}Login - Delphi Database{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-center mb-4">
<img src="{{ url_for('static', path='/logo/delphi-logo.webp') }}" alt="Delphi Logo" class="mb-3" style="width: 60px; height: 60px;">
<h2 class="card-title">Welcome Back</h2>
<p class="text-muted">Sign in to access Delphi Database</p>
</div>
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
</div>
{% endif %}
<form method="post" action="/login">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input type="text" class="form-control" id="username" name="username" required
placeholder="Enter your username" autocomplete="username">
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="password" class="form-control" id="password" name="password" required
placeholder="Enter your password" autocomplete="current-password">
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="rememberMe">
<label class="form-check-label" for="rememberMe">
Remember me
</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="bi bi-box-arrow-in-right me-2"></i>Sign In
</button>
</div>
</form>
<div class="text-center mt-4">
<small class="text-muted">
<i class="bi bi-info-circle me-1"></i>
Default credentials: admin / admin123
</small>
</div>
</div>
</div>
<div class="text-center mt-3">
<small class="text-muted">
Don't have an account? Contact your administrator.
</small>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Auto-focus on username field
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('username').focus();
});
// Show/hide password toggle (optional enhancement)
document.addEventListener('DOMContentLoaded', function() {
const passwordField = document.getElementById('password');
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'btn btn-outline-secondary';
toggleBtn.innerHTML = '<i class="bi bi-eye"></i>';
toggleBtn.style.border = 'none';
toggleBtn.style.background = 'transparent';
// Add toggle functionality
toggleBtn.addEventListener('click', function() {
if (passwordField.type === 'password') {
passwordField.type = 'text';
this.innerHTML = '<i class="bi bi-eye-slash"></i>';
} else {
passwordField.type = 'password';
this.innerHTML = '<i class="bi bi-eye"></i>';
}
});
// Insert toggle button into password input group
const passwordInputGroup = passwordField.closest('.input-group');
if (passwordInputGroup) {
const span = passwordInputGroup.querySelector('.input-group-text');
passwordInputGroup.insertBefore(toggleBtn, span.nextSibling);
}
});
</script>
{% endblock %}