Une gestion des Authorizations sur mesure avec Spring

Quand on parle de gestion des authorizations on pense souvent RBAC (Role-Based Access Control). Cette stratégie est tout à fait viable pour différencier les utilisateurs des membres du staff (avec des droits plus élevés). Cependant, on en atteint rapidement les limites lorsque l'on souhaite restreindre les actions des utilisateurs à certaines ressources (pour éviter que tout le monde puisse modifier les données de tout le monde par exemple).

L'idée de cet article est de vous présenter un moyen de gérer ce type d'authorizations dans une application Spring Boot tout en gardant quelque chose qui soit simplement maintenable et utilisable.

Bien qu'il contienne des exemples de code, le but de cet article n'est pas de fournir une solution toute faite puisque cette gestion doit être adaptée à vos besoins. Pour cette raison l'implémentation fournie n'est pas utilisable directement (puisque je ne donne pas toutes les implémentations, les dépendances ou les tests).

Stratégie globale

Autant que faire se peut la vérification des authorizations doit se faire en dehors des traitements métier ; dans du code dédié. Spring Security propose depuis très longtemps une décoration des méthodes avec l'annotation @PreAuthorize : c'est ce que nous allons utiliser ici.

Vous pouvez aussi utiliser très simplement @Secured si votre besoin est de simplement vérifier les rôles et groupes de l'utilisateur ou les annotations de la JSR250 mais ce ne sera pas le sujet de cet article.
A plusieurs reprises je vais utiliser le terme application service : ce sont des services qui ne sont chargés que de l'orchestration des opérations. Il ne font pas de traitements métier.

Pour cet article nous allons donc utiliser @PreAuthorize dans une application

  • où les utilisateurs peuvent faire certaines actions (en fonction de leur rôles) sur des groupes d'objets. Les règles métier à appliquer pour savoir qui a accès à quels objets changent pour chaque type d'objets,
  • où on ne manipule pas de types primitifs (ou de String) en entrée des méthodes des applications services mais uniquement des types dédiés (par exemple on n’aura pas un String avec le login de la personne mais un objet Username),
  • où la sécurisation est faite en entrée des applications services.

Si votre projet n'a pas ces pré-requis l'article est quand même tout à fait applicable, je devais simplement faire des choix :).

Comment gérer qui peut faire quoi ?

Il est très courant de vérifier la possibilité de faire une action en se basant directement sur les rôles de l'utilisateur connecté. Je n'aime pas cette stratégie !

Dans ma compréhension un rôle doit donner accès à une liste d'actions possibles, je vais donc préférer associer les rôles à des actions et vérifier la possibilité de faire une action. De cette manière il sera bien plus simple de comprendre le contenu des @PreAuthorize mais il sera aussi plus simple de créer de nouveaux rôles pouvant faire des sous-ensembles d'actions.

En combinant la possibilité de faire une action avec les accès à une ressource nous allons pouvoir couvrir une part importante des besoins de vérification d'accès de notre application.

Choix d'une implémentation par type d'objet

Nous devons maintenant trouver un moyen de fournir une implémentation par type d'objet sur lequel l'accès doit être validé. Commençons par définir une interface :

import org.springframework.security.core.Authentication;

@FunctionalInterface
public interface CanChecker<T> {
  /**
  * Checks if the authenticated user can access the item.
  *
  * @param authentication
  *          authenticated user information
  * @param action
  *          action to check
  * @param item
  *          element to check action possibility on to
  * @return true is the user can do the action on the resource, false otherwise
  */
  boolean can(Authentication authentication, String action, T item);
}

Nous retrouvons bien nos notions : est-ce que l'utilisateur connecté peut faire une action sur un objet.

Nous pouvons maintenant écrire une class pour sélectionner l'implémentation correspondant à un objet donné et faire la vérification d'accès :

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;

@Service
class CanEvaluator {
  private static final Logger logger = LoggerFactory.getLogger(CanEvaluator.class);
  private final ObjectChecker defaultEvaluator;
  private final Map<Class<?>, CanChecker<?>> evaluators;

  public CanEvaluator(List<CanChecker<?>> checkers) {
    evaluators = checkers.stream().collect(Collectors.toMap(this::getCheckerResourceClass, checker -> checker));
    logger.info("Authorized types: {}",
        evaluators.keySet().stream().map(Class::getName).collect(Collectors.joining(", ")));
    defaultEvaluator = (ObjectChecker) evaluators.get(Object.class);
    // Here you should ensure that you have a default evaluator :)
  }

  private Class<?> getCheckerResourceClass(CanChecker<?> checker) {
    Class<?> checkerClass = findCheckerClass(checker);
    return (Class<?>) ((ParameterizedType) streamParameterizedTypes(checkerClass)
        .filter(type -> ((ParameterizedType) type).getRawType().equals(CanChecker.class))
        .findFirst()
        .get()).getActualTypeArguments()[0];
  }

  private Class<?> findCheckerClass(CanChecker<?> checker) {
    Class<?> checkerClass = checker.getClass();
    while (Arrays.stream(checkerClass.getInterfaces()).noneMatch(interf -> CanChecker.class.equals(interf))) {
      checkerClass = checkerClass.getSuperclass();
    }
    return checkerClass;
  }

  private Stream<Type> streamParameterizedTypes(Class<?> checkerClass) {
    return Arrays.stream(checkerClass.getGenericInterfaces()).filter(type -> type instanceof ParameterizedType);
  }

  public boolean can(Authentication authentication, String action, Object item) {
    return evaluators.getOrDefault(item.getClass(), defaultEvaluator).canOnObject(authentication, action, item);
  }
}

Pour pouvoir faire cette implémentation j'ai dû ajouter une méthode dans l'interface CanChecker :

@SuppressWarnings("unchecked")
default boolean canOnObject(Authentication authentication, String action, Object item) {
  return can(authentication, action, (T) item);
}

Ce n'est pas très élégant mais, de cette manière, vos implémentations seront directement appelées avec des objets du type qu'elles gèrent.

Il nous manque aussi l'implémentation par défaut, j'ai choisi de bloquer les actions par défaut :

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
 
@Service
class ObjectChecker implements CanChecker<Object> {
  private static final Logger logger = LoggerFactory.getLogger(ObjectChecker.class);
 
  @Override
  public boolean can(Authentication authentication, String action, Object item) {
    logger.error("Error checking rights, falled back to default handler for action {} on class {}", action, getItemClass(item));
 
    return false;
  }
 
  private String getItemClass(Object item) {
    if (item == null) {
      return "unknown";
    }
 
    return item.getClass().getName();
  }
}

Maintenant que nous avons ce mécanisme de résolution d'un CanChecker pour un type d'objet nous pouvons utiliser cela depuis les @PreAuthorize.

Branchement à Spring

La première chose dont nous allons avoir besoin est de redéfinir notre SecurityExpressionRoot :

import org.springframework.security.access.expression.SecurityExpressionRoot;
import org.springframework.security.access.expression.method.MethodSecurityExpressionOperations;
import org.springframework.security.core.Authentication;
 
class MyMethodSecurityExpressionRoot extends SecurityExpressionRoot implements MethodSecurityExpressionOperations {
  private final CanEvaluator evaluator;
 
  private Object filterObject;
  private Object returnObject;
  private Object target;
 
  public MyMethodSecurityExpressionRoot(Authentication authentication, CanEvaluator evaluator) {
    super(authentication);
    this.evaluator = evaluator;
  }
 
  @Override
  public void setFilterObject(Object filterObject) {
    this.filterObject = filterObject;
  }
 
  @Override
  public Object getFilterObject() {
    return filterObject;
  }
 
  @Override
  public void setReturnObject(Object returnObject) {
    this.returnObject = returnObject;
  }
 
  @Override
  public Object getReturnObject() {
    return returnObject;
  }
 
  void setThis(Object target) {
    this.target = target;
  }
 
  @Override
  public Object getThis() {
    return target;
  }
 
  public boolean can(String action, Object item) {
    return evaluator.can(getAuthentication(), action, item);
  }
}

Le but ici est d'invoquer la méthode can lorsque l'on utilisera @PreAuthorize("can('action', #item)"). Vous pouvez donc changer la signature comme vous le souhaitez pour correspondre à vos besoins. Vous pouvez par exemple ajouter une clé pour le type de ressource et sélectionner votre CanChecker (ou équivalent) non pas en fonction du type mais en fonction de la clé.

Nous devons maintenant utiliser ce nouveau SecurityExpressionRoot, pour cela il nous faut un MethodSecurityExpressionHandler :

import org.aopalliance.intercept.MethodInvocation;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionOperations;
import org.springframework.security.core.Authentication;
 
class MyMethodSecurityExpressionHandler extends DefaultMethodSecurityExpressionHandler {
  private final CanEvaluator evaluator;
 
  MyMethodSecurityExpressionHandler(CanEvaluator evaluator) {
    this.evaluator = evaluator;
  }
 
  @Override
  protected MethodSecurityExpressionOperations createSecurityExpressionRoot(Authentication authentication, MethodInvocation invocation) {
    MyMethodSecurityExpressionRoot root = new MyMethodSecurityExpressionRoot(authentication, evaluator);
 
    root.setThis(invocation.getThis());
    root.setPermissionEvaluator(getPermissionEvaluator());
    root.setTrustResolver(getTrustResolver());
    root.setRoleHierarchy(getRoleHierarchy());
    root.setDefaultRolePrefix(getDefaultRolePrefix());
 
    return root;
  }
}

Et enfin nous devons configurer l'utilisation de ce handler :

import org.springframework.context.annotation.Lazy;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration;
 
@EnableGlobalMethodSecurity(prePostEnabled = true)
class MySecurityConfiguration extends GlobalMethodSecurityConfiguration {
  private final CanEvaluator evaluator;
 
  // All beans here needs to be Lazy otherwise it'll break AOP (cache, transactions, etc...)
  public MySecurityConfiguration(@Lazy CanEvaluator evaluator) {
    this.evaluator = evaluator;
  }
 
  @Override
  protected MethodSecurityExpressionHandler createExpressionHandler() {
    return new MyMethodSecurityExpressionHandler(evaluator);
  }
}

Même si c'est dit en commentaire je préfère le redire ici : injectez impérativement tous les beans de cette classe de configuration en @Lazy sinon vous allez perdre vos transactions, caches, etc !

Vous avez maintenant la possibilité d'utiliser votre nouvelle expression dans les @PreAuthorize :

@PreAuthorize("can('read', #stuff)")
public Optional<Stuff> get(StuffId stuff) {
  //...
}

Et c'est votre implémentation de CanChecker<StuffId> qui sera appelée pour savoir si votre utilisateur peut faire l'action read.

Et le filtrage des données en retour ?

La stratégie que nous avons vue permet de vérifier qu'un utilisateur peut accéder à une ressource mais elle ne permet pas un filtrage des données en retour. Même si on peut faire des choses avec @PostAuthorize je préfère traiter ce second besoin directement dans le code métier en filtrant les données lors de leur récupération.

S'adapter à votre besoin

L'exemple donné ici ne correspond très certainement pas directement à votre besoin : pas le bon verbe (can), pas les bons paramètres, ... Je pense cependant que la stratégie de choisir une implémentation en fonction du besoin métier s'applique dans la majorité des cas.

C'est maintenant à vous de trouver comment :

  • Associer des actions à des rôles (est-ce qu'une Map en dur suffit ?) ;
  • Faire simplement les implémentations des CanChecker des différents types (pensez aux délégations) ;
  • Améliorer CanEvaluator pour gérer l'héritage, les collections et les tableaux d'objets traités.

Un dernier point : selon votre besoin il est possible de brancher cette mécanique sur l'expression native Spring hasAuthority en définissant votre propre permissionEvaluator dans votre MethodSecurityExpressionHandler :

@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
  MyMethodSecurityExpressionHandler handler = new MyMethodSecurityExpressionHandler(evaluator);
  handler.setPermissionEvaluator(new MyPermissionEvaluator(evaluator));
  return handler;
}

De cette manière vous pourrez utiliser @PreAuthorize("hasPermission(#item, 'read')") et @PreAuthorize("hasPermission(#item, 'resource', 'read')"), c'est à vous de voir !