Preparing to setup fastapi-users

This commit is contained in:
Darryl Nixon 2023-05-26 11:25:42 -07:00
parent 7598e2c8fc
commit 216d2ac42b
15 changed files with 266 additions and 45 deletions

View file

@ -13,3 +13,4 @@ build/
**/*.pyo **/*.pyo
**/*.mo **/*.mo
*.egg-info/ *.egg-info/
*.png

View file

@ -1,5 +1,10 @@
FROM python:3.11-alpine 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. # 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 # This won't stop bad passwords from being used, but at least won't cause
# errors or, worse, weaker crypt. # errors or, worse, weaker crypt.
@ -21,9 +26,17 @@ RUN mkdir -p "${ENV_GHOSTFORGE_DATA_DIR}"
WORKDIR /ghostforge WORKDIR /ghostforge
COPY . . 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. # Install ghostforge from the work directory.
RUN pip install . 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. # Expose the web "serve" port specific in the environment variables.
ARG GHOSTFORGE_INTERNAL_WEB_PORT ARG GHOSTFORGE_INTERNAL_WEB_PORT
ENV ENV_GHOSTFORGE_INTERNAL_WEB_PORT=${GHOSTFORGE_INTERNAL_WEB_PORT} ENV ENV_GHOSTFORGE_INTERNAL_WEB_PORT=${GHOSTFORGE_INTERNAL_WEB_PORT}
@ -34,4 +47,4 @@ EXPOSE ${ENV_GHOSTFORGE_INTERNAL_WEB_PORT}
# TODO: Replace with ghostforge_serve when it works. # TODO: Replace with ghostforge_serve when it works.
# This currently just keeps the container running for development. # This currently just keeps the container running for development.
CMD [ "python3", "-m", "http.server" ] CMD ["sh", "-c", "uvicorn ghostforge.serve:gf --host 0.0.0.0 --port $GHOSTFORGE_INTERNAL_WEB_PORT"]

View file

@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<img src="doc/ghostforge.png" alt="ghostforge Logo"> <img src="ghostforge.png" alt="ghostforge Logo">
# ghostforge # ghostforge

View file

@ -58,7 +58,6 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# are written from script.py.mako # are written from script.py.mako
# output_encoding = utf-8 # output_encoding = utf-8
# sqlalchemy.url = driver://user:pass@localhost/dbname
sqlalchemy.url = postgresql+asyncpg://%(POSTGRES_USER)s:%(POSTGRES_PASSWORD)s@%(POSTGRES_CONTAINER)s:5432/%(POSTGRES_DB)s sqlalchemy.url = postgresql+asyncpg://%(POSTGRES_USER)s:%(POSTGRES_PASSWORD)s@%(POSTGRES_CONTAINER)s:5432/%(POSTGRES_DB)s
[post_write_hooks] [post_write_hooks]

View file

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

19
ghostforge/auth.py Normal file
View file

@ -0,0 +1,19 @@
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,
)

View file

View file

@ -3,6 +3,7 @@ import os
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlmodel import SQLModel
database_url = ( database_url = (
f'postgresql+asyncpg://{os.environ.get("POSTGRES_USER")}:' f'postgresql+asyncpg://{os.environ.get("POSTGRES_USER")}:'
@ -18,3 +19,10 @@ async def get_session() -> AsyncSession:
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session: async with async_session() as session:
yield session yield session
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)

88
ghostforge/htmljson.py Normal file
View file

@ -0,0 +1,88 @@
from functools import wraps
from inspect import _ParameterKind
from inspect import Parameter
from inspect import signature
from typing import Callable
from fastapi import Request
from pydantic import BaseModel
from ghostforge.templates import templates
# Original credit: https://github.com/acmpo6ou/fastapi_html_json/blob/master/html_json.py
class HtmlJson:
def __init__(self):
"""
Provides @html_or_json decorator, see its documentation for more info.
:param templates_dir: directory containing jinja2 templates.
"""
pass
@staticmethod
def add_request_param(wrapper: Callable, f: Callable):
"""
Adds `request` parameter to signature of wrapper if it's not there already.
:param f: decorated function.
"""
sig = signature(f)
if "request" in sig.parameters:
return
params = list(sig.parameters.values())
request_param = Parameter("request", _ParameterKind.POSITIONAL_OR_KEYWORD, annotation=Request)
params.append(request_param)
wrapper.__signature__ = sig.replace(parameters=params)
def render_template(self, template: str, request: Request, result):
"""
Renders jinja2 template no matter what the view function returns: be it dictionary,
pydantic model or a list.
:param template: path to the template.
:param request: needed by TemplateResponse.
:param result: return value of the view function.
:param breadcrumbs: breadcrumbs for HTML rendering.
:return: rendered template.
"""
if isinstance(result, BaseModel):
result = result.dict()
elif isinstance(result, list):
result = {"data": result[:]}
if hasattr(request.state, "ghostforge"):
if "crumbs" in request.state.ghostforge:
result["crumbs"] = request.state.ghostforge["crumbs"]
result.update({"request": request})
return templates.TemplateResponse(template, result)
def html_or_json(self, template: str):
"""
A decorator that will make decorated async view function able to return jinja2 template
or json depending on Accept header of request.
:param template: path to jinja2 template.
"""
def decorator(f: Callable):
@wraps(f)
async def wrapper(*args, **kwargs):
request = kwargs["request"]
try:
result = await f(*args, **kwargs)
except TypeError:
kwargs.pop("request")
result = await f(*args, **kwargs)
accept = request.headers["accept"].split(",")[0]
if accept == "text/html":
return self.render_template(template, request, result)
return result
self.add_request_param(wrapper, f)
return wrapper
return decorator

View file

@ -1,8 +0,0 @@
from sqlmodel import Field
from sqlmodel import SQLModel
class User(SQLModel, table=True):
id: int = Field(default=None, primary_key=True)
name: str = Field(unique=True)
password: str

View file

@ -1,11 +1,17 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from users import gf as gf_users from ghostforge.db import create_db_and_tables
from ghostforge.users import gf as gf_users
# from ghostforge.models import User # from ghostforge.models import User
gf = FastAPI() gf = FastAPI()
gf.mount("/static", StaticFiles(directory="static"), name="static") gf.mount("/static", StaticFiles(directory="ghostforge/static"), name="static")
gf.include_router(gf_users) gf.include_router(gf_users)
@gf.on_event("startup")
async def on_startup():
await create_db_and_tables()

View file

@ -2,7 +2,7 @@ import importlib.metadata
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="ghostforge/templates")
# Inject version string and Github URL into every template for navbar display. # Inject version string and Github URL into every template for navbar display.
try: try:
@ -15,31 +15,31 @@ templates.env.globals["gf_repository_url"] = "https://github.com/DarrylNixon/gho
# Same, but build the navbar from an ordered dictionary for centralization. # Same, but build the navbar from an ordered dictionary for centralization.
# Since 3.7 (we require >= 3.9), dicts are guaranteed ordered as inserted. # Since 3.7 (we require >= 3.9), dicts are guaranteed ordered as inserted.
templates.env.globals["gf_navbar"] = { templates.env.globals["gf_navbar"] = {
"Management": { "Ghosts": {
"Dashboard": "/user/0", "Dashboard": "/users/0",
"New Ghost": "/user/0", "New": "/users/0",
"Active Ghosts": "/user/0", "Active": "/users/0",
"Archived Ghosts": "/user/0", "Archived": "/users/0",
}, },
"Research": { "Research": {
"Guidebook": "/user/0", "Guidebook": "/users/0",
"Cheat Sheet": "/user/0", "Cheat Sheet": "/users/0",
}, },
"Settings": { "Settings": {
"Your Profile": "/user/0", "Your Profile": "/users/0",
"Configuration": "/user/0", "Configuration": "/users/0",
"Integrations": "/user/0", "Integrations": "/users/0",
"Manage Users": "/user/0", "Manage Users": "/users/0",
}, },
"Meta": { "Meta": {
"About GhostForge": "/user/0", "About GhostForge": "/users/0",
"System Logs": "/user/0", "System Logs": "/users/0",
"Logout": "/user/0", "Logout": "/users/0",
}, },
} }
templates.env.globals["avatar_menu"] = { templates.env.globals["avatar_menu"] = {
"Dashboard": "/user/0", "Dashboard": "/users/0",
"Your Profile": "/user/0", "Your Profile": "/users/0",
"Logout": "/user/0", "Logout": "/users/0",
} }

View file

@ -11,7 +11,8 @@
<div class="column col-6"> <div class="column col-6">
<div class="panel"> <div class="panel">
<div class="panel-header text-center"> <div class="panel-header text-center">
<figure class="avatar avatar-lg"><img src="../img/avatar-2.png" alt="Avatar"></figure> <figure class="avatar avatar-lg"><img src="{{ url_for('static', path='/img/default-avatar.png') }}"
alt="Avatar"></figure>
<div class="panel-title h5 mt-10">Bruce Banner</div> <div class="panel-title h5 mt-10">Bruce Banner</div>
<div class="panel-subtitle">THE HULK</div> <div class="panel-subtitle">THE HULK</div>
</div> </div>

View file

@ -1,15 +1,109 @@
from fastapi import APIRouter from datetime import datetime
from fastapi import Request from typing import List
from fastapi.responses import HTMLResponse from typing import Optional
from templates import templates 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 ghostforge.db import get_session
from ghostforge.htmljson import HtmlJson
gf = APIRouter() gf = APIRouter()
ph = PasswordHasher()
hj = HtmlJson()
@gf.get("/user/{id}", response_class=HTMLResponse) class UserBase(SQLModel):
async def get_user(request: Request, id: str): name: str = Field()
crumbs = [("settings", False), ("users", "/users"), (id, False)]
return templates.TemplateResponse( class Config:
"user.html", {"request": request, "crumbs": crumbs, "user": "test", "id": id, "title": f"User {id}"} orm_mode = True
)
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(UserBase):
name: str
password: str
class UserRead(UserBase):
id: int
class UserUpdate(SQLModel):
name: Optional[str] = None
@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()]
@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
@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")
data = {"crumbs": [("settings", False), ("users", "/users"), (user_id, False)]}
request.state.ghostforge = data | getattr(request.state, "ghostforge", {})
return user
@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}

View file

@ -21,11 +21,11 @@ dependencies = [
"sqlmodel==0.0.8", "sqlmodel==0.0.8",
"greenlet==2.0.2", "greenlet==2.0.2",
"jinja2==3.1.2", "jinja2==3.1.2",
"argon2-cffi==21.3.0",
"fastapi-users==11.0.0",
"fastapi-users-db-sqlalchemy==5.0.0",
] ]
[project.scripts]
ghostforge_serve = "ghostforge.serve:start"
[project.urls] [project.urls]
homepage = "https://github.com/DarrylNixon/ghostforge" homepage = "https://github.com/DarrylNixon/ghostforge"
repository = "https://github.com/DarrylNixon/ghostforge" repository = "https://github.com/DarrylNixon/ghostforge"