From 6f81ef699d19198da4978ea2815249006a748a10 Mon Sep 17 00:00:00 2001 From: Darryl Nixon Date: Fri, 26 May 2023 18:04:31 -0700 Subject: [PATCH] Replaced rolled-own/argon2 with fastapi-users, oof --- Dockerfile | 13 --- ghostforge/auth.py | 19 ---- ghostforge/db.py | 22 +++- ghostforge/serve.py | 78 +++++++++++++- ghostforge/static/js/login.js | 34 ++++++ ghostforge/templates.py | 32 +++--- ghostforge/templates/base.html | 2 +- ghostforge/templates/dashboard.html | 59 ++++++++++ ghostforge/templates/login.html | 51 +++++++++ ghostforge/templates/navigation/top.html | 2 +- ghostforge/users.py | 131 +++++++++-------------- migrations/env.py | 5 +- migrations/script.py.mako | 1 - pyproject.toml | 3 +- 14 files changed, 310 insertions(+), 142 deletions(-) delete mode 100644 ghostforge/auth.py create mode 100644 ghostforge/static/js/login.js create mode 100644 ghostforge/templates/dashboard.html create mode 100644 ghostforge/templates/login.html 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 @@
{% include "navigation/side.html" %}
-
+
{% include "navigation/top.html" %} {% block content %} {% endblock content %} diff --git a/ghostforge/templates/dashboard.html b/ghostforge/templates/dashboard.html new file mode 100644 index 0000000..1d83317 --- /dev/null +++ b/ghostforge/templates/dashboard.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

{% block title %}Dashboard{% endblock %}

+
+
+
+
+
+
+
Comments
+
+
+ +
+
+ +
+ +
+
+
+
+
+
Comments
+
+
+ +
+
+ +
+ +
+
+
+
+
+
Comments
+
+
+ +
+
+ +
+ +
+
+
+{% endblock %} diff --git a/ghostforge/templates/login.html b/ghostforge/templates/login.html new file mode 100644 index 0000000..1f05320 --- /dev/null +++ b/ghostforge/templates/login.html @@ -0,0 +1,51 @@ + + + + + + + + {% block title %}{{ title }}{% endblock title %} + + + + + + + +
+
+
+
+
+
+ ghostforge +
+
+
+
+
+ + +
+
+ + +
+ +
+
+ +
+
+
+
+
+ + + + diff --git a/ghostforge/templates/navigation/top.html b/ghostforge/templates/navigation/top.html index c116945..de6a7f2 100644 --- a/ghostforge/templates/navigation/top.html +++ b/ghostforge/templates/navigation/top.html @@ -30,7 +30,7 @@ alt="Avatar">
- Steve Rogers + {{ user.email }}
diff --git a/ghostforge/users.py b/ghostforge/users.py index 6f456c3..0baffb1 100644 --- a/ghostforge/users.py +++ b/ghostforge/users.py @@ -1,109 +1,76 @@ -from datetime import datetime -from typing import List +import os +import uuid from typing import Optional -from argon2 import PasswordHasher from fastapi import APIRouter from fastapi import Depends -from fastapi import HTTPException -from fastapi import Query from fastapi import Request -from sqlmodel import Field -from sqlmodel import select -from sqlmodel import Session -from sqlmodel import SQLModel +from fastapi_users import BaseUserManager +from fastapi_users import FastAPIUsers +from fastapi_users import schemas +from fastapi_users import UUIDIDMixin +from fastapi_users.authentication import AuthenticationBackend +from fastapi_users.authentication import BearerTransport +from fastapi_users.authentication import CookieTransport +from fastapi_users.authentication import JWTStrategy +from fastapi_users.db import SQLAlchemyUserDatabase -from ghostforge.db import get_session -from ghostforge.htmljson import HtmlJson +from ghostforge.db import get_user_db +from ghostforge.db import User + +SECRET = os.environ.get("GHOSTFORGE_JWT_SECRET") gf = APIRouter() -ph = PasswordHasher() -hj = HtmlJson() -class UserBase(SQLModel): - name: str = Field() - - class Config: - orm_mode = True +class UserRead(schemas.BaseUser[uuid.UUID]): + pass -class User(UserBase, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - creation: datetime = Field(default=datetime.now()) - password_hash: Optional[str] - - def verify_password(self, password: str): - return ph.verify(self.password_hash, password) - - def set_password(self, password: str): - self.password_hash = ph.hash(password) +class UserCreate(schemas.BaseUserCreate): + pass -class UserCreate(UserBase): - name: str - password: str +class UserUpdate(schemas.BaseUserUpdate): + pass -class UserRead(UserBase): - id: int +class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): + reset_password_token_secret = SECRET + verification_token_secret = SECRET + + async def on_after_register(self, user: User, request: Optional[Request] = None): + print(f"User {user.id} has registered.") + + async def on_after_forgot_password(self, user: User, token: str, request: Optional[Request] = None): + print(f"User {user.id} has forgot their password. Reset token: {token}") + + async def on_after_request_verify(self, user: User, token: str, request: Optional[Request] = None): + print(f"Verification requested for user {user.id}. Verification token: {token}") -class UserUpdate(SQLModel): - name: Optional[str] = None +async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)): + yield UserManager(user_db) -@gf.get("/users", response_model=List[UserRead]) -async def read_users( - offset: int = 0, limit: int = Query(default=100, lte=100), session: Session = Depends(get_session) -): - users = await session.execute(select(User).offset(offset).limit(limit)) - return [UserRead.from_orm(user[0]) for user in users.all()] +bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") +cookie_transport = CookieTransport(cookie_httponly=True, cookie_name="ghostforge", cookie_samesite="strict") -@gf.post("/users", response_model=UserRead) -async def create_hero(user: UserCreate, session: Session = Depends(get_session)): - new_user = User.from_orm(user) - if len(user.password) < 12: - raise HTTPException(status_code=442, detail="Password must be at least 12 characters") - new_user.set_password(user.password) - session.add(new_user) - await session.commit() - await session.refresh(new_user) - return new_user +def get_jwt_strategy() -> JWTStrategy: + return JWTStrategy(secret=SECRET, lifetime_seconds=604800) -@gf.get("/users/{user_id}", response_model=UserRead) -@hj.html_or_json("user.html") -async def read_user(user_id: int, session: Session = Depends(get_session), request: Request = None): - user = await session.get(User, user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") +jwt_backend = AuthenticationBackend( + name="jwt", + transport=bearer_transport, + get_strategy=get_jwt_strategy, +) - data = {"crumbs": [("settings", False), ("users", "/users"), (user_id, False)]} - request.state.ghostforge = data | getattr(request.state, "ghostforge", {}) - return user +web_backend = AuthenticationBackend(name="cookie", transport=cookie_transport, get_strategy=get_jwt_strategy) + +fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [web_backend, jwt_backend]) -@gf.patch("/users/{user_id}", response_model=UserRead) -async def update_user(user_id: int, user: UserUpdate, session: Session = Depends(get_session), request: Request = None): - edit_user = await session.get(User, user_id) - if not edit_user: - raise HTTPException(status_code=404, detail="User not found") - data = user.dict(exclude_unset=True) - for key, value in data.items(): - setattr(edit_user, key, value) - session.add(edit_user) - await session.commit() - await session.refresh(edit_user) - return edit_user - - -@gf.delete("/users/{user_id}") -async def delete_user(user_id: int, session: Session = Depends(get_session), request: Request = None): - user = await session.get(User, user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - session.delete(user) - await session.commit() - return {"ok": True} +def get_current_user(active: bool = True, optional: bool = False) -> User: + return fastapi_users.current_user(active=active, optional=optional) diff --git a/migrations/env.py b/migrations/env.py index 1c848ea..4bf02cc 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -6,7 +6,8 @@ from alembic import context from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config -from sqlmodel import SQLModel + +from ghostforge.db import Base # from ghostforge.models import User @@ -32,7 +33,7 @@ if config.config_file_name is not None: # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata -target_metadata = SQLModel.metadata +target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, # can be acquired: diff --git a/migrations/script.py.mako b/migrations/script.py.mako index 3124b62..55df286 100644 --- a/migrations/script.py.mako +++ b/migrations/script.py.mako @@ -7,7 +7,6 @@ Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa -import sqlmodel ${imports if imports else ""} # revision identifiers, used by Alembic. diff --git a/pyproject.toml b/pyproject.toml index a3d3a41..044ada0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,12 +18,11 @@ dependencies = [ "pydantic==1.10.8", "alembic==1.11.1", "asyncpg==0.27.0", - "sqlmodel==0.0.8", "greenlet==2.0.2", "jinja2==3.1.2", - "argon2-cffi==21.3.0", "fastapi-users==11.0.0", "fastapi-users-db-sqlalchemy==5.0.0", + "httpx==0.24.1", ] [project.urls]