Progetto

Generale

Profilo

Django Tornado Nginx Auth

Si suppone che si abbia un progetto django nella directory /home/utente/projects/django/ e un progetto tornado nella directory /home/utente/projects/tornado/. Si vuole configurare nginx in modo che django venga servito all'url http://example.com/ e tornado all'url http://example.com/tornado/, facendo in modo che l'accesso a tornado sia inibito se non si è autenticati a django.

La guida è condotta su un sistema Debian Jessie, al momento in testing (per altre versioni probabilmente andrà adattato qualcosa).

Prerequisiti

Servono (oltre a django e tornado, ovviamente) anche i pacchetti: devscripts, mercurial, fakeroot.

Compilazione di nginx

Esiste un plugin per nginx (scritto da uno dei core developer di nginx stesso) che nella versione 1.4.x del server (quella attualmente in debian) non è incluso. Quindi bisogna scaricare il plugin e ricompilare nginx. Per comodità si useranno il più possibile i sorgenti e il sistema di build di debian.

$ mkdir /home/utente/projects/nginx
$ cd /home/utente/projects/nginx
$ dget http://ftp.de.debian.org/debian/pool/main/n/nginx/nginx_1.4.4-1.dsc

Questo per scaricare il pacchetto sorgente di nginx 1.4.4 e averlo automaticamente scompattato.

$ cd nginx-1.4.4/debian/modules
$ hg clone http://mdounin.ru/hg/ngx_http_auth_request_module nginx-auth-request

A questo punto nella sotto-directory debian/modules/nginx-auth-request c'è il codice del plugin richiesto (preso direttamente dal repository mercurial dove viene sviluppato, dato che il repository su github è meno aggiornato).

# apt-get build-dep nginx

In questo modo verranno installati i pacchetti necessari a compilare nginx. Ora bisogna modificare il file debian/rules nella sezione riguardante il pacchetto binario nginx-full (solo in questa, nelle altre non è necessario), aggiungendo la riga:

--add-module=$(MODULESDIR)/nginx-auth-request \

sotto alle altre direttive --add-module, in modo che il file si presenti così:

[...]
config.status.full: config.env.full                                                 ### In questa sezione...
    cd $(BUILDDIR_full) && CFLAGS="$(CFLAGS)" CORE_LINK="$(LDFLAGS)" ./configure  \
        --prefix=/usr/share/nginx \
        --conf-path=/etc/nginx/nginx.conf \
        --error-log-path=/var/log/nginx/error.log \
        --http-client-body-temp-path=/var/lib/nginx/body \
        --http-fastcgi-temp-path=/var/lib/nginx/fastcgi \
        --http-log-path=/var/log/nginx/access.log \
        --http-proxy-temp-path=/var/lib/nginx/proxy \
        --http-scgi-temp-path=/var/lib/nginx/scgi \
        --http-uwsgi-temp-path=/var/lib/nginx/uwsgi \
        --lock-path=/var/lock/nginx.lock \
        --pid-path=/run/nginx.pid \
        --with-pcre-jit \
        --with-debug \
        --with-http_addition_module \
        --with-http_dav_module \
        --with-http_geoip_module \
        --with-http_gzip_static_module \
        --with-http_image_filter_module \
        --with-http_realip_module \
        --with-http_stub_status_module \
        --with-http_ssl_module \
        --with-http_sub_module \
        --with-http_xslt_module \
        --with-ipv6 \
        --with-mail \
        --with-mail_ssl_module \
        --add-module=$(MODULESDIR)/nginx-auth-pam \
        --add-module=$(MODULESDIR)/nginx-dav-ext-module \
        --add-module=$(MODULESDIR)/nginx-echo \
        --add-module=$(MODULESDIR)/nginx-upstream-fair \
        --add-module=$(MODULESDIR)/ngx_http_substitutions_filter_module \
        --add-module=$(MODULESDIR)/nginx-auth-request \                             ### ...aggiungere questa riga
            $(CONFIGURE_OPTS) >$@
    touch $@
[...]

A questo punto bisogna compilare i pacchetti binari:

$ cd /home/utente/projects/nginx/nginx-1.4.4
$ fakeroot debian/rules binary

Alla fine della compilazione nella directory /home/utente/projects/nginx saranno presenti i pacchetti binari:

nginx_1.4.4-1_all.deb
nginx-common_1.4.4-1_all.deb
nginx-doc_1.4.4-1_all.deb
nginx-extras_1.4.4-1_amd64.deb
nginx-extras-dbg_1.4.4-1_amd64.deb
nginx-full_1.4.4-1_amd64.deb
nginx-full-dbg_1.4.4-1_amd64.deb
nginx-light_1.4.4-1_amd64.deb
nginx-light-dbg_1.4.4-1_amd64.deb
nginx-naxsi_1.4.4-1_amd64.deb
nginx-naxsi-dbg_1.4.4-1_amd64.deb
nginx-naxsi-ui_1.4.4-1_all.deb

Si può procedere ad installare i pacchetti necessari direttamente da questa directory:

# dpkg -i nginx_1.4.4-1_all.deb nginx-common_1.4.4-1_all.deb nginx-full_1.4.4-1_amd64.deb

Avvio delle applicazioni

Dato che la guida si concentra sulla gestione dell'autenticazione tra django e tornado, si assume per semplicità che l'applicazione tornado sia hello-world (link) e che le due applicazioni siano servite dai server di sviluppo:

$ cd /home/utente/projects/django
$ ./manage.py runserver
$ cd /home/utente/projects/tornado
$ ./hello-world.py

In questo modo il server di sviluppo di django risponde a 127.0.0.1:8000 e tornado a 127.0.0.1:8888

Creazione del file di configurazione di nginx

Si crei il file /etc/nginx/sites-available/django:

server {
    listen 80;
    root /home/utente/projects/django;
    server_name django;
    access_log /home/utente/projects/django/access.log;
    error_log /home/utente/projects/django/error.log;
    location / {
        proxy_pass http://127.0.0.1:8000;
    }
    location /tornado {
        auth_request /is_logged_in/;
        proxy_pass http://127.0.0.1:8888;
    }
}

e si attivi con:

# ln -s /etc/nginx/sites-available/django /etc/nginx/sites-enabled/django
# /etc/init.d/nginx restart

In questo modo si dice a nginx che per URL sotto a /tornado deve fare una request a /is_logged_in/ e accettare di passare il controllo a tornado solo se riceve una risposta HTTP 200.

Aggiungere la riga

127.0.0.1 django

al file /etc/hosts.

Modifica delle applicazioni Django e Tornado

Dato che i cookie in passaggio da django a tornado possono essere modificati dall'utente (e quindi si rischia di permettere a un utente di impersonarne un altro all'interno di tornado), si può fare in modo che django usi i cookie firmati, e riutilizzare l'infrastruttura relativa anche da tornado.

Bisogna fare un link simbolico al file settings.py di django all'interno della directory di tornado ed esportare una variabile d'ambiente prima di lanciare l'applicazione tornado stessa:

$ ln -s /home/utente/projects/django/my_project/settings.py /home/utente/projects/tornado/django_settings.py
$ cd /home/utente/projects/tornado && export DJANGO_SETTINGS_MODULE="django_settings" && ./hello_world.py

In questo modo tornado può caricare i settings di django, ed accedere alle classi relative alla gestione dei cookie firmati (che necessitano di settings come SIGNING_BACKEND). L'applicazione hello world va modificata così:

#!/usr/bin/env python
import tornado.ioloop
import tornado.web

class MainHandler(tornado.web.RequestHandler):
    def get_django_signed_cookie(self, key):
        from django.core import signing

        signed_value = self.get_cookie(key)
        try:
            value = signing.get_cookie_signer(salt=key).unsign(signed_value, max_age=None)
        except:
            value = None
        return value

    def get_current_user(self):
        user = self.get_django_signed_cookie('user')
        return user

    def get(self):
        username = self.get_current_user()
        self.write("Hello, %s!" % username)                 ### 'Hello, world!' diventa 'Hello, username!'
                                                            ### Il setup del cookie viene fatto nella view di login di django (v. sotto)

application = tornado.web.Application([
#    (r"/", MainHandler),                           ### Commentare/cancellare questa riga...
    (r'/tornado', MainHandler),                     ### ...e aggiungere questa
])

if __name__ == "__main__":
    application.listen(8888)
    tornado.ioloop.IOLoop.instance().start()

in modo che MainHandler risponda all'URL /tornado anziché all'URL /. Di fatto il metodo MainHandler.get_django_signed_cookie() è paragonabile a HttpRequest.get_signed_cookie() di django. Nell'applicazione django va aggiunto in urls.py:

    url(r'^is_logged_in/$', 'myproject.views.is_logged_in', name='is_logged_in'),

e una view di questo tipo:

from django import http

def is_logged_in(request):
    if request.user.is_anonymous():
        return http.HttpResponseForbidden("NO")
    else:
        return http.HttpResponse("OK")

che non fa altro che restituire un HTTP 403 in caso l'utente non sia loggato, e un HTTP 200 diversamente.

Bisogna poi settare un cookie con i dati che si vuole che passino da django a tornado nella view di login (e cancellarlo in quella di logout), ad esempio in questo modo:

from django import http
from django.contrib import messages
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse
from django.shortcuts import render
from django.utils.translation import ugettext_lazy as _

def login_view(request):
    username = password = ''
    _next = request.GET.get("next", None)
    if request.POST:
        username = request.POST['username']
        password = request.POST['password']
        user = authenticate(username=username, password=password)
        if user is not None:
            if user.is_active:
                login(request, user)
                messages.info(request, _('Welcome, %s!' % username))
                if _next:
                    response = http.HttpResponseRedirect(_next)
                else:
                    response = http.HttpResponseRedirect(reverse('home'))
                ### Il cookie sarà firmato dall'infrastruttura di Django in modo che se viene modificato
                ### dall'utente, tornado se ne accorga in fase di authorization
                response.set_signed_cookie(
                    key='user',
                    value=request.user.username,
                    expires=request.session.get_expiry_age(),
                )
                return response
            else:
                messages.error(request, _('Account %s is disabled' % username))
        else:
            messages.error(request,
                _("The credentials you supplied were not correct or did not grant access to this resource."))
    return render(request, 'login.html')

@login_required
def logout_view(request):
    logout(request)
    response = http.HttpResponseRedirect(reverse('home'))
    response.delete_cookie('user')
    return response

In questo esempio il cookie user contiene lo username dell'utente appena loggato, ma si possono definire cookie per qualsiasi dato sia necessario passare tra django e tornado. Come si vede il valore di expires del cookie è settato uguale all'expiry_age della sessione attuale.

Se un template dell'applicazione django ha un link di questo tipo (o se si tenta di scrivere a mano l'URL):

<a href="/tornado" class="btn">Tornado</a>

nginx si comporterà così:

  • Vede la richiesta a http://django/tornado
  • La direttiva auth_request passa il controllo all'URL http://django/is_logged_in/
  • Django controlla se l'utente è loggato o meno, e dà una response di tipo 200 o 403
  • Se la risposta è 200, allora nginx prosegue e passa il controllo a http://127.0.0.1:8888 dove risponde tornado, che avrà accesso ai cookie eventualmente settati dalla view di login di django
  • Se la risposta è 403, nginx non prosegue e l'utente si vede impossibilitato a proseguire

Dubbi/problemi

  • Compilare un plugin (seppur proveniente da una fonte autorevole, come un core dev di nginx) non mi piace troppo, specialmente se in un pacchetto così fondamentale come il server web/proxy
  • Non ho ancora risolto il problema per cui l'utente non loggato vede un errore 403 anziché un più educato redirect alla view di login. Purtroppo il plugin non supporta i redirect, ma soltanto 403/200, quindi perché funzioni la direttiva auth_request non ho potuto usare il decoratore login_required nella view django.
  • Il changelog di nginx dice che dalla versione 1.5.4 del 27 agosto 2013 il modulo ngx_http_auth_request_module è incluso, quindi ci si può aspettare che anche i futuri pacchetti debian lo includano.