Il configure https sur son site Django. La suite est à peine croyable.

Configurer un projet Django pour servir un site en https, c'est facile et c'est pas cher. Si si, je vous jure !

« Hommage à Calder », installation d'Arne Quinze à Nice.

Entre les trucs franchement flippants votés par notre bien-aimé (ironie) gouvernement de gauche (ironie) et l'annonce de Let's Encrypt qui sort de sa version beta, il n'y a vraiment plus aucune raison de ne pas activer https pour tous vos sites. Voici comment procéder avec Django.

Pour ceux qui ne sont pas trop au courant, je rappelle que https est un protocole de transfert sur le web qui consiste à faire transiter du http tout bête dans un tunnel chiffré. Pour parler clairement, cela signifie que quand vous vous connectez sur un site avec https, l'intégralité des échanges ressemble à de la soupe de navets pour tout autre que vous-même (le navigateur) et votre correspondant (le serveur).

À l'inverse, en http, toute personne ayant le moyen d'écouter les paquets réseaux de votre communication (e.g un malware sur votre routeur, votre fournisseur d'accès, quelqu'un sur le même wifi que vous s'il n'est pas chiffré non-plus, l'admin système de votre boite, les mouchards des RG, etc.) pourra consulter l'intégralité de votre transaction voire modifier vos échanges sans que vous ne vous en rendiez compte (on parle d'attaque « man in the middle »).

Activer ssl offre donc deux garanties :

  • les échanges entre vos internautes et vous sont indéchiffrables et inaltérables ;
  • il est impossible à quelqu'un de se faire passer pour vous puisque votre identité est certifiée par une autorité de certification.

En tant qu'utilisateurice du web, c'est une mesure de salubrité personnelle que de toujours se connecter sur la version https d'un site quand c'est possible. En tant que professionnel·le·s, c'est à vous d'offrir à vos internautes cette possibilité.

Il est faux de penser qu'https n'est utile que pour les échanges de données sensibles (e.g les paiements par carte-bleue). À vrai dire, il n'existe aucune raison valable de ne pas activer ssl sur l'intégralité de votre site, pour les raisons suivantes :

  • vous ne pouvez pas forcément savoir quelles informations seront considérées sensibles ou pas par celleux qui utilisent vos services ;
  • nous ne vivons toujours pas dans une utopie d'ouverture et de tolérance, et l'on ne peut jamais savoir à quel moment vos internautes auront besoin d'anonymat ;
  • à une époque ou la moindre cafetière se connecte au réseau, les sources potentielles de failles de sécurité se multiplient, aussi mieux vaut prévenir que guérir.

Créer un certificat avec Let's Encrypt

Jusqu'à il y a peu, obtenir des certificats était complexe et coûteux. Les choses sont en train de changer, notamment grâce à l'apparition de Let's Encrypt, une autorité de certification fournissant des certificats gratuits et des outils open-source pour en faciliter la gestion.

Allez, je vous laisse lire la doc si vous voulez en savoir plus, mais pour les gens pressés, voici les quelques commandes à exécuter sur votre serveur.

Gros warning tout gras. N'exécutez pas ces commandes sur votre serveur sans savoir ce que vous faites ou vous allez vous faire pincer les doigts très fort. Parce que d'abord, il faut être grave incompétent pour copier-coller du texte depuis le web dans une console de son serveur. Ensuite, le client Let's Encrypt est plein de code qui s'auto-update et contient des rm -Rf /${variable} ce qui est une idée pour le moins saugrenue. Vous êtes prévenu·e·s.

cd /tmp/
git clone https://github.com/letsencrypt/letsencrypt.git
cd letsencrypt
sudo service nginx stop
./letsencrypt-auto certonly --email votre@email.fr -d votre.domaine.fr -d votre.autre.domaine.fr
sudo service nginx start

Et bam ! Let's encrypt va automatiquement reconnaître que vous êtes bien le propriétaire des domaines indiqués (en lançant un petit serveur en local, d'où la nécessité de tuer votre serveur web avant) et générer des certificats, par défaut dans /etc/letsencrypt/live/votre.domaine.fr/.

Activer https dans Nginx

Je pars du principe que vous avez une installation assez classique à base de Nginx en reverse proxy. Nous allons indiquer au serveur où trouver les certificats, et pour faire bonne mesure nous allons complètement désactiver l'accès non-chiffré, que nous redirigerons systématiquement vers du https.

upstream monsite {
    # Adresse du serveur wsgi, par exemple gunicorn
    server localhost:8000;
}

server {
    # Redirige toutes les requêtes sur le port 80
    # vers la même page sur le port 443.
    listen         80;
    server_name    monsite.maboite.com;
    return         301 https://$server_name$request_uri;
}

server {
    # Et la magie opère…
    listen 443 ssl;
    server_name monsite.talengi.com;

    ssl_certificate /etc/letsencrypt/live/monsite.maboite.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/monsite.maboite.com/privkey.pem;

    location / {
        proxy_pass http://monsite;
        proxy_redirect off;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # Notez cette ligne, elle est importante, nous allons expliquer
        # plus loin.
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Redémarrez Nginx, puis, en vous connectant sur http://www.votresite.com, vérifiez que vous êtes redirigé·e vers la version https. Prenez ensuite quelques minutes pour déguster une coupe de champagne et adresser une prière à saint Snowden.

Correctement gérer https avec Django

Nous n'avons pour le moment pas modifié la config Django, et notre site marche parfaitement. Que nous reste-t-il à faire ?

À vrai dire, il y a un petit problème sur votre installation, qui passe à peu près invisible mais qui vous fera vous arracher les cheveux si vous avez le malheur de tomber dessus : Django n'est pas au courant du fait que les requêtes sont maintenant sécurisées.

L'objet HttpRequest de Django dispose d'une méthode is_secure qui permet de vérifier si la connexion est sécurisée ou non. Cette méthode vérifie tout bêtement si le protocole utilisé pour la requête est équivalent à « https ». Si oui, elle est considérée « sécurisée ».

Or, nous sommes dans une configuration ou Django ne se connecte pas en frontal sur le web puisqu'il se cache derrière un reverse proxy. Voici vaguement ce qui se passe.

L'internaute <-> https <-> Nginx <-> http <-> gunicorn.

Si la requête reçue par Nginx est bien en https, le serveur web n'a pas besoin de chiffrer la connexion au serveur wsgi. Par conséquent, Django persiste à penser que la connexion se fait en http.

Pour illustrer le problème, voici la trace réseau lorsque je me connecte à l'url https://www.miximum.fr/blog (qui normalement redirige vers https://www.miximum.fr/blog/ (avec le « / » à la fin).

Capture de la chaîne de
redirection
Saurez-vous repérer l'intrus ?

La chaîne de redirection est la suivante :

  1. https://www.miximum.fr/blog
  2. https://www.miximum.fr/blog/
  3. https://www.miximum.fr/blog/

Or la première redirection est superflue, nous aimerions obtenir :

  1. https://www.miximum.fr/blog
  2. https://www.miximum.fr/blog/

Puisque Django pense être hébergé sur un domaine non sécurisé, il génère des urls avec le protocole http au lieu de https.

Pour y remédier, nous allons bidouiller quelques paramètres. Dans vos settings, ajoutez ceci :

SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

Nouvel avertissement bien gras. À nouveau, vous allez ouvrir une grosse faille de sécu si vous configurez cette option sans comprendre ce que vous faites. Lisez bien la suite.

Cette option indique à Django que pour vérifier si la requête doit être considérée comme sécurisée, il doit vérifier l'existence d'une entête http « HTTP_X_FORWARDED_PROTO ». Si l'entête en question contient la valeur « https », alors la requête est sécurisée. Cool !

Oui mais attention ! Il faut être certain que cette fameuse entête ne puisse pas arriver jusqu'à Django à notre insu. Qu'est-ce qui empêche un petit malin de taper la chose suivante dans une console ?

curl -H "X-FORWARDED-PROTO: https" https://www.miximum.fr/url-securisée/

Il faut donc, au niveau de la configuration du reverse proxy, s'assurer que l'on écrase systématiquement la valeur de l'entête.



location / {
    proxy_pass http://monsite;
    proxy_redirect off;
    proxy_set_header Host $http_host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    # C'est  ->
    proxy_set_header X-Forwarded-Proto $scheme;
}

Si, et seulement si, votre projet est correctement configuré pour systématiquement utiliser https, vous pouvez ajouter les options suivantes :

CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True

Je vous laisse lire la doc pour savoir de quoi il retourne.

Exemple d'utilisation

À part lors de la génération de liens, à quoi cela peut-il servir de distinguer les urls sécurisées des autres ? Prenons un exemple concret tiré d'un vrai projet.

Imaginez que vous vouliez utiliser le framework de syndication de Django pour construire des flux rss sécurisés, c'est à dire qu'un utilisateur devra être authentifié pour y accéder.

La plupart des lecteurs rss ne gèrent rien de mieux qu'une authentification http basique qui a le gros désavantage de ne pas chiffrer les identifiants envoyés.

Nous voulons donc la chose suivante :

  • si un utilisateur non-authentifié essaye d'accéder à un flux rss via le protocole http, on l'envoie bouler purement et simplement (403) ;
  • si un utilisateur non-authentifié essaye d'accéder à un flux rss via https, alors on peut se permettre de lui demander ses identifiants (401).

Voici un exemple d'implémentation :

# -*- coding: utf-8 -*-
from __future__ import unicode_literals

import base64

from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.views.generic import View
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth import authenticate, login
from django.contrib.syndication.views import Feed


class HttpResponseUnauthorized(HttpResponse):
    status_code = 401

    def __init__(self, *args, **kwargs):
        super(HttpResponseUnauthorized, self).__init__(*args, **kwargs)
        self['WWW-Authenticate'] = 'Basic realm="My authenticated feed"'


class SecureFeed(Feed, View):
    """Authenticated feed, oh yeah!."""

    def dispatch(self, request, *args, **kwargs):
        # Si l'utilisateur fournit des identifiants, tentons de
        # l'authentifier
        if 'HTTP_AUTHORIZATION' in request.META:
            self.authenticate_user(request)

        # Si l'utilisateur n'est pas authentifié…
        if not self.request.user.is_authenticated():

            # …et que la requête est sécurisée, on peut lui demander
            # de fournir ses identifients
            if self.request.is_secure():
                return HttpResponseUnauthorized('Unauthorized')

            # …sinon, il faut l'envoyer bouler par mesure de sécurité
            # (c'est pour son bien)
            else:
                msg = _('This url cannot be accessed through a non-secure protocol')
                raise PermissionDenied(msg)
        else:
            return super(SecureFeed, self).dispatch(request, *args, **kwargs)

    def authenticate_user(self, request):
        """Authentification Basic auth classique."""
        try:
            auth = request.META['HTTP_AUTHORIZATION'].split()
            decoded_auth = base64.b64decode(auth[1])
            username, password = decoded_auth.split(':')
        except:
            # Invalid authorization header sent by client
            # Let's block everything.
            raise PermissionDenied()

        user = authenticate(username=username, password=password)
        if user is not None:
            if user.is_active:
                login(request, user)


    # Other feed methods…

Ah, et puis, tester https dans Django, c'est vraiment de la tarte. Le code précédent devrait faire passer les tests suivants :

# -*- coding: utf-8 -*-
from __future__ import unicode_literals

import base64

from django.test import TestCase
from django.core.urlresolvers import reverse

from accounts.factories import UserFactory


class AuthenticatedFeedTests(TestCase):
    def setUp(self):
        self.user = UserFactory(
            email='myuser@test.com',
            password='pass'
        )
        self.url = reverse('my_authenticated_feed')

    def test_authenticated_user(self):
        self.client.login(email=self.user.email, password='pass')
        res = self.client.get(self.url)
        self.assertEqual(res.status_code, 200)

    def test_unsecure_unauthenticated_user(self):
        res = self.client.get(self.url, **{'wsgi.url_scheme': 'http'})
        self.assertEqual(res.status_code, 403)

    def test_secure_unauthenticated_user(self):
        res = self.client.get(self.url, **{'wsgi.url_scheme': 'https'})
        self.assertEqual(res.status_code, 401)
        self.assertTrue('WWW-AUTHENTICATE' in res)

    def test_login_with_invalid_credentials(self):
        res = self.client.get(self.url, **{
            'wsgi.url_scheme': 'https',
            'HTTP_AUTHORIZATION': 'portenawak'
        })
        self.assertEqual(res.status_code, 403)

    def test_login_unactive_user(self):
        self.user.is_active = False
        self.user.save()

        credentials = '{}:pass'.format(self.user.email)
        b64_credentials = base64.b64encode(credentials)
        full_credentials = 'Basic: {}'.format(b64_credentials)

        res = self.client.get(self.url, **{
            'wsgi.url_scheme': 'https',
            'HTTP_AUTHORIZATION': full_credentials
        })
        self.assertEqual(res.status_code, 401)

    def test_login_with_wrong_password(self):
        credentials = '{}:wrongpassword'.format(self.user.email)
        b64_credentials = base64.b64encode(credentials)
        full_credentials = 'Basic: {}'.format(b64_credentials)

        res = self.client.get(self.url, **{
            'wsgi.url_scheme': 'https',
            'HTTP_AUTHORIZATION': full_credentials
        })
        self.assertEqual(res.status_code, 401)

    def test_sucessfull_login(self):
        credentials = '{}:pass'.format(self.user.email)
        b64_credentials = base64.b64encode(credentials)
        full_credentials = 'Basic: {}'.format(b64_credentials)

        res = self.client.get(self.url, **{
            'wsgi.url_scheme': 'https',
            'HTTP_AUTHORIZATION': full_credentials
        })
        self.assertEqual(res.status_code, 200)

Pour finir

Aahhh… Il y avait longtemps que je n'avais pas posté du code sur Miximum. Ça fait du bien !