From c7d02783af96396b0c736282e7157add643ef8a5 Mon Sep 17 00:00:00 2001 From: Lachlan Harris <121010139+lachlanharrisdev@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:09:12 +1000 Subject: [PATCH 1/3] feat: jwt auth in api backend --- .github/workflows/ci.yml | 19 +++++- .github/workflows/deploy.yml | 20 ------ README | 20 ++++++ backend/.env.example | 6 ++ backend/README | 11 +++- backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/v1/__init__.py | 0 backend/app/api/v1/api.py | 16 +++++ backend/app/api/v1/auth/auth.py | 98 ++++++++++++++++++++++++++++ backend/app/api/v1/auth/config.py | 63 ++++++++++++++++++ backend/app/api/v1/auth/endpoints.py | 75 +++++++++++++++++++++ backend/app/api/v1/health.py | 44 +++++++++++++ backend/app/main.py | 14 +--- backend/requirements.txt | 4 ++ backend/tests/test_auth.py | 66 +++++++++++++++++++ docker-compose.yml | 4 ++ 17 files changed, 424 insertions(+), 36 deletions(-) delete mode 100644 .github/workflows/deploy.yml create mode 100644 backend/.env.example delete mode 100644 backend/app/__init__.py delete mode 100644 backend/app/api/__init__.py delete mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/api.py create mode 100644 backend/app/api/v1/auth/auth.py create mode 100644 backend/app/api/v1/auth/config.py create mode 100644 backend/app/api/v1/auth/endpoints.py create mode 100644 backend/app/api/v1/health.py create mode 100644 backend/tests/test_auth.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 907b63d..3508ebd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,16 @@ # Copyright (c) 2026 Lachlan Harris. All Rights Reserved. # This code is licensed under Apache 2.0 # -# Run pytest tests for the backend. Done on every push & pr -# so its not necessary to run on deploy +# CI/CD workflow for backend CI and full-stack deployment on: push: pull_request: jobs: + # continueous integration test job + # runs on every push and pull request via pytest + # NOTE: only tests the backend test: runs-on: ubuntu-latest @@ -30,3 +32,16 @@ jobs: - name: Run tests run: | PYTHONPATH=backend pytest -q backend/tests + + + # deploy to raspberry pi 5 + # only deploy on pushes to main, and only after tests have passed + deploy: + runs-on: self-hosted + needs: test + if: github.ref == 'refs/heads/main' # only deploy on pushes to main + + steps: + - uses: actions/checkout@v6 + - run: docker compose pull + - run: docker compose up -d --build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index c1a9c0e..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright (c) 2026 Lachlan Harris. All Rights Reserved. -# This code is licensed under Apache 2.0 -# -# Deploy the application to my raspi at home. The remaining work is done on the pi -# to manage the reverse proxy & tunnel to the public internet - -name: Deploy to Pi - -on: - push: - branches: [ main ] - workflow_dispatch: - -jobs: - deploy: - runs-on: self-hosted - steps: - - uses: actions/checkout@v6 - - run: docker compose pull - - run: docker compose up -d --build \ No newline at end of file diff --git a/README b/README index ff65be4..663002d 100644 --- a/README +++ b/README @@ -15,6 +15,26 @@ Links * GitHub Repository: https://github.com/lachlanharrisdev/readme * Use the app: https://readme.lachlanharris.dev +Get Started +----------- + +1) Clone the repository + ++---- +| git clone https://github.com/lachlanharrisdev/readme +| cd readme ++---- + +2) Using a text editor, update the appropriate environment variables in `docker-compose.yml`, including SECRET_KEY + +3) Run the application + ++---- +| docker compose up -d ++---- + + The application will be reachable at `localhost:3000` + ------- This project is delivered under the Apache 2.0 License. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..6a097cb --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,6 @@ +# JWT + +# openssl rand -hex 32 +SECRET_KEY=a3603d4c1350de7cdddffb5556f379ff7e3ac7265f9bd11448188040a2ad7cf1 +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=300 \ No newline at end of file diff --git a/backend/README b/backend/README index 422627c..bfb40a6 100644 --- a/backend/README +++ b/backend/README @@ -19,14 +19,21 @@ Run locally | docker compose up -d db +---- -2) Install backend dependencies and start the API: +2) Clone the env and fill out +---- | cd backend +| cp .env.example .env.local +| nano .env.local ++---- + +3) Install dependencies and start the API: + ++---- | python -m venv .venv | source .venv/bin/activate | pip install -r requirements.txt -| uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 +| uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 --env-file .env.local +---- Run with Docker Compose diff --git a/backend/app/__init__.py b/backend/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py new file mode 100644 index 0000000..a68d117 --- /dev/null +++ b/backend/app/api/v1/api.py @@ -0,0 +1,16 @@ +# Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +# This project is licensed under Apache 2.0 +# +# src/api/v1/api.py +# Primary API router for V1. This file can be considered the +# "main" file for the API + +from fastapi import APIRouter + +from .auth import endpoints +from . import health + +api_router = APIRouter() +api_router.include_router(endpoints.router, prefix="/auth", tags=["auth"]) + +api_router.include_router(health.router, prefix="/health", tags=["health"]) diff --git a/backend/app/api/v1/auth/auth.py b/backend/app/api/v1/auth/auth.py new file mode 100644 index 0000000..ed8be0f --- /dev/null +++ b/backend/app/api/v1/auth/auth.py @@ -0,0 +1,98 @@ +# Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +# This project is licensed under Apache 2.0 +# +# src/api/v1/auth/auth.py +# Handles authentication logic + +from .config import * + +import jwt + +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jwt.exceptions import InvalidTokenError +from pwdlib import PasswordHash +from typing import Annotated + + +# Database interaction +# -------------------- + + +def get_user(db, username: str): + if username in db: + user_dict = db[username] + return UserInDB(**user_dict) + + +# Password hashing / verification +# ------------------------------- + + +def verify_password(plain_password, hashed_password): + return password_hash.verify(plain_password, hashed_password) + + +def get_password_hash(password): + return password_hash.hash(password) + + +DUMMY_HASH = password_hash.hash("dummypassword") + + +def authenticate_user(fake_db, username: str, password: str): + user = get_user(fake_db, username) + if not user: + verify_password(password, DUMMY_HASH) + return False + if not verify_password(password, user.hashed_password): + return False + return user + + +# JWT token creation / verification +# --------------------------------- + + +def create_access_token(data: dict, expires_delta: timedelta | None = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +# Self utilities +# -------------- + + +async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username = payload.get("sub") + if username is None: + raise credentials_exception + token_data = TokenData(username=username) + except InvalidTokenError: + raise credentials_exception + user = get_user(fake_users_db, username=token_data.username) + if user is None: + raise credentials_exception + return user + + +async def get_current_active_user( + current_user: Annotated[User, Depends(get_current_user)], +): + if current_user.disabled: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user diff --git a/backend/app/api/v1/auth/config.py b/backend/app/api/v1/auth/config.py new file mode 100644 index 0000000..7f58d3b --- /dev/null +++ b/backend/app/api/v1/auth/config.py @@ -0,0 +1,63 @@ +# Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +# This project is licensed under Apache 2.0 +# +# src/api/v1/auth/env.py +# Manages authentication-specific env variables & constants + +import os + +from dotenv import load_dotenv + +from fastapi.security import OAuth2PasswordBearer +from pwdlib import PasswordHash +from pydantic import BaseModel + +# Environment variable loading +# ---------------------------- +load_dotenv() + +SECRET_KEY = os.getenv( + "SECRET_KEY", "8f70b2b1dd185be4d29bbfeeba2f98b588b6653c857d5d29bc77fd7e14b8dcc9" +) +ALGORITHM = os.getenv("ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "300")) + +# Constants +# --------- + +password_hash = PasswordHash.recommended() + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token") + +fake_users_db = { + "lachlanharris": { + "username": "lachlanharris", + "full_name": "Lachlan Harris", + "email": "contact@lachlanharris.dev", + "hashed_password": "$argon2id$v=19$m=65536,t=3,p=4$wagCPXjifgvUFBzq4hqe3w$CYaIb8sB+wtD+Vu/P4uod1+Qof8h+1g7bbDlBID48Rc", # "secret" + "disabled": False, + } +} + +# Types +# ----- + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + username: str | None = None + + +class User(BaseModel): + username: str + email: str | None = None + full_name: str | None = None + disabled: bool | None = None + + +class UserInDB(User): + hashed_password: str diff --git a/backend/app/api/v1/auth/endpoints.py b/backend/app/api/v1/auth/endpoints.py new file mode 100644 index 0000000..b7102a4 --- /dev/null +++ b/backend/app/api/v1/auth/endpoints.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +# This project is licensed under Apache 2.0 +# +# src/api/v1/auth.py +# JWT Authentication API endpoints according to the V1 specification + +from .config import * +from .auth import * + +import jwt + +from datetime import timedelta +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from typing import Annotated + +router = APIRouter() + + +# JWT-specific endpoints +# ---------------------- + + +@router.post("/token") +async def login_for_access_token( + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], +) -> Token: + """ + Request a JWT access token by providing a username and password + + Requires: + username: string + password: string + + Response: + access_token: string + token_type: string ("bearer") + + Errors: + 401 Unauthorized: If the username or password is incorrect + """ + user = authenticate_user(fake_users_db, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires + ) + return Token(access_token=access_token, token_type="bearer") + + +# Utilities +# --------- + + +@router.get("/me") +async def read_users_me( + current_user: Annotated[User, Depends(get_current_active_user)], +) -> User: + """ + Get the current authenticated user's information + + Authorization: true + + Response: + username: string + email: string + full_name: string + disabled: boolean + """ + return current_user diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py new file mode 100644 index 0000000..8c51b61 --- /dev/null +++ b/backend/app/api/v1/health.py @@ -0,0 +1,44 @@ +# Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +# This project is licensed under Apache 2.0 +# +# src/api/v1/health.py +# Health check API endpoints according to the V1 specification + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.engine import Engine + +from .db import get_engine, ping_db + +router = APIRouter() + + +@router.get("/") +def health_check(): + """ + Health check endpoint to verify that the API is running and responsive. + + Response: + status: string ("ok") + + Errors: + 500 Internal Server Error: If the API is not healthy + """ + return {"status": "ok"} + + +@router.get("/db") +def health_check_db(engine: Engine = Depends(get_engine)): + """ + Health check endpoint to verify that the database connection is healthy. + + Response: + status: string ("ok") + + Errors: + 500 Internal Server Error: If the database connection is not healthy + """ + try: + ping_db(engine) + return {"status": "ok"} + except Exception as e: + raise HTTPException(status_code=500, detail="Database connection failed") from e diff --git a/backend/app/main.py b/backend/app/main.py index 7a1b061..1620fc8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,18 +1,8 @@ from fastapi import FastAPI import uvicorn -from .api.v1.db import get_engine, ping_db +from .api.v1.api import api_router app = FastAPI(title="ReadMe Backend", version="0.1.0") - -@app.get("/health") -def health() -> dict[str, str]: - return {"status": "ok"} - - -@app.get("/health/db") -def health_db() -> dict[str, str]: - engine = get_engine() - ping_db(engine) - return {"status": "ok"} +app.include_router(api_router, prefix="/api/v1") diff --git a/backend/requirements.txt b/backend/requirements.txt index fa6ba1d..c2da275 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,3 +3,7 @@ uvicorn[standard]>=0.44,<1 sqlmodel>=0.0.38,<1 psycopg[binary]>=3.2,<4 pydantic-settings>=2.2,<3 +python-multipart>=0.0.25,<1 +pwdlib>=0.3,<1 +pwdlib[argon2]>=0.3,<1 +pyjwt>=2.12,<3 \ No newline at end of file diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..fc63d1f --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,66 @@ +# Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +# This code is licensed under Apache 2.0 +# +# Test file scoped to src/api/v1/auth + +from fastapi.testclient import TestClient + + +def test_auth_token_ok() -> None: + from app.main import app + + client = TestClient(app) + res = client.post( + "/api/v1/auth/token", data={"username": "lachlanharris", "password": "secret"} + ) + assert res.status_code == 200 + assert "access_token" in res.json() + assert res.json()["token_type"] == "bearer" + + +def test_auth_token_bad_credentials() -> None: + from app.main import app + + client = TestClient(app) + res = client.post( + "/api/v1/auth/token", + data={"username": "lachlanharris", "password": "wrongpassword"}, + ) + assert res.status_code == 401 + assert res.json()["detail"] == "Incorrect username or password" + + res = client.post( + "/api/v1/auth/token", data={"username": "mr-i-dont-exist", "password": "secret"} + ) + assert res.status_code == 401 + assert res.json()["detail"] == "Incorrect username or password" + + +def test_auth_me_ok() -> None: + from app.main import app + + client = TestClient(app) + token_res = client.post( + "/api/v1/auth/token", data={"username": "lachlanharris", "password": "secret"} + ) + access_token = token_res.json()["access_token"] + + res = client.get( + "/api/v1/auth/me", headers={"Authorization": f"Bearer {access_token}"} + ) + assert res.status_code == 200 + assert res.json() == { + "username": "lachlanharris", + "full_name": "Lachlan Harris", + "email": "contact@lachlanharris.dev", + "disabled": False, + } + + +def test_auth_me_no_token() -> None: + from app.main import app + + client = TestClient(app) + res = client.get("/api/v1/auth/me") + assert res.status_code == 401 + assert res.json()["detail"] == "Not authenticated" diff --git a/docker-compose.yml b/docker-compose.yml index 681d2c8..91015df 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,10 @@ services: environment: DATABASE_URL: postgresql+psycopg://app:app@db:5432/app + SECRET_KEY: fe3e36c1d1101643e27c83bcba73be5dae2353aa0d2319fd68b44df202b92e34 # openssl rand -hex 32 + ALGORITHM: HS256 + ACCESS_TOKEN_EXPIRE_MINUTES: 30 + frontend: build: From 33e2f542ed7809ca1e59e1a2fb78f560deae32d6 Mon Sep 17 00:00:00 2001 From: Lachlan Harris <121010139+lachlanharrisdev@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:13:02 +1000 Subject: [PATCH 2/3] fix: prefix endpoints in health tests --- backend/tests/test_health.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 74743ee..6873eda 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -12,22 +12,6 @@ def test_health_ok() -> None: from app.main import app client = TestClient(app) - res = client.get("/health") - assert res.status_code == 200 - assert res.json() == {"status": "ok"} - - -def test_health_db_ok_sqlite(monkeypatch) -> None: - import app.main as main - - engine = create_engine( - "sqlite:///:memory:", - connect_args={"check_same_thread": False}, - ) - - monkeypatch.setattr(main, "get_engine", lambda: engine) - - client = TestClient(main.app) - res = client.get("/health/db") + res = client.get("/api/v1/health") assert res.status_code == 200 assert res.json() == {"status": "ok"} From 6672bae3bb85b496a9d22226f3eaca8c1a4f879c Mon Sep 17 00:00:00 2001 From: Lachlan Harris <121010139+lachlanharrisdev@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:14:20 +1000 Subject: [PATCH 3/3] fix: duplicate ci on pull requests --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3508ebd..d50babb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: push: + branches: + - main pull_request: jobs: