Ga naar hoofdinhoud

Cookies afschermen: uitloggen

Een sessie hoort niet eeuwig te duren. Hij eindigt op twee manieren: de bezoeker logt uit, of de tijd is om. In allebei de gevallen beslist je server, niet de browser. Eerst uitloggen; de tijd komt in les 6. De cookie weghalen is niet genoeg: wie het sessie-id eerder heeft gekopieerd, stuurt het zelf mee. Pas als de sessie uit sessies.db weg is, hoort dat id bij niemand meer.

Een endpoint om uit te loggen​

Heb je opdracht 5 van Cookie of sessie? gemaakt, dan ken je /uitloggen al. Het is een POST, en dat is geen toeval: een link is altijd een GET, en een link kan ook op een andere site staan. Hoe dat zit, zag je in les 4.

Zet dit endpoint onder de andere in main.py:

@app.post("/uitloggen")
async def uitloggen(sessie_id: str = Cookie(default="")):
with SqliteDict("sessies.db") as sessies:
if sessie_id in sessies:
del sessies[sessie_id]
sessies.commit()
antwoord = JSONResponse({"bericht": "Uitgelogd"})
antwoord.delete_cookie("sessie_id")
return antwoord
  1. Regel 2:

    Het sessie-id uit de cookie, net als bij verwijderen.

  2. Regel 3-6:

    De sessie gaat weg op de server. Daarna hoort dit sessie-id bij niemand meer.

  3. Regel 8:

    delete_cookie zegt tegen de browser: gooi deze cookie weg.

Geef static/formulier.html een knop om uit te loggen. Het is een vierde formulier, met method="post", onder de andere drie:

<!doctype html>
<html>
<body>
<form method="post" action="/registreer">
<input type="text" name="naam" placeholder="Je naam" required>
<input type="password" name="wachtwoord" placeholder="Wachtwoord" required>
<button type="submit">Registreer</button>
</form>
<form method="post" action="/inloggen">
<input type="text" name="naam" placeholder="Je naam" required>
<input type="password" name="wachtwoord" placeholder="Wachtwoord" required>
<button type="submit">Inloggen</button>
</form>
<form method="post" action="/bericht">
<input type="text" name="bericht" placeholder="Je bericht" required>
<button type="submit">Verstuur</button>
</form>
<form method="post" action="/uitloggen">
<button>Uitloggen</button>
</form>
</body>
</html>

Doe de test​

Een script uitloggen.py logt in als Sara, plaatst een bericht en bewaart haar sessie-id. Daarna logt Sara uit, en probeert een dief met dat bewaarde id haar bericht te verwijderen.

Het script maakt Sara aan met het wachtwoord zon123. Gooi daarom eerst gastenboek.db, sessies.db en gebruikers.db weg en start de server opnieuw.

import httpx

adres = "http://127.0.0.1:8000"

sara = httpx.Client(base_url=adres)
sara.post("/registreer", data={"naam": "Sara", "wachtwoord": "zon123"})
sara.post("/inloggen", data={"naam": "Sara", "wachtwoord": "zon123"})
sleutel = sara.post("/bericht", data={"bericht": "Hoi"}).json()["sleutel"]
oud_id = sara.cookies.get("sessie_id")
print(sara.post("/uitloggen").json())
print("Cookie na uitloggen:", sara.cookies.get("sessie_id"))

dief = httpx.Client(base_url=adres, cookies={"sessie_id": oud_id})
print("Met het oude sessie-id:", dief.delete(f"/bericht/{sleutel}").status_code)
  1. Regel 6-7:

    Sara maakt een account en logt in, zoals in Wie mag wat. Daarna heeft haar Client de cookie sessie_id.

  2. Regel 8:

    /bericht geeft de sleutel van het nieuwe bericht terug. Het script bewaart hem om straks te verwijderen.

  3. Regel 9:

    Het sessie-id van Sara, bewaard voordat ze uitlogt. sara.cookies zijn de cookies die haar Client bewaart, en .get haalt er één uit, net als bij een dictionary. Zo heeft een dief het id als hij het eerder heeft gekopieerd.

  4. Regel 10-11:

    Sara logt uit, en het script kijkt of haar Client de cookie nog heeft.

  5. Regel 13-14:

    Een tweede Client krijgt het bewaarde id met cookies= mee, zoals dief.py in les 2, en probeert het bericht van Sara te verwijderen.

Draai het script.

Sara heeft geen cookie meer, en de server kent haar sessie niet meer. Wie het oude id nog heeft, is voor de server niet ingelogd.

Controlevraag
Sara sluit haar browser zonder op Uitloggen te klikken. Een dief stuurt met een eerder gekopieerd id een DELETE voor haar bericht. Welke statuscode volgt?
Kies wat jij denkt. Bij het goede antwoord zie je de uitleg.

Er gaat iets mis​

Met het oude sessie-id: 200​

Na het uitloggen is de cookie weg, maar uitloggen.py geeft Met het oude sessie-id: 200.

Oorzaak: het endpoint doet alleen delete_cookie. De browser gooit de cookie weg, maar voor de server is de sessie nog geldig. Wie dat sessie-id nog heeft, bijvoorbeeld omdat het was uitgelezen voordat je httponly gebruikte, komt er nog steeds mee binnen.

Oplossing: gooi altijd ook de sessie op de server weg, zoals in regel 3 tot en met 6 van uitloggen.

KeyError: 'sleutel'​

Het script stopt bij de regel met sleutel = met KeyError: 'sleutel'.

Oorzaak: Sara bestond al, met een ander wachtwoord. Dan mislukt het inloggen, weigert /bericht met Log eerst in, en staat er in het antwoord geen sleutel.

Oplossing: gooi gastenboek.db, sessies.db en gebruikers.db weg, start de server opnieuw en draai het script nog een keer.

Opdrachten​

Open http://127.0.0.1:8000/static/formulier.html. Je gooide gebruikers.db net weg, dus maak eerst met het eerste formulier opnieuw een account. Log in, ga terug en klik op Uitloggen. Klik in het tabblad Netwerk op het verzoek uitloggen en zoek onder Reactieheaders de regel set-cookie. Hoe vertelt de server de browser dat de cookie weg moet?

Antwoord
sessie_id=""; expires=…; Max-Age=0; Path=/; SameSite=lax

De server zet de cookie opnieuw, met een lege waarde en Max-Age=0: houdbaar voor nul seconden. De browser gooit hem dan meteen weg. Een apart commando om een cookie te verwijderen bestaat niet.

Opdracht 2: Predict - Uitloggen zonder in te loggen​

Een nieuwe bezoeker, zonder cookie, stuurt POST /uitloggen.

Vraag: wat antwoordt de server?

Antwoord

{'bericht': 'Uitgelogd'}. Het sessie-id is leeg, dus if sessie_id in sessies is niet waar en er gaat niets weg. Daarna haalt delete_cookie een cookie weg die er niet was. Er gaat niets mis: uitloggen als je niet bent ingelogd, doet niets.

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 fastapi.staticfiles import StaticFiles
from sqlitedict import SqliteDict

app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")

@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,
httponly=True,
samesite="lax",
)
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, sessie_id: str = Cookie(default="")):
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {})

with SqliteDict("gastenboek.db") as db:
bericht = db.get(sleutel)
if bericht is None or bericht["naam"] != mijn.get("naam"):
raise HTTPException(status_code=403, detail="Dit is niet jouw bericht")
del db[sleutel]
db.commit()
return {"bericht": "Verwijderd"}

@app.delete("/berichten")
async def alles_wissen(sessie_id: str = Cookie(default="")):
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {})
with SqliteDict("gastenboek.db") as db:
van_mij = []
for sleutel, bericht in db.items():
if bericht["naam"] == mijn.get("naam"):
van_mij.append(sleutel)
for sleutel in van_mij:
del db[sleutel]
db.commit()
return {"bericht": "Jouw berichten zijn gewist"}

@app.post("/uitloggen")
async def uitloggen(sessie_id: str = Cookie(default="")):
with SqliteDict("sessies.db") as sessies:
if sessie_id in sessies:
del sessies[sessie_id]
sessies.commit()
antwoord = JSONResponse({"bericht": "Uitgelogd"})
antwoord.delete_cookie("sessie_id")
return antwoord

Uitloggen beëindigt de sessie nu op de server. Wie niet uitlogt, houdt zijn sessie in sessies.db nog eeuwig: de 30 dagen van max_age gelden alleen voor de cookie in de browser. Dat los je op in les 6.

Door naar les 6: een sessie laten verlopen.