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:
- 4096 tavua yhtä evästettä kohti (nimi, arvo ja määreet yhteensä)
- 50 evästettä yhtä sivustoa kohti
- 3000 evästettä yhteensä
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
| Määre | Merkitys |
|---|---|
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
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.
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:
- Palvelinpuolen sessio. Evästeessä kulkee pelkkä tunniste, ja tiedot ovat palvelimen muistissa, tiedostossa tai tietokannassa. Tietoa voi olla paljon, ja sen voi mitätöidä palvelimelta käsin.
- Asiakaspuolen sessio. Koko sessiotieto kulkee evästeessä. Palvelimelle ei jää mitään, mikä sopii hyvin tilattomiin ympäristöihin. Tilaa on vain evästeen verran, eikä sessiota voi mitätöidä palvelimelta.
Flaskin oletussessio on jälkimmäistä tyyppiä. Kaikki, mitä
session-sanakirjaan laitetaan, kulkee selaimen evästeessä.
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
| Asetus | Oletus | Suositus |
|---|---|---|
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:
- Koko. Koska koko sessio kulkee evästeessä, siihen mahtuu noin 4 kt. Listat ja hakutulokset eivät kuulu sessioon.
- Uloskirjautuminen. Asiakaspuolen sessiota ei voi mitätöidä palvelimelta: vanha eväste kelpaa allekirjoituksensa voimassaoloajan. Jos sovelluksessa on oltava aito uloskirjautuminen kaikilta laitteilta, tarvitaan palvelinpuolen sessio, esimerkiksi Flask-Session.
- Salaisen avaimen vaihtaminen mitätöi kaikki voimassa olevat sessiot. Se on ainoa keino kirjata kaikki ulos kerralla.
- XSS. Jos sivulle saa ujutettua JavaScriptiä, istunto on
vaarassa.
HttpOnlysuojaa evästeen lukemiselta, mutta skripti voi silti tehdä pyyntöjä käyttäjän nimissä. Templatejen autoescape on siis turvallisuusominaisuus. - CSRF.
SameSite=Laxtorjuu tavallisimmat tapaukset, mutta tilaa muuttaville lomakkeille kuuluu lisäksi CSRF-tunniste, kts. Flask-WTF.
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.
| Flaskin oma sessio | Palvelimella 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
);
- Vanhentuneita rivejä ei poisteta itsestään. Aseta
SESSION_CLEANUP_N_REQUESTStai aja siivous komennollaflask session_cleanup, esimerkiksi ajastettuna. - Session elinikä tulee
PERMANENT_SESSION_LIFETIME-asetuksesta. Flask-SessionissaSESSION_PERMANENTon oletuksenaTrue, joten sessio on pysyvä ilman erillistäsession.permanent = True-riviä. - Evästeen nimi on yhä Flaskin
SESSION_COOKIE_NAMEeli oletuksenasession— mutta sen sisältönä on nyt pelkkä tunniste. - Kirjaston asetusnimet ovat muuttuneet versioiden välillä. Tarkista aina käyttämäsi version dokumentaatio.
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
- Tunniste arvotaan
secrets-moduulilla, eirandom-moduulilla eikä juoksevalla numerolla. Arvattava tunniste tarkoittaa, että kuka tahansa voi esiintyä toisena käyttäjänä. - Vanhenemisaika tarkistetaan palvelimella. Evästeen
Max-Ageon 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?
| Tapa | Sopii | Rajoitukset |
|---|---|---|
| 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 |
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.
Käyttäjien kommentit