From 6aa4d59a25550ebcecbe743dac8cc2dcaf2acca3 Mon Sep 17 00:00:00 2001 From: HotSwapp <47397945+HotSwapp@users.noreply.github.com> Date: Mon, 6 Oct 2025 19:04:36 -0500 Subject: [PATCH] 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 --- TODO.md | 16 +- app/__pycache__/auth.cpython-313.pyc | Bin 0 -> 7942 bytes app/__pycache__/database.cpython-313.pyc | Bin 2942 -> 3108 bytes app/__pycache__/main.cpython-313.pyc | Bin 2906 -> 9347 bytes app/__pycache__/models.cpython-313.pyc | Bin 8835 -> 8890 bytes app/auth.py | 186 +++++++++++++++++++++++ app/database.py | 12 +- app/main.py | 141 ++++++++++++++++- app/models.py | 5 +- app/templates/base.html | 4 +- app/templates/login.html | 112 +++++++++++++- cookies.txt | 5 + delphi.db | Bin 73728 -> 73728 bytes requirements.txt | 2 +- 14 files changed, 466 insertions(+), 17 deletions(-) create mode 100644 app/__pycache__/auth.cpython-313.pyc create mode 100644 app/auth.py create mode 100644 cookies.txt 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 0000000000000000000000000000000000000000..23dc641b2061ad66dfa8185e71df9b383af6d292 GIT binary patch literal 7942 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 literal 0 HcmV?d00001 diff --git a/app/__pycache__/database.cpython-313.pyc b/app/__pycache__/database.cpython-313.pyc index cfc457d21ee955df6351fb04325993b2104f43c0..02afa77d5d297750da63595bef6032eec2318cf0 100644 GIT binary patch delta 582 zcmXX?KWGzS6o22{eV6N{m$XTmG$FRJmDU>+ggPi1q#$BLL*aC?7_QCNv~o%0-QDD% zb#ZYiw>S!Rsf$QPadU7IJBWJdAh@_lAQp;)_`cIOyg$G9dw>5F_op)>%QAuF-Hq?t zGwHp|G&|l~e6{RMIFpUZl^o`3;N;fTb<KBR)2mUb3{5|an={2-E!OAon9;S1vc#z<|!2$ zT@ko#H*jNf6NMXymg{-3E_$7vo`^L!47TxvB&M%-@Afgcir7c265`ACYgm>^>wj(F zZg|4SdBWf=x8D|?pL_u;5|&%Xsvls|;{foiM3XVbC1Qu3sN+S# iV1e delta 390 zcmZ1?@lTBJGcPX}0}%B4J;@N@n#d=?_-ms2ZkHH#B?eQFI1m&G1`EV6$8ZHR1v8m2 zvjRm-n1ThR7_wN3gn~tag}^Lfpc)aNxG<$s1motPO!-WV5|cAoQyJYR zpJ#Pv0ZK7X4r5!z>8HtZi^VCiICZiDyPVoehR%f6nG=_TXjI?ietus(tG)ROp=M4&dHvGJuvIYrt) zbsm#NxqA4)S~Qu8gn%M`nqreTa5cKg0@+0(AVLa6NPq|hAaRSixTFZIw@3~o2C}UP zYy*c)ZhlH>PO4oID0mowxOnO08tzhyk8BJa5|`N}8(2QD2{Q^$;GChpz+{EU1u5N& d0(xHo^7&+9=;)k8QR$)!#3Hg9Xgc6C9 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 CB4%3v delta 898 zcmYLH&rcIU6rS1NyZu3-AXqG}6%RE67l>d)BPym_gM>ZkVUta1hqk!wmbY6Yc)-Lz zVdGu>13bvpi(Wh$uaj`ngm{D0tBEsRTPB%#?|tuk^WJ{5Z%XvSVK0?35L@pyel_wM zLO*#hSn7YLw}}yYi);kQ#ybkT&zk!CNzfe&@EpaWncdlYOeMV3S9M^X$N5tg$6F8efogMB8xW zMZr$w(5|!;!wAM5xq$4XowC!;xIMBW!GtrhHOKFs&J3#U(Ht69Ob+tP!`mH&^-2ub zi`dRO8tXL9JMH3O+3N4r$kdBBrWMf}rWQ zqi^y`3RBr5FNjCQN~d%5I$07fvv)wipQR{3iW#cZw%smxfxrUOjOMkq4As2m4hcGy zHe?w(8U0OvSGh;=LgZ#%<#NpLv5jDiqZ0Uj2$z_9EBcd})eqE#2B~;q<0wBG;lnGO z$R&nr?AR!(RNm_n*oOoQsa*BD?U3pZUsg#cWRt_lfJ((6grW#}&NI3J0XHUN5{L{l zO*0-K8Mkw*4N`p$H&|+o?Z9WpZxk(LKO~A5sp`5lzv{Y?Zq6Ojbg<`DJXX-!pZB2! z3ggB@_7&23$Yr8R%dZhHh=)yVSAu5szTe(y)~Qt5SbswGItd^9^*RBnxo*4CBCZ>M z0#_{7SY({OKKS({8gKU60&>Wrp#eF8IohQxRi)CdgRor4b%EM%Pc! n>@g~wpw%B}^%zZ^YSCVyAe<`E$HL9%+10zonJ|Wp{>}dY3hTuN diff --git a/app/__pycache__/models.cpython-313.pyc b/app/__pycache__/models.cpython-313.pyc index a089a711468d8884227ef8afb36d02e3cb2066be..8a14ce8fc9e905e8a5ea0414d17157e9485c27f1 100644 GIT binary patch delta 2211 zcmb7FUuau(6wYnZ+vGMkNt@|lF18$dQl?6Ky?5l)AY?s80y za<&`Uu!D`g-jj_$Qn;H46*aY@FvToi(DDVv(6WN%Db;lqehNk*(RN=5L5KT#tOrlA zyn4M-Q3@5U@ee+JDzNzW9rvgpq~RCOmCj-0SUbU!u-=|IE7KCUUb|>XD$^8GE9Mo` zlIm;4X3R!uMkJp%Ogvx3QInMmbyG9)c{WBHJw=cu2tI-k{MCL%=}Xyf@5%BiVu+{>^MP-0+YlM3_+&z4_kprQUIp>zlzg1&^QHS^7)BH z43`*b@laALWlcAYT)8;MlE|?w>Fs;Ln}RrpY-1VT>nc6JgrO}77hsM>Y1vr#YyjolXjSdPUA&XqNs!5JX{LTCR?icsrI5-iME4kEP$clm25h@*p<5e37o~BX|SB@-eNX zF-=#seGO8ei3b{la%2PsT#etf(^q)oSV)8gQN@wQCX|!aaUH{^Nf?IBg5fz+&nQ0- zaHrj>aY(S*o60zC;j?Xly<;CSKJSeRpTKv$S*I70J7Ppyyucl>4!OQ~bOUc;*?X@k z=4w+RSm}#TwU~qZ;=gsF#2LXRe9?E?)C*`aSPr_y?VHY;a*vGP0`|q zoPTOTS+8Q($&`!FF_Vs|6I>wJ+dr?SBf^((JAHsR4v<6cfBIaDOZ@7proQ>8UHyMS zC}l=)3qI<<5#ZgN9?xXs>TApbv zC;R)wy9n=C+&tWx{5|lq$LIq5So-2w-l=M>Sg&X&*cLvb_uMXH6CMtv0u%RK>EA?` RH~vFMV#5P{gQ|y?{R?~hzm@<1 delta 2154 zcmb7_OKcle6ox&qJ@IR7Cr;vLlBrAMI2J*OrX^LPq9ksrM1&LITlac9V(YAmPQYbN`vS zkI(;{d6;}Vsr;cR4io;~U-@w(wyOlCUw30mZ&_3FvU|zHJ*p{SikecrAydkB)iY(L zTTLeJ6|Nm!hr#U=t`l9C!R;5Wg09=(`h@F2*K2SGgxfcPcE7Ow3~SvhN$YHYJ@~3iNb{aOWP?d<7PevKiR`$<2b0& z+0@ns5?Jb<`C@@-waIjvvkFhAry=1u?Gl@z{jS7^q2dT7PGimKPvaJn{bTA5UM`mN zY%5*J^JTiU1P>f@%PuO4^RE`2 z8KMK1TtCb6c%eB9bIPk{FJpO`6Q@UxoH$E$vXH;P!|3qyB+ro?kP$N{K@GnKPrC!> z1)?5tBHbl6m)7c~EaSW@FZU|#e;?Niu;We`arp_Cw-XmM-KQ?Sg8R_zY^%n!YN4!g znfBEIiWu<)l4Hrx**VE9q&;_9Wab;cd+tf{BD&2Ke9*VP_!5>aECRv>?x)ArNQjIs zXL7Z|CgTxuC{G4LQ5v=}E%tvnZA8?&UMRA(R_;zmna6*1A5Ia%zHuX*QG)YCCmdQ$ zyfW9Q>Ls}fcYMiE2b=v6@dfzB7dv?@A;Q2(QgCMA?-rp&%+6Qv+hAVK;)P}ft_-m$ z4a;_RY$A^b$q{)G@ey!Tyi-icTgh4VpB$l=06CV*b**+32kiQT(mU{}{}6$2_juR? z$(Ofi2|LMkn%K_SI^#^sv91}((T&Fvpfhrk3fvFeYZ1tb-ok=DNv5vR+<`YkrKvKO z?VMDxY`-_+%+yv2e`3QhL_?q@1aBOAaqO*BWJ>HP2%JUlTPWx1C1ant3wk(i%tnaF zTsK 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 @@