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:
16
TODO.md
16
TODO.md
@@ -6,20 +6,20 @@ Refer to `del.plan.md` for context. Check off items as they’re completed.
|
||||
- [ ] Create requirements.txt with minimal deps
|
||||
- [ ] Copy delphi-logo.webp into static/logo/
|
||||
- [ ] 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 Phone model with FK to Client
|
||||
- [ ] Add Case model (file_no unique, FK to Client)
|
||||
- [ ] Add Transaction model with FK to Case
|
||||
- [ ] Add Document model with FK to Case
|
||||
- [ ] Add Payment model with FK to Case
|
||||
- [ ] Create tables and seed default admin user
|
||||
- [ ] Create FastAPI app with DB session dependency
|
||||
- [ ] Add SessionMiddleware with SECRET_KEY from env
|
||||
- [ ] Configure Jinja2 templates and mount static files
|
||||
- [ ] Create base.html with Bootstrap 5 CDN and nav
|
||||
- [ ] Implement login form, POST handler, and logout
|
||||
- [ ] Create login.html form
|
||||
- [x] Create tables and seed default admin user
|
||||
- [x] Create FastAPI app with DB session dependency
|
||||
- [x] Add SessionMiddleware with SECRET_KEY from env
|
||||
- [x] Configure Jinja2 templates and mount static files
|
||||
- [x] Create base.html with Bootstrap 5 CDN and nav
|
||||
- [x] Implement login form, POST handler, and logout
|
||||
- [x] Create login.html form
|
||||
- [ ] Implement dashboard route listing cases
|
||||
- [ ] Add simple search by file_no/name/keyword
|
||||
- [ ] Create dashboard.html with table and search box
|
||||
|
||||
BIN
app/__pycache__/auth.cpython-313.pyc
Normal file
BIN
app/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
186
app/auth.py
Normal file
186
app/auth.py
Normal 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()
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
141
app/main.py
141
app/main.py
@@ -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
|
||||
})
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
5
cookies.txt
Normal 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
|
||||
@@ -1,5 +1,5 @@
|
||||
fastapi==0.104.1
|
||||
sqlalchemy==2.0.23
|
||||
sqlalchemy==1.4.54
|
||||
alembic==1.12.1
|
||||
python-multipart==0.0.6
|
||||
python-jose[cryptography]==3.3.0
|
||||
|
||||
Reference in New Issue
Block a user