środa, 25 lipca 2012

Apache Basic Auth jakiego nie znacie!


    Basic Auth w protokole HTTP jest to najprostszy i zapewne najszerzej wykorzystywany rodzaj autoryzacji. O bezpieczeństwie tego typu autoryzacji możemy mówić dopiero wtedy gdy używamy szyfrowanego https, ale dla nas nie ma to w tym momencie zanczenia :) Basic Auth konfigurujemy w apache bardzo łatwo np:



        <Location /secure>
                AuthType Basic
                AuthName "Log in please."

               AuthUserFile /etc/apache2/secret/htpass
               Require valid-user 
        </Location>

    Do wskazanego pliku dodajemy użytkowników przy pomocy polecenia htpasswd. Od tej pory gdy ktoś będzie wywoływał jakiekolwiek zapytanie do naszego serwera rozpoczynające się od /secure, serwer wyśle mu w odpowiedzi komunikat 401 - Authorization Required. Nasza przeglądarka wyświetli nam okienko, gdzie wpiszemy nazwę użytkownika oraz hasło. Następnie ustawi nagłówek Authorization w zapytaniu http na wartość np taka:

        GET /secure HTTP/1.1
        Host: localhost
        Authorization: Basic dGVzdDp0ZXN0

Hasło i nazwa użytkownika nie jest w żaden sposób zaszyfrowane. Całość jest tylko przekształcona przy pomocy algorytmu base64 - powszechnie używanego do kodowania tekstów zawierających znaki z poza tablicy ASCII. Poniżej prosty przykład, tworzenia użytkownika oraz zakodowania go w base64:

        $ htpasswd -bn test test
        test:0C3DJcaJJjJFI
        $ echo -n "test:test" | openssl enc -base64
        dGVzdDp0ZXN0

Co prawda hasło w pliku nie jest podawane w formie jawnej tylko funkcji skrótu crypt (lub md5 czy sha1), jednak w samym nagłówku już nie. Inaczej jest przy autoryzacji typu Digest, nie o niej jednak mowa - istnieje kilka odmian a konfiguracje je wykorzystujące należą raczej do rzadkości.

Wiemy już wszystko co powinniśmy i jak do tej pory żadnych rewelacji, pytanie więc czemu tytuł tego posta brzmi "Apache Basic Auth jakiego nie znacie!"?

Ponieważ Basic Auth można wykorzystać do o wiele bardziej zaawansowanej konfiguracji, np:

        1. Uwierzytelnić się przy pomocy ciasteczka lub zapytania url
                - bez użycia konstrukcji http://user:pass@host

        2. Uwierzytelnić jedną sesje pomiędzy wieloma różnymi serwerami apache

        3. Zdefiniować gdzie dokładnie użytkownik ma mieć dostęp, określając pojedynczy plik lub głęboko zagnieżdżoną lokacje

Tak naprawdę to dopiero wierzchołek góry lodowej. Gdy przeczytasz ten artykuł do końca, będziesz w stanie określić np. okres ważności hasła - być może na kilka sposobów :)

Zaczynamy od sprawy podstawowej, czyli autoryzacja przy pomocy ciasteczka lub zapytania url. Wiemy jak wygląda nagłówek Authorization, wiemy jak wygenerować jego treść, więc nic prostrzego jak go dodać do zapytania. Najpierw wyciągamy wartość z url-a lub ciasteczka i zapisujemy w zmiennej środowiskowej STOKEN:

        RewriteCond %{HTTP:Cookie} stoken\=([0-9a-z\=]+)$ [NC,OR]
        RewriteCond %{QUERY_STRING} stoken\=([0-9a-z\=]+)$ [NC]
        RewriteRule ^/secure - [env=STOKEN:%1]


Teraz wystarczy już tylko ustawić nagłówek, wstawiając przy okazji ciasteczko - linki na stronie nie będą zawierały parametru stoken w url:

        RequestHeader set Authorization "Basic %{STOKEN}e" env=STOKEN
        Header set Set-Cookie "stoken=%{STOKEN}e;" env=STOKEN

Przetestować konfiguracje możemy przy pomocy polecenia

        $ curl http://localhost/?stoken=dGVzdDp0ZXN0

Oczywiście wynik testu będzie negatywny, ponieważ ustawiliśmy nagłówek w zapytaniu które już wpadło do serwera - musimy więc przesłać je ponownie i wykorzystamy do tego parametr proxy [P], całość wygląda tak:

        RewriteCond %{HTTP:Authorization} !^$
        RewriteRule ^/auth/secure - [S=3]       # jump, omijamy ustawianie nagłówków itp -->


        RewriteCond %{HTTP:Cookie} stoken\=([0-9a-z\=]+)$ [NC,OR]
        RewriteCond %{QUERY_STRING} stoken\=([0-9a-z\=]+)$ [NC]
        RewriteRule ^/secure - [env=STOKEN:%1]

        RewriteCond %{ENV:STOKEN} ^$
        RewriteRule ^/secure - [F,L]   # zabraniamy dostępu jeżeli nie został ustawiony stoken

        RequestHeader set Authorization "Basic %{STOKEN}e" env=STOKEN
        Header set Set-Cookie "stoken=%{STOKEN}e;" env=STOKEN
        RewriteCond %{ENV:STOKEN} !^$
        RewriteRule ^(.*)$ http://localhost/auth$1 [P]


        # --> jump
        <Location /auth/secure>
                AuthType Basic
                AuthName "Log in please."

               AuthUserFile /etc/apache2/secret/htpass
               Require valid-user

        </Location>

Pojawia się jednak problem w przypadku podania złego hasła. Serwer odpowie 401 a przeglądarka wyświetli nam okienko z prośbą o hasło, gdy tymczasem my wolimy od razu zgłosić błąd 403 - Forbidden. Problem ten można rozwiązać przy pomocy dyrektywy ErrorDocument przekierowującej do dokumentu dla którego dostęp jest zabroniony :)

        <Location /auth/secure>
                AuthType Basic
                AuthName "Log in please."
                AuthUserFile /etc/apache2/secret/htpass
                Require valid-user

                ErrorDocument 401 /secure/noauth
                ErrorDocument 403 "Access Denied!"
        </Location>
        <Location /secure>
                ErrorDocument 403 "Access Denied!"
        </Location>

Jeżeli chodzi o punkt pierwszy to by było wszystko. Teraz punkt drugi, czyli - jak uwierzytelnić w ten sposób jedną sesje na wielu serwerach? Aplikacja wpuszczająca do systemu musi zapisać nazwę użytkownika i hasło w takim miejscu do którego dostęp będzie miał serwer apache, czyli do memcache, bazy ldap lub tak jak w naszym przypadku mysql. Baze możemy przygotować przy pomocy sekwencji poleceń:

        create database httpd_auth;
        create user apache@localhost identified by '12345';
        grant select on httpd_auth.* to apache@localhost;
        use httpd_auth;
        CREATE TABLE `users` (
          `uid` int(10) unsigned NOT NULL AUTO_INCREMENT,
          `username` char(255) NOT NULL,
          `pass` char(64) NOT NULL,
          `servername` char(128) NOT NULL,
          PRIMARY KEY (`uid`),
          UNIQUE KEY (`username`),
          UNIQUE KEY (`servername`)
        ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

Dodaliśmy więc tabele httpd_auth.users oraz użytkownika apache który ma możliwość odczytania z niej danych. W tabeli users możemy również dodać kolumny creation_timestamp, expire_after określające okres ważności wpisu. Hasło wstawiane do kolumny pass jest zapisane otwartym tekstem - hash otrzymujemy wykonując funkcje encrypt. Jawne hasło jest potrzebne do generowania poprawnych wartości dla stoken. Konfiguracje Basic Auth w apachu zmieniamy na podobną do:

        DBDriver mysql
        DBDParams "host=localhost port=3306 dbname=httpd_auth user=apache pass=12345"


        <Location /auth/secure>
                AuthType Basic
                AuthName "Log in please."
                AuthBasicProvider dbd
                AuthDBDUserPWQuery "SELECT ENCRYPT(pass) as pass FROM users WHERE username = %s and servername=localhost"
                Require valid-user

                ErrorDocument 401 /secure/noauth
                ErrorDocument 403 "Access Denied!"
        </Location>
        <Location /secure>
                ErrorDocument 403 "Access Denied!"
        </Location>


Jak widać powyżej mamy możliwość zdefiniowania zapytania wykonywanego do bazy w celu autoryzacji użytkownika. Dzięki temu wykorzystanie kolumn creation_timestamp, expire_after jest już banalnie proste :) Dodatkowo wykonujemy w locie skrót hasła funkcją encrypt i nie będzie to miało wpływu na obciążenie bazy jeżeli tylko włączymy jej cache zapytań - powinniśmy to robić przy każdej instalacji.

Na koniec zostało nam najtrudniejsze - jak uściślić dostęp do lokalizacji bardziej zagnieżdżonych, czyli np http://localhost/secure/files/user/?

Musimy mieć możliwość zbadania w bazie czy zapytanie GET zawiera dozwoloną ścieżkę. Dozwoloną ścieżkę w konfiguracji apacha określimy jako SCONTEXT (ang. security context). W tym celu możemy wykorzystać nazwę użytkownika, ponieważ do jej rozkodowanej postaci mamy dostęp już z mod_rewrite dzięki zmiennej REMOTE_USER. Zmieniamy więc format z user:pass na uid@scontext:pass. W bazie użytkownika możemy dodać przy pomocy zapytania:

        insert into users set username=CONCAT(LAST_INSERT_ID(),"@secure/user/"), pass='password', servername='localhost';

Nazwa użytkownika powinna być unikalna, dlatego tutaj posługuję się funkcją LAST_INSERT_ID. Nie ma znaczenia jaką nazwę użytkownika wybierzemy, ważne by się nie powtarzały. Teraz generujemy token zakodowany w base64:

        $ echo -n "5@secure/user/:password" | openssl enc -base64
        NUBzZWN1cmUvdXNlci86cGFzc3dvcmQ=

W ten sposób zdefiniowaliśmy, że użytkownik będzie miał dostęp do adresu http://localhost/secure/user i wszystkiego poniżej, ale już zapytanie do http://localhost/secure powinno zwrócić mu błąd 403. Jak to osiągnąć?

Musimy przyrównać scieżkę z zapytania do tej części nazwy użytkownika która jest ograniczona małpką i dwukropkiem. Nie jest to takie proste jak się wydaje - mod_rewrite nie pozwala na porównanie dwóch zmiennych środowiskowych, a jedynie zmiennej do wyrażenia regularnego. Sztuczka polega więc na stworzeniu regexpa który zawiera odwołanie wsteczne do siebie. Jest to możliwe tylko na systemach zgodnych z POSIX. Na Windowsie raczej nam się to nie uda, ponieważ jego biblioteki regexp-ów nie zawierają takich konstrukcji.

    O co tutaj chodzi dokładnie? Ano chodzi o sklejenie dwóch ciągów i sprawdzenie czy odwołanie wsteczne zostanie poprawnie przypasowane, np: "a<>a" ~= "(.*)<>\1". Odwołanie wsteczne \1 jest rozwiązywane na zawartość objętą nawiasem. W praktyce u nas będzie to wyglądać tak:

   # --> jump
   RewriteRule (/auth)?/secure/noauth /secure/noauth [F,L]

   RewriteCond %{IS_SUBREQ} =true
   RewriteRule .* - [L]

   RewriteRule ^/auth/(.+/).*$ - [env=SCONTEXT:$1]
   RewriteCond %{LA-U:REMOTE_USER}<>%{ENV:SCONTEXT} !^[0-9a-z]+@(.+)<>\1.*$ [NC]
   RewriteRule ^/auth/secure - [F,L]


Nazwę, użytkownika rozwiązujemy wykonując podzapytanie przy pomocy %{LA-U:REMOTE_USER} i musimy to uwzględnić dodając warunek sprawdzający wartość zmiennej %{IS_SUBREQ}.

I to wszystko na dzisiaj moi drodzy. Pytanie po co się tak męczyć, może jednak lepiej zrobić to zwyczajnym skryptem php? ;] Jeżeli ktoś ma ochote, może przetestować wydajność obu rozwiązań. Prosiłbym o informacje jak wypadły testy :)