Tworzenie systemu sesji aplikacji webowej

14 Sierpnia 2017

Zacząłem ostatnio pracować nad aplikacją do certyfikowania sędziów dla Polskiej Ligi Quidditcha. Postanowiłem napisać ją w F# i Suave, mając na celu zrobić jak najlepszą robotę pod względem bezpieczeństwa.

Suave ma zaimplementowany system obsługi sesji, ale nieco mi się on nie spodobał, jako że przesyła dane sesji do klienta (zaszyfrowane), a ja chciałbym trzymać wszystkie dane po stronie serwera.

Prawdę mówiąc mógłbym korzystać z tego wbudowanego systemu (co pewnie jest zalecane), jednak przeczytawszy trochę informacji w internecie o tym jak sesje powinny działać, stwierdziłem, że jestem w stanie to zaimplementować w dużo prostszy sposób, który będzie bardziej zrozumiały dla osoby patrzącej na mój kod i nie znającej wewnętrznej budowy Suave’a w detalu.

Moja pełna implementacja dzieli się w sumie na trzy moduły: Cookies, Session, Authentication. Natomiast w tym blogpoście opiszę minimum dotyczące sesji.

Ciasteczko

W jaki sposób utrzymuje się sesję w bezstanowym protokole jakim jest HTTP? Za pomocą ciasteczek, czyli de facto słownika {klucz=wartość}, który jest przesyłany z każdym żądaniem.

Moje ciasteczko nazywa się Session i zawiera SessionId, który identyfikuje obiekt sesji, który opiszę dalej. Więc kiedy użytkownik połączy się z aplikacją (a dokładniej wejdzie na stronę wymagającą sesji) to wraz z odpowiedzią dostanie coś takiego

Set-Cookie:Session=f3HQhAOPli1g1CUpCtZiijTqDUwimMOmR1ZZ0wKV1I+6WV9oxhrW8qQBL80Wo+e+9xpYfPBgKowbUjx0npkKzX2pG/qPuHaMQCET/DIrEc8TQLs7bu5LYOqwEZBaPxHvAlvy+H5LxYcxPoC5ZGOyi+Ri9WBXOoDs5D1SmSWesv;Path=/;Expires=Mon, 14 Aug 2017 05:07:25 GMT;HttpOnly

Jest to część nagłówka HTTP o nazwie Set-Cookie, która informuje przeglądarkę, że ta ma ustawić ciasteczko o podanej dalej nazwie i wartości. Moje SessionId to string o długości nieco ponad 100 znaków. Następnie mamy Path=/, czyli ścieżkę dla której (i jej podfolderów) będziemy przesyłali to ciasteczko spowrotem do serwera. Dalej jest data wygaśnięcia i HttpOnly, które oznacza, że nie można się do tego ciasteczka dobrać z JavaScriptu.

Ponieważ jestem w trybie debugowania to nie ma tu jeszcze jednej ważnej flagi, mianowicie Secure. Flaga ta oznacza, że ciasteczko można przesyłać tylko gdy łączymy się po HTTPS. Zapobiega to możliwości przejęcia sesji (czyli skopiowania ciasteczka sesji na inny komputer) przez atak Man-In-The-Middle.

Ważne jest aby ciasteczko sesji miało datę wygaśnięcia. Przy czym albo ustawia się faktyczną datę wygaśnięcia, po której przeglądarka zapomina ciasteczko, albo może być to “Session”, czyli do momentu gdy sesja (w rozumieniu przeglądarki) się zakończy. Może to być np. po zamknięciu tej karty.

Ja zdecydowałem się na 12h, bo wtedy w ciągu dania możesz wejść rano, wejść wieczorem i nie musisz się logować drugi raz. W zależności od tego jak popularna i podatna na ataki jest twoja aplikacja, będziesz chciał ustawić to tak, żeby nie ułatwiać przechwycenia sesji komuś kto podejdzie do twojego komputera podczas twojej nieobecności. Dla przykładu, banki wylogowywuję cię jak tylko zamkniesz kartę.

Można jeszcze zrobić tak, że sesja trwa przeglądarkową sesję, a ty serwujesz drugie ciasteczko, które żyje dłużej i służy do autologowania użytkownika, który wybrał opcję “Zapamiętaj mnie”. To ma zaletę taką, że dajemy użytkownikowi wybór. Jeśli korzysta z domowego komputera, to włącza autologowanie, jeśli korzysta z publicznego komputera to woli, żeby po jego odejściu, kolejna osoba nie była w stanie użyć jego sesji.

Ustawianie ciasteczek w Suave

Zaczniemy od otworzenia potrzebnych namespace’ów

open System
open Suave.Http
open Suave.Cookie
open Suave.Operators

Następnie dla czytelności kodu ustawiam moje flagi w stałych

#if DEBUG
let SECURE = false
#else
let SECURE = true
#endif

let HTTPONLY = true

Korzystam z funkcji Suave.Http.HttpCookie.create podając jej odpowiednie argumenty (nazwa, wartość, czas wygaszenia, ścieżka, domena, secure, httpOnly)

let createSessionCookie sessionId =
    let tomorrow = DateTimeOffset <| DateTime.Now.AddHours(12.0)    
    HttpCookie.create "Session" sessionId (Some tomorrow) (Some "/") None SECURE HTTPONLY

let setSessionCookie sessionId =
    setCookie <| createSessionCookie sessionId

Ta ostatnia funkcja zwraca WebPart, czyli możemy ją w prosty sposób połączyć z dowolną częścią naszej aplikacji. Np:

let pageWithSession = setSessionCookie "..." >=> OK "Page with Session"

Sesja

Nasze ciasteczko zawiera id obiektu, z którym będziemy pracowali po stronie serwera. Ten obiekt zawiera to co potrzebujemy (dlaczego w ogóle mamy coś takiego jak sesję). W moim przypadku (w momencie pisania posta na wczesnym etapie rozwoju aplikacji) jest to taki obiekt

type CSRF = string
type SessionId = string
type Session =
    | NotLoggedIn of SessionId * CSRF
    | LoggedIn of SessionId * User * CSRF

Sesja służy mi do przechowywania informacji o ciasteczku służącym do blokowania ataków Cross Site Request Forgery oraz o tym jaki użytkownik jest zalogowany.

Przechowuję moje sesje w bazie danych, choć mógłby to być jakiś zewnętrzny cache, albo po prostu pamięć serwera. Oprócz tego co w samym obiekcie sesji, w bazie trzymam jeszcze swoją własną datę wygaśnięcia sesji. Przy każdym dostępie do sesji usuwam stare sesje, żeby zapobiec sytuacji w której ktoś wykradnie sesję (i nie użyje jej od razu) albo nie będzie zwracał uwagi na datę wygaśnięcia ciasteczka.

Teraz pisząc ten akapit, wpadłem na to, że prawdopodobnie można mnie zDoS-ować prosząc o dużo nowych sesji w krótkim czasie, co zapcha mi bazę danych. Więc to jeszcze jest do przemyślenia.

Nowe id sesji

Id sesji powinno być losowe. W ten sposób napastnik nie będzie w stanie przewidzieć kolejnego id sesji i podszyć się pod inną osobę. Jak wiemy System.Random nie służy do zapewnienia prawdziwej losowości, więc używam wbudowanych metod kryptograficznych do wygenerowania losowego ciągu bajtów. Żeby uzyskać string, zapisuję wylosowane bajty w postaci Base64.

Na koniec obcinam dwa ostatnie znaki, na wszelki wypadek, bo przy stringu kończącym się na == ciasteczko tej końcówki nie zawierało i miałem niezgodność. Nie wiem gdzie dokładnie leży problem.

let newSessionId () =
    let bytes = [|for i in 1..128 -> 0uy|]
    let randomizer = System.Security.Cryptography.RandomNumberGenerator.Create()
    randomizer.GetBytes(bytes)
    let str = System.Convert.ToBase64String bytes
    str.Substring(0, str.Length - 2)

Wykorzystanie sesji

Ostatnia rzecz to funkcja session, która przyjmuje funkcję Session -> WebPart i zwraca WebPart. Celem tej funkcji jest odczytanie ciasteczek, czytanie i tworzenie sesji w bazie i uruchomienie funkcji przekazanej jej w argumencie.

let session (action:Session->WebPart) =
    context (fun httpContext ->
                let withNewSession () =
                    let session = createSession None
                    do saveSession session
                    setSessionCookie session >=> action session

                match getSessionCookie httpContext with
                | None ->
                    withNewSession ()
                | Some sessionCookie ->
                    match getSession sessionCookie.value with
                    | None ->
                        withNewSession ()
                    | Some session ->
                        action session
            )

Jest tu kilka nieopisanych przeze mnie funkcji, ale ich nazwy wyjaśniają co one robią.

Prawie. createSession przyjmuje opcję użytkownika i tworzy zalogowaną lub niezalogowaną sesję. A getSession i saveSession odwołują się do bazy danych.

Użycie

W moim kodzie na razie używam tego tylko w funkcji do zalogowania się, ale tak jak napisałem wyżej, dopiero co zacząłem pisać tę aplikację.

let loginPage = 
    choose [
        GET >=> session (function
                        | NotLoggedIn _ -> Views.loginPage
                        | LoggedIn _ -> Redirection.FOUND "/")
        POST (*...*)
    ]

Podsumowanie

Mam nadzieję, że dość jasno przedstawiłem wam jak wygląda sprawa z sesjami aplikacji webowych. Nie jest to specjalnie skomplikowane, a działa na tym większa część internetu.

Jeśli macie uwagi co do mojego podejścia, to zachęcam do komentowania, bo chciałbym żeby ten projekt służył mi potem jako przykład bezpiecznej aplikacji.

Do dalszego czytania - The definitive guide to form-based website authentication (StackOverflow).