Ga naar hoofdinhoud

Onthouden op de server: sessies

Hier bouw je op verder: Een dictionary in een dictionary

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

In opdracht 3 van de vorige les veranderde je je eigen cookie in een andere naam, en je server geloofde het meteen. Daar kon weinig mis, want je wachtwoord moest nog kloppen. Maar je wilt niet bij elk bericht je wachtwoord typen: je wilt één keer inloggen, en dat je server dat onthoudt. Met de naam in een cookie kan dat niet. Daarvoor heb je een sessie nodig.

Stel dat je server na het inloggen een cookie naam=sara zet, en bij elk volgend bericht gelooft dat je Sara bent. Dan typt Alex in het tabblad App sara in zijn eigen cookie, en schrijft hij als Sara, zonder haar wachtwoord ooit te kennen.

Hier geldt de vuistregel uit Server of browser?: wat op de computer van de bezoeker staat, mag je niet vertrouwen. Wat op jouw server staat wel.

Je maakt de cookie dus leeg van betekenis. In plaats van de naam zet je er een sessie-id in: een lang, willekeurig getal dat verder niets betekent. Wie er bij dat getal is ingelogd, bewaar je op de server, met het sessie-id als sleutel.

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

main.pydraait op de server
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, want daar zijn te veel mogelijkheden voor.

Inloggen maakt een sessie​

Inloggen wordt een eigen endpoint: één keer naam en wachtwoord, en daarna een sessie. Zet import secrets bij je imports, en dit endpoint onder je andere endpoints:

main.pydraait op de server
@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 = RedirectResponse(url="/gastenboek", status_code=303)
antwoord.set_cookie(key="sessie_id", value=sessie_id, max_age=60 * 60 * 24 * 30)
return antwoord
  1. Regel 3-6:

    Dezelfde controle als in je POST /gastenboek uit Inloggen: een wachtwoord controleren. Pas als het wachtwoord klopt, gaat de functie verder.

  2. Regel 8:

    Elke keer dat iemand inlogt, maakt je server een nieuw sessie-id.

  3. Regel 9-11:

    Een tweede database, alleen voor sessies. Onder het sessie-id staat een dictionary met de naam. Die naam zette je server er zelf in, nadat het wachtwoord klopte; de bezoeker kan er niet bij. Het is een dictionary en geen losse naam, zodat er later meer bij kan.

  4. Regel 13-15:

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

Het formulier: inloggen of schrijven​

Je GET /gastenboek zoekt nu de sessie op. Vervang hem door:

main.pydraait op de server
@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 2:

    De cookie heet nu sessie_id en bevat alleen het nummer. Zonder cookie is hij leeg.

  2. 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.

  3. Regel 5:

    Ook mijn.get("naam", "") heeft een standaardwaarde: wie niet is ingelogd, krijgt een lege naam.

In templates/gastenboek.html kiest de template met de naam welk formulier je ziet. Vervang je formulier door:

templates/gastenboek.htmlde server vult hem in, de browser toont hem
{% if naam %}
<p>Je schrijft als {{ naam }}.</p>
<form method="post" action="/gastenboek">
<input type="text" name="bericht" required>
<button type="submit">Verstuur</button>
</form>
{% else %}
<form method="post" action="/inloggen">
<input type="text" name="naam" required>
<input type="password" name="wachtwoord" required>
<button type="submit">Inloggen</button>
</form>
<a href="/registreer">Nog geen account?</a>
{% endif %}

Het formulier om te schrijven heeft geen naam en geen wachtwoord meer. Heeft jouw formulier hx-post en hx-target, of een id voor je tekenteller, laat die dan staan. Staat de <script> voor app.js in je <head>, dan toont de Console zonder inloggen een rode regel: het inlogformulier heeft geen veld voor de teller. Zet de <script> dan binnen het {% if naam %}-blok, onder het formulier om te schrijven. Het inlogformulier houdt method en action, zonder htmx: na het inloggen stuurt je server de browser door, en dan hoort de hele pagina te verversen.

Een bericht op naam van de sessie​

Vervang ook je POST /gastenboek. De controle op de lengte blijft bovenaan; de naam komt nu uit de sessie:

main.pydraait op de server
@app.post("/gastenboek")
async def gastenboek_opslaan(bericht: str = Form(...), sessie_id: str = Cookie(default="")):
if len(bericht) > 80:
raise HTTPException(status_code=400, detail="Bericht is te lang")

with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {})
if not mijn:
raise HTTPException(status_code=401, detail="Log eerst in")

with SqliteDict("gastenboek.db") as db:
db[f"bericht_{time.time_ns()}"] = {"naam": mijn["naam"], "bericht": bericht}
db.commit()
return RedirectResponse(url="/berichten", status_code=303)
  1. Regel 2:

    naam en wachtwoord zijn weg uit de parameters. Alleen het bericht komt nog uit het formulier, en het sessie-id uit de cookie.

  2. Regel 6-9:

    Zonder sessie ben je niet ingelogd, en dan antwoordt je server met een 401, net als bij een fout wachtwoord.

  3. Regel 11-13:

    De naam bij het bericht komt uit de sessie. Die zette je server er bij het inloggen in, dus hij klopt.

De cookie naam uit de vorige les zet je server niet meer. Gebruik je het htmx-formulier, dan blijft je HTMLResponse het antwoord in plaats van de omleiding; zet daarin escape(mijn["naam"]) waar escape(naam) stond.

Testen​

  1. Ga naar /gastenboek. Je ziet het inlogformulier. Log in als sara.
  2. Je komt terug op /gastenboek, met "Je schrijft als sara". Plaats twee berichten, zonder één keer je wachtwoord te typen.
  3. Kijk in het tabblad App naar de cookies: sessie_id bevat een rij tekens in plaats van een naam.
  4. Staat de cookie naam uit de vorige les er ook nog? Die houdt 30 dagen, want je server haalt hem niet weg. Selecteer hem en druk op Delete; je server gebruikt hem niet meer.
Gaat er iets mis?

Zie je na het inloggen weer het inlogformulier? Staat er in het tabblad App wel een cookie sessie_id, kijk dan of sessies.commit() in je /inloggen staat. Staat er geen cookie, kijk dan of set_cookie op het antwoord staat dat je ook echt returnt. Meer oplossingen staan op Er gaat iets mis.

Controlevraag
Alex is ingelogd en heeft het formulier om te schrijven open. Hij verandert in App de waarde van zijn cookie sessie_id in sara, en verstuurt dan een bericht. Wat zie je in Netwerk bij POST /gastenboek?
Kies wat jij denkt. Bij het goede antwoord zie je de uitleg.
Controleer je server

Laat je server draaien en typ in een tweede terminal python controleer.py onthouden. Het script logt in, kijkt of je server de sessie onthoudt en of een bericht zonder sessie een 401 krijgt; je downloadt het op Project checken.

Opdrachten​

Opdracht 1: Predict - Een vreemd sessie-id​

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

Voorspel
Wat ziet de bezoeker, en wat gebeurt er in sessies.db?
Kies wat jij denkt. Bij het goede antwoord zie je de uitleg.

Opdracht 2: Run​

Bouw /inloggen, het nieuwe formulier en je GET- en POST-endpoint om naar sessies. Log in, plaats twee berichten en bekijk de cookie in het tabblad App. Hoe vaak moest je je wachtwoord typen?

Antwoord

Eén keer, bij het inloggen. Daarna stuurt je browser bij elk verzoek de cookie sessie_id mee, en vindt je server daarmee je naam in sessies.db.

Opdracht 3: Investigate - Kijk in je sessies​

Maak een tijdelijk endpoint dat alles in sessies.db als JSON teruggeeft. Log in, en log daarna in een privévenster nog een keer in als dezelfde gebruiker. Wat zie je?

Tip

In Elk bericht bewaren: een eigen sleutel gaf /berichten de database als JSON terug, met list(db.values()). Hier wil je ook de sessie-id's zien: dict(sessies) maakt van de hele database een dictionary, met elke sleutel en zijn waarde.

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

Je ziet twee sessie-id's, elk met {"naam": "sara"}. Elke inlog maakt een nieuwe sessie, ook voor dezelfde gebruiker: het privévenster 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 is iemand voor je server ingelogd als die bezoeker, zonder wachtwoord.

Opdracht 4: Make - Alleen je eigen berichten verwijderen​

Toon op /berichten de verwijderknop uit Een bericht verwijderen: een verborgen veld alleen bij berichten van de bezoeker die is ingelogd.

Tip

Geef de naam uit de sessie mee aan de template, en gebruik in je for-lus {% if bericht.naam == naam %}.

Antwoord

In je GET /berichten komen drie dingen bij. Zet de parameter sessie_id: str = Cookie(default="") erbij, naast request: Request. Zet vóór de return het uitlezen van de sessie:

main.pydraait op de server
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {})

En zet in de dictionary voor de template "naam": mijn.get("naam", "") naast "berichten".

In templates/berichten.html komt een {% if %} om het formuliertje. De link naar de detailpagina blijft staan:

templates/berichten.htmlde server vult hem in, de browser toont hem
{% for sleutel, bericht in berichten %}
<li>
<a href="/bericht/{{ sleutel }}">{{ bericht.naam }}</a>: {{ bericht.bericht }}
{% if bericht.naam == naam %}
<form method="post" action="/verwijderen">
<input type="hidden" name="sleutel" value="{{ sleutel }}">
<button type="submit">Verwijderen</button>
</form>
{% endif %}
</li>
{% endfor %}

Gebruik je de htmx-recepten, dan staat de lijst in berichten_lijst.html. Zet de {% if %} daar om je hx-delete-knop, en geef ook GET /berichten/lijst de drie aanpassingen hierboven, anders zijn de knoppen na Ververs weg.

Wie niet is ingelogd, krijgt een lege naam, en geen enkel bericht heeft een lege naam: dan staat er nergens een knop. De knop verdwijnt bij berichten van anderen, 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 van de ingelogde bezoeker is.

Tip

Het POST-endpoint kan de cookie net zo goed opvragen als de GET. Vergelijk de naam bij het bericht met de naam uit de sessie voordat je iets verwijdert.

Antwoord

Vervang je POST /verwijderen door deze versie:

main.pydraait op de server
@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, {})
with SqliteDict("gastenboek.db") as db:
if sleutel in db:
if db[sleutel]["naam"] != mijn.get("naam"):
raise HTTPException(status_code=403, detail="Dit is niet jouw bericht")
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. De naam bij het bericht en de naam in de sessie heeft je server allebei zelf vastgelegd, nadat het wachtwoord klopte. Daarom mag hij ze vergelijken. Een naam uit een formulier of een cookie zou de bezoeker zelf kunnen kiezen. De verborgen knop uit opdracht 4 blijft nuttig, want die voorkomt dat iemand per ongeluk iets probeert wat toch niet mag. De echte afscherming zit hier.

Dat geldt alleen voor dit endpoint. Bouwde je het htmx-recept Verwijderen, dan verwijdert DELETE /bericht/{sleutel} nog steeds elk bericht: zet dezelfde parameter en dezelfde controle ook daarin. Een knop Alles wissen kan iedereen nog steeds gebruiken; hoe je elk endpoint apart afschermt, staat in Wie mag wat: elk endpoint apart.