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

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.

No Comments

Post a Comment