Authentification HTTP et Sécurité avec Apache Shiro

Authentifier les utilisateurs d’une application est une étape cruciale pour tout système d’information. Restreindre l’accès à ses ressources selon certains critères l’est également. Côté web, Spring Security tient le haut du pavé en la matière. Pourtant son adhérence à Spring et sa volumétrie — plus de 10 JAR de dépendances — peuvent représenter un frein à son adoption. En outre, son absence d’intégration à Guice ou la récurrence du déploiement d’une application App Engine peuvent le rendre impraticable. Voici une bonne occasion pour se tourner vers Apache Shiro.

L’authentification est le procédé vérifiant la véracité de l’identité annoncée par un utilisateur. Cette identité est usuellement représentée par un couple login:password

L’autorisation restreint les fonctionnalités d’une application aux droits d’un utilisateur authentifié. Ces droits dépendent usuellement des groupes d’affectation de l’utilisateur

Introduction à l’authentification HTTP

JAAS — Java Authentication and Authorization Service — fut un des premiers framework à ouvrir le bal de la sécurité Java. Son modèle objet fut d’ailleurs retenu par les frameworks postérieurs : un Subject — l’utilisateur — est authentifié si ses Principals — ses attributs identifiants — et ses Credentials — la preuve de son identité — correspondent au référentiel d’authentification ; il lui est alors associé plusieurs Roles dont découlent des Permissions (les opérations de l’application sont alors restreintes par permissions). L’authentification HTTP a été retenue pour les environnements web, son support par les implémentations JAX-RS et les navigateurs modernes (à l’aide d’une fenêtre déroulante de saisie) l’ont d’ailleurs popularisée. JAX-RS intègre JAAS (via un SecurityContext) mais son adhérence aux policies Java et sa modularité limitée l’ont relégué au profit de frameworks dédiés.

Le temps dépasse rapidement la sécurité, et devant le peu d’apports ces dernières années à l’authentification HTTP, d’autres protocoles ont vu le jour. On citera OAuth qui a eu les faveurs de nombreux acteurs du net et bénéficie de plusieurs librairies, Scribe et SocialAuth notamment (une implémentation Shiro est en cours). Par souci de simplicité, cet article se focalisera sur les deux authentifications HTTP et leur intégration à Shiro.

L’authentification Basic, la moins sécurisée des deux — elle transmet le password en clair —, s’effectue ainsi :

  1. Le serveur reçoit une requête pour une ressource sécurisée ;
  2. Le serveur retourne un code status 401 et positionne le header WWW-Authenticate: Basic ;
  3. Le client réitère sa requête accompagnée de Authorization: Basic login:password en encodant la chaine login:password en base 64 ;
  4. Le serveur décode l’encodage base 64 et donne accès à la ressource ou renvoie à l’étape 2.

L’authentification Digest, plus sécurisée — elle utilise un encodage md5 prouvant sa connaissance du password sans le transmettre —, s’effectue ainsi :

  1. Le serveur reçoit une requête pour une ressource sécurisée ;
  2. Le serveur retourne un code status 401, positionne le header WWW-Authenticate: Digest, le header realm au nom de l’application serveur, et date puis hash le header nonce afin de délimiter la validité de la proposition d’authentification dans le temps ;
  3. Le client réitère sa requête accompagnée de Authorization: Digest username="..", nonce="..", response=".." ;
  4. Le serveur compare le calcul md5(md5(login:realm:password):nonce:md5(httpMethod:uri)) au header response et donne accès à la ressource ou renvoie à l’étape 2 (le password n’est pas communiqué par le client, le serveur le récupère dans son référentiel à l’aide du login).

Le calcul du digest a été enrichi par la RFC 2617, mais les deux restent supportés.

Le filtre de servlet Shiro

Shiro — anciennement JSecurity — est un framework robuste offrant authentification, autorisation, cryptographie et gestion de sessions. Il s’intègre aisément en environnement web à l’aide d’un filtre de servlet (une dépendance supplémentaire est nécessaire). Voyons comment, via le web.xml suivant, déclarer une ressource sécurisée :

<?xml version="1.0" encoding="utf-8"?>
<web-app>
    <filter>
        <filter-name>Shiro</filter-name>
        <filter-class>org.apache.shiro.web.servlet.IniShiroFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>Shiro</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <servlet>
        <servlet-name>Jersey</servlet-name>
        <servlet-class>com.sun.jersey.spi.container.servlet.ServletContainer</servlet-class>
    </servlet>
    <servlet-mapping>
        <servlet-name>Jersey</servlet-name>
        <url-pattern>/*</url-pattern>
    </servlet-mapping>
</web-app>

La configuration précédente permet de déclarer des ressources à l’aide de Jersey — l’implémentation JAX-RS de référence — et de laisser Shiro intercepter chaque appel. Afin de lui indiquer quelle vérification effectuer pour autoriser/refuser l’accés, un fichier shiro.ini doit être créé à la racine du classpath (ce répertoire peut être configuré dans le fichier web.xml, son contenu peut même être placé dans ce dernier ; plus d’informations). La balise main y définit les classes dédiées à la logique de filtrage. La balise urls y définit avec plus de granularité, les adresses et la logique associée.

[main]
authcBasicRealm = com.xebia.shiro.StaticRealm
matcher = com.xebia.shiro.ReverseCredentialsMatcher
authcBasicRealm.credentialsMatcher = $matcher

[urls]
/** = authcBasic

Le filtre authcBasic fait partie des filtres fournis par défaut. Il est configurable à l’aide de la norme JavaBean dans la balise main (Shiro utilise Apache BeanUtils pour cela — les types primitifs peuvent être fournis directement, les types complexes sont à déclarer avec $). Ainsi configuré, il permet d’authentifier l’intégralité des appels effectués sur le serveur via le couple login:password récupéré des headers http (en Basic). Le token résultat est transmit à un couple Realm/Matcher, le premier accède au référentiel et communique le véritable password au second chargé de vérifier si les deux correspondent. Les Realms classiques — JDBC, LDAP, etc — sont fournis. Les Matchers sont couramment redéfinis lorsque les passwords ne sont pas stockés en clair et qu’une étape de calcul intermédiaire est nécessaire (plus d’informations). Lorsque plusieurs Realms sont utilisés, il est possible de spécifier la logique de résolution de l’authentification à l’aide d’une stratégie (tous, au moins un, etc).

Sécuriser une ressource

Avant de passer au vif du sujet, il est nécessaire d’écrire une ressource et d’effectuer plusieurs tests d’intégration afin de s’assurer du comportement effectif. Lors de l’appel au serveur sur /safe, la ressource ci-dessous vérifie si l’utilisateur dispose du rôle « vip » et retourne une chaine de caractères en fonction. Comme Shiro est configuré à l’aide d’un filtre de servlet, l’utilisateur n’atteint cette méthode que s’il s’est correctement authentifié.

import org.apache.shiro.SecurityUtils;

@Path("/safe")
public class SafeResource {

    @GET
    public Response get() {
        String state;
        if (SecurityUtils.getSubject().hasRole("vip")) {
            state = "authorized";
        } else {
            state = "authenticated";
        }
        return Response.ok(state).build();
    }
}

La classe SafeResource est filtrée par Shiro. Aucune vérification de l’authentification n’est effectuée ici. L’utilisateur est déjà authentifié lorsqu’il atteint une méthode de cette ressource. Attention, contrairement à ce qui présenté ici, la documentation Shiro souligne l’importance d’expliciter les rôles, c’est à dire d’autoriser les fonctionnalités d’une application par permission et non par rôle. Ainsi, les droits d’un rôle ne lui sont pas directement attaché, ce qui facilite grandement leur maintenance : s’il s’avère nécessaire d’ajouter, supprimer ou modifier un rôle, cela peut être effectué sans modifier le code de l’application ; modifier l’association rôle – permissions dans le référentiel d’authentification suffit.

Les dépendances nécessaires à l’exécution du code suivant étant nombreuses, voici un pom.xml. Attention, ces versions évoluent, il est important de les tenir à jour.

<dependencies>
    <dependency>
    	<groupId>org.apache.shiro</groupId>
    	<artifactId>shiro-core</artifactId>
    	<version>1.1.0</version>
    </dependency>
    <dependency>
    	<groupId>org.apache.shiro</groupId>
    	<artifactId>shiro-web</artifactId>
    	<version>1.1.0</version>
    </dependency>
    <dependency>
    	<groupId>com.sun.jersey</groupId>
    	<artifactId>jersey-server</artifactId>
    	<version>1.6</version>
    </dependency>
    <dependency>
    	<groupId>com.sun.jersey</groupId>
    	<artifactId>jersey-client</artifactId>
    	<version>1.6</version>
    </dependency>
    <dependency>
    	<groupId>org.mortbay.jetty</groupId>
    	<artifactId>jetty-embedded</artifactId>
    	<version>6.1.26</version>
    </dependency>
    <dependency>
    	<groupId>commons-logging</groupId>
    	<artifactId>commons-logging</artifactId>
    	<version>1.1.1</version>
    </dependency>
    <dependency>
    	<groupId>com.google.guava</groupId>
    	<artifactId>guava</artifactId>
    	<version>r08</version>
    </dependency>
</dependencies>

Tester l’intégration

Nous allons nous assurer du fonctionnement du filtrage à l’aide de tests unitaires basés sur un serveur Jetty-Embedded. Nous lui indiquons le répertoire WEB-INF contenant notre web.xml afin qu’il déploie les ressources et le filtre Shiro qui y sont configurés.

public class SafeResourceTest {
    private static final String WEB_INF_DIRECTORY = "war";

    Server server;

    @Before
    public void before() throws Exception {
        server = new Server(8080);
        server.addHandler(new WebAppContext(WEB_INF_DIRECTORY, "/"));
        server.start();
    }

    @After
    public void after() throws Exception {
        server.stop();
    }
}

Trois tests d’intégration peuvent être envisagés :

  1. Le premier vérifie que sans information de connexion dans le header http, une erreur 401 est remontée ;
  2. Le second vérifie que, lorsque l’utilisateur est correct, l’authentification fonctionne ;
  3. Le dernier vérifie que, lorsque les rôles de l’utilisateur le permettent, l’autorisation fonctionne.
import static com.sun.jersey.api.client.ClientResponse.Status.OK;
import static com.sun.jersey.api.client.ClientResponse.Status.UNAUTHORIZED;

public class SafeResourceTest {
[...]
    @Test
    public void authentication_should_failed_without_credential() {
        assertEquals(UNAUTHORIZED.getStatusCode(), resource().getStatus());
    }

    @Test
    public void authentication_should_succeed_with_credential() {
        ClientResponse response = resource("pierre", "trev");
        assertEquals(OK.getStatusCode(), response.getStatus());
        assertEquals("authenticated", response.getEntity(String.class));
    }

    @Test
    public void authozisation_should_succeed_with_role() {
        ClientResponse response = resource("paul", "uelb");
        assertEquals(OK.getStatusCode(), response.getStatus());
        assertEquals("authorized", response.getEntity(String.class));
    }

    private static final String RESOURCE_URL = "http://localhost:8080/safe";

    private ClientResponse resource() {
        return Client.create().resource(RESOURCE_URL).get(ClientResponse.class);
    }

    private ClientResponse resource(String user, String pass) {
        WebResource resource = Client.create().resource(RESOURCE_URL);
        resource.addFilter(new HTTPBasicAuthFilter(user, pass));
        return resource.get(ClientResponse.class);
    }
}

Realm et Matcher pour l’authentification

Maintenant que le jeu de tests est en place, concentrons-nous sur la logique d’authentification et d’autorisation. Le Realm que nous utilisons, StaticRealm, repose sur des données définies, à des fins d’illustration, dans la classe statique suivante :

import com.google.common.collect.HashMultimap;
import org.apache.shiro.crypto.hash.Sha256Hash;

public class Safe {
    static Map<String, String> passwords = new HashMap<String, String>();
    static HashMultimap<String, String> roles = HashMultimap.create();

    static{
        passwords.put("pierre", encrypt("vert"));
        passwords.put("paul", encrypt"bleu");
        roles.put("paul", "vip");
    }

    private String encrypt(String password) {
        return new Sha256Hash(password).toString();
    }

    public static String getPassword(String username) {
        return passwords.get(username);
    }

    public static Set<String> getRoles(String username) {
        return roles.get(username);
    }
}

Pour mémoire, un couple Realm/Matcher est chargé de l’authentification (voir le fichier de configuration plus haut) ; le premier accède au référentiel et communique le password au second chargé de vérifier si les deux correspondent. Lorsque l’authentification échoue, une AuthentificationException est levée.

public class StaticRealm extends AuthorizingRealm {

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) 
            throws AuthenticationException {
        UsernamePasswordToken upToken = (UsernamePasswordToken) token;

        String username = upToken.getUsername();
        checkNotNull(username, "Null usernames are not allowed by this realm.");

        String password = Safe.getPassword(username);
        checkNotNull(password, "No account found for user [" + username + "]");

        return new SimpleAuthenticationInfo(username, password.toCharArray(), getName());
    }

    private void checkNotNull(Object reference, String message) {
        if (reference == null) {
            throw new AuthenticationException(message);
        }
    }
}

En se basant sur notre référentiel, la classe Safe, ce Realm récupère l’utilisateur dont le login est fourni en entrée et transmet son password au Matcher (Shiro s’en charge pour lui — on comprend que cette séparation est faite dans un soucis de modularité). Ce Matcher a la particularité de vérifier qu’on lui transmet l’inverse du mot de passe stocké :

public class ReverseCredentialsMatcher extends SimpleCredentialsMatcher {

    @Override
    public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
        String tokenCredentials = charArrayToString(token.getCredentials());
        String reverseToken = StringUtils.reverse(tokenCredentials);
        String encryptedToken = new Sha256Hash(reverseToken).toString();

        String accountCredentials = charArrayToString(info.getCredentials());
        return accountCredentials.equals(encryptedToken);
    }

    private String charArrayToString(Object credentials) {
        return new String((char[]) credentials);
    }
}

Ainsi, les tests d’intégration sont au vert. Dans les cas simples, comme le notre, il est préférable d’utiliser la classe IniRealm dont la déclaration est automatique à l’ajout des balises users et roles dans le fichier de configuration.

[users]
# user = password, roles...
pierre = 6a103aecbd239f79ce183fc33649b71783a61711afb291524c97af442deb33a5
paul = 50ab0299c6f88a48cb8e5a3f9122f7cc8ce015641a4f18cdeece9a75361a6ff1, vip

[roles]
# role = permissions...

[urls]
/index.html = anon
/profit = authcBasic, roles[admin]
/stats = authcBasic, perms["stats:read"]
/** = authcBasic

La balise urls peut également être finement configurée. Ici, la page d’accueil est rendue accessible à tous (anon pour anonyme) et les ressources profit et stats sont limitées respectivement par rôle et par permission. Attention, l’ordre de déclaration de ces urls compte. La dernière ressource, **, n’a d’effet que sur les ressources non concernées par les déclarations précédentes.

Un riche modèle de permissions

Comme cela a été souligné plus haut, la documentation recommande d’expliciter les rôles, c’est à dire d’autoriser les fonctionnalités d’une application par permission et non par rôle. Le modèle de permissions de Shiro est son meilleur atout : il est basé sur une simple chaine de caractères organisée ainsi : domain:action:instance (par exemple : imprimante:état:lp7200) et qu’il est possible de gérer à l’aide de wildcards (allouer des droits sur un ensemble d’objets du domaine se fait ainsi : imprimante:* ou *:état). Ce modèle est longuement discuté sur la page de la documentation consacrée au sujet.

Dans notre cas il peut être judicieux d’utiliser les ressources comme premier niveau d’autorisation et les verbes HTTP comme second. C’est ce que propose un filtre, qu’il est possible d’adjoindre à celui chargé de l’authentification de la manière suivante :

[urls]
/safe/** = authcBasic, rest[safe]

Le premier se charge de l’authentification grace aux headers, le second permet l’autorisation des ressources sous la forme resource:httpmethod (il est nécessaire de le configurer avec le nom de la ressource). Pour intégrer cette autorisation au code existant, il suffit d’implémenter la méthode idoine dans notre Realm :

public class StaticRealm extends AuthorizingRealm {
[...]
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        checkNotNull(principals, "PrincipalCollection method argument cannot be null.");

        String username = (String) principals.getPrimaryPrincipal();
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(Safe.getRoles(username));
        info.setStringPermissions(Safe.getPermissions(username));
        return info;
    }
}

Le référentiel doit ajouter les permissions adéquates :

public class Safe {
[...]
    static{
        passwords.put("paul", encrypt("bleu"));
        permissions.put("paul", "safe:*");
    }
}

Par simplicité, l’autorisation est laissée à la charge du Realm de l’authentification. Cela est possible car les filtres additionnent les rôles et permissions des utilisateurs authentifiés par leurs Realms. Le filtre authcBasic communique au filtre rest les utilisateurs authentifiés avec succès. Ce dernier, n’ayant pas de Realm, se base donc uniquement sur son prédécesseur pour autoriser ou non l’accès à une ressource. L’ordre de ces deux filtres est donc primordial. Pour obtenir authentification puis autorisation, un appel doit être valide au regard de tous les filtres.

Dernière précision, il n’est pas obligatoire « d’attacher » les Realms aux filtres comme nous l’avons fait avec authcBasic. Il est tout à fait possible de les déclarer indépendamment de la manière suivante :

[main]
staticRealm = com.xebia.shiro.StaticRealm
anotherRealm = com.xebia.shiro.AnotherRealm
matcher = com.xebia.shiro.ReverseCredentialsMatcher
staticRealm.credentialsMatcher = $matcher

securityManager.realms = $staticRealm, $anotherRealm
strategy = org.apache.shiro.authc.pam.FirstSuccessfulStrategy
securityManager.authenticator.authenticationStrategy = $strategy

[urls]
/safe/** = authcBasic, rest[safe]

La stratégie indiquée ci-dessus retourne les rôles et permissions d’un utilisateur dès qu’il est identifié par un Realm. Seuls ses droits dans ce dernier sont pris en compte, il ne sont pas additionnés avec ceux des suivants comme c’est le cas par défaut (AtLeastOneSuccessfulStrategy, la stratégie par défaut, additionne les droits si plusieurs Realms sont couronnés de succès).

Autoriser par annotation

L’absence d’intégration des annotations Shiro AspectJ @RequiresRoles et @RequiresPermissions — pourtant bien documentées — en environnement web pourraient encourager à utiliser, dans notre cas de figure, la sécurité intégrée à Jersey. Comme cela a été indiqué plus haut, celle-ci repose sur Jaas ; elle est configurée à l’aide de balises security-constraint dans le web.xml où les rôles sont détaillés.

La classe RolesAllowedResourceFilterFactory chargée de l’authentification Jersey peut, bien sur, être modifiée afin d’utiliser l’annotation @RolesAllowed au lieu de SecurityUtils.getSubject().hasRole(..). Cela a le net avantage de simplifier grandement l’autorisation, qui, sans annotation, devient vite un enchaînement de conditionnelles peu élégant.

Malheureusement, cette approche limite le filtrage aux rôles ; aucune annotation n’est prévue pour les permissions. Et comme il vaut mieux expliciter les rôles, nous ne retiendrons pas ce compromis. Ceci dit, et comme l’a montré le dernier paragraphe, il est tout à fait possible de configurer l’autorisation des ressources sans avoir recours à du code. Ceci permet de diminuer quelque peu le désagrément engendré par l’absence d’intégration des annotations Shiro aux environnements web.

Shiro, une alternative de choix

Shiro n’est pas exempt de défauts. La première de ses lacunes — qu’il est étonnant de constater — est son absence de gestion de l’authentification Digest (délicat à réaliser soi-même). Côté documentation, certaines classes ne bénéficient d’aucune JavaDoc contraignant à l’analyse des sources ; de plus, l’absence de présentation avancée de la richesse de la configuration masque une partie des possibilités du framework (parcourir le graphe d’héritage de IniShiroFilter à la recherche du SecurityManager pour s’en convaincre). Côté technique, l’impossibilité d’avoir deux systèmes de sécurité parallèles peut gêner (les Realms peuvent être chaînés au sein du SecurityManager, mais en une seule chaine), enfin, l’utilisation abondante de l’héritage ne facilite pas l’utilisation.

Malgré ces défauts de jeunesse, Shiro n’en demeure pas moins un framework robuste, facilement intégrable à tous type d’environnement et aisément adaptable à un contexte, même complexe. Sa légèreté rend son impact quasiment négligeable tout en offrant une vaste gamme d’outils ayant trait à la sécurité (les outils de cryptographie non présentés ici en simplifient grandement l’utilisation). Son architecture pragmatique ainsi que son intégration à Spring en font une alternative sérieuse à Spring Security qui mérite d’être attentivement envisagée.

Billets sur le même thème :

Laisser un commentaire