mirror of
https://github.com/DarrylNixon/ghostforge
synced 2024-04-22 06:27:20 -07:00
Preparing to setup fastapi-users
This commit is contained in:
parent
7598e2c8fc
commit
216d2ac42b
15 changed files with 266 additions and 45 deletions
|
@ -13,3 +13,4 @@ build/
|
||||||
**/*.pyo
|
**/*.pyo
|
||||||
**/*.mo
|
**/*.mo
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
*.png
|
||||||
|
|
15
Dockerfile
15
Dockerfile
|
@ -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"]
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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]
|
||||||
|
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
19
ghostforge/auth.py
Normal file
19
ghostforge/auth.py
Normal 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,
|
||||||
|
)
|
|
@ -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
88
ghostforge/htmljson.py
Normal 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
|
|
@ -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
|
|
|
@ -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()
|
||||||
|
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue