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 --- deploy/food.service | 3 + food_project/settings.py | 25 ++++++- food_project/urls.py | 1 + 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 --- 8 files changed, 201 insertions(+), 19 deletions(-) create mode 100644 kitchen/middleware.py create mode 100644 kitchen/templates/registration/login.html diff --git a/deploy/food.service b/deploy/food.service index 1211bce..d9cade8 100644 --- a/deploy/food.service +++ b/deploy/food.service @@ -9,6 +9,9 @@ Group=openclaw WorkingDirectory=/var/lib/food Environment="PATH=/var/lib/food/.venv/bin:/usr/bin" Environment="DJANGO_SETTINGS_MODULE=food_project.settings" +# Secrets/config (DJANGO_SECRET_KEY, optional DJANGO_DEBUG) live here, not in git. +# The leading '-' makes it optional so the service still starts if absent. +EnvironmentFile=-/var/lib/food/.env ExecStart=/var/lib/food/.venv/bin/gunicorn food_project.wsgi:application \ --bind 127.0.0.1:8042 \ --workers 2 \ diff --git a/food_project/settings.py b/food_project/settings.py index 05035af..96dcfd0 100644 --- a/food_project/settings.py +++ b/food_project/settings.py @@ -10,6 +10,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.2/ref/settings/ """ +import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -20,10 +21,16 @@ BASE_DIR = Path(__file__).resolve().parent.parent # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-4v$$nwxx6)+2yz%$8c@+kocm#op1cjm*688np#)z$b_6crvub*' +# Set DJANGO_SECRET_KEY in the environment for production; the literal below is +# only a development fallback. +SECRET_KEY = os.environ.get( + "DJANGO_SECRET_KEY", + "django-insecure-4v$$nwxx6)+2yz%$8c@+kocm#op1cjm*688np#)z$b_6crvub*", +) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +# Off by default (prod-safe); set DJANGO_DEBUG=1 for local development. +DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1" ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'food.jihakuz.xyz', 'food.tomflux.xyz'] @@ -60,6 +67,7 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'kitchen.middleware.AppLoginRequiredMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] @@ -141,3 +149,16 @@ STORAGES = { # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + +# --- Auth / sessions --- +# Goal: log in once, then effectively never again on that device. +# A long cookie age + sliding expiry (re-saved each request) means an active +# user stays logged in indefinitely. +SESSION_COOKIE_AGE = 60 * 60 * 24 * 365 # ~1 year, in seconds +SESSION_EXPIRE_AT_BROWSER_CLOSE = False +SESSION_SAVE_EVERY_REQUEST = True + +LOGIN_URL = 'login' +LOGIN_REDIRECT_URL = 'app-pantry' +LOGOUT_REDIRECT_URL = 'login' diff --git a/food_project/urls.py b/food_project/urls.py index a788823..9884dbf 100644 --- a/food_project/urls.py +++ b/food_project/urls.py @@ -19,6 +19,7 @@ from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), + path('accounts/', include('django.contrib.auth.urls')), # login/logout for the web UI path('api/', include('kitchen.urls')), path('api-auth/', include('rest_framework.urls')), # browsable API login path('app/', include('kitchen.urls_htmx')), # HTMX frontend 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