Cookies afschermen: een sessie laten verlopen
In set_cookie staat max_age. Daarmee gooit de browser de cookie na 30 dagen
weg, maar sessies.db weet daar niets van. Een sessie-id dat iemand heeft
gekopieerd, blijft zo eeuwig geldig. Daarom bewaart de server in de sessie zelf
tot wanneer hij geldt, en kijkt hij daar bij elk verzoek naar.
Een sessie met een eindtijd
Zet onder de imports, boven app = FastAPI(), hoe lang een sessie geldt:
GELDIG = 60 * 60 * 24 * 30
Dat is 30 dagen in seconden, net als max_age. Nu staat het getal op één plek.
Zet onder app.mount(...) een functie die een sessie ophaalt en controleert:
def sessie_ophalen(sessie_id: str):
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {})
if mijn and mijn.get("tot", 0) < time.time():
del sessies[sessie_id]
sessies.commit()
return {}
return mijn
- Regel 3:
De sessie van deze bezoeker, of een lege dictionary als er geen is.
- Regel 4:
time.time()is het tijdstip van nu in seconden. Ligttotdaarvoor, dan is de sessie verlopen. Een sessie zondertottelt ook als verlopen. - Regel 5-7:
Een verlopen sessie gaat weg, en de functie doet alsof er geen sessie is.
In inloggen krijgt de nieuwe sessie een tot, en max_age gebruikt
GELDIG. Vervang je inloggen door:
@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, "tot": time.time() + GELDIG}
sessies.commit()
antwoord = JSONResponse({"bericht": f"Ingelogd als {naam}"})
antwoord.set_cookie(
key="sessie_id",
value=sessie_id,
max_age=GELDIG,
httponly=True,
samesite="lax",
)
return antwoord
In bericht_plaatsen, bericht_verwijderen en alles_wissen vervang je het
with-blok van sessies.db door één regel:
mijn = sessie_ophalen(sessie_id)
Doe de test met een verlopen sessie
Dertig dagen wachten is geen test. Het script verlopen.py zet daarom de tijd
van Sara's sessie in je eigen sessies.db op een moment dat al voorbij is, en
probeert dan haar bericht te verwijderen. Gooi ook nu eerst gastenboek.db,
sessies.db en gebruikers.db weg en start de server opnieuw, net als in
les 5.
import time
import httpx
from sqlitedict import SqliteDict
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"]
sessie_id = sara.cookies.get("sessie_id")
with SqliteDict("sessies.db") as sessies:
mijn = sessies[sessie_id]
mijn["tot"] = time.time() - 1
sessies[sessie_id] = mijn
sessies.commit()
print("Cookie nog bij Sara:", sara.cookies.get("sessie_id") is not None)
print("Verwijderen:", sara.delete(f"/bericht/{sleutel}").status_code)
- Regel 9-12:
Sara maakt een account, logt in en plaatst een bericht, zoals in
uitloggen.py. - Regel 14:
Het script opent dezelfde
sessies.dbals je server. Draai het daarom in je mapveiligheid-cookies. - Regel 15-18:
De sessie van Sara ophalen,
totop één seconde geleden zetten en de sessie terugzetten, zoals in Een dictionary aanpassen. - Regel 20-21:
De
Clientvan Sara heeft de cookie nog, en probeert er haar eigen bericht mee te verwijderen.
Draai het script in een tweede terminal.
De cookie staat er nog, net als bij een dief die het id heeft gekopieerd. Toch weigert de server, want de tijd in de sessie is voorbij.
Er gaat iets mis
Verwijderen: 200
Oorzaak: in bericht_verwijderen staat nog het oude with-blok van
sessies.db. Dat leest de sessie zonder naar tot te kijken.
Oplossing: vervang dat blok door mijn = sessie_ophalen(sessie_id), net als
in bericht_plaatsen en alles_wissen.
NameError: name 'GELDIG' is not defined
Het script stopt met een foutmelding van httpx, en in de terminal van je
server eindigt de foutmelding met die regel.
Oorzaak: inloggen gebruikt GELDIG, maar de regel GELDIG = 60 * 60 * 24 * 30
staat niet in main.py.
Oplossing: zet die regel onder de imports, boven app = FastAPI().
IndentationError in sessie_ophalen
fastapi dev stopt meteen, en de foutmelding wijst naar de regel
if mijn and mijn.get("tot", 0) < time.time(): en eindigt met:
IndentationError: unexpected indent
Oorzaak: je liet je editor het with-blok overal vervangen. De twee regels
staan ook bovenaan in sessie_ophalen zelf, en daar mogen ze niet weg. Nu
staat in sessie_ophalen mijn = sessie_ophalen(sessie_id), met de rest van
de functie nog ingesprongen. Haal je alleen de inspringing weg, dan start de
server wel, maar roept de functie zichzelf eindeloos aan: de terminal toont
RecursionError: maximum recursion depth exceeded, en verlopen.py stopt met
json.decoder.JSONDecodeError.
Oplossing: zet in sessie_ophalen het with-blok terug, zoals hierboven.
Vervang het alleen in de drie endpoints, één voor één.
KeyError bij sessies[sessie_id]
Het script stopt met KeyError en een lang getal: het sessie-id van Sara.
Oorzaak: je draaide verlopen.py in een andere map dan je server. Daar
maakt SqliteDict stil een nieuwe, lege sessies.db, en daarin staat Sara niet.
Oplossing: draai het script in je map veiligheid-cookies, waar ook
main.py staat.
Opdrachten
Opdracht 1: Predict - Een sessie zonder tot
Stel dat er in sessies.db nog een sessie staat van vóór deze les, zonder
tot. Dat gebeurt als je de databases niet weggooit. Met die sessie-cookie
verstuur je een bericht.
Vraag: wat antwoordt de server?
Tip
Kijk in sessie_ophalen wat mijn.get("tot", 0) geeft als er geen tot is.
Antwoord
{"detail":"Log eerst in"}, met 401. Zonder tot geeft mijn.get("tot", 0)
een 0, en dat ligt ver vóór nu. sessie_ophalen gooit de sessie weg en geeft
een lege dictionary, dus bericht_plaatsen ziet geen sessie. Wie opnieuw
inlogt, krijgt een sessie met een tot.
Opdracht 2: Investigate - Ziet de server hoe oud de cookie is?
Open het tabblad Netwerk. Maak met het formulier een account, want je
gooide gebruikers.db weg, log in en verstuur een bericht.
Klik op het verzoek bericht en zoek onder Verzoekheaders de regel
Cookie. Staat daar hoe lang de cookie nog geldt? Wat betekent dat voor
max_age?
Antwoord
Nee. Er staan alleen namen en waarden, zoals sessie_id=…. Max-Age gaat mee in
de set-cookie van de server naar de browser, maar niet terug: de browser
houdt die tijd voor zichzelf. Je server kan aan een verzoek dus niet zien hoe
oud een cookie is, en een script stuurt elk sessie-id mee dat het kent. Daarom
staat de tijd die telt in sessies.db.
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
GELDIG = 60 * 60 * 24 * 30
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
def sessie_ophalen(sessie_id: str):
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {})
if mijn and mijn.get("tot", 0) < time.time():
del sessies[sessie_id]
sessies.commit()
return {}
return mijn
@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, "tot": time.time() + GELDIG}
sessies.commit()
antwoord = JSONResponse({"bericht": f"Ingelogd als {naam}"})
antwoord.set_cookie(
key="sessie_id",
value=sessie_id,
max_age=GELDIG,
httponly=True,
samesite="lax",
)
return antwoord
@app.post("/bericht")
async def bericht_plaatsen(bericht: str = Form(...), sessie_id: str = Cookie(default="")):
mijn = sessie_ophalen(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="")):
mijn = sessie_ophalen(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="")):
mijn = sessie_ophalen(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
Een sessie eindigt nu op de server: bij uitloggen, of als de tijd om is. Wat de browser nog bewaart, verandert daar niets aan.
Door naar les 7: in je eigen project.