Ga naar hoofdinhoud

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
  1. Regel 3:

    De sessie van deze bezoeker, of een lege dictionary als er geen is.

  2. Regel 4:

    time.time() is het tijdstip van nu in seconden. Ligt tot daarvoor, dan is de sessie verlopen. Een sessie zonder tot telt ook als verlopen.

  3. 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)
  1. Regel 9-12:

    Sara maakt een account, logt in en plaatst een bericht, zoals in uitloggen.py.

  2. Regel 14:

    Het script opent dezelfde sessies.db als je server. Draai het daarom in je map veiligheid-cookies.

  3. Regel 15-18:

    De sessie van Sara ophalen, tot op één seconde geleden zetten en de sessie terugzetten, zoals in Een dictionary aanpassen.

  4. Regel 20-21:

    De Client van 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.

Controlevraag
Iemand kopieerde het sessie-id van Sara en bewaart het 31 dagen. Sara logt nooit uit. Wat krijgt hij als hij dan haar bericht wil verwijderen?
Kies wat jij denkt. Bij het goede antwoord zie je de uitleg.

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.

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.