Wachtwoorden: inlogpogingen beperken
Met Argon2 uit les 5 tot en met 7 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. 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 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 7 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 daar 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()}")
Uitvoer:
Poging 1: 200 {'bericht': 'Naam of wachtwoord klopt niet'}
Poging 2: 200 {'bericht': 'Naam of wachtwoord klopt niet'}
Poging 3: 200 {'bericht': 'Naam of wachtwoord klopt niet'}
Poging 4: 200 {'bericht': 'Naam of wachtwoord klopt niet'}
Poging 5: 200 {'bericht': 'Naam of wachtwoord klopt niet'}
Poging 6: 429 {'error': 'Rate limit exceeded: 5 per 1 minute'}
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
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 welkom123, vanaf dezelfde computer.
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 - Waarom /wijzig ook?
Je zet de limiet alleen op /inloggen. Hoe kan een script dan toch onbeperkt
wachtwoorden van Sara proberen?
Antwoord
Via /wijzig. Dat endpoint doet met het veld oud precies wat /inloggen
met wachtwoord doet: ph.verify, en een ander antwoord als het klopt. Klopt
het, dan verandert het wachtwoord meteen ook. Elk endpoint dat een wachtwoord
controleert, krijgt daarom een limiet, net als in
Wie mag wat elk endpoint zijn eigen
controle kreeg.
Opdracht 3: Make - In je eigen project
Wil je accounts in je eigen project, neem dan de drie endpoints uit deze reeks
over, met ph, NEP en de limiter bovenaan, en maak er formulieren bij voor
registreren en inloggen.
Tip
Een wachtwoordveld in HTML is een <input type="password">: dan ziet niemand
over je schouder mee. De name moet wachtwoord zijn, zoals de parameter.
Antwoord
Loop deze lijst af:
- Er staat nergens een wachtwoord als gewone tekst: niet in de database, niet in een print, niet in een cookie.
/registreerbewaartph.hash(wachtwoord)./inloggengebruiktph.verify, ook bij een onbekende naam (tegenNEP), en geeft bij een onbekende naam en bij een fout wachtwoord dezelfde melding./wijzigcontroleert eerst het oude wachtwoord./inloggenen/wijzighebben@limiter.limitdirect onder@app.post, enrequest: Requestvooraan bij de parameters. Een script dat zes keer fout inlogt, krijgt bij de zesde een429.
Heb je in je project al een limiter uit Te veel verzoeken, gebruik dan die; een
tweede limiter of app.add_exception_handler is niet nodig.
Het formulier voor registreren:
<form method="post" action="/registreer">
<input type="text" name="naam" required>
<input type="password" name="wachtwoord" required>
<button type="submit">Registreer</button>
</form>
Het formulier voor inloggen is hetzelfde, met action="/inloggen".
Moet je site na het inloggen onthouden wie er is, zet de naam dan in de sessie
uit Onthouden op de server: sessies. Zet in
/inloggen de parameter sessie_id: str = Cookie(default="") erbij, naast
wachtwoord, en vervang de laatste return door:
if not sessie_id:
sessie_id = secrets.token_hex(16)
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {"naam": naam, "berichten": []})
mijn["naam"] = naam
sessies[sessie_id] = mijn
sessies.commit()
antwoord = RedirectResponse(url="/berichten", status_code=303)
antwoord.set_cookie(key="sessie_id", value=sessie_id, httponly=True, samesite="lax")
return antwoord
Dit staat ná ph.verify en de controle op NEP: pas daar weet je wie er
inlogt. secrets, Cookie en RedirectResponse heb je sinds de les over
sessies al geïmporteerd. 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.
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.
Door naar les 9: in de praktijk.