From 1fa3d8a3726f908a6ccdefccfee5b254c482d5ee Mon Sep 17 00:00:00 2001 From: Darryl Nixon Date: Tue, 23 May 2023 17:14:49 -0700 Subject: [PATCH] Basic JWT endpoints --- .env | 22 +++++++++++++++++----- .flake8 | 2 +- Dockerfile | 3 ++- README.md | 3 ++- ghostforge/__init__.py | 9 --------- ghostforge/api.py | 35 +++++++++++++++++++++++++++++++++++ ghostforge/auth/__init__.py | 11 +++++++++++ ghostforge/auth/bearer.py | 35 +++++++++++++++++++++++++++++++++++ ghostforge/auth/handler.py | 28 ++++++++++++++++++++++++++++ ghostforge/cli.py | 9 +++++++++ ghostforge/models.py | 27 +++++++++++++++++++++++++++ pyproject.toml | 22 +++++++++++++--------- requirements.txt | 1 - 13 files changed, 180 insertions(+), 27 deletions(-) create mode 100644 ghostforge/api.py create mode 100644 ghostforge/auth/__init__.py create mode 100644 ghostforge/auth/bearer.py create mode 100644 ghostforge/auth/handler.py create mode 100644 ghostforge/models.py delete mode 100644 requirements.txt diff --git a/.env b/.env index 1f45160..71869a6 100644 --- a/.env +++ b/.env @@ -1,3 +1,12 @@ +# DATABASE_* variables are used both to initially configure the +# ghostforge postgresql database and to later access the data from +# ghostforge execution. +DATABASE_CONTAINER_NAME=ghostforge-db +DATABASE_USER=ghost +DATABASE_NAME=ghostforge +DATABASE_PORT=5432 +DATABASE_PASSWORD= + # GHOSTFORGE_*_WEB_PORT variables are used to determine what # port the web interface is served on within the container (INTERNAL) # and what port this will map to on the host (HOST). @@ -11,8 +20,11 @@ GHOSTFORGE_INTERNAL_WEB_PORT=1337 # Valid values are [prod, dev] GHOSTFORGE_ENV=prod -# DATABASE_* variables are used both to initially configure the -# ghostforge postgresql database and to later access the data from -# ghostforge execution. -DATABASE_USER=ghost -DATABASE_NAME=ghostforge +# GHOSTFORGE_DATA_DIR stores persistent files for a ghostforge instance +# and should be mapped or stored as a volume from the docker host. +# GHOSTFORGE_DATA_DIR is created within the ghostforge container. +GHOSTFORGE_DATA_DIR=/data + +# JWT_SECRET is used for authentication purposes and should be +# randomized using the method in README.md. +GHOSTFORGE_JWT_SECRET= diff --git a/.flake8 b/.flake8 index c8ef649..dd0767d 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,3 @@ [flake8] max-line-length = 160 -exclude = docs/*, .git, __pycache__ +exclude = docs/*, .git, __pycache__, build diff --git a/Dockerfile b/Dockerfile index 9c41fdd..c58ca7f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,13 @@ FROM python:3.11-alpine ENV DATABASE_PASSWORD "" RUN if [ -z "${DATABASE_PASSWORD}" ]; then echo "ghostforge build error: Set DATABASE_PASSWORD in .env."; exit 1; fi +RUN if [ -z "${GHOSTFORGE_JWT_SECRET}" ]; then echo "ghostforge build error: Set GHOSTFORGE_JWT_SECRET in .env."; exit 1; fi WORKDIR /ghostforge COPY . . RUN rm .env +RUN mkdir -p "${GHOSTFORGE_DATA_DIR}" -RUN pip install --no-cache-dir --requirement requirements.txt RUN pip install . ENV GHOSTFORGE_INTERNAL_WEB_PORT=8080 diff --git a/README.md b/README.md index 3019662..96b44b6 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ You'll need `docker-compose` installed or you can convert the contents of `docke ```bash git clone https://github.com/darrylnixon/ghostforge.git && \ cd ghostforge && \ -PW=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(24))") | sed -i .bak "s/^DATABASE_PASSWORD=.*/DATABASE_PASSWORD=$PW" .env; +PW=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^DATABASE_PASSWORD=.*/DATABASE_PASSWORD=$PW/" .env' && \ +JWT=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^GHOSTFORGE_JWT_SECRET=.*/GHOSTFORGE_JWT_SECRET=$JWT/" .env'; docker-compose up --detach --build; docker exec --interactive --tty ghostforge ghostforge_adduser; ``` diff --git a/ghostforge/__init__.py b/ghostforge/__init__.py index eaf852f..e69de29 100644 --- a/ghostforge/__init__.py +++ b/ghostforge/__init__.py @@ -1,9 +0,0 @@ -""" -Initialization constants for ghostforge. - -Constants: - _PROJECT (str): Name for the project - __version__ (str): The current version of the binhop package -""" -_PROJECT = "ghostforge" -__version__ = "0.0.1" diff --git a/ghostforge/api.py b/ghostforge/api.py new file mode 100644 index 0000000..44a7e9f --- /dev/null +++ b/ghostforge/api.py @@ -0,0 +1,35 @@ +from fastapi import Body +from fastapi import Depends +from fastapi import FastAPI + +from ghostforge.auth import check_user +from ghostforge.auth.bearer import JWTBearer +from ghostforge.auth.handler import signJWT +from ghostforge.models import UserLoginSchema + +# define("port", default=os.environ.get("GHOSTFORGE_INTERNAL_WEB_PORT", 1337), help="Webserver port", type=int) +# define("db_host", default=os.environ.get("DATABASE_CONTAINER_NAME", "localhost"), help="Host/IP of db") +# define("db_port", default=os.environ.get("DATABASE_PORT", 5432), help="Port of db", type=int) +# define("db_database", default=os.environ.get("DATABASE_NAME", "ghostforge"), help="Name of db") +# define("db_user", default=os.environ.get("DATABASE_USER", "ghostforge"), help="User with access to db") +# define("db_password", default=os.environ.get("DATABASE_PASSWORD", "secure"), help="Password for db user") +# define("secret_key", default=os.environ.get("DATABASE_PASSWORD", "secure"), help="Password for db user") + +app = FastAPI() + + +@app.get("/", tags=["root"]) +async def read_root() -> dict: + return {"message": "Welcome!"} + + +@app.post("/login", tags=["user"]) +async def user_login(user: UserLoginSchema = Body(...)): + if check_user(user): + return signJWT(user.name) + return {"error": "Wrong login details!"} + + +@app.post("/auth_check", dependencies=[Depends(JWTBearer())], tags=["auth_check"]) +async def auth_check(post) -> dict: + return {"data": "post added."} diff --git a/ghostforge/auth/__init__.py b/ghostforge/auth/__init__.py new file mode 100644 index 0000000..744d500 --- /dev/null +++ b/ghostforge/auth/__init__.py @@ -0,0 +1,11 @@ +from ghostforge.models import UserLoginSchema + +users = [{"name": "test", "password": "pw"}] + + +def check_user(data: UserLoginSchema): + print(data) + for user in users: + if user["name"] == data.name and user["password"] == data.password: + return True + return False diff --git a/ghostforge/auth/bearer.py b/ghostforge/auth/bearer.py new file mode 100644 index 0000000..8861ee8 --- /dev/null +++ b/ghostforge/auth/bearer.py @@ -0,0 +1,35 @@ +from fastapi import HTTPException +from fastapi import Request +from fastapi.security import HTTPAuthorizationCredentials +from fastapi.security import HTTPBearer + +from ghostforge.auth.handler import decodeJWT + +# from https://github.com/testdrivenio/fastapi-jwt/ + + +class JWTBearer(HTTPBearer): + def __init__(self, auto_error: bool = True): + super(JWTBearer, self).__init__(auto_error=auto_error) + + async def __call__(self, request: Request): + credentials: HTTPAuthorizationCredentials = await super(JWTBearer, self).__call__(request) + if credentials: + if not credentials.scheme == "Bearer": + raise HTTPException(status_code=403, detail="Invalid authentication scheme.") + if not self.verify_jwt(credentials.credentials): + raise HTTPException(status_code=403, detail="Invalid token or expired token.") + return credentials.credentials + else: + raise HTTPException(status_code=403, detail="Invalid authorization code.") + + def verify_jwt(self, jwtoken: str) -> bool: + isTokenValid: bool = False + + try: + payload = decodeJWT(jwtoken) + except Exception: + payload = None + if payload: + isTokenValid = True + return isTokenValid diff --git a/ghostforge/auth/handler.py b/ghostforge/auth/handler.py new file mode 100644 index 0000000..131cdd7 --- /dev/null +++ b/ghostforge/auth/handler.py @@ -0,0 +1,28 @@ +import os +import time +from typing import Dict + +import jwt + +# Based on handler from https://github.com/testdrivenio/fastapi-jwt/ + +JWT_SECRET = os.environ.get("GHOSTFORGE_JWT_SECRET") + + +def token_response(token: str): + return {"access_token": token} + + +def signJWT(user: str) -> Dict[str, str]: + payload = {"user_id": user, "expires": time.time() + 600} + token = jwt.encode(payload, JWT_SECRET, algorithm="HS256") + + return token_response(token) + + +def decodeJWT(token: str) -> dict: + try: + decoded_token = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return decoded_token if decoded_token["expires"] >= time.time() else None + except Exception: + return {} diff --git a/ghostforge/cli.py b/ghostforge/cli.py index e69de29..749b6ca 100644 --- a/ghostforge/cli.py +++ b/ghostforge/cli.py @@ -0,0 +1,9 @@ +import os + +import uvicorn + + +def start_api() -> None: + uvicorn.run( + "ghostforge.api:app", host="0.0.0.0", port=os.environ.get("GHOSTFORGE_INTERNAL_WEB_PORT", 1337), reload=True + ) diff --git a/ghostforge/models.py b/ghostforge/models.py new file mode 100644 index 0000000..9508d4b --- /dev/null +++ b/ghostforge/models.py @@ -0,0 +1,27 @@ +import datetime + +from pydantic import BaseModel +from pydantic import Field + + +class UserSchema(BaseModel): + name: str = Field(...) + password: str = Field(...) + created: datetime.datetime = datetime.datetime + + class Config: + schema_extra = { + "example": { + "name": "Jeremy Tootsieroll", + "password": "notarealpassword", + "created": "2021-03-05T08:21:00.000Z", + } + } + + +class UserLoginSchema(BaseModel): + name: str = Field(...) + password: str = Field(...) + + class Config: + schema_extra = {"example": {"name": "Jeremy Tootsieroll", "password": "notarealpassword"}} diff --git a/pyproject.toml b/pyproject.toml index 8ae3448..210f166 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0"] +requires = ["setuptools>=67.8"] build-backend = "setuptools.build_meta" [project] @@ -9,22 +9,26 @@ authors = [{ name = "ghostforge", email = "git@nixon.mozmail.com" }] description = "A false identity information manager for privacy prudent persons" readme = "README.md" requires-python = ">=3.9" -dependencies = ["tornado", "queries"] -license = "MIT" +license = { text = "MIT" } +dependencies = [ + "fastapi>=0.95.2", + "uvicorn>=0.22.0", + "loguru>=0.7.0", + "passlib>=1.7.4", + "pydantic>=1.10.8", +] [project.scripts] -ghostforge_serve = "ghostforge.cli:service" +ghostforge_serve = "ghostforge.cli:start_api" [project.urls] "Homepage" = "https://github.com/DarrylNixon/ghostforge" "Bug Tracker" = "https://github.com/DarrylNixon/ghostforge/issues" [tool.bandit] -exclude_dirs = ["/doc"] -skips = [] +exclude_dirs = ["/doc", "/build"] +# TODO: Stop skipping B104 (binding on 0.0.0.0), is there a nice way to get a good docker bind address? +skips = ["B104"] [tool.black] line-length = 120 - -[tool.isort] -profile = "black" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 9c558e3..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -.