Evästeet ja sessiot

HTTP on tilaton: palvelin ei tiedä pyyntöjen välillä mitään edellisistä pyynnöistä. Evästeet ja sessiot ovat kaksi tapaa kuljettaa tilaa pyynnöstä toiseen. Tällä luennolla ne käsitellään Flaskin näkökulmasta.

Evästeet

Evästeet ovat tekstimuotoista tietoa, jota palvelin voi lähettää selaimelle. Evästeelle voidaan määrätä elinikä, jonka ajan selain säilyttää sen. Oletuselinikä on meneillään oleva istunto, eli tietoa säilytetään siihen asti kun selain suljetaan. Tällaisia evästeitä selain säilyttää tyypillisesti muistissa; pidemmäksi määritellyt tallennetaan levylle.

Seuraavan kerran kun selain pyytää sivua samalta sivustolta, se lähettää evästeen palvelimelle. Palvelimella toimiva sovellus voi hyödyntää edellisellä kerralla tallennettua tietoa. Evästettä voi ajatella muuttujana, johon tallennetaan tekstiä ja joka on käytettävissä seuraavilla kerroilla.

Evästeessä voidaan säilyttää esimerkiksi käyttöliittymän asetuksia, kuten valittu kieli tai teema. Selaimella on aina jokin raja sille, kuinka suuria evästeet saavat olla ja kuinka monta niitä säilytetään. Voimassa olevan RFC 6265:n mukaan selaimen pitäisi tukea vähintään:

Mitä evästeeseen ei laiteta

Eväste on tekstiä käyttäjän omalla koneella. Sitä voi lukea, muokata ja poistaa kuka tahansa, jolla on pääsy selaimeen. Älä siis tallenna evästeeseen salasanoja, henkilötietoja äläkä mitään, minkä muuttaminen antaisi käyttäjälle oikeuksia, joita hänellä ei pitäisi olla. Älä myöskään säilytä evästeessä mitään, mitä ei saa hävitä: käyttäjä voi tyhjentää evästeet milloin tahansa.

Set-Cookie-otsake

Palvelin lähettää evästeen HTTP-vastauksen otsakkeissa:

Set-Cookie: kieli=fi; Max-Age=2592000; Path=/; Secure; HttpOnly; SameSite=Lax

Selain palauttaa sen seuraavissa pyynnöissä ilman määreitä:

Cookie: kieli=fi
Evästeen määreet
MääreMerkitys
nimi=arvo Ainoa pakollinen osa. Arvo on merkkijono; erikoismerkit on koodattava.
Max-Age Elinikä sekunteina. Suositeltava tapa, koska se ei riipu selaimen kellon oikeellisuudesta. Arvo 0 poistaa evästeen.
Expires Vanhenemisaika RFC 1123 -muodossa, esimerkiksi Wed, 05 May 2004 07:31:27 GMT. Jos kumpaakaan aikamäärettä ei anneta, eväste on voimassa vain istunnon ajan.
Path Polku, jolla eväste lähetetään. Oletuksena pyynnön polun hakemisto ja sen alihakemistot. Path=/ tarkoittaa koko sivustoa.
Domain Laajentaa evästeen myös alidomaineihin. Ilman tätä määrettä eväste lähetetään vain sille palvelimelle, joka sen asetti — se on yleensä turvallisin vaihtoehto.
Secure Eväste lähetetään vain salatulla TLS-yhteydellä eli https-osoitteissa.
HttpOnly Eväste ei näy sivun JavaScriptille. Estää evästeen varastamisen XSS-haavoittuvuuden kautta, ja kuuluu jokaiseen istuntoevästeeseen.
SameSite Lähetetäänkö eväste, kun pyyntö tulee toiselta sivustolta. Strict ei koskaan, Lax vain tavallisilla siirtymillä, None aina (vaatii Secure-määreen). Suojaa CSRF-hyökkäyksiltä.

Evästeet Flaskissa

Eväste asetetaan vastausolioon. Siksi näkymän on palautettava Response-olio eikä pelkkää merkkijonoa — helpoiten make_response-funktiolla:

from flask import Flask, make_response, redirect, render_template, request, url_for

app = Flask(__name__)

@app.post("/kieli")
def aseta_kieli():
    kieli = request.form.get("kieli", "fi")
    if kieli not in ("fi", "en"):
        kieli = "fi"

    vastaus = make_response(redirect(url_for("etusivu"), code=303))
    vastaus.set_cookie("kieli", kieli,
                       max_age=60 * 60 * 24 * 30,   #30 vuorokautta
                       httponly=True,
                       secure=True,
                       samesite="Lax")
    return vastaus

@app.get("/")
def etusivu():
    #get palauttaa oletusarvon, jos evästettä ei ole
    kieli = request.cookies.get("kieli", "fi")
    return render_template(f"etusivu.{kieli}.html")

@app.post("/unohda")
def unohda():
    vastaus = make_response(redirect(url_for("etusivu"), code=303))
    vastaus.delete_cookie("kieli")
    return vastaus
Otsakkeet ennen sisältöä

Vanhoissa CGI-ohjeissa varoitetaan, ettei evästettä voi enää asettaa sen jälkeen kun sivun sisältöä on ehditty tulostaa. Se pitää paikkansa ohjelmassa, joka kirjoittaa suoraan standarditulostukseen: otsakkeet lähtevät ensin eikä niihin voi enää palata.

Flaskissa asia ei tule vastaan, koska vastaus kootaan Response-olioon ja lähetetään vasta kun näkymäfunktio on palauttanut sen. Otsakkeita voi siis muokata mihin asti tahansa — myös after_request-koukussa.

Muista myös, että evästeen arvo on käyttäjän syötettä siinä missä lomakkeen kenttäkin. Yllä olevassa esimerkissä kieli tarkistetaan sallittujen arvojen luettelosta ennen käyttöä; ilman tarkistusta muokattu eväste ohjaisi lukemaan mielivaltaisen tiedoston.

Evästeet ja lainsäädäntö

Sähköisen viestinnän palveluista annettu laki ja EU:n tietosuoja-asetus edellyttävät, että käyttäjältä pyydetään suostumus ennen kuin hänen päätelaitteelleen tallennetaan muita kuin palvelun toteuttamisen kannalta välttämättömiä evästeitä. Kirjautumisen vaatima istuntoeväste on välttämätön; analytiikka- ja seurantaevästeet eivät ole. Suostumuksen on oltava vapaaehtoinen ja peruutettavissa yhtä helposti kuin se annetaan.

Sessiot

Sessionhallinta tarkoittaa tiedon säilyttämistä yhden käyttökerran ajan — siihen asti kunnes käyttäjä kirjautuu ulos tai sessio vanhenee. Sessioilla toteutetaan tyypillisesti kirjautuminen.

Sessiolle luodaan käynnistyessä yksilöllinen tunniste, ja sessioon voidaan asettaa sessiomuuttujia. Tunniste on aina jollain tavalla välitettävä selaimelle, jotta seuraava pyyntö osataan yhdistää samaan sessioon. Vaihtoehtoja on kaksi: osoitteen mukana tai evästeessä. Osoitteessa kulkeva tunniste vuotaa selainhistoriaan, kirjanmerkkeihin ja Referer-otsakkeeseen, joten käytännössä käytetään aina evästettä.

Sessiototeutukset jakautuvat kahteen tyyppiin sen mukaan, missä varsinainen tieto sijaitsee:

Flaskin oletussessio on jälkimmäistä tyyppiä. Kaikki, mitä session-sanakirjaan laitetaan, kulkee selaimen evästeessä.

Kaavio: selain lähettää kirjautumispyynnön ilman evästettä, palvelin            vastaa Set-Cookie-otsakkeella, selain tallentaa evästeen ja lähettää sen            seuraavissa pyynnöissä Cookie-otsakkeessa, ja palvelin tarkistaa            allekirjoituksen. Alalaidassa sessioeväste on jaettu kolmeen osaan:            base64-koodattu hyötykuorma, aikaleima ja allekirjoitus. Hyötykuorma on            luettavissa ilman salaista avainta.
Mitä selaimen ja palvelimen välillä oikeasti liikkuu.
Flaskin sessio on allekirjoitettu, ei salattu

Sessioevästeessä on kolme osaa: hyötykuorma, aikaleima ja allekirjoitus. Hyötykuorma on base64-koodattua JSONia, jonka kuka tahansa evästeen näkevä voi lukea ilman salaista avainta. Voit kokeilla itse: kopioi evästeen ensimmäinen osa ja pura se base64-purkimella.

Allekirjoitus estää sisällön muuttamisen, ei sen lukemista. Sessioon ei siis saa laittaa salasanoja, henkilötunnuksia eikä muuta arkaluontoista — käytännössä sinne kuuluu vain käyttäjän tunniste ja sen perusteella palvelimella haettava muu tieto.

Sessio Flask-sovelluksessa

import os
from flask import Flask, redirect, render_template, request, session, url_for

app = Flask(__name__)

#Ilman salaista avainta sessio ei toimi lainkaan. Avainta EI kirjoiteta
#lähdekoodiin eikä versionhallintaan, koska sen tietävä voi väärentää
#allekirjoituksen ja kirjautua kenä tahansa. Arvo luetaan ympäristöstä.
app.secret_key = os.environ["SECRET_KEY"]

@app.get("/")
def etusivu():
    tunnus = session.get("tunnus")
    if tunnus is None:
        return redirect(url_for("kirjautuminen"), code=303)
    return render_template("etusivu.html", tunnus=tunnus)

@app.get("/kirjaudu")
def kirjautuminen():
    return render_template("kirjaudu.html")

@app.post("/kirjaudu")
def kirjaudu():
    tunnus = request.form.get("tunnus", "").strip()
    salasana = request.form.get("salasana", "")

    if not tarkista_salasana(tunnus, salasana):
        return render_template("kirjaudu.html", virhe="Väärä tunnus tai salasana."), 401

    #Tyhjennä vanha sessio ennen uuden avaamista. Muuten hyökkääjä voi asettaa
    #uhrin selaimeen tietämänsä sessiotunnisteen ja päästä sisään sen avulla
    #(session fixation).
    session.clear()
    session["tunnus"] = tunnus

    return redirect(url_for("etusivu"), code=303)

@app.post("/kirjaudu-ulos")
def kirjaudu_ulos():
    session.clear()
    return redirect(url_for("etusivu"), code=303)

Salaisen avaimen voi arpoa komennolla:

python3 -c "import secrets; print(secrets.token_hex(32))"

Sessio käyttäytyy sanakirjan tavoin: session["avain"] = arvo, session.get("avain"), session.pop("avain", None) ja session.clear(). Muutokset tallentuvat automaattisesti vastauksen mukana.

Session asetukset ja turvallisuus

Flaskin sessioasetukset
AsetusOletusSuositus
SESSION_COOKIE_HTTPONLY True Jätä ennalleen.
SESSION_COOKIE_SECURE False Aseta True, kun sivusto on https-osoitteessa.
SESSION_COOKIE_SAMESITE None Aseta "Lax".
PERMANENT_SESSION_LIFETIME 31 vuorokautta Lyhennä kirjautumissessiolle sopivaksi.
from datetime import timedelta

app.config.update(
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_SAMESITE="Lax",
    PERMANENT_SESSION_LIFETIME=timedelta(hours=2),
)

Oletuksena sessio päättyy selaimen sulkemiseen. PERMANENT_SESSION_LIFETIME vaikuttaa vasta, kun sessio merkitään pysyväksi kirjautumisen yhteydessä:

session.permanent = True

Muuta muistettavaa:

Palvelimella säilytettävä sessio

Kun sessioon tarvitaan enemmän tilaa kuin evästeeseen mahtuu, kun tiedot eivät saa näkyä käyttäjälle, tai kun istunto on voitava katkaista palvelimelta käsin, tiedot on säilytettävä palvelimella. Silloin evästeessä kulkee pelkkä tunniste ja kaikki muu on tiedostossa tai tietokannassa.

Kumpi sessiototeutus?
Flaskin oma sessioPalvelimella säilytettävä
Missä tiedot ovat Selaimen evästeessä Palvelimen tietokannassa
Käyttäjä näkee sisällön Kyllä Ei — vain tunnisteen
Tilaa Noin 4 kt Käytännössä rajattomasti
Mitätöinti palvelimelta Ei onnistu Rivi pois kannasta
Vaatii Salaisen avaimen Tietokannan ja siivouksen

Flask-Session

Flask-Session korvaa Flaskin oman session palvelinpuolisella toteutuksella. Sovelluskoodiin ei tarvitse koskea: session-sanakirjaa käytetään täsmälleen kuten ennenkin, ja vain asetukset muuttuvat.

Kirjasto asennetaan yhdessä sen tallennustavan kanssa, jota aiot käyttää:

pip install Flask-Session                 #tiedostotallennus (cachelib)
pip install "Flask-Session[sqlalchemy]"   #tietokantatallennus

Tiedostoon tallentaminen

Yksinkertaisin vaihtoehto on tallentaa sessiot tiedostoiksi levylle. Se sopii kehitykseen ja pieniin sovelluksiin. Tallennus hoidetaan cachelib-kirjaston FileSystemCache-välimuistilla:

import os
from datetime import timedelta

from cachelib.file import FileSystemCache
from flask import Flask, session
from flask_session import Session

app = Flask(__name__)

#Sessiokansio EI kuulu www-hakemistoon: tiedostot sisältävät sessiotiedot
#sellaisenaan. Kansioon tarvitaan kirjoitusoikeus.
SESSIOKANSIO = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                            "..", "hidden", "sessiot")

app.config.update(
    SESSION_TYPE="cachelib",
    SESSION_SERIALIZATION_FORMAT="json",
    SESSION_CACHELIB=FileSystemCache(cache_dir=SESSIOKANSIO, threshold=500),
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_SAMESITE="Lax",
    PERMANENT_SESSION_LIFETIME=timedelta(hours=2),
)

Session(app)

@app.post("/kirjaudu")
def kirjaudu():
    #käyttö on täsmälleen samanlaista kuin Flaskin omalla sessiolla
    session.clear()
    session["tunnus"] = "tjlahton"
    return redirect(url_for("etusivu"), code=303)

threshold on se määrä sessiotiedostoja, jonka jälkeen vanhimpia aletaan siivota pois. Huomaa, että aiemmissa versioissa käytetty SESSION_TYPE="filesystem" on merkitty vanhentuneeksi versiossa 0.7.0 ja poistuu versiossa 1.0.0 — se teki konepellin alla saman kuin cachelib.

SQLite-tietokantaan tallentaminen

Tietokantatallennus käyttää Flask-SQLAlchemyä, joten sessiot menevät samaan kantaan muun sovellusdatan kanssa. Yhteysosoite annetaan SQLALCHEMY_DATABASE_URI-asetuksessa, ja SQLitellä se on sqlite:/// ja absoluuttinen polku:

import os
from datetime import timedelta

from flask import Flask, session
from flask_session import Session
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)

KANTA = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                     "..", "hidden", "sovellus.db")

app.config.update(
    SQLALCHEMY_DATABASE_URI=f"sqlite:///{KANTA}",
    SESSION_TYPE="sqlalchemy",
    SESSION_SQLALCHEMY_TABLE="sessiot",      #oletusnimi on sessions
    SESSION_CLEANUP_N_REQUESTS=100,          #siivotaan vanhentuneet ajoittain
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_SAMESITE="Lax",
    PERMANENT_SESSION_LIFETIME=timedelta(hours=2),
)

kanta = SQLAlchemy(app)
app.config["SESSION_SQLALCHEMY"] = kanta

Session(app)

#Sessiotaulu luodaan kerran. Tämän voi ajaa myös erillisenä skriptinä.
with app.app_context():
    kanta.create_all()

Taulu syntyy kantaan suunnilleen tässä muodossa, eli samalla idealla kuin seuraavassa omassa toteutuksessa:

CREATE TABLE sessiot (
    id         INTEGER PRIMARY KEY,
    session_id VARCHAR(255) UNIQUE,
    data       BLOB,
    expiry     DATETIME
);
Huomioita

Oma toteutus

Mekanismin ymmärtää parhaiten toteuttamalla sen itse. Alla on toimiva versio SQLite-kannan päälle ilman ylimääräisiä kirjastoja.

Oma sessiototeutus SQLiten päälle
CREATE TABLE Sessio (
    Tunniste TEXT PRIMARY KEY,
    Tiedot   TEXT NOT NULL,
    Vanhenee INTEGER NOT NULL
);
import json
import os
import secrets
import sqlite3
import time

from flask import Flask, g, make_response, redirect, request, url_for

KANTA = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                     "..", "hidden", "sessiot.db")
EVASTE = "sid"
IKA = 60 * 60 * 2          #sessio on voimassa kaksi tuntia

app = Flask(__name__)

def kanta():
    if "kanta" not in g:
        g.kanta = sqlite3.connect(KANTA)
        g.kanta.row_factory = sqlite3.Row
    return g.kanta

@app.teardown_appcontext
def sulje_kanta(poikkeus):
    yhteys = g.pop("kanta", None)
    if yhteys is not None:
        yhteys.close()

def lue_sessio():
    """Palauttaa sessiotiedot sanakirjana tai tyhjän sanakirjan."""
    tunniste = request.cookies.get(EVASTE)
    if not tunniste:
        return {}

    #Vanhenemisaika tarkistetaan kyselyssä, joten vanhentunut sessio ei kelpaa
    #vaikka eväste olisi yhä selaimessa.
    rivi = kanta().execute("""
        SELECT Tiedot FROM Sessio
        WHERE Tunniste = ? AND Vanhenee > ?
    """, (tunniste, int(time.time()))).fetchone()

    if rivi is None:
        return {}
    return json.loads(rivi["Tiedot"])

def aloita_sessio(vastaus, tiedot):
    """Luo uuden session ja asettaa tunnisteen evästeeseen."""
    #Tunniste on arvottava kryptografisesti vahvalla satunnaisuudella.
    #Juokseva numero tai aikaleima olisi arvattavissa.
    tunniste = secrets.token_urlsafe(32)

    yhteys = kanta()
    with yhteys:
        #siivotaan samalla vanhentuneet sessiot pois
        yhteys.execute("DELETE FROM Sessio WHERE Vanhenee < ?", (int(time.time()),))
        yhteys.execute("""
            INSERT INTO Sessio (Tunniste, Tiedot, Vanhenee)
            VALUES (?, ?, ?)
        """, (tunniste, json.dumps(tiedot), int(time.time()) + IKA))

    vastaus.set_cookie(EVASTE, tunniste, max_age=IKA,
                       httponly=True, secure=True, samesite="Lax")
    return vastaus

def paata_sessio(vastaus):
    """Poistaa session sekä palvelimelta että selaimesta."""
    tunniste = request.cookies.get(EVASTE)
    if tunniste:
        yhteys = kanta()
        with yhteys:
            yhteys.execute("DELETE FROM Sessio WHERE Tunniste = ?", (tunniste,))
    vastaus.delete_cookie(EVASTE)
    return vastaus

@app.get("/")
def etusivu():
    sessio = lue_sessio()
    if "tunnus" not in sessio:
        return redirect(url_for("kirjautuminen"), code=303)
    return render_template("etusivu.html", tunnus=sessio["tunnus"])

@app.post("/kirjaudu")
def kirjaudu():
    tunnus = request.form.get("tunnus", "").strip()
    if not tarkista_salasana(tunnus, request.form.get("salasana", "")):
        return render_template("kirjaudu.html", virhe="Väärä tunnus tai salasana."), 401

    vastaus = make_response(redirect(url_for("etusivu"), code=303))
    return aloita_sessio(vastaus, {"tunnus": tunnus})

@app.post("/kirjaudu-ulos")
def kirjaudu_ulos():
    vastaus = make_response(redirect(url_for("etusivu"), code=303))
    return paata_sessio(vastaus)

Selaimeen menee nyt pelkkä satunnainen merkkijono:

Set-Cookie: sid=ZH148ahpXfgdfTeYZmk9Cr5yMygr-BKkcJtSksb9QoM; Max-Age=7200; HttpOnly; Secure; SameSite=Lax

ja tiedot jäävät kantaan:

Tunniste                                       Tiedot                     Vanhenee
ZH148ahpXfgdfTeYZmk9Cr5yMygr-BKkcJtSksb9QoM    {"tunnus": "tjlahton"}     1789593075
Muistettavaa omassa toteutuksessa
  • Tunniste arvotaan secrets-moduulilla, ei random-moduulilla eikä juoksevalla numerolla. Arvattava tunniste tarkoittaa, että kuka tahansa voi esiintyä toisena käyttäjänä.
  • Vanhenemisaika tarkistetaan palvelimella. Evästeen Max-Age on vain pyyntö selaimelle, eikä siihen voi luottaa.
  • Vanhentuneet rivit on siivottava, tai sessiotaulu kasvaa loputtomiin.
  • Kirjautumisen yhteydessä luodaan aina uusi tunniste. Vanhan uudelleenkäyttö altistaa session fixation -hyökkäykselle.
  • Sessiokanta ei kuulu www-hakemistoon sen paremmin kuin muutkaan tietokannat.

Mihin tila kannattaa tallentaa?

TapaSopiiRajoitukset
Osoitteen parametrit Näkymän tila, joka halutaan jakaa linkkinä tai lisätä kirjanmerkkeihin Osoitteen pituusraja noin 2000 merkkiä; tila on käyttäjän muokattavissa
Lomakkeen piilokentät Monivaiheisen lomakkeen väliaikainen tila Kulkee vain lomakkeen mukana; käyttäjän muokattavissa
Eväste Käyttäjän asetukset, kuten kieli tai teema Noin 4 kt; käyttäjän luettavissa ja poistettavissa; suostumusvaatimus ei-välttämättömille
Sessio Kirjautuminen ja käyttäjäkohtainen tila Flaskissa allekirjoitettu muttei salattu; noin 4 kt; vaatii salaisen avaimen
Tietokanta Tieto, jonka on säilyttävä istunnon jälkeenkin Vaatii oman rakenteensa, kts. tietokantaluento
Huomaa viikkotehtävässä

Viikkotehtävässä 2 evästeiden ja sessioiden käyttäminen on kielletty. Se on tarkoituksellista: tehtävän idea on harjoitella tilan kuljettamista osoitteessa ja lomakkeen piilokentissä, eli juuri niitä keinoja, jotka toimivat ilman selaimeen tallennettavaa tietoa.

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti