Uma expressão de segurança personalizada com Spring Security

Uma expressão de segurança personalizada com Spring Security

1. Visão geral

Neste tutorial, vamos nos concentrar emcreating a custom security expression with Spring Security.

Às vezes,the expressions available in the framework simplesmente não são expressivos o suficiente. E, nesses casos, é relativamente simples construir uma nova expressão que seja semanticamente mais rica do que as existentes.

Discutiremos primeiro como criar umPermissionEvaluator personalizado, depois uma expressão totalmente personalizada - e, finalmente, como substituir uma das expressões de segurança integradas.

2. Uma Entidade de Usuário

Primeiro, vamos preparar a base para a criação de novas expressões de segurança.

Vamos dar uma olhada em nossa entidadeUser - que tem umPrivileges e umOrganization:

@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
}

E aqui está nossoPrivilege simples:

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

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

    // standard getters and setters
}

E nossoOrganization:

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

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

    // standard setters and getters
}

Finalmente - usaremos umPrincipal personalizado mais simples:

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;
    }

    ...
}

Com todas essas classes prontas, vamos usar nossoPrincipal personalizado em uma implementação básica 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);
    }
}

Como você pode ver, não há nada de complicado nesses relacionamentos - o usuário tem um ou mais privilégios e cada usuário pertence a uma organização.

3. Configuração de dados

A seguir - vamos inicializar nosso banco de dados com dados de teste simples:

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

    @Autowired
    private PrivilegeRepository privilegeRepository;

    @Autowired
    private OrganizationRepository organizationRepository;

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

Aqui estão nossos métodosinit:

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);
}

Observe que:

  • O usuário “john” tem apenasFOO_READ_PRIVILEGE

  • O usuário “tom” temFOO_READ_PRIVILEGE eFOO_WRITE_PRIVILEGE

4. Um avaliador de permissão personalizado

Neste ponto, estamos prontos para começar a implementar nossa nova expressão - por meio de um novo avaliador de permissão personalizado.

Vamos usar os privilégios do usuário para proteger nossos métodos - mas em vez de usar nomes de privilégio codificados, queremos alcançar uma implementação mais aberta e flexível.

Vamos começar.

4.1. PermissionEvaluator

Para criar nosso próprio avaliador de permissão personalizado, precisamos implementar a 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());
    }
}

Aqui está o nosso métodohasPrivilege():

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;
}

Agora temos uma nova expressão de segurança disponível e pronta para ser usada:hasPermission.

E assim, em vez de usar a versão mais codificada:

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

Podemos usar o uso:

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

or

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

Nota:#id se refere ao parâmetro do método e 'Foo' se refere ao tipo de objeto de destino.

4.2. Configuração de Segurança do Método

Não é suficiente definir oCustomPermissionEvaluator - também precisamos usá-lo em nossa configuração de segurança do método:

@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. Exemplo na prática

Vamos agora começar a usar a nova expressão - em alguns métodos simples de controle:

@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;
    }
}

E lá vamos nós - estamos prontos e usando a nova expressão na prática. __

4.4. O teste ao vivo

Vamos agora escrever testes simples ao vivo - acessando a API e garantindo que tudo esteja funcionando bem:

@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"));
}

E aqui está nosso métodogivenAuth():

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. Uma nova expressão de segurança

Com a solução anterior, pudemos definir e usar a expressãohasPermission - que pode ser bastante útil.

No entanto, ainda estamos um tanto limitados aqui pelo nome e semântica da própria expressão.

E então, nesta seção, vamos personalizar totalmente - e vamos implementar uma expressão de segurança chamadaisMember() - verificando se o principal é um membro de uma organização. __

5.1. Expressão de segurança de método personalizado

Para criar essa nova expressão customizada, precisamos começar implementando a nota raiz onde a avaliação de todas as expressões de segurança começa:

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();
    }

    ...
}

Agora, como fornecemos essa nova operação bem na nota raiz aqui; isMember() é usado para verificar se o usuário atual é um membro em determinadoOrganization.

Observe também como estendemosSecurityExpressionRoot para incluir também as expressões embutidas.

5.2. Manipulador de expressão personalizado

Em seguida, precisamos injetar nossoCustomMethodSecurityExpressionRoot em nosso manipulador de expressão:

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. Configuração de Segurança do Método

Agora, precisamos usar nossoCustomMethodSecurityExpressionHandler na configuração de segurança do método:

@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. Usando a Nova Expressão

Aqui está um exemplo simples para proteger nosso método de controlador usandoisMember():

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

5.5. Teste ao vivo

Finalmente, aqui está um teste simples ao vivo para o usuário “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. Desativar uma expressão de segurança integrada

Finalmente, vamos ver como substituir uma expressão de segurança embutida - discutiremos a desativação dehasAuthority().

6.1. Raiz de expressão de segurança personalizada

Começaremos de forma semelhante escrevendo nosso próprioSecurityExpressionRoot - principalmente porque os métodos integrados sãofinale não podemos substituí-los:

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");
    }
    ...
}

Depois de definir essa nota raiz, teremos que injetá-la no manipulador de expressão e, em seguida, conectar esse manipulador em nossa configuração - assim como fizemos acima na Seção 5.

6.2. Exemplo - usando a expressão

Agora, se quisermos usarhasAuthority() para proteger métodos - da seguinte forma, ele lançaráRuntimeException quando tentarmos acessar o método:

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

6.3. Teste ao vivo

Finalmente, aqui está o nosso teste simples:

@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. Conclusão

Neste guia, mergulhamos profundamente nas várias maneiras de implementar uma expressão de segurança personalizada no Spring Security, se as existentes não forem suficientes.

E, como sempre, o código-fonte completo pode ser encontradoover on GitHub.