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 extends GrantedAuthority> 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.