Replaced rolled-own/argon2 with fastapi-users, oof

This commit is contained in:
Darryl Nixon 2023-05-26 18:04:31 -07:00
parent 216d2ac42b
commit 6f81ef699d
14 changed files with 310 additions and 142 deletions

View file

@ -1,10 +1,5 @@
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.
@ -26,17 +21,9 @@ 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}

View file

@ -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,
)

View file

@ -1,9 +1,21 @@
import os 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 AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlmodel import SQLModel
class Base(DeclarativeBase):
pass
class User(SQLAlchemyBaseUserTableUUID, Base):
pass
database_url = ( database_url = (
f'postgresql+asyncpg://{os.environ.get("POSTGRES_USER")}:' f'postgresql+asyncpg://{os.environ.get("POSTGRES_USER")}:'
@ -24,5 +36,9 @@ async def get_session() -> AsyncSession:
async def create_db_and_tables(): async def create_db_and_tables():
# TODO: Remove the drop, this is for dev # TODO: Remove the drop, this is for dev
async with engine.begin() as conn: async with engine.begin() as conn:
# await conn.run_sync(SQLModel.metadata.drop_all) # await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(SQLModel.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
async def get_user_db(session: AsyncSession = Depends(get_session)):
yield SQLAlchemyUserDatabase(session, User)

View file

@ -1,15 +1,89 @@
from typing import Annotated
from fastapi import Depends
from fastapi import FastAPI 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 fastapi.staticfiles import StaticFiles
from ghostforge.db import create_db_and_tables 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 # from ghostforge.models import User
gf = FastAPI() gf = FastAPI()
hj = HtmlJson()
gf.mount("/static", StaticFiles(directory="ghostforge/static"), name="static") 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") @gf.on_event("startup")

View file

@ -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;
}
});

View file

@ -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. # Since 3.7 (we require >= 3.9), dicts are guaranteed ordered as inserted.
templates.env.globals["gf_navbar"] = { templates.env.globals["gf_navbar"] = {
"Ghosts": { "Ghosts": {
"Dashboard": "/users/0", "Dashboard": "/dashboard",
"New": "/users/0", "New": "/ghosts/new",
"Active": "/users/0", "Active": "/ghosts",
"Archived": "/users/0", "Archived": "/ghosts/archive",
}, },
"Research": { "Research": {
"Guidebook": "/users/0", "Guidebook": "/guidebook",
"Cheat Sheet": "/users/0", "Cheat Sheet": "/cheatsheet",
}, },
"Settings": { "Settings": {
"Your Profile": "/users/0", "Your Profile": "/profile",
"Configuration": "/users/0", "Configuration": "/configuration",
"Integrations": "/users/0", "Integrations": "/integrations",
"Manage Users": "/users/0", "Manage Users": "/manage",
}, },
"Meta": { "Meta": {
"About GhostForge": "/users/0", "About GhostForge": "/about",
"System Logs": "/users/0", "System Logs": "/logs",
"Logout": "/users/0", "Logout": "/logout",
}, },
} }
templates.env.globals["avatar_menu"] = { templates.env.globals["avatar_menu"] = {
"Dashboard": "/users/0", "Dashboard": "/dashboard",
"Your Profile": "/users/0", "Your Profile": "/profile",
"Logout": "/users/0", "Logout": "/logout",
} }

View file

@ -17,7 +17,7 @@
<div class="off-canvas off-canvas-sidebar-show d-flex"> <div class="off-canvas off-canvas-sidebar-show d-flex">
{% include "navigation/side.html" %} {% include "navigation/side.html" %}
<div class="off-canvas-content"> <div class="off-canvas-content">
<div class="container"> <div class="container" style="padding-right: 5em; padding-top: 1em;">
{% include "navigation/top.html" %} {% include "navigation/top.html" %}
{% block content %} {% block content %}
{% endblock content %} {% endblock content %}

View file

@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block content %}
<div class="columns">
<div class="column col-12">
<h1>{% block title %}Dashboard{% endblock %}</h1>
</div>
</div>
<div class="columns">
<div class="column col-4">
<div class="panel">
<div class="panel-header">
<div class="panel-title">Comments</div>
</div>
<div class="panel-nav">
<!-- navigation components: tabs, breadcrumbs or pagination -->
</div>
<div class="panel-body">
<!-- contents -->
</div>
<div class="panel-footer">
<!-- buttons or inputs -->
</div>
</div>
</div>
<div class="column col-4">
<div class="panel">
<div class="panel-header">
<div class="panel-title">Comments</div>
</div>
<div class="panel-nav">
<!-- navigation components: tabs, breadcrumbs or pagination -->
</div>
<div class="panel-body">
<!-- contents -->
</div>
<div class="panel-footer">
<!-- buttons or inputs -->
</div>
</div>
</div>
<div class="column col-4">
<div class="panel">
<div class="panel-header">
<div class="panel-title">Comments</div>
</div>
<div class="panel-nav">
<!-- navigation components: tabs, breadcrumbs or pagination -->
</div>
<div class="panel-body">
<!-- contents -->
</div>
<div class="panel-footer">
<!-- buttons or inputs -->
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='/img/favicon.ico') }}">
<title>{% block title %}{{ title }}{% endblock title %}</title>
<link href="{{ url_for('static', path='/css/spectre.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/spectre-icons.min.css') }}" rel="stylesheet">
</head>
<html>
<body>
<div class="container">
<div class="columns">
<div class="column col-4 col-xs-12"></div>
<div class="column col-4 col-xs-12">
<div class="card" style="margin-top: 5em;">
<div class="card-image" style="text-align: center; margin-top: 1em;">
<img src="{{ url_for('static', path='/img/ghostforge-sidebar.png') }}" alt="ghostforge">
</div>
<div class="card-body">
<div id="auth-error"></div>
<form id="auth-form" method="POST">
<div class="form-group">
<label class="form-label" for="email">Email</label>
<input class="form-input" type="email" name="email" id="email" required>
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input class="form-input" type="password" name="password" id="password" required>
</div>
<button class="btn btn-primary btn-block" type="submit">Log In</button>
</form>
</div>
<div class="card-footer">
<div class="text-center my-2">
&copy; 2023 ghostforge
</div>
</div>
</div>
</div>
<div class="column col-4 col-xs-12"></div>
</div>
</div>
</body>
<script src="{{ url_for('static', path='/js/login.js') }}"></script>
</html>

View file

@ -30,7 +30,7 @@
alt="Avatar"> alt="Avatar">
</div> </div>
<div class="tile-content"> <div class="tile-content">
Steve Rogers {{ user.email }}
</div> </div>
</div> </div>
</li> </li>

View file

@ -1,109 +1,76 @@
from datetime import datetime import os
from typing import List import uuid
from typing import Optional from typing import Optional
from argon2 import PasswordHasher
from fastapi import APIRouter from fastapi import APIRouter
from fastapi import Depends from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query
from fastapi import Request from fastapi import Request
from sqlmodel import Field from fastapi_users import BaseUserManager
from sqlmodel import select from fastapi_users import FastAPIUsers
from sqlmodel import Session from fastapi_users import schemas
from sqlmodel import SQLModel 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.db import get_user_db
from ghostforge.htmljson import HtmlJson from ghostforge.db import User
SECRET = os.environ.get("GHOSTFORGE_JWT_SECRET")
gf = APIRouter() gf = APIRouter()
ph = PasswordHasher()
hj = HtmlJson()
class UserBase(SQLModel): class UserRead(schemas.BaseUser[uuid.UUID]):
name: str = Field() pass
class Config:
orm_mode = True
class User(UserBase, table=True): class UserCreate(schemas.BaseUserCreate):
id: Optional[int] = Field(default=None, primary_key=True) pass
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): class UserUpdate(schemas.BaseUserUpdate):
name: str pass
password: str
class UserRead(UserBase): class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
id: int 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): async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
name: Optional[str] = None yield UserManager(user_db)
@gf.get("/users", response_model=List[UserRead]) bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
async def read_users( cookie_transport = CookieTransport(cookie_httponly=True, cookie_name="ghostforge", cookie_samesite="strict")
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) def get_jwt_strategy() -> JWTStrategy:
async def create_hero(user: UserCreate, session: Session = Depends(get_session)): return JWTStrategy(secret=SECRET, lifetime_seconds=604800)
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) jwt_backend = AuthenticationBackend(
@hj.html_or_json("user.html") name="jwt",
async def read_user(user_id: int, session: Session = Depends(get_session), request: Request = None): transport=bearer_transport,
user = await session.get(User, user_id) get_strategy=get_jwt_strategy,
if not user: )
raise HTTPException(status_code=404, detail="User not found")
data = {"crumbs": [("settings", False), ("users", "/users"), (user_id, False)]} web_backend = AuthenticationBackend(name="cookie", transport=cookie_transport, get_strategy=get_jwt_strategy)
request.state.ghostforge = data | getattr(request.state, "ghostforge", {})
return user fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [web_backend, jwt_backend])
@gf.patch("/users/{user_id}", response_model=UserRead) def get_current_user(active: bool = True, optional: bool = False) -> User:
async def update_user(user_id: int, user: UserUpdate, session: Session = Depends(get_session), request: Request = None): return fastapi_users.current_user(active=active, optional=optional)
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

@ -6,7 +6,8 @@ from alembic import context
from sqlalchemy import pool from sqlalchemy import pool
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlmodel import SQLModel
from ghostforge.db import Base
# from ghostforge.models import User # from ghostforge.models import User
@ -32,7 +33,7 @@ if config.config_file_name is not None:
# for 'autogenerate' support # for 'autogenerate' support
# from myapp import mymodel # from myapp import mymodel
# target_metadata = mymodel.Base.metadata # 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, # other values from the config, defined by the needs of env.py,
# can be acquired: # can be acquired:

View file

@ -7,7 +7,6 @@ Create Date: ${create_date}
""" """
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
import sqlmodel
${imports if imports else ""} ${imports if imports else ""}
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.

View file

@ -18,12 +18,11 @@ dependencies = [
"pydantic==1.10.8", "pydantic==1.10.8",
"alembic==1.11.1", "alembic==1.11.1",
"asyncpg==0.27.0", "asyncpg==0.27.0",
"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==11.0.0",
"fastapi-users-db-sqlalchemy==5.0.0", "fastapi-users-db-sqlalchemy==5.0.0",
"httpx==0.24.1",
] ]
[project.urls] [project.urls]