diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..af8b7d8 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,68 @@ +"""Signed session cookies for protecting the API.""" + +import base64 +import hashlib +import hmac +import json +import time + +from fastapi import Cookie, HTTPException, status + +from app.config import settings + +SESSION_COOKIE = "todorabbit_session" + + +def _b64encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def _b64decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _sign(payload: str) -> str: + digest = hmac.new(settings.session_secret.encode(), payload.encode(), hashlib.sha256).digest() + return _b64encode(digest) + + +def create_session_token(username: str, now: float | None = None) -> str: + """Create a signed token that expires after ``session_ttl_minutes``.""" + issued_at = time.time() if now is None else now + claims = {"sub": username, "exp": int(issued_at + settings.session_ttl_minutes * 60)} + payload = _b64encode(json.dumps(claims, separators=(",", ":")).encode()) + return f"{payload}.{_sign(payload)}" + + +def verify_session_token(token: str, now: float | None = None) -> str | None: + """Return the username for a valid, unexpired token, otherwise ``None``.""" + payload, separator, signature = token.partition(".") + if not separator or not hmac.compare_digest(signature, _sign(payload)): + return None + + try: + claims = json.loads(_b64decode(payload)) + expires_at = int(claims["exp"]) + username = str(claims["sub"]) + except (ValueError, KeyError, TypeError): + return None + + if expires_at <= (time.time() if now is None else now): + return None + return username + + +async def require_session( + session: str | None = Cookie(None, alias=SESSION_COOKIE), +) -> str: + """Dependency that rejects requests without a valid session cookie.""" + if not settings.auth_enabled: + return "anonymous" + + username = verify_session_token(session) if session else None + if username is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + return username diff --git a/backend/app/config.py b/backend/app/config.py index 3adbf66..c3219b9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,5 +1,6 @@ """Application configuration using pydantic-settings.""" +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -11,5 +12,18 @@ class Settings(BaseSettings): database_url: str = "sqlite+aiosqlite:///./todos.db" cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"] + auth_enabled: bool = False + auth_username: str = "admin" + auth_password: str = "" + session_secret: str = "" + session_ttl_minutes: int = 60 + session_cookie_secure: bool = True + + @model_validator(mode="after") + def _require_credentials_when_auth_enabled(self) -> "Settings": + if self.auth_enabled and not (self.auth_password and self.session_secret): + raise ValueError("AUTH_PASSWORD and SESSION_SECRET must be set when AUTH_ENABLED is true") + return self + settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index ad22c5c..bddf36c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,12 +2,13 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.auth import require_session from app.config import settings from app.database import Base, engine -from app.routes import todos +from app.routes import auth, todos @asynccontextmanager @@ -38,7 +39,8 @@ async def lifespan(app: FastAPI): ) # Include routers -app.include_router(todos.router) +app.include_router(auth.router) +app.include_router(todos.router, dependencies=[Depends(require_session)]) @app.get("/api/health") diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 0000000..b610419 --- /dev/null +++ b/backend/app/routes/auth.py @@ -0,0 +1,56 @@ +"""Login and logout endpoints.""" + +import hmac + +from fastapi import APIRouter, HTTPException, Response, status +from pydantic import BaseModel + +from app.auth import SESSION_COOKIE, create_session_token +from app.config import settings + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +class LoginRequest(BaseModel): + """Credentials submitted to start a session.""" + + username: str + password: str + + +class SessionResponse(BaseModel): + """The user a session belongs to.""" + + username: str + + +def _matches(provided: str, expected: str) -> bool: + return hmac.compare_digest(provided.encode(), expected.encode()) + + +@router.post("/login", response_model=SessionResponse) +async def login(credentials: LoginRequest, response: Response) -> SessionResponse: + """Exchange the configured credentials for a session cookie.""" + if not settings.auth_enabled: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Authentication is disabled") + + username_ok = _matches(credentials.username, settings.auth_username) + password_ok = _matches(credentials.password, settings.auth_password) + if not (username_ok and password_ok): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + response.set_cookie( + SESSION_COOKIE, + create_session_token(credentials.username), + max_age=settings.session_ttl_minutes * 60, + httponly=True, + samesite="lax", + secure=settings.session_cookie_secure, + ) + return SessionResponse(username=credentials.username) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout(response: Response) -> None: + """End the current session.""" + response.delete_cookie(SESSION_COOKIE, httponly=True, samesite="lax") diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..27f6830 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,86 @@ +"""Tests for session-cookie authentication.""" + +import pytest +from httpx import AsyncClient + +from app.auth import SESSION_COOKIE, create_session_token, verify_session_token +from app.config import settings + +CREDENTIALS = {"username": "admin", "password": "correct horse battery staple"} + + +@pytest.fixture +def auth_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "auth_enabled", True) + monkeypatch.setattr(settings, "auth_username", CREDENTIALS["username"]) + monkeypatch.setattr(settings, "auth_password", CREDENTIALS["password"]) + monkeypatch.setattr(settings, "session_secret", "test-secret") + monkeypatch.setattr(settings, "session_cookie_secure", False) + + +def test_token_round_trip(): + token = create_session_token("admin", now=1_000) + assert verify_session_token(token, now=1_001) == "admin" + + +def test_expired_token_is_rejected(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(settings, "session_secret", "test-secret") + token = create_session_token("admin", now=1_000) + assert verify_session_token(token, now=1_000 + settings.session_ttl_minutes * 60) is None + + +def test_tampered_token_is_rejected(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(settings, "session_secret", "test-secret") + payload, signature = create_session_token("admin", now=1_000).split(".") + forged = create_session_token("someone-else", now=1_000).split(".")[0] + assert verify_session_token(f"{forged}.{signature}", now=1_001) is None + assert verify_session_token(f"{payload}.bad-signature", now=1_001) is None + assert verify_session_token("not-a-token", now=1_001) is None + + +@pytest.mark.asyncio +async def test_todos_require_a_session(client: AsyncClient, auth_enabled: None): + response = await client.get("/api/todos") + assert response.status_code == 401 + assert response.json()["detail"] == "Not authenticated" + + +@pytest.mark.asyncio +async def test_login_sets_a_http_only_session_cookie(client: AsyncClient, auth_enabled: None): + response = await client.post("/api/auth/login", json=CREDENTIALS) + assert response.status_code == 200 + assert response.json() == {"username": "admin"} + + set_cookie = response.headers["set-cookie"] + assert set_cookie.startswith(f"{SESSION_COOKIE}=") + assert "HttpOnly" in set_cookie + assert "SameSite=lax" in set_cookie + + todos = await client.get("/api/todos") + assert todos.status_code == 200 + + +@pytest.mark.asyncio +async def test_login_rejects_wrong_password(client: AsyncClient, auth_enabled: None): + response = await client.post( + "/api/auth/login", json={**CREDENTIALS, "password": "wrong"} + ) + assert response.status_code == 401 + assert SESSION_COOKIE not in response.headers.get("set-cookie", "") + + +@pytest.mark.asyncio +async def test_logout_clears_the_session(client: AsyncClient, auth_enabled: None): + await client.post("/api/auth/login", json=CREDENTIALS) + + response = await client.post("/api/auth/logout") + assert response.status_code == 204 + + todos = await client.get("/api/todos") + assert todos.status_code == 401 + + +@pytest.mark.asyncio +async def test_health_check_stays_public(client: AsyncClient, auth_enabled: None): + response = await client.get("/api/health") + assert response.status_code == 200 diff --git a/docs/configuration.md b/docs/configuration.md index e59b6bb..053062d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,6 +9,12 @@ The backend reads its settings from environment variables, or from a `.env` file | `DATABASE_URL` | `sqlite+aiosqlite:///./todos.db` | SQLAlchemy async database URL. | | `CORS_ORIGINS` | `["http://localhost:5173", "http://localhost:3000"]` | JSON list of origins allowed to call the API from a browser. | | `ARCHIVE_RETENTION_DAYS` | `30` | Days to keep archived todos before the nightly cleanup deletes them. | +| `AUTH_ENABLED` | `false` | Require a session cookie for `/api/todos`. See [Authentication](#authentication). | +| `AUTH_USERNAME` | `admin` | Username accepted by `POST /api/auth/login`. | +| `AUTH_PASSWORD` | empty | Password accepted by `POST /api/auth/login`. Required when authentication is enabled. | +| `SESSION_SECRET` | empty | Secret used to sign session cookies. Required when authentication is enabled. | +| `SESSION_TTL_MINUTES` | `60` | How long a session stays valid after login, in minutes. | +| `SESSION_COOKIE_SECURE` | `true` | Send the session cookie over HTTPS only. Set to `false` for local development over plain HTTP. | Example `backend/.env`: @@ -18,6 +24,20 @@ CORS_ORIGINS=["https://todos.example.com"] ARCHIVE_RETENTION_DAYS=60 ``` +## Authentication + +Authentication is off by default. Set `AUTH_ENABLED=true` together with `AUTH_PASSWORD` and `SESSION_SECRET` to protect the todo endpoints. The backend refuses to start if either value is missing. + +```bash +curl -c cookies.txt -X POST http://localhost:8000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "your-password"}' + +curl -b cookies.txt http://localhost:8000/api/todos +``` + +A successful login sets an `HttpOnly`, `SameSite=Lax` cookie that expires after `SESSION_TTL_MINUTES`. `POST /api/auth/logout` clears it. Use a long random value for `SESSION_SECRET`, and keep it the same across all backend instances. Changing it signs everyone out. `/api/health` stays public. + ## Archived todo retention Archiving a todo hides it from the default list but keeps the row. The `purge_archived` job removes archived todos once they are older than `ARCHIVE_RETENTION_DAYS`.