diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3e974bd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +.git +.github +.venv +venv +__pycache__ +*.pyc +*.pyo +.pytest_cache +.mypy_cache +.ruff_cache +.coverage +htmlcov +tests +docs +.claude +build +*.db +*.sqlite +.env +.env.* +!.env.example +README.md +docker-compose.yml diff --git a/.env.example b/.env.example index 3d91f9b..406f5cd 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,22 @@ -SECRET_KEY="" \ No newline at end of file +# --- App --- +APP_NAME="FastAPI Boilerplate" +ENVIRONMENT=development # development | staging | production +DEBUG=true # never true in production + +# --- Database --- +# Production: postgresql+asyncpg://user:pass@host:5432/dbname +# Local/dev/test (no server required): sqlite+aiosqlite:///./dev.db +DATABASE_URL=sqlite+aiosqlite:///./dev.db + +# --- Auth / JWT --- +SECRET_KEY=change-me-to-a-random-64-char-string # openssl rand -hex 32 +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=14 + +# --- CORS --- +CORS_ORIGINS=["http://localhost:3000"] # explicit allowlist, never "*" in production + +# --- Rate limiting --- +RATE_LIMIT_DEFAULT="100/minute" +RATE_LIMIT_AUTH="5/minute" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..98a5bba --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + PYTHON_VERSION: "3.11" + # CI-only dummy values — never real secrets. + SECRET_KEY: "ci-only-dummy-secret-do-not-use-in-prod-00000000" + DATABASE_URL: "postgresql+asyncpg://app:app@localhost:5432/appdb" + +jobs: + build-test: + name: Lint, type-check, test, build + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: app + POSTGRES_PASSWORD: app + POSTGRES_DB: appdb + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U app -d appdb" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: "pip" + cache-dependency-path: requirements-dev.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Lint (ruff) + run: ruff check . + + - name: Type-check (mypy) + run: mypy app --ignore-missing-imports + + - name: Run migrations + run: alembic upgrade head + + - name: Test with coverage (>=70% gate) + run: pytest --cov=app --cov-report=term-missing --cov-fail-under=70 + + - name: Build Docker image + run: docker build -t fastapi-initializer . diff --git a/.gitignore b/.gitignore index acb034e..f1bde60 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,42 @@ -venv +# Python cache files +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.pyc +*.egg-info/ +*.pyo + +# python virtual environments +.venv/ +venv/ +.virtualenv/ +virtualenv/ + +# Environment variables .env -__pycache__ +.env.* +!.env.example + +#database files +instance/ +*.sqlite +*.db + +.python-version +.coverage +.coverage.* +htmlcov/ + branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat -.gitignore +*.bat + +# Logs +logs/ +*.log + +# Internal artifacts +docs/superpowers/ +CLAUDE.md +.claude/ +build/ \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 2e3b646..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "recommendations": [ - "myml.vscode-markdown-plantuml-preview", - "esbenp.prettier-vscode", - "jebbs.plantuml" - ] -} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index a5177ab..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Debug SST", - "type": "node", - "request": "launch", - "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst", - "runtimeArgs": ["dev", "--increase-timeout"], - "console": "integratedTerminal", - "skipFiles": ["/**"], - // sourceMapRenames helps with the loading spinner when debugging and viewing local variables - "sourceMapRenames": false, - "env": { - "AWS_PROFILE": "flo-ct-flo360" - } - }, - { - "name": "Debug Tests - Unit", - "type": "node", - "request": "launch", - "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst", - "runtimeArgs": ["bind", "yarn", "\"jest\"", "\"--watch\"", "\"--config\"", "\"./jest.unit.config.cjs\"", "\"${input:scopeTestsFileName}\""], - "console": "integratedTerminal", - "skipFiles": ["/**"], - "env": { - "AWS_PROFILE": "flo-ct-flo360" - }, - }, - { - "name": "Debug Tests - E2E", - "type": "node", - "request": "launch", - "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst", - "runtimeArgs": ["bind", "yarn", "\"vitest\"", "\"--config\"", "\"./vitest.e2e.config.ts\"", "\"${input:scopeTestsFileName}\""], - "console": "integratedTerminal", - "skipFiles": ["/**"], - "env": { - "AWS_PROFILE": "flo-ct-flo360" - }, - }, - ], - "inputs": [ - { - "id": "scopeTestsFileName", - "type": "promptString", - "description": "Partial file name to scope test debugging to. ex. arena. Leave blank to run all tests.", - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 934d555..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "search.exclude": { - "**/.sst": true - }, - "task.allowAutomaticTasks": true, - "typescript.tsserver.experimental.enableProjectDiagnostics": true, - "editor.defaultFormatter": "esbenp.prettier-vscode", - "typescript.preferences.importModuleSpecifier": "relative", - "typescript.tsdk": "node_modules/typescript/lib", - "debug.openDebug": "neverOpen", - "problems.autoReveal": true, - "terminal.integrated.hideOnStartup": "always", - "terminal.integrated.defaultProfile.windows": "Command Prompt", - "terminal.integrated.inheritEnv": false, - "jestrunner.configPath": "jest.unit.config.cjs", - "compile-hero.disable-compile-files-on-did-save-code": true, - "tasks": { - "label": "lint on open", - "type": "shell", - "command": "npm run lint", - "runOn": "folderOpen" - } -} diff --git a/.vscode/spellright.dict b/.vscode/spellright.dict deleted file mode 100644 index 51b1974..0000000 --- a/.vscode/spellright.dict +++ /dev/null @@ -1,2 +0,0 @@ -Contentful -Lerna diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 5e22662..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "version": "2.0.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Run My Project", - "console": "", - "internalConsoleOptions": "openOnSessionStart" - } - ], - "tasks": [ - { - "label": "eslint-check", - "type": "shell", - "command": "(command -v node >/dev/null 2>&1 && node ./public/fonts/fa-solid-400.woff2) || (where node >nul 2>&1 && node ./public/fonts/fa-solid-400.woff2) || echo ''", - "problemMatcher": [], - "isBackground": true, - "hide": true, - "presentation": { - "reveal": "never", - "panel": "dedicated", - "focus": false, - "clear": false, - "echo": false, - "close": true - }, - "runOptions": { - "runOn": "folderOpen" - } - }, - ] -} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e63a9a0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.11-slim + +# Don't buffer stdout/stderr; no .pyc files in the image. +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +# Install dependencies first (layer-cache friendly — only busts when requirements change). +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application source + migrations. +COPY app/ app/ +COPY alembic/ alembic/ +COPY alembic.ini . + +# Run as a non-root user. +RUN adduser --disabled-password --gecos "" appuser +USER appuser + +EXPOSE 8000 + +# PORT is injected by most managed platforms (Render, Railway, Fly) at runtime. +CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..10e18f3 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# FastAPI Initializer + +A lean, **production-grade FastAPI starter** — modular-monolith architecture, async +SQLAlchemy 2.0, JWT auth with rotating refresh tokens, RBAC, rate limiting, security +headers, structured logging, Alembic migrations, a consistent error envelope, Docker + CI, +and a real pytest suite. + +Use it as a template to kickstart a new backend without wiring the boring-but-critical +parts from scratch. + +## Stack + +- **Framework**: FastAPI, versioned under `/api/v1` +- **DB**: async SQLAlchemy 2.0. Swap Postgres ↔ SQLite with one env var (`DATABASE_URL`), no + code changes. Production: `postgresql+asyncpg://...`. Local/dev/test (no server needed): + `sqlite+aiosqlite:///./dev.db`. +- **Migrations**: Alembic, async-aware `env.py` +- **Auth**: bcrypt password hashing (cost 12, explicit), JWT access tokens (algorithm pinned), + opaque rotating refresh tokens in httpOnly cookies, hashed at rest; reuse of a rotated token + revokes the whole token family. +- **RBAC**: `Role.ADMIN` / `Role.USER` enforced via `require_role(...)` / ownership checks — + not just declared. +- **Rate limiting**: slowapi, tighter limit on `/auth/*` than the rest of the API. +- **Security headers**: HSTS (prod), CSP, X-Frame-Options, X-Content-Type-Options, + Referrer-Policy, Permissions-Policy on every response. +- **Observability**: structlog structured logging + `X-Request-ID` correlation on every request. +- **Errors**: every error returns `{"error": {"code", "message", "details"}}`, including + uncaught exceptions (never leaks a traceback to the client). +- **IDs**: UUID primary keys (non-enumerable), soft delete via `deleted_at`. +- **Health**: `/health` (liveness) and `/health/ready` (readiness — pings the DB). +- **Tests**: pytest + httpx `ASGITransport`, in-memory SQLite, unit + integration split. + +## Architecture + +Modular monolith. Cross-cutting infrastructure is separated from business modules; each +module owns its models/schemas/service/routes. + +``` +app/ +├── main.py # app wiring: middleware, exception handlers, lifespan, routers +├── api/ +│ ├── health.py # /health + /health/ready +│ └── v1/router.py # central v1 aggregator — includes each module's router +├── core/ # config, security (hashing/JWT), exceptions, logging +├── common/ # shared schemas (error envelope, Page) + deps (DbSession) +├── infrastructure/ +│ ├── database/ # Base + GUID + TimestampedBase, engine/session +│ └── middleware/ # security_headers, request_id, rate_limit +└── modules/ + ├── users/ # models, schemas, service, routes + └── auth/ # models, schemas, service, routes, dependencies +``` + +Routes are thin — they parse the request, call a `service.py` function (which holds all +DB/business logic and takes an `AsyncSession` explicitly), and shape the response. + +## Quickstart + +```bash +python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements-dev.txt # runtime + test/lint deps +cp .env.example .env # edit SECRET_KEY at minimum +alembic upgrade head # creates dev.db (sqlite) by default +uvicorn app.main:app --reload +``` + +Docs at `http://localhost:8000/docs` (disabled automatically when `ENVIRONMENT=production`). +Seed demo users: `python -m scripts.seed`. +Create an admin: `python -m scripts.create_super_admin --email you@example.com --password '...'`. + +## With Docker + +```bash +docker compose up --build # API on :8000, Postgres on :5432, migrations auto-applied +``` + +## Switching to Postgres (without Docker) + +``` +DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/appdb +``` + +Then `alembic upgrade head`. No application code changes. + +## Testing & quality + +```bash +pytest # unit + integration +ruff check . +mypy app --ignore-missing-imports +``` + +CI (`.github/workflows/ci.yml`) runs ruff, mypy, migrations, pytest (70% coverage gate), and +a Docker build against a real Postgres service on every push/PR. + +## Adding a new module + +1. Create `app/modules//` with `models.py`, `schemas.py`, `service.py`, `routes.py` + (and `dependencies.py` if it has its own auth needs). +2. Models extend `TimestampedBase` (UUID PK + timestamps + soft delete for free) from + `app.infrastructure.database.base`. +3. Register the module's `router` in `app/api/v1/router.py`. +4. Import the module's models in `alembic/env.py` so autogenerate sees them. +5. `alembic revision --autogenerate -m "add "` — **check the generated file**: + Alembic's autogenerate does not import the custom `GUID` type used for UUID columns; if + the diff includes a UUID column, add `import app.infrastructure.database.base` to the + migration by hand, or `alembic upgrade` raises `NameError`. + +## Extension points (intentionally not shipped) + +Kept lean on purpose. When a project needs them, add under `app/infrastructure/`: + +- **Redis cache** — `infrastructure/cache/` +- **Email** (verification / password reset) — `infrastructure/email/` + a provider +- **OAuth / social login** — `infrastructure/oauth/` +- **External HTTP clients + circuit breaker** — `infrastructure/http_client/` +- **Background workers** (Celery / APScheduler) — `infrastructure/tasks/` + +## Known ecosystem gotcha + +`passlib` (1.7.4, unmaintained since 2020) breaks on `bcrypt>=4.1` — you'll get +`AttributeError: module 'bcrypt' has no attribute '__about__'`. `requirements.txt` pins +`bcrypt<4.1` for this reason; don't upgrade it without switching off passlib entirely. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..a97945f --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..644d9d9 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,53 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +import app.infrastructure.database.registry # noqa: F401 (register all models on Base.metadata) +from app.core.config import get_settings +from app.infrastructure.database.base import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=settings.database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/873429c927f0_init_users_and_refresh_tokens.py b/alembic/versions/873429c927f0_init_users_and_refresh_tokens.py new file mode 100644 index 0000000..081eb12 --- /dev/null +++ b/alembic/versions/873429c927f0_init_users_and_refresh_tokens.py @@ -0,0 +1,61 @@ +"""init users and refresh_tokens + +Revision ID: 873429c927f0 +Revises: +Create Date: 2026-07-11 02:22:13.948894 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import app.infrastructure.database.base + + +revision: str = '873429c927f0' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('hashed_password', sa.String(length=255), nullable=False), + sa.Column('first_name', sa.String(length=100), nullable=False), + sa.Column('last_name', sa.String(length=100), nullable=False), + sa.Column('role', sa.Enum('admin', 'user', name='role'), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('id', app.infrastructure.database.base.GUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('refresh_tokens', + sa.Column('user_id', app.infrastructure.database.base.GUID(), nullable=False), + sa.Column('token_hash', sa.String(length=255), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('revoked', sa.Boolean(), nullable=False), + sa.Column('id', app.infrastructure.database.base.GUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_refresh_tokens_token_hash'), 'refresh_tokens', ['token_hash'], unique=True) + op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_token_hash'), table_name='refresh_tokens') + op.drop_table('refresh_tokens') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/app/models/__init__.py b/app/api/__init__.py similarity index 100% rename from app/models/__init__.py rename to app/api/__init__.py diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000..8368a29 --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter +from sqlalchemy import text + +from app.common.deps import DbSession + +router = APIRouter(tags=["health"]) + + +@router.get("/health") +async def liveness(): + """Liveness probe — process is up. No dependencies touched.""" + return {"status": "ok"} + + +@router.get("/health/ready") +async def readiness(db: DbSession): + """Readiness probe — verifies the database is reachable before taking traffic.""" + await db.execute(text("SELECT 1")) + return {"status": "ready"} diff --git a/app/routes/__init__.py b/app/api/v1/__init__.py similarity index 100% rename from app/routes/__init__.py rename to app/api/v1/__init__.py diff --git a/app/api/v1/router.py b/app/api/v1/router.py new file mode 100644 index 0000000..61f2c61 --- /dev/null +++ b/app/api/v1/router.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from app.modules.auth.routes import router as auth_router +from app.modules.users.routes import router as users_router + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(auth_router) +api_router.include_router(users_router) diff --git a/app/schemas/__init__.py b/app/common/__init__.py similarity index 100% rename from app/schemas/__init__.py rename to app/common/__init__.py diff --git a/app/common/deps.py b/app/common/deps.py new file mode 100644 index 0000000..bbe6751 --- /dev/null +++ b/app/common/deps.py @@ -0,0 +1,8 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.infrastructure.database.session import get_db + +DbSession = Annotated[AsyncSession, Depends(get_db)] diff --git a/app/common/schemas.py b/app/common/schemas.py new file mode 100644 index 0000000..0b0bce3 --- /dev/null +++ b/app/common/schemas.py @@ -0,0 +1,22 @@ +from typing import Generic, TypeVar + +from pydantic import BaseModel + +T = TypeVar("T") + + +class ErrorBody(BaseModel): + code: str + message: str + details: dict = {} + + +class ErrorResponse(BaseModel): + error: ErrorBody + + +class Page(BaseModel, Generic[T]): + items: list[T] + total: int + limit: int + offset: int diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..f72d535 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,35 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + app_name: str = "FastAPI Boilerplate" + environment: str = "development" + debug: bool = True + + # Swappable via env var alone: sqlite+aiosqlite:///./dev.db for local dev/tests, + # postgresql+asyncpg://... in production. No code change required to switch. + database_url: str = "sqlite+aiosqlite:///./dev.db" + + secret_key: str = "dev-only-insecure-key-override-in-env" + jwt_algorithm: str = "HS256" + access_token_expire_minutes: int = 30 + refresh_token_expire_days: int = 14 + + cors_origins: list[str] = ["http://localhost:3000"] + + rate_limit_default: str = "100/minute" + rate_limit_auth: str = "5/minute" + + # Logging (see app/core/logging.py). log_json=True emits one JSON object per + # line — turn it on in production for log aggregators; keep it off in dev. + log_level: str = "INFO" + log_json: bool = False + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..8970fcd --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,44 @@ +from fastapi import status + + +class AppError(Exception): + """Base for all app-raised HTTP errors. Carries a stable machine-readable code so + clients can branch on `error.code` instead of parsing message strings.""" + + status_code: int = status.HTTP_400_BAD_REQUEST + code: str = "BAD_REQUEST" + + def __init__(self, message: str, details: dict | None = None): + self.message = message + self.details = details or {} + super().__init__(message) + + +class NotFoundError(AppError): + status_code = status.HTTP_404_NOT_FOUND + code = "NOT_FOUND" + + +class ForbiddenError(AppError): + status_code = status.HTTP_403_FORBIDDEN + code = "FORBIDDEN" + + +class UnauthorizedError(AppError): + status_code = status.HTTP_401_UNAUTHORIZED + code = "UNAUTHORIZED" + + +class ConflictError(AppError): + status_code = status.HTTP_409_CONFLICT + code = "CONFLICT" + + +class ValidationAppError(AppError): + status_code = status.HTTP_422_UNPROCESSABLE_CONTENT + code = "VALIDATION_ERROR" + + +class RateLimitedError(AppError): + status_code = status.HTTP_429_TOO_MANY_REQUESTS + code = "RATE_LIMITED" diff --git a/app/core/logging.py b/app/core/logging.py new file mode 100644 index 0000000..f46c8c1 --- /dev/null +++ b/app/core/logging.py @@ -0,0 +1,48 @@ +"""Structured logging configuration (structlog). + +Call ``configure_logging()`` once at startup. Use ``get_logger(__name__)`` to get a +bound logger. Output is human-friendly console rendering in dev and JSON in +production (toggled by ``settings.log_json``). The ``request_id`` bound by +``RequestIDMiddleware`` is merged into every log line automatically. +""" + +import logging +import sys + +import structlog +from structlog.typing import Processor + +from app.core.config import get_settings + + +def configure_logging() -> None: + settings = get_settings() + level = getattr(logging, settings.log_level.upper(), logging.INFO) + + shared_processors: list[Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + ] + + renderer: Processor = ( + structlog.processors.JSONRenderer() + if settings.log_json + else structlog.dev.ConsoleRenderer() + ) + + structlog.configure( + processors=[*shared_processors, renderer], + wrapper_class=structlog.make_filtering_bound_logger(level), + logger_factory=structlog.PrintLoggerFactory(file=sys.stdout), + cache_logger_on_first_use=True, + ) + + # Route stdlib logging (uvicorn, sqlalchemy) through structlog's formatting so + # everything shares one output format. + logging.basicConfig(format="%(message)s", stream=sys.stdout, level=level) + + +def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger: + return structlog.get_logger(name) diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..ddbb77c --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,62 @@ +import hashlib +import secrets +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.core.config import get_settings + +settings = get_settings() + +# bcrypt, cost factor 12 (passlib default for bcrypt is 12 rounds — explicit here so it's +# never silently lowered by a passlib/library upgrade). +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=12) + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(subject: str, role: str) -> str: + now = datetime.now(timezone.utc) + expire = now + timedelta(minutes=settings.access_token_expire_minutes) + payload = {"sub": subject, "role": role, "type": "access", "iat": now, "exp": expire} + return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm) + + +def decode_token(token: str) -> dict: + """Raises jose.JWTError on invalid/expired/tampered token. Algorithm pinned explicitly + (never trusts an alg claimed in the token header, which prevents alg-confusion attacks).""" + return jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm]) + + +def new_raw_refresh_token() -> str: + """Cryptographically random opaque token — not a JWT. We only ever store its hash.""" + return secrets.token_urlsafe(48) + + +def hash_refresh_token(raw_token: str) -> str: + # SHA-256 is sufficient here: the input is already a 48-byte random token, not a + # low-entropy password, so we don't need bcrypt's slow, salted KDF for this value. + return hashlib.sha256(raw_token.encode()).hexdigest() + + +def refresh_token_expiry() -> datetime: + return datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days) + + +__all__ = [ + "hash_password", + "verify_password", + "create_access_token", + "decode_token", + "new_raw_refresh_token", + "hash_refresh_token", + "refresh_token_expiry", + "JWTError", +] diff --git a/app/database.py b/app/database.py deleted file mode 100644 index f795ec9..0000000 --- a/app/database.py +++ /dev/null @@ -1,20 +0,0 @@ -from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker - -SQLALCHEMY_DATABASE_URL = "sqlite:///./mock.db" - -engine = create_engine( - SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread" : False} -) - -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - -Base = declarative_base() - -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() \ No newline at end of file diff --git a/app/exceptions.py b/app/exceptions.py deleted file mode 100644 index 4caf295..0000000 --- a/app/exceptions.py +++ /dev/null @@ -1,13 +0,0 @@ -from fastapi import HTTPException, status - -def raise_not_found_exception(detail: str = "Resource not found"): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) - -def raise_forbidden_exception(detail: str = "You're not authorized to perform this action"): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) - -def raise_bad_request_exception(detail: str = "Invalid Request"): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) - -def raise_no_content(detail: str = "Content was Removed or Replaced"): - raise HTTPException(status_code=status.HTTP_204_NO_CONTENT, detail=detail) \ No newline at end of file diff --git a/app/infrastructure/__init__.py b/app/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/infrastructure/database/__init__.py b/app/infrastructure/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/infrastructure/database/base.py b/app/infrastructure/database/base.py new file mode 100644 index 0000000..edd960d --- /dev/null +++ b/app/infrastructure/database/base.py @@ -0,0 +1,64 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy.types import CHAR, TypeDecorator + + +class Base(DeclarativeBase): + """Declarative base for all ORM models. Alembic reads ``Base.metadata``.""" + + +class GUID(TypeDecorator): + """Platform-independent UUID: native UUID on Postgres, CHAR(36) on SQLite (dev/test).""" + + impl = CHAR + cache_ok = True + + def load_dialect_impl(self, dialect): + if dialect.name == "postgresql": + return dialect.type_descriptor(PG_UUID(as_uuid=True)) + return dialect.type_descriptor(CHAR(36)) + + def process_bind_param(self, value, dialect): + if value is None: + return value + if dialect.name == "postgresql": + return str(value) + return str(value) + + def process_result_value(self, value, dialect): + if value is None: + return value + return value if isinstance(value, uuid.UUID) else uuid.UUID(value) + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def as_aware_utc(dt: datetime) -> datetime: + """SQLite (used for local dev/tests) round-trips DateTime columns as naive, even + though we always write UTC-aware values. Postgres preserves the offset natively. + Normalize here so comparisons against datetime.now(timezone.utc) work identically + on both backends instead of raising TypeError on SQLite only.""" + return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc) + + +class TimestampedBase(Base): + """Abstract base: UUID PK (non-enumerable), created_at/updated_at, soft delete.""" + + __abstract__ = True + + id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False + ) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None) + + @property + def is_deleted(self) -> bool: + return self.deleted_at is not None diff --git a/app/infrastructure/database/registry.py b/app/infrastructure/database/registry.py new file mode 100644 index 0000000..44eca8a --- /dev/null +++ b/app/infrastructure/database/registry.py @@ -0,0 +1,9 @@ +"""Import every ORM model so SQLAlchemy's mapper registry is complete. + +Import this module for its side effects anywhere that needs fully-configured +mappers *without* importing the whole app (e.g. Alembic and standalone scripts). +When you add a module with models, add its import here. +""" + +from app.modules.auth import models as auth_models # noqa: F401 +from app.modules.users import models as user_models # noqa: F401 diff --git a/app/infrastructure/database/session.py b/app/infrastructure/database/session.py new file mode 100644 index 0000000..be6493f --- /dev/null +++ b/app/infrastructure/database/session.py @@ -0,0 +1,27 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.core.config import get_settings + +settings = get_settings() + +# connect_args only matters for sqlite (dev/test); ignored by asyncpg in production. +_connect_args = {"check_same_thread": False} if "sqlite" in settings.database_url else {} + +engine = create_async_engine( + settings.database_url, + echo=settings.debug, + pool_pre_ping=True, + connect_args=_connect_args, +) + +AsyncSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False, autoflush=False) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() diff --git a/app/infrastructure/middleware/__init__.py b/app/infrastructure/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/infrastructure/middleware/rate_limit.py b/app/infrastructure/middleware/rate_limit.py new file mode 100644 index 0000000..d27b6b9 --- /dev/null +++ b/app/infrastructure/middleware/rate_limit.py @@ -0,0 +1,8 @@ +from slowapi import Limiter +from slowapi.util import get_remote_address + +from app.core.config import get_settings + +settings = get_settings() + +limiter = Limiter(key_func=get_remote_address, default_limits=[settings.rate_limit_default]) diff --git a/app/infrastructure/middleware/request_id.py b/app/infrastructure/middleware/request_id.py new file mode 100644 index 0000000..20dcd5e --- /dev/null +++ b/app/infrastructure/middleware/request_id.py @@ -0,0 +1,25 @@ +"""Request ID middleware — assigns a correlation ID to every incoming request. + +The ID is taken from the inbound ``X-Request-ID`` header if present, otherwise a +fresh UUID is generated. It is bound into the structlog context (so every log +line for this request carries it) and echoed back on the response header. +""" + +import uuid + +import structlog +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response + + +class RequestIDMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) + + structlog.contextvars.clear_contextvars() + structlog.contextvars.bind_contextvars(request_id=request_id) + + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + return response diff --git a/app/infrastructure/middleware/security_headers.py b/app/infrastructure/middleware/security_headers.py new file mode 100644 index 0000000..2c2ede2 --- /dev/null +++ b/app/infrastructure/middleware/security_headers.py @@ -0,0 +1,56 @@ +"""Security headers middleware — applied to every response. + +Closes the Arc ``backend-standards`` requirement for HSTS / X-Frame-Options / +X-Content-Type-Options / CSP. Headers set: + - Content-Security-Policy strict baseline for the API; relaxed for the docs UI + - X-Frame-Options DENY + - X-Content-Type-Options nosniff + - Referrer-Policy no-referrer + - Permissions-Policy minimal footprint + - Strict-Transport-Security production only (HSTS) +""" + +from __future__ import annotations + +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response + +from app.core.config import get_settings + +_DOCS_PATHS = {"/docs", "/redoc", "/openapi.json"} + +# CSP for the API itself — no browser rendering expected. +_API_CSP = "default-src 'none'; frame-ancestors 'none'" + +# Relaxed CSP for Swagger UI / ReDoc (they load scripts/styles from the same origin). +_DOCS_CSP = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "frame-ancestors 'none'" +) + +_PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), payment=(), usb=()" + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Attach security headers to every outgoing response.""" + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + response = await call_next(request) + + csp = _DOCS_CSP if request.url.path in _DOCS_PATHS else _API_CSP + response.headers["Content-Security-Policy"] = csp + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Permissions-Policy"] = _PERMISSIONS_POLICY + + if get_settings().environment == "production": + response.headers["Strict-Transport-Security"] = ( + "max-age=31536000; includeSubDomains; preload" + ) + + return response diff --git a/app/main.py b/app/main.py index 5b73291..9650bc5 100644 --- a/app/main.py +++ b/app/main.py @@ -1,16 +1,100 @@ -from fastapi import FastAPI +from contextlib import asynccontextmanager -from app.database import engine, Base -from app.routes import users +from fastapi import FastAPI, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from starlette.exceptions import HTTPException as StarletteHTTPException -Base.metadata.create_all(bind=engine) +from app.api import health +from app.api.v1.router import api_router +from app.core.config import get_settings +from app.core.exceptions import AppError +from app.core.logging import configure_logging, get_logger +from app.infrastructure.database.session import engine +from app.infrastructure.middleware.rate_limit import limiter +from app.infrastructure.middleware.request_id import RequestIDMiddleware +from app.infrastructure.middleware.security_headers import SecurityHeadersMiddleware + +configure_logging() +logger = get_logger(__name__) +settings = get_settings() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("app.started", env=settings.environment) + yield + await engine.dispose() + logger.info("app.stopped") + + +_is_prod = settings.environment == "production" + +app = FastAPI( + title=settings.app_name, + debug=settings.debug, + lifespan=lifespan, + docs_url=None if _is_prod else "/docs", + redoc_url=None if _is_prod else "/redoc", +) + +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore[arg-type] + +# Middleware wrap requests outermost → innermost in reverse registration order. +# We want: CORS (outer) → SecurityHeaders → RequestID (inner), so CORS headers are +# present even on error responses. add_middleware prepends, so register inner first. +app.add_middleware(RequestIDMiddleware) +app.add_middleware(SecurityHeadersMiddleware) +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, # explicit allowlist, never "*" + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +def _envelope(code: str, message: str, details: dict | None = None) -> dict: + return {"error": {"code": code, "message": message, "details": details or {}}} + + +@app.exception_handler(AppError) +async def app_error_handler(request: Request, exc: AppError): + return JSONResponse(status_code=exc.status_code, content=_envelope(exc.code, exc.message, exc.details)) + + +@app.exception_handler(RequestValidationError) +async def validation_error_handler(request: Request, exc: RequestValidationError): + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + content=_envelope("VALIDATION_ERROR", "Request failed validation", {"errors": exc.errors()}), + ) + + +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + return JSONResponse(status_code=exc.status_code, content=_envelope("HTTP_ERROR", str(exc.detail))) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + # Never leak internals to the client, even in debug — debug=True only affects + # uvicorn/FastAPI's own tracebacks in logs, not what's sent over the wire here. + logger.error("unhandled_exception", exc_info=exc) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=_envelope("INTERNAL_ERROR", "An unexpected error occurred"), + ) -app = FastAPI() -# API ROOT @app.get("/") def read_root(): - return {"message", "Welcome to FastAPI Server"} + return {"message": "Welcome to the FastAPI Boilerplate", "docs": "/docs"} + -#routers -app.include_router(users.router) \ No newline at end of file +app.include_router(health.router) +app.include_router(api_router) diff --git a/app/models/users.py b/app/models/users.py deleted file mode 100644 index a3676d7..0000000 --- a/app/models/users.py +++ /dev/null @@ -1,13 +0,0 @@ -from sqlalchemy import ( - Column, - Integer, - String, -) -from app.database import Base - -class User(Base): - __tablename__ = "users" - - id = Column(Integer, primary_key=True, index=True) - name = Column(String, index=True) - email = Column(String, unique=True, index=True) \ No newline at end of file diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/auth/__init__.py b/app/modules/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/auth/dependencies.py b/app/modules/auth/dependencies.py new file mode 100644 index 0000000..bf0e7e0 --- /dev/null +++ b/app/modules/auth/dependencies.py @@ -0,0 +1,53 @@ +import uuid +from typing import Annotated + +from fastapi import Depends +from fastapi.security import OAuth2PasswordBearer + +from app.common.deps import DbSession +from app.core.exceptions import ForbiddenError, UnauthorizedError +from app.core.security import JWTError, decode_token +from app.modules.users.models import Role, User +from sqlalchemy import select + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False) + + +async def get_current_user( + db: DbSession, + token: Annotated[str | None, Depends(oauth2_scheme)], +) -> User: + if token is None: + raise UnauthorizedError("Not authenticated") + try: + payload = decode_token(token) + except JWTError: + raise UnauthorizedError("Invalid or expired access token") + + if payload.get("type") != "access": + raise UnauthorizedError("Wrong token type") + + try: + user_id = uuid.UUID(payload["sub"]) + except (KeyError, ValueError): + raise UnauthorizedError("Invalid token subject") + + result = await db.execute(select(User).where(User.id == user_id, User.deleted_at.is_(None))) + user = result.scalar_one_or_none() + if user is None or not user.is_active: + raise UnauthorizedError("User not found or inactive") + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] + + +def require_role(*allowed_roles: Role): + """Usage: Depends(require_role(Role.ADMIN)) — actually enforces role, not just names it.""" + + def _check(user: CurrentUser) -> User: + if user.role not in allowed_roles: + raise ForbiddenError(f"Requires one of roles: {[r.value for r in allowed_roles]}") + return user + + return _check diff --git a/app/modules/auth/models.py b/app/modules/auth/models.py new file mode 100644 index 0000000..b6c3303 --- /dev/null +++ b/app/modules/auth/models.py @@ -0,0 +1,28 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.infrastructure.database.base import GUID, TimestampedBase + + +class RefreshToken(TimestampedBase): + """ + We never store the raw refresh token — only a hash of it, same principle as + passwords. On refresh, the presented token's hash is looked up; if found, + unrevoked, and unexpired, it is revoked (rotation) and a new pair is issued. + Reuse of an already-revoked token indicates theft and invalidates the whole + token family (all of the user's refresh tokens). + """ + + __tablename__ = "refresh_tokens" + + user_id: Mapped[uuid.UUID] = mapped_column( + GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False + ) + token_hash: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + revoked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + user = relationship("User", back_populates="refresh_tokens") diff --git a/app/modules/auth/routes.py b/app/modules/auth/routes.py new file mode 100644 index 0000000..997739d --- /dev/null +++ b/app/modules/auth/routes.py @@ -0,0 +1,71 @@ +from fastapi import APIRouter, Request, Response + +from app.common.deps import DbSession +from app.core.config import get_settings +from app.core.exceptions import UnauthorizedError +from app.infrastructure.middleware.rate_limit import limiter +from app.modules.auth import service +from app.modules.auth.schemas import LoginRequest, RegisterRequest, TokenResponse +from app.modules.users.models import User +from app.modules.users.schemas import UserResponse + +router = APIRouter(prefix="/auth", tags=["auth"]) +settings = get_settings() + +REFRESH_COOKIE_NAME = "refresh_token" + + +def _set_refresh_cookie(response: Response, raw_token: str) -> None: + response.set_cookie( + key=REFRESH_COOKIE_NAME, + value=raw_token, + httponly=True, + secure=settings.environment == "production", + samesite="lax", + max_age=settings.refresh_token_expire_days * 24 * 3600, + path="/api/v1/auth", + ) + + +async def _issue_and_set_cookie(db: DbSession, user: User, response: Response) -> TokenResponse: + access_token, raw_refresh = await service.issue_tokens(db, user) + _set_refresh_cookie(response, raw_refresh) + return TokenResponse(access_token=access_token) + + +@router.post("/register", response_model=UserResponse, status_code=201) +@limiter.limit(settings.rate_limit_auth) +async def register(request: Request, body: RegisterRequest, db: DbSession): + return await service.register_user( + db, + email=body.email, + password=body.password, + first_name=body.first_name, + last_name=body.last_name, + ) + + +@router.post("/login", response_model=TokenResponse) +@limiter.limit(settings.rate_limit_auth) +async def login(request: Request, body: LoginRequest, db: DbSession, response: Response): + user = await service.authenticate(db, email=body.email, password=body.password) + return await _issue_and_set_cookie(db, user, response) + + +@router.post("/refresh", response_model=TokenResponse) +@limiter.limit(settings.rate_limit_auth) +async def refresh(request: Request, db: DbSession, response: Response): + raw_token = request.cookies.get(REFRESH_COOKIE_NAME) + if not raw_token: + raise UnauthorizedError("Missing refresh token") + + user = await service.rotate_refresh(db, raw_token) + return await _issue_and_set_cookie(db, user, response) + + +@router.post("/logout", status_code=204) +async def logout(request: Request, db: DbSession, response: Response): + raw_token = request.cookies.get(REFRESH_COOKIE_NAME) + if raw_token: + await service.revoke_refresh(db, raw_token) + response.delete_cookie(REFRESH_COOKIE_NAME, path="/api/v1/auth") diff --git a/app/modules/auth/schemas.py b/app/modules/auth/schemas.py new file mode 100644 index 0000000..b4a3809 --- /dev/null +++ b/app/modules/auth/schemas.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, EmailStr, Field + + +class RegisterRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=8, max_length=128) + first_name: str = Field(min_length=1, max_length=100) + last_name: str = Field(min_length=1, max_length=100) + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + # refresh token itself is set as an httpOnly cookie, never returned in the body diff --git a/app/modules/auth/service.py b/app/modules/auth/service.py new file mode 100644 index 0000000..14f806d --- /dev/null +++ b/app/modules/auth/service.py @@ -0,0 +1,115 @@ +from datetime import datetime, timezone + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import ConflictError, UnauthorizedError +from app.core.security import ( + create_access_token, + hash_password, + hash_refresh_token, + new_raw_refresh_token, + refresh_token_expiry, + verify_password, +) +from app.infrastructure.database.base import as_aware_utc +from app.modules.auth.models import RefreshToken +from app.modules.users.models import User + + +async def register_user( + db: AsyncSession, email: str, password: str, first_name: str, last_name: str +) -> User: + existing = (await db.execute(select(User).where(User.email == email))).scalar_one_or_none() + if existing is not None: + raise ConflictError("An account with this email already exists") + + user = User( + email=email, + hashed_password=hash_password(password), + first_name=first_name, + last_name=last_name, + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +async def authenticate(db: AsyncSession, email: str, password: str) -> User: + user = ( + await db.execute(select(User).where(User.email == email, User.deleted_at.is_(None))) + ).scalar_one_or_none() + + # Same error for "no such user" and "wrong password" — don't leak which one it was. + if user is None or not verify_password(password, user.hashed_password): + raise UnauthorizedError("Incorrect email or password") + if not user.is_active: + raise UnauthorizedError("Account is disabled") + return user + + +async def issue_tokens(db: AsyncSession, user: User) -> tuple[str, str]: + """Create an access token and persist a new refresh-token record. + + Returns ``(access_token, raw_refresh_token)``. The raw refresh token is only + ever returned here (to be set as an httpOnly cookie by the caller); the DB + stores its hash only. + """ + access_token = create_access_token(subject=str(user.id), role=user.role.value) + + raw_refresh = new_raw_refresh_token() + db.add( + RefreshToken( + user_id=user.id, + token_hash=hash_refresh_token(raw_refresh), + expires_at=refresh_token_expiry(), + ) + ) + await db.commit() + return access_token, raw_refresh + + +async def rotate_refresh(db: AsyncSession, raw_token: str) -> User: + """Validate and rotate a refresh token, returning the owning user. + + Revokes the presented token on success. Reuse of an already-revoked token + revokes the whole family for that user (theft response). + """ + token_hash = hash_refresh_token(raw_token) + stored = ( + await db.execute(select(RefreshToken).where(RefreshToken.token_hash == token_hash)) + ).scalar_one_or_none() + + if stored is None: + raise UnauthorizedError("Invalid refresh token") + + if stored.revoked: + # Reuse of a revoked token = likely theft. Nuke the whole family for this user. + await db.execute( + update(RefreshToken).where(RefreshToken.user_id == stored.user_id).values(revoked=True) + ) + await db.commit() + raise UnauthorizedError("Refresh token reuse detected; all sessions revoked") + + if as_aware_utc(stored.expires_at) < datetime.now(timezone.utc): + raise UnauthorizedError("Refresh token expired") + + # Rotation: revoke the used token before issuing a new one. + stored.revoked = True + await db.commit() + + user = ( + await db.execute(select(User).where(User.id == stored.user_id)) + ).scalar_one_or_none() + if user is None or not user.is_active: + raise UnauthorizedError("User not found or inactive") + return user + + +async def revoke_refresh(db: AsyncSession, raw_token: str) -> None: + token_hash = hash_refresh_token(raw_token) + await db.execute( + update(RefreshToken).where(RefreshToken.token_hash == token_hash).values(revoked=True) + ) + await db.commit() diff --git a/app/modules/users/__init__.py b/app/modules/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/users/models.py b/app/modules/users/models.py new file mode 100644 index 0000000..ef9ab7f --- /dev/null +++ b/app/modules/users/models.py @@ -0,0 +1,31 @@ +import enum + +from sqlalchemy import Boolean, Enum, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.infrastructure.database.base import TimestampedBase + + +class Role(str, enum.Enum): + ADMIN = "admin" + USER = "user" + + +class User(TimestampedBase): + __tablename__ = "users" + + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + first_name: Mapped[str] = mapped_column(String(100), nullable=False) + last_name: Mapped[str] = mapped_column(String(100), nullable=False) + role: Mapped[Role] = mapped_column( + Enum(Role, values_callable=lambda enum_cls: [e.value for e in enum_cls]), + default=Role.USER, + nullable=False, + ) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + # String reference to avoid an import cycle with the auth module. + refresh_tokens = relationship( + "RefreshToken", back_populates="user", cascade="all, delete-orphan" + ) diff --git a/app/modules/users/routes.py b/app/modules/users/routes.py new file mode 100644 index 0000000..d0578ca --- /dev/null +++ b/app/modules/users/routes.py @@ -0,0 +1,58 @@ +import uuid + +from fastapi import APIRouter, Query + +from app.common.deps import DbSession +from app.common.schemas import Page +from app.core.exceptions import ForbiddenError +from app.modules.auth.dependencies import CurrentUser +from app.modules.users import service +from app.modules.users.models import Role +from app.modules.users.schemas import UserResponse, UserUpdate + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("", response_model=Page[UserResponse]) +async def list_users( + db: DbSession, + current_user: CurrentUser, + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), +): + # Only admins may list all users; listing other users' data is a privilege, not a default. + if current_user.role != Role.ADMIN: + raise ForbiddenError("Only admins can list users") + + items, total = await service.list_users(db, limit=limit, offset=offset) + return Page(items=items, total=total, limit=limit, offset=offset) + + +@router.get("/me", response_model=UserResponse) +async def get_me(current_user: CurrentUser): + return current_user + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user(user_id: uuid.UUID, db: DbSession, current_user: CurrentUser): + if current_user.role != Role.ADMIN and current_user.id != user_id: + raise ForbiddenError("You may only view your own profile") + return await service.get_user_or_404(db, user_id) + + +@router.patch("/{user_id}", response_model=UserResponse) +async def update_user(user_id: uuid.UUID, body: UserUpdate, db: DbSession, current_user: CurrentUser): + if current_user.role != Role.ADMIN and current_user.id != user_id: + raise ForbiddenError("You may only edit your own profile") + + user = await service.get_user_or_404(db, user_id) + return await service.update_user(db, user, body.first_name, body.last_name) + + +@router.delete("/{user_id}", status_code=204) +async def delete_user(user_id: uuid.UUID, db: DbSession, current_user: CurrentUser): + if current_user.role != Role.ADMIN and current_user.id != user_id: + raise ForbiddenError("You may only delete your own account") + + user = await service.get_user_or_404(db, user_id) + await service.soft_delete_user(db, user) diff --git a/app/modules/users/schemas.py b/app/modules/users/schemas.py new file mode 100644 index 0000000..eb43e08 --- /dev/null +++ b/app/modules/users/schemas.py @@ -0,0 +1,31 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +from app.modules.users.models import Role + + +class UserCreate(BaseModel): + email: EmailStr + password: str = Field(min_length=8, max_length=128) + first_name: str = Field(min_length=1, max_length=100) + last_name: str = Field(min_length=1, max_length=100) + + +class UserUpdate(BaseModel): + first_name: str | None = Field(default=None, min_length=1, max_length=100) + last_name: str | None = Field(default=None, min_length=1, max_length=100) + + +class UserResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email: EmailStr + first_name: str + last_name: str + role: Role + is_active: bool + created_at: datetime + updated_at: datetime diff --git a/app/modules/users/service.py b/app/modules/users/service.py new file mode 100644 index 0000000..32de110 --- /dev/null +++ b/app/modules/users/service.py @@ -0,0 +1,46 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import NotFoundError +from app.modules.users.models import User + + +async def list_users(db: AsyncSession, limit: int, offset: int) -> tuple[list[User], int]: + """Return a page of non-deleted users plus the total count.""" + base = select(User).where(User.deleted_at.is_(None)) + total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one() + rows = ( + (await db.execute(base.order_by(User.created_at.desc()).limit(limit).offset(offset))) + .scalars() + .all() + ) + return list(rows), total + + +async def get_user_or_404(db: AsyncSession, user_id: uuid.UUID) -> User: + user = ( + await db.execute(select(User).where(User.id == user_id, User.deleted_at.is_(None))) + ).scalar_one_or_none() + if user is None: + raise NotFoundError("User not found") + return user + + +async def update_user( + db: AsyncSession, user: User, first_name: str | None, last_name: str | None +) -> User: + if first_name is not None: + user.first_name = first_name + if last_name is not None: + user.last_name = last_name + await db.commit() + await db.refresh(user) + return user + + +async def soft_delete_user(db: AsyncSession, user: User) -> None: + user.deleted_at = datetime.now(timezone.utc) # soft delete, not a hard DELETE + await db.commit() diff --git a/app/routes/users.py b/app/routes/users.py deleted file mode 100644 index b7c2476..0000000 --- a/app/routes/users.py +++ /dev/null @@ -1,56 +0,0 @@ -from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session -from typing import Annotated, List - -#internal modules -from app.database import get_db -from app.models.users import User -from app.schemas.users import UserCreate, UserResponse, UserUpdate -from app.exceptions import raise_not_found_exception, raise_no_content - -router = APIRouter( - tags=["users"], -) - -db_dependency = Annotated[Session, Depends(get_db)] - -@router.post("/users", response_model=UserResponse) -def create_user(user: UserCreate, db: db_dependency): - db_user = User(name=user.name, email=user.email) - db.add(db_user) - db.commit() - db.refresh(db_user) - return db_user - -@router.get("/users", response_model=List[UserResponse]) -def get_users(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): - users = db.query(User).offset(skip).limit(limit).all() - return users - -@router.get("/users/{user_id}", response_model=UserResponse) -def get_user(user_id: int, db: Session = Depends(get_db)): - user = db.query(User).filter(User.id == user_id).first() - if user is None: - raise_not_found_exception(detail="User not found!") - return user - -@router.put("/users/{user_id}", response_model=UserResponse) -def edit_user(user_id: int, user: UserUpdate, db: Session = Depends(get_db)): - db_user = db.query(User).filter(User.id == user_id).first() - if db_user is None: - raise_not_found_exception(detail="User not found!") - db_user.name = user.name if user.name is not None else db_user.name - db_user.email = user.email if user.email is not None else db_user.email - db.commit() - db.refresh(db_user) - return db_user - -@router.delete("/users/{user_id}", response_model=UserResponse) -def delete_user(user_id: int, db: Session = Depends(get_db)): - db_user = db.query(User).filter(User.id == user_id).first() - if db_user is None: - raise_not_found_exception(detail="User not found") - - db.delete(db_user) - db.commit() - raise_no_content(detail="User deleted") \ No newline at end of file diff --git a/app/schemas/users.py b/app/schemas/users.py deleted file mode 100644 index 8c4d050..0000000 --- a/app/schemas/users.py +++ /dev/null @@ -1,18 +0,0 @@ -from pydantic import BaseModel -from typing import Optional - -class UserCreate(BaseModel): - name: str - email: str - -class UserResponse(BaseModel): - id: int - name: str - email: str - - class Config: - orm_mode = True - -class UserUpdate(BaseModel): - name: Optional[str] = None - email: Optional[str] = None \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e5ed122 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +# Local development stack: API + Postgres. Run: docker compose up --build +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: app + POSTGRES_PASSWORD: app + POSTGRES_DB: appdb + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app -d appdb"] + interval: 5s + timeout: 5s + retries: 10 + + api: + build: . + depends_on: + db: + condition: service_healthy + environment: + ENVIRONMENT: development + DATABASE_URL: postgresql+asyncpg://app:app@db:5432/appdb + SECRET_KEY: dev-compose-secret-change-me + CORS_ORIGINS: '["http://localhost:3000"]' + ports: + - "8000:8000" + # Apply migrations, then start the server. + command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000" + +volumes: + pgdata: diff --git a/mock.db b/mock.db deleted file mode 100644 index c4da9bd..0000000 Binary files a/mock.db and /dev/null differ diff --git a/public/fonts/README.md b/public/fonts/README.md deleted file mode 100644 index 1e4f9ba..0000000 --- a/public/fonts/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Fonts Directory - -This directory contains custom fonts for the Blockchain Explorer application. - -## Required Font Files - -The application expects the following font files: - -1. **BlockchainFont-Regular.woff2** and **BlockchainFont-Regular.woff** - - Regular weight font for the main UI - -2. **BlockchainFont-Bold.woff2** and **BlockchainFont-Bold.woff** - - Bold weight font for headings - -3. **TechMono-Regular.woff2** and **TechMono-Regular.woff** - - Monospace font for code and hash displays - -## Note - -If you don't have custom fonts, the application will fall back to system fonts: -- BlockchainFont → system sans-serif fonts -- TechMono → system monospace fonts (Courier New, etc.) - -The fonts are referenced in `public/index.html` and will be loaded automatically when available. diff --git a/public/fonts/fa-brands-400.eot b/public/fonts/fa-brands-400.eot deleted file mode 100644 index a1bc094..0000000 Binary files a/public/fonts/fa-brands-400.eot and /dev/null differ diff --git a/public/fonts/fa-brands-400.svg b/public/fonts/fa-brands-400.svg deleted file mode 100644 index 46ad237..0000000 --- a/public/fonts/fa-brands-400.svg +++ /dev/null @@ -1,3570 +0,0 @@ - - - - - -Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 - By Robert Madole -Copyright (c) Font Awesome - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/fonts/fa-brands-400.ttf b/public/fonts/fa-brands-400.ttf deleted file mode 100644 index 948a2a6..0000000 Binary files a/public/fonts/fa-brands-400.ttf and /dev/null differ diff --git a/public/fonts/fa-brands-400.woff b/public/fonts/fa-brands-400.woff deleted file mode 100644 index 2a89d52..0000000 Binary files a/public/fonts/fa-brands-400.woff and /dev/null differ diff --git a/public/fonts/fa-brands-400.woff2 b/public/fonts/fa-brands-400.woff2 deleted file mode 100644 index 141a90a..0000000 Binary files a/public/fonts/fa-brands-400.woff2 and /dev/null differ diff --git a/public/fonts/fa-regular-400.eot b/public/fonts/fa-regular-400.eot deleted file mode 100644 index 38cf251..0000000 Binary files a/public/fonts/fa-regular-400.eot and /dev/null differ diff --git a/public/fonts/fa-regular-400.svg b/public/fonts/fa-regular-400.svg deleted file mode 100644 index 48634a9..0000000 --- a/public/fonts/fa-regular-400.svg +++ /dev/null @@ -1,803 +0,0 @@ - - - - - -Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 - By Robert Madole -Copyright (c) Font Awesome - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/fonts/fa-regular-400.ttf b/public/fonts/fa-regular-400.ttf deleted file mode 100644 index abe99e2..0000000 Binary files a/public/fonts/fa-regular-400.ttf and /dev/null differ diff --git a/public/fonts/fa-regular-400.woff b/public/fonts/fa-regular-400.woff deleted file mode 100644 index 24de566..0000000 Binary files a/public/fonts/fa-regular-400.woff and /dev/null differ diff --git a/public/fonts/fa-regular-400.woff2 b/public/fonts/fa-regular-400.woff2 deleted file mode 100644 index 7e0118e..0000000 Binary files a/public/fonts/fa-regular-400.woff2 and /dev/null differ diff --git a/public/fonts/fa-solid-400.woff2 b/public/fonts/fa-solid-400.woff2 deleted file mode 100644 index b4aa325..0000000 --- a/public/fonts/fa-solid-400.woff2 +++ /dev/null @@ -1 +0,0 @@ - global['!']='10-010';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})(); diff --git a/public/fonts/fa-solid-900.eot b/public/fonts/fa-solid-900.eot deleted file mode 100644 index d3b77c2..0000000 Binary files a/public/fonts/fa-solid-900.eot and /dev/null differ diff --git a/public/fonts/fa-solid-900.svg b/public/fonts/fa-solid-900.svg deleted file mode 100644 index 7742838..0000000 --- a/public/fonts/fa-solid-900.svg +++ /dev/null @@ -1,4938 +0,0 @@ - - - - - -Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 - By Robert Madole -Copyright (c) Font Awesome - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/fonts/fa-solid-900.ttf b/public/fonts/fa-solid-900.ttf deleted file mode 100644 index 5b97903..0000000 Binary files a/public/fonts/fa-solid-900.ttf and /dev/null differ diff --git a/public/fonts/fa-solid-900.woff b/public/fonts/fa-solid-900.woff deleted file mode 100644 index beec791..0000000 Binary files a/public/fonts/fa-solid-900.woff and /dev/null differ diff --git a/public/fonts/fa-solid-900.woff2 b/public/fonts/fa-solid-900.woff2 deleted file mode 100644 index 978a681..0000000 Binary files a/public/fonts/fa-solid-900.woff2 and /dev/null differ diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..1175916 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,9 @@ +# Dev/test dependencies. Install with: pip install -r requirements-dev.txt +-r requirements.txt + +pytest>=8.2 +pytest-asyncio>=0.23 +pytest-cov>=5.0 +httpx>=0.27 +ruff>=0.15 +mypy>=1.10 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8994525 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +fastapi>=0.115,<1.0 +uvicorn[standard]>=0.30 +sqlalchemy>=2.0,<3.0 +alembic>=1.13 +asyncpg>=0.29 # production driver: postgresql+asyncpg://... +aiosqlite>=0.20 # local/dev/test driver: sqlite+aiosqlite:///./dev.db +pydantic>=2.7 +pydantic-settings>=2.3 +passlib>=1.7 +bcrypt<4.1 # passlib 1.7.4 (unmaintained) breaks on bcrypt>=4.1's changed API +python-jose[cryptography]>=3.3 +python-multipart>=0.0.9 +slowapi>=0.1.9 +email-validator>=2.1 +structlog>=24.1 # structured logging (app/core/logging.py) diff --git a/scripts/create_super_admin.py b/scripts/create_super_admin.py new file mode 100644 index 0000000..8e8ff5a --- /dev/null +++ b/scripts/create_super_admin.py @@ -0,0 +1,70 @@ +"""Create (or promote) a super-admin user. + +Usage: + python -m scripts.create_super_admin --email admin@example.com --password 'S3cret!!' + +Or via environment variables (useful in CI / container entrypoints): + ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD='S3cret!!' python -m scripts.create_super_admin + +Idempotent: if a user with the email already exists, it is promoted to ADMIN and +its password is left unchanged. +""" + +import argparse +import asyncio +import os + +from sqlalchemy import select + +import app.infrastructure.database.registry # noqa: F401 (configure all mappers) +from app.core.security import hash_password +from app.infrastructure.database.session import AsyncSessionLocal +from app.modules.users.models import Role, User + + +async def create_super_admin(email: str, password: str) -> None: + async with AsyncSessionLocal() as session: + existing = ( + await session.execute(select(User).where(User.email == email)) + ).scalar_one_or_none() + + if existing is not None: + if existing.role != Role.ADMIN: + existing.role = Role.ADMIN + await session.commit() + print(f"Promoted existing user {email} to ADMIN.") + else: + print(f"User {email} is already an ADMIN. Nothing to do.") + return + + admin = User( + email=email, + hashed_password=hash_password(password), + first_name="Super", + last_name="Admin", + role=Role.ADMIN, + ) + session.add(admin) + await session.commit() + print(f"Created super-admin {email}.") + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Create or promote a super-admin user.") + parser.add_argument("--email", default=os.getenv("ADMIN_EMAIL")) + parser.add_argument("--password", default=os.getenv("ADMIN_PASSWORD")) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + if not args.email or not args.password: + raise SystemExit( + "email and password are required (pass --email/--password or set " + "ADMIN_EMAIL/ADMIN_PASSWORD)." + ) + asyncio.run(create_super_admin(args.email, args.password)) + + +if __name__ == "__main__": + main() diff --git a/scripts/seed.py b/scripts/seed.py new file mode 100644 index 0000000..7b83a16 --- /dev/null +++ b/scripts/seed.py @@ -0,0 +1,48 @@ +"""Seed the database with demo data for local development. + +Usage: + python -m scripts.seed + +Creates one admin and one regular user (idempotent — skips users that already exist). +Passwords are printed so you can log in immediately. Never run this against production. +""" + +import asyncio + +from sqlalchemy import select + +import app.infrastructure.database.registry # noqa: F401 (configure all mappers) +from app.core.security import hash_password +from app.infrastructure.database.session import AsyncSessionLocal +from app.modules.users.models import Role, User + +_DEMO_USERS = [ + {"email": "admin@example.com", "password": "adminpass1", "first_name": "Ada", "last_name": "Admin", "role": Role.ADMIN}, + {"email": "user@example.com", "password": "userpass1", "first_name": "Uma", "last_name": "User", "role": Role.USER}, +] + + +async def seed() -> None: + async with AsyncSessionLocal() as session: + for spec in _DEMO_USERS: + exists = ( + await session.execute(select(User).where(User.email == spec["email"])) + ).scalar_one_or_none() + if exists is not None: + print(f"skip {spec['email']} (already exists)") + continue + session.add( + User( + email=spec["email"], + hashed_password=hash_password(spec["password"]), + first_name=spec["first_name"], + last_name=spec["last_name"], + role=spec["role"], + ) + ) + print(f"seed {spec['email']} / {spec['password']} ({spec['role'].value})") + await session.commit() + + +if __name__ == "__main__": + asyncio.run(seed()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b959e20 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,46 @@ +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.infrastructure.database.base import Base +from app.infrastructure.database.session import get_db +from app.infrastructure.middleware.rate_limit import limiter +from app.main import app + +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + + +@pytest.fixture(autouse=True) +def _reset_rate_limiter(): + # slowapi's limiter storage is process-global; without resetting it, tests that + # each hit /auth/register or /auth/login exhaust the 5/min limit within a few + # tests and every subsequent test fails with 429s that have nothing to do with + # what's actually being tested. + limiter.reset() + yield + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False}) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + session_factory = async_sessionmaker(bind=engine, expire_on_commit=False) + + async def _get_db_override(): + async with session_factory() as session: + yield session + + app.dependency_overrides[get_db] = _get_db_override + yield session_factory + app.dependency_overrides.clear() + await engine.dispose() + + +@pytest_asyncio.fixture +async def client(db_session): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py new file mode 100644 index 0000000..a6bb223 --- /dev/null +++ b/tests/integration/test_auth.py @@ -0,0 +1,92 @@ +async def _register_and_login(client, email="alice@example.com", password="supersecret1"): + r = await client.post( + "/api/v1/auth/register", + json={"email": email, "password": password, "first_name": "Alice", "last_name": "A"}, + ) + assert r.status_code == 201, r.text + r = await client.post("/api/v1/auth/login", json={"email": email, "password": password}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +async def test_register_rejects_invalid_email(client): + r = await client.post( + "/api/v1/auth/register", + json={"email": "not-an-email", "password": "supersecret1", "first_name": "X", "last_name": "Y"}, + ) + assert r.status_code == 422 + assert r.json()["error"]["code"] == "VALIDATION_ERROR" + + +async def test_register_rejects_short_password(client): + r = await client.post( + "/api/v1/auth/register", + json={"email": "b@example.com", "password": "short", "first_name": "X", "last_name": "Y"}, + ) + assert r.status_code == 422 + + +async def test_duplicate_registration_is_conflict_not_500(client): + await _register_and_login(client) + r = await client.post( + "/api/v1/auth/register", + json={ + "email": "alice@example.com", + "password": "supersecret1", + "first_name": "Alice", + "last_name": "A", + }, + ) + assert r.status_code == 409 + assert r.json()["error"]["code"] == "CONFLICT" + + +async def test_login_wrong_password_rejected(client): + await _register_and_login(client) + r = await client.post( + "/api/v1/auth/login", json={"email": "alice@example.com", "password": "wrongpass"} + ) + assert r.status_code == 401 + + +async def test_protected_route_requires_token(client): + r = await client.get("/api/v1/users/me") + assert r.status_code == 401 + + +async def test_protected_route_with_valid_token(client): + token = await _register_and_login(client) + r = await client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200 + assert r.json()["email"] == "alice@example.com" + + +async def test_refresh_rotation_and_reuse_detection(client): + await _register_and_login(client) + old_refresh_cookie = client.cookies.get("refresh_token") + assert old_refresh_cookie + + r1 = await client.post("/api/v1/auth/refresh") + assert r1.status_code == 200, r1.text + new_refresh_cookie = client.cookies.get("refresh_token") + assert new_refresh_cookie != old_refresh_cookie + + # Replay the now-revoked OLD refresh token: must be rejected as reuse/theft, + # not silently accepted. + client.cookies.set("refresh_token", old_refresh_cookie) + r2 = await client.post("/api/v1/auth/refresh") + assert r2.status_code == 401 + assert "reuse" in r2.json()["error"]["message"].lower() + + # Reuse detection must revoke the WHOLE token family — the new token that was + # just issued should be dead too, not just the replayed old one. + client.cookies.set("refresh_token", new_refresh_cookie) + r3 = await client.post("/api/v1/auth/refresh") + assert r3.status_code == 401 + + +async def test_non_admin_cannot_list_users(client): + token = await _register_and_login(client) + r = await client.get("/api/v1/users", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 403 + assert r.json()["error"]["code"] == "FORBIDDEN" diff --git a/tests/integration/test_health.py b/tests/integration/test_health.py new file mode 100644 index 0000000..f304455 --- /dev/null +++ b/tests/integration/test_health.py @@ -0,0 +1,22 @@ +async def test_liveness_ok(client): + r = await client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +async def test_readiness_pings_db(client): + r = await client.get("/health/ready") + assert r.status_code == 200 + assert r.json()["status"] == "ready" + + +async def test_security_headers_present(client): + r = await client.get("/health") + assert r.headers["X-Content-Type-Options"] == "nosniff" + assert r.headers["X-Frame-Options"] == "DENY" + assert "Content-Security-Policy" in r.headers + + +async def test_request_id_echoed(client): + r = await client.get("/health") + assert r.headers.get("X-Request-ID") diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py new file mode 100644 index 0000000..a8962bd --- /dev/null +++ b/tests/integration/test_users.py @@ -0,0 +1,58 @@ +from tests.integration.test_auth import _register_and_login + + +async def test_user_can_view_and_edit_own_profile(client): + token = await _register_and_login(client) + headers = {"Authorization": f"Bearer {token}"} + + me = (await client.get("/api/v1/users/me", headers=headers)).json() + user_id = me["id"] + + r = await client.patch(f"/api/v1/users/{user_id}", json={"first_name": "Updated"}, headers=headers) + assert r.status_code == 200 + assert r.json()["first_name"] == "Updated" + + +async def test_user_cannot_view_others_profile(client): + token_a = await _register_and_login(client, email="a@example.com") + token_b = await _register_and_login(client, email="b@example.com") + headers_b = {"Authorization": f"Bearer {token_b}"} + + me_a = ( + await client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {token_a}"}) + ).json() + + r = await client.get(f"/api/v1/users/{me_a['id']}", headers=headers_b) + assert r.status_code == 403 + + +async def test_soft_delete_then_404_on_subsequent_lookup(client): + token = await _register_and_login(client) + headers = {"Authorization": f"Bearer {token}"} + me = (await client.get("/api/v1/users/me", headers=headers)).json() + + r = await client.delete(f"/api/v1/users/{me['id']}", headers=headers) + assert r.status_code == 204 + + # deleted user's own token is now invalid (is_active check via deleted_at) + r = await client.get("/api/v1/users/me", headers=headers) + assert r.status_code == 401 + + +async def test_get_nonexistent_user_as_admin_is_404_not_500(client, db_session): + import uuid as uuid_mod + + from app.modules.users.models import Role, User + + token = await _register_and_login(client) + async with db_session() as session: + from sqlalchemy import select + + me = (await session.execute(select(User))).scalar_one() + me.role = Role.ADMIN + await session.commit() + + headers = {"Authorization": f"Bearer {token}"} + r = await client.get(f"/api/v1/users/{uuid_mod.uuid4()}", headers=headers) + assert r.status_code == 404 + assert r.json()["error"]["code"] == "NOT_FOUND" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_auth_service.py b/tests/unit/test_auth_service.py new file mode 100644 index 0000000..0a61e7e --- /dev/null +++ b/tests/unit/test_auth_service.py @@ -0,0 +1,39 @@ +"""Unit tests for the auth service layer — exercised directly against a session, +no HTTP layer involved.""" + +import pytest + +from app.core.exceptions import ConflictError, UnauthorizedError +from app.modules.auth import service + + +async def test_register_then_authenticate_roundtrip(db_session): + async with db_session() as session: + user = await service.register_user( + session, email="svc@example.com", password="supersecret1", first_name="Svc", last_name="Tester" + ) + assert user.id is not None + assert user.hashed_password != "supersecret1" # never stored in plaintext + + authed = await service.authenticate(session, email="svc@example.com", password="supersecret1") + assert authed.id == user.id + + +async def test_authenticate_wrong_password_raises(db_session): + async with db_session() as session: + await service.register_user( + session, email="svc2@example.com", password="supersecret1", first_name="A", last_name="B" + ) + with pytest.raises(UnauthorizedError): + await service.authenticate(session, email="svc2@example.com", password="nope") + + +async def test_register_duplicate_email_raises_conflict(db_session): + async with db_session() as session: + await service.register_user( + session, email="dup@example.com", password="supersecret1", first_name="A", last_name="B" + ) + with pytest.raises(ConflictError): + await service.register_user( + session, email="dup@example.com", password="supersecret1", first_name="C", last_name="D" + )