diff --git a/.coverage b/.coverage index 9d46389..b3c7568 100644 Binary files a/.coverage and b/.coverage differ diff --git a/.env.example b/.env.example index da6646a..d7a580f 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,14 @@ # DATABASE URL DATABASE_URL= +# Set to True to expose the interactive docs (/docs, /redoc, /openapi.json) +# to all clients. Whitelisted IPs keep access regardless of this flag. +# Keep False in production. +DEBUG=False + # JWT VARIABLES +# JWT_SECRET is REQUIRED when DEBUG=False. Generate one with: +# python -c "import secrets; print(secrets.token_urlsafe(64))" JWT_SECRET= JWT_ALGORITHM=HS256 diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 749cb12..8f00fed 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' # Use your project's version + python-version: '3.12' # 3.12+ required for PEP 695 generic syntax - name: Install dependencies run: | diff --git a/.github/workflows/create_pull_request.yml b/.github/workflows/create_pull_request.yml index 37b8cf9..0c9cbd4 100644 --- a/.github/workflows/create_pull_request.yml +++ b/.github/workflows/create_pull_request.yml @@ -34,7 +34,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' # You can change this to your preferred version + python-version: '3.12' # 3.12+ required for PEP 695 generic syntax cache: 'pip' - name: Install Dependencies diff --git a/.gitignore b/.gitignore index 73fd17f..a81d367 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ logs/ .mypy_cache/ .coverage htmlcov/ +.claude/ +CLAUDE.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6ae4926..ae6a4c4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,9 +27,9 @@ repos: - id: ruff args: [ --fix ] - # 4. Type Checking (Mypy) + # 4. Type Checking (Mypy) β€” v1.7.1 predates PEP 695; needs >= 1.12 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.7.1 + rev: v1.15.0 hooks: - id: mypy additional_dependencies: diff --git a/README.md b/README.md index 28bcbe5..7a1e850 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,22 @@ This repository provides a clean and scalable template for building FastAPI appl - πŸ—’οΈ [Predefined Environment Configuration](./app/settings.py) with [Pydantic-Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) - πŸ›œ Dependency management Setup for [Common Dependencies](./app/dependencies.py) - `get_db`: Async Database Session Dependency - - `get_current_user`: Async User dependency, Extract user Database with Access Token in request, raises 401 Http Exception if token is not valid + - `get_current_user`: Async User dependency. Extracts the user from the request's access token, and raises a 401 if the token is missing, invalid, blacklisted, or is not an **access** token. - πŸ‘€ Initial [User Model](./app/models/auth.py) and [User Authentication Endpoints](./app/routers/auth.py) with [Unit Tests](./app/routers/tests/test_auth.py) +- πŸ” Full JWT auth flow: signup β†’ email activation, sign-in issuing separate **access** and **refresh** tokens (each tagged with a `type` claim so they are not interchangeable), password reset, profile update, and logout via a Redis token blacklist. +- 🧰 Async [Redis manager](./app/redis_manager.py) (`redis.asyncio`) backing the token blacklist and short-lived one-time codes (activation / password reset). +- πŸ”’ Docs (`/docs`, `/redoc`, `/openapi.json`) gated behind a `DEBUG` flag **or** an IP allowlist β€” hidden with a 404 otherwise. +- 🚦 Per-endpoint rate limiting on the auth routes via [`slowapi`](./app/limiter.py), plus per-account lockout after repeated bad codes (brute-force protection on login and code endpoints). +- 🩺 `/health` readiness probe and a startup connectivity check for the database and Redis. - πŸ“ [Predefined Logging](./app/logger.py) Configuration - βš™οΈ Unit Test Configuration with Pytest (With Async Support) - ⏺️ [Alembic Data Migration](./alembic) Configuration and [alembic.ini](alembic.ini) ## Getting Started +> Requires **Python 3.12+** (the codebase uses PEP 695 generic syntax). + In order to get started with the FastAPI Project, follow the following steps - [ ] Activate Project Python Virtual Environment ```bash @@ -31,6 +38,8 @@ In order to get started with the FastAPI Project, follow the following steps - [ ] Create an .env file from the .env.example file and provide values for missing environment variables - 1. Update the DATABASE_URL to point at an accessible DATABASE server - 2. Update the MAIL_CONFIG section to include mail server credentials + - 3. Set `DEBUG=True` for local development (also exposes the interactive docs β€” see Quirks) +- [ ] Ensure a **Redis** server is running and reachable at `REDIS_HOST`/`REDIS_PORT` (defaults to `localhost:6379`). The auth flows and the test suite talk to a real Redis instance. - [ ] Install ```make``` if you do not already have it and run the command ```make run-local``` to start you local server - [ ] Apply Initial Database Migration for Ensure Database Connection string is valid ```bash @@ -47,8 +56,52 @@ In order to get started with the FastAPI Project, follow the following steps make run-local ``` -## NOTES -- After making Changes to your Model(s) in the models/ directory, ensure the Model class is Imported in the __init__ module of the models directory, this way, the configured alembic for your project can pick up models changes for Migrations +## Architecture + +The template is **async-first** and organizes each feature across four layers. When you add a feature, follow the same shape the `auth` feature uses: + +| Layer | Responsibility | +| --- | --- | +| [`routers/`](./app/routers) | HTTP endpoints, dependency wiring, and `response_model`. Kept **thin** β€” no business logic. | +| [`services/`](./app/services) | Business logic: DB queries, token/password/email orchestration, Redis access. | +| [`schemas/`](./app/schemas) | Pydantic request/response models β€” all validation lives here. | +| [`models/`](./app/models) | SQLAlchemy ORM models (persistence). All inherit `AbstractBase` β†’ UUID PK + `date_created`/`date_updated`. | + +Request flow: a router aggregates into [`app/api_router.py`](./app/api_router.py) under the `/v1` prefix, which is mounted in [`app/main.py`](./app/main.py). `main.py` also assembles the middleware stack (CORS β†’ GZip β†’ TrustedHost β†’ docs gate β†’ request logging), a `slowapi` rate limiter, and uniform JSON exception handlers. + +Supporting singletons: [`redis_manager`](./app/redis_manager.py) (async Redis for the token blacklist and one-time codes) and [`send_mail`](./app/mailer.py) (Jinja templates from `app/templates/`, always dispatched via FastAPI `BackgroundTasks`). + +### Paginated responses + +[`app/schemas/__init__.py`](./app/schemas/__init__.py) provides a reusable generic envelope for list endpoints, `PaginatedResponse[T]`, so paginated payloads share one consistent shape: + +| Field | Meaning | +| --- | --- | +| `total_results` | total rows matching the query | +| `current_page` | 1-based page number returned | +| `total_pages` | total number of pages | +| `per_page` | page size used | +| `results` | the page of items, typed as `list[T]` | + +Parameterize it with the item schema and use it as the endpoint's `response_model`: + +```python +from app.schemas import PaginatedResponse +from app.schemas.auth import UserModel + +@router.get("/users", response_model=PaginatedResponse[UserModel]) +async def list_users(db: DBDep, page: int = 1, per_page: int = 20): + # ... run the query, collect `users` and `total_results` ... + return PaginatedResponse[UserModel]( + total_results=total_results, + current_page=page, + total_pages=-(-total_results // per_page), # ceiling division + per_page=per_page, + results=users, + ) +``` + +> The model uses PEP 695 generic syntax (`class PaginatedResponse[T]`), which needs **Python 3.12+** and **mypy β‰₯ 1.12** β€” the CI workflows and the pre-commit `mypy` pin are set accordingly. On an older toolchain you'd see `Name "T" is not defined`; bump the versions (or fall back to the classic `Generic[T]` + `TypeVar` form). ## Project Structure @@ -81,6 +134,25 @@ fastapi-project-structure/ └── requirements.txt ``` +## Quirks & Gotchas + +Things that are easy to trip over when building on this template: + +- **Alembic only sees models imported in [`app/models/__init__.py`](./app/models/__init__.py).** After adding a model, import it there (and add it to `__all__`) *before* running `alembic revision --autogenerate` β€” otherwise the migration silently misses your table. +- **Redis is required and its client is async.** Auth flows (logout blacklist, activation/reset codes) and the test suite hit a real Redis server. Every `redis_manager` call is a coroutine β€” `await` it. The test suite closes the connection pool after each test (autouse fixture in [`conftest.py`](./conftest.py)) because `pytest-asyncio` gives each test its own event loop; a shared `redis.asyncio` pool would otherwise reuse a closed-loop socket and raise `Event loop is closed`. +- **Docs are gated by `DEBUG` OR the IP allowlist.** The [`AllowAuthorizedDocAccess`](./app/middlewares.py) middleware serves `/docs`, `/redoc`, and `/openapi.json` only when `settings.DEBUG` is true **or** the client IP is in `allowed_ips` (default `127.0.0.1`); otherwise it returns a 404 that hides their existence. Note this middleware runs **before** `TrustedHostMiddleware`, so a request that clears the docs gate must still use a host listed in `main.py`'s `allowed_hosts`. +- **Access and refresh tokens are not interchangeable.** Each carries a `type` claim (`access` / `refresh`). `get_current_user` rejects anything that isn't an access token; the `refresh_token` endpoint rejects anything that isn't a refresh token. +- **Refresh tokens are single-use (rotated).** Each call to `/refresh_token` blacklists the presented refresh token and returns a fresh access **and** refresh token, so a leaked refresh token is usable at most once. +- **Logout is global.** It blacklists the presented token(s) for their remaining lifetime *and* bumps a per-user token version in Redis, so **every** token issued before the logout is invalidated across all devices. Tokens carry a `ver` claim checked on each request; pass the refresh token in the logout body (`{"refresh_token": "..."}`) to blacklist it explicitly too. +- **Repeated bad codes lock the account.** After `MAX_CODE_ATTEMPTS` (default 5) wrong activation/reset codes, that account is locked for `CODE_LOCKOUT_SECONDS`; a successful attempt clears the counter. +- **`/health` and startup checks.** `/health` returns 503 if the DB or Redis is unreachable. On boot the app pings both; in production (`DEBUG=False`) it refuses to start if either is down, in `DEBUG` it only logs. +- **Sign-in requires a verified email.** `signin_user` returns `403 Email not verified` until activation flips `is_verified`. In tests, `UserFactory` builds verified users; use `create_user`/an unverified user to exercise the rejection. +- **Auth endpoints are rate-limited** via `slowapi` (`@limiter.limit` in [`app/routers/auth.py`](./app/routers/auth.py), registered in [`app/main.py`](./app/main.py)). Limits are **Redis-backed** ([`app/limiter.py`](./app/limiter.py)) so they hold across workers/replicas. The limiter is **disabled in the test suite** (`conftest.py`) since the counter is shared across tests β€” enable it per-test (with a unique client key) to assert 429s. +- **Access tokens are short-lived; sign secrets are enforced in production.** Access tokens default to 15 minutes (`ACCESS_TOKEN_LIFESPAN_MIN`), refresh tokens to 28 days (`REFRESH_TOKEN_LIFESPAN_DAYS`). With `DEBUG=False`, an empty `JWT_SECRET` makes the app refuse to start. +- **One-time codes are single-use and cryptographically random.** Activation and password-reset codes come from `secrets` and are deleted from Redis on successful use, so they can't be replayed. +- **`app/main.py` contains `{{ project_name }}`-style placeholders** (title/version/summary). These are template placeholders meant to be filled in per project, not bugs. +- **Mail sends with `VALIDATE_CERTS=True`.** If your dev SMTP uses a self-signed certificate, adjust the `ConnectionConfig` in [`app/mailer.py`](./app/mailer.py). + ## How to Download Complete Project Structure from Github 1. **Clone the repository:** @@ -123,8 +195,20 @@ pytest -s app/routers/tests/test_auth.py ## Environment Variables -Copy `.env.example` to `.env` and update the values as needed. +Copy `.env.example` to `.env` and update the values as needed. Notable keys: + +- `DATABASE_URL` (required) β€” async driver expected, e.g. `postgresql+asyncpg://...` +- `DEBUG` (default `False`) β€” when `True`, exposes the interactive docs to all clients (see Quirks) +- `REDIS_HOST` / `REDIS_PORT` (default `localhost` / `6379`) +- `JWT_SECRET` β€” signing key; **required when `DEBUG=False`** (the app refuses to boot with an empty secret in production). `JWT_ALGORITHM` defaults to `HS256`. +- `ACCESS_TOKEN_LIFESPAN_MIN` (default `15`, **minutes**) / `REFRESH_TOKEN_LIFESPAN_DAYS` (default `28`, days) +- `MAIL_*` β€” SMTP credentials used by the mailer + +## Upgrading an Existing Project +Backporting these changes into a project scaffolded from an older version of the +template? Follow [UPGRADING.md](./UPGRADING.md) β€” it isolates each change so you +can apply them independently, and keeps rate limiting an optional, skippable step. ## Contributing Contributions are welcome! Please open issues or submit pull requests. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e69de29 diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..379f578 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,568 @@ +# Upgrading an Older Project + +This guide backports the recent security, correctness, and structural fixes into +a project that was scaffolded from an **earlier version** of this template. + +It is written so you can adopt everything **without turning on rate limiting** β€” +that step is isolated near the end and is safe to skip. Every other step is +independent, so apply them in any order you like and run your tests after each. + +> Conventions below: `app/...` paths are relative to your project. Snippets show +> the **target** state; adapt names if yours differ. + +--- + +## 0. Compatibility at a glance + +| Change | Type | Action needed | +| --- | --- | --- | +| Shared `settings` singleton | Safe | Mechanical import swap | +| Async Redis (`redis.asyncio`) | **Breaking (code)** | Add `await` to every `redis_manager` call | +| Token `type` claims (access/refresh) | **Breaking (tokens)** | Old tokens lack `type`; see Β§4 | +| Logout blacklist TTL + refresh rotation | Behavioral | Review logout/refresh clients | +| `is_verified` server default + sign-in enforcement | **Breaking (data)** | Backfill existing users; see Β§6 | +| Docs gated by `DEBUG` **or** IP allowlist | Behavioral | Set `DEBUG`; see Β§7 | +| `secrets` for codes, mailer `VALIDATE_CERTS=True` | Safe | Drop-in | +| Short-lived access tokens + `JWT_SECRET` fail-fast | Behavioral | Set lifespans/secret; see Β§9 note | +| Health check + startup connectivity | Safe (additive) | See Β§10 | +| Global logout (token versioning) | **Breaking (tokens)** | Adds `ver` claim; see Β§11 | +| Account lockout on codes | Behavioral | See Β§12 | +| Rate limiting | **Optional** | Skip it β€” see Β§13 | + +**Backup first:** commit or branch before starting, and snapshot your database +before running the migration in Β§6. + +--- + +## 1. Dependencies + +Make sure these are installed and pinned in `requirements.txt`: + +``` +redis>=4.2 # provides redis.asyncio +slowapi==0.1.9 # only if you do the OPTIONAL rate-limiting step (Β§13) +``` + +If you skip Β§13 you do **not** need `slowapi`. + +--- + +## 2. Shared settings singleton + +Re-parsing `.env` in every module is wasteful. Define one instance and import it. + +In `app/settings.py`, add at the bottom: + +```python +# Import this instead of calling Settings() again. +settings = Settings() # type: ignore +``` + +Then in each module that had `settings = Settings()`, replace: + +```python +from app.settings import Settings +settings = Settings() # type: ignore +``` + +with: + +```python +from app.settings import settings +``` + +Also add the flags used later, in the `Settings` class body: + +```python +DEBUG: bool = False +ACCESS_TOKEN_LIFESPAN_MIN: int = 15 # minutes +REFRESH_TOKEN_LIFESPAN_DAYS: int = 28 # days +``` + +--- + +## 3. Async Redis (breaking β€” needs `await`) + +Switch the client to `redis.asyncio` so cache calls stop blocking the event loop. + +`app/redis_manager.py`: + +```python +import redis.asyncio as redis # was: import redis + +class RedisManager: + def __init__(self): + self.redis_client = redis.Redis(host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + decode_responses=True) + + async def cache_json_item(self, key, value, ttl=3600) -> None: + await self.redis_client.set(name=key, value=json.dumps(value), ex=ttl) + + async def get_json_item(self, key, default=None): + value = await self.redis_client.get(name=key) + return default if value is None else json.loads(value) + + async def delete_key(self, key) -> None: + await self.redis_client.delete(key) + + # Helpers used by Β§11 (token versioning) and Β§12 (lockout counters): + async def get_int(self, key) -> int: + value = await self.redis_client.get(name=key) + return int(value) if value is not None else 0 + + async def increment(self, key, ttl=None) -> int: + value = await self.redis_client.incr(key) + if ttl is not None and value == 1: # set expiry on first increment + await self.redis_client.expire(key, ttl) + return value +``` + +**Now add `await` to every call site** β€” search your codebase: + +```bash +grep -rn "redis_manager\.\(cache_json_item\|get_json_item\|delete_key\)" app/ +``` + +Each hit inside an `async def` gets an `await`. This includes +`get_current_user` (the logout blacklist check) and your auth service functions. + +**Test fixture (required).** `pytest-asyncio` gives each test its own event loop, +but a module-level async Redis client pools connections bound to a closed loop β†’ +`RuntimeError: Event loop is closed`. Close the pool after each test. In your root +`conftest.py`: + +```python +from app.redis_manager import redis_manager + +@pytest.fixture(autouse=True) +async def close_redis_connections(): + yield + await redis_manager.redis_client.aclose() +``` + +Direct `redis_manager` calls in your tests also need `await` (and any sync test +that touches Redis must become `async def`). + +--- + +## 4. Token `type` claims (access vs refresh) + +Tag tokens so an access token can't be used where a refresh token is expected and +vice versa. + +In your token creators (`app/services/auth.py`): + +```python +to_encode.update({"exp": expire, "type": "access"}) # create_access_token +to_encode.update({"exp": expire, "type": "refresh"}) # create_refresh_token +``` + +In `get_current_user` (`app/dependencies.py`), after decoding and reading `sub`: + +```python +if payload.get("type") != "access": + raise credentials_exception +``` + +In the `refresh_token` service, after decoding: + +```python +if payload.get("type") != "refresh": + raise HTTPException(status_code=401, detail="Invalid Refresh Token") +``` + +> **Migration note:** tokens issued before this change have **no `type` claim**, +> so they will be rejected after you deploy. Expect all users to re-authenticate +> once. If you must avoid that, treat a *missing* `type` as `access` for a grace +> period (`payload.get("type", "access") != "access"`) and remove the fallback +> after your access-token lifetime has elapsed. + +--- + +## 5. Logout TTL + refresh rotation + +**Blacklist for the token's real remaining life** (not a fixed TTL). Add a helper +and use it from logout: + +```python +async def blacklist_token(token: str) -> None: + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + except InvalidTokenError: + return + exp = payload.get("exp") + ttl = int(exp - datetime.now(UTC).timestamp()) if exp else 0 + if ttl > 0: + await redis_manager.cache_json_item(token, {"timestamp": str(datetime.now(UTC))}, ttl=ttl) +``` + +Let the logout route accept an optional refresh token so it can be revoked too +(add a `LogoutData(refresh_token: str | None = None)` schema and pass it through). + +**Refresh rotation.** In `refresh_token`, reject blacklisted tokens, then rotate: + +```python +if await redis_manager.get_json_item(token_data.refresh_token): + raise HTTPException(status_code=401, detail="Invalid Refresh Token") +# ...decode, validate type == "refresh", load user... +email = payload.get("sub") +if not isinstance(email, str): + raise HTTPException(status_code=401, detail="Invalid Refresh Token") +# ... +await blacklist_token(token_data.refresh_token) # single-use +new_access = create_access_token({"sub": email}, ACCESS_TOKEN_LIFESPAN) +new_refresh = create_refresh_token({"sub": email}, REFRESH_TOKEN_LIFESPAN) +``` + +**Client impact:** `/refresh_token` now returns a **new** refresh token each time; +clients must store and use the returned one. The old one stops working. + +--- + +## 6. `is_verified` (data-breaking β€” read carefully) + +Two parts: a schema default, and β€” only if you want it β€” sign-in enforcement. + +**a) Add the column / server default.** If your `User` model lacks `is_verified`, +add it. Give it a server default so raw inserts and existing rows are covered: + +```python +from sqlalchemy import Boolean, false +is_verified: Mapped[bool] = mapped_column( + Boolean, default=False, server_default=false(), nullable=False +) +``` + +Generate a migration and, **critically, backfill existing users** so you don't +lock everyone out: + +```python +def upgrade(): + # add column if it doesn't exist yet, then: + op.alter_column("users", "is_verified", + existing_type=sa.Boolean(), existing_nullable=False, + server_default=sa.false()) + # Existing accounts predate verification β€” treat them as verified: + op.execute("UPDATE users SET is_verified = true") +``` + +**b) (Optional) Enforce it at sign-in.** This is a behavioral break β€” do it only +after the backfill above, or unverified legacy users can't log in: + +```python +if not user.is_verified: + raise HTTPException(status_code=403, detail="Email not verified") +``` + +If you use factories in tests, set `is_verified = True` on the user factory so the +rest of your auth tests keep signing in. + +--- + +## 7. Docs gating (`DEBUG` **or** IP allowlist) + +Serve `/docs`, `/redoc`, and `/openapi.json` only when `DEBUG` is on **or** the +caller IP is whitelisted; otherwise return a 404 that hides their existence. +In your `AllowAuthorizedDocAccess` middleware: + +```python +protected_paths = ("/docs", "/redoc", "/openapi.json") + +async def dispatch(self, request, call_next): + if request.url.path in self.protected_paths: + client_ip = request.client.host if request.client else None + if not (settings.DEBUG or client_ip in self.allowed_ips): + return JSONResponse(status_code=404, + content={"detail": "This route does not exist", + "path": request.url.path}) + return await call_next(request) +``` + +**Watch out:** this middleware runs **before** `TrustedHostMiddleware`, so a +request that clears the docs gate must still use a host in your `allowed_hosts`. +Set `DEBUG=True` in your local `.env`; keep it `False` in production. + +--- + +## 8. Cryptographically secure codes + +Swap `random` for `secrets` in `generate_random_code`: + +```python +import secrets + +def generate_random_code(n: int = 4) -> str: + return "".join(secrets.choice("0123456789") for _ in range(n)) +``` + +Also delete activation/reset codes from Redis after successful use +(`await redis_manager.delete_key(...)`) so they can't be replayed. + +--- + +## 9. Mailer certs, short-lived tokens, and a required secret + +- In `app/mailer.py`'s `ConnectionConfig`, set `VALIDATE_CERTS=True` (keep `False` + locally only if your dev SMTP uses a self-signed cert). +- Drive token lifespans from settings (added in Β§2) β€” and note **access tokens are + minutes, refresh tokens are days**: + + ```python + ACCESS_TOKEN_LIFESPAN = timedelta(minutes=settings.ACCESS_TOKEN_LIFESPAN_MIN) + REFRESH_TOKEN_LIFESPAN = timedelta(days=settings.REFRESH_TOKEN_LIFESPAN_DAYS) + ``` + +- Fail fast on a missing signing secret in production. In `Settings`: + + ```python + from pydantic import model_validator + + @model_validator(mode="after") + def _require_jwt_secret_in_production(self): + if not self.DEBUG and not self.JWT_SECRET: + raise ValueError("JWT_SECRET must be set when DEBUG is False") + return self + ``` + +--- + +## 10. Health check + startup connectivity + +Add a readiness probe and validate connectivity on boot. + +`app/routers/health.py`: + +```python +from typing import Annotated +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession +from app.dependencies import get_db +from app.redis_manager import redis_manager + +router = APIRouter(tags=["Health"]) + +@router.get("/health") +async def health(db: Annotated[AsyncSession, Depends(get_db)]): + checks = {"database": "ok", "redis": "ok"} + try: + await db.execute(text("SELECT 1")) + except Exception: + checks["database"] = "error" + try: + await redis_manager.redis_client.ping() + except Exception: + checks["redis"] = "error" + if any(v != "ok" for v in checks.values()): + raise HTTPException(status_code=503, detail=checks) + return {"status": "ok", "checks": checks} +``` + +Register it (`app.include_router(health_router)`), and check connectivity in the +lifespan β€” fail fast in production, warn in `DEBUG`: + +```python +async def check_connectivity() -> None: + problems = [] + try: + async with AsyncSessionLocal() as session: + await session.execute(text("SELECT 1")) + except Exception as exc: + problems.append(f"database ({exc})") + try: + await redis_manager.redis_client.ping() + except Exception as exc: + problems.append(f"redis ({exc})") + if problems: + logger.error("Startup connectivity check failed: " + ", ".join(problems)) + if not settings.DEBUG: + raise RuntimeError("Startup connectivity check failed") + +@asynccontextmanager +async def lifespan(app): + await check_connectivity() + yield +``` + +> Requires the async Redis client from Β§3. Most test clients (`ASGITransport`) +> don't trigger the lifespan, so this won't run in typical tests; the `DEBUG` +> guard keeps any explicit lifespan test green even if the DB/Redis is down. + +--- + +## 11. Global logout (token versioning) + +Make logout invalidate **every** token a user holds, across all devices β€” not just +the pair presented. Requires the `get_int`/`increment` helpers from Β§3. + +Give each token a unique id and the user's current version. In your token +creators (`app/services/auth.py`): + +```python +import secrets + +def token_version_key(email: str) -> str: + return f"token-version-{email}" + +# in create_access_token / create_refresh_token, when building the payload: +to_encode.update({"exp": expire, "type": "access", "jti": secrets.token_hex(16)}) +to_encode.update({"exp": expire, "type": "refresh", "jti": secrets.token_hex(16)}) +``` + +At login, read the version and embed it as a `ver` claim: + +```python +version = await redis_manager.get_int(token_version_key(email)) +access = create_access_token({"sub": email, "ver": version}, ACCESS_TOKEN_LIFESPAN) +refresh = create_refresh_token({"sub": email, "ver": version}, REFRESH_TOKEN_LIFESPAN) +``` + +Reject stale tokens in `get_current_user` (and the same check in `refresh_token`, +re-embedding the current version on rotation): + +```python +version = await redis_manager.get_int(auth_services.token_version_key(username)) +if payload.get("ver", 0) != version: + raise credentials_exception +``` + +Bump the version on logout (the route passes the authenticated `user.email`): + +```python +async def logout(access_token, refresh_token=None, email=None): + await blacklist_token(access_token) + if refresh_token: + await blacklist_token(refresh_token) + if email: + await redis_manager.increment(token_version_key(email)) # invalidate all + return {"detail": "User Logged Out Successfully"} +``` + +> **Migration note:** old tokens have no `ver`; `payload.get("ver", 0)` treats them +> as version `0`, so they stay valid until the first logout for that user (which +> sets the version to `1`). **Keep `token-version-*` on a persistent Redis** (AOF/ +> RDB) β€” if the key is evicted, the version resets to `0` and pre-logout tokens +> validate again. For stronger guarantees, store the version on the users table. + +--- + +## 12. Account lockout on codes + +Throttle brute-forcing of the 6-digit activation/reset codes per account (this is +account-level, complementing any per-IP rate limit). Uses `get_int`/`increment` +from Β§3. + +```python +MAX_CODE_ATTEMPTS = 5 +CODE_LOCKOUT_SECONDS = 15 * 60 + +async def guard_code_attempts(scope: str, email: str) -> str: + key = f"failed-{scope}-{email}" + if await redis_manager.get_int(key) >= MAX_CODE_ATTEMPTS: + raise HTTPException(status_code=429, detail="Too many attempts. Please try again later.") + return key +``` + +Wrap each code check (`activate_user`, `reset_password`): + +```python +attempt_key = await guard_code_attempts("reset", email) # or "activation" +data = await redis_manager.get_json_item(f"reset-code-{email}") +if not data or data.get("code") != submitted_code: + await redis_manager.increment(attempt_key, ttl=CODE_LOCKOUT_SECONDS) + raise HTTPException(status_code=400, detail="Invalid Reset Code") +# ...on success: +await redis_manager.delete_key(f"reset-code-{email}") +await redis_manager.delete_key(attempt_key) # clear the counter +``` + +> Apply this to the **code-guessing** endpoints, not login β€” account lockout on +> login lets an attacker lock out a victim (DoS). Guard login with the per-IP rate +> limit in Β§13 instead. + +--- + +## 13. Rate limiting β€” OPTIONAL (skip to keep it inactive) + +**You can stop here.** Everything above works without rate limiting. This section +is only if you *choose* to add it. Two ways to keep it inactive: + +**Option A β€” don't add it at all.** Do nothing. No `slowapi` dependency, no +decorators. This is the "without rate limiting active" path. + +**Option B β€” add the wiring but keep it switched off**, so you can flip it on +later per-environment. Add a setting: + +```python +# app/settings.py +RATE_LIMIT_ENABLED: bool = False +``` + +Create `app/limiter.py` (Redis-backed so limits hold across workers/replicas): + +```python +from slowapi import Limiter +from slowapi.util import get_remote_address +from app.settings import settings + +limiter = Limiter( + key_func=get_remote_address, + enabled=settings.RATE_LIMIT_ENABLED, + storage_uri=f"redis://{settings.REDIS_HOST}:{settings.REDIS_PORT}", +) +``` + +Register it in `app/main.py` (harmless while disabled): + +```python +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from app.limiter import limiter + +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +``` + +Decorate sensitive routes (each needs a `request: Request` parameter): + +```python +@router.post("/token") +@limiter.limit("10/minute") +async def signin(request: Request, ...): + ... +``` + +With `RATE_LIMIT_ENABLED=False` the decorators are no-ops, so behavior is +unchanged. Turn it on later by setting `RATE_LIMIT_ENABLED=True` in the target +environment. **In tests, keep it disabled** β€” the shared counter would let +unrelated tests exhaust each other's quota: + +```python +# conftest.py +from app.limiter import limiter +limiter.enabled = False +``` + +If you enable it in a specific test, use a **unique client key per run** (the +Redis-backed counter persists across runs within the window) to avoid flakiness. + +--- + +## Verify + +After applying the steps you want: + +```bash +pytest -q # or: make test-local +``` + +Watch specifically for: un-`await`ed Redis calls (coroutine warnings), the +`Event loop is closed` error (missing Β§3 fixture), 401s from pre-`type`/pre-`ver` +tokens (Β§4/Β§11), and 403s from unverified legacy users (Β§6b backfill). + +## Rollback + +Every step is self-contained; revert individually via git. The only step with +persistent state is Β§6 β€” restore your database snapshot if you need to undo the +migration and backfill. diff --git a/alembic/versions/eae7f8b6a379_add_user_model_with_basic_fields.py b/alembic/versions/eae7f8b6a379_add_user_model_with_basic_fields.py index 9169a00..951f9e9 100644 --- a/alembic/versions/eae7f8b6a379_add_user_model_with_basic_fields.py +++ b/alembic/versions/eae7f8b6a379_add_user_model_with_basic_fields.py @@ -25,7 +25,12 @@ def upgrade() -> None: "users", sa.Column("email", sa.String(), nullable=False), sa.Column("password_hash", sa.String(), nullable=False), - sa.Column("is_verified", sa.Boolean(), nullable=False), + sa.Column( + "is_verified", + sa.Boolean(), + server_default=sa.false(), + nullable=False, + ), sa.Column("id", sa.UUID(), nullable=False), sa.Column( "date_created", diff --git a/app/database.py b/app/database.py index 80be0eb..fa6c88b 100644 --- a/app/database.py +++ b/app/database.py @@ -1,18 +1,8 @@ -from sqlalchemy import create_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from sqlalchemy.orm import sessionmaker -from app.settings import Settings +from app.settings import settings -settings = Settings() # type: ignore - -# Add Support for Both ASYNC and SYNC Database URLs -# With Async being the center of focus DATABASE_URL = settings.DATABASE_URL -DATABASE_SYNC_URL = settings.DATABASE_URL.replace("+asyncpg", "") - -sync_engine = create_engine(DATABASE_SYNC_URL) -SyncSessionLocal = sessionmaker(bind=sync_engine) async_engine = create_async_engine(DATABASE_URL) AsyncSessionLocal = async_sessionmaker(bind=async_engine, expire_on_commit=False) diff --git a/app/dependencies.py b/app/dependencies.py index c56c65c..6a7558b 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -27,7 +27,7 @@ async def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) # check if the user token has been added to the list of logged out tokens - if redis_manager.get_json_item(token): + if await redis_manager.get_json_item(token): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or Expired credentials", @@ -40,7 +40,14 @@ async def get_current_user( username = payload.get("sub") if username is None: raise credentials_exception - token_data = auth_schemas.TokenData(username=username) + # Reject refresh tokens (or any non-access token) on authenticated routes. + if payload.get("type") != "access": + raise credentials_exception + # Reject tokens issued before the user's last global logout. + version = await redis_manager.get_int(auth_services.token_version_key(username)) + if payload.get("ver", 0) != version: + raise credentials_exception + token_data = auth_schemas.TokenData(email=username) except InvalidTokenError as err: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -48,7 +55,7 @@ async def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) user: UserDB | None = await auth_services.get_user( - email=token_data.username, session=db + email=token_data.email, session=db ) if not user: raise credentials_exception diff --git a/app/limiter.py b/app/limiter.py new file mode 100644 index 0000000..7970f02 --- /dev/null +++ b/app/limiter.py @@ -0,0 +1,14 @@ +from slowapi import Limiter +from slowapi.util import get_remote_address + +from app.settings import settings + +# Shared rate limiter, keyed by client IP. Backed by Redis so limits are +# enforced consistently across every worker/replica (an in-memory store would +# give each process its own counter and reset on restart). Import this in +# routers to decorate endpoints with `@limiter.limit(...)`, and register it on +# the app in main.py. +limiter = Limiter( + key_func=get_remote_address, + storage_uri=f"redis://{settings.REDIS_HOST}:{settings.REDIS_PORT}", +) diff --git a/app/logger.py b/app/logger.py index 787f15c..10fbee9 100644 --- a/app/logger.py +++ b/app/logger.py @@ -25,7 +25,7 @@ def format(self, record): file_handler = TimedRotatingFileHandler( LOG_FILE, when="midnight", - interval=1 // 86400, + interval=1, backupCount=7, ) diff --git a/app/mailer.py b/app/mailer.py index 2e30dcc..e4ba902 100644 --- a/app/mailer.py +++ b/app/mailer.py @@ -6,9 +6,7 @@ from fastapi_mail.errors import ConnectionErrors from app.logger import logger -from app.settings import Settings - -settings = Settings() # type: ignore +from app.settings import settings conf = ConnectionConfig( MAIL_USERNAME=settings.MAIL_USERNAME, @@ -20,7 +18,7 @@ MAIL_STARTTLS=False, MAIL_SSL_TLS=True, USE_CREDENTIALS=True, - VALIDATE_CERTS=False, + VALIDATE_CERTS=True, TEMPLATE_FOLDER=Path(__file__).parent / "templates/", ) @@ -30,13 +28,13 @@ async def send_mail( receipients: List[str], payload: dict, template: str, - attachments: List[UploadFile] = [], + attachments: List[UploadFile] | None = None, ): message = MessageSchema( subject=subject, recipients=receipients, # type: ignore subtype=MessageType.html, - attachments=attachments, # type: ignore + attachments=attachments or [], # type: ignore template_body=payload, ) diff --git a/app/main.py b/app/main.py index 828d044..67b588e 100644 --- a/app/main.py +++ b/app/main.py @@ -6,21 +6,48 @@ from fastapi.middleware.gzip import GZipMiddleware from fastapi.requests import Request from fastapi.responses import JSONResponse -from slowapi import Limiter -from slowapi.util import get_remote_address +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from sqlalchemy import text from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.trustedhost import TrustedHostMiddleware from app.api_router import api +from app.database import AsyncSessionLocal +from app.limiter import limiter from app.logger import logger from app.middlewares import AllowAuthorizedDocAccess, log_request_middleware -from app.settings import Settings - -settings = Settings() # type: ignore +from app.redis_manager import redis_manager +from app.routers.health import router as health_router +from app.settings import settings + + +async def check_connectivity() -> None: + """Fail fast (in production) if the DB or Redis is unreachable at startup.""" + problems = [] + try: + async with AsyncSessionLocal() as session: + await session.execute(text("SELECT 1")) + except Exception as exc: # noqa: BLE001 + problems.append(f"database ({exc})") + try: + await redis_manager.redis_client.ping() + except Exception as exc: # noqa: BLE001 + problems.append(f"redis ({exc})") + + if problems: + message = "Startup connectivity check failed: " + ", ".join(problems) + logger.error(message) + # In DEBUG (local/dev) log and continue; in production refuse to start. + if not settings.DEBUG: + raise RuntimeError(message) + else: + logger.info("Startup connectivity check passed (database, redis)") @asynccontextmanager async def lifespan(app: FastAPI): + await check_connectivity() yield @@ -58,10 +85,12 @@ def initiate_app(): app.add_middleware(AllowAuthorizedDocAccess) app.add_middleware(BaseHTTPMiddleware, dispatch=log_request_middleware) - limiter = Limiter(key_func=get_remote_address) + # Enforce the rate limits declared via @limiter.limit(...) on the routes. app.state.limiter = limiter + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore app.include_router(api) + app.include_router(health_router) return app diff --git a/app/middlewares.py b/app/middlewares.py index a3b76d6..356716f 100644 --- a/app/middlewares.py +++ b/app/middlewares.py @@ -5,6 +5,7 @@ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from app.logger import logger +from app.settings import settings async def log_request_middleware(request: Request, call_next): @@ -26,16 +27,27 @@ class AllowAuthorizedDocAccess(BaseHTTPMiddleware): allowed_ips = [ "127.0.0.1", # allows Viewing Docs in Local Development Environment ] + # The interactive docs, ReDoc, and the raw OpenAPI schema all expose the + # API surface, so all three must be gated - not just "/docs". + protected_paths = ("/docs", "/redoc", "/openapi.json") async def dispatch( self, request: Request, call_next: RequestResponseEndpoint ) -> Response: - client_ip = request.client.host # type: ignore - - if "/docs" in request.url.path: - if client_ip not in self.allowed_ips: + if request.url.path in self.protected_paths: + client_ip = request.client.host if request.client else None + # Docs are exposed when DEBUG is enabled OR the caller's IP is + # whitelisted - so whitelisted IPs keep access even in production. + docs_allowed = settings.DEBUG or client_ip in self.allowed_ips + if not docs_allowed: + # Respond as if the route does not exist so unauthorized + # callers cannot even confirm the docs are hosted here. return JSONResponse( - status_code=500, content="Application Has Crashed 😭" + status_code=404, + content={ + "detail": "This route does not exist", + "path": request.url.path, + }, ) response = await call_next(request) diff --git a/app/models/_base.py b/app/models/_base.py index e2cd817..14c0d4e 100644 --- a/app/models/_base.py +++ b/app/models/_base.py @@ -12,7 +12,7 @@ class AbstractBase(DeclarativeBase): """ __abstract__ = True - id: Mapped[UUID] = mapped_column( + id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, unique=True, diff --git a/app/models/auth.py b/app/models/auth.py index b37797f..5cabbf1 100644 --- a/app/models/auth.py +++ b/app/models/auth.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from sqlalchemy import Boolean, String +from sqlalchemy import Boolean, String, false from sqlalchemy.orm import Mapped, mapped_column from app.models._base import AbstractBase @@ -14,4 +14,6 @@ class User(AbstractBase): email: Mapped[str] = mapped_column(String, unique=True, index=True) password_hash: Mapped[str] - is_verified: Mapped[bool] = mapped_column(Boolean, default=False) + is_verified: Mapped[bool] = mapped_column( + Boolean, default=False, server_default=false(), nullable=False + ) diff --git a/app/models/tests/factories.py b/app/models/tests/factories.py index ce3453c..50af43b 100644 --- a/app/models/tests/factories.py +++ b/app/models/tests/factories.py @@ -16,3 +16,4 @@ class Meta: # type: ignore email = factory.faker.Faker("email") password_hash = get_password_hash("password") + is_verified = True diff --git a/app/redis_manager.py b/app/redis_manager.py index 8bddd93..12597d7 100644 --- a/app/redis_manager.py +++ b/app/redis_manager.py @@ -1,11 +1,9 @@ import json from typing import Any, cast -import redis +import redis.asyncio as redis -from app.settings import Settings - -settings = Settings() # type: ignore +from app.settings import settings class RedisManager: @@ -16,12 +14,16 @@ def __init__(self): decode_responses=True, ) - def cache_json_item(self, key: str, value: dict[str, Any], ttl: int = 3600) -> None: + async def cache_json_item( + self, key: str, value: dict[str, Any], ttl: int = 3600 + ) -> None: value_as_string = json.dumps(value) - self.redis_client.set(name=key, value=value_as_string, ex=ttl) + await self.redis_client.set(name=key, value=value_as_string, ex=ttl) - def get_json_item(self, key: str, default: None = None) -> dict[str, Any] | None: - value = self.redis_client.get(name=key) + async def get_json_item( + self, key: str, default: None = None + ) -> dict[str, Any] | None: + value = await self.redis_client.get(name=key) if value is None: return default @@ -29,8 +31,20 @@ def get_json_item(self, key: str, default: None = None) -> dict[str, Any] | None value_decoded = json.loads(cast(str, value)) return value_decoded - def delete_key(self, key: str) -> None: - self.redis_client.delete(key) + async def delete_key(self, key: str) -> None: + await self.redis_client.delete(key) + + async def get_int(self, key: str) -> int: + value = await self.redis_client.get(name=key) + return int(value) if value is not None else 0 + + async def increment(self, key: str, ttl: int | None = None) -> int: + # Atomic INCR. When ttl is given, the expiry is set on first increment + # so the counter decays as a fixed window (used for failure lockouts). + value = await self.redis_client.incr(key) + if ttl is not None and value == 1: + await self.redis_client.expire(key, ttl) + return value redis_manager = RedisManager() diff --git a/app/routers/auth.py b/app/routers/auth.py index 42db779..1697aa1 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -1,18 +1,16 @@ from typing import Annotated -from fastapi import BackgroundTasks, Body, Depends, HTTPException, status +from fastapi import BackgroundTasks, Body, Depends, HTTPException, Request, status from fastapi.routing import APIRouter from fastapi.security import OAuth2PasswordRequestForm from pydantic import EmailStr, ValidationError from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_current_user, get_db +from app.limiter import limiter from app.models import User as UserDB from app.schemas import auth as auth_schemas from app.services import auth as auth_services -from app.settings import Settings - -settings = Settings() # type: ignore router = APIRouter(prefix="/auth", tags=["Authentication"]) @@ -24,7 +22,9 @@ @router.post("/signup", response_model=auth_schemas.UserModel) +@limiter.limit("5/minute") async def signup( + request: Request, db: DBDep, bg_task: BackgroundTasks, # needed to send verification/welcome email payload: auth_schemas.UserSignUpData, @@ -33,7 +33,9 @@ async def signup( @router.post("/activation") +@limiter.limit("10/minute") async def activate_user( + request: Request, db: DBDep, bg_task: BackgroundTasks, # needed to send verification/welcome email payload: auth_schemas.UserVerificationModel, @@ -42,7 +44,9 @@ async def activate_user( @router.post("/resend_activation") +@limiter.limit("3/minute") async def resend_activation_code( + request: Request, db: DBDep, email: EmailBody, bg_task: BackgroundTasks, # needed to send verification/welcome email @@ -51,7 +55,9 @@ async def resend_activation_code( @router.post("/initiate_password_reset") +@limiter.limit("3/minute") async def initiate_password_reset( + request: Request, db: DBDep, email: EmailBody, background_task: BackgroundTasks, @@ -60,7 +66,9 @@ async def initiate_password_reset( @router.post("/reset_password") +@limiter.limit("5/minute") async def reset_password( + request: Request, db: DBDep, reset_data: auth_schemas.PasswordResetData, ): @@ -68,7 +76,12 @@ async def reset_password( @router.post("/token", response_model=auth_schemas.Token) -async def signin(db: DBDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]): +@limiter.limit("10/minute") +async def signin( + request: Request, + db: DBDep, + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], +): try: login_data = auth_schemas.UserSignInData.model_validate( {"email": form_data.username, "password": form_data.password} @@ -83,13 +96,18 @@ async def signin(db: DBDep, form_data: Annotated[OAuth2PasswordRequestForm, Depe @router.post("/logout") async def logout( - _: CurrentUserDep, token: Annotated[str, Depends(auth_services.oauth2_scheme)] + user: CurrentUserDep, + token: Annotated[str, Depends(auth_services.oauth2_scheme)], + payload: auth_schemas.LogoutData | None = None, ): - return await auth_services.logout(token) + refresh = payload.refresh_token if payload else None + return await auth_services.logout(token, refresh, user.email) @router.post("/refresh_token") +@limiter.limit("10/minute") async def get_refresh_token( + request: Request, db: DBDep, token_data: auth_schemas.RefreshTokenModel, ): diff --git a/app/routers/health.py b/app/routers/health.py new file mode 100644 index 0000000..3d6a096 --- /dev/null +++ b/app/routers/health.py @@ -0,0 +1,31 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_db +from app.redis_manager import redis_manager + +router = APIRouter(tags=["Health"]) + + +@router.get("/health") +async def health(db: Annotated[AsyncSession, Depends(get_db)]): + """Liveness/readiness probe: 200 only when the DB and Redis are reachable.""" + checks = {"database": "ok", "redis": "ok"} + + try: + await db.execute(text("SELECT 1")) + except Exception: + checks["database"] = "error" + + try: + await redis_manager.redis_client.ping() + except Exception: + checks["redis"] = "error" + + if any(status != "ok" for status in checks.values()): + raise HTTPException(status_code=503, detail=checks) + + return {"status": "ok", "checks": checks} diff --git a/app/routers/tests/conftest.py b/app/routers/tests/conftest.py index 851e288..5b50ce2 100644 --- a/app/routers/tests/conftest.py +++ b/app/routers/tests/conftest.py @@ -1,10 +1,10 @@ -import faker import pytest +from faker import Faker from httpx import AsyncClient from app.models.auth import User as UserDB -faker = faker.Faker() +faker = Faker() @pytest.fixture diff --git a/app/routers/tests/test_auth.py b/app/routers/tests/test_auth.py index a569ea2..4122b73 100644 --- a/app/routers/tests/test_auth.py +++ b/app/routers/tests/test_auth.py @@ -54,7 +54,9 @@ async def test_signup_fails( async def test_activate_user(client: AsyncClient, user: UserDB): - redis_manager.cache_json_item(f"activation-code-{user.email}", {"code": "000000"}) + await redis_manager.cache_json_item( + f"activation-code-{user.email}", {"code": "000000"} + ) response: Response = await client.post( "/v1/auth/activation", json={"code": "000000", "email": user.email} ) @@ -64,7 +66,9 @@ async def test_activate_user(client: AsyncClient, user: UserDB): async def test_resend_activation_code(client: AsyncClient, user: UserDB): - redis_manager.cache_json_item(f"activation-code-{user.email}", {"code": "000000"}) + await redis_manager.cache_json_item( + f"activation-code-{user.email}", {"code": "000000"} + ) response: Response = await client.post( "/v1/auth/resend_activation", json={"email": user.email} ) @@ -84,7 +88,7 @@ async def test_initiate_password_reset(client: AsyncClient, signup_data: dict): async def test_reset_password(client: AsyncClient, user: UserDB): # Seed the value to be validated against - redis_manager.cache_json_item(f"reset-code-{user.email}", {"code": "000000"}) + await redis_manager.cache_json_item(f"reset-code-{user.email}", {"code": "000000"}) data = {"new_password": "password", "email": user.email, "code": "000000"} response: Response = await client.post("/v1/auth/reset_password", json=data) assert response.status_code == 200 @@ -93,7 +97,7 @@ async def test_reset_password(client: AsyncClient, user: UserDB): async def test_reset_password_fails(client: AsyncClient, user: UserDB): # Seed the value to be validated against - redis_manager.cache_json_item(f"reset-code-{user.email}", {"code": "0000"}) + await redis_manager.cache_json_item(f"reset-code-{user.email}", {"code": "0000"}) data = {"new_password": "password", "email": user.email, "code": "1111"} response: Response = await client.post("/v1/auth/reset_password", json=data) assert response.status_code == 400 @@ -165,6 +169,48 @@ async def test_logout(client: AsyncClient, auth_header: dict[str, str]): assert failed_response.status_code == 401 +async def test_logout_with_refresh_token_revokes_it(client: AsyncClient, user: UserDB): + tokens = ( + await client.post( + "/v1/auth/token", data={"username": user.email, "password": "password"} + ) + ).json() + access, refresh = tokens["access_token"], tokens["refresh_token"] + + logout = await client.post( + "/v1/auth/logout", + headers={"Authorization": f"Bearer {access}"}, + json={"refresh_token": refresh}, + ) + assert logout.status_code == 200 + + # The refresh token was blacklisted at logout, so it can no longer refresh. + refreshed = await client.post( + "/v1/auth/refresh_token", json={"refresh_token": refresh} + ) + assert refreshed.status_code == 401 + + +async def test_logout_invalidates_all_user_tokens(client: AsyncClient, user: UserDB): + login = {"username": user.email, "password": "password"} + first = (await client.post("/v1/auth/token", data=login)).json()["access_token"] + second = (await client.post("/v1/auth/token", data=login)).json()["access_token"] + assert first != second # jti makes each issued token unique + + header_second = {"Authorization": f"Bearer {second}"} + assert (await client.get("/v1/auth/me", headers=header_second)).status_code == 200 + + # Log out using the FIRST token; this bumps the user's token version. + logout = await client.post( + "/v1/auth/logout", headers={"Authorization": f"Bearer {first}"} + ) + assert logout.status_code == 200 + + # The SECOND token was never blacklisted, but the version bump invalidates + # every token issued before the logout - a global "log out everywhere". + assert (await client.get("/v1/auth/me", headers=header_second)).status_code == 401 + + async def test_get_user_detail( client: AsyncClient, auth_header: dict[str, str], diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index e69de29..203edbf 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class PaginatedResponse[T](BaseModel): + total_results: int + current_page: int + total_pages: int + per_page: int + results: list[T] diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 54e140b..3db2351 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -14,13 +14,18 @@ class Token(BaseModel): class TokenData(BaseModel): - username: str + email: str class RefreshTokenModel(BaseModel): refresh_token: Annotated[str, Field(min_length=32)] +class LogoutData(BaseModel): + # Optional: supply the refresh token on logout so it is blacklisted too. + refresh_token: Annotated[str, Field(min_length=32)] | None = None + + class UserSignUpData(BaseModel): password: Annotated[str, Field(min_length=8, max_length=50)] email: Annotated[EmailStr, Field(max_length=254), AfterValidator(str.lower)] @@ -34,25 +39,26 @@ class UserVerificationModel(BaseModel): class PasswordResetData(BaseModel): code: str email: Annotated[EmailStr, Field(max_length=254), AfterValidator(str.lower)] - new_password: Annotated[str, Field(min_length=8)] + new_password: Annotated[str, Field(min_length=8, max_length=50)] class UserSignInData(BaseModel): - email: Annotated[EmailStr, Field(max_length=100), AfterValidator(str.lower)] - password: Annotated[str, Field(min_length=8)] + email: Annotated[EmailStr, Field(max_length=254), AfterValidator(str.lower)] + password: Annotated[str, Field(min_length=8, max_length=50)] class UserModel(BaseModel): id: UUID email: EmailStr + is_verified: bool date_created: datetime date_updated: datetime class UpdateUserModel(BaseModel): - old_password: Annotated[str | None, Field(min_length=8)] = None - new_password: Annotated[str | None, Field(min_length=8)] = None + old_password: Annotated[str | None, Field(min_length=8, max_length=50)] = None + new_password: Annotated[str | None, Field(min_length=8, max_length=50)] = None @model_validator(mode="after") def check_password_dependency(self) -> "UpdateUserModel": diff --git a/app/services/auth.py b/app/services/auth.py index f9e86f9..76a42ef 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -1,11 +1,12 @@ +import secrets from datetime import UTC, datetime, timedelta -from random import choices from typing import Literal, cast import bcrypt import jwt from fastapi import BackgroundTasks, HTTPException, status from fastapi.security import OAuth2PasswordBearer +from jwt.exceptions import InvalidTokenError from pydantic.networks import EmailStr from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -15,18 +16,40 @@ from app.models import User as UserDB from app.redis_manager import redis_manager from app.schemas import auth as auth_schema -from app.settings import Settings - -settings = Settings() # type: ignore +from app.settings import settings JWT_SECRET = settings.JWT_SECRET JWT_ALGORITHM = settings.JWT_ALGORITHM -ACCESS_TOKEN_LIFESPAN = timedelta(days=14) -REFRESH_TOKEN_LIFESPAN = timedelta(days=28) +ACCESS_TOKEN_LIFESPAN = timedelta(minutes=settings.ACCESS_TOKEN_LIFESPAN_MIN) +REFRESH_TOKEN_LIFESPAN = timedelta(days=settings.REFRESH_TOKEN_LIFESPAN_DAYS) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="v1/auth/token") +# Per-user token version. Bumped on logout to invalidate every token issued +# before it (global logout). Failure counters gate code brute-forcing. +MAX_CODE_ATTEMPTS = 5 +CODE_LOCKOUT_SECONDS = 15 * 60 + + +def token_version_key(email: str) -> str: + return f"token-version-{email}" + + +async def guard_code_attempts(scope: str, email: str) -> str: + """ + Throttle brute-forcing of the 6-digit codes: after MAX_CODE_ATTEMPTS wrong + submissions for (scope, email), lock the account out for CODE_LOCKOUT_SECONDS. + Returns the counter key so the caller can register a failure or clear it. + """ + key = f"failed-{scope}-{email}" + if await redis_manager.get_int(key) >= MAX_CODE_ATTEMPTS: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many attempts. Please try again later.", + ) + return key + def verify_password(plain_password: str, hashed_password: str): return bcrypt.checkpw( @@ -43,8 +66,9 @@ def get_password_hash(password: str): def generate_random_code(n: int = 4) -> str: - code = choices("0123456789", k=n) - return "".join(code) + # secrets.choice is cryptographically secure - important for OTP / reset + # / activation codes that gate account access. + return "".join(secrets.choice("0123456789") for _ in range(n)) async def get_user(email: EmailStr, session: AsyncSession) -> UserDB | None: @@ -73,65 +97,6 @@ async def create_user( return new_user -async def verify_user( - verification_data: auth_schema.UserVerificationModel, - background_task: BackgroundTasks, - session: AsyncSession, -): - """ - Verify the User Against a Token generated and stored in the Sign up Process for the User Credentials - """ - data = redis_manager.get_json_item(f"verification-code-{verification_data.email}") - if not isinstance(data, dict): - logger.info(f"Corrupt Log Data: {data}") - raise HTTPException(status_code=404, detail="Invalid Verification Code") - - if data.get("code") != verification_data.code: - logger.info("Invalid Verification Code") - raise HTTPException(status_code=400, detail="Invalid Verification Code") - - user = await get_user(verification_data.email, session) - if not user: - logger.info("User Not Found") - raise HTTPException(status_code=404, detail="Invalid Verification Code") - - background_task.add_task( - send_mail, - subject="Welcome to {Project Name}", - receipients=[user.email], - payload={"name": user.email.split("@")[0]}, - template="auth/welcome.html", - ) - return await update_user( - verification_data.email, auth_schema.UpdateUserModel(), session - ) - - -async def resend_verification_code( - email: str, session: AsyncSession, background_task: BackgroundTasks -): - user = await get_user(email, session) - if not user: - logger.warning(f"Email Verification Requested for invalid user {email}") - return {"detail": "Verification code resent"} - - code = generate_random_code(4) - redis_manager.cache_json_item( - key=f"verification-code-{email}", value={"code": code} - ) - - first_name = cast(UserDB, user).email.split("@")[0] - background_task.add_task( - send_mail, - subject="OTP Verification", - receipients=[user.email], - payload={"name": first_name, "otp": code}, - template="auth/verification.html", - ) - - return {"detail": "Verification code resent"} - - async def initiate_password_reset( email: str, session: AsyncSession, background_task: BackgroundTasks ): @@ -140,7 +105,9 @@ async def initiate_password_reset( return {"detail": "Password Reset Code Sent"} code = generate_random_code(6) - redis_manager.cache_json_item(f"reset-code-{email}", {"code": code}, ttl=60 * 30) + await redis_manager.cache_json_item( + f"reset-code-{email}", {"code": code}, ttl=60 * 30 + ) background_task.add_task( send_mail, @@ -156,8 +123,10 @@ async def initiate_password_reset( async def reset_password( reset_data: auth_schema.PasswordResetData, session: AsyncSession ): - data = redis_manager.get_json_item(f"reset-code-{reset_data.email}") + attempt_key = await guard_code_attempts("reset", reset_data.email) + data = await redis_manager.get_json_item(f"reset-code-{reset_data.email}") if not data or data.get("code") != reset_data.code: + await redis_manager.increment(attempt_key, ttl=CODE_LOCKOUT_SECONDS) raise HTTPException(status_code=400, detail="Invalid Reset Code") user = await get_user(reset_data.email, session) @@ -175,6 +144,10 @@ async def reset_password( await session.execute(stmt) await session.commit() + # Consume the code and clear the failure counter on success. + await redis_manager.delete_key(f"reset-code-{reset_data.email}") + await redis_manager.delete_key(attempt_key) + return {"detail": "Password Reset Successfully"} @@ -214,29 +187,23 @@ async def update_user( def create_access_token( - data: dict[str, str | datetime], expires_delta: timedelta | None = None + data: dict[str, str | int | datetime], expires_delta: timedelta | None = None ): to_encode = data.copy() - if expires_delta: - expire = datetime.now(UTC) + expires_delta - else: - expire = datetime.now(UTC) + timedelta(minutes=15) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM) - return encoded_jwt + expire = datetime.now(UTC) + (expires_delta or ACCESS_TOKEN_LIFESPAN) + # jti makes every token unique (so distinct logins never collide); type and + # the caller-supplied ver drive access/refresh and global-logout checks. + to_encode.update({"exp": expire, "type": "access", "jti": secrets.token_hex(16)}) + return jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM) def create_refresh_token( - data: dict[str, str | datetime], expires_delta: timedelta | None = None + data: dict[str, str | int | datetime], expires_delta: timedelta | None = None ): to_encode = data.copy() - if expires_delta: - expire = datetime.now(UTC) + expires_delta - else: - expire = datetime.now(UTC) + timedelta(days=7) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM) - return encoded_jwt + expire = datetime.now(UTC) + (expires_delta or REFRESH_TOKEN_LIFESPAN) + to_encode.update({"exp": expire, "type": "refresh", "jti": secrets.token_hex(16)}) + return jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM) async def authenticate_user( @@ -257,9 +224,8 @@ async def signup_user( ): user = await create_user(data, session) - # TODO: Send Activation Code Email here too code = generate_random_code(6) - redis_manager.cache_json_item( + await redis_manager.cache_json_item( f"activation-code-{data.email}", {"code": code}, ttl=60 * 30 ) @@ -282,7 +248,7 @@ async def resend_activation_code( return {"detail": "Activation Code Sent"} code = generate_random_code(6) - redis_manager.cache_json_item( + await redis_manager.cache_json_item( f"activation-code-{email}", {"code": code}, ttl=60 * 30 ) @@ -301,16 +267,26 @@ async def activate_user( session: AsyncSession, bg_task: BackgroundTasks, ): - data = redis_manager.get_json_item(f"activation-code-{verification_data.email}") + attempt_key = await guard_code_attempts("activation", verification_data.email) + data = await redis_manager.get_json_item( + f"activation-code-{verification_data.email}" + ) if not data or data.get("code") != verification_data.code: - raise HTTPException(status_code=400, detail="Invalid Reset Code") + await redis_manager.increment(attempt_key, ttl=CODE_LOCKOUT_SECONDS) + raise HTTPException(status_code=400, detail="Invalid Activation Code") user = await get_user(verification_data.email, session) if not user: - raise HTTPException(status_code=400, detail="Invalid Reset Code") + raise HTTPException(status_code=400, detail="Invalid Activation Code") - # TODO: Perform the actual user verification here + user.is_verified = True + session.add(user) + await session.commit() + + # Consume the code and clear the failure counter on success. + await redis_manager.delete_key(f"activation-code-{verification_data.email}") + await redis_manager.delete_key(attempt_key) bg_task.add_task( send_mail, @@ -333,25 +309,56 @@ async def signin_user(data: auth_schema.UserSignInData, session: AsyncSession): detail="Incorrect email or password", headers={"WWW-Authenticate": "Bearer"}, ) + if not user.is_verified: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Email not verified", + ) + version = await redis_manager.get_int(token_version_key(data.email)) access_token = create_access_token( - data={"sub": data.email}, expires_delta=ACCESS_TOKEN_LIFESPAN + data={"sub": data.email, "ver": version}, expires_delta=ACCESS_TOKEN_LIFESPAN ) refresh_token = create_refresh_token( - data={"sub": data.email}, expires_delta=REFRESH_TOKEN_LIFESPAN + data={"sub": data.email, "ver": version}, expires_delta=REFRESH_TOKEN_LIFESPAN ) return auth_schema.Token( token_type="Bearer", access_token=access_token, refresh_token=refresh_token, - access_expires_at=datetime.now() + ACCESS_TOKEN_LIFESPAN, - refresh_expires_at=datetime.now() + REFRESH_TOKEN_LIFESPAN, + access_expires_at=datetime.now(UTC) + ACCESS_TOKEN_LIFESPAN, + refresh_expires_at=datetime.now(UTC) + REFRESH_TOKEN_LIFESPAN, ) +async def blacklist_token(token: str) -> None: + """ + Blacklist a token for the remainder of its lifetime so it cannot be reused. + A fixed TTL would either expire the entry early (re-enabling the token) or + linger long after the token itself has expired, so the TTL is derived from + the token's own `exp` claim. + """ + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + except InvalidTokenError: + # Already invalid; get_current_user / refresh_token will reject it anyway. + return + + exp = payload.get("exp") + ttl = int(exp - datetime.now(UTC).timestamp()) if exp else 0 + if ttl > 0: + await redis_manager.cache_json_item( + token, {"timestamp": str(datetime.now(UTC))}, ttl=ttl + ) + + async def refresh_token( token_data: auth_schema.RefreshTokenModel, session: AsyncSession ): + # Reject tokens blacklisted at logout or by a previous rotation. + if await redis_manager.get_json_item(token_data.refresh_token): + raise HTTPException(status_code=401, detail="Invalid Refresh Token") + try: payload = jwt.decode( token_data.refresh_token, JWT_SECRET, algorithms=[JWT_ALGORITHM] @@ -363,24 +370,54 @@ async def refresh_token( if not payload: raise HTTPException(status_code=401, detail="Invalid Refresh Token") + # Reject access tokens (or any non-refresh token) on the refresh endpoint. + if payload.get("type") != "refresh": + raise HTTPException(status_code=401, detail="Invalid Refresh Token") + email = payload.get("sub") + if not isinstance(email, str): + raise HTTPException(status_code=401, detail="Invalid Refresh Token") + user = await get_user(email, session) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Refresh Token" ) - new_access_token = create_access_token(data={"sub": email}) + # Reject tokens issued before the user's last global logout. + version = await redis_manager.get_int(token_version_key(email)) + if payload.get("ver", 0) != version: + raise HTTPException(status_code=401, detail="Invalid Refresh Token") + + # Rotate: invalidate the presented refresh token and issue a fresh pair so a + # leaked refresh token has a single, one-time use. + await blacklist_token(token_data.refresh_token) + new_access_token = create_access_token( + data={"sub": email, "ver": version}, expires_delta=ACCESS_TOKEN_LIFESPAN + ) + new_refresh_token = create_refresh_token( + data={"sub": email, "ver": version}, expires_delta=REFRESH_TOKEN_LIFESPAN + ) return auth_schema.Token( token_type="Bearer", access_token=new_access_token, - refresh_token=token_data.refresh_token, - access_expires_at=datetime.now() + ACCESS_TOKEN_LIFESPAN, - # TODO: Implement logic to correctly calculate expiry data of refresh token + refresh_token=new_refresh_token, + access_expires_at=datetime.now(UTC) + ACCESS_TOKEN_LIFESPAN, + refresh_expires_at=datetime.now(UTC) + REFRESH_TOKEN_LIFESPAN, ) -async def logout(token: str): - # Implement logic to blacklist token - redis_manager.cache_json_item(token, {"timestamp": str(datetime.now())}) +async def logout( + access_token: str, refresh_token: str | None = None, email: str | None = None +): + # Blacklist the access token, and the refresh token too when the client + # supplies it - otherwise the refresh token would outlive the logout and + # could still mint new access tokens. + await blacklist_token(access_token) + if refresh_token: + await blacklist_token(refresh_token) + # Bump the user's token version so EVERY token issued before now (across all + # devices) is invalidated, not just the pair presented here. + if email: + await redis_manager.increment(token_version_key(email)) return {"detail": "User Logged Out Successfully"} diff --git a/app/services/tests/test_auth.py b/app/services/tests/test_auth.py index 4f752af..2a9e56b 100644 --- a/app/services/tests/test_auth.py +++ b/app/services/tests/test_auth.py @@ -47,63 +47,6 @@ async def test_create_user_fails( assert "Email already registered" in str(err.value) -async def test_verify_user_succeeds(user: UserDB, session: AsyncSession): - verification_data = auth_schemas.UserVerificationModel( - email=user.email, code="000000" - ) - redis_manager.cache_json_item( - f"verification-code-{verification_data.email}", {"code": "000000"} - ) - user = await auth_services.verify_user( - verification_data, - BackgroundTasks(tasks=[]), - session, - ) - # test that the flag for verified user is activated - - -@pytest.mark.parametrize( - "verification_data,cached_code", - [ - ({"email": faker.email(), "code": "000000"}, {"code": "000000"}), - ({"email": faker.email(), "code": "000000"}, {"code": "000001"}), - ({"email": faker.email(), "code": "000000"}, None), - ], -) -async def test_verify_user_fails( - user: UserDB, - session: AsyncSession, - verification_data: dict[str, str], - cached_code: dict[str, str] | None, -): - data = auth_schemas.UserVerificationModel(**verification_data) - redis_manager.cache_json_item( - f"verification-code-{data.email}", cached_code # type: ignore - ) - with pytest.raises(HTTPException) as err: - await auth_services.verify_user( - data, - BackgroundTasks(tasks=[]), - session, - ) - - assert err.value.detail == "Invalid Verification Code" - - -async def test_resend_verification_code_for_user(user: UserDB, session: AsyncSession): - result = await auth_services.resend_verification_code( - user.email, session, BackgroundTasks(tasks=[]) - ) - assert result == {"detail": "Verification code resent"} - - -async def test_resend_verification_code_for_none_user(session: AsyncSession): - result = await auth_services.resend_verification_code( - faker.email(), session, BackgroundTasks(tasks=[]) - ) - assert result == {"detail": "Verification code resent"} - - async def test_initiate_password_reset_for_user(user: UserDB, session: AsyncSession): result = await auth_services.initiate_password_reset( user.email, session, BackgroundTasks(tasks=[]) @@ -119,7 +62,7 @@ async def test_initiate_password_reset_for_none_user(session: AsyncSession): async def test_reset_password_for_user(user: UserDB, session: AsyncSession): - redis_manager.cache_json_item(f"reset-code-{user.email}", {"code": "000000"}) + await redis_manager.cache_json_item(f"reset-code-{user.email}", {"code": "000000"}) result = await auth_services.reset_password( auth_schemas.PasswordResetData( code="000000", email=user.email, new_password="newpassword" @@ -138,7 +81,7 @@ async def test_reset_password_for_user(user: UserDB, session: AsyncSession): ) async def test_reset_password_fails(user: UserDB, session: AsyncSession, code: str): none_existence_email = faker.email() - redis_manager.cache_json_item( + await redis_manager.cache_json_item( f"reset-code-{none_existence_email}", {"code": "000000"} ) with pytest.raises(HTTPException) as err: @@ -232,14 +175,16 @@ async def test_signup_user(session: AsyncSession): @pytest.mark.parametrize( "code,response_message", [ - ("111111", "Invalid Reset Code"), + ("111111", "Invalid Activation Code"), ("000000", "Email Activation Successful"), ], ) async def test_activate_user( user: UserDB, session: AsyncSession, code: str, response_message: str ): - redis_manager.cache_json_item(f"activation-code-{user.email}", {"code": "000000"}) + await redis_manager.cache_json_item( + f"activation-code-{user.email}", {"code": "000000"} + ) if code != "000000": with pytest.raises(HTTPException) as err: result = await auth_services.activate_user( @@ -303,6 +248,21 @@ async def test_signin_user_for_none_user(session: AsyncSession): assert err.value.detail == "Incorrect email or password" +async def test_signin_unverified_user_forbidden(session: AsyncSession): + # create_user leaves is_verified False, so sign-in must be rejected. + email = faker.email() + await auth_services.create_user( + auth_schemas.UserSignUpData(email=email, password="password"), session + ) + with pytest.raises(HTTPException) as err: + await auth_services.signin_user( + auth_schemas.UserSignInData(email=email, password="password"), session + ) + + assert err.value.status_code == 403 + assert err.value.detail == "Email not verified" + + async def test_refresh_token(user: UserDB, session: AsyncSession): initial_refresh_token = auth_services.create_refresh_token({"sub": user.email}) updated_refreshed_token = await auth_services.refresh_token( @@ -337,3 +297,49 @@ async def test_refresh_token_payload_return_none(session: AsyncSession): session, ) assert err.value.detail == "Invalid Refresh Token" + + +async def test_reset_password_locks_out_after_max_attempts( + user: UserDB, session: AsyncSession +): + await redis_manager.cache_json_item(f"reset-code-{user.email}", {"code": "000000"}) + + # MAX_CODE_ATTEMPTS wrong codes each fail with 400 ... + for _ in range(auth_services.MAX_CODE_ATTEMPTS): + with pytest.raises(HTTPException) as err: + await auth_services.reset_password( + auth_schemas.PasswordResetData( + code="999999", email=user.email, new_password="newpassword" + ), + session, + ) + assert err.value.status_code == 400 + + # ... then the account is locked out, even for the CORRECT code. + with pytest.raises(HTTPException) as err: + await auth_services.reset_password( + auth_schemas.PasswordResetData( + code="000000", email=user.email, new_password="newpassword" + ), + session, + ) + assert err.value.status_code == 429 + + +async def test_refresh_token_is_single_use(user: UserDB, session: AsyncSession): + initial_token = auth_services.create_refresh_token( + {"sub": user.email}, auth_services.REFRESH_TOKEN_LIFESPAN + ) + # First use rotates the token and returns a fresh pair. + first = await auth_services.refresh_token( + auth_schemas.RefreshTokenModel(refresh_token=initial_token), session + ) + assert isinstance(first, auth_schemas.Token) + + # Reusing the now-rotated (blacklisted) token must be rejected. + with pytest.raises(HTTPException) as err: + await auth_services.refresh_token( + auth_schemas.RefreshTokenModel(refresh_token=initial_token), session + ) + assert err.value.status_code == 401 + assert err.value.detail == "Invalid Refresh Token" diff --git a/app/settings.py b/app/settings.py index e2cfb1c..e872916 100644 --- a/app/settings.py +++ b/app/settings.py @@ -1,3 +1,4 @@ +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -5,8 +6,12 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env") DATABASE_URL: str # required environment variable + DEBUG: bool = False - JWT_SECRET: str = "" # Optional environment variable but unsafe + ACCESS_TOKEN_LIFESPAN_MIN: int = 15 + REFRESH_TOKEN_LIFESPAN_DAYS: int = 28 + + JWT_SECRET: str = "" # REQUIRED in production (DEBUG=False); see validator below JWT_ALGORITHM: str = "HS256" # optional environement variable with default value REDIS_HOST: str = "localhost" @@ -18,3 +23,16 @@ class Settings(BaseSettings): MAIL_PORT: str # required environment variable MAIL_SERVER: str # required environment variable MAIL_FROM_NAME: str # required environment variable + + @model_validator(mode="after") + def _require_jwt_secret_in_production(self) -> "Settings": + # An empty JWT_SECRET signs forgeable tokens. Allow it only in DEBUG + # (local/dev); refuse to boot in production so the misconfig is loud. + if not self.DEBUG and not self.JWT_SECRET: + raise ValueError("JWT_SECRET must be set when DEBUG is False") + return self + + +# Shared, import-once settings instance. Import this rather than calling +# Settings() again - each call re-reads and re-parses the .env file. +settings = Settings() # type: ignore diff --git a/app/tests/test_dependencies.py b/app/tests/test_dependencies.py index e882dcc..74853d6 100644 --- a/app/tests/test_dependencies.py +++ b/app/tests/test_dependencies.py @@ -7,6 +7,7 @@ from app import dependencies from app.models import User as UserDB from app.routers.tests.conftest import access_token # noqa +from app.services import auth as auth_services async def test_get_db_yields_session(): @@ -69,3 +70,15 @@ async def test_get_current_user_not_in_db(self, access_token, session): # noqa with pytest.raises(HTTPException) as exc: await dependencies.get_current_user(access_token, session) assert exc.value.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_get_current_user_rejects_non_access_token(self, session, user): + # A refresh token carries type="refresh" and must not authenticate a + # request. The user genuinely exists, so without the type check the token + # would be accepted like an access token - this pins the rejection. + refresh_token = auth_services.create_refresh_token({"sub": user.email}) + + with pytest.raises(HTTPException) as exc: + await dependencies.get_current_user(refresh_token, session) + + assert exc.value.status_code == status.HTTP_401_UNAUTHORIZED + assert exc.value.detail == "Could not validate credentials" diff --git a/app/tests/test_main.py b/app/tests/test_main.py index ded6b2b..f342443 100644 --- a/app/tests/test_main.py +++ b/app/tests/test_main.py @@ -12,6 +12,14 @@ async def test_lifespan(): pass +async def test_health_endpoint_ok(client): + response = await client.get("/health") + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" + assert body["checks"] == {"database": "ok", "redis": "ok"} + + async def test_http_exception_handler(): async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" diff --git a/app/tests/test_middlewares.py b/app/tests/test_middlewares.py index d18b066..ef6d8c2 100644 --- a/app/tests/test_middlewares.py +++ b/app/tests/test_middlewares.py @@ -1,20 +1,63 @@ -import pytest -from httpx import ASGITransport, AsyncClient, Response +import uuid +from httpx import ASGITransport, AsyncClient + +from app import middlewares +from app.limiter import limiter from app.main import app -# Lazy override of the global client fixture to patch the ip address -@pytest.fixture -async def client(): - transport = ASGITransport( - app=app, - client=("10.0.0.5", 12345), - ) - async with AsyncClient(transport=transport, base_url="http://testserver") as ac: - yield ac +def make_client(ip: str, host: str = "localhost") -> AsyncClient: + # Pair a client IP with a trusted host so requests that pass the doc-access + # gate still clear TrustedHostMiddleware (which runs after it). + transport = ASGITransport(app=app, client=(ip, 12345)) + return AsyncClient(transport=transport, base_url=f"http://{host}") + + +async def test_docs_served_in_debug_regardless_of_ip(monkeypatch): + # DEBUG alone is enough, even for a non-whitelisted IP. + monkeypatch.setattr(middlewares.settings, "DEBUG", True) + async with make_client("10.0.0.5") as ac: + docs = await ac.get("/docs") + schema = await ac.get("/openapi.json") + assert docs.status_code == 200 + assert schema.status_code == 200 + + +async def test_docs_served_for_whitelisted_ip_when_debug_off(monkeypatch): + # A whitelisted IP keeps access even with DEBUG off. + monkeypatch.setattr(middlewares.settings, "DEBUG", False) + async with make_client("127.0.0.1") as ac: + docs = await ac.get("/docs") + schema = await ac.get("/openapi.json") + assert docs.status_code == 200 + assert schema.status_code == 200 + + +async def test_docs_hidden_when_debug_off_and_ip_not_whitelisted(monkeypatch): + # Both gates closed -> the docs are hidden. + monkeypatch.setattr(middlewares.settings, "DEBUG", False) + async with make_client("10.0.0.5") as ac: + docs = await ac.get("/docs") + schema = await ac.get("/openapi.json") + assert docs.status_code == 404 + assert schema.status_code == 404 -async def test_docs_route_is_whitelisted(client: AsyncClient): - response: Response = await client.get("/docs") - assert response.status_code == 500 +async def test_rate_limit_returns_429(monkeypatch): + # The limiter is disabled globally for tests; enable it just for this one. + # /resend_activation is capped at 3/minute, so the 4th+ call is rejected. + # Use a unique client key so the Redis-backed counter can't carry residual + # counts from a previous run into this one. + monkeypatch.setattr(limiter, "enabled", True) + async with make_client(uuid.uuid4().hex) as ac: + statuses = [ + ( + await ac.post( + "/v1/auth/resend_activation", json={"email": "ghost@example.com"} + ) + ).status_code + for _ in range(5) + ] + assert statuses.count(200) == 3 + assert statuses.count(429) == 2 diff --git a/app/tests/test_redis_manager.py b/app/tests/test_redis_manager.py index 7f3e0cc..6170870 100644 --- a/app/tests/test_redis_manager.py +++ b/app/tests/test_redis_manager.py @@ -1,14 +1,14 @@ from app.redis_manager import redis_manager -def test_cache_and_get_json_item_method(): - redis_manager.cache_json_item("test-item", {"item": 41}) - test_item = redis_manager.get_json_item("test-item") +async def test_cache_and_get_json_item_method(): + await redis_manager.cache_json_item("test-item", {"item": 41}) + test_item = await redis_manager.get_json_item("test-item") assert test_item == {"item": 41} -def test_delete_key_method(): - redis_manager.cache_json_item("test-item", {"item": 41}) - redis_manager.delete_key("test_item") - test_item = redis_manager.get_json_item("test_item") +async def test_delete_key_method(): + await redis_manager.cache_json_item("test-item", {"item": 41}) + await redis_manager.delete_key("test_item") + test_item = await redis_manager.get_json_item("test_item") assert test_item is None diff --git a/conftest.py b/conftest.py index 9d281b6..e244368 100644 --- a/conftest.py +++ b/conftest.py @@ -8,16 +8,19 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from app.dependencies import get_db +from app.limiter import limiter from app.main import app from app.models._base import AbstractBase -from app.settings import Settings +from app.redis_manager import redis_manager + +# The rate limiter uses an in-memory, IP-keyed counter shared across the whole +# test session; disable it so unrelated tests don't exhaust each other's quota. +limiter.enabled = False if typing.TYPE_CHECKING: pass -settings = Settings() # type: ignore - # Uses an SQLITE In-memory DB for setting DATABASE_URL = "sqlite+aiosqlite:///:memory:" @@ -45,6 +48,15 @@ async def run_migrations(): await setup_db() +@pytest.fixture(autouse=True) +async def close_redis_connections(): + # redis.asyncio pools connections bound to the running event loop. Because + # pytest-asyncio gives each test a fresh loop, close the pool after every + # test so the next one reconnects instead of reusing a closed-loop socket. + yield + await redis_manager.redis_client.aclose() + + @pytest.fixture(scope="session", autouse=True) def mock_fastmail_send(): """ diff --git a/mypy.ini b/mypy.ini index 205c573..03cf55e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,2 +1,7 @@ +[mypy] +python_version = 3.12 +ignore_missing_imports = True +plugins = pydantic.mypy + [mypy-redis.*] ignore_missing_imports = True diff --git a/requirements.txt b/requirements.txt index 1a3c740..3feab85 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -aiosmtplib==4.0.2 +aiosmtplib==5.1.1 aiosqlite==0.21.0 alembic==1.18.4 annotated-doc==0.0.4 @@ -9,9 +9,9 @@ bcrypt==5.0.0 blinker==1.9.0 certifi==2025.10.5 cffi==2.0.0 -click==8.3.0 +click==8.3.3 coverage==7.14.0 -cryptography==46.0.7 +cryptography==49.0.0 Deprecated==1.2.18 dnspython==2.8.0 email-validator==2.3.0 @@ -20,7 +20,7 @@ Faker==38.2.0 fastapi==0.136.1 fastapi-cli==0.0.13 fastapi-cloud-cli==0.3.1 -fastapi-mail==1.5.8 +fastapi-mail==1.6.5 fastar==0.11.0 greenlet==3.2.4 h11==0.16.0 @@ -35,6 +35,8 @@ Mako==1.3.12 markdown-it-py==4.0.0 MarkupSafe==3.0.3 mdurl==0.1.2 +mypy==2.3.0 +mypy_extensions==1.1.0 packaging==25.0 passlib==1.7.4 pluggy==1.6.0 @@ -45,12 +47,12 @@ pydantic-extra-types==2.11.1 pydantic-settings==2.11.0 pydantic_core==2.46.4 Pygments==2.20.0 -PyJWT==2.12.0 +PyJWT==2.13.0 pytest==9.0.3 pytest-asyncio==1.3.0 pytest-cov==7.0.0 python-dotenv==1.2.2 -python-multipart==0.0.27 +python-multipart==0.0.31 PyYAML==6.0.3 redis==6.4.0 regex==2025.11.3 @@ -62,7 +64,7 @@ shellingham==1.5.4 slowapi==0.1.9 sniffio==1.3.1 SQLAlchemy==2.0.45 -starlette==0.49.1 +starlette==1.3.1 typer==0.19.2 typing-inspection==0.4.2 typing_extensions==4.15.0 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..bcc092d --- /dev/null +++ b/ruff.toml @@ -0,0 +1,3 @@ +# Minimum Python the project targets. Setting this teaches ruff which builtins +# exist (e.g. `anext`, 3.10+) and enables version-appropriate lint rules. +target-version = "py312"