From d92deeab514a52e7c3416baef78f8969ade951cc Mon Sep 17 00:00:00 2001 From: Tom Flux Date: Tue, 23 Jun 2026 20:37:48 +0100 Subject: Phase 2: session auth for the web UI Require login for /app/, with a long sliding session so you log in once per device and effectively stay in. - AppLoginRequiredMiddleware gates only /app/; /api/ keeps DRF token auth and /admin/ keeps its own login (a blanket LoginRequired would break token requests, whose user isn't resolved until the view runs). - Login page (styled to the dark palette) via django.contrib.auth.urls; logout control in the nav. - Session: ~1 year cookie, sliding (saved every request), survives browser close. - Dropped every @csrf_exempt now that a real session + CSRF token are in place (HTMX already sends X-CSRFToken). - SECRET_KEY and DEBUG now read from the environment (prod-safe defaults); systemd loads an optional /var/lib/food/.env. - Tests authenticate, plus new coverage: /app/ redirects when logged out, login grants access, /api/ is not caught by the app gate. Co-Authored-By: Claude Opus 4.8 --- kitchen/middleware.py | 21 ++++++ kitchen/templates/kitchen/base.html | 4 ++ kitchen/templates/registration/login.html | 116 ++++++++++++++++++++++++++++++ kitchen/tests.py | 41 ++++++++--- kitchen/views_htmx.py | 9 --- 5 files changed, 174 insertions(+), 17 deletions(-) create mode 100644 kitchen/middleware.py create mode 100644 kitchen/templates/registration/login.html (limited to 'kitchen') diff --git a/kitchen/middleware.py b/kitchen/middleware.py new file mode 100644 index 0000000..63c2f4f --- /dev/null +++ b/kitchen/middleware.py @@ -0,0 +1,21 @@ +from django.conf import settings +from django.contrib.auth.views import redirect_to_login + + +class AppLoginRequiredMiddleware: + """Require a logged-in session for the /app/ HTMX UI. + + Deliberately scoped to /app/ only: + - /api/ uses DRF token auth (its user isn't resolved until the view runs, + so a blanket login check here would wrongly reject valid tokens). + - /admin/ has its own login. + - the login page and /static/ must stay reachable while logged out. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.path.startswith("/app/") and not request.user.is_authenticated: + return redirect_to_login(request.get_full_path(), settings.LOGIN_URL) + return self.get_response(request) diff --git a/kitchen/templates/kitchen/base.html b/kitchen/templates/kitchen/base.html index 323e985..92faedc 100644 --- a/kitchen/templates/kitchen/base.html +++ b/kitchen/templates/kitchen/base.html @@ -409,6 +409,10 @@ Shopping Cook Log +
+ {% csrf_token %} + +
diff --git a/kitchen/templates/registration/login.html b/kitchen/templates/registration/login.html new file mode 100644 index 0000000..4d0e1aa --- /dev/null +++ b/kitchen/templates/registration/login.html @@ -0,0 +1,116 @@ + + + + + + Sign in — Kitchen + + + + + + diff --git a/kitchen/tests.py b/kitchen/tests.py index 31e0665..065d530 100644 --- a/kitchen/tests.py +++ b/kitchen/tests.py @@ -1,12 +1,39 @@ from django.test import TestCase, Client from django.urls import reverse +from django.contrib.auth.models import User from kitchen.models import Ingredient, PantryItem, MetaRecipe, Slot, SlotOption -class PantryStateTests(TestCase): +class AuthTests(TestCase): + def test_app_requires_login(self): + resp = self.client.get(reverse("app-pantry")) + self.assertEqual(resp.status_code, 302) + self.assertIn(reverse("login"), resp.url) + + def test_login_grants_access(self): + User.objects.create_user("tom", password="pw") + self.assertTrue(self.client.login(username="tom", password="pw")) + resp = self.client.get(reverse("app-pantry")) + self.assertEqual(resp.status_code, 200) + + def test_api_is_not_redirected_to_login(self): + # /api/ uses DRF token auth; the /app/ login middleware must not touch it. + resp = self.client.get("/api/pantry/") + self.assertIn(resp.status_code, (401, 403)) + self.assertNotEqual(resp.status_code, 302) + + +class _AuthedTestCase(TestCase): def setUp(self): self.client = Client() + self.user = User.objects.create_user("tester", password="pw") + self.client.force_login(self.user) + + +class PantryStateTests(_AuthedTestCase): + def setUp(self): + super().setUp() self.eggs = Ingredient.objects.create(name="eggs", default_unit="items") self.item = PantryItem.objects.create( ingredient=self.eggs, location="fridge", state="in" @@ -56,7 +83,6 @@ class PantryStateTests(TestCase): reverse("app-pantry-add"), {"ingredient_name": "eggs", "location": "fridge"}, ) - # No duplicate row, and the existing one is back in stock. items = PantryItem.objects.filter(ingredient=self.eggs, location="fridge") self.assertEqual(items.count(), 1) self.assertEqual(items.first().state, "in") @@ -68,12 +94,12 @@ class PantryStateTests(TestCase): self.assertContains(resp, "eggs") -class RecipesPresenceTests(TestCase): - """The recipes matcher is presence-based now: having an ingredient (not - 'out') satisfies a slot regardless of quantity.""" +class RecipesPresenceTests(_AuthedTestCase): + """Presence-based matcher: having an ingredient (not 'out') satisfies a slot + regardless of quantity.""" def setUp(self): - self.client = Client() + super().setUp() self.noodles = Ingredient.objects.create(name="noodles", default_unit="nests") self.mr = MetaRecipe.objects.create(name="Noodles", method="boil") slot = Slot.objects.create(meta_recipe=self.mr, name="carb", required=True) @@ -82,7 +108,6 @@ class RecipesPresenceTests(TestCase): ) def test_present_ingredient_no_quantity_is_available(self): - # 'in' with no quantity should still count as available. PantryItem.objects.create( ingredient=self.noodles, location="cupboard", state="in" ) @@ -98,7 +123,7 @@ class RecipesPresenceTests(TestCase): self.assertEqual(resp.status_code, 200) -class PageSmokeTests(TestCase): +class PageSmokeTests(_AuthedTestCase): def test_pages_render(self): for name in ("app-pantry", "app-recipes", "app-shopping", "app-log"): with self.subTest(page=name): diff --git a/kitchen/views_htmx.py b/kitchen/views_htmx.py index 21eec8a..9e1838e 100644 --- a/kitchen/views_htmx.py +++ b/kitchen/views_htmx.py @@ -7,7 +7,6 @@ from decimal import Decimal from django.http import HttpResponse from django.shortcuts import render, get_object_or_404 -from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST, require_http_methods from .models import ( @@ -221,7 +220,6 @@ def log_page(request): # --- HTMX Actions --- -@csrf_exempt @require_POST def pantry_add(request): """Add an item (or restock an existing one to 'in'). Quantity is optional.""" @@ -264,7 +262,6 @@ def pantry_add(request): return render(request, "kitchen/partials/pantry_table.html", _pantry_context()) -@csrf_exempt @require_POST def pantry_set_state(request, item_id): """Set an item's In/Low/Out state — the primary pantry interaction.""" @@ -289,7 +286,6 @@ def pantry_search(request): ) -@csrf_exempt @require_http_methods(["DELETE"]) def pantry_delete(request, item_id): item = get_object_or_404(PantryItem, id=item_id) @@ -298,7 +294,6 @@ def pantry_delete(request, item_id): return render(request, "kitchen/partials/pantry_table.html", ctx) -@csrf_exempt @require_POST def pantry_move(request, item_id): """Move an item between fridge / freezer / cupboard.""" @@ -322,7 +317,6 @@ def pantry_move(request, item_id): return render(request, "kitchen/partials/pantry_table.html", _pantry_context()) -@csrf_exempt @require_POST def pantry_save_expiry(request, item_id): """Set or clear an item's expiry date (inline editor in the item menu).""" @@ -332,7 +326,6 @@ def pantry_save_expiry(request, item_id): return render(request, "kitchen/partials/pantry_table.html", _pantry_context()) -@csrf_exempt @require_POST def shopping_generate(request): """Generate smart shopping list and return updated HTML.""" @@ -420,7 +413,6 @@ def shopping_generate(request): }) -@csrf_exempt @require_POST def shopping_toggle(request, item_id): item = get_object_or_404(ShoppingListItem, id=item_id) @@ -430,7 +422,6 @@ def shopping_toggle(request, item_id): return render(request, "kitchen/partials/shopping_list.html", {"items": _shopping_list_items()}) -@csrf_exempt @require_POST def shopping_clear(request): ShoppingListItem.objects.filter(checked=True).delete() -- cgit v1.2.3