diff --git a/Dockerfile b/Dockerfile index 794c524..adb1326 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,5 @@ FROM python:3.11-alpine -# argon2 needs these to build, and we need argon2 for secure password -# hashing/salting -RUN apk add gcc musl-dev libffi-dev -RUN python3 -m pip install -U cffi pip setuptools - # Enforcement to ensure passwords environment variables are not left blank. # This won't stop bad passwords from being used, but at least won't cause # errors or, worse, weaker crypt. @@ -26,17 +21,9 @@ RUN mkdir -p "${ENV_GHOSTFORGE_DATA_DIR}" WORKDIR /ghostforge COPY . . -# argon2 needs these to build, and we need argon2 for secure password -# hashing/salting -RUN apk add gcc musl-dev libffi-dev -RUN python3 -m pip install -U cffi pip setuptools - # Install ghostforge from the work directory. RUN pip install . -# Reclaim space from build-time packages -RUN apk del gcc musl-dev libffi-dev - # Expose the web "serve" port specific in the environment variables. ARG GHOSTFORGE_INTERNAL_WEB_PORT ENV ENV_GHOSTFORGE_INTERNAL_WEB_PORT=${GHOSTFORGE_INTERNAL_WEB_PORT} diff --git a/ghostforge/auth.py b/ghostforge/auth.py deleted file mode 100644 index 7cbe6d0..0000000 --- a/ghostforge/auth.py +++ /dev/null @@ -1,19 +0,0 @@ -import os - -from fastapi_users.authentication import AuthenticationBackend -from fastapi_users.authentication import BearerTransport -from fastapi_users.authentication import JWTStrategy - -SECRET = os.environ.get("GHOSTFORGE_JWT_SECRET") -bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") - - -def get_jwt_strategy() -> JWTStrategy: - return JWTStrategy(secret=SECRET, lifetime_seconds=3600) - - -auth_backend = AuthenticationBackend( - name="jwt", - transport=bearer_transport, - get_strategy=get_jwt_strategy, -) diff --git a/ghostforge/db.py b/ghostforge/db.py index 4ee92ba..4981c27 100644 --- a/ghostforge/db.py +++ b/ghostforge/db.py @@ -1,9 +1,21 @@ import os +from fastapi import Depends +from fastapi_users.db import SQLAlchemyBaseUserTableUUID +from fastapi_users.db import SQLAlchemyUserDatabase from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import sessionmaker -from sqlmodel import SQLModel + + +class Base(DeclarativeBase): + pass + + +class User(SQLAlchemyBaseUserTableUUID, Base): + pass + database_url = ( f'postgresql+asyncpg://{os.environ.get("POSTGRES_USER")}:' @@ -24,5 +36,9 @@ async def get_session() -> AsyncSession: async def create_db_and_tables(): # TODO: Remove the drop, this is for dev async with engine.begin() as conn: - # await conn.run_sync(SQLModel.metadata.drop_all) - await conn.run_sync(SQLModel.metadata.create_all) + # await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + +async def get_user_db(session: AsyncSession = Depends(get_session)): + yield SQLAlchemyUserDatabase(session, User) diff --git a/ghostforge/serve.py b/ghostforge/serve.py index 1557d9b..b8a185f 100644 --- a/ghostforge/serve.py +++ b/ghostforge/serve.py @@ -1,15 +1,89 @@ +from typing import Annotated + +from fastapi import Depends from fastapi import FastAPI +from fastapi import Request +from fastapi import Response +from fastapi.responses import HTMLResponse +from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from ghostforge.db import create_db_and_tables -from ghostforge.users import gf as gf_users +from ghostforge.db import User +from ghostforge.htmljson import HtmlJson +from ghostforge.templates import templates +from ghostforge.users import fastapi_users +from ghostforge.users import get_current_user +from ghostforge.users import jwt_backend +from ghostforge.users import UserCreate +from ghostforge.users import UserRead +from ghostforge.users import UserUpdate +from ghostforge.users import web_backend + +# from ghostforge.users import gf as gf_users # from ghostforge.models import User gf = FastAPI() +hj = HtmlJson() gf.mount("/static", StaticFiles(directory="ghostforge/static"), name="static") -gf.include_router(gf_users) +# gf.include_router(gf_users) + +gf.include_router(fastapi_users.get_auth_router(jwt_backend), prefix="/auth/jwt", tags=["auth"]) +gf.include_router(fastapi_users.get_auth_router(web_backend), prefix="/auth/cookie", tags=["auth"]) +gf.include_router( + fastapi_users.get_register_router(UserRead, UserCreate), + prefix="/auth", + tags=["auth"], +) +gf.include_router( + fastapi_users.get_reset_password_router(), + prefix="/auth", + tags=["auth"], +) +gf.include_router( + fastapi_users.get_verify_router(UserRead), + prefix="/auth", + tags=["auth"], +) +gf.include_router( + fastapi_users.get_users_router(UserRead, UserUpdate), + prefix="/users", + tags=["users"], +) + + +@gf.get("/", response_class=HTMLResponse) +async def home(request: Request, current_user=Depends(get_current_user())): + if current_user: + return RedirectResponse(url="/dashboard") + return RedirectResponse(url="/login") + + +@gf.get("/login", response_class=HTMLResponse) +async def login_form(request: Request, current_user=Depends(get_current_user(optional=True))): + if current_user: + return RedirectResponse(url="/dashboard") + return templates.TemplateResponse("login.html", {"request": request}) + + +@gf.get("/logout") +async def logout(response: Response): + response.delete_cookie("ghostforge") + response.headers["Location"] = "/login" + response.status_code = 302 + return response + + +@gf.get("/dashboard") +async def authenticated_route( + request: Request, current_user: Annotated[User, Depends(get_current_user(optional=True))] +): + if not current_user: + return RedirectResponse(url="/login") + crumbs = [("dashboard", False), (current_user.email, False)] + return templates.TemplateResponse("dashboard.html", {"request": request, "crumbs": crumbs, "user": current_user}) @gf.on_event("startup") diff --git a/ghostforge/static/js/login.js b/ghostforge/static/js/login.js new file mode 100644 index 0000000..fedf377 --- /dev/null +++ b/ghostforge/static/js/login.js @@ -0,0 +1,34 @@ +const loginForm = document.getElementById('auth-form'); +const loginError = document.getElementById('auth-error'); + +loginForm.addEventListener('submit', async (event) => { + event.preventDefault(); + + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + + const encodedData = new URLSearchParams(); + encodedData.append('username', email); + encodedData.append('password', password); + + const response = await fetch(`/auth/cookie/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: encodedData, + }); + + if (response.ok) { + document.cookie = 'ghostforge=' + response.headers.get('Set-Cookie'); + window.location.href = '/dashboard'; + } else { + const errorMessage = "Invalid username or password."; + let toast = document.getElementById("error-msg"); + if (!toast) { + toast = document.createElement('div'); + toast.id = "error-msg"; + toast.classList.add("toast", "toast-error"); + loginError.appendChild(toast); + } + toast.innerText = errorMessage; + } +}); diff --git a/ghostforge/templates.py b/ghostforge/templates.py index b717a82..c38f2bd 100644 --- a/ghostforge/templates.py +++ b/ghostforge/templates.py @@ -16,30 +16,30 @@ templates.env.globals["gf_repository_url"] = "https://github.com/DarrylNixon/gho # Since 3.7 (we require >= 3.9), dicts are guaranteed ordered as inserted. templates.env.globals["gf_navbar"] = { "Ghosts": { - "Dashboard": "/users/0", - "New": "/users/0", - "Active": "/users/0", - "Archived": "/users/0", + "Dashboard": "/dashboard", + "New": "/ghosts/new", + "Active": "/ghosts", + "Archived": "/ghosts/archive", }, "Research": { - "Guidebook": "/users/0", - "Cheat Sheet": "/users/0", + "Guidebook": "/guidebook", + "Cheat Sheet": "/cheatsheet", }, "Settings": { - "Your Profile": "/users/0", - "Configuration": "/users/0", - "Integrations": "/users/0", - "Manage Users": "/users/0", + "Your Profile": "/profile", + "Configuration": "/configuration", + "Integrations": "/integrations", + "Manage Users": "/manage", }, "Meta": { - "About GhostForge": "/users/0", - "System Logs": "/users/0", - "Logout": "/users/0", + "About GhostForge": "/about", + "System Logs": "/logs", + "Logout": "/logout", }, } templates.env.globals["avatar_menu"] = { - "Dashboard": "/users/0", - "Your Profile": "/users/0", - "Logout": "/users/0", + "Dashboard": "/dashboard", + "Your Profile": "/profile", + "Logout": "/logout", } diff --git a/ghostforge/templates/base.html b/ghostforge/templates/base.html index 36d862e..02843eb 100644 --- a/ghostforge/templates/base.html +++ b/ghostforge/templates/base.html @@ -17,7 +17,7 @@