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
# 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}

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

View file

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

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.
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",
}

View file

@ -17,7 +17,7 @@
<div class="off-canvas off-canvas-sidebar-show d-flex">
{% include "navigation/side.html" %}
<div class="off-canvas-content">
<div class="container">
<div class="container" style="padding-right: 5em; padding-top: 1em;">
{% include "navigation/top.html" %}
{% block 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">
</div>
<div class="tile-content">
Steve Rogers
{{ user.email }}
</div>
</div>
</li>

View file

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

View file

@ -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:

View file

@ -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.

View file

@ -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]