Une expression de sécurité personnalisée avec Spring Security

Une expression de sécurité personnalisée avec Spring Security

1. Vue d'ensemble

Dans ce didacticiel, nous allons nous concentrer surcreating a custom security expression with Spring Security.

Parfois, lesthe expressions available in the framework ne sont tout simplement pas assez expressifs. Et, dans ces cas, il est relativement simple de créer une nouvelle expression sémantiquement plus riche que les expressions existantes.

Nous allons d'abord expliquer comment créer unPermissionEvaluator personnalisé, puis une expression entièrement personnalisée - et enfin comment remplacer l'une des expressions de sécurité intégrées.

2. Une entité utilisateur

Tout d'abord, préparons les bases de la création des nouvelles expressions de sécurité.

Jetons un coup d'œil à notre entitéUser - qui a unPrivileges et unOrganization:

@Entity
public class User{
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    private String password;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "users_privileges",
      joinColumns =
        @JoinColumn(name = "user_id", referencedColumnName = "id"),
      inverseJoinColumns =
        @JoinColumn(name = "privilege_id", referencedColumnName = "id"))
    private Set privileges;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "organization_id", referencedColumnName = "id")
    private Organization organization;

    // standard getters and setters
}

Et voici nos simplesPrivilege:

@Entity
public class Privilege {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column(nullable = false, unique = true)
    private String name;

    // standard getters and setters
}

Et nosOrganization:

@Entity
public class Organization {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column(nullable = false, unique = true)
    private String name;

    // standard setters and getters
}

Enfin, nous utiliserons unPrincipal personnalisé plus simple:

public class MyUserPrincipal implements UserDetails {

    private User user;

    public MyUserPrincipal(User user) {
        this.user = user;
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public Collection getAuthorities() {
        List authorities = new ArrayList();
        for (Privilege privilege : user.getPrivileges()) {
            authorities.add(new SimpleGrantedAuthority(privilege.getName()));
        }
        return authorities;
    }

    ...
}

Avec toutes ces classes prêtes, nous allons utiliser nosPrincipal personnalisés dans une implémentation de base deUserDetailsService:

@Service
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userRepository.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException(username);
        }
        return new MyUserPrincipal(user);
    }
}

Comme vous pouvez le constater, ces relations n’ont rien de compliqué: l’utilisateur possède un ou plusieurs privilèges et chaque utilisateur appartient à une organisation.

3. Configuration des données

Ensuite, initialisons notre base de données avec des données de test simples:

@Component
public class SetupData {
    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PrivilegeRepository privilegeRepository;

    @Autowired
    private OrganizationRepository organizationRepository;

    @PostConstruct
    public void init() {
        initPrivileges();
        initOrganizations();
        initUsers();
    }
}

Voici nos méthodesinit:

private void initPrivileges() {
    Privilege privilege1 = new Privilege("FOO_READ_PRIVILEGE");
    privilegeRepository.save(privilege1);

    Privilege privilege2 = new Privilege("FOO_WRITE_PRIVILEGE");
    privilegeRepository.save(privilege2);
}
private void initOrganizations() {
    Organization org1 = new Organization("FirstOrg");
    organizationRepository.save(org1);

    Organization org2 = new Organization("SecondOrg");
    organizationRepository.save(org2);
}
private void initUsers() {
    Privilege privilege1 = privilegeRepository.findByName("FOO_READ_PRIVILEGE");
    Privilege privilege2 = privilegeRepository.findByName("FOO_WRITE_PRIVILEGE");

    User user1 = new User();
    user1.setUsername("john");
    user1.setPassword("123");
    user1.setPrivileges(new HashSet(Arrays.asList(privilege1)));
    user1.setOrganization(organizationRepository.findByName("FirstOrg"));
    userRepository.save(user1);

    User user2 = new User();
    user2.setUsername("tom");
    user2.setPassword("111");
    user2.setPrivileges(new HashSet(Arrays.asList(privilege1, privilege2)));
    user2.setOrganization(organizationRepository.findByName("SecondOrg"));
    userRepository.save(user2);
}

Notez que:

  • L'utilisateur «john» n'a queFOO_READ_PRIVILEGE

  • L'utilisateur «tom» a à la foisFOO_READ_PRIVILEGE etFOO_WRITE_PRIVILEGE

4. Un évaluateur d'autorisations personnalisé

À ce stade, nous sommes prêts à commencer à mettre en œuvre notre nouvelle expression, via un nouvel évaluateur d’autorisations personnalisé.

Nous allons utiliser les privilèges de l'utilisateur pour sécuriser nos méthodes - mais au lieu d'utiliser des noms de privilèges codés en dur, nous voulons parvenir à une implémentation plus ouverte et flexible.

Commençons.

4.1. PermissionEvaluator

Afin de créer notre propre évaluateur d'autorisation personnalisé, nous devons implémenter l'interfacePermissionEvaluator:

public class CustomPermissionEvaluator implements PermissionEvaluator {
    @Override
    public boolean hasPermission(
      Authentication auth, Object targetDomainObject, Object permission) {
        if ((auth == null) || (targetDomainObject == null) || !(permission instanceof String)){
            return false;
        }
        String targetType = targetDomainObject.getClass().getSimpleName().toUpperCase();

        return hasPrivilege(auth, targetType, permission.toString().toUpperCase());
    }

    @Override
    public boolean hasPermission(
      Authentication auth, Serializable targetId, String targetType, Object permission) {
        if ((auth == null) || (targetType == null) || !(permission instanceof String)) {
            return false;
        }
        return hasPrivilege(auth, targetType.toUpperCase(),
          permission.toString().toUpperCase());
    }
}

Voici notre méthodehasPrivilege():

private boolean hasPrivilege(Authentication auth, String targetType, String permission) {
    for (GrantedAuthority grantedAuth : auth.getAuthorities()) {
        if (grantedAuth.getAuthority().startsWith(targetType)) {
            if (grantedAuth.getAuthority().contains(permission)) {
                return true;
            }
        }
    }
    return false;
}

Nous avons maintenant une nouvelle expression de sécurité disponible et prête à être utilisée:hasPermission.

Et ainsi, au lieu d'utiliser la version plus codée:

@PostAuthorize("hasAuthority('FOO_READ_PRIVILEGE')")

Nous pouvons utiliser utiliser:

@PostAuthorize("hasPermission(returnObject, 'read')")

or

@PreAuthorize("hasPermission(#id, 'Foo', 'read')")

Remarque:#id fait référence au paramètre de méthode et «Foo» fait référence au type d'objet cible.

4.2. Configuration de la sécurité de la méthode

Il ne suffit pas de définir lesCustomPermissionEvaluator - nous devons également l’utiliser dans la configuration de la sécurité de nos méthodes:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {

    @Override
    protected MethodSecurityExpressionHandler createExpressionHandler() {
        DefaultMethodSecurityExpressionHandler expressionHandler =
          new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setPermissionEvaluator(new CustomPermissionEvaluator());
        return expressionHandler;
    }
}

4.3. Exemple en pratique

Commençons maintenant à utiliser la nouvelle expression - dans quelques méthodes de contrôleur simples:

@Controller
public class MainController {

    @PostAuthorize("hasPermission(returnObject, 'read')")
    @RequestMapping(method = RequestMethod.GET, value = "/foos/{id}")
    @ResponseBody
    public Foo findById(@PathVariable long id) {
        return new Foo("Sample");
    }

    @PreAuthorize("hasPermission(#foo, 'write')")
    @RequestMapping(method = RequestMethod.POST, value = "/foos")
    @ResponseStatus(HttpStatus.CREATED)
    @ResponseBody
    public Foo create(@RequestBody Foo foo) {
        return foo;
    }
}

Et nous y voilà - nous sommes tous prêts et utilisons la nouvelle expression dans la pratique. __

4.4. Le test en direct

Écrivons maintenant des tests en direct simples - en appuyant sur l'API et en nous assurant que tout est en ordre de marche:

@Test
public void givenUserWithReadPrivilegeAndHasPermission_whenGetFooById_thenOK() {
    Response response = givenAuth("john", "123").get("http://localhost:8081/foos/1");
    assertEquals(200, response.getStatusCode());
    assertTrue(response.asString().contains("id"));
}

@Test
public void givenUserWithNoWritePrivilegeAndHasPermission_whenPostFoo_thenForbidden() {
    Response response = givenAuth("john", "123").contentType(MediaType.APPLICATION_JSON_VALUE)
                                                .body(new Foo("sample"))
                                                .post("http://localhost:8081/foos");
    assertEquals(403, response.getStatusCode());
}

@Test
public void givenUserWithWritePrivilegeAndHasPermission_whenPostFoo_thenOk() {
    Response response = givenAuth("tom", "111").contentType(MediaType.APPLICATION_JSON_VALUE)
                                               .body(new Foo("sample"))
                                               .post("http://localhost:8081/foos");
    assertEquals(201, response.getStatusCode());
    assertTrue(response.asString().contains("id"));
}

Et voici notre méthodegivenAuth():

private RequestSpecification givenAuth(String username, String password) {
    FormAuthConfig formAuthConfig =
      new FormAuthConfig("http://localhost:8081/login", "username", "password");

    return RestAssured.given().auth().form(username, password, formAuthConfig);
}

5. Une nouvelle expression de sécurité

Avec la solution précédente, nous avons pu définir et utiliser l'expressionhasPermission - ce qui peut être très utile.

Cependant, nous sommes encore quelque peu limités ici par le nom et la sémantique de l'expression elle-même.

Et donc, dans cette section, nous allons passer à la personnalisation complète - et nous allons implémenter une expression de sécurité appeléeisMember() - en vérifiant si le mandant est membre d'une organisation. __

5.1. Expression de sécurité de méthode personnalisée

Afin de créer cette nouvelle expression personnalisée, nous devons commencer par implémenter la note racine où commence l'évaluation de toutes les expressions de sécurité:

public class CustomMethodSecurityExpressionRoot
  extends SecurityExpressionRoot implements MethodSecurityExpressionOperations {

    public CustomMethodSecurityExpressionRoot(Authentication authentication) {
        super(authentication);
    }

    public boolean isMember(Long OrganizationId) {
        User user = ((MyUserPrincipal) this.getPrincipal()).getUser();
        return user.getOrganization().getId().longValue() == OrganizationId.longValue();
    }

    ...
}

Maintenant, comment nous avons fourni cette nouvelle opération directement dans la note fondamentale ici; isMember() est utilisé pour vérifier si l'utilisateur actuel est membre deOrganization donnés.

Notez également comment nous avons étendu lesSecurityExpressionRoot pour inclure également les expressions intégrées.

5.2. Gestionnaire d'expressions personnalisées

Ensuite, nous devons injecter nosCustomMethodSecurityExpressionRoot dans notre gestionnaire d'expression:

public class CustomMethodSecurityExpressionHandler
  extends DefaultMethodSecurityExpressionHandler {
    private AuthenticationTrustResolver trustResolver =
      new AuthenticationTrustResolverImpl();

    @Override
    protected MethodSecurityExpressionOperations createSecurityExpressionRoot(
      Authentication authentication, MethodInvocation invocation) {
        CustomMethodSecurityExpressionRoot root =
          new CustomMethodSecurityExpressionRoot(authentication);
        root.setPermissionEvaluator(getPermissionEvaluator());
        root.setTrustResolver(this.trustResolver);
        root.setRoleHierarchy(getRoleHierarchy());
        return root;
    }
}

5.3. Configuration de la sécurité de la méthode

Maintenant, nous devons utiliser nosCustomMethodSecurityExpressionHandler dans la configuration de la sécurité de la méthode:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
    @Override
    protected MethodSecurityExpressionHandler createExpressionHandler() {
        CustomMethodSecurityExpressionHandler expressionHandler =
          new CustomMethodSecurityExpressionHandler();
        expressionHandler.setPermissionEvaluator(new CustomPermissionEvaluator());
        return expressionHandler;
    }
}

5.4. Utilisation de la nouvelle expression

Voici un exemple simple pour sécuriser notre méthode de contrôleur en utilisantisMember():

@PreAuthorize("isMember(#id)")
@RequestMapping(method = RequestMethod.GET, value = "/organizations/{id}")
@ResponseBody
public Organization findOrgById(@PathVariable long id) {
    return organizationRepository.findOne(id);
}

5.5. Test en direct

Enfin, voici un simple test en direct pour l'utilisateur «john»:

@Test
public void givenUserMemberInOrganization_whenGetOrganization_thenOK() {
    Response response = givenAuth("john", "123").get("http://localhost:8081/organizations/1");
    assertEquals(200, response.getStatusCode());
    assertTrue(response.asString().contains("id"));
}

@Test
public void givenUserMemberNotInOrganization_whenGetOrganization_thenForbidden() {
    Response response = givenAuth("john", "123").get("http://localhost:8081/organizations/2");
    assertEquals(403, response.getStatusCode());
}

6. Désactiver une expression de sécurité intégrée

Enfin, voyons comment remplacer une expression de sécurité intégrée - nous aborderons la désactivation dehasAuthority().

6.1. Racine d'expression de sécurité personnalisée

Nous commencerons de la même manière en écrivant nos propresSecurityExpressionRoot - principalement parce que les méthodes intégrées sont desfinal et que nous ne pouvons donc pas les remplacer:

public class MySecurityExpressionRoot implements MethodSecurityExpressionOperations {
    public MySecurityExpressionRoot(Authentication authentication) {
        if (authentication == null) {
            throw new IllegalArgumentException("Authentication object cannot be null");
        }
        this.authentication = authentication;
    }

    @Override
    public final boolean hasAuthority(String authority) {
        throw new RuntimeException("method hasAuthority() not allowed");
    }
    ...
}

Après avoir défini cette note fondamentale, nous devrons l'injecter dans le gestionnaire d'expression, puis câbler ce gestionnaire dans notre configuration - comme nous l'avons fait ci-dessus dans la section 5.

6.2. Exemple - Utilisation de l'expression

Maintenant, si nous voulons utiliserhasAuthority() pour sécuriser les méthodes - comme suit, il lanceraRuntimeException lorsque nous essayons d'accéder à la méthode:

@PreAuthorize("hasAuthority('FOO_READ_PRIVILEGE')")
@RequestMapping(method = RequestMethod.GET, value = "/foos")
@ResponseBody
public Foo findFooByName(@RequestParam String name) {
    return new Foo(name);
}

6.3. Test en direct

Enfin, voici notre test simple:

@Test
public void givenDisabledSecurityExpression_whenGetFooByName_thenError() {
    Response response = givenAuth("john", "123").get("http://localhost:8081/foos?name=sample");
    assertEquals(500, response.getStatusCode());
    assertTrue(response.asString().contains("method hasAuthority() not allowed"));
}

7. Conclusion

Dans ce guide, nous avons analysé en profondeur les différentes façons dont nous pouvons implémenter une expression de sécurité personnalisée dans Spring Security, si les expressions existantes ne suffisent pas.

Et, comme toujours, le code source complet peut être trouvéover on GitHub.