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
- Regel 2:
Het sessie-id uit de cookie, net als bij verwijderen.
- Regel 3-6:
De sessie gaat weg op de server. Daarna hoort dit sessie-id bij niemand meer.
- Regel 8:
delete_cookiezegt 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)
- Regel 6-7:
Sara maakt een account en logt in, zoals in Wie mag wat. Daarna heeft haar
Clientde cookiesessie_id. - Regel 8:
/berichtgeeft de sleutel van het nieuwe bericht terug. Het script bewaart hem om straks te verwijderen. - Regel 9:
Het sessie-id van Sara, bewaard voordat ze uitlogt.
sara.cookieszijn de cookies die haarClientbewaart, en.gethaalt er één uit, net als bij een dictionary. Zo heeft een dief het id als hij het eerder heeft gekopieerd. - Regel 10-11:
Sara logt uit, en het script kijkt of haar
Clientde cookie nog heeft. - Regel 13-14:
Een tweede
Clientkrijgt het bewaarde id metcookies=mee, zoalsdief.pyin 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.
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
Opdracht 1: Investigate - Hoe haalt de server een cookie weg?
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.