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