diff options
author | 2024-08-19 01:05:27 +0100 | |
---|---|---|
committer | 2024-08-19 01:05:27 +0100 | |
commit | fe6abd8a5719cbcab1d1207918136f19042e4fa3 (patch) | |
tree | df09b6c83653189f668fce044c41096386e5bde0 | |
parent | Caddy local support (diff) |
Add debug endpoints and implement token auth
Co-authored-by: Joe Banks <[email protected]>
25 files changed, 419 insertions, 64 deletions
@@ -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__) + + [email protected]("/me") +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 |