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

16
TODO.md
View File

@@ -6,20 +6,20 @@ Refer to `del.plan.md` for context. Check off items as theyre completed.
- [ ] Create requirements.txt with minimal deps - [ ] Create requirements.txt with minimal deps
- [ ] Copy delphi-logo.webp into static/logo/ - [ ] Copy delphi-logo.webp into static/logo/
- [ ] Set up SQLAlchemy Base and engine/session helpers - [ ] Set up SQLAlchemy Base and engine/session helpers
- [ ] Add User model with username and password_hash - [x] Add User model with username and password_hash
- [ ] Add Client model (rolodex_id and core fields) - [ ] Add Client model (rolodex_id and core fields)
- [ ] Add Phone model with FK to Client - [ ] Add Phone model with FK to Client
- [ ] Add Case model (file_no unique, FK to Client) - [ ] Add Case model (file_no unique, FK to Client)
- [ ] Add Transaction model with FK to Case - [ ] Add Transaction model with FK to Case
- [ ] Add Document model with FK to Case - [ ] Add Document model with FK to Case
- [ ] Add Payment model with FK to Case - [ ] Add Payment model with FK to Case
- [ ] Create tables and seed default admin user - [x] Create tables and seed default admin user
- [ ] Create FastAPI app with DB session dependency - [x] Create FastAPI app with DB session dependency
- [ ] Add SessionMiddleware with SECRET_KEY from env - [x] Add SessionMiddleware with SECRET_KEY from env
- [ ] Configure Jinja2 templates and mount static files - [x] Configure Jinja2 templates and mount static files
- [ ] Create base.html with Bootstrap 5 CDN and nav - [x] Create base.html with Bootstrap 5 CDN and nav
- [ ] Implement login form, POST handler, and logout - [x] Implement login form, POST handler, and logout
- [ ] Create login.html form - [x] Create login.html form
- [ ] Implement dashboard route listing cases - [ ] Implement dashboard route listing cases
- [ ] Add simple search by file_no/name/keyword - [ ] Add simple search by file_no/name/keyword
- [ ] Create dashboard.html with table and search box - [ ] Create dashboard.html with table and search box

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 # Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create declarative base for models # Import Base from models for SQLAlchemy 1.x compatibility
Base = declarative_base() from .models import Base
def get_db() -> Generator[Session, None, None]: def get_db() -> Generator[Session, None, None]:
@@ -68,6 +68,14 @@ def create_tables() -> None:
""" """
Base.metadata.create_all(bind=engine) 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: def get_database_url() -> str:
""" """

View File

@@ -10,15 +10,18 @@ import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, Request 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.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dotenv import load_dotenv from dotenv import load_dotenv
from starlette.middleware.base import BaseHTTPMiddleware
from .database import create_tables, get_db, get_database_url from .database import create_tables, get_db, get_database_url
from .models import User from .models import User
from .auth import authenticate_user, get_current_user_from_session
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
@@ -36,6 +39,34 @@ logger = logging.getLogger(__name__)
templates = Jinja2Templates(directory="app/templates") 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
""" """
@@ -79,7 +110,11 @@ app.add_middleware(
allow_headers=["*"], 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) app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
# Mount static files directory # Mount static files directory
@@ -114,3 +149,105 @@ async def health_check(db: Session = Depends(get_db)):
"database": "error", "database": "error",
"error": str(e) "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 import Column, Integer, String, DateTime, ForeignKey, Float, Text, Boolean
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .database import Base
# Create Base for SQLAlchemy 1.x compatibility
Base = declarative_base()
class User(Base): class User(Base):

View File

@@ -43,10 +43,10 @@
</ul> </ul>
<ul class="navbar-nav"> <ul class="navbar-nav">
{% if 'user' in session %} {% if request.session.get('user') %}
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <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> </a>
<ul class="dropdown-menu" aria-labelledby="userDropdown"> <ul class="dropdown-menu" aria-labelledby="userDropdown">
<li><a class="dropdown-item" href="/profile">Profile</a></li> <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 %}

5
cookies.txt Normal file
View File

@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1761003936 session eyJ1c2VyX2lkIjogMSwgInVzZXIiOiB7ImlkIjogMSwgInVzZXJuYW1lIjogImFkbWluIn19.aORUoA.KSAst9pcXJJJHTc1m-mY_jQ0P6A

BIN
delphi.db

Binary file not shown.

View File

@@ -1,5 +1,5 @@
fastapi==0.104.1 fastapi==0.104.1
sqlalchemy==2.0.23 sqlalchemy==1.4.54
alembic==1.12.1 alembic==1.12.1
python-multipart==0.0.6 python-multipart==0.0.6
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0