diff --git a/TODO.md b/TODO.md index 50342ec..b39b358 100644 --- a/TODO.md +++ b/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 diff --git a/app/__pycache__/auth.cpython-313.pyc b/app/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000..23dc641 Binary files /dev/null and b/app/__pycache__/auth.cpython-313.pyc differ diff --git a/app/__pycache__/database.cpython-313.pyc b/app/__pycache__/database.cpython-313.pyc index cfc457d..02afa77 100644 Binary files a/app/__pycache__/database.cpython-313.pyc and b/app/__pycache__/database.cpython-313.pyc differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index 211c740..3854c75 100644 Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/__pycache__/models.cpython-313.pyc b/app/__pycache__/models.cpython-313.pyc index a089a71..8a14ce8 100644 Binary files a/app/__pycache__/models.cpython-313.pyc and b/app/__pycache__/models.cpython-313.pyc differ diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..77fef29 --- /dev/null +++ b/app/auth.py @@ -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() diff --git a/app/database.py b/app/database.py index 4b35001..ecd4359 100644 --- a/app/database.py +++ b/app/database.py @@ -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: """ diff --git a/app/main.py b/app/main.py index a92da2f..e3bb415 100644 --- a/app/main.py +++ b/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 + }) diff --git a/app/models.py b/app/models.py index f4409cd..61e9df7 100644 --- a/app/models.py +++ b/app/models.py @@ -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): diff --git a/app/templates/base.html b/app/templates/base.html index a16f733..98a50da 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -43,10 +43,10 @@