OpenID Connect en java

Les adapters Java

Une sélection : https://oauth.net/code/java/

A ne plus utiliser car déprécié par la communauté : https://www.keycloak.org/docs/latest/securing_apps/#java-adapters

  • Adapters liés à une plateforme fortement déconseillés, car ils créent des dépendances “Hors Maven”

Mise en place de OpenIDConnect dans une application Java

  • Avec pac4j https://www.pac4j.org/
    • Situation application quelconque JavaEE, application de type ihm en java
  • Avec Spring security https://docs.spring.io/spring-security/reference/servlet/oauth2/index.html
    • Situation application Spring Boot, application de type Web Service

OIDC et Pac4j

Pac4j

Dépendances modules jee + oidc:

		<dependency>
			<groupId>org.pac4j</groupId>
			<artifactId>jakartaee-pac4j</artifactId>
			<version>7.1.0</version>
		</dependency>

		<dependency>
			<groupId>org.pac4j</groupId>
			<artifactId>pac4j-oidc</artifactId>
			<version>5.7.0</version>
		</dependency>
  • Une classe de configuration
public class DemoConfigFactory implements ConfigFactory {

    @Override
    public Config build(final Object... parameters) {

    }

}
  • Définition des protocoles d’authentification
  OidcConfiguration oidcConfig = new OidcConfiguration();
        oidcConfig.setClientId(clientId);
        oidcConfig.setSecret(clientSecret);
        oidcConfig.setDiscoveryURI(configurationEndpoint);
		oidcConfig.setClientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
        OidcClient oidcClient = new OidcClient(oidcConfig);
  • Définition de l’url de callback (= ou est ce que l’on revient après authentification sur le serveur central)
oidcClient.setCallbackUrl("http://localhost:8080/demo/callback");
  • Ce endpoint doit être intercepté
	<filter>
		<filter-name>callbackFilter</filter-name>
		<filter-class>org.pac4j.jee.filter.CallbackFilter</filter-class>
	</filter>
	<filter-mapping>
		<filter-name>callbackFilter</filter-name>
		<url-pattern>/callback</url-pattern>
		<dispatcher>REQUEST</dispatcher>
	</filter-mapping>
  • Définition de la liste de client (plusieurs mode d’authentification possible potentiellement : oidc + basic par exemple)
Config config = new Config(oidcClient);
  • Définition de règles et conditions
config.addAuthorizer("admin", new RequireAnyRoleAuthorizer("ROLE_ADMIN"));
config.addAuthorizer("mustBeAuthent",new IsAuthenticatedAuthorizer());

config.addMatcher("excludedPath", new PathMatcher().excludeRegex("^\\/(accueil)?$"));
  • Activables selon les endpoints
	<filter>
		<filter-name>oidcFilter</filter-name>
		<filter-class>org.pac4j.jee.filter.SecurityFilter</filter-class>
		<init-param>
			<param-name>configFactory</param-name>
			<param-value>fr.insee.demo.security.DemoConfigFactory</param-value>
		</init-param>
		<init-param>
			<param-name>authorizers</param-name>
			<param-value>mustBeAuthent,csrfToken</param-value>
		</init-param>
		<init-param>
			<param-name>matchers</param-name>
			<param-value>excludedPath</param-value>
		</init-param>
	</filter>
	<filter-mapping>
		<filter-name>oidcFilter</filter-name>
		<url-pattern>/*</url-pattern>
	</filter-mapping>

Obtenir le jeton

WebContext context = new JEEContext(request, response);
ProfileManager manager = new ProfileManager(context,JEESessionStore.INSTANCE);
Optional<UserProfile> profile = manager.getProfile();
OidcProfile oidcProfile = (OidcProfile) profile.get();
Map h = oidcProfile.getAttributes();
  • Il faut connaitre le nom des claims souhaités :
h.get("email")

Gestion des droits

Pour mapper des roles, il faut implémenter selon le besoin un générateur de roles à partir du profile (accessToken notemment)

oidcClient.addAuthorizationGenerator(new AuthorizationGenerator() {
	@Override
	public Optional<UserProfile> generate(WebContext context,
													  SessionStore sessionStore,
													  UserProfile profile) {
		return Optional.empty();
	}
});

Peut s’utiliser dans le code directement

oidcProfile.getRoles();
request.isUserInRole("admin");

ou géré par règles dans la config

config.addAuthorizer("admin", new RequireAnyRoleAuthorizer("admin"));

Possiblité de l’implémentation keycloak incluse

var profileCreator = new OidcProfileCreator(oidcConfig, oidcClient);
profileCreator.setProfileDefinition(
		new OidcProfileDefinition(x -> new KeycloakOidcProfile()));
oidcClient.setProfileCreator(profileCreator);
oidcClient.addAuthorizationGenerator(
		new KeycloakRolesAuthorizationGenerator());

Connexion à un web service, authentifié par un jeton

Obtenir le jeton pour se connecter à un Web Service

oidcProfile.getAccessToken().value()
  • Il faut ensuite ajouter l’entête “Authorization: Bearer tokenString” pour accéder au WS
oidcProfile.getAccessToken().toAuthorizationHeader()
  • Un test peut être fait sur le WS embarqué de keycloak : https://mon.serveur.keycloak/auth/realms/formation/protocol/openid-connect/userinfo

Logout

Concrètement, il s’agit d’une :

  • Déconnexion locale : request.getSession().invalidate();
  • Déconnexion Keycloak : un appel sur https://mon.serveur.keycloak/auth/realms/formation/protocol/openid-connect/logout?redirect_uri=https://localhost:8443/ logout l’utilisateur sur Keycloak et redirige vers “redirect_uri”
	<!-- Logout configuration -->
	<filter>
		<filter-name>logoutFilter</filter-name>
		<filter-class>org.pac4j.jee.filter.LogoutFilter</filter-class>
		<init-param>
			<param-name>centralLogout</param-name>
			<param-value>true</param-value>
		</init-param>
		<init-param>
			<param-name>logoutUrlPattern</param-name>
			<param-value>.*</param-value>
		</init-param>
		<init-param>
			<param-name>defaultUrl</param-name>
			<param-value>http://localhost:8080/</param-value>
		</init-param>
	</filter>
	<filter-mapping>
		<filter-name>logoutFilter</filter-name>
		<url-pattern>/logout</url-pattern>
	</filter-mapping>

Bonus : CSRF

Config :

config.addAuthorizer("csrfToken", new CsrfAuthorizer());

Controller :

var gen = new DefaultCsrfTokenGenerator();
var csrfToken = gen.get(context, JEESessionStore.INSTANCE);
model.addAttribute("_csrf_token_name", Pac4jConstants.CSRF_TOKEN);
model.addAttribute("_csrf_token", csrfToken);

Ecran :

<form th:action="@{/private}" method="POST">
    <input type="hidden" th:value="${_csrf_token}" th:name="${_csrf_token_name}"/>
    <input type="submit" value="C'est parti !!" />
</form>

Environnement de production

  • Il faut être capable d’adapter facilement l’application au différents environnements : local, dv/qf, prod
  • Il y a une conf commune localhost par realm (une confidential, une public)
  • Une demande doit être faite pour les environnement de dv/qf : une seule configuration pour tous les environnements dv/qf
  • Une demande doit être faite pour la prod : en cas de confidential, seule la prod aura accès au secret

Cas du filtre Keycloak

  • Le json peut être paramétré pour s’adapter à des propriétés systèmes (avec valeurs par défaut sur localhost par exemple)
{
  "realm": "${fr.insee.keycloak.realm:formation}",
  "auth-server-url": "${fr.insee.keycloak.server:https://mon.serveur.keycloak/auth}",
  "ssl-required": "none",
  "resource": "${fr.insee.keycloak.resource:localhost-web}",
  "credentials": {
    "secret": "${fr.insee.keycloak.credentials.secret:abcd_1234_abcd}"
  },
  "confidential-port": 0,
  "principal-attribute": "preferred_username"
}
  • Il faut rajouter ces variables au démarrage de la jvm (-Dfr.insee.keycloak.realm=monRealm)

OIDC et Spring security

Adapter Spring security module oAuth2

Dépendances :

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

Adapter Spring security module oAuth2

Configuration particulière de spring security

@Configuration
@EnableWebSecurity
public class MySecurityConfigurationAdapter {
		
	@Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    // Cas d'un webservice :
    // - ne pas gerer les sessions
    // - pas de csrf
    http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    http.csrf().disable();
    // Descriptions des regles d'accès par path
    http.authorizeHttpRequests(
        requests ->
            requests
                // Ignorer le path du swagger
                .requestMatchers(HttpMethod.GET, "/swagger-ui/**", "/v3/api-docs/**")
                .permitAll()
                .requestMatchers(HttpMethod.GET, "/", "/healthcheck")
                .permitAll()
                .requestMatchers(HttpMethod.GET, "/private")
                .authenticated()
                .requestMatchers(HttpMethod.GET, "/admin")
                .hasRole("admin")
                .requestMatchers(HttpMethod.OPTIONS)
                .permitAll()
                .anyRequest()
                .denyAll());
    // Moyens d'authentification : oAuth2 bearer
    http.oauth2ResourceServer(
		// pour personnaliser l'authenticator
        oauth2 ->
            oauth2.jwt(
                jwtConfigurer -> {
                  jwtConfigurer.jwtAuthenticationConverter(jwtAuthenticationConverter());
                }));
    return http.build();
  }
  }
// web service = pas de gestion de session
http.sessionManagement()
			.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// pas de formulaire => pas de csrf
http.csrf().disable();
// gestion des accès
http.authorizeRequests(
			requests -> // Fonction des droits 
			)
				  .oauth2ResourceServer();

Fonction de droits

authz -> 
	authz.antMatchers(HttpMethod.GET, "/private")  // CONDITION
			.authenticated(). // UTILISATEUR AUTHENTIFIE SANS CONTROLE

		antMatchers(HttpMethod.GET,"/admin") // CONDITION
			.hasRole("admin") // CONTROLE DU ROLE

		.antMatchers(HttpMethod.OPTIONS).permitAll() // pour les requetes CORS
		.antMatchers("error", "/","/accueil").permitAll() // Chemins publics
		.anyRequest().denyAll()  // Dans le doute le reste est interdit

Configuration via SpringBoot

  • Seul l’emplacement des certificat est nécessaire. La conf se retrouve ici : http://localhost:8180/auth/realms/test/.well-known/openid-configuration
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:8180/auth/realms/test/protocol/openid-connect/certs

Recherche des roles

  • Comme souvent, se réimplémente !
http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwtConfigurer -> {
					  jwtConfigurer.jwtAuthenticationConverter(
								 jwtAuthenticationConverter());
				  }));
  private JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter());
    jwtAuthenticationConverter.setPrincipalClaimName("preferred_username");
    return jwtAuthenticationConverter;
  }
@SuppressWarnings("unchecked")
  private Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter() {
    return source -> {
      String[] claimPath = OIDC_CLAIM_ROLE.split("\\.");
      Map<String, Object> claims = source.getClaims();
      try {

        for (int i = 0; i < claimPath.length - 1; i++) {
          claims = (Map<String, Object>) claims.get(claimPath[i]);
        }

        List<String> roles =
            (List<String>) claims.getOrDefault(claimPath[claimPath.length - 1], new ArrayList<>());
        return roles.stream()
            .map(
                s ->
                    new GrantedAuthority() {
                      @Override
                      public String getAuthority() {
                        return "ROLE_" + s;
                      }

                      @Override
                      public String toString() {
                        return getAuthority();
                      }
                    })
            .collect(Collectors.toList());
      } catch (ClassCastException e) {
        // role path not correctly found, assume that no role for this user
        return new ArrayList<>();
      }
    };
  }

Obtenir le jeton (appel à web service)

JwtAuthenticationToken token =
				  (JwtAuthenticationToken) request.getUserPrincipal();
maRequete.header("Authorization",
							 "Bearer " + token.getToken());

Dépendances adapters java Keycloak (JEE et Spring Security)

  • Ces adapters étaient plus facile à configurer dans notre environnement Keycloak (prévus pour !)
  • C’est toujours un plus de ne pas dépendre d’une implémentation mais d’un standard (OIDC/OAuth)
  • Le projet keycloak abandonne le support des adapters Keycloak