Fix redirect-lus achter TLS-proxy (v0.2.02 beta)
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled

Inloggen mislukte met DJANGO_SECURE=1 achter een reverse proxy die TLS
afhandelt, terwijl het inlogscherm zelf wel laadde.

- nginx.conf gaf `X-Forwarded-Proto $scheme` door. Achter een TLS-proxy
  komt het verzoek als http binnen, dus overschreef nginx de `https` van
  de proxy met `http`. Django zag de verbinding daardoor als onveilig en
  stuurde elk verzoek naar /api en /admin door naar https, waarna de
  proxy het weer als http aanleverde: een oneindige redirect-lus.
- nginx neemt nu de X-Forwarded-Proto van de proxy over via een map, en
  valt alleen terug op $scheme als die header ontbreekt. Werkt daardoor
  zowel achter een externe proxy als bij TLS op nginx zelf.
- .env.example: voorbeelden voor CSRF-origin en frontend-URL op https
  gezet, DJANGO_SECURE standaard op 1, met uitleg waarom een http-origin
  het inloggen met een CSRF-fout laat stranden.
- DEPLOY.md: checklist "Draaien achter TLS" toegevoegd met de drie
  vereisten (proxy-header, CSRF-origin, allowed hosts), commando's om te
  controleren of de header aankomt, en het advies poort 8080 aan de
  loopback te binden zodat de app niet buiten TLS om bereikbaar is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAQ4Np13v8fwbTtKHKnwDz
This commit is contained in:
Ramon 2026-08-13 21:41:47 +02:00
parent 4f0bc009bb
commit 1865de948e
6 changed files with 66 additions and 9 deletions

View file

@ -15,11 +15,17 @@ DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=rooster-test.example.nl,localhost,127.0.0.1 DJANGO_ALLOWED_HOSTS=rooster-test.example.nl,localhost,127.0.0.1
# Volledige origin(s) inclusief http(s):// voor CSRF achter nginx. # Volledige origin(s) inclusief http(s):// voor CSRF achter nginx.
DJANGO_CSRF_TRUSTED_ORIGINS=http://rooster-test.example.nl:8080 # LET OP: dit moet exact de origin zijn die de browser gebruikt. Draai je met
# TLS, dan hoort hier https:// te staan (en zonder poort als je op 443 zit).
# Staat hier http:// terwijl de browser https:// gebruikt, dan mislukt elke
# POST — dus ook het inloggen — met een CSRF-fout.
DJANGO_CSRF_TRUSTED_ORIGINS=https://rooster.example.nl
# Zet op 1 zodra de server achter HTTPS draait (TLS-certificaat aanwezig). # Zet op 1 zodra de server achter HTTPS draait (TLS-certificaat aanwezig).
# Activeert o.a. veilige cookies, HSTS en een redirect naar https. # Activeert o.a. veilige cookies, HSTS en een redirect naar https.
DJANGO_SECURE=0 # Vereist dat de reverse proxy X-Forwarded-Proto: https meestuurt; nginx.conf
# in dit project geeft die header door.
DJANGO_SECURE=1
# --- PostgreSQL ----------------------------------------------------------- # --- PostgreSQL -----------------------------------------------------------
POSTGRES_DB=rooster POSTGRES_DB=rooster
@ -37,5 +43,6 @@ EMAIL_HOST_PASSWORD=
EMAIL_USE_TLS=1 EMAIL_USE_TLS=1
DEFAULT_FROM_EMAIL=Roosterwijs <noreply@jouwschool.nl> DEFAULT_FROM_EMAIL=Roosterwijs <noreply@jouwschool.nl>
# Basis-URL van de frontend voor resetlinks in e-mails (zonder slash op het eind), # Basis-URL van de frontend voor resetlinks in e-mails (zonder slash op het eind),
# bv. http://rooster-test.example.nl:8080 (leeg = afleiden uit het verzoek). # bv. https://rooster.example.nl (leeg = afleiden uit het verzoek).
# Met TLS: gebruik https://, anders wijzen resetlinks naar een http-adres.
FRONTEND_BASE_URL= FRONTEND_BASE_URL=

View file

@ -146,6 +146,44 @@ Migraties draaien automatisch mee bij het opstarten van de backend.
je breidt de nginx-config uit met certificaten. De nginx-container is daar al je breidt de nginx-config uit met certificaten. De nginx-container is daar al
het logische punt voor. **Zet daarna `DJANGO_SECURE=1` in `.env`** — dat het logische punt voor. **Zet daarna `DJANGO_SECURE=1` in `.env`** — dat
activeert veilige cookies, HSTS en een https-redirect in Django. activeert veilige cookies, HSTS en een https-redirect in Django.
Zie hieronder wat er dan verder moet kloppen.
### Draaien achter TLS: checklist
Met `DJANGO_SECURE=1` moeten drie dingen kloppen, anders lukt **inloggen niet**
terwijl het inlogscherm zelf gewoon laadt (dat wordt door nginx geserveerd,
zonder tussenkomst van Django).
1. **De proxy moet `X-Forwarded-Proto: https` meesturen.** Django leest die
header (`SECURE_PROXY_SSL_HEADER`) om te bepalen of de verbinding veilig is.
Ontbreekt hij, dan denkt Django dat het http is en stuurt het elk verzoek
naar `/api` en `/admin` door naar https — waarna de proxy het weer als http
aanlevert: een oneindige redirect-lus (`ERR_TOO_MANY_REDIRECTS`) en dus een
mislukte login. Caddy en Traefik zetten deze header standaard; controleer het
als je een eigen nginx/Apache ervoor hebt.
2. **`DJANGO_CSRF_TRUSTED_ORIGINS` moet de https-origin zijn**, exact zoals de
browser hem gebruikt (bv. `https://rooster.example.nl`, zonder poort op 443).
Staat hier nog een `http://`-adres, dan geeft elke POST — inclusief het
inloggen — een CSRF-fout (HTTP 403).
3. **`DJANGO_ALLOWED_HOSTS` moet de publieke hostnaam bevatten**, anders volgt
een 400 Bad Request.
Controleren of de header goed aankomt:
```bash
docker compose logs backend | grep -i "Mislukte inlogpoging" # bereikt de login de backend?
curl -sI https://<jouw-domein>/api/auth/me/ | head -n 1 # 200/401 = goed, 301 = lus
```
Krijg je bij die `curl` een `301`, dan komt `X-Forwarded-Proto` niet goed door.
- **Beperk poort 8080 tot de proxy:** in `docker-compose.yml` staat
`"8080:80"`, wat op álle netwerkinterfaces luistert. Daardoor is de app ook
rechtstreeks via `http://<server>:8080` te bereiken — buiten TLS om. Draait je
reverse proxy op dezelfde machine, maak er dan `"127.0.0.1:8080:80"` van.
Draait de proxy op een andere host, laat het dan staan en zet er een firewall
op: nginx vertrouwt de `X-Forwarded-Proto` van de aanroeper.
- **Inloggen verplicht:** de hele API vereist een ingelogde gebruiker; de - **Inloggen verplicht:** de hele API vereist een ingelogde gebruiker; de
frontend toont eerst een inlogscherm. Maak gebruikers aan via frontend toont eerst een inlogscherm. Maak gebruikers aan via
`createsuperuser` (zie boven) of in de Django-admin. Modulebeheer is `createsuperuser` (zie boven) of in de Django-admin. Modulebeheer is

View file

@ -1,4 +1,16 @@
# Nginx: serveert de React-app en stuurt API/admin door naar gunicorn. # Nginx: serveert de React-app en stuurt API/admin door naar gunicorn.
# Draait er een reverse proxy vóór ons die TLS afhandelt (Caddy/Traefik), dan
# komt het verzoek hier als gewoon http binnen en is $scheme dus "http". Zouden
# we dat doorgeven, dan denkt Django dat de verbinding onveilig is en stuurt het
# met DJANGO_SECURE=1 elk verzoek door naar https een oneindige redirect-lus.
# Daarom: neem de X-Forwarded-Proto van de proxy over, en val alleen terug op
# $scheme als die header ontbreekt (nginx zelf aan de buitenkant, of geen TLS).
map $http_x_forwarded_proto $rw_forwarded_proto {
default $http_x_forwarded_proto;
"" $scheme;
}
server { server {
listen 80; listen 80;
server_name _; server_name _;
@ -33,7 +45,7 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $rw_forwarded_proto;
} }
# Django-admin loopt ook via de backend. # Django-admin loopt ook via de backend.
@ -42,7 +54,7 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $rw_forwarded_proto;
} }
# Alle overige paden: de single-page React-app. # Alle overige paden: de single-page React-app.

View file

@ -1,12 +1,12 @@
{ {
"name": "roosterwijs-frontend", "name": "roosterwijs-frontend",
"version": "0.2.1-beta", "version": "0.2.2-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "roosterwijs-frontend", "name": "roosterwijs-frontend",
"version": "0.2.1-beta", "version": "0.2.2-beta",
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"

View file

@ -1,7 +1,7 @@
{ {
"name": "roosterwijs-frontend", "name": "roosterwijs-frontend",
"private": true, "private": true,
"version": "0.2.1-beta", "version": "0.2.2-beta",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View file

@ -35,7 +35,7 @@ button,a,input,select{transition:border-color .15s,background-color .15s,color .
.cog-btn{width:100%;min-height:38px;justify-content:flex-start;font-size:15px;padding:0} .cog-btn{width:100%;min-height:38px;justify-content:flex-start;font-size:15px;padding:0}
.cog-btn::after{content:"Beheer en instellingen";margin-left:8px;font-size:13px} .cog-btn::after{content:"Beheer en instellingen";margin-left:8px;font-size:13px}
.cog-dropdown{position:static;min-width:0;margin:4px 0 0;box-shadow:none} .cog-dropdown{position:static;min-width:0;margin:4px 0 0;box-shadow:none}
.topbar::after{content:"v0.2.01 beta";order:4;margin:10px 8px 0;color:#98a2b3;font-size:10px} .topbar::after{content:"v0.2.02 beta";order:4;margin:10px 8px 0;color:#98a2b3;font-size:10px}
.content{margin-left:var(--sidebar);width:calc(100% - var(--sidebar));max-width:1680px;padding:32px 38px 48px} .content{margin-left:var(--sidebar);width:calc(100% - var(--sidebar));max-width:1680px;padding:32px 38px 48px}
.content-wide{padding:24px 28px 42px} .content-wide{padding:24px 28px 42px}
h1{font-size:clamp(24px,2.4vw,30px);line-height:1.2;letter-spacing:-.025em;font-weight:750} h1{font-size:clamp(24px,2.4vw,30px);line-height:1.2;letter-spacing:-.025em;font-weight:750}