diff --git a/.env.example b/.env.example index 0e60c99..52673f5 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,5 @@ CREDENTIAL_ENCRYPTION_KEY=replace-with-a-valid-fernet-key SYNC_INTERVAL_MINUTES=5 DATA_DIR=/data DATABASE_URL=sqlite:////data/app.db +# Set to true only when TLS terminates in front of this service. +SESSION_HTTPS_ONLY=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99c019b --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Local secrets and runtime data +.env +data/ + +# Virtual environments +.venv/ +venv/ + +# Python bytecode and build artifacts +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +build/ +dist/ + +# Tooling caches +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile index 9ecbada..39458b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,4 +7,4 @@ COPY app /app/app RUN mkdir -p /data && chmod 700 /data ENV DATA_DIR=/data EXPOSE 8080 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] +CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8080"] diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/csrf.py b/app/auth/csrf.py index dc64779..208cd23 100644 --- a/app/auth/csrf.py +++ b/app/auth/csrf.py @@ -14,5 +14,7 @@ def ensure_csrf_token(request: Request) -> str: def validate_csrf(request: Request, submitted_token: str) -> None: expected = request.session.get("csrf_token") - if not isinstance(expected, str) or not hmac.compare_digest(expected, submitted_token): + if not isinstance(expected, str) or not hmac.compare_digest( + expected.encode("utf-8"), submitted_token.encode("utf-8") + ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token") diff --git a/app/config.py b/app/config.py index 11b7db1..bb1646a 100644 --- a/app/config.py +++ b/app/config.py @@ -14,6 +14,7 @@ class Settings(BaseSettings): data_dir: Path = Field(default=Path("/data"), alias="DATA_DIR") database_url: str | None = Field(default=None, alias="DATABASE_URL") sync_interval_minutes: PositiveInt = Field(default=5, alias="SYNC_INTERVAL_MINUTES") + session_https_only: bool = Field(default=False, alias="SESSION_HTTPS_ONLY") @model_validator(mode="after") def derive_paths(self) -> "Settings": diff --git a/app/db/models.py b/app/db/models.py index f5f8de8..256ef8a 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -4,7 +4,7 @@ import enum from datetime import datetime, timezone from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column def utcnow() -> datetime: diff --git a/app/db/repositories.py b/app/db/repositories.py index b703e40..7be9d6a 100644 --- a/app/db/repositories.py +++ b/app/db/repositories.py @@ -4,7 +4,7 @@ from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from app.db.models import Activity, ActivityStatus, HealthState, SyncUser +from app.db.models import Activity, ActivityStatus, SyncUser class UserRepository: diff --git a/app/main.py b/app/main.py index dbdf867..6827988 100644 --- a/app/main.py +++ b/app/main.py @@ -24,7 +24,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: SessionMiddleware, secret_key=resolved.secret_key, same_site="lax", - https_only=False, + https_only=resolved.session_https_only, ) app.include_router(web_router) @@ -33,6 +33,3 @@ def create_app(settings: Settings | None = None) -> FastAPI: return {"status": "ok"} return app - - -app = create_app() diff --git a/app/security/__init__.py b/app/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/web/__init__.py b/app/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/web/routes.py b/app/web/routes.py index 79eb6a7..2d4e242 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -1,3 +1,5 @@ +from pathlib import Path + from fastapi import APIRouter, Form, HTTPException, Request, status from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates @@ -10,7 +12,7 @@ from app.security.credentials import CredentialCipher from app.web.forms import UserFormData router = APIRouter() -templates = Jinja2Templates(directory="app/web/templates") +templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) def _get_user_or_404(repository: UserRepository, user_id: int) -> SyncUser: diff --git a/tests/conftest.py b/tests/conftest.py index 002eb64..aa8aab8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,8 +22,11 @@ def db_session() -> Session: ) Base.metadata.create_all(engine) factory = sessionmaker(bind=engine, expire_on_commit=False) - with factory() as session: - yield session + try: + with factory() as session: + yield session + finally: + engine.dispose() @pytest.fixture @@ -46,4 +49,8 @@ def client(tmp_path: Path) -> TestClient: DATABASE_URL=f"sqlite:///{tmp_path / 'app.db'}", SYNC_INTERVAL_MINUTES=5, ) - return TestClient(create_app(settings)) + app = create_app(settings) + try: + yield TestClient(app) + finally: + app.state.db_engine.dispose() diff --git a/tests/db/test_repositories.py b/tests/db/test_repositories.py index 28cdf8d..130deec 100644 --- a/tests/db/test_repositories.py +++ b/tests/db/test_repositories.py @@ -21,8 +21,20 @@ def test_create_two_independent_users(db_session, user_repository) -> None: garmin_password_enc="g-pw-2", ) + disabled = user_repository.create( + name="Paused", + enabled=False, + health_state=HealthState.DISABLED, + mywhoosh_email_enc="mw-3", + mywhoosh_password_enc="mw-pw-3", + garmin_email_enc="g-3", + garmin_password_enc="g-pw-3", + ) + assert first.id != second.id assert {u.name for u in user_repository.list_enabled()} == {"Max", "Anna"} + assert disabled.id not in {u.id for u in user_repository.list_enabled()} + assert {u.name for u in user_repository.list_all()} == {"Max", "Anna", "Paused"} def test_activity_external_id_is_unique_per_user(user_repository, activity_repository) -> None: diff --git a/tests/web/test_users.py b/tests/web/test_users.py index ffc8763..4ae4fc8 100644 --- a/tests/web/test_users.py +++ b/tests/web/test_users.py @@ -225,6 +225,23 @@ def test_create_user_rejects_invalid_csrf(client: TestClient) -> None: assert response.status_code == 403 +def test_create_user_rejects_non_ascii_csrf_token(client: TestClient) -> None: + login(client) + response = client.post( + "/users", + data={ + "csrf_token": "invalid-tokeü", + "name": "Max", + "mywhoosh_email": "max@example.com", + "mywhoosh_password": "mw-secret", + "garmin_email": "max-garmin@example.com", + "garmin_password": "garmin-secret", + "enabled": "on", + }, + ) + assert response.status_code == 403 + + def test_dashboard_lists_created_user_without_secrets(client: TestClient) -> None: login(client) create_user_via_http(client)