Vai al contenuto

Utenti, login e permessi

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:

  1. senza accedere, cliccate su un indirizzo riservato come /nuovo: finite sul login;
  2. registratevi con un nome vostro, accedete e pubblicate un annuncio;
  3. 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.db perché 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 oppure None.
  • Il context_processor rende utente disponibile in tutti i template. È così che base.html mostra "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=Lax sul 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).