Wachtwoorden: inlogpogingen beperken
Met Argon2 uit les 7 tot en met 10 geeft een gelekte database
de wachtwoorden niet weg. Maar wie de database niet heeft, kan nog iets anders:
/inloggen zelf proberen, keer op keer, met steeds een ander wachtwoord. Je
server antwoordt op elke poging, en elk antwoord zegt of het raak was.
Een grens op het aantal pogingen ken je al: de verzoeklimiet met slowapi uit
Te veel verzoeken: een limiet met slowapi. Die zet je nu op de twee
endpoints die een wachtwoord controleren.
slowapi in deze map
Elke reeks heeft zijn eigen virtual environment, dus installeer slowapi ook
hier, in de terminal van VS Code:
python -m pip install slowapi
De limiet op /inloggen
Bovenaan main.py komen de imports en de limiter, net als in Te veel
verzoeken. Request komt bij de import uit fastapi:
from fastapi import FastAPI, Form, HTTPException, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
- Regel 2-4:
De drie regels van
slowapi, dezelfde als in Een limiet met slowapi. - Regel 6:
Tel per computer, aan de hand van zijn adres. Zonder
headers_enabledhoeft een endpoint geenresponse: Responsete hebben.
Direct onder app = FastAPI() komen de twee regels die de limiter aan je app
hangen:
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
In /inloggen uit les 10 veranderen alleen de kop en de regel
erboven:
@app.post("/inloggen")
@limiter.limit("5/minute")
async def inloggen(request: Request, naam: str = Form(...), wachtwoord: str = Form(...)):
with SqliteDict("gebruikers.db") as db:
opgeslagen = db.get(naam, NEP)
try:
ph.verify(opgeslagen, wachtwoord)
except VerifyMismatchError:
return {"bericht": "Naam of wachtwoord klopt niet"}
if opgeslagen == NEP:
return {"bericht": "Naam of wachtwoord klopt niet"}
if ph.check_needs_rehash(opgeslagen):
with SqliteDict("gebruikers.db") as db:
db[naam] = ph.hash(wachtwoord)
db.commit()
return {"bericht": "Ingelogd"}
- Regel 2:
Hoogstens vijf pogingen per minuut per computer. Een mens die zich vergist, heeft er genoeg aan; een script dat een lijst afloopt, komt nergens.
- Regel 3:
request: Requeststaat vooraan. Daaruit haaltslowapihet adres om te tellen.
/wijzig controleert ook een wachtwoord: het oude. Zonder limiet op /wijzig
probeert een script daar wat /inloggen nu weigert. Zet dus ook boven /wijzig een
@limiter.limit("5/minute"), en request: Request vooraan bij zijn
parameters. Elk endpoint telt apart: vijf foute pogingen op /inloggen laten
/wijzig met rust.
Doe de test
Probeer dit uitsluitend op je eigen server op 127.0.0.1, met je eigen
testaccount. Inlogpogingen doen op een account van een ander is strafbaar, ook
als het "maar een testje" is.
Start je server opnieuw. Staat Sara nog niet in je database, draai dan eerst
registreer.py uit les 1. Maak daarna pogingen.py, dat zes
keer met hetzelfde foute wachtwoord inlogt:
import httpx
adres = "http://127.0.0.1:8000/inloggen"
for nummer in range(6):
antwoord = httpx.post(adres, data={"naam": "sara", "wachtwoord": "fout"})
print(f"Poging {nummer + 1}: {antwoord.status_code} {antwoord.json()}")
Zo ziet je main.py er nu uit
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import FastAPI, Form, HTTPException, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from sqlitedict import SqliteDict
limiter = Limiter(key_func=get_remote_address)
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
ph = PasswordHasher()
NEP = ph.hash("niemand")
@app.post("/registreer")
async def registreer(naam: str = Form(...), wachtwoord: str = Form(...)):
with SqliteDict("gebruikers.db") as db:
if naam in db:
raise HTTPException(status_code=400, detail="Deze naam is al bezet")
db[naam] = ph.hash(wachtwoord)
db.commit()
return {"bericht": f"Welkom, {naam}"}
@app.post("/inloggen")
@limiter.limit("5/minute")
async def inloggen(request: Request, naam: str = Form(...), wachtwoord: str = Form(...)):
with SqliteDict("gebruikers.db") as db:
opgeslagen = db.get(naam, NEP)
try:
ph.verify(opgeslagen, wachtwoord)
except VerifyMismatchError:
return {"bericht": "Naam of wachtwoord klopt niet"}
if opgeslagen == NEP:
return {"bericht": "Naam of wachtwoord klopt niet"}
if ph.check_needs_rehash(opgeslagen):
with SqliteDict("gebruikers.db") as db:
db[naam] = ph.hash(wachtwoord)
db.commit()
return {"bericht": "Ingelogd"}
@app.post("/wijzig")
@limiter.limit("5/minute")
async def wijzig(request: Request, naam: str = Form(...), oud: str = Form(...), nieuw: str = Form(...)):
with SqliteDict("gebruikers.db") as db:
opgeslagen = db.get(naam, NEP)
try:
ph.verify(opgeslagen, oud)
except VerifyMismatchError:
return {"bericht": "Naam of wachtwoord klopt niet"}
if opgeslagen == NEP:
return {"bericht": "Naam of wachtwoord klopt niet"}
with SqliteDict("gebruikers.db") as db:
db[naam] = ph.hash(nieuw)
db.commit()
return {"bericht": "Wachtwoord gewijzigd"}
Registreren, inloggen en wijzigen met Argon2, en een limiet op elk endpoint dat een wachtwoord controleert: de hele reeks in één server.
De zesde poging komt niet meer bij ph.verify: slowapi weigert hem met een
429 voordat je functie draait. Wie wachtwoorden wil proberen, haalt er nu
vijf per minuut, en een lijst van duizend wachtwoorden kost hem meer dan drie
uur. Het scheelt je server ook werk, want elke ph.verify is expres traag.
Er gaat iets mis
De server start niet
Je server start niet, en in de terminal eindigt de foutmelding met:
Exception: No "request" or "websocket" argument on function "<function wijzig at 0x…>"
Oorzaak: je zette @limiter.limit boven /wijzig, maar request: Request
staat niet bij zijn parameters. slowapi controleert dat al bij het starten:
zonder request weet hij niet van welke computer een verzoek komt. Bij
/inloggen kan hetzelfde gebeuren; dan staat er inloggen in de melding. Het
getal achter 0x verschilt per keer.
Oplossing: zet request: Request vooraan bij de parameters:
# FOUT
@app.post("/wijzig")
@limiter.limit("5/minute")
async def wijzig(naam: str = Form(...), oud: str = Form(...), nieuw: str = Form(...)):
# GOED
@app.post("/wijzig")
@limiter.limit("5/minute")
async def wijzig(request: Request, naam: str = Form(...), oud: str = Form(...), nieuw: str = Form(...)):
Al bij poging 1 een 429
pogingen.py geeft zes keer 429, ook bij Poging 1.
Oorzaak: je draaide het script binnen een minuut nog een keer. De limiet telt per minuut, en de pogingen van daarnet tellen nog mee.
Oplossing: wacht een minuut, of sla main.py op: fastapi dev start dan
opnieuw, en begint opnieuw te tellen.
Zes keer 200
pogingen.py geeft zes keer 200, en nooit een 429.
Oorzaak: @limiter.limit staat boven @app.post in plaats van eronder.
FastAPI legt dan de functie zonder limiet vast als endpoint, en slowapi telt
niets. Je krijgt geen foutmelding; de limiet werkt stil niet.
Oplossing: zet @app.post bovenaan, en @limiter.limit direct eronder:
# FOUT
@limiter.limit("5/minute")
@app.post("/inloggen")
# GOED
@app.post("/inloggen")
@limiter.limit("5/minute")
Sla main.py op en draai pogingen.py opnieuw.
Opdrachten
Opdracht 1: Predict - Sara zelf
Je draait pogingen.py, en direct daarna logt Sara in met haar goede
wachtwoord, vanaf dezelfde computer. Sinds les 9 is
dat Lange-Nieuwe-Zin-7.
Vraag: wat krijgt ze terug?
Tip
Waarop telt get_remote_address: op de naam, of op de computer?
Antwoord
429 {'error': 'Rate limit exceeded: 5 per 1 minute'}. De limiet telt per
computer, en het script en Sara zitten op 127.0.0.1. Ze moet wachten tot de
minuut om is, of je slaat main.py op en fastapi dev begint opnieuw te
tellen. Op een echte site zit een bezoeker niet op de computer van een
aanvaller, en merkt hij hier niets van.
Opdracht 2: Investigate - En /registreer?
/registreer controleert geen wachtwoord, en krijgt in deze les geen limiet.
Kan een script daar toch iets mee doen wat je niet wilt?
Tip
Wat kost ph.hash? En wat antwoordt /registreer als een naam al bestaat?
Antwoord
Ja, twee dingen. Elke registratie rekent een trage ph.hash uit. Een script
dat duizend accounts aanmaakt, laat je server dus lang rekenen: met de 19
hashes per seconde uit les 6 bijna een minuut, en ondertussen wacht
iedereen. Dat is wat een verzoek kost.
En bij een naam die al bestaat, antwoordt /registreer met
400 Deze naam is al bezet. Zo zoekt een script uit welke namen een account
hebben, precies wat /inloggen met de nep-hash verborgen houdt.
Een limiet op /registreer helpt tegen allebei, ook al raadt daar niemand een
wachtwoord.
Opdracht 3: Make - In je eigen project
Je gastenboek heeft accounts sinds
Registreren: een account opslaan, en bewaart de
wachtwoorden nog als gewone tekst. Maak ze veilig met wat je in deze reeks
leerde: Argon2 bij het registreren, ph.verify met NEP op elke plek die een
wachtwoord controleert, een oude hash bijwerken na het inloggen, en een limiet
op elk van die plekken en op /registreer.
Tip
Zoek naar gebruikers.get(naam): op elke plek waar je een wachtwoord
vergelijkt, komt ph.verify. Bouwde je de uitbreiding sessies, dan is dat
POST /inloggen. Zonder die uitbreiding is het je POST /gastenboek, want
daar controleert je server het wachtwoord bij elk bericht.
Antwoord
Loop deze lijst af:
/registreerbewaartph.hash(wachtwoord), en weigert nog steeds een naam die al bestaat.- Elke plek die een wachtwoord controleert, gebruikt
ph.verify, ook bij een onbekende naam (tegenNEP), en geeft bij een onbekende naam en bij een fout wachtwoord dezelfde melding. - Na een geslaagde controle werkt
check_needs_rehasheen oude hash bij, zoals in les 10. - Elk van die endpoints heeft
@limiter.limitdirect onder@app.post, enrequest: Requestvooraan bij de parameters. Een script dat zes keer fout inlogt, krijgt bij de zesde een429. - Ook
/registreerheeft zo'n limiet, metrequest: Request: elke registratie kost een trageph.hash, en de melding over een bezette naam verraadt welke namen bestaan (opdracht 2). - Er staat nergens een wachtwoord als gewone tekst: niet in de database, niet
in een print, niet in een cookie. Gooi je oude
gebruikers.dbweg en registreer opnieuw:ph.verifykan niets met een wachtwoord dat als gewone tekst is opgeslagen, en dan stopt je server met een500en in de terminalargon2.exceptions.InvalidHashError.
Installeer eerst in de virtual environment van je project wat erbij komt, in de terminal van VS Code:
python -m pip install argon2-cffi slowapi
Direct onder app = FastAPI() komen de PasswordHasher, de nep-hash en de
limiter:
ph = PasswordHasher()
NEP = ph.hash("niemand")
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
Heb je in je project al een limiter uit Te veel verzoeken, gebruik dan die: de
laatste drie regels en de imports van slowapi staan er dan al, en een tweede
limiter of app.add_exception_handler is niet nodig.
Met de sessies uit Onthouden op de server: sessies
wordt je POST /inloggen zo. Bij je imports komen de regels van argon2 en
slowapi; Request staat in de import uit fastapi sinds de templates al. De
401 uit de basis blijft, het bijwerken uit les 10 komt erbij, en daaronder
blijft de sessie zoals hij was:
hier met de tot, httponly=True en samesite="lax" uit de reeks Cookies
afschermen. Laat die staan. Sloeg je die reeks over, dan houd je je eigen
sessies[sessie_id] en set_cookie.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Cookie, FastAPI, Form, HTTPException, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
@app.post("/inloggen")
@limiter.limit("5/minute")
async def inloggen(request: Request, naam: str = Form(...), wachtwoord: str = Form(...)):
with SqliteDict("gebruikers.db") as gebruikers:
opgeslagen = gebruikers.get(naam, NEP)
try:
ph.verify(opgeslagen, wachtwoord)
except VerifyMismatchError:
raise HTTPException(status_code=401, detail="Naam of wachtwoord klopt niet")
if opgeslagen == NEP:
raise HTTPException(status_code=401, detail="Naam of wachtwoord klopt niet")
if ph.check_needs_rehash(opgeslagen):
with SqliteDict("gebruikers.db") as gebruikers:
gebruikers[naam] = ph.hash(wachtwoord)
gebruikers.commit()
sessie_id = secrets.token_hex(16)
with SqliteDict("sessies.db") as sessies:
sessies[sessie_id] = {"naam": naam, "tot": time.time() + GELDIG}
sessies.commit()
antwoord = RedirectResponse(url="/gastenboek", status_code=303)
antwoord.set_cookie(
key="sessie_id",
value=sessie_id,
max_age=GELDIG,
httponly=True,
samesite="lax",
)
return antwoord
Geeft /inloggen zo een RedirectResponse terug, dan werkt de limiet ook:
slowapi heeft alleen bij headers_enabled=True een response: Response
nodig voor een dictionary.
Door naar les 12: in de praktijk.