Protéger n’importe quelle application web avec un portail SSO

SSO ?

Le SSO (ou Single-Sign-On) est une fonctionnalité ô combien pratique pour l’utilisateur final : celui-ci s’identifie une seule fois puis ensuite est authentifié de manière transparente à toutes les applications protégées par le portail SSO. C’est malheureusement une fonctionnalité très souvent premium ou enterprise-grade car les protocols le mettant en oeuvre (SAML, OAuth, OpenID Connect) ne s’adressent de toute manière pas au premier schrtoumf venu et présentent une complexité certaine.

J’exclus d’office les authentifications de type basique avec validation en LDAP, parce que cela existe déjà d’une part et surtout pour la première raison évoqué ci-dessous.

On peut voir deux avantages énormes en terme de sécurité au SSO :

  • l’utilisateur s’identifie sur le portail SSO une seule fois. Le mot de passe, s’il y en a, n’est donc vu que par le portail SSO et non par l’application web protégée. En cas de hack du/des serveur(s) hébergeant l’application web, il n’y aura aucune données d’identification à récupérer. On dit que le processus d’authentification est déléguée à une application tierce.
  • la base de données des utilisateurs est centralisée, ainsi que sa gestion (autoriser/refuser des utilisateurs)

On dispose donc d’un très grand nombre d’applications web dont seule une minorité propose nativement une implémentation de SSO. Comment faire alors pour en restreindre l’accès, sans déveloper (donc valable autant pour une application open-source qu’une application propriétaire) ?

Reverse proxy

Une des solutions, celle que je vais détailler ici, va consister à introduire un intermédiaire entre le client et l’application web et par exemple avoir le modèle suivant :

Client -> Serveur HTTP -> Serveur Auth -> Server applicatif

Même si rien n’empêche techniquement de tout faire supporter par une unique instance, il est usuel de distinguer le serveur applicatif et les serveur(s) HTTP, les responsabilités étant distinctes. En effet, le(s) serveur(s) HTTP aura(ont) en charge :

  • la scalabilité horizontale ou load-balancing
  • le log des requêtes HTTP
  • l’off-loading TLS
  • le service des ressources statiques
  • la mise en cache de certaines ressources (statiques et/ou dynamiques)

Tandis que le serveur applicatif aura en unique responsabilité l’exécution du site web (aka la génération des pages dynamiques).

OpenResty OpenIDC

ZmartZone propose un serveur nginx agissant en tant que client OAuth. L’implémentation est faite en lua, grâce au framework OpenResty accompagné du module nginx éponyme.

Builder ce serveur est relativement simple et construire le containeur Docker pourra se faire avec le Dockerfile suivant : 

FROM openresty/openresty:alpine-fat

RUN set -xe; \
    apk add --no-cache ca-certificates; \
    /usr/local/openresty/luajit/bin/luarocks install lua-resty-openidc; \
    /usr/local/openresty/luajit/bin/luarocks install lua-resty-iputils

COPY nginx.conf /etc/nginx/conf.d/docker.conf.default
COPY docker-entrypoint.sh /

RUN chmod +x /docker-entrypoint.sh

CMD ["/usr/local/openresty/bin/openresty", "-g", "daemon off;"]
ENTRYPOINT [ "/docker-entrypoint.sh" ]

Le plus pénible sera la configuration à effectuer dans le fichier nginx.conf. Etant donné que l’implémentation OAuth est faite en lua, la conf se fait aussi en lua ; on est à la limite du code. J’ai donc ajouté à mon containeur un petit script afin de pouvoir effectuer une configuration classique directement au travers des variables d’environnement systèmes.

Le point d’entrée suivant :

#!/bin/sh

DEFAULT_CONF=/etc/nginx/conf.d/docker.conf

# deploy default conf
if [ ! -f "$DEFAULT_CONF" ]; then
    cp "$DEFAULT_CONF".default "$DEFAULT_CONF"

    if [ -n "$UPSTREAM" ]; then
        sed -i "s/#upstream#/$UPSTREAM/g" "$DEFAULT_CONF"
    fi

    if [ -n "$SESSION_SECRET" ]; then
        sed -i "s/\(?:#session_secret#\)/$SESSION_SECRET/g" "$DEFAULT_CONF"
    fi

    if [ -n "$DISCOVERY_URL" ]; then
        sed -i "s!#discovery_url#!$DISCOVERY_URL!g" "$DEFAULT_CONF"
    fi

    if [ -n "$CLIENT_ID" ]; then
        sed -i "s/#client_id#/$CLIENT_ID/g" "$DEFAULT_CONF"
    fi

    if [ -n "$CLIENT_SECRET" ]; then
        sed -i "s/#client_secret#/$CLIENT_SECRET/g" "$DEFAULT_CONF"
    fi

set -xe
    if [ -n "$WHITELIST_IPS" ]; then
        _ips=`echo $WHITELIST_IPS | tr ',' '\n'`
        _lua_ips=""

        for ip in $_ips; do
            _a=$_lua_ips',"'$ip'"'
            _lua_ips=$_a
        done

        _lua_ips2=`echo $_lua_ips | sed 's/^,//'`
        sed -i "s!#whitelist_ips#!$_lua_ips2!g" "$DEFAULT_CONF"
    fi
fi

sed -i '/^\s*--/d' "$DEFAULT_CONF"

exec "$@"

permet ainsi de configurer le fichier template pour nginx :

lua_package_path '~/lua/?.lua;;';
resolver 127.0.0.11;

lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
lua_ssl_verify_depth 5;

lua_shared_dict discovery 1m;
lua_shared_dict jwks 1m;

upstream _upstream {
        server #upstream#;
}

init_by_lua_block {
    local iputils = require("resty.iputils")
    iputils.enable_lrucache()
    local whitelist_ips = { #whitelist_ips# }

    -- WARNING: Global variable, recommend this is cached at the module level
    -- https://github.com/openresty/lua-nginx-module#data-sharing-within-an-nginx-worker
    whitelist = iputils.parse_cidrs(whitelist_ips)
}

server {
    listen 80 default_server;
    server_name _;

    set $session_name '_nginx_openidc';
    set $session_secret '#session_secret#';

    error_log /usr/local/openresty/nginx/logs/debug.log debug;
    error_log /dev/stderr warn;

    large_client_header_buffers 4 16k;

    access_by_lua_block {

        local opts = {
            -- the full redirect URI must be protected by this script
            -- if the URI starts with a / the full redirect URI becomes
            -- ngx.var.scheme.."://"..ngx.var.http_host..opts.redirect_uri
            -- unless the scheme was overridden using opts.redirect_uri_scheme or an X-Forwarded-Proto header in the incoming request
            redirect_uri = "/callback",

            -- The discovery endpoint of the OP. Enable to get the URI of all endpoints (Token, introspection, logout...)
            discovery = "#discovery_url#",

            -- Access to OP Token endpoint requires an authentication. Several authentication modes are supported:
            --token_endpoint_auth_method = ["client_secret_basic"|"client_secret_post"|"private_key_jwt"|"client_secret_jwt"],
            -- o If token_endpoint_auth_method is set to "client_secret_basic", "client_secret_post", or "client_secret_jwt", authentication to Token endpoint is using client_id and client_secret
            --   For non compliant OPs to OAuth 2.0 RFC 6749 for client Authentication (cf. https://tools.ietf.org/html/rfc6749#section-2.3.1)
            --   client_id and client_secret MUST be invariant when url encoded
            client_id = "#client_id#",
            client_secret = "#client_secret#",

            -- When using https to any OP endpoints, enforcement of SSL certificate check can be mandated ("yes") or not ("no").
            ssl_verify = "no",

            renew_access_token_on_expiry = true,
            -- whether this plugin shall try to silently renew the access token once it is expired if a refresh token is available.
            -- if it fails to renew the token, the user will be redirected to the authorization endpoint.
            access_token_expires_in = 3600,
            -- Default lifetime in seconds of the access_token if no expires_in attribute is present in the token endpoint response.

            access_token_expires_leeway = 60,
            --  Expiration leeway for access_token renewal. If this is set, renewal will happen access_token_expires_leeway seconds before the token expiration. This avoids errors in case the access_token just expires when arriving to the OAuth Resource Se
        }

        local openidc = require("resty.openidc")
        local iputils = require("resty.iputils")
        openidc.set_logging(nil, { DEBUG = ngx.INFO })

        if not iputils.ip_in_cidrs(ngx.var.http_x_forwarded_for, whitelist) and not (ngx.var.uri and ngx.re.match(ngx.var.uri, '/manifest.json$')) then
            -- call authenticate for OpenID Connect user authentication
            local res, err = require("resty.openidc").authenticate(opts)

            if err then
                ngx.status = 500
                ngx.log(ngx.STDERR, err)
                ngx.header["Content-Type"] = "text/plain"
                ngx.say(err)
                ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
            end

            -- at this point res is a Lua table with 3 keys:
            --   id_token    : a Lua table with the claims from the id_token (required)
            --   access_token: the access token (optional)
            --   user        : a Lua table with the claims returned from the user info endpoint (optional)

            -- set headers with user info: this will overwrite any existing headers
            -- but also scrub(!) them in case no value is provided in the token
            ngx.req.set_header("X-USER", res.id_token.sub)
        end
    }

    location / {
        proxy_pass http://_upstream/;
    }
}

Il sera donc nécessaire de spécifier les paramètres suivants : 

  • UPSTREAM : le serveur oauth agissant en tant que reverse proxy pour l’application web à protéger, il est nécessaire de spécifier l’url interne de l’application web qui est le backend
  • SESSION_SECRET : une chaîne de caractère bien aléatoire servant à chiffrer le cookie d’authentification. Il doit être le même sur tous les serveurs d’authentification
  • WHITELIST_IPS : une liste d’ips ou CIDR séparés par une virgule
  • DISCOVERY_URL : l’url vers l’endpoint du serveur SSO. Dans le cas de ADFS, ça sera https://mon-sso.kveer.fr/adfs/.well-known/openid-configuration
  • CLIENT_ID : l’id de l’application créé sur le serveur SSO
  • CLIENT_SECRET : le secret partagé de l’application généré par le serveur SSO

S’il y a besoin de faire des choses un peu plus exotiques, le template nginx.conf complet avec quelques explications (trop) sommaires sont sur le README.

Utiliser TLSv1.1 et TLSv1.2 sous nginx

Suite à mes 2 précédents billets sur une bonne configuration SSL de son serveur web :

Alléluia, openssl 1.0.1 est enfin marqué stable pour Gentoo. Avec lui, le support de TLS en version 1.1 et 1.2 arrive, lesquels permettent notamment de corriger la fameuse attaque BEAST.

Pour utiliser ces protocoles sous nginx, il suffit juste de compiler openssl, recompiler nginx, redémarrer nginx et le tour est joué 😉

Et maintenant on arrive à la grosse blague du billet : Chrome, que je considère comme l’un des 3 navigateurs majeurs, avec Internet Explorer et Firefox, ne supporte que TLS en version 1.0, lequel est sensible à l’attaque BEAST. Difficile sous ces conditions de désactiver TLS 1.0 et inférieur.

Ci-dessous le score donné par les tests SSL de Qualys de mon serveur web avec TLS v1.0 désactivé par rapport à avant :

qualys_2013

ERRATA : il semble que Chrome, au moins dans sa version 24.0.1312.57, supporte TLS v1.1.

Utiliser ruby (on rails) avec nginx

Ruby on Rails, ce n’est vraiment pas la panacée du point du déploiement, et je dirais même que c’est une technologie chiante avec tous ses modules et bugs obscurs dû à des dépendances pourries, mais lorsqu’on désire utiliser l’excellent gestionnaire de projet redmine, il faut bien mettre les mains dans le cambouis.

Jusqu’à présent, je faisais tourner redmine en utilisant mongrel, donc avec une passerelle fastcgi pour le faire communiquer avec nginx.
C’est une solution complètement naze, j’en conviens, car de cette manière, une seule requête pouvait être traitée à la fois, mais au moins c’était fonctionnel.
J’ai pu tricher en détournant les requêtes vers les ressources statiques vers nginx de la manière suivante :

server {
	listen 443;
	listen [mon_ipv6]:443 ssl ipv6only=on;
	server_name redmine.mondomaine.com;

	access_log /var/log/nginx/redmine.access_log main;
	error_log /var/log/nginx/redmine.error_log notice;

	root /var/www/redmine/htdocs/public;

	location ~* (^$|/$|^/(projects|attachments)|^[^\.]+$) {
		proxy_pass http://127.0.0.1:1091;
		proxy_set_header X-Real-IP $remote_addr;
		proxy_set_header Host $host;
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
		proxy_set_header X_FORWARDED_PROTO 'https';
		proxy_redirect http://redmine.mondomaine.com https://$host;
	}
}

Mais ce genre de rustine atteint très rapidement ses limites dès lors que plusieurs personnes utilisent le site en même temps.

Depuis que redmine est passé en version 1.4.x, j’ai mis à jour les modules demandés selon les prérequis, et là ça marche… si on veut :

  • consommation de mon quad-core de manière très variable mais plus souvent vers le 80% que vers le 0%, même lorsqu’il n’y a personne
  • warning en folie sur des instructions dépréciées

C’est une situation qui n’est pas tenable très longtemps, mon instance teamspeak sur le même serveur pourra confirmer, ça laggait grave.

Comment remédier à ces 3 problèmes que je rappelle :

  • consommation CPU délirante
  • warnings
  • mono-tache (1 requête à la fois)

J’ai tenté la solution phusion passenger, qui consiste à compiler un module passenger à Nginx. Nginx n’étant pas modulaire, compiler un module pour Nginx revient à recompiler Nginx.

Solution 1

J’ai installé la dernière version stable disponible dans portage, la version =www-apache/passenger-3.0.7 , puis utilisé la commande passenger-install-nginx-module . J’ai tenté, j’ai eu une erreur, je n’ai pas cherché très longtemps car cette solution ne me plaît pas : la commande va recompiler nginx, avec les modules par défaut et le module passenger, donc exit les USE que j’avais utilisés sur nginx.

Solution 2

J’ai utilisé passenger –start . A la première exécution, il va compiler une version de nginx avec passenger et l’installer quelque part dans /var/lib.

Cette solution est mieux au niveau architecture car on se retrouve avec deux instances nginx :

  • l’instance d’origine, qui était là avant passenger, celle que j’ai compilé et configuré reste et prend le rôle de frontend
  • passenger se lance dans une seconde instance d’une autre compilation de nginx (celle incluant le module passenger, ici ce nginx n’est qu’une coquille pour passenger). Elle a le rôle de backend et héberge donc l’application ruby. Elle communique avec le frontend par fastcgi.

Ca n’a pas marché, passenger n’étant pas fichu de compiler correctement la dépendance libev.

Solution 3

Mettons donc à jour passenger avec gem en version 3.0.13 (gem install passenger -v 3.0.13 ), non encore stable sur ma distribution au moment où je rédige ce billet. En retestant la solution 2, ça marche 🙂
Mais j’ai toujours une consommation CPU délirante.

Solution 4 (c’est la bonne)

OK, la solution 3 fonctionne, mais avoir 2 nginx, c’est un peu naze. N’y-a-t-il pas moyen de compiler « manuellement » nginx ? Ainsi j’aurais passenger directement intégré à mon nginx principal.
En lisant la doc un peu plus loin, c’est effectivement possible.
Et bah c’est partie, je copie l’ebuild nginx dans mon overlay, et zou on intègre passenger.
Ça compile, très bon signe ; on notera que des fichiers supplémentaires ont été générés durant la compilation pour phusion passenger : l’agent Nginx.

J’ajoute la configuration suivante dans nginx :

http {
	# path obtenu avec passenger-config --root
	passenger_root /usr/local/lib64/ruby/gems/1.8/gems/passenger-3.0.13;
	passenger_max_pool_size 10;
}

server {
	listen 443;
	listen [mon_ipv6]:443 ssl ipv6only=on;
	server_name redmine.mondomaine.com;

	access_log /var/log/nginx/redmine.access_log main;
	error_log /var/log/nginx/redmine.error_log notice;

	root /var/www/redmine/htdocs/public;
	passenger_enabled on;
	passenger_use_global_queue on;
	passenger_user mongrel_redmine;
}

Et là c’est jackpot, tous les problèmes ont été résolus, que ça soit les vieux pics cpu ou les warnings, c’est maintenant de l’histoire ancienne (jusqu’à la prochaine grosse update de redmine…), et j’ai gagné en rapidité dans un environnement multi-utilisateurs vu que j’ai désormais un pool et non plus un seul processus pour gérer les requêtes vers l’application redmine (qui repose sur le framework ruby on rails). Le changement se ressent immédiatement.

Je n’ai pas trop cherché à comprendre pourquoi, mais j’ai obtenu une conf parfaitement fonctionnelle, donc ON NE TOUCHE PLUS.

Pour les intéressés sous Gentoo, j’ai un overlay contenant un ebuild permettant de compiler nginx avec passenger. Il faut juste ajouter ça dans layman pour synchroniser l’overlay : https://raw.github.com/LordVeovis/gentoo/master/repositories.xml.

Je suppose, sans l’avoir testé, que cet ebuild est capable de fonctionner avec une version plus ancienne de passenger, comme celle marquée comme stable dans portage, mais je ne testerais pas, tant que ça ne bug pas.

Voir aussi

Configuration robuste d’un serveur SSL

En visitant le site cacert.org, notamment pour voir où en était leur intégration dans les différents systèmes d’exploitation et navigateurs, je suis tombé sur Qualys SSL Server Test un site permettant de scanner la partie SSL d’un site web.

Je passe le test pour mon blog, on va voir ce que ça donne…
Pour note, mon blog est desservi par nginx, lui-même linké à openssl pour tous se qui se rapport au chiffrage.
C’est long… et surprise, je décroche un F ! Bravo, je ne peux pas faire pire !

Analysons le résultat :

  • tout d’abord, mon certificat est trusted, mais il semble qu’il y ait un problème sur la chaîne de certification. Une petite vérification m’a permis de confirmer qu’effectivement le serveur ne contient que le certificat du site et le certificat racine, en oubliant le certificat intermédiaire (chaîne de 3 certificats). Qu’à cela ne tienne, une petite concaténation m’a permis de corriger ce soucis. Le problème que ça aurait pu engendrer est qu’un navigateur qui n’a jamais visité un site certifié par Start SSL (qui est l’entité émettrice de mon certificat) aurait jugé que le certificat n’est pas valide, puisqu’il n’arrive pas à remonter de mon certificat à un des certificats racines du navigateur, vu qu’il lui manque le fameux certificat intermédiaire que je n’ai pas présenté.
  • second problème, j’ai des algorithmes de chiffrages qui sont considéré insecure. oups ? J’avais pourtant bien restreint les algorithmes à HIGH:-DES pourtant ! Il se trouve après avoir trouvé le dénominateur commun aux algorithmes non secure que le coupable est anonymous DH, ou bien ADH. Je change donc dans nginx la liste des algorithmes autorisés à HIGH:-DES:ADH  et je repasse le test… avec succès 🙂 Dans les sources, j’ai pu trouver un lien expliquant de façon succincte pourquoi cette famille d’algorithmes n’est pas sûre : du fait que le Diffie-Hellman est effectué en clair (anonymous), l’échange de clé reste secret (cela est assuré par le procédé Diffie-Hellman), mais n’empêche pas une attaque du milieu, à savoir un agent positionné entre deux participants et qui intercepterait les échanges des deux participant pour proposer ses propres paramètres Diffie-Hellman ; le procédé Diffie-Hellman a été bien réalisé… mais pas avec la bonne personne !
  • dernier problème, mineur celui-là, l’incapacité à renégocier une session chiffrée. Une petite ligne de configuration à rajouter, plus difficile à trouver cependant. Corrigé, je repasse le test, et cette fois, tout est vert, j’ai un A 🙂

Pour résumer, voici à quoi ressemble la configuration SSL dans le fichier de configuration de nginx :

ssl_ciphers HIGH:-DES:-ADH;
ssl_prefer_server_ciphers on;
ssl_protocols TLSv1 SSLv3;
ssl_session_cache shared:SSL:1m;

Pour comparer, j’ai balancé le test sur un serveur sous Windows Server 2003, et un autre sous Windows Server 2008 R2, tous deux avec les configurations par défaut.
L’un est catastrophique, l’autre est parfait, plus parfait même que mon nginx tuné, notamment par le support du protocol TLS 1.2.
Il est dommage toutefois de constater le faible choix disponible dans la liste des algorithmes proposés sous Windows Server 2003.
Je vais essayer de désactiver SSLv2 sous Windows Server 2003 et voir ce que cela donne…

Bien que les notes fournies par Qualys puissent paraître sévères en rapport avec le monde réel (qui utilise encore ADH parmi les navigateurs modernes ?), reconfigurer proprement la partie ssl d’un serveur ne mange pas de pain (mis à part le cas IIS où la configuration est on ne peut plus restreinte), alors go on !

Voir aussi