Dispense: lecture-notes-sessions

Matteo Vaccari > Applicazioni Web

Note sulla gestione delle sessioni

Il protocollo HTTP è stateless, il che significa che non mantiene memoria fra una transazione HTTP e la successiva, anche se provengono dallo stesso utente. In altre parole, ogni volta che il nostro browser contatta un server HTTP, è come se lo facesse per la prima volta.

In queste condizioni, come è possibile fornire un servizio web che tenga traccia di una sequenza di azioni dell'utente? Definiamo una sessione: una sequenza di transazioni HTTP compiute da uno stesso utente, da uno stesso browser in un intervallo di tempo limitato. Vogliamo quindi associare dei dati alle sessioni: vogliamo che quando un utente su un sito di e-commerce mette dei prodotti nel "carrello", questi prodotti restino nel carrello anche se l'utente passa da una pagina all'altra prima di premere il bottone "acquista".

Come possiamo quindi associare dati alle sessioni? Andiamo con ordine.

Il Session Id

Per prima cosa ci occorre identificare la sessione con un numero che chiamiamo "Session ID". Il Session ID è un numero grande e generato in maniera casuale.

quando arriva un nuovo utente:
  se alla sessione è associato un SESSION ID, recupera i dati associati alla sessione
  altrimenti, genera un nuovo SESSION ID e associalo alla sessione.

Algoritmo per la generazione del SESSION ID in Java:

// Restituisce una stringa di 64 caratteri che rappresentano 256 bit casuali.  
// La classe Hex è org.apache.commons.codec.binary.Hex.
// Vedi https://www.owasp.org/index.php/Insufficient_Session-ID_Length
public String generate() {
  byte[] bytes = new byte[32];
  new SecureRandom().nextBytes(bytes);
  return Hex.encodeHexString(bytes);
}

Nota l'uso di un generatore di numeri casuali crittograficamente sicuro per una maggiore sicurezza. Questo non significa che il numero sia "crittato", significa solo che è molto più difficile per un attaccante indovinare i numeri che vengono generati.

Associare il Session ID alla sessione.

Ci sono fondamentalmente tre maniere di associare un session ID a una sessione.

  1. Salvare il session ID nel path delle pagine. Fino a pochi anni fa, se visitavo http://www.amazon.com/, venivo immediatamente rediretto a una url simile a http://www.amazon.com/.../103-9557789-1670200/home.html. Il numero è il session ID. Ogni volta che richiedevo una pagina successiva, il server poteva ritrovare il Session Id nella url da me richiesta.

  2. Associare il Session Id a un parametro della Query String. Quando il mio servizio web genera una pagina, tutti i link interni (quelli che puntano ad altre pagine della mia applicazione) vengono riscritti aggiungendo un parametro, ad esempio http://www.foo.com/bar?sessid=11e144e3b510f0c5263e78eff0eb6e4a.

  3. Salvare il Session Id in un cookie. Questo è l'algoritmo usato da quasi tutti i servizi web, per cui lo esaminiamo di seguito in maggiore dettaglio.

Cookies

Il termine cookie è preesistente al web in Informatica. Il Jargon File dice:

A handle, transaction ID, or other token of agreement between cooperating programs. The claim check you get from a dry-cleaning shop is a perfect mundane example of a cookie; the only thing it's useful for is to relate a later transaction to this one (so you get the same clothes back)

Now mainstream in the specific sense of web-browser cookies

I web cookies sono un'invenzione proprietaria di Netscape, che è diventata standard. Un cookie è un frammento di informazione che viene associato al browser dal server. Quando un browser visita un servizio web, la risposta potrebbe contenere header come questi:

Set-Cookie: foobar=123; path=/
Set-Cookie: piciopacio=blabla; path=/

Ogni successiva richiesta che il browser fa a quel servizio web da questo momento, e fino a che il cookie non scade, conterrà uno header come questo:

Cookie: foobar=123; piciopacio=blabla

Un cookie è composto dalle seguenti informazioni:

  1. Un nome: es. MY_SESSION_ID
  2. Un valore: una sequenza di lettere e numeri
  3. Una data di scadenza. Se non viene specificata, il cookie è temporaneo, il che significa che quando l'utente chiude il browser, il cookie viene dimenticato. Altrimenti può avere una data di scadenza anche di parecchi anni nel futuro.
  4. Un dominio. Solo quel dominio riceverà il cookie.
  5. Un path. Serve ad associare il cookie a un sottoinsieme delle pagine del servizio web. Di solito il path è "/", il che significa che si applica a tutte le pagine.

I cookie vengono utilizzati generalmente per tre scopi:

  1. Salvare le preferenze dell'utente (per esempio, se preferisce testo grande o piccolo)
  2. Identificare l'utente nel lungo termine (eticamente discutibile!)
  3. Associare alla sessione un Session Id.

Quindi l'algoritmo per la gestione delle sessioni diventa:

Per ogni richiesta:
  Se contiene il cookie di sessione, recupera il Session Id dal cookie.
  Altrimenti, genera un nuovo Session Id; aggiungi alla risposta un nuovo cookie che contiene il Session Id.

Associare dati alla sessione

Abbiamo visto come associare alla sessione un Session Id. Ora vediamo come utilizzare questo session Id per associare dati alla sessione. Ma prima una domanda: perché non possiamo salvare direttamente i dati della sessione in un cookie? Pensaci per 5 minuti. Poi leggi il riquadro "Cookie Poisoning".

Cookie Poisoning

Un negozio online associava uno sconto ai suoi clienti più affezionati. Questo sconto veniva conservato in un cookie: ad esempio il valore "10" indicava che l'utente aveva diritto a uno sconto del 10%.

Non ci volle molto perché qualcuno lo scoprisse. Questo qualcuno modificò il valore del cookie nell'archivio dei cookie del suo browser per farlo diventare "90". E da quel momento cominciò a fare acquisti *molto* scontati.

Questa storia ci insegna che le informazioni che sono salvate nella macchina di qualcun altro non sono sicure. Non possiamo garantire che non vengano manomesse. Per questo motivo, i dati sensibili devono essere salvati lato server, dove l'utente non può toccarle. Il nostro Session Id è una chiave che ci permette di recuperare i dati di sessione in un apposito session store.

Tipi di Session Store

Ci sono fondamentalmente tre maniere di implementare un Session Store:

  1. Un file locale, il cui nome contiene il Session Id.
  2. Una tabella di database, la cui chiave primaria è il Session Id.
  3. Un cookie crittato.

La prima opzione è molto semplice da implementare; ma ha il difetto che se il nostro servizio web è distribuito su più di un server, dobbiamo mettere il file su un filesystem di rete, che è meno performante ed affidabile di un filesystem locale.

La seconda opzione è anch'essa molto semplice da realizzare, robusta e ha poco overhead. La maggior parte delle applicazioni web fanno dozzine di query al database per ogni richiesta. Aggiungerne una o due per recuperare i dati di sessione non comporta un ritardo apprezzabile. Se questo fosse un problema però, si potrebbe ricorrere a un database distribuito in RAM come Memcached. Questa soluzione rende il nostro servizio scalabile: è facile aggiungere altri server che gestiscono le richieste in parallelo. Ogni richiesta può essere servita indifferentemente da uno qualsiasi dei nostri server, perché gli unici dati condivisi stanno nel database, che è raggiungibile in maniera eguale da tutti i nostri server.

La terza opzione è usata nel framework Ruby On Rails; l'idea è di crittare i dati di sessione con una chiave casuale molto lunga, in modo che anche se i dati risiedono sulla macchina dell'utente, decrittarli sia proibitivamente costoso in termini computazionali. Basta scegliere una chiave sufficientemente lunga... La debolezza di questo schema è che se la chiave viene compromessa (cioè qualcuno la scopre), diventa possibile contraffare i dati di sessione. D'altra parte, questa soluzione è efficientissima perché il server non deve salvare nemmeno un byte di memoria per ciascuna sessione. Un problema minore è che in un cookie non si possono salvare grandi quantità di informazioni, ma su questo argomento parliamo più avanti.

Che cosa "salvare" in una sessione

Ora che abbiamo un meccanismo che ci permette di associare dati alle sessioni, dobbiamo chiederci: che dati salviamo? Molto spesso gli sviluppatori poco esperti, quando non sanno che pesci prendere per passare dati da una pagina a un'altra, li associano alla sessione. Ma questo è un errore, come il riquadro seguente dimostra.

La triste storia dei dati paginati

Qualche tempo fa ho assistito un cliente che aveva un problema con un'applicazione: ogni tanto capitava che la memoria occupata nella macchina virtuale Java cominciasse a salire, raggiungendo in pochi minuti il massimo. Dopodiché l'applicazione non funzionava più e l'unica soluzione era un restart dell'application server. Analizzando il problema abbiamo scoperto che quest'applicazione comprendeva una funzione di "report" che permetteva di visualizzare tutte le transazioni avvenute in un certo periodo. Queste transazioni venivano visualizzate come righe in una tabella HTML. Quando queste transazioni erano più di 100, venivano visualizzate le prime 100, con un bottone per navigare sulle pagine successive.

Come era implementata la paginazione? Quando l'utente faceva la ricerca, i risultati provenienti dal database, fossero 1 oppure 50.000 transazioni, venivano convertiti in un array di oggetti Java. Dopodiché questo array veniva salvato con la sessione, il che significa (per Tomcat) nella memoria della macchina virtuale Java.

Dove sta il problema? Il problema è che recuperare 50.000 righe da una query e trasformarle in oggetti Java comporta non solo un grosso dispendio di memoria (circa 50MB secondo le nostre misure), ma anche un tempo abbastanza lungo. Durante questo tempo l'utente, spazientito, premeva F5 per ricaricare la pagina, il che faceva partire una seconda query. La prima ricerca, però, non veniva interrotta solo per il fatto che l'utente aveva ricaricato la pagina. La conseguenza di ciò è che l'utente, ricaricando alcune volte la pagina nella speranza di vedere i suoi risultati, sovraccaricava in pochi minuti la memoria dell'application server, abbattendo il servizio per tutti!

La storia precedente illustra due gravi errori di progettazione in quell'applicazione.

  1. Se mostro al massimo 100 righe, la mia query deve restituire al massimo 100 righe. Non ha senso caricare 50.000 righe in memoria per mostrarne solo 100. In Mysql è molto facile eseguire query paginate, usando i parametri "base" e "limit". Con altri motori di database ci sono altri metodi per ottenere lo stesso risultato.
  2. Quando gestisco una richiesta HTTP, le mie fonti di informazioni devono essere solo due: la richiesta stessa, e la mia base di dati. Non devo fare affidamento a variabili globali o a variabili salvate con la sessione. La maniera corretta per mantenere una dialogo con l'utente è di salvare lo stato della conversazione nelle pagine (vedi HATEOAS più avanti.)

Fatte queste premesse, la conclusione è che l'unica informazione che ha senso salvare con la sessione è l'identità dell'utente. Siccome vogliamo evitare di chiedere username e password all'utente per ogni clic, conserviamo con la sessione il fatto che l'utente si sia autenticato. In altre parole, anche se le piattaforme di programmazione web consentono di solito di salvare "nella sessione" dati arbitrari, noi vogliamo salvare unicamente lo user_id del nostro utente.

Per esempio, se volessimo implementare un Session Store in una tabella di database, potremmo usare una tabella simile a questa:

create table sessions (
    session_id char(24) not null,
    user_id int not null references users,
    remote_ip_address varchar(50) not null,
    created_at datetime not null,
    updated_at datetime not null,
    primary key(session_id)
);  

La ragione principale per cui non vogliamo salvare dati voluminosi nella sessione è che vogliamo rendere il nostro servizio scalabile. Se noi salviamo molti dati per ciascun utente attivo, la quantità massima di utenti che possiamo servire diminuisce.

Sicurezza delle sessioni

Che cosa potrebbe succedere se qualcuno riuscisse a leggere i miei cookie? Per esempio potrebbe leggere il mio Session Id di Facebook, Twitter o Flickr e rubarmi la sessione. C'è un plugin di Firefox che si chiama Firesheep, che è stato inventato proprio per segnalare la pericolosità di questa vulnerabilità. (Nota: non usatelo per fare scherzi, soprattutto non sulle reti dell'Università!!) Fintantoché la comunicazione fra client e server avviene su una connessione non crittata, un utente sulla mia stessa rete locale può leggere i miei cookie. Questo vale anche per le reti WiFi, a meno che non usino il livello di sicurezza WPA2. La maniera più sicura di proteggere gli utenti del nostro servizio da questa vulnerabilità è di usare sempre HTTPS invece di HTTP.

Il problema del furto di sessione spiega anche perché i Session Id devono essere numeri casuali difficili da indovinare. Se la mia applicazione assegnasse Session Id consecutivi, sarebbe facile per un utente che si veda assegnato il Session Id 100 dedurre che c'è un qualche utente che ha l'Id 99 e rubargli la sessione contraffacendo il proprio cookie.

Verifica della validità di una sessione

L'algoritmo di base per verificare se abbiamo una sessione valida è il seguente.

def get_session
  # a) do we have a cookie?
  session_cookie = @request.cookies["MY_SESSION_ID"]
  return nil if session_cookie.nil?

  # b) is the cookie valid?
  session = @session_repository.find(session_cookie.value)
  return nil if session.nil?

  # c) is the session valid?
  if Time.now - session["creation_date"] > MAX_SESSION_LIFE ||
     Time.now - session["last_access"]   > MAX_SESSION_TIMEOUT ||
     session["ip_address"] != @request.remote_ip_address
     return nil
  end
  return session
end

E' opportuno invalidare le sessioni dopo un certo periodo di tempo. Opzionalmente si può invalidare anche una sessione che sia inattiva da un tempo sufficiente. Un'ulteriore misura di sicurezza è conservare l'indirizzo IP dell'utente che ha creato la sessione, per rendere più difficile il furto di sessione.

Hateoas

Hateoas è l'acronimo di Hypermedia As The Engine Of Application State. L'idea è di distinguere fra lo stato del server e lo stato del client. Quando l'utente fornisce dati al server, per esempio per registrarsi, viene modificato lo stato del server, il che comporta di solito una scrittura su un database. Quando invece l'utente sta visitando la terza pagina di un risultato paginato su 10 pagine, il fatto che lui si trovi sulla terza pagina e non sulla prima o sulla decima è salvato non sul server, ma sulla pagina HTML stessa: infatti la pagina conterrà un link "pagina precedente" che punta alla seconda pagina, e un link "pagina successiva" che punta alla quarta. Il server non ha nessun bisogno di ricordare su che pagina si trova il nostro utente. Sono i link che contengono queste informazioni:

<a href="/results?from=200">Pagina precedente</a> 
Sei sulla terza pagina
<a href="/results?from=400">Pagina successiva</a> 

Un altro caso che di solito spinge i programmatori sprovveduti a salvare dati in sessione è quello dei "wizard", ovvero delle form distribuite su più pagine. L'utente può fornire dei dati, poi passare alla pagina successiva, poi tornare alla precedente e in tutti questi passaggi i dati precedentemente inseriti devono essere ricordati. In questo caso è forte la tentazione di salvare questi dati nella sessione, ma sarebbe un errore! Questi dati vanno salvati nella pagina stessa. Basta utilizzare dei campi di tipo "hidden" sulle form.

Approfondimenti

Ho scritto un articolo su perché la gestione delle sessioni in Tomcat sia inadatta a realizzare servizi scalabili: http://matteo.vaccari.name/blog/archives/650

Questo articolo descrive (fra l'altro) l'incidente dei risultati paginati. http://matteo.vaccari.name/blog/archives/502

Questa pagina è una raccolta di risorse su HATEOAS: http://www.odino.org/363/resources-about-hateoas