aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Chris Lovering <[email protected]>2024-08-19 01:05:27 +0100
committerGravatar Chris Lovering <[email protected]>2024-08-19 01:05:27 +0100
commitfe6abd8a5719cbcab1d1207918136f19042e4fa3 (patch)
treedf09b6c83653189f668fce044c41096386e5bde0
parentCaddy local support (diff)
Add debug endpoints and implement token auth
Co-authored-by: Joe Banks <[email protected]>
-rw-r--r--Makefile5
-rw-r--r--docker-compose.yml3
-rw-r--r--poetry.lock19
-rw-r--r--pyproject.toml1
-rw-r--r--thallium-backend/Dockerfile2
-rw-r--r--thallium-backend/migrations/versions/1723831312-ac28edf8dd84_users_and_products.py51
-rw-r--r--thallium-backend/migrations/versions/1724025217-bd897d0f21e1_add_products_users_vouchers.py70
-rw-r--r--thallium-backend/requirements.txt3
-rw-r--r--thallium-backend/scripts/__init__.py0
-rw-r--r--thallium-backend/scripts/seed.py20
-rw-r--r--thallium-backend/src/app.py20
-rw-r--r--thallium-backend/src/auth.py110
-rw-r--r--thallium-backend/src/dto/__init__.py5
-rw-r--r--thallium-backend/src/dto/login.py13
-rw-r--r--thallium-backend/src/dto/users.py25
-rw-r--r--thallium-backend/src/dto/vouchers.py21
-rw-r--r--thallium-backend/src/orm/__init__.py2
-rw-r--r--thallium-backend/src/orm/base.py6
-rw-r--r--thallium-backend/src/orm/users.py5
-rw-r--r--thallium-backend/src/orm/vouchers.py25
-rw-r--r--thallium-backend/src/routes/__init__.py4
-rw-r--r--thallium-backend/src/routes/debug.py17
-rw-r--r--thallium-backend/src/routes/login.py31
-rw-r--r--thallium-backend/src/routes/vouchers.py24
-rw-r--r--thallium-backend/src/settings.py1
25 files changed, 419 insertions, 64 deletions
diff --git a/Makefile b/Makefile
index 6c54ba5..92d2c97 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: all install relock lock lockci lint lintdeps precommit test retest
+.PHONY: all install relock lock lockci lint lintdeps precommit test retest seed
all: install precommit lint
@@ -28,5 +28,8 @@ test:
retest:
pytest -n 4 --lf
+seed:
+ cd thallium-backend && poetry run python -m scripts.seed
+
revision:
cd thallium-backend && poetry run alembic revision --autogenerate -m CHANGEME
diff --git a/docker-compose.yml b/docker-compose.yml
index 5719724..050c570 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -20,7 +20,7 @@ services:
cache_from:
- type=registry,ref=ghcr.io/owl-corp/thallium-backend:latest
restart: unless-stopped
- command: ["alembic upgrade head && uvicorn src.app:fastapi_app --host 0.0.0.0 --port 8000 --reload"]
+ command: ["alembic upgrade head && uvicorn src.app:fastapi_app --host 0.0.0.0 --port 8000 --reload --no-server-header"]
volumes:
- ./thallium-backend/src:/thallium/src:ro
env_file:
@@ -29,6 +29,7 @@ services:
BACKEND_DATABASE_URL: postgresql+psycopg_async://thallium:thallium@postgres:5432/thallium
BACKEND_TOKEN: suitable-for-development-only
BACKEND_APP_PREFIX: /api
+ BACKEND_SIGNING_KEY: super-secure-key
ports:
- "8000:8000"
depends_on:
diff --git a/poetry.lock b/poetry.lock
index 1baec8b..d98419f 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1360,6 +1360,23 @@ toml = ["tomli (>=2.0.1)"]
yaml = ["pyyaml (>=6.0.1)"]
[[package]]
+name = "pyjwt"
+version = "2.9.0"
+description = "JSON Web Token implementation in Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"},
+ {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"},
+]
+
+[package.extras]
+crypto = ["cryptography (>=3.4.0)"]
+dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"]
+docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
+tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
+
+[[package]]
name = "pyproject-hooks"
version = "1.1.0"
description = "Wrappers to call pyproject.toml-based build backend hooks."
@@ -2030,4 +2047,4 @@ test = ["pytest"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12.0"
-content-hash = "e3a40d7454a73938cfdfdf27b6baf48fdabe86829bd5bee5910f52849d17be93"
+content-hash = "23165f45d667eb70355dbd76143922d5c819535437346e810485276b8bd6755c"
diff --git a/pyproject.toml b/pyproject.toml
index 7381adf..50f1b28 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,6 +16,7 @@ sqlalchemy = {version = "^2.0.32", extras = ["asyncio"]}
psycopg = {version = "^3.2.1", extras = ["binary"]}
pydantic = "^2.8.2"
pydantic-settings = "^2.4.0"
+pyjwt = "^2.9.0"
uvicorn = "^0.30.6"
[tool.poetry.group.linting.dependencies]
diff --git a/thallium-backend/Dockerfile b/thallium-backend/Dockerfile
index f8a4d2b..0710893 100644
--- a/thallium-backend/Dockerfile
+++ b/thallium-backend/Dockerfile
@@ -19,4 +19,4 @@ COPY . .
HEALTHCHECK --start-period=5s --interval=30s --timeout=1s CMD curl http://localhost/heartbeat || exit 1
ENTRYPOINT ["/bin/bash", "-c"]
-CMD ["alembic upgrade head && uvicorn src.app:fastapi_app --host 0.0.0.0 --port 8000"]
+CMD ["alembic upgrade head && uvicorn src.app:fastapi_app --host 0.0.0.0 --port 8000 --no-server-header"]
diff --git a/thallium-backend/migrations/versions/1723831312-ac28edf8dd84_users_and_products.py b/thallium-backend/migrations/versions/1723831312-ac28edf8dd84_users_and_products.py
deleted file mode 100644
index ef0f52b..0000000
--- a/thallium-backend/migrations/versions/1723831312-ac28edf8dd84_users_and_products.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""
-Add users and products to the DB.
-
-Revision ID: ac28edf8dd84
-Revises:
-Create Date: 2024-08-16 18:01:52.768054+00:00
-"""
-
-import sqlalchemy as sa
-from alembic import op
-
-# revision identifiers, used by Alembic.
-revision = "ac28edf8dd84"
-down_revision = None
-branch_labels = None
-depends_on = None
-
-
-def upgrade() -> None:
- """Apply this migration."""
- # ### commands auto generated by Alembic - please adjust! ###
- op.create_table(
- "products",
- sa.Column("product_id", sa.Integer(), nullable=False),
- sa.Column("name", sa.String(), nullable=False),
- sa.Column("description", sa.String(), nullable=False),
- sa.Column("price", sa.Numeric(), nullable=False),
- sa.Column("image", sa.LargeBinary(), nullable=False),
- sa.Column("id", sa.Uuid(), nullable=False),
- sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
- sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
- sa.PrimaryKeyConstraint("product_id", "id", name=op.f("products_pk")),
- )
- op.create_table(
- "users",
- sa.Column("user_id", sa.Integer(), nullable=False),
- sa.Column("is_admin", sa.Boolean(), nullable=False),
- sa.Column("id", sa.Uuid(), nullable=False),
- sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
- sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
- sa.PrimaryKeyConstraint("user_id", "id", name=op.f("users_pk")),
- )
- # ### end Alembic commands ###
-
-
-def downgrade() -> None:
- """Revert this migration."""
- # ### commands auto generated by Alembic - please adjust! ###
- op.drop_table("users")
- op.drop_table("products")
- # ### end Alembic commands ###
diff --git a/thallium-backend/migrations/versions/1724025217-bd897d0f21e1_add_products_users_vouchers.py b/thallium-backend/migrations/versions/1724025217-bd897d0f21e1_add_products_users_vouchers.py
new file mode 100644
index 0000000..2354063
--- /dev/null
+++ b/thallium-backend/migrations/versions/1724025217-bd897d0f21e1_add_products_users_vouchers.py
@@ -0,0 +1,70 @@
+"""
+Add products, users and vouchers.
+
+Revision ID: bd897d0f21e1
+Revises:
+Create Date: 2024-08-18 23:53:37.211777+00:00
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "bd897d0f21e1"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ """Apply this migration."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ "products",
+ sa.Column("product_id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(), nullable=False),
+ sa.Column("description", sa.String(), nullable=False),
+ sa.Column("price", sa.Numeric(), nullable=False),
+ sa.Column("image", sa.LargeBinary(), nullable=False),
+ sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.PrimaryKeyConstraint("product_id", "id", name=op.f("products_pk")),
+ )
+ op.create_table(
+ "users",
+ sa.Column("permissions", sa.Integer(), nullable=False),
+ sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.PrimaryKeyConstraint("id", name=op.f("users_pk")),
+ )
+ op.create_table(
+ "vouchers",
+ sa.Column("voucher_code", sa.String(), nullable=False),
+ sa.Column("active", sa.Boolean(), nullable=False),
+ sa.Column("balance", sa.Numeric(), nullable=False),
+ sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.PrimaryKeyConstraint("id", name=op.f("vouchers_pk")),
+ )
+ op.create_index(
+ "ix_unique_active_voucher_code",
+ "vouchers",
+ ["voucher_code"],
+ unique=True,
+ postgresql_where=sa.text("active"),
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Revert this migration."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f("vouchers_voucher_code_ix"), table_name="vouchers")
+ op.drop_index("ix_unique_active_voucher_code", table_name="vouchers", postgresql_where=sa.text("active"))
+ op.drop_table("vouchers")
+ op.drop_table("users")
+ op.drop_table("products")
+ # ### end Alembic commands ###
diff --git a/thallium-backend/requirements.txt b/thallium-backend/requirements.txt
index 1e4a92a..d06fbbb 100644
--- a/thallium-backend/requirements.txt
+++ b/thallium-backend/requirements.txt
@@ -307,6 +307,9 @@ pydantic-settings==2.4.0 ; python_full_version >= "3.12.0" and python_full_versi
pydantic==2.8.2 ; python_full_version >= "3.12.0" and python_full_version < "4.0.0" \
--hash=sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a \
--hash=sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8
+pyjwt==2.9.0 ; python_full_version >= "3.12.0" and python_full_version < "4.0.0" \
+ --hash=sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850 \
+ --hash=sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c
python-dotenv==1.0.1 ; python_full_version >= "3.12.0" and python_full_version < "4.0.0" \
--hash=sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca \
--hash=sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a
diff --git a/thallium-backend/scripts/__init__.py b/thallium-backend/scripts/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/thallium-backend/scripts/__init__.py
diff --git a/thallium-backend/scripts/seed.py b/thallium-backend/scripts/seed.py
new file mode 100644
index 0000000..b27a586
--- /dev/null
+++ b/thallium-backend/scripts/seed.py
@@ -0,0 +1,20 @@
+import asyncio
+
+from src.orm import Voucher
+from src.settings import Connections
+
+
+async def main() -> None:
+ """Seed the database with some test data."""
+ async with Connections.DB_SESSION_MAKER() as session, session.begin():
+ session.add_all(
+ [
+ Voucher(voucher_code="k1p", balance="13.37", active=False),
+ Voucher(voucher_code="k1p", balance="13.37", active=False),
+ Voucher(voucher_code="k1p", balance="13.37"),
+ ]
+ )
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/thallium-backend/src/app.py b/thallium-backend/src/app.py
index 6060ec3..3e5847c 100644
--- a/thallium-backend/src/app.py
+++ b/thallium-backend/src/app.py
@@ -1,6 +1,8 @@
import logging
+import time
+from collections.abc import Awaitable, Callable
-from fastapi import FastAPI, Request
+from fastapi import FastAPI, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@@ -24,3 +26,19 @@ def pydantic_validation_error(request: Request, error: RequestValidationError) -
"""Raise a warning for pydantic validation errors, before returning."""
log.warning("Error from %s: %s", request.url, error)
return JSONResponse({"error": str(error)}, status_code=422)
+
+
+@fastapi_app.middleware("http")
+async def add_process_time_and_security_headers(
+ request: Request,
+ call_next: Callable[[Request], Awaitable[Response]],
+) -> Response:
+ """Add process time and some security headers before sending the response."""
+ start_time = time.perf_counter()
+ response = await call_next(request)
+ response.headers["X-Process-Time"] = str(time.perf_counter() - start_time)
+ response.headers["X-Frame-Options"] = "DENY"
+ response.headers["X-XSS-Protection"] = "1; mode=block"
+ response.headers["Strict-Transport-Security"] = "max-age=31536000"
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ return response
diff --git a/thallium-backend/src/auth.py b/thallium-backend/src/auth.py
new file mode 100644
index 0000000..1dd1e23
--- /dev/null
+++ b/thallium-backend/src/auth.py
@@ -0,0 +1,110 @@
+import logging
+import typing as t
+from datetime import UTC, datetime, timedelta
+from enum import IntFlag
+from uuid import uuid4
+
+import jwt
+from fastapi import HTTPException, Request
+from fastapi.security import HTTPAuthorizationCredentials
+from fastapi.security.http import HTTPBase
+
+from src.dto.users import User, UserPermission
+from src.settings import CONFIG
+
+log = logging.getLogger(__name__)
+
+
+class UserTypes(IntFlag):
+ """All types of users."""
+
+ VOUCHER_USER = 2**0
+ REGULAR_USER = 2**1
+
+
+class TokenAuth(HTTPBase):
+ """Ensure all requests with this auth enabled include an auth header with the expected token."""
+
+ def __init__(
+ self,
+ *,
+ auto_error: bool = True,
+ allow_vouchers: bool = False,
+ allow_regular_users: bool = False,
+ ) -> None:
+ super().__init__(scheme="token", auto_error=auto_error)
+ self.allow_vouchers = allow_vouchers
+ self.allow_regular_users = allow_regular_users
+
+ async def __call__(self, request: Request) -> HTTPAuthorizationCredentials:
+ """Parse the token in the auth header, and check it matches with the expected token."""
+ creds: HTTPAuthorizationCredentials = await super().__call__(request)
+ if creds.scheme.lower() != "token":
+ raise HTTPException(
+ status_code=401,
+ detail="Incorrect scheme passed",
+ )
+ if self.allow_regular_users and creds.credentials == CONFIG.super_admin_token.get_secret_value():
+ request.state.user = User(user_id=uuid4(), permissions=~UserPermission(0))
+ return
+
+ jwt_data = verify_jwt(
+ creds.credentials,
+ allow_vouchers=self.allow_vouchers,
+ allow_regular_users=self.allow_regular_users,
+ )
+ if not jwt_data:
+ raise HTTPException(
+ status_code=403,
+ detail="Invalid authentication credentials",
+ )
+ if jwt_data["iss"] == "thallium:user":
+ request.state.user_id = jwt_data["sub"]
+ else:
+ request.state.voucher_id = jwt_data["sub"]
+
+
+def build_jwt(
+ identifier: str,
+ user_type: t.Literal["voucher", "user"],
+) -> str:
+ """Build & sign a jwt."""
+ return jwt.encode(
+ payload={
+ "sub": identifier,
+ "iss": f"thallium:{user_type}",
+ "exp": datetime.now(tz=UTC) + timedelta(minutes=30),
+ "nbf": datetime.now(tz=UTC) - timedelta(minutes=1),
+ },
+ key=CONFIG.signing_key.get_secret_value(),
+ )
+
+
+def verify_jwt(
+ jwt_data: str,
+ *,
+ allow_vouchers: bool,
+ allow_regular_users: bool,
+) -> dict | None:
+ """Return and verify the given JWT."""
+ issuers = []
+ if allow_vouchers:
+ issuers.append("thallium:voucher")
+ if allow_regular_users:
+ issuers.append("thallium:user")
+ try:
+ return jwt.decode(
+ jwt_data,
+ key=CONFIG.signing_key.get_secret_value(),
+ issuer=issuers,
+ algorithms=("HS256",),
+ options={"require": ["exp", "iss", "sub", "nbf"]},
+ )
+ except jwt.InvalidIssuerError as e:
+ raise HTTPException(403, "Your user type does not have access to this resource") from e
+ except jwt.InvalidSignatureError as e:
+ raise HTTPException(401, "Invalid JWT signature") from e
+ except (jwt.DecodeError, jwt.MissingRequiredClaimError, jwt.InvalidAlgorithmError) as e:
+ raise HTTPException(401, "Invalid JWT passed") from e
+ except (jwt.ImmatureSignatureError, jwt.ExpiredSignatureError) as e:
+ raise HTTPException(401, "JWT not valid for current time") from e
diff --git a/thallium-backend/src/dto/__init__.py b/thallium-backend/src/dto/__init__.py
new file mode 100644
index 0000000..92d3914
--- /dev/null
+++ b/thallium-backend/src/dto/__init__.py
@@ -0,0 +1,5 @@
+from .login import VoucherClaim, VoucherLogin
+from .users import User
+from .vouchers import Voucher
+
+__all__ = ("LoginData", "User", "Voucher", "VoucherClaim", "VoucherLogin")
diff --git a/thallium-backend/src/dto/login.py b/thallium-backend/src/dto/login.py
new file mode 100644
index 0000000..8f27acb
--- /dev/null
+++ b/thallium-backend/src/dto/login.py
@@ -0,0 +1,13 @@
+from pydantic import BaseModel
+
+
+class VoucherLogin(BaseModel):
+ """The data needed to login with a voucher."""
+
+ voucher_code: str
+
+
+class VoucherClaim(VoucherLogin):
+ """A JWT for a verified voucher."""
+
+ jwt: str
diff --git a/thallium-backend/src/dto/users.py b/thallium-backend/src/dto/users.py
new file mode 100644
index 0000000..0d1cdac
--- /dev/null
+++ b/thallium-backend/src/dto/users.py
@@ -0,0 +1,25 @@
+from enum import IntFlag
+from uuid import UUID
+
+from pydantic import BaseModel
+
+
+class UserPermission(IntFlag):
+ """The permissions a user has."""
+
+ VIEW_VOUCHERS = 2**0
+ ISSUE_VOUCHERS = 2**1
+ REVOKE_VOUCHERS = 2**1
+ VIEW_PRODUCTS = 2**2
+ MANAGE_USERS = 2**3
+
+
+class User(BaseModel):
+ """An user authenticated with the backend."""
+
+ id: UUID
+ permissions: int
+
+ def has_permission(self, permission: UserPermission) -> bool:
+ """Whether the user has the given permission."""
+ return (self.permissions & permission) == permission
diff --git a/thallium-backend/src/dto/vouchers.py b/thallium-backend/src/dto/vouchers.py
new file mode 100644
index 0000000..81dfe02
--- /dev/null
+++ b/thallium-backend/src/dto/vouchers.py
@@ -0,0 +1,21 @@
+from datetime import datetime
+from decimal import Decimal
+from uuid import UUID
+
+from pydantic import BaseModel
+
+
+class VoucherCreate(BaseModel):
+ """The data required to create a new Voucher."""
+
+ voucher_code: str
+ balance: Decimal
+
+
+class Voucher(VoucherCreate):
+ """A voucher as stored in the database."""
+
+ id: UUID
+ created_at: datetime
+ updated_at: datetime
+ active: bool
diff --git a/thallium-backend/src/orm/__init__.py b/thallium-backend/src/orm/__init__.py
index ed803e8..cf70ddd 100644
--- a/thallium-backend/src/orm/__init__.py
+++ b/thallium-backend/src/orm/__init__.py
@@ -3,10 +3,12 @@
from .base import AuditBase, Base
from .products import Product
from .users import User
+from .vouchers import Voucher
__all__ = (
"AuditBase",
"Base",
"Product",
"User",
+ "Voucher",
)
diff --git a/thallium-backend/src/orm/base.py b/thallium-backend/src/orm/base.py
index a1642c7..ec79d99 100644
--- a/thallium-backend/src/orm/base.py
+++ b/thallium-backend/src/orm/base.py
@@ -2,13 +2,13 @@
import re
from datetime import datetime
-from uuid import UUID, uuid4
+from uuid import UUID
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.schema import MetaData
-from sqlalchemy.sql import func
+from sqlalchemy.sql import func, text
from sqlalchemy.types import DateTime
NAMING_CONVENTIONS = {
@@ -35,7 +35,7 @@ class Base(AsyncAttrs, DeclarativeBase):
class AuditBase:
"""Common columns for a table with UUID PK and datetime audit columns."""
- id: Mapped[UUID] = mapped_column(default=uuid4, primary_key=True)
+ id: Mapped[UUID] = mapped_column(server_default=text("gen_random_uuid()"), primary_key=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
diff --git a/thallium-backend/src/orm/users.py b/thallium-backend/src/orm/users.py
index 065519a..8f78387 100644
--- a/thallium-backend/src/orm/users.py
+++ b/thallium-backend/src/orm/users.py
@@ -1,4 +1,4 @@
-from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.orm import Mapped
from .base import AuditBase, Base
@@ -8,5 +8,4 @@ class User(AuditBase, Base):
__tablename__ = "users"
- user_id: Mapped[int] = mapped_column(primary_key=True)
- is_admin: Mapped[bool]
+ permissions: Mapped[int]
diff --git a/thallium-backend/src/orm/vouchers.py b/thallium-backend/src/orm/vouchers.py
new file mode 100644
index 0000000..2b3af77
--- /dev/null
+++ b/thallium-backend/src/orm/vouchers.py
@@ -0,0 +1,25 @@
+from decimal import Decimal
+
+from sqlalchemy import Index, text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from .base import AuditBase, Base
+
+
+class Voucher(AuditBase, Base):
+ """A valid voucher in the database."""
+
+ __tablename__ = "vouchers"
+
+ voucher_code: Mapped[str] = mapped_column()
+ active: Mapped[bool] = mapped_column(default=True)
+ balance: Mapped[Decimal]
+
+ __table_args__ = (
+ Index(
+ "ix_unique_active_voucher_code",
+ voucher_code,
+ unique=True,
+ postgresql_where=text("active"),
+ ),
+ )
diff --git a/thallium-backend/src/routes/__init__.py b/thallium-backend/src/routes/__init__.py
index afa02af..2dc76c8 100644
--- a/thallium-backend/src/routes/__init__.py
+++ b/thallium-backend/src/routes/__init__.py
@@ -1,8 +1,12 @@
from fastapi import APIRouter
from src.routes.debug import router as debug_router
+from src.routes.login import router as login_router
+from src.routes.vouchers import router as voucher_router
from src.settings import CONFIG
top_level_router = APIRouter()
+top_level_router.include_router(login_router)
+top_level_router.include_router(voucher_router)
if CONFIG.debug:
top_level_router.include_router(debug_router)
diff --git a/thallium-backend/src/routes/debug.py b/thallium-backend/src/routes/debug.py
index fac40d7..60c8643 100644
--- a/thallium-backend/src/routes/debug.py
+++ b/thallium-backend/src/routes/debug.py
@@ -1,10 +1,13 @@
import logging
from fastapi import APIRouter
+from sqlalchemy import select
-from src.settings import PrintfulClient
+from src.dto import Voucher
+from src.orm import Voucher as DBVoucher
+from src.settings import DBSession, PrintfulClient
-router = APIRouter(tags=["debug"])
+router = APIRouter(tags=["debug"], prefix="/debug")
log = logging.getLogger(__name__)
@@ -34,3 +37,13 @@ async def get_v2_oauth_scopes(client: PrintfulClient) -> dict:
"""Return all templates in printful."""
resp = await client.get("/v2/oauth-scopes")
return resp.json()
+
+
[email protected]("/vouchers")
+async def get_vouchers(db: DBSession, *, only_active: bool = True) -> list[Voucher]:
+ """Return all templates in printful."""
+ stmt = select(DBVoucher)
+ if only_active:
+ stmt = stmt.where(DBVoucher.active)
+ res = await db.execute(stmt)
+ return res.scalars().all()
diff --git a/thallium-backend/src/routes/login.py b/thallium-backend/src/routes/login.py
new file mode 100644
index 0000000..7eeb2cf
--- /dev/null
+++ b/thallium-backend/src/routes/login.py
@@ -0,0 +1,31 @@
+import logging
+
+from fastapi import APIRouter, HTTPException
+from sqlalchemy import and_, select
+
+from src.auth import build_jwt
+from src.dto import VoucherClaim, VoucherLogin
+from src.orm import Voucher as DBVoucher
+from src.settings import DBSession
+
+router = APIRouter(tags=["Login"])
+log = logging.getLogger(__name__)
+
+
[email protected]("/voucher-login")
+async def handle_voucher_login(login_payload: VoucherLogin, db: DBSession) -> VoucherClaim:
+ """Return a signed JWT if the given voucher is present in the database."""
+ stmt = select(DBVoucher).where(
+ and_(
+ DBVoucher.voucher_code == login_payload.voucher_code,
+ DBVoucher.active,
+ )
+ )
+ voucher = await db.scalar(stmt)
+ if not voucher:
+ raise HTTPException(422, "Voucher not found")
+
+ return VoucherClaim(
+ voucher_code=login_payload.voucher_code,
+ jwt=build_jwt(str(voucher.id), "voucher"),
+ )
diff --git a/thallium-backend/src/routes/vouchers.py b/thallium-backend/src/routes/vouchers.py
new file mode 100644
index 0000000..97b9fef
--- /dev/null
+++ b/thallium-backend/src/routes/vouchers.py
@@ -0,0 +1,24 @@
+import logging
+
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy import select
+
+from src.auth import TokenAuth
+from src.dto import Voucher
+from src.orm import Voucher as DBVoucher
+from src.settings import DBSession
+
+router = APIRouter(
+ prefix="/vouchers",
+ tags=["Voucher users"],
+ dependencies=[Depends(TokenAuth(allow_vouchers=True))],
+)
+log = logging.getLogger(__name__)
+
+
+async def get_vouchers(request: Request, db: DBSession) -> Voucher | None:
+ """Get the voucher for the currently authenticated voucher id."""
+ stmt = select(DBVoucher).where(DBVoucher.id == request.state.voucher_id)
+ res = await db.execute(stmt)
+ return res.scalars().one_or_none()
diff --git a/thallium-backend/src/settings.py b/thallium-backend/src/settings.py
index f6e144c..81a6335 100644
--- a/thallium-backend/src/settings.py
+++ b/thallium-backend/src/settings.py
@@ -23,6 +23,7 @@ class _Config(
debug: bool = False
git_sha: str = "development"
+ signing_key: pydantic.SecretStr
database_url: pydantic.SecretStr
super_admin_token: pydantic.SecretStr