Applications Réparties - Partie II: approche client/serveur par objets distribués

Cours Polytech'Nice, SI4, 2011-2012-2013

Sujet de TP4

Objectif/Contenu :

  1. étude rapide et utilisation dans un cadre Java RMI de JAAS (Java Authentication and Authorization Service) 
  2. utilisation de sockets Sécurisés pour RMI: RMI over SSL


1. Module de Login

JAAS permet d'authentifier des utilisateurs. Notre but est qu'un client RMI puisse faire passer deux chaines l'identifiant (un nom et un mot de passe), grâce auxquelles l'objet serveur pourra authentifier cet utilisateur. Ce n'est que si l'authentification réussit que le serveur renverra au client une référence distante vers un autre objet remote. Seul le stub du premier objet remote (le serveur) sera publié dans le registry RMI qui n'est absolument pas sécurisé. Un client récupérant ce stub aura alors à invoquer une méthode (appelons-la logon): cette méthode prendra ces deux chaines en paramètre (donc, un client malicieux qui aurait récupéré un stub dans le registry devra deviner quelles sont les bonnes valeurs pour ces 2 chaines). Si correctement authentifié, le client récupèrera une remote reference pour pouvoir poursuivre dans son utilisation de l'application qui tourne sur la machine distante. Si l'authentification échoue, alors, le client recevra au contraire une exception particulière.

Plusieurs étapes sont nécessaires

  1. On doit associer à une application Java un LoginContext.  Dans un fichier de configuration (appelons-le login.conf), on décrit une entrée particulière consistant à associer un nom de loginContext (que l'on choisit arbitrairement, par exemple "MonServeur") auquel on associe un nom de classe Java qui correspond à l'implémentation d'un LoginModule. L'application qui devra vérifier le login devra être démarrée en utilisant -Djava.security.auth.login.config=login.conf.
    Le fichier de configuration contient par exemple ceci :

    MonServeur { // le nom du module pour effectuer les tests d'authentification est indiqué, et est Required
    security.module.SimpleMonServeurLoginModule required debug=true; }
    ;

  2. Pour initialiser l'utilisation de ce module de Login, il suffit de rajouter avant le code que l'on veut exécuter seulement si l'appelant est authentifié, une ligne dans ce style:
    LoginContext lc = new LoginContext("MonServeur", new security.module.RemoteCallbackHandler(username, passwd));
    Comme on le voit sur cet exemple, initialiser le login nécessite de passer une instance d'une classe qui implante un CallbackHandler (classe nommée security.module.RemoteCallbackHandler dans l'exemple ci-dessus). Un CallbackHandler doit implanter une méthode handle dans laquelle on doit se préoccuper de la manière de faire passer au module de Login deux types de choses: un Name ou un Password. La manière d'obtenir ces infos peut être très variée (saisie sur la console, saisie graphique, paramètres du constructeur, carte à puce, etc), et le seul but de ce handler est ensuite de faire suivre ce qu'il a pu récupérer vers le  module de login qui en a besoin pour réaliser l'authentification de l'utilisateur. Si le module nécessite plusieurs mots de passe (par exemple, pour débloquer un fichier, puis, débloquer une clé stockée dans ce fichier), alors, il utilisera le handler plusieurs fois pour demander un tel mot de passe à chaque fois. Dans notre exemple simple, nous avons supposé que notre RemoteCallbackHandler est créé en passant 2 chaines de caractères. Ce sont celles que le client aura envoyé via un appel RMI. La manière dont le client les aura lui-même saisies peut être totalement libre (ces deux chaines peuvent par exemple être passées comme arguments de la méthode main du programme client).
           

Récupérez un exemple complet qui met en oeuvre ce qui vient d'être expliqué. Analysez finement le code, sans oublier de considérer les règles de sécurité (grant) nécessaires à utiliser pour JAAS. Ce qui signifie que vous devez lancer le côté serveur avec un fichier de policy Java. Pour faire simple, faites en sorte que le fichier de policy utilisé coté serveur accorde AllPermission, mais plus précisément pour que notre application RMI fonctionne, il n'y a en fait besoin que des permissions liées à l'utilisation des Sockets. Et le droit d'authentifier les utilisateurs via un Login Context et exécuter des opérations en tant que Privileged, : pour celà, il nous faut avoir des grants issues de javax.security.auth.AuthPermission. Nous y reviendrons plus bas dans le sujet
Testez l'exemple, même en local. Comme le serveur ne crée pas lui même le rmiregistry, pensez à démarrer celui ci à l'avance (ou modifiez la ligne contenant getRegistry en createRegistry). Vous pouvez essayer un client qui propose le bon username (testUser) pour args[0] et le bon mot de passe (testPassword) pour args[1], mais, vous pouvez aussi tester d'autres user name et mots de passe qui ne permettront pas d'authentifier le client avec succès.

2. N'autoriser certaines actions qu'en fonction de l'identité des utilisateurs

Le but de cet exercice est d'utiliser la notion de Principal afin de pouvoir autoriser à certains utilisateurs certaines opérations précises.

Notre module de Login, en exécutant automatiquement sa méthode commit() après avoir exécuté sa méthode login(), crée un tel Principal et l'associe en tant que Subject au code à exécuter via Subject.doAsPrivileged(...)). Ce Principal (instance de la classe SamplePrincipal fournie dans le .zip), est identifié par son nom.

Jusque à présent, l'utilisateur testUser devait être authentifié, et ensuite, il pouvait à loisir invoquer les méthodes offertes par l'interface Remote Service. Le but de l'exercice est d'autoriser une connexion de la part d'un autre utilisateur (adminUser). Puis de ne donner les permissions nécessaires dans la méthode setVal, qu'à l'utilisateur adminUser. Pour que l'exercice soit intéressant, nous allons supposer que setVal effectue aussi l'écriture dans un fichier local. On voit donc que l'utilisateur testUser n'aura pas le droit d'exécuter setVal intégralement.

Pour parvenir à réaliser ceci, procédons par étapes:

  1. Modifiez le module de Login, afin qu'il puisse considérer que username est valide si il est soit égal à adminUser, soit égal à testUser. Pour simplifier au maximum, nous supposerons que le mot de passe reste identique pour les 2 types d'utilisateurs. Vérifier que tout continue à fonctionner et que adminUser peut être authentifié correctement.
  2. RMI n'étant pas prévu initialement pour intégrer JAAS, nous devons modifier l'implémentation côté serveur. Toute méthode offerte par une interface Remote dont nous voulons autoriser l'exécution seulement à certains Principals, devra emballer son code métier dans un bloc doAsPrivileged. doAsPrivileged requiert un paramètre qui est le Subject (représentant le Principal) qui exécute la méthode, et une instance d'une classe implantant une méthode run(). C'est cette méthode run() que JAAS va exécuter pour le compte du Subject correspondant à celui qui a invoqué la méthode. Voici le principe illustré par l'exemple, cad, voici la nouvelle implantation de notre méthode setVal de l'interface Service (ne vous étonnez pas si elle ne compile pas pour le moment!) :

        public  synchronized int  setVal(final int v, final Facturation cname) throws RemoteException {

            try{
                System.out.println("Nous dit qui est le sujet : " + subject + " ");
                val=(Integer)Subject.doAsPrivileged(subject,
                        new PrivilegedExceptionAction<Object>() {
                    public void ecrireFichier(){
                        try {
                            FileOutputStream fo=new FileOutputStream(new File("essaiResultat"));
                            fo.write(new byte[]{'c','o','u','c','o','u'});
                            // si on n a pas de permission de write fichier, ceci ne fonctionne pas
                            // dès lors que nous avons un securityManager activé,  sauf si on grant explicitement la permission File.io.Permission "essaiResultat" , "write";
                        } catch (IOException e1) {
                            e1.printStackTrace();
                        }
                    }
                    public Object run() throws Exception {
                        Thread.sleep(500);
                        Facture f=new Facture(v,cname.getName());
                        cname.facturer(f);
                        System.out.println("Nouvelle " + f);
                        this.ecrireFichier();
                        return val * v;
                    }
                }, null);
            } catch (PrivilegedActionException pae){
                if (pae.getException() instanceof RemoteException)
                    throw (RemoteException)pae.getException();
            }
            return val;
        }
     

  3. JavaRMI et JAAS n'ont pas été conçus pour être bien intégrés. Ceci implique que chaque fois qu'un client authentifié veut ensuite exécuter la méthode setVal, alors il doit d'une manière ou d'une autre présenter l'objet qui l'identifie (le Subject qui lui correspond et qui a été créé à la suite du login() réussi). Il faut donc mémoriser le sujet correspondant au LoginContext, sujet qui peut être aisément récupéré avec lc.getSubject() comme illustré ci dessous:

        public Service logon(String username, String passwd) throws RemoteException, LoginException{
            // Verifier si l'utilisateur a bien donné un login et passwd egaux à testUser et testPasswd
            // Si non, renvoyer une instance de LoginException
           
            LoginContext lc = new LoginContext("MonServeur", new security.module.RemoteCallbackHandler(username, passwd));
            try{
                lc.login();
                System.out.println("Authentifié le sujet "+ lc.getSubject());
            }
            catch (LoginException e){
                System.out.println("Recu "+ username + " et " + passwd + " mais, après vérif, ils sont incorrects");
                throw e;
            }
            return simpl; // simpl est la réf RMI vers le service "métier"
    }


    Mais il faudra aussi faire en sorte que le proxy (simpl) que nous renvoyons au client permette ensuite de repérer quel est le client qui s'en sert pour appeler des méthodes à distance. Un client une fois authentifié est concrétisé par un Subject, objet créé si l'authentification a réussi au moment de l'appel à logon. Nous avons deux options :
    1. soit nous renvoyons au client le proxy simpl + le Subject créé après l'authentification réussie de ce client; ce client devra aussi passer en paramètre ce Subject pour tout appel de méthode qu'il voudrait faire sur l'objet RMI dont il a un proxy (simpl)
    2. soit nous renvoyons au client un proxy simpl correspondant à un objet RMI créé pour chaque client et contenant un attribut mémorisant le Subject.
    Discuter brièvement des avantages et des inconvénients de ces 2 possibilités.
    Dans la suite, nous allons mettre en oeuvre la seconde option.
    Modifier en conséquence notre code ci-dessus (méthode logon, et toutes les autres classes nécessaires) afin que l'objet dont nous renvoyons à chaque client un proxy (simpl), soit propre à chaque client. Nous allons donc créer une instance de la classe ServiceImpl par client, et il faudra que cette classe soit équipée d'un attribut Subject initialisé dans le constructeur. Faites les modifications demandées (la méthode setVal recopiée ci dessus compilera donc enfin puisque l'attribut subject existera!). Pour voir si vous avez bien tout suivi, répondez aux questions suivantes 1) Où donc cet attribut subject sera ensuite utile ? 2) La méthode setVal a-t-elle besoin de rester synchronized ?

  4. Il ne nous reste plus qu'à ajouter à notre fichier de policy utilisé coté serveur qui contient déjà ceci:
    grant {
       permission java.net.SocketPermission "localhost:1024-",
               "connect, accept, resolve";
       permission javax.security.auth.AuthPermission "createLoginContext.MonServeur";
       permission javax.security.auth.AuthPermission "modifyPrincipals";
       permission javax.security.auth.AuthPermission "doAsPrivileged";
    };

    une autre règle grant pour nos utilisateurs, devant doit donc respecter la syntaxe ci-dessous:

    grant  Principal  nom_complet_class_implantant_Principal  "nom utilisateur granté" {   .... // ici toutes les permissions que l'utilisateur autorisé("nom utilisateur granté" == nom de login passé au LoginContext) a besoin pour réaliser tout son code"    ;   }

  5. Une fois votre fichier de policy mis au point, tester avec un Client se nommant adminUser; si vous lui avez donné toutes les grants nécessaires pour s'exécuter, par exemple, celle d'écrire dans un fichier précis, cela devrait fonctionner comme il faut. Tester alors avec un client se nommant testUser, auquel vous n'avez pas donné les grants nécessaires. Alors, ce client devrait recevoir le message d'erreur suivant :

    java.security.AccessControlException: access denied (java.io.FilePermission essaiResultat write)
        at java.security.AccessControlContext.checkPermission(Unknown Source)


    Remarque: JAAS permet de créer ses propres permissions (propres à son application), pour ensuite pouvoir granter à certains Principal l'autorisation d'exécuter ou non les méthodes (repérées par un nom inventé dans l'implémentation de sa classe de Permission). Ceci sort du cadre de ce TP.

Le mini projet: application répartie mini twitter

Vous ferez en sorte, d'appliquer ces exercices pour votre projet. Typiquement, vos clients voulant tweeter devront être authentifiés. Pour commencer, faites qu'il n'y ait besoin que d'un seul login et mot de passe connu de tous les clients potentiels; dans une seconde étape, vous lèverez cette hypothèse en maintenant derrière votre LoginModule, une mini base de données sous forme de Hashmap qui permette de répertorier le nom de login de chaque client connu, et le mot de passe qui lui correspond; ainsi qu'un ensemble de méthodes pour permettre à chaque utilisateur de modifier ce mot de passe, et se désinscrire de notre système tweeter. Enfin, le Principal associé à chaque client pourrait soit lui être renvoyé pour qu'il l'utilise dans les futures invocations de méthodes sur le système twitter (privé), ou, faire en sorte que l'on envoie à chaque client un proxy vers un service privé propre pour chaque client. Bien sûr, avec cette seconde solution, ces différents objets offrant le service privé à chacun de ces clients, utilisent derrière (et de manière concurrente...) des structures communes où sont regroupés tous les tweets de tous les clients. Pour le projet il est autorisé que vous n'utilisiez pas l'API de JAAS, mais, que vous reproduisiez simplement son fonctionnement, en codant directement dans le code métier de la méthode de connexion offerte par le serveur public, la vérification de l'utilisateur et son mot de passe.

Bien sûr, si le nom d'utilisateur et le mot de passe passent en clair sur le socket ... un utilisateur malicieux peut, en sniffant le réseau, les récupérer aisément. Nous allons donc voir comment faire tourner notre application RMI sur des sockets sécurisés (basés sur la technologie SSL).


3. RMI sur SSL

Rien de plus simple, en se souvenant qu'une socket TCP (et donc SSL sur TCP) a deux comportements associés (celui côté serveur, celui côté client), et qu'en Java, le design pattern Factory nous permet d'avoir des usines à objets. Alors, lorsque on instancie une classe étendant UnicastRemoteObject, il suffit de lui passer en paramètre du constructeur une instance de deux  Factory permettant de créer les 2 extrémités d'une socket sécurisée.

 

  1. Créez vos 2 Factory. La première implante RMIServerSocketFactory (et Serializable), et la seconde implante RMIClientSocketFactory (et Serializable)


      public class RMISSLServerSocketFactory implements RMIServerSocketFactory, Serializable{
        public ServerSocket createServerSocket (int port) throws IOException{
            SslRMIServerSocketFactory factory= new SslRMIServerSocketFactory();
            ServerSocket socket = factory.createServerSocket(port);
            return socket;
        }
    }

        public class RMISSLClientSocketFactory implements RMIClientSocketFactory, Serializable{

    public Socket createSocket(String host, int port) throws IOException {
    SslRMIClientSocketFactory factory = new SslRMIClientSocketFactory();
    Socket socket = factory.createSocket(host,port);
    return socket;
    }
    }

  2. Dans le code de lancement du serveur, instanciez une factory de chacun des deux nouveaux types.
  3. Passez ces objets Factory en paramètre des constructeurs des classes qui étendent UnicastRemoteObject qui remplacent donc les constructeurs par défaut. Réfléchissez avant de coder : quels sont les objets distants qui doivent utiliser une connexion réseau sur SSL. C'est seulement eux dont l'instanciation (le constructeur) devra faire en sorte d'indiquer ces usines à sockets

  4. Les 2 extrémités d'une socket sécurisée doivent s'authentifier mutuellement afin de pouvoir appliquer ce protocole SSL, cad ouvrir une session SSL; pour l'ouverture d'une telle session, le protocole oblige l'extrémité serveur à fournir un certificat au client qui ensuite, vérifiera sa validité. La solution la plus simple pour cet exercice est d'utiliser un Keystore via un outil fourni en standard par Java, l'outil keytool

       Correction (contenant des screenshots des configurations des JVM client et serveur, sous Eclipse. Notez: pas besoin de lancer le rmiregistry, car c est fait dans le code du serveur).
    Une solution pour sécuriser également l'accès au rmiregistry: le Client, et le Serveur



    Page maintenue par Francoise Baude @2011-