summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTom Flux <tom@tomflux.xyz>2026-06-23 20:37:48 +0100
committerTom Flux <tom@tomflux.xyz>2026-06-23 20:37:48 +0100
commitd92deeab514a52e7c3416baef78f8969ade951cc (patch)
treef5c071147ea6f62501dee904a1f381805a1e5941
parent682c0f76b85950ad0889e11750dc84e0f00d2807 (diff)
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 <noreply@anthropic.com>
-rw-r--r--deploy/food.service3
-rw-r--r--food_project/settings.py25
-rw-r--r--food_project/urls.py1
-rw-r--r--kitchen/middleware.py21
-rw-r--r--kitchen/templates/kitchen/base.html4
-rw-r--r--kitchen/templates/registration/login.html116
-rw-r--r--kitchen/tests.py41
-rw-r--r--kitchen/views_htmx.py9
8 files changed, 201 insertions, 19 deletions
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 @@
<a href="{% url 'app-shopping' %}" {% if active_tab == 'shopping' %}class="active"{% endif %}>Shopping</a>
<a href="{% url 'app-log' %}" {% if active_tab == 'log' %}class="active"{% endif %}>Cook Log</a>
</div>
+ <form method="post" action="{% url 'logout' %}" style="margin-left: auto;">
+ {% csrf_token %}
+ <button type="submit" style="background: none; border: 1px solid var(--teal-dark); color: var(--grey-light); padding: 0.4rem 0.75rem; border-radius: 4px; font-size: 0.85rem; cursor: pointer;">Log out</button>
+ </form>
</nav>
<div class="container">
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Sign in — Kitchen</title>
+ <style>
+ :root {
+ --bg-dark: #15131c;
+ --navy: #0e0e5b;
+ --teal-dark: #325664;
+ --sage: #658d89;
+ --red-bright: #f01111;
+ --yellow: #f9df11;
+ --teal-light: #87d1d1;
+ --grey-light: #babcc4;
+ --cream: #f7fdc7;
+ }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+ background: var(--bg-dark);
+ color: var(--grey-light);
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1.5rem;
+ }
+ .login-card {
+ width: 100%;
+ max-width: 360px;
+ background: rgba(50, 86, 100, 0.15);
+ border: 1px solid var(--teal-dark);
+ border-radius: 10px;
+ padding: 1.75rem 1.5rem;
+ }
+ .logo {
+ font-size: 1.4rem;
+ font-weight: 700;
+ color: var(--yellow);
+ margin-bottom: 1.25rem;
+ }
+ label {
+ display: block;
+ color: var(--teal-light);
+ font-size: 0.78rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin: 0 0 0.3rem;
+ }
+ input {
+ width: 100%;
+ background: rgba(14, 14, 91, 0.3);
+ border: 1px solid var(--teal-dark);
+ color: var(--cream);
+ padding: 0.7rem 0.8rem;
+ border-radius: 8px;
+ font-size: 1rem;
+ min-height: 48px;
+ }
+ input:focus {
+ outline: none;
+ border-color: var(--yellow);
+ box-shadow: 0 0 0 2px rgba(249, 223, 17, 0.15);
+ }
+ .field { margin-bottom: 1rem; }
+ .btn {
+ width: 100%;
+ background: var(--yellow);
+ color: var(--bg-dark);
+ border: none;
+ font-size: 1rem;
+ font-weight: 700;
+ padding: 0.8rem;
+ border-radius: 8px;
+ cursor: pointer;
+ min-height: 48px;
+ }
+ .btn:active { transform: translateY(1px); }
+ .errors {
+ background: rgba(240, 17, 17, 0.14);
+ color: var(--red-bright);
+ border-radius: 8px;
+ padding: 0.6rem 0.8rem;
+ font-size: 0.85rem;
+ margin-bottom: 1rem;
+ }
+ </style>
+</head>
+<body>
+ <form class="login-card" method="post" action="{% url 'login' %}">
+ {% csrf_token %}
+ <div class="logo">🍳 Kitchen</div>
+
+ {% if form.errors %}
+ <div class="errors">That username and password didn't match. Try again.</div>
+ {% endif %}
+
+ <div class="field">
+ <label for="id_username">Username</label>
+ <input type="text" name="username" id="id_username" autocapitalize="none"
+ autocomplete="username" autofocus required>
+ </div>
+ <div class="field">
+ <label for="id_password">Password</label>
+ <input type="password" name="password" id="id_password"
+ autocomplete="current-password" required>
+ </div>
+
+ <input type="hidden" name="next" value="{{ next }}">
+ <button type="submit" class="btn">Sign in</button>
+ </form>
+</body>
+</html>
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()