Transférer une authentification jaas de JBOSS AS 7 vers des services tiers

Avec la complexification des applications en entreprise, il arrive de plus en plus souvent que vos applications web soient juste des interfaces vers des services tiers. Pour accéder à ces services, votre application doit pouvoir être identifiée par ce service et les « credentials » de votre utilisateur transféré à cette application. Il existe des solutions d’entreprise destinées à cela. Vous pouvez par exemple faire de vos applications tierces des EJB remote appartenant au même domaine de sécurité dans JBOSS. Et acheter un corde si c’est votre première expérience dans ce domaine. :) Vous pouvez aussi mettre en place un serveur Kerberos et utiliser des tickets kerberos. Et acheter un 9mm si vous n’avez aucune expérience là dedans. Vous pouvez aussi laisser le service tiers faire une « totale confiance » en votre interface et ne faire aucune authentification. Là, c’est votre responsable sécurité qui appuiera pour vous sur la détente du 9mm!

Je vous propose ici une solution simple qui transfère le user/pass de l’utilisateur courant vers les autres applications. La technique est connue et vieille comme le web, je vous explique simplement comment la mettre en Å“uvre dans le cadre de JBOSS AS 7.

Situation

Soit une application web tournant sous JBOSS 7 et N service tiers, accessibles en http, devant reconnaître l’utilisateur courant. Nous avons comme projet, lorsque l’utilisateur s’identifie sur l’application web, de nous connecter à chacun de ces services afin d’y récupérer des cookies d’authentification que nous pourrons utiliser par la suite. L’application web est supposée déjà configurée pour utiliser l’authentification du conteneur (security-domain dans le web.xml).

Fonctionnement de jboss

login module

JBOSS 7 stocke la configuration dans un fichier appelé standalone.xml, où vous trouverez une section contenant ceci:

                        <login-module code="org.jboss.security.auth.spi.LdapLoginModule" flag="required">
                            <module-option name="java.naming.factory.initial" value="com.sun.jndi.ldap.LdapCtxFactory"/>
                            <module-option name="java.naming.security.authentication" value="simple"/>
                            <module-option name="java.naming.provider.url" value="ldaps://myldap:636"/>
                            <module-option name="java.naming.security.protocol" value="ssl"/>
                            <module-option name="java.naming.security.authentication" value="simple"/>
                            <module-option name="principalDNPrefix" value="uid="/>
                            <module-option name="principalDNSuffix" value=",ou=Persons,ou=People,dc=company,dc=com"/>
                            <module-option name="rolesCtxDN" value="ou=Group,dc=company,dc=com"/>
                            <module-option name="uidAttributeID" value="memberUid"/>
                            <module-option name="roleAttributeID" value="cn"/>
                        </login-module>

Il s’agit d’un module JAAS, implémentant une interface standard: LoginModule. Pour intervenir dans l’authentification, il suffira de créer notre propre module, d’y récupérer le mot de passe, et de faire les demandes de ticket. JBOSS étant bien conçu, il suffit que notre classe de module soit présente dans WEB-INF/classes ou WEB-INF/lib pour être utilisable. On peut aussi en faire un module séparé, mais ce n’est pas le cadre de ce billet.

Le Sujet

JAAS (utilisé dans JBOSS) utilise un notion de Subject. Ce subjet représente « l’utilisateur ». Il s’agit d’un ensemble d’identités (Principal), de crédits publics et de crédits privés. Nous allons stocker les tickets obtenus depuis les applications tierces dans ce sujet via un appel à subject.getPublicCredentials().add(...). Nous verrons plus tard qu’il est possible de récupérer par la suite le sujet et donc les credentials depuis n’importe où dans l’application.

Le reste

C’est bien beau de faire un LoginModule allant récupérer des ticket supplémentaire, mais il ne faut pas oublier le rôle de base du LoginModule: authentifier l’utilisateur. Pour ce faire, nous allons déléguer à un autre Module (par exemple LDAP). Cette délégation se fera par une simple ligne de configuration:
<module-option name="otherLoginModule" value="org.jboss.security.auth.spi.LdapLoginModule"/>
et un petit peu de code:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class TicketLoginModule implements LoginModule {
  private static final String KEY_OTHER_LOGIN_MODULE = "otherLoginModule";
  public void initialize(Subject subject, CallbackHandler callbackHandler,
      Map<String, ?> sharedState, Map<String, ?> options) {
    String subModuleClass = (String)options.get(KEY_OTHER_LOGIN_MODULE);
    Class<? extends LoginModule> subClass;
    try {
      if (subModuleClass!=null && (subClass=((Class<? extends LoginModule>) Class.forName(subModuleClass)))!=null){
        subModule = subClass.newInstance();
        subModule.initialize(subject, callbackHandler, sharedState, options);
      } else
        throw new IllegalArgumentException("You must specify a submodule");
    } catch (ClassNotFoundException e) {
      throw new IllegalArgumentException("Submodule does not exist "+e.getMessage(),e);
    } catch (InstantiationException e) {
      throw new IllegalArgumentException("Submodule could not be loaded ",e);
    } catch (IllegalAccessException e) {
      throw new IllegalArgumentException("Submodule could not be loaded ",e);
    }
  }
  @Override
  public boolean login() throws LoginException {
    return subModule.login();
  }
 
  @Override
  public boolean commit() throws LoginException {
    return subModule.commit();
  }
 
  @Override
  public boolean abort() throws LoginException {
    return subModule.abort();
  }
 
  @Override
  public boolean logout() throws LoginException {
    if (subModule.logout()){
      subject.getPublicCredentials().remove(ticketStore);
      return true;
    }
    return false;
     
  }
 
}

Concrètement

L’exemple que je donne utilise des cookies de l’application commons httpclient de jakarta, mais peut facilement être adapté à d’autres situations. Partons du code ci-dessus qui ne fait que déléguer. Il nous faudra modifier la méthode login. si subModule.login() est vrai, alors on peux commencer la récupération des tickets, on stocke ces ticket dans le Subject est c’est réglé.

user / pass

Vous l’aurez surement remarqué, login ne reçois rien en paramètre. Pas de user, pas de mot de passe. Comment les récupérer? Un regard dans le code source dans la classe LdapLoginModule nous renseigne assez vite sur la technique, se basant sur le sharedState (voir la méthode initialize) et les callbacks (voir la même méthode). Je vous met le code brut, a vous de le lire! :) Je rappelle que ce bout vient en grande partie de JBOSS donc gaffe copyright, etc. Allez lire la doc de JBOSS à ce sujet :p


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
  @Override
  public boolean login() throws LoginException {
    if (subModule.login()) {
      String user;
      char[] credential = null;
      Object username = sharedState.get("javax.security.auth.login.name");
      Object password = sharedState
          .get("javax.security.auth.login.password");
      if (username == null || password == null) {
        String[] info = getUsernameAndPassword();
        username = info[0];
        password = info[1];
      }
      if (username instanceof Principal)
        user = ((Principal) username).getName();
      else
        user = username.toString();
      if (password instanceof char[])
        credential = (char[]) password;
      else if (password != null) {
        String tmp = password.toString();
        credential = tmp.toCharArray();
      }
      getTickets(user, credential);
      return true;
    }
    return false;
  }
  // from jboss code
  protected String[] getUsernameAndPassword() throws LoginException {
    String[] info = { null, null };
    // prompt for a username and password
    if (callbackHandler == null) {
      throw new LoginException(ErrorCodes.NULL_VALUE
          + "Error: no CallbackHandler available "
          + "to collect authentication information");
    }
 
    NameCallback nc = new NameCallback("User name: ", "guest");
    PasswordCallback pc = new PasswordCallback("Password: ", false);
    Callback[] callbacks = { nc, pc };
    String username = null;
    String password = null;
    try {
      callbackHandler.handle(callbacks);
      username = nc.getName();
      char[] tmpPassword = pc.getPassword();
      if (tmpPassword != null)
        password = new String(tmpPassword);
    } catch (IOException e) {
      LoginException le = new LoginException(ErrorCodes.PROCESSING_FAILED
          + "Failed to get username/password");
      le.initCause(e);
      throw le;
    } catch (UnsupportedCallbackException e) {
      LoginException le = new LoginException(
          ErrorCodes.UNRECOGNIZED_CALLBACK
              + "CallbackHandler does not support: "
              + e.getCallback());
      le.initCause(e);
      throw le;
    }
    info[0] = username;
    info[1] = password;
    return info;
  }

Authentification

Tout se passe maintenant dans la méthode getTickets() qui reçois le user / pass. Nous avons créé une classe TicketStore, avec une instance ticketStore, qui est une simple HashMap<String,CookieStore> (pour rappel, j’utilise httpclient). Nous stockons cette instance dans les public credentials du Subject (à nouveau, reçus par initialize). Pourquoi cette classe TicketStore? Parce que, dans le paquet des Credentials d’un Subject, il est beaucoup plus facile de retrouver un objet ayant une classe bien précise. La méthode getTickets fonctionne donc simplement comme ceci:


1
2
3
4
5
6
7
8
9
10
  private static final String PREFIX_SERVER_BASIC = "server.basic.";
  private void getTickets(String username, char[] credential) {
    subject.getPublicCredentials().add(ticketStore);
    for (String option : options.keySet()) {
      if (option.startsWith(PREFIX_SERVER_BASIC)) {
        addBasicTicket(option.substring(PREFIX_SERVER_BASIC.length()),
            (String) options.get(option), username, credential);
      }
    }
  }

Elle ne traite actuellement que des services distants supportant l’authentification de type BASIC via http ou https. Elle va identifier le serveur et la méthode (GET, POST, PROPFIND, ….) à utiliser, s’y connecter trois fois (afin d’être sur d’avoir reçu les cookies en bonne et due forme), et stocker les cookies dans le store.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
  // basic authentication: GET,<realm>,server or
  // GET,server
  // it does preemptive authentication
  private void addBasicTicket(String servername, String configuration,
      String username, char[] credential) {
    log.fine("Basic ticketing for " + username + " on "
        + servername + "=" + configuration);
    String[] value = configuration.split(",");
    if (value.length < 2)
      throw new IllegalArgumentException("Server " + servername
          + " has invalid configuration value: " + configuration
          + ". Format is <method>,[<realm>,]<server>");
    String server = value[value.length - 1];
    final String method = value[0].toUpperCase();
    String realm = value.length == 3 ? value[1] : AuthScope.ANY_REALM;
 
    try {
      final URL url = new URL(server);
      final URI uri = url.toURI();
      HttpHost targetHost = new HttpHost(url.getHost(), url.getPort(),
          url.getProtocol());
 
      DefaultHttpClient httpclient = new DefaultHttpClient();
 
      httpclient.getCredentialsProvider().setCredentials(
          new AuthScope(targetHost.getHostName(),
              targetHost.getPort(), realm),
          new UsernamePasswordCredentials(username, new String(
              credential)));
 
      // Create AuthCache instance
      AuthCache authCache = new BasicAuthCache();
      // Generate BASIC scheme object and add it to the local auth cache
      BasicScheme basicAuth = new BasicScheme();
      authCache.put(targetHost, basicAuth);
 
      // Add AuthCache to the execution context
      BasicHttpContext localcontext = new BasicHttpContext();
      localcontext.setAttribute(ClientContext.AUTH_CACHE, authCache);
 
      HttpRequestBase httpget = new HttpRequestBase() {
        {
          setURI(uri);
        }
 
        @Override
        public String getMethod() {
          return method;
        }
      };
      httpclient.getParams().setParameter(ClientPNames.COOKIE_POLICY,
          CookiePolicy.BROWSER_COMPATIBILITY);
      BasicCookieStore cookieStore = new BasicCookieStore();
      httpclient.setCookieStore(cookieStore);
      for (int i = 0; i < 3; i++) {
        HttpResponse response = httpclient.execute(targetHost, httpget,
            localcontext);
        HttpEntity entity = response.getEntity();
        EntityUtils.consume(entity);
      }
      log.fine("Basic ticketing done on " + servername + "="
          + configuration);
      this.ticketStore.put(servername, cookieStore);
    } catch (MalformedURLException e) {
      log.log(Level.WARNING, "Could not get ticket for user " + username
          + " on server " + server + ": " + e.getMessage(), e);
    } catch (URISyntaxException e) {
      log.log(Level.WARNING, "Could not get ticket for user " + username
          + " on server " + server + ": " + e.getMessage(), e);
    } catch (ClientProtocolException e) {
      log.log(Level.WARNING, "Could not get ticket for user " + username
          + " on server " + server + ": " + e.getMessage(), e);
    } catch (IOException e) {
      log.log(Level.WARNING, "Could not get ticket for user " + username
          + " on server " + server + ": " + e.getMessage(), e);
    }
 
  }

Le cas des authentifications par DIGEST, formulaire ou autre sont laissés comme exercices :)

configuration

Il reste un détail à ne pas oublier dans la configuration de JAAS. JBOSS garde un cache. Si vous revenez demain avec un browser tout neuf avec le même utilisateur / mot de passe, vous ne passerez plus par la méthode login. Du coup pas de ticket, du coup des problèmes avec vos services tiers qui eux ne se souviennent plus de vous. Il conviens donc de désactiver ce cache dans la configuration (fichier standalone.xml):

                 name="xwiki" cache-type="none">
                    
                         code="com.company.auth.TicketLoginModule" flag="required">
                             name="otherLoginModule" value="org.jboss.security.auth.spi.LdapLoginModule"/>
                             name="java.naming.factory.initial" value="com.sun.jndi.ldap.LdapCtxFactory"/>
                             name="java.naming.security.authentication" value="simple"/>
                             name="java.naming.provider.url" value="ldaps://ldapserver:636"/>
                             name="java.naming.security.protocol" value="ssl"/>
                             name="java.naming.security.authentication" value="simple"/>
                             name="principalDNPrefix" value="uid="/>
                             name="principalDNSuffix" value=",ou=Persons,ou=People,dc=company,dc=com"/>
                             name="rolesCtxDN" value="ou=Group,ou=Intranet,dc=company,dc=com"/>
                             name="uidAttributeID" value="memberUid"/>
                             name="roleAttributeID" value="cn"/>
                             name="server.basic.webdav" value="HEAD,https://autreserveur/webDAV/"/>
                             name="server.basic.webservice" value="GET,https://serviceserveur/webservices/unService.wsdl"/>
                        
                    
                

Il s’agit là de la configuration final de votre domaine. Le code complet du module JAAS est en bas de ce document ;)

Utiliser les tickets

Reste à récupérer les tickets dans l’application. Voici un code simple récupérant l’entrée « webdav » et « webservice » de la configuration ci-dessus. Le plus dur est de savoir qu’il faut passer par le PolicyContext pour récupérer le Subject ;)


1
2
3
4
5
6
7
8
9
10
11
12
import javax.security.jacc.PolicyContext
import javax.security.auth.Subject;
import com.company.auth.TicketLoginModule.TicketStore
//.....
    Subject currentSubject = (Subject)PolicyContext.getContext("javax.security.auth.Subject.container");
    if (currentSubject!=null){
      TicketStore ticketStore = currentSubject.getPublicCredentials(TicketStore.class);
      if (ticketStore!=null && ticketStore.containsKey(name)){
        recupererDesTrucsDansLeWebdav(ticketStore.get("webdav"));
        recupererDesTrucsDansLeWebservice(ticketStore.get("webservice"));
      }
    }

Conclusion

Il est maintenant possible d’étendre les classes données en exemple afin de se connecter à d’autres services éventuels (LDAP, http, pop3, etc) sans avoir besoin de stocker le mot de passe de l’utilisateur. N’oubliez pas que vos services tiers doivent avoir une session d’une durée de vie au moins égale à celle de votre sessions jboss. Sinon, problèmes assurés quand ces sessions seront expirée. Il vous faudra peut-être créer un service en plus quelque part dans votre application qui va régulièrement rafraîchir ces cookies et donc ces sessions.

Vous avez la recette, faites monter la sauce et bonne chance.

Code complet

Tout le code de la classe TicketLoginModule

package com.company.auth;

import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.security.Principal;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.security.auth.Subject;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.NameCallback;
import javax.security.auth.callback.PasswordCallback;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.security.auth.login.LoginException;
import javax.security.auth.spi.LoginModule;

import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.AuthCache;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.CookieStore;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.params.ClientPNames;
import org.apache.http.client.params.CookiePolicy;
import org.apache.http.client.protocol.ClientContext;
import org.apache.http.impl.auth.BasicScheme;
import org.apache.http.impl.client.BasicAuthCache;
import org.apache.http.impl.client.BasicCookieStore;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.util.EntityUtils;
import org.jboss.security.ErrorCodes;

/**
* This login module delegates to another login module identified by key
* "otherLoginModule". When other module sucessfully authenticate a user, it
* grabs username and passowrd to create cookie session to other web services on
* behalf of current subject. The cookies are then stored in subject, as public
* credentials, under the class TicketStore so that application can later find
* them using
*
*
* <code><pre>
* Subject currentSubject = (Subject)PolicyContext.getContext("javax.security.auth.Subject.container");
* currentSubject.getPublicCredentials(TicketStore.class)
* </pre></code>
*
* Each server you want to ticket with is identified by
* server.basic.someServerId=GET,https://someserver/someurl
* server.basic.someOtherServerId=HEAD,somerealm,http://someserver/someurl
* server
* .basic.somewebdavServerId=PROPFIND,https://someserver/webdav/some/document
*
* @author tchize
*
*/
public class TicketLoginModule implements LoginModule {
private static final String PREFIX_SERVER_BASIC = "server.basic.";
private static final String KEY_OTHER_LOGIN_MODULE = "otherLoginModule";

public static class TicketStore extends HashMap<String, CookieStore> {
private static final long serialVersionUID = 203826269825982312L;
}

private static Logger log = Logger.getLogger(TicketLoginModule.class
.getCanonicalName());
private Map<String, ?> options;
private Subject subject;
private LoginModule subModule;
private Map<String, ?> sharedState;

private TicketStore ticketStore = new TicketStore();
private CallbackHandler callbackHandler;

@SuppressWarnings("unchecked")
@Override
public void initialize(Subject subject, CallbackHandler callbackHandler,
Map<String, ?> sharedState, Map<String, ?> options) {
this.options = options;
this.subject = subject;
this.sharedState = sharedState;
this.callbackHandler = callbackHandler;
String subModuleClass = (String) options.get(KEY_OTHER_LOGIN_MODULE);
Class<? extends LoginModule> subClass;
try {
if (subModuleClass != null
&& (subClass = ((Class<? extends LoginModule>) Class
.forName(subModuleClass))) != null) {
subModule = subClass.newInstance();
subModule.initialize(subject, callbackHandler, sharedState,
options);
} else
throw new IllegalArgumentException(
"You must specify a submodule");
} catch (ClassNotFoundException e) {
throw new IllegalArgumentException("Submodule does not exist "
+ e.getMessage(), e);
} catch (InstantiationException e) {
throw new IllegalArgumentException(
"Submodule could not be loaded ", e);
} catch (IllegalAccessException e) {
throw new IllegalArgumentException(
"Submodule could not be loaded ", e);
}
}

@Override
public boolean login() throws LoginException {
if (subModule.login()) {
String user;
char[] credential = null;
Object username = sharedState.get("javax.security.auth.login.name");
Object password = sharedState
.get("javax.security.auth.login.password");
if (username == null || password == null) {
String[] info = getUsernameAndPassword();
username = info[0];
password = info[1];
}
if (username instanceof Principal)
user = ((Principal) username).getName();
else
user = username.toString();
if (password instanceof char[])
credential = (char[]) password;
else if (password != null) {
String tmp = password.toString();
credential = tmp.toCharArray();
}
getTickets(user, credential);
return true;
}
return false;
}

// from jboss code
protected String[] getUsernameAndPassword() throws LoginException {
String[] info = { null, null };
// prompt for a username and password
if (callbackHandler == null) {
throw new LoginException(ErrorCodes.NULL_VALUE
+ "Error: no CallbackHandler available "
+ "to collect authentication information");
}

NameCallback nc = new NameCallback("User name: ", "guest");
PasswordCallback pc = new PasswordCallback("Password: ", false);
Callback[] callbacks = { nc, pc };
String username = null;
String password = null;
try {
callbackHandler.handle(callbacks);
username = nc.getName();
char[] tmpPassword = pc.getPassword();
if (tmpPassword != null)
password = new String(tmpPassword);
} catch (IOException e) {
LoginException le = new LoginException(ErrorCodes.PROCESSING_FAILED
+ "Failed to get username/password");
le.initCause(e);
throw le;
} catch (UnsupportedCallbackException e) {
LoginException le = new LoginException(
ErrorCodes.UNRECOGNIZED_CALLBACK
+ "CallbackHandler does not support: "
+ e.getCallback());
le.initCause(e);
throw le;
}
info[0] = username;
info[1] = password;
return info;
}

private void getTickets(String username, char[] credential) {
subject.getPublicCredentials().add(ticketStore);
for (String option : options.keySet()) {
Ce contenu a été publié dans Java enterprise, Niveau, Technique par tchize_. Mettez-le en favori avec son permalien.

Une réflexion au sujet de « Transférer une authentification jaas de JBOSS AS 7 vers des services tiers »

  1. Ping : Recap java, semaine 28, année 2012 | Blog de la rubrique java

Laisser un commentaire