In questa lezione impari a
- salvare le password come hash, mai in chiaro
- registrare un utente e fargli fare login e logout con la sessione
- proteggere le pagine riservate con un decoratore
- distinguere autenticazione (chi sei) e autorizzazione (cosa puoi fare)
- proteggere le operazioni di scrittura dell'API con una chiave
Oggi la bacheca ha gli utenti: per pubblicare bisogna essere registrati e collegati, ogni annuncio ha un autore, e solo l'autore può eliminarlo. È la parte più delicata di ogni applicazione: qui un errore non fa "solo" un bug, apre una falla.
Il kit della lezione
Nel database c'è già un utente: prof con password prof1234. Provate:
- senza accedere, cliccate su un indirizzo riservato come
/nuovo: finite sul login; - registratevi con un nome vostro, accedete e pubblicate un annuncio;
- aprite un annuncio del prof: il pulsante "Elimina" non c'è. E se provate lo stesso? Lo vediamo più avanti.
Autenticazione e autorizzazione
| Autenticazione | Autorizzazione | |
|---|---|---|
| Domanda | chi sei? | cosa puoi fare? |
| Nella bacheca | login con nome e password | solo l'autore elimina il suo annuncio |
| Se manca | redirect al login | errore 403 Forbidden |
Il database: utenti e autori
CREATE TABLE utenti (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
);
CREATE TABLE annunci (
...
autore_id INTEGER REFERENCES utenti(id) -- chiave esterna
);
UNIQUE impedisce due utenti con lo stesso nome. autore_id collega ogni annuncio al suo autore: è una relazione uno a molti (un utente, tanti annunci).
Lo schema è cambiato: se copiate il codice in un progetto vecchio, cancellate
bacheca.dbperché venga ricreato.
Le password: mai in chiaro
Se nel database ci fosse la password vera, chiunque riesca a leggerlo (un attacco, un backup perso, un collega curioso) avrebbe le password di tutti. E molte persone usano la stessa password ovunque.
Si salva invece un hash: un'"impronta" calcolata dalla password con una funzione a senso unico. Dalla password si ottiene l'hash, ma dall'hash non si torna alla password.
from werkzeug.security import generate_password_hash, check_password_hash
h = generate_password_hash("prof1234")
# 'scrypt:32768:8:1$kQ3x…$9f1c…' ← questo va nel database
check_password_hash(h, "prof1234") # True
check_password_hash(h, "Prof1234") # False
| Il pezzo dell'hash | Cosa significa |
|---|---|
scrypt:32768:8:1 |
l'algoritmo usato e quanto "lavoro" richiede: apposta lento, per rendere costosi i tentativi a forza bruta |
kQ3x… |
il sale (salt), casuale: due utenti con la stessa password hanno hash diversi |
9f1c… |
l'impronta vera e propria |
werkzeug è installato insieme a Flask: non serve aggiungerlo a requirements.txt. Aprite bacheca.db con SQLite Viewer e guardate la colonna password_hash.
Registrazione
@app.route("/registrati", methods=["GET", "POST"])
def registrati():
errori = []
if request.method == "POST":
username = request.form.get("username", "").strip().lower()
password = request.form.get("password", "")
conferma = request.form.get("conferma", "")
if not (3 <= len(username) <= 20) or not username.isalnum():
errori.append("Il nome utente deve avere da 3 a 20 lettere o cifre")
if len(password) < 8:
errori.append("La password deve avere almeno 8 caratteri")
if password != conferma:
errori.append("Le due password non coincidono")
if leggi_uno("SELECT id FROM utenti WHERE username = ?", (username,)):
errori.append("Questo nome utente è già preso")
if not errori:
esegui("INSERT INTO utenti (username, password_hash) VALUES (?, ?)",
(username, generate_password_hash(password)))
...
È un form come quelli della lezione 3, con una differenza fondamentale: nel database va generate_password_hash(password), mai password. Il nome utente viene messo in minuscolo, così "Giulia" e "giulia" sono lo stesso utente.
Login e sessione
HTTP non ha memoria: ogni richiesta è indipendente dalle altre. Come fa il server a ricordare che "questo browser" ha fatto login? Con la sessione.
1. POST /login (nome + password)
2. il server controlla l'hash → giusto
session["utente_id"] = 7
la risposta contiene un cookie FIRMATO con la SECRET_KEY: session=eyJ1dGVudGVfaWQiOjd9.Zx…
3. da ora il browser rimanda quel cookie a OGNI richiesta
4. il server lo legge, verifica la firma e sa che è l'utente 7
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "").strip().lower()
password = request.form.get("password", "")
u = leggi_uno("SELECT * FROM utenti WHERE username = ?", (username,))
if u and check_password_hash(u["password_hash"], password):
session.clear()
session["utente_id"] = u["id"]
flash(f"Ciao {u['username']}!", "successo")
return redirect(url_for("home"))
flash("Nome utente o password sbagliati", "errore")
return render_template("login.html")
- Il messaggio d'errore è lo stesso sia per nome sbagliato sia per password sbagliata: altrimenti diremmo a un attaccante quali nomi utente esistono.
session.clear()prima del login cancella eventuali dati vecchi.- Il cookie è firmato, non cifrato: chi lo intercetta può leggerne il contenuto (provate a decodificarlo!), ma non può modificarlo senza la
SECRET_KEY, perché la firma non tornerebbe. Per questo nella sessione mettiamo solo l'id, mai la password.
@app.route("/logout", methods=["POST"])
def logout():
session.clear()
...
Anche il logout è un POST: in base.html il pulsante "Esci" sta dentro un piccolo form. Con un link GET, un sito malevolo potrebbe "farvi uscire" con un'immagine <img src="…/logout">.
Chi è collegato? before_request e g
@app.before_request
def carica_utente():
g.utente = None
if "utente_id" in session:
g.utente = leggi_uno("SELECT id, username FROM utenti WHERE id = ?", (session["utente_id"],))
@app.context_processor
def utente_nei_template():
return {"utente": g.get("utente")}
@app.before_request: la funzione viene eseguita prima di ogni richiesta.gè un oggetto dove appoggiare dati che valgono per la richiesta corrente: in qualsiasi route,g.utenteè l'utente collegato oppureNone.- Il
context_processorrendeutentedisponibile in tutti i template. È così chebase.htmlmostra "Accedi" oppure "Esci (giulia)":
{% if utente %}
<a href="{{ url_for('nuovo') }}">+ Nuovo annuncio</a>
<form class="in-linea" method="post" action="{{ url_for('logout') }}">
<button class="bottone secondario">Esci ({{ utente.username }})</button>
</form>
{% else %}
<a href="{{ url_for('login') }}">Accedi</a>
{% endif %}
Proteggere le pagine: un decoratore
Ogni pagina riservata dovrebbe controllare "c'è un utente collegato? se no, vai al login". Invece di ripetere il controllo in ogni route, lo scriviamo una volta in un decoratore:
def login_richiesto(funzione):
@wraps(funzione)
def controllo(*args, **kwargs):
if g.utente is None:
flash("Devi accedere per vedere questa pagina", "errore")
return redirect(url_for("login", prossima=request.path))
return funzione(*args, **kwargs)
return controllo
e lo mettiamo sopra le route riservate, sotto @app.route:
@app.route("/nuovo", methods=["GET", "POST"])
@login_richiesto
def nuovo():
...
richiesta a /nuovo
│
▼
controllo(): g.utente è None? ── sì ──► redirect a /login?prossima=/nuovo
│ no
▼
nuovo() (la route vera)
Un decoratore è una funzione che avvolge un'altra funzione: esegue qualcosa prima (il controllo) e poi, se va tutto bene, chiama quella originale. @wraps serve a non far perdere il nome alla funzione originale, che Flask usa per url_for. Il parametro prossima ricorda dove volevate andare: dopo il login il kit vi riporta lì.
Autorizzazione: solo l'autore elimina
Nel template, il pulsante "Elimina" compare solo all'autore:
{% if utente and utente.id == annuncio.autore_id %}
<form method="post" action="{{ url_for('elimina', id=annuncio.id) }}"> ... </form>
{% endif %}
Ma nascondere un pulsante non protegge niente: chiunque può mandare quella richiesta POST a mano. Il controllo vero è nel server:
@app.route("/annuncio/<int:id>/elimina", methods=["POST"])
@login_richiesto
def elimina(id):
a = trova_annuncio(id)
if a is None:
abort(404)
if a["autore_id"] != g.utente["id"]:
abort(403) # Forbidden
...
Regola d'oro: il template decide cosa mostrare, il server decide cosa si può fare. Ogni route che modifica dati controlla sia l'autenticazione (
@login_richiesto) sia l'autorizzazione (è il tuo?).
Il nome dell'autore: JOIN
Per mostrare "scritto da giulia" serve il nome dell'utente, che sta in un'altra tabella. In app.py c'è una query riusata ovunque:
SELECT_ANNUNCI = """
SELECT annunci.*, utenti.username AS autore
FROM annunci LEFT JOIN utenti ON annunci.autore_id = utenti.id
"""
LEFT JOIN tiene anche gli annunci senza autore (quelli creati dall'API), che nel template mostrano "API" grazie a {{ a.autore or "API" }}.
E l'API?
Un programma non fa login con un form. Per le richieste che scrivono (POST e DELETE) chiediamo una chiave nell'intestazione X-API-Key, con un altro decoratore:
def chiave_api_richiesta(funzione):
@wraps(funzione)
def controllo(*args, **kwargs):
if request.headers.get("X-API-Key") != app.config["API_KEY"]:
return jsonify({"errore": "chiave API mancante o sbagliata"}), 401
return funzione(*args, **kwargs)
return controllo
In Thunder Client: scheda Headers, nome X-API-Key, valore chiave-di-prova. Senza chiave: 401 Unauthorized. Le letture (GET) restano libere. In client.py la chiave è già aggiunta.
Riepilogo della sicurezza
| Minaccia | Difesa nel kit |
|---|---|
| database rubato → password di tutti | hash con sale (generate_password_hash) |
| scoprire quali utenti esistono | stesso messaggio per nome e password sbagliati |
| modificare il cookie di sessione | cookie firmato con la SECRET_KEY |
| aprire pagine riservate | @login_richiesto |
| eliminare annunci altrui | controllo dell'autore nel server → 403 |
| logout forzato da un altro sito | logout solo con POST |
| uso dell'API da chiunque | chiave X-API-Key per scrivere |
| redirect verso siti esterni dopo il login | prossima accettata solo se inizia con / |
Un tema che qui non affrontiamo è il CSRF: un sito malevolo che fa inviare al vostro browser un form POST verso la bacheca, approfittando del fatto che siete collegati. Nella lezione 7 impostiamo
SameSite=Laxsul cookie, che lo blocca nella maggior parte dei casi; nelle applicazioni professionali si aggiunge un token CSRF in ogni form (lo fa la libreria Flask-WTF).
Missioni
⭐ Missione 1 – I miei annunci
Create la pagina riservata /profilo che mostra il nome dell'utente, quanti annunci ha pubblicato e l'elenco dei suoi annunci (riusate _scheda.html). Aggiungete la voce nel menu, solo per chi è collegato.
⭐ Missione 2 – Provate a forzarlo
Collegati con il vostro utente, aprite un annuncio del prof. Con F12 aggiungete a mano nella pagina il form di eliminazione (copiatelo da un vostro annuncio, cambiando l'id) e inviatelo. Cosa risponde il server? Poi commentate le due righe del controllo dell'autore e riprovate. Rimettetele.
⭐⭐ Missione 3 – Una password più robusta
In registrazione richiedete che la password contenga almeno una cifra e almeno una lettera maiuscola, e che sia diversa dal nome utente.
Aiutino
any(c.isdigit() for c in password) vale True se c'è almeno una cifra. Allo stesso modo con c.isupper().
⭐⭐ Missione 4 – Cambiare la password
Pagina riservata /password con tre campi: password attuale, nuova, conferma. Se l'attuale è giusta (check_password_hash) e la nuova è valida, salvate il nuovo hash con un UPDATE.
⭐⭐⭐ Missione 5 – L'amministratore
Aggiungete alla tabella utenti la colonna admin INTEGER NOT NULL DEFAULT 0 e rendete amministratore il prof (cancellate bacheca.db e modificate i dati iniziali in db.py). L'amministratore può eliminare qualsiasi annuncio: cambiate sia il controllo nel server sia la condizione nel template.
Aiutino
In carica_utente leggete anche la colonna admin. Il controllo diventa: if a["autore_id"] != g.utente["id"] and not g.utente["admin"]: abort(403).