Authentification programmatique par certificat auprès de Shibboleth

Shibboleth est une solution qui vous permet de fédérer vos utilisateurs et d’implémenter du SSO (Single Sign On) sur vos applications. C’est puissant, c’est beau, c’est fort, et ça marche tout seul quand on utilise un navigateur. Mais quand on cherche à faire des choses un peu hors norme telles que s’authentifier de façon programmatique (par exemple dans un batch ou un client lourd), la documentation est bien avare en conseils.

Et c’est précisément ce à quoi je me suis retrouvé confronté récemment. Dans mon scénario, un programme autonome tournant sur le poste de l’utilisateur devait s’identifier automatiquement et de façon entièrement transparente auprès de Shibboleth via le certificat utilisateur de la session Windows.

J’ai souffert, j’ai transpiré, et pour que plus personne n’ait à subir ceci, je vous donne ici la recette magique.

Le principe

Notre but est en définitive d’accéder à une ressource (URL) sécurisée par Shibboleth, sans navigateur, et sans intervention utilisateur.

Le principe est le suivant :

  • Configurer un client HTTP avec un contexte lui permettant d’établir un handshake SSL avec Shibboleth. Ce contexte devra être porteur du certificat d’identification de la personne, ainsi que des certificats parents associés (chaîne d’autorités de confiance).

  • Utiliser ce client HTTP pour interagir avec Shibboleth et simuler la cinématique effectuée par un navigateur Internet afin d’obtenir un précieux jeton d’authentification et finalement taper sur l’URL désirée.

Le client HTTP

La première chose à faire, c’est de configurer correctement un client HTTP pour l’alimenter avec le certificat utilisateur et tous les certificats parents associés, afin de s’assurer que le handshake HTTPS avec Shibboleth se déroule bien.

Récupérer le certificat utilisateur

Windows expose deux keystores system spéciaux qui nous permettent de retrouver le certificat de notre utilisateur connecté :

  • Windows-MY qui permet de retrouver le certificat personnel
  • Windows-ROOT qui contient les autorités de confiance

En premier lieu, fouillons donc le keystore Windows-MY pour trouver un certificat d’authentification valide. On reconnaît un certificat d’authentification à son usage codifié "1.3.6.1.5.5.7.3.2" (ça ne s’invente pas !).

// Charger le keystore Windows-MY
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstanceKeyManagerFactory.getDefaultAlgorithm());
KeyStore keyStore = KeyStore.getInstance("Windows-MY");
keyStore.load(null, null);
keyManagerFactory.init(keyStore, null);

// Trouvons le certificat utilisateur
String certificateAlias = null;
Enumeration aliases = keyStore.aliases();
while (aliases.hasMoreElements()) {
    String alias = aliases.nextElement();
    Certificate certificate = keyStore.getCertificate(alias);
    if (certificate instanceof X509Certificate) {
        X509Certificate x509Cert = (X509Certificate) certificate;
        List extendedKeyUsage = x509Cert.getExtendedKeyUsage();
        if (extendedKeyUsage != null && extendedKeyUsage.contains("1.3.6.1.5.5.7.3.2")) {
            try {
                x509Cert.checkValidity();
                certificateAlias = alias;
            } catch (CertificateExpiredException | CertificateNotYetValidException e) {
                // Certificate expiré ou pas encore valide
                // ...ignorons le silentieusement et passons au suivant
            }
        }
    }
}

Si tout va bien, on a maintenant déterminé notre certificat d’authentification utilisateur et on a une instance bien initialisée de KeyManagerFactory qui le contient.

Récupérer les certificats parents

Passons à la suite: charger la chaîne de certificats de confiance associée, contenue dans le keystore système Windows-ROOT, pour fabriquer un TrustManager qui exposera notre certificat et sa chaîne de confiance.

KeyStore ts = KeyStore.getInstance("Windows-ROOT");
ts.load(null, null);
TrustManagerFactory tmf = TrustManagerFactory.getInstanceTrustManagerFactory.getDefaultAlgorithm());
tmf.init(ts);
TrustManager[] trustManagers = tmf.getTrustManagers();

KeyManager[] keyManagers = keyManagerFactory.getKeyManagers();
keyManagers[0] = new CustomX509ExtendedKeyManager((X509ExtendedKeyManager) keyManagers[0], certificateAlias);

CustomX509ExtendedKeyManager est une petite classe utilitaire qui nous permet de "forcer" l’utilisation de notre certificat d’authentification.

class CustomX509ExtendedKeyManager extends X509ExtendedKeyManager {
    private X509ExtendedKeyManager delegate;
    private String clientAuth;  // L'alias de notre certificat

    public CustomX509ExtendedKeyManager(X509ExtendedKeyManager delegate, String clientAuth) {
        this.delegate = delegate;
        this.clientAuth = clientAuth;
    }

    // La magie est ici : retourner toujours l'alias de notre certificat
    @Override
    public String chooseClientAlias(String[] strings, Principal[] principals, Socket socket) {
        if (clientAuth != null) {
            return clientAuth;
        } else {
            log.warn("Failed to autodetermine clientAlias, leaving original KeyManager decide");
            return delegate.chooseClientAlias(strings, principals, socket);
        }
    }

    // Méthodes déléguées au X509ExtendedKeyManager initial
    @Override
    public String chooseEngineClientAlias(String[] strings, Principal[] principals, SSLEngine sslEngine) {
        return delegate.chooseEngineClientAlias(strings, principals, sslEngine);
    }

    @Override
    public String chooseEngineServerAlias(String s, Principal[] principals, SSLEngine sslEngine) {
        return delegate.chooseEngineServerAlias(s, principals, sslEngine);
    }

    @Override
    public String[] getClientAliases(String s, Principal[] principals) {
        return delegate.getClientAliases(s, principals);
    }

    @Override
    public String[] getServerAliases(String s, Principal[] principals) {
        return delegate.getServerAliases(s, principals);
    }

    @Override
    public String chooseServerAlias(String s, Principal[] principals, Socket socket) {
        return delegate.chooseServerAlias(s, principals, socket);
    }

    @Override
    public X509Certificate[] getCertificateChain(String alias) {
        return delegate.getCertificateChain(alias);
    }

    @Override
    public PrivateKey getPrivateKey(String alias) {
        return delegate.getPrivateKey(alias);
    }
}

Configurer le client HTTP (enfin !)

On a notre keyManager, on a notre trustManager : tout est prêt pour fabriquer le SSLContext dont notre client HTTP aura besoin pour établir sa connection HTTPS porteuse d’identification !

SSLContext context = SSLContext.getInstance("TLS");
context.init(keyManagers, trustManagers, new SecureRandom());

On y est presque. Plus qu’à instancier le client HTTP avec ce SSLContext. Nous utiliserons ici le client OkHttp.

OkHttpClient okHttpClient = new OkHttpClient.Builder()
        .cookieJar(new JavaNetCookieJar(new CookieManager(null, CookiePolicy.ACCEPT_ALL)))
        .hostnameVerifier((hostname, session) -> true)
        .readTimeout(Duration.ofSeconds(0));
        .sslSocketFactory(context.getSocketFactory(), (X509TrustManager) trustManagers[0])
        .build();

Interaction avec Shibboleth

Maintenant que nous avons un client HTTP bien configuré, on va pouvoir dialoguer avec Shibboleth pour établir le SSO.

La cinématique est la suivante :

Étape 1. Tentative d’accès à la ressource désirée

Utilisons le client HTTP pour tenter d’accéder à l’URL souhaitée.

Request request = new Request.Builder()
        .url(url)
        .get()
        .build();
String responseBody;
try (Response response = okHttpClient.newCall(request).execute()) 
    responseBody = response.body().string();
}

Étape 2. Traitement de la réponse initiale de Shibboleth

Shibboleth nous retourne une page HTML comportant un formulaire. Ce formulaire dispose d’un attribut action qui vas nous aiguiller vers le bon endroit pour la suite du processus.

// Introduisons une méthode utilitaire pour retrouver des données depuis les pages HTML retournées par Shibboleth
private String getDocumentValue(Document document, String query, String attributeKey) {
    Elements elements = document.select(query);
    return elements == null ? null : elements.attr(attributeKey);
}
// Parsons la réponse reçue (avec Jsoup: https://jsoup.org/)
Document document = Jsoup.parse(responseBody);
// ...pour retrouver le formulaire et son "action"
String action = getDocumentValue(document, "form", "action");
if (action == null) {
    throw new IllegalStateException("SSO failure: no action could be determined");
}

Si cette action correspond à l’URI /Shibboleth.sso/SAML2/POST cela veut dire que Shibboleth nous connaît déjà à travers une interaction précédente et il n’est pas nécessaire de faire un handshake SSL complet. On peut sauter à l’étape 4.

Sinon, il est nécessaire de continuer et faire le handshake !

Étape 3. Authentification par certificat (handshake SSL)

Pour ce faire il faut POSTer sur l’url action les données suivantes :

  • AuthMethod = CertificateAuthentication
  • RetrieveCertificate = 1

Si tout s’est bien passé, Shibboleth nous retournera un statut HTTP 307, indiquant une redirection vers une nouvelle URL qui nous permettra de continuer le processus.

request = new Request.Builder()
        .url(action)
        .post(new FormBody.Builder()
                .add("AuthMethod", "CertificateAuthentication")
                .add("RetrieveCertificate", "1")
                .build())
        .build();
String authRedirectLocation;
try (Response response = okHttpClient.newCall(request).execute)) {
    // On a bien un 307 ?
    if (response.code() != 307) {
        throw new IllegalStateException("Authentication request ended with code: " + responseCode);
    }
    // Oui, super ! le header "location" nous indique où aller maintenant.
    authRedirectLocation = response.header("location");
    if (authRedirectLocation == null) {
        throw new IllegalStateException("SSO failure: no authentication redirection could be determined");
    }
}

On poste à nouveau les informations techniques de l’étape précédente, mais sur la nouvelle URL que l’on vient d’obtenir.

request = new Request.Builder()
        .url(authRedirectLocation)
        .post(new FormBody.Builder()
                .add("AuthMethod", "CertificateAuthentication")
                .add("RetrieveCertificate", "1")
                .build())
        .build();
try (Response response = okHttpClient.newCall(request).execute)) {
    // Cette fois, on devrait avoir un code 200 !
    if (response.code() != 200) {
        throw new IllegalStateException("SAML request ended with code: " + responseCode);
    }
    responseBody = response.body().string();
}

La réponse obtenue cette fois est un nouveau formulaire, avec son propre action.

document = Jsoup.parse(responseBody);
action = getDocumentValue(document, "form", "action");
if (action == null) {
    throw new IllegalStateException("SSO failure: no shibboleth endpoint could be determined");
}

Étape 4.

A ce stade de la cinématique, Shibboleth nous a retourné un formulaire contenant des choses bien intéressantes :

  • Le tag HTML input nommé SAMLResponse contient notre jeton d’authentification (sous forme d’un jeton SAML)
  • Le tag HTML input nommé RelayState détient une donnée technique nécessaire pour terminer la cinématique

Il ne reste plus qu’à envoyer ces informations vers l’URL pointée par l’action du formulaire pour finir le processus !

String samlResponse = getDocumentValue(document, "input[name=SAMLResponse]", "value");
if (samlResponse == null) {
    throw new IllegalStateException("SSO failure: no samlResponse could be determined");
}
String relayState = getDocumentValue(document, "input[name=RelayState]", "value");
if (relayState == null) {
    throw new IllegalStateException("SSO failure: no relayState could be determined");
}
request = new Request.Builder()
        .url(action)
        .post(new FormBody.Builder()
                .add("SAMLResponse", samlResponse)
                .add("RelayState", relayState)
                .build())
        .build();
try (Response response = okHttpClient.newCall(request).execute()) { ;
    // Tout s'est bien passé ? On attend un code 200
    if (response.code() != 200) {
        throw new IllegalStateException("Shibboleth auth ended with code: " + responseCode);
    }

    // Gagné ! Voilà la réponse à l'URL souhaitée ! 
    HttpResponse httpResponse = new HttpResponse();
    httpResponse.setCode(responseCode);
    httpResponse.setBody(response.body().string());
    return httpResponse;
}

CQFD

Et voilà !

Avec un peu d’huile de coude, il est bien possible de simuler programmatiquement avec Shibboleth le flux d’authentification "Certificate" qui se déroulerait normalement au sein d’un navigateur de façon totalement programmatique et donc transparente pour l’utilisateur final.

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

%d blogueurs aiment cette page :