Cookie Authentication

... Petr Blahoš, 13. 4. 2017 Pyramid Python

Tohle je přetisk článku z mého staršího, teď už opuštěného blogu.

Tento článek píšu částečně proto, že jsem se sám chtěl důkladněji podívat na to, co se děje při cookie authentikaci, a částečně pod vlivem prezentace Authentication Is Hard, Let's Ride Bikes, kterou vám tímto doporučuji, ač je o něčem jiném.

Co se tedy děje. Při přihlášení pošle browser nějaké to jméno a heslo, server jej ověří, řekne browseru: V dalších požadavcích mi posílej toto cookie. Při odhlášení server řekne browseru: Zruš cookie. Teď si napíšeme malou aplikaci, která tohle dělá. Udělejte si už klasicky, virtualenv pro python3, do kterého nainstalujete pyramid, mako a waitress. Pak si do něj naklonujte https://github.com/petrblahos/tricycles.git. Při spouštění jednotlivých kroků pamatujte, že máme malou samostatně stojící aplikaci bez pasteru, takže když změníte kód, aplikace se vám nerestartuje.

Krok 1

A teď už ke kroku 1, který dělá to, co jsem popsal nahoře.

@view_config(route_name="login")
def login_view(self):
    userid = self.request.params.get("userid")
    response = self.response(["LOGGED IN", userid ])
    response.set_cookie("userid", str(userid))
    #response.set_cookie("userid", str(userid), httponly=1)
    #response.set_cookie("userid", str(userid), max_age=600) # 600 seconds - survives browser restart
    #response.set_cookie("userid", str(userid), secure=1)    # secure flag
    return response

View login bychom samozřejmně dělali ověření uživatelova hesla. Vyzkoušejte si variantu s max_age, při které ta cookie přežije restart browseru (ovšem máme nastaveno 5 minut, takže rychle). Taky si všimněte příznaku secure. Nenechte se mýlit, ten neudělá tu cookie nějak zázračně bezpečnou, ale povolí browseru její posílání jen po zabezpečeném spojení. Nejdůležitějsí je ale příznak httponly. Ten si v praxi určitě zapneme, protože znemožní sáhnout na cookie ze skriptu, přes document.cookies. V logoutu naopak řekneme browseru, ať cookie zapomene.

@view_config(route_name="logout")
def logout_view(self):
    response = self.response(["LOGGED OUT" ])
    response.delete_cookie("userid")
    return response

Teď v chrome console napište

document.cookie="userid=frank"

a klikněte na HOME. Jednoduché, ale naprosto očekávané. Proto v kroku 2 cookie podepíšeme.

Krok 2

Nejprve varování: Tento kód je ukázka. Nepoužívejte ho pro žádné seriózní účely. Raději použijte váš framework, který to dělá rychleji, lépe, a konzistentně. A teď už k tomu podepisování. Probíhá to tak, že vezmeme nějaká data, k nim přidáme nějaké tajemství, které zná jen naše aplikace, a uděláme hash. Do cookie dáme tento hash, naše userid, a ještě časové razítko. Když přijde cookie aplikaci, tak z něj vyextrahuje userid a časové razítko, spočítá hash, a porovná s hashem, který přišel v cookie. Pokud jsou různé, něco je špatně.

  • Do hashe lze taky započítat IP adresa klienta, tím pádem přestane přihlášení platit, když se vám změní IP adresa.
  • IP adresa v hashi prý nefunguje dobře z IPv6.
  • To počítání hashe se dá pojmout různě. Použijte Váš framework.
  • Důležitá je samozřejmně délka toho tajemství, a hashovací funkce. V tom nikomu nebudu radit, na to jsou povolanější.

Teď kousek kódu. Při každém requestu nejprve voláme _decode_cookie, čímž zjistíme, kdo je přihlášen.

def _decode_cookie(self):
    cookie = self.request.cookies.get("userid", None)
    if not cookie:
        return None
    # try to extract a userid and timestamp from the cookie
    try:
        (digest, ts, userid) = cookie.split("-", 2)
        logging.info("cookie splitted up:%s-%s-%s" % (digest, ts, userid, ))
    except:
        logging.error("BAD COOKIE FORMAT:%s|" % cookie)
        return None
    ip = ""
    d2 = calculate_digest(self.SECRET, userid, ts, ip)
    if d2==digest:
        return userid
    logging.error("bad digest")
    return None

A v loginu (po úspěšném ověření hesla) naopak vytvoříme novou cookie:

def _encode_cookie(self, userid):
    ip = ""
    ts = int(time.time())
    digest = calculate_digest(self.SECRET, userid, ts, ip)
    return "%s-%s-%s" % (digest, ts, userid)

Vlastní počítání hashe v mém případě jen použije funkci sha1 na všechno, co do ní přijde, a jak jsem psal, takhle to nedělejte, použijte Váš framework.

Samozřejmně použijeme opět cookie s onlyhttp, ale pro tu legraci si to na chvíli vypněte, a zkuste změnit obsah cookie.

Krok 3

V kroku 3 v rámci aspoň trochy politické korektnosti v okamžiku detekce špatné cookie nebudeme prostě říkat, že nikdo není přihlášen, ale vrátíme HTTP Bad Request. Tady si všiměte pěkné věci. V Pyramid jsou httpexceptions normální Response, takže já si ji vytvořím, pak nastavím cookie, a pak ji vyvolám. Kdybych nenastavil cookie, tak by mi klient ji pořád posílal, a já bych pořád vracel HTTP Bad Request.

def _decode_cookie(self):
    # [...]
    response = HTTPBadRequest()
    response.delete_cookie("userid")
    raise response

Krok 4

Na závěr krok 4 jako bonus: podle návodu v jednom starším článku na zdrojáku uděláme to, aby nás tento systém automaticky odhlásil na všech ostatních zařízení při změně hesla. V tom článku se heslo použije k výpočtu hashe, který se pak posílá zpět v cookie. Mě je posílání hesla (ač zahashovaného) proti srsti, proto raději při změně hesla vygeneruju náhodný řetězec, který použiju jako per-user sůl při vytváření hashe. Zase platí, že tohle je ukázka a že v praxi použijeme rozumný zdroj náhodného řetězce a vygenerujeme rozumnou délku (a použijeme rozumnou hashovací funkci).

@view_config(route_name="passwd")
def passwd_view(self):
    # When the user changes the password, we generate a new random string
    # and "store it into the database with the password".
    del self.USER_SALT[self.identity]
    # Then we re-generate the cookie, otherwise we will be logged out.
    response = self.response(["PASSWORD CHANGED", self.identity ])
    response.set_cookie("userid", self._encode_cookie(self.identity), httponly=1)
    return response

Co chybí

Pyramid Cookie auth například ještě podporuje Cookie Reissue. To funguje tak, že si řeknete, že ticket v cookie platí třeba 20 minut (timeout), a že pokud je v příchozím requestu aspoň 2 minuty starý (reissue_time), tak se má vygenerovat nový. Takže bude fungovat odhlášení po dvaceti minutách neaktivity.

Druhá věc, kterou by bylo dobré zmínit v souvislosti s cookies, je ochrana proti CSRF. Snad někdy příště.