Author SHA1 Message Date
Tom FluxandClaude Opus 4.8 ba1f792826 auth: trust nginx HTTPS proxy for CSRF (SECURE_PROXY_SSL_HEADER + CSRF_TRUSTED_ORIGINS)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:45:46 +01:00
Tom FluxandClaude Opus 4.8 6bad2a2ad1 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>
2026-06-23 20:37:48 +01:00
8 changed files with 209 additions and 19 deletions
+3
View File
@@ -9,6 +9,9 @@ Group=openclaw
WorkingDirectory=/var/lib/food WorkingDirectory=/var/lib/food
Environment="PATH=/var/lib/food/.venv/bin:/usr/bin" Environment="PATH=/var/lib/food/.venv/bin:/usr/bin"
Environment="DJANGO_SETTINGS_MODULE=food_project.settings" 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 \ ExecStart=/var/lib/food/.venv/bin/gunicorn food_project.wsgi:application \
--bind 127.0.0.1:8042 \ --bind 127.0.0.1:8042 \
--workers 2 \ --workers 2 \
+31 -2
View File
@@ -10,6 +10,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/ https://docs.djangoproject.com/en/5.2/ref/settings/
""" """
import os
from pathlib import Path from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # 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/ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # 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! # 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'] ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'food.jihakuz.xyz', 'food.tomflux.xyz']
@@ -60,6 +67,7 @@ MIDDLEWARE = [
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'kitchen.middleware.AppLoginRequiredMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
] ]
@@ -141,3 +149,24 @@ STORAGES = {
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 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'
# Behind the nginx HTTPS reverse proxy: trust the forwarded scheme so Django
# knows requests are HTTPS, and trust the site's origins for CSRF (needed for
# the login POST and HTMX POSTs now that they're no longer csrf-exempt).
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
CSRF_TRUSTED_ORIGINS = [
'https://food.tomflux.xyz',
]
+1
View File
@@ -19,6 +19,7 @@ from django.urls import path, include
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), 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/', include('kitchen.urls')),
path('api-auth/', include('rest_framework.urls')), # browsable API login path('api-auth/', include('rest_framework.urls')), # browsable API login
path('app/', include('kitchen.urls_htmx')), # HTMX frontend path('app/', include('kitchen.urls_htmx')), # HTMX frontend
+21
View File
@@ -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)
+4
View File
@@ -409,6 +409,10 @@
<a href="{% url 'app-shopping' %}" {% if active_tab == 'shopping' %}class="active"{% endif %}>Shopping</a> <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> <a href="{% url 'app-log' %}" {% if active_tab == 'log' %}class="active"{% endif %}>Cook Log</a>
</div> </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> </nav>
<div class="container"> <div class="container">
+116
View File
@@ -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>
+33 -8
View File
@@ -1,12 +1,39 @@
from django.test import TestCase, Client from django.test import TestCase, Client
from django.urls import reverse from django.urls import reverse
from django.contrib.auth.models import User
from kitchen.models import Ingredient, PantryItem, MetaRecipe, Slot, SlotOption 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): def setUp(self):
self.client = Client() 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.eggs = Ingredient.objects.create(name="eggs", default_unit="items")
self.item = PantryItem.objects.create( self.item = PantryItem.objects.create(
ingredient=self.eggs, location="fridge", state="in" ingredient=self.eggs, location="fridge", state="in"
@@ -56,7 +83,6 @@ class PantryStateTests(TestCase):
reverse("app-pantry-add"), reverse("app-pantry-add"),
{"ingredient_name": "eggs", "location": "fridge"}, {"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") items = PantryItem.objects.filter(ingredient=self.eggs, location="fridge")
self.assertEqual(items.count(), 1) self.assertEqual(items.count(), 1)
self.assertEqual(items.first().state, "in") self.assertEqual(items.first().state, "in")
@@ -68,12 +94,12 @@ class PantryStateTests(TestCase):
self.assertContains(resp, "eggs") self.assertContains(resp, "eggs")
class RecipesPresenceTests(TestCase): class RecipesPresenceTests(_AuthedTestCase):
"""The recipes matcher is presence-based now: having an ingredient (not """Presence-based matcher: having an ingredient (not 'out') satisfies a slot
'out') satisfies a slot regardless of quantity.""" regardless of quantity."""
def setUp(self): def setUp(self):
self.client = Client() super().setUp()
self.noodles = Ingredient.objects.create(name="noodles", default_unit="nests") self.noodles = Ingredient.objects.create(name="noodles", default_unit="nests")
self.mr = MetaRecipe.objects.create(name="Noodles", method="boil") self.mr = MetaRecipe.objects.create(name="Noodles", method="boil")
slot = Slot.objects.create(meta_recipe=self.mr, name="carb", required=True) 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): def test_present_ingredient_no_quantity_is_available(self):
# 'in' with no quantity should still count as available.
PantryItem.objects.create( PantryItem.objects.create(
ingredient=self.noodles, location="cupboard", state="in" ingredient=self.noodles, location="cupboard", state="in"
) )
@@ -98,7 +123,7 @@ class RecipesPresenceTests(TestCase):
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
class PageSmokeTests(TestCase): class PageSmokeTests(_AuthedTestCase):
def test_pages_render(self): def test_pages_render(self):
for name in ("app-pantry", "app-recipes", "app-shopping", "app-log"): for name in ("app-pantry", "app-recipes", "app-shopping", "app-log"):
with self.subTest(page=name): with self.subTest(page=name):
-9
View File
@@ -7,7 +7,6 @@ from decimal import Decimal
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404 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 django.views.decorators.http import require_POST, require_http_methods
from .models import ( from .models import (
@@ -221,7 +220,6 @@ def log_page(request):
# --- HTMX Actions --- # --- HTMX Actions ---
@csrf_exempt
@require_POST @require_POST
def pantry_add(request): def pantry_add(request):
"""Add an item (or restock an existing one to 'in'). Quantity is optional.""" """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()) return render(request, "kitchen/partials/pantry_table.html", _pantry_context())
@csrf_exempt
@require_POST @require_POST
def pantry_set_state(request, item_id): def pantry_set_state(request, item_id):
"""Set an item's In/Low/Out state — the primary pantry interaction.""" """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"]) @require_http_methods(["DELETE"])
def pantry_delete(request, item_id): def pantry_delete(request, item_id):
item = get_object_or_404(PantryItem, id=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) return render(request, "kitchen/partials/pantry_table.html", ctx)
@csrf_exempt
@require_POST @require_POST
def pantry_move(request, item_id): def pantry_move(request, item_id):
"""Move an item between fridge / freezer / cupboard.""" """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()) return render(request, "kitchen/partials/pantry_table.html", _pantry_context())
@csrf_exempt
@require_POST @require_POST
def pantry_save_expiry(request, item_id): def pantry_save_expiry(request, item_id):
"""Set or clear an item's expiry date (inline editor in the item menu).""" """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()) return render(request, "kitchen/partials/pantry_table.html", _pantry_context())
@csrf_exempt
@require_POST @require_POST
def shopping_generate(request): def shopping_generate(request):
"""Generate smart shopping list and return updated HTML.""" """Generate smart shopping list and return updated HTML."""
@@ -420,7 +413,6 @@ def shopping_generate(request):
}) })
@csrf_exempt
@require_POST @require_POST
def shopping_toggle(request, item_id): def shopping_toggle(request, item_id):
item = get_object_or_404(ShoppingListItem, id=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()}) return render(request, "kitchen/partials/shopping_list.html", {"items": _shopping_list_items()})
@csrf_exempt
@require_POST @require_POST
def shopping_clear(request): def shopping_clear(request):
ShoppingListItem.objects.filter(checked=True).delete() ShoppingListItem.objects.filter(checked=True).delete()