Applications Réparties - Partie II: approche client/serveur par objets distribués
Cours Polytech'Nice, SI4, 2011-2012-2013
Sujet de TP4
Objectif/Contenu :
- Encore quelques aspects avancés voire orthogonaux à Java RMI, mais
bien utiles comme la sécurité par exemple:
plus précisément:
- étude
rapide et utilisation dans un cadre Java RMI de JAAS (Java
Authentication and Authorization Service)
- 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
- 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;
}
;
- 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:
- 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.
- 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;
}
- 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 :
- 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)
- 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 ?
- 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" ; }
- 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.
-
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;
}
}
- Dans le code de lancement du serveur, instanciez une factory de chacun des deux nouveaux types.
- 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
- 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
- créer un keystore à la
racine de votre projet Eclipse, qui contiendra juste un certificat, que
nous créeons en répondant aux questions posées par keytool. En fait,
l'important pour la suite sera que ce keystore existe, et que les 2
extrémités en connaitront l'existence et le mot de passe autorisant à en
lister et voir le contenu (retenez donc bien ce mot de passe !)
$ keytool -genkey -keystore test.ks
Tapez le mot de passe du Keystore :
Ressaisissez le nouveau mot de passe :
Quels sont vos prénom et nom ?
[Unknown] : Francoise
Quel est le nom de votre unité organisationnelle ?
[Unknown] : EPU
Quelle est le nom de votre organisation ?
[UNS] : UNS
Quel est le nom de votre ville de résidence ?
[Nice] :
Quel est le nom de votre état ou province ?
[Unknown] : France
Quel est le code de pays à deux lettres pour cette unité ?
[FR] :
Est-ce CN=Francoise, OU=EPU, O=UNS, L=Nice, ST=France, C=FR ?
[non] : oui
Spécifiez le mot de passe de la clé pour <mykey>
(appuyez sur Entrée s'il s'agit du mot de passe du Keystore) :
- Vérifier que tout s'est bien passé
$ keytool -list -keystore test.ks
Tapez le mot de passe du Keystore :
Type Keystore : JKS
Fournisseur Keystore : SUN
Votre Keystore contient 1 entrée(s)
mykey, 21 avr. 2011, PrivateKeyEntry,
Empreinte du certificat (MD5) : CF:C6:46:E1:90:2B:D2:FE:2E:E9:B3:5A:4E:45:5E:93
- Démarrer l'exécution de
l'application RMI côté serveur requiert de passer deux nouvelles
propriétés Java; indiquant l'emplacement du keystore stockant donc un ou
des certificats, et le mot de passe permettant d'accéder à ce keystore
afin que le serveur puisse prendre le certificat qu'il a besoin de
soumettre au client. Les 2 propriétés sont donc:
-Djavax.net.ssl.keyStore=test.ks -Djavax.net.ssl.keyStorePassword= le mot de passe choisi plus haut
- De même pour démarrer
l'exécution de l'application RMI côté client il faut utiliser une
propriété permettant de localiser le keystore qui est supposé de
confiance, et permettra de valider l'authentification du certificat
reçu.
-Djavax.net.ssl.trustStore=test.ks
Remarque: en déploiement réparti, ce fichier test.ks n'est donc pas
accessible à la fois de la part du serveur ET du client. Pas de soucis,
il suffit
d'en faire une copie sur le site client (ce sera réalisé dans
l'opération préalable de déploiement des codes vers chaque machine
cliente, utiles à la fois au Client pour être programmé puis s'exécuter)
et d'indiquer le bon chemin pour
qu'il y accède afin de vérifier le certificat.
- Pour finir, puisque tout fonctionne, posez vous cette ultime question: comment le client sait-il qu'il utilise une socket Factory basée sur le protocole SSL ? En effet, le client n'a subi aucune modification de son code durant tout cet exercice...
Si vous lanciez votre application en utilisant le serveur http servant
des .class, vous vous apercevriez que la sérialisation dans le
rmiregistry provoque le téléchargement de la classe RMISSLClientFactory.
Le stub que récupèrera le client est équipé de l'information nécessaire
pour que le client se mette à utiliser cette factory de sockets RMI (quitte à ce que, lui
aussi, commence par télécharger le .class correspondant à cette factory qu'il n'avait pas forcément
dans son classpath). C'est la force du système RMI qui permet le téléchargement des .class!
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-