Ga naar hoofdinhoud

Wie mag wat: een knop is geen slot

Hier bouw je op verder: Door een dictionary lopen

Weet je dit niet meer zeker? Lees het terug voordat je verdergaat.

In je gastenboek staat bij een bericht een knop Verwijderen. Heb je opdracht 4 van Sessies gemaakt, dan staat hij alleen bij je eigen berichten, en dat voelt als beveiliging. Maar een knop doet maar één ding: een verzoek versturen. Wie dat verzoek zelf verstuurt, heeft de knop niet nodig, verborgen of niet.

Maakte je ook opdracht 5 van Sessies, dan heb je dit gat in je eigen gastenboek al gedicht. Deze reeks laat zien waarom die controle nodig was: de server van deze map heeft hem nog niet.

Doe het na, op je eigen server​

Laat de server uit les 1 draaien, en maak in dezelfde map een scriptje test_verwijderen.py. Het speelt twee bezoekers: Sara en Alex. Allebei maken ze een account, loggen ze in en plaatsen ze een bericht. Daarna verwijdert Alex dat van Sara.

import httpx

adres = "http://127.0.0.1:8000"

sara = httpx.Client(base_url=adres)
alex = httpx.Client(base_url=adres)

sara.post("/registreer", data={"naam": "Sara", "wachtwoord": "zon123"})
sara.post("/inloggen", data={"naam": "Sara", "wachtwoord": "zon123"})
alex.post("/registreer", data={"naam": "Alex", "wachtwoord": "maan456"})
alex.post("/inloggen", data={"naam": "Alex", "wachtwoord": "maan456"})

sara.post("/bericht", data={"bericht": "Hoi allemaal"})
alex.post("/bericht", data={"bericht": "Leuk gastenboek"})

for sleutel, bericht in alex.get("/berichten").json().items():
if bericht["naam"] == "Sara":
antwoord = alex.delete(f"/bericht/{sleutel}")
print(f"Alex verwijdert het bericht van Sara: {antwoord.status_code}")

namen = []
for bericht in alex.get("/berichten").json().values():
namen.append(bericht["naam"])
print(f"Nog in het gastenboek: {namen}")
  1. Regel 5-6:

    Twee httpx.Clients zijn twee bezoekers, elk met hun eigen cookies en dus een eigen sessie, zoals in Een bezoeker die onthoudt.

  2. Regel 8-11:

    Elk een account en een inlog. Na het inloggen bewaart elke Client zijn eigen cookie sessie_id.

  3. Regel 16-19:

    Alex vraagt de berichten op, zoals elke bezoeker kan, en stuurt voor het bericht van Sara een DELETE: alex.delete doet dat, zoals alex.post een POST stuurt. De knop in de browser verstuurt hetzelfde verzoek.

  4. Regel 21-24:

    Van elk bericht dat over is alleen de naam, want de sleutels zijn bij jou anders.

Draai het in een tweede terminal.

Alex hoefde niets te raden of te kraken: hij logde in als zichzelf, en de sleutel stond in het antwoord van /berichten. Het endpoint deed wat Alex vroeg.

Je server weet precies wie Alex is: de naam staat in zijn sessie. Alleen vraagt bericht_verwijderen er niet naar. Een verborgen knop, zoals in opdracht 4 van Sessies, maakt dat alleen minder zichtbaar.

Controlevraag
Je zet in app.js een controle: de knop stuurt alleen een DELETE als het bericht van jou is. Alex draait daarna test_verwijderen.py nog een keer. Welke statuscode krijgt hij?
Kies wat jij denkt. Bij het goede antwoord zie je de uitleg.

Er gaat iets mis​

Twee keer Alex, of meer namen​

Het script print Nog in het gastenboek: ['Alex', 'Alex'], of er staan namen bij die je niet kent.

Oorzaak: de databases van een vorige keer staan er nog. Draai je het script een tweede keer, dan plaatst Alex opnieuw een bericht, naast dat van de eerste keer. Start je de server in een andere map, zoals je eigen project, dan ziet het script ook de berichten van daar.

Oplossing: werk in de map veiligheid-toegang uit les 1, en gooi gastenboek.db, sessies.db en gebruikers.db weg voordat je het script opnieuw draait.

Eerst op papier

Zoek eerst zonder computer bij zes verzoeken de controle die de server beschermt: Vertrouw de bezoeker niet. Je docent kan de hand-out printen.

Opdrachten​

Opdracht 1: Predict - Zonder browser​

Een derde script heeft geen account, is nooit ingelogd en gebruikt geen Client. Het kent de sleutel van een bericht en stuurt alleen dit:

import httpx

sleutel = "bericht_1767000000000000000"
antwoord = httpx.delete(f"http://127.0.0.1:8000/bericht/{sleutel}")
print(antwoord.status_code)

Vraag: als dat bericht bestaat, wat print het script?

Tip

Kijk welke parameters bericht_verwijderen heeft. Komt daar een cookie in voor?

Antwoord

200. Het endpoint kijkt alleen naar de sleutel in het pad. Wie het verzoek stuurt, ingelogd of niet, maakt voor deze server niets uit. Plaatsen kan alleen met een account; verwijderen kan iedereen.

Opdracht 2: Investigate - Waar staat de sleutel?​

Kijk naar de HTML van het recept Verwijderen in het htmx-overzicht: het stukje met <li id="bericht-{{ sleutel }}">, tot en met de knop. Heb je dat recept gebouwd, open dan ook je gastenboek en kijk in het tabblad Elementen naar een bericht. Waar staat de sleutel? Wat betekent dat voor een idee als "maak de sleutels heel lang, dan raadt niemand ze"?

Antwoord

De sleutel staat vier keer bij elk bericht: in id="bericht-…", in de link naar de detailpagina href="/bericht/…", en in de knop bij hx-delete en hx-target. Ook als je de knop verbergt, staat hij in het id en in de link, en /berichten geeft hem ook terug. Een lange sleutel is dus geen slot: je hoeft hem niet te raden, want de server geeft hem zelf. De controle moet in het endpoint, en dat doe je in les 3.

Zo ziet je main.py er nu uit
import secrets
import time

from fastapi import Cookie, FastAPI, Form, HTTPException
from fastapi.responses import JSONResponse
from sqlitedict import SqliteDict

app = FastAPI()

@app.post("/registreer")
async def registreer(naam: str = Form(...), wachtwoord: str = Form(...)):
with SqliteDict("gebruikers.db") as gebruikers:
if naam in gebruikers:
raise HTTPException(status_code=400, detail="Deze naam is al bezet")
gebruikers[naam] = wachtwoord
gebruikers.commit()
return {"bericht": f"Welkom, {naam}"}

@app.post("/inloggen")
async def inloggen(naam: str = Form(...), wachtwoord: str = Form(...)):
with SqliteDict("gebruikers.db") as gebruikers:
opgeslagen = gebruikers.get(naam)
if opgeslagen != wachtwoord:
raise HTTPException(status_code=401, detail="Naam of wachtwoord klopt niet")

sessie_id = secrets.token_hex(16)
with SqliteDict("sessies.db") as sessies:
sessies[sessie_id] = {"naam": naam}
sessies.commit()

antwoord = JSONResponse({"bericht": f"Ingelogd als {naam}"})
antwoord.set_cookie(key="sessie_id", value=sessie_id, max_age=60 * 60 * 24 * 30)
return antwoord

@app.post("/bericht")
async def bericht_plaatsen(bericht: str = Form(...), sessie_id: str = Cookie(default="")):
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {})
if not mijn:
raise HTTPException(status_code=401, detail="Log eerst in")

sleutel = f"bericht_{time.time_ns()}"
with SqliteDict("gastenboek.db") as db:
db[sleutel] = {"naam": mijn["naam"], "bericht": bericht}
db.commit()
return {"sleutel": sleutel}

@app.get("/berichten")
async def berichten():
with SqliteDict("gastenboek.db") as db:
return dict(db)

@app.delete("/bericht/{sleutel}")
async def bericht_verwijderen(sleutel: str):
with SqliteDict("gastenboek.db") as db:
if sleutel not in db:
raise HTTPException(status_code=404, detail="Dit bericht bestaat niet")
del db[sleutel]
db.commit()
return {"bericht": "Verwijderd"}

Dit is de zwakke versie: bericht_verwijderen vraagt niet wie er komt.

Door naar les 3: de controle met 403.