Ga naar hoofdinhoud

Sessies

Hier bouw je op verder

In de vorige les veranderde je je eigen cookie in een andere naam, en je server geloofde het meteen. Voor een naam in een formulier maakt dat weinig uit. Maar zodra er rechten aan hangen — wie mag een bericht verwijderen — is het een probleem. In deze les los je dat op met een sessie.

Stel dat je op de berichtenpagina een verwijderknop wilt tonen, maar alleen bij berichten die de bezoeker zelf heeft geplaatst. Met de naam in de cookie werkt dat niet: iedereen kan er een andere naam in typen en zo bij de berichten van een ander komen.

De vuistregel uit Server of browser? geldt hier onverkort. Alles wat op de computer van de bezoeker staat, mag je niet vertrouwen. Alleen wat op jouw server staat, mag dat wel.

De oplossing is de cookie leeg te maken van betekenis. In plaats van de naam zet je er een sessie-id in: een lang, willekeurig getal dat verder niets betekent. De gegevens zelf bewaar je op de server, met dat sessie-id als sleutel.

Het sessie-id maak je met secrets, een ingebouwde module voor waarden die niet te raden mogen zijn:

import secrets

sessie_id = secrets.token_hex(16)

Dat geeft 32 tekens, bijvoorbeeld 9f2c4a7e10b8d3f5a6c1e0b4d7928f3a. Een bezoeker kan dat wel veranderen, maar niet in het sessie-id van iemand anders — daar zijn te veel mogelijkheden voor.

De sessie opslaan

Sessies bewaar je in een eigen database, los van je gastenboek:

Drie dingen gebeuren hier. Is er nog geen sessie-id, dan maak je er een. Het bericht gaat zoals altijd in gastenboek.db. En in sessies.db groeit onder dat ene sessie-id een lijst met de sleutels van de berichten die deze bezoeker heeft geplaatst.

@app.post("/gastenboek")
async def gastenboek_opslaan(
naam: str = Form(...),
bericht: str = Form(...),
sessie_id: str = Cookie(default=""),
):
if not sessie_id:
sessie_id = secrets.token_hex(16)

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

with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {"naam": naam, "berichten": []})
mijn["naam"] = naam
mijn["berichten"].append(sleutel)
sessies[sessie_id] = mijn
sessies.commit()

antwoord = RedirectResponse(url="/berichten", status_code=303)
antwoord.set_cookie(key="sessie_id", value=sessie_id, max_age=60 * 60 * 24 * 30)
return antwoord
  1. Regel 5:

    De cookie heet nu sessie_id en bevat alleen het nummer; bij een eerste bezoek is hij leeg.

  2. Regel 7-8:

    Geen sessie-id? Dan maak je er een, en verderop gaat het als cookie mee terug.

  3. Regel 10:

    De sleutel van het bericht staat nu in een variabele, want je hebt hem straks een tweede keer nodig.

  4. Regel 15-16:

    Een tweede database, alleen voor sessies. sessies.get(sessie_id, {"naam": naam, "berichten": []}) doet het werk bij een eerste bezoek: bestaat de sessie nog niet, dan krijg je een verse dictionary in plaats van een foutmelding.

  5. Regel 17-20:

    De naam bijwerken, de sleutel van dit bericht aan de lijst toevoegen, en de dictionary terugzetten onder het sessie-id. Dat terugzetten is nodig: een gewijzigde dictionary die je uit sessies haalde, schrijft zich niet vanzelf terug.

  6. Regel 23:

    Dezelfde set_cookie als in de vorige les, maar met het sessie-id als waarde in plaats van de naam.

De sessie uitlezen

Ophalen gaat omgekeerd: sessie-id uit de cookie, gegevens uit de database.

@app.get("/gastenboek")
async def gastenboek_form(request: Request, sessie_id: str = Cookie(default="")):
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {})
return templates.TemplateResponse(request, "gastenboek.html", {"naam": mijn.get("naam", "")})
  1. Regel 4:

    Het sessie-id uit de cookie is de sleutel in sessies.db. Een onbekend of leeg id geeft een lege dictionary, geen KeyError.

  2. Regel 5:

    Ook mijn.get("naam", "") heeft een standaardwaarde, zodat een eerste bezoek een leeg veld toont in plaats van een foutmelding.

Voor de bezoeker verandert er niets: de naam staat nog steeds voorgevuld. Het verschil zit erin dat die naam nu van jouw server komt en niet van een computer waar je niets over te zeggen hebt.

Testen

  1. Vul het gastenboek in en ga terug naar /gastenboek — je naam staat er nog
  2. Kijk in het tabblad App naar de cookie: daar staat nu een rij tekens in plaats van je naam
  3. Verander die rij tekens en herlaad de pagina
Gaat er iets mis?

Krijg je KeyError bij het uitlezen? Gebruik sessies.get(sessie_id, {}) en niet sessies[sessie_id] — bij een onbekend sessie-id bestaat die sleutel niet. Bekijk de troubleshooting pagina voor meer oplossingen.

Opdrachten

Opdracht 1: Predict - Een vreemd sessie-id

Je verandert de cookie sessie_id in hallo en vraagt /gastenboek op.

Vraag: wat ziet de bezoeker, en wat gebeurt er in sessies.db?

Tip

Kijk naar de tweede waarde in sessies.get(sessie_id, {}).

Antwoord

De bezoeker ziet een leeg naamveld: onder de sleutel hallo staat niets, dus get geeft de lege dictionary terug. In sessies.db verandert er niets — uitlezen schrijft niet.

Dat is precies de bedoeling. Wie aan de eigen cookie sleutelt, wordt behandeld als een nieuwe bezoeker en komt niet in de sessie van iemand anders terecht.

Opdracht 2: Run

Bouw je POST- en GET-endpoint om naar sessies. Verstuur een bericht, bekijk de cookie in het tabblad App en controleer dat je naam nog steeds wordt onthouden.

Opdracht 3: Investigate - Kijk in je sessies

Maak een tijdelijk endpoint dat alles in sessies.db als JSON teruggeeft, en bekijk het na twee berichten.

Tip

Zo'n endpoint ken je al uit POST naar database: open de database en geef dict(sessies) terug.

Antwoord
@app.get("/sessies")
async def alle_sessies():
with SqliteDict("sessies.db") as sessies:
return dict(sessies)

Je ziet één sessie-id met daaronder je naam en een lijst met twee berichtsleutels. Open je dezelfde site in een privévenster en plaats je daar een bericht, dan komt er een tweede sessie-id bij: die browser stuurt jouw cookie niet mee.

Haal dit endpoint daarna weg. Op een echte site zou je hiermee de sessies van al je bezoekers weggeven, en met een sessie-id kan iemand zich voordoen als die bezoeker.

Opdracht 4: Make - Alleen je eigen berichten verwijderen

Toon op /berichten de verwijderknop uit Terug naar de lijst alleen bij berichten die in de sessie van deze bezoeker staan.

Tip

Geef de lijst met eigen berichtsleutels mee aan de template, en gebruik in je for-lus {% if sleutel in mijn_berichten %}.

Antwoord
@app.get("/berichten")
async def berichten(request: Request, sessie_id: str = Cookie(default="")):
with SqliteDict("gastenboek.db") as db:
alle_berichten = list(db.items())
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {})
return templates.TemplateResponse(
request,
"berichten.html",
{"berichten": alle_berichten, "mijn_berichten": mijn.get("berichten", [])},
)

In templates/berichten.html:

{% for sleutel, bericht in berichten %}
<li>
{{ bericht.naam }}: {{ bericht.bericht }}
{% if sleutel in mijn_berichten %}
<form method="post" action="/verwijderen">
<input type="hidden" name="sleutel" value="{{ sleutel }}">
<button type="submit">Verwijderen</button>
</form>
{% endif %}
</li>
{% endfor %}

De knop verdwijnt bij berichten van anderen. Let op wat dit wél en niet is: de knop is weg, maar het endpoint /verwijderen verwijdert nog steeds alles wat het binnenkrijgt. Wie het formulier nabouwt, komt er alsnog bij.

Opdracht 5: Make - Echt afschermen

Zorg dat /verwijderen een bericht alleen weggooit als het in de sessie van de bezoeker staat.

Tip

Het POST-endpoint kan de cookie net zo goed opvragen als de GET. Controleer de sleutel tegen de lijst uit de sessie voordat je iets verwijdert.

Antwoord
@app.post("/verwijderen")
async def verwijderen(sleutel: str = Form(...), sessie_id: str = Cookie(default="")):
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {})
if sleutel not in mijn.get("berichten", []):
raise HTTPException(status_code=403, detail="Dit is niet jouw bericht")

with SqliteDict("gastenboek.db") as db:
if sleutel in db:
del db[sleutel]
db.commit()
return RedirectResponse(url="/berichten", status_code=303)

403 Forbidden betekent: ik weet wie je bent, maar dit mag je niet. Nu ligt de controle op de server, waar de bezoeker er niet bij kan. De verborgen knop uit opdracht 4 blijft nuttig — die voorkomt dat iemand per ongeluk iets probeert wat toch niet mag — maar de echte afscherming zit hier.