Spring security privilege

Vamos lá! nesse post temos um projeto com spring security e as entidades User e Role. Nesse caso, pra fazer o controle de acesso para o resource Category por exemplo, seria necessário adicionar uma anotação na classe Restcontroller ou em cada método para permitir acesso apenas de usuários que tenham uma determinada Role específica.

Num futuro bem próximo isso pode se tornar um problema por que estou criando um forte acoplamento entre resoruces de Category com uma determinada Role de usuário. Vamos supor que somente usuário da Role de nome ROLE_ADMIN possam acessar consultar categoria. Se algum dia houver uma mudança de regra de negócio e o nome da Role admin mudar ou se precisar permitir que usuários de outra role possam acessar esse mesmo recurso, ou se futuramente usuários ROLE_ADMIN não puderem mais acessar consultar categoria... Em todas essas situações será necessário alteração no código e isso poderá acontecer várias vezes. Pois regras desse tipo podem mudar muitas vezes.

Ainda tem outro problema muito comum: Não será possível adicionar apenas uma permissão específica para o usuário. Se o usuário tem a ROLE_USER por exemplo e eu quiser adicionar apenas uma permissão específica que tem em ROLE_ADMIN não será possível por que as permissões estão amarradas por role.

O Spring security já tem uma solução pronta para isso. A saída para resolver todos esses problemas é adicionar uma nova entidade para controlar os privilégios. A vantagem de usar Spring security é que ele formece várias interfaces que podemos implementar da maneira que quisermos e utuilizar apenas o que precisamos.

No nosso caso vamos criar um entidade chamada Privilage e fazer uma relação many to many com User e Role. Dessa forma, posso adicionar privilégios para uma determinada role ou se preferir conceder privilégios diretamente para o usuário. Nesse caso o usuário herda todos os privilégios da role e caso seja necessário poderá ter algum privilégio a mais. Tudo isso poderá ser feito via banco de dados sem a nacessidade de implementação no código.

 package com.br.davifelipe.springjwt.model;
import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.Collection;
import javax.persistence.Entity;
 import javax.persistence.GeneratedValue;
 import javax.persistence.GenerationType;
 import javax.persistence.Id;
 import javax.persistence.ManyToMany;
import lombok.Data;
 import lombok.ToString;
@Entity
 @Data
 @ToString(exclude="id")
 public class Privilege implements Serializable{
    private static final long serialVersionUID = 8817356608384481025L;
    @Id
     @GeneratedValue(strategy = GenerationType.AUTO)
     private Integer id;
     
     private String name;
     
     @ManyToMany(mappedBy = "privileges")
     private Collection<User> users = new ArrayList<>();
     
     @ManyToMany(mappedBy = "privileges")
     private Collection<Role> roles = new ArrayList<>();
 }
 

Model Role

 package com.br.davifelipe.springjwt.model;
import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
import javax.persistence.Entity;
 import javax.persistence.FetchType;
 import javax.persistence.GeneratedValue;
 import javax.persistence.GenerationType;
 import javax.persistence.Id;
 import javax.persistence.JoinColumn;
 import javax.persistence.JoinTable;
 import javax.persistence.ManyToMany;
import lombok.Data;
 import lombok.ToString;
@Entity
 @Data
 @ToString(exclude="id")
 public class Role implements Serializable{
     
     private static final long serialVersionUID = 1146043919291423157L;
    @Id
     @GeneratedValue(strategy = GenerationType.AUTO)
     private Integer id;
  
     private String name;
     
     @ManyToMany(mappedBy = "roles")
     private Collection<User> users = new ArrayList<>();
     
     @ManyToMany(fetch = FetchType.EAGER)
     @JoinTable(
             name = "ROLES_PRIVILAGES", 
             joinColumns = @JoinColumn(
                     name = "role_id", referencedColumnName = "id"), 
             inverseJoinColumns = @JoinColumn(
                     name = "privilege_id", referencedColumnName = "id")) 
     private List<Privilege> privileges = new ArrayList<>();
     
     public void addPrivilege(Privilege privilege) {
         this.privileges.add(privilege);
     }
     
     public void addUser(User user) {
         this.users.add(user);
     }
   
 }

No model de usuário temos que alterar a implementação no método getAutorities para retonar para o Spring security todos os objetos SimpleGrantedAuthority referente a todos os privilégios tanto do usuário como das roles do usuário.

 package com.br.davifelipe.springjwt.model;
import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
import javax.persistence.Entity;
 import javax.persistence.FetchType;
 import javax.persistence.GeneratedValue;
 import javax.persistence.GenerationType;
 import javax.persistence.Id;
 import javax.persistence.JoinColumn;
 import javax.persistence.JoinTable;
 import javax.persistence.ManyToMany;
import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.core.authority.SimpleGrantedAuthority;
import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.NoArgsConstructor;
 import lombok.ToString;
@Entity
 @Data
 @NoArgsConstructor
 @ToString(exclude="id")
 @EqualsAndHashCode(exclude={"name","email","password"})
 public class User implements Serializable{
     
     private static final long serialVersionUID = -6746994790280544901L;
     
     @Id
     @GeneratedValue(strategy = GenerationType.IDENTITY)
     private Integer id;
     private String name;
     private String email;
     private String password;
     
     @ManyToMany(fetch = FetchType.LAZY)
     @JoinTable( 
         name = "USERS_ROLES", 
         joinColumns = @JoinColumn(
           name = "user_id", referencedColumnName = "id"), 
         inverseJoinColumns = @JoinColumn(
           name = "role_id", referencedColumnName = "id")) 
     private List<Role> roles = new ArrayList<>();
     
     @ManyToMany(fetch = FetchType.EAGER)
     @JoinTable(
             name = "USERS_PRIVILAGES", 
             joinColumns = @JoinColumn(
                     name = "user_id", referencedColumnName = "id"), 
             inverseJoinColumns = @JoinColumn(
                     name = "privilege_id", referencedColumnName = "id")) 
     private List<Privilege> privileges = new ArrayList<>();
     
     public Collection<GrantedAuthority> getAutorities() {
                
         List<GrantedAuthority> authorities = new ArrayList<>();
         for (Privilege privilege : this.privileges) {
             authorities.add(new SimpleGrantedAuthority(privilege.getName()));
         }
         
         for (Role role : this.roles) {
             authorities.add(new SimpleGrantedAuthority(role.getName()));
             for (Privilege privilege : role.getPrivileges()) {
                 authorities.add(new SimpleGrantedAuthority(privilege.getName()));
             }
         }
         
         return authorities;
     }
     
     public void addPrivilege(Privilege privilage) {
         this.privileges.add(privilage);
     }
     
     public void addRole(Role role) {
         this.roles.add(role);
     }
 }
 

Lembrando que esse método é chamado em UserDatailsServiceImpl que é a nossa implementação da interface org.springframework.security.core.userdetails.UserDetailsService. 

 package com.br.davifelipe.springjwt.services;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.core.userdetails.UserDetails;
 import org.springframework.security.core.userdetails.UserDetailsService;
 import org.springframework.security.core.userdetails.UsernameNotFoundException;
 import org.springframework.stereotype.Service;
import com.br.davifelipe.springjwt.model.User;
 import com.br.davifelipe.springjwt.repositories.UserRepository;
 import com.br.davifelipe.springjwt.security.MyUserDatails;
@Service
 public class UserDatailsServiceImpl implements UserDetailsService {
     
     @Autowired
     private UserRepository repositoryUser;
     
     @Override
     public UserDetails loadUserByUsername(String username){
         Optional<User> obj = repositoryUser.findByEmail(username);
         User user = obj.orElse(null);
         if(user == null) {
             throw new UsernameNotFoundException("User not found: "+username);
         }
         
         return new MyUserDatails(user.getId(), user.getEmail(), user.getPassword(), user.getAutorities());
     }
 }
 

Essa é a forma que estamos dizendo para o Spring security como estão sendo definidas as autorities do usuário logado.

Em AuthResource vamos definir que o novo usuário cadastrado vai ter a role ROLE_USER com os privilégios CATEGORY_READ_PRIVILEGE, CATEGORY_WRITE_PRIVILEGE e CATEGORY_DELETE_PRIVILEGE vinculados diretamente a ele.

 package com.br.davifelipe.springjwt.resources;
import java.net.URI;
import javax.validation.Valid;
import org.modelmapper.ModelMapper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import com.br.davifelipe.springjwt.dto.ResetPasswordDTO;
 import com.br.davifelipe.springjwt.dto.ForgotPasswordDTO;
 import com.br.davifelipe.springjwt.dto.MessageDTO;
 import com.br.davifelipe.springjwt.dto.SingUpDTO;
 import com.br.davifelipe.springjwt.model.Privilege;
 import com.br.davifelipe.springjwt.model.ResetPasswordToken;
 import com.br.davifelipe.springjwt.model.Role;
 import com.br.davifelipe.springjwt.model.User;
 import com.br.davifelipe.springjwt.services.EmailService;
 import com.br.davifelipe.springjwt.services.PrivilegeService;
 import com.br.davifelipe.springjwt.services.ResetPasswordTokenService;
 import com.br.davifelipe.springjwt.services.RoleService;
 import com.br.davifelipe.springjwt.services.UserService;
 import com.br.davifelipe.springjwt.services.exceptions.ObjectNotFoundException;
 import com.fasterxml.jackson.core.JsonProcessingException;
@RestController
 @RequestMapping("/auth")
 public class AuthResource {
     
     @Autowired
     private UserService serviceUser;
     
     @Autowired
     private RoleService serviceRole;
     
     @Autowired
     private PrivilegeService servicePrivilege;
     
     @Autowired
     private ResetPasswordTokenService serviceResetPassword;
     
     @Autowired
     EmailService emailService;
     
     @Value("${auth.reset-password-token-expiration-miliseg}")
     private Long resetPasswordTokenExpirationMisiseg;
     
     @PostMapping("/sing-up")
     public ResponseEntity<Void> singUp(@Valid @RequestBody SingUpDTO dto){
         
         ModelMapper modelMapper = new ModelMapper();
         User user = modelMapper.map(dto,User.class);
         
         Role roleUser = serviceRole.findOrInsertByName("ROLE_USER");
         Privilege caregoryRead = servicePrivilege.findOrInsertByName("CATEGORY_READ_PRIVILEGE");
         Privilege caregoryWrite = servicePrivilege.findOrInsertByName("CATEGORY_WRITE_PRIVILEGE");
         Privilege caregoryDelete = servicePrivilege.findOrInsertByName("CATEGORY_DELETE_PRIVILEGE");
         
         user.addRole(roleUser);
         user.addPrivilege(caregoryRead);
         user.addPrivilege(caregoryWrite);
         user.addPrivilege(caregoryDelete);
         
         user = this.serviceUser.insert(user);
         
         URI uri = ServletUriComponentsBuilder
                   .fromCurrentContextPath().path("/{id}")
                   .buildAndExpand(user.getId())
                   .toUri();
         return ResponseEntity.created(uri).build();
     }
     
     @PostMapping("/reset-password")
     public ResponseEntity<MessageDTO> findById(@Valid @RequestBody ResetPasswordDTO dto) {
         
         ResetPasswordToken resetPasswordToken = serviceResetPassword.findByToken(dto.getToken());
         if(resetPasswordToken == null) {
             return ResponseEntity.status(HttpStatus.BAD_REQUEST.value()).body(
                     new MessageDTO("Invalid link","Reset password error", HttpStatus.BAD_REQUEST.value())
                     );
         }
         
         if(resetPasswordToken.isExpired(resetPasswordTokenExpirationMisiseg)) {
             return ResponseEntity.status(HttpStatus.BAD_REQUEST.value()).body(
                     new MessageDTO("Expired link","Reset password error", HttpStatus.BAD_REQUEST.value())
                     );
         }
         
         User user = resetPasswordToken.getUser();
         user.setPassword(dto.getPassword());
         serviceUser.update(user);
         //clean token to prevent that it will be used more than once
         resetPasswordToken.setToken(null);
         serviceResetPassword.update(resetPasswordToken);
         
         return ResponseEntity.ok().body(new MessageDTO("Password was changed", HttpStatus.OK.value()));
     }
     
     @PostMapping("/forgot-password")
     public ResponseEntity<MessageDTO> findById(@Valid @RequestBody ForgotPasswordDTO dto) 
             throws JsonProcessingException {
         
         User userFound = serviceUser.findByEmail(dto.getEmail());
         
         if(userFound == null) {
             throw new ObjectNotFoundException("Object "+User.class.getName()+" not found! e-mail "+dto.getEmail());
         }
         
         ResetPasswordToken resetPasswordToken = serviceUser.generateResetPasswordToken(userFound);
         emailService.sendResetPasswordToken(resetPasswordToken);
         
         StringBuilder sb = new StringBuilder();
         sb.append("An e-mail has been sent to the addres you have provided.");
         sb.append("Please follow the link in the e-mail to complete you password reset request.");
         
         return ResponseEntity.ok().body(new MessageDTO(sb.toString(),
                                                         HttpStatus.OK.value()
                                                         )
                                         );
     }
     
 }
 

E finalmente em CategoryResource vamos adicionar a anotação @PostAuthorize para permitir cada operação de acordo com privilégio concedido ao usuário logado

 package com.br.davifelipe.springjwt.resources;
import java.net.URI;
import javax.validation.Valid;
import org.modelmapper.ModelMapper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.ResponseEntity;
 import org.springframework.security.access.prepost.PostAuthorize;
 import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.PutMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import com.br.davifelipe.springjwt.dto.CategoryDTO;
 import com.br.davifelipe.springjwt.model.Category;
 import com.br.davifelipe.springjwt.services.CategoryService;
 import com.br.davifelipe.springjwt.services.exceptions.ObjectNotFoundException;
@RestController
 @RequestMapping("/category")
 public class CategoryResource {
     
     @Autowired
     private CategoryService service;
     
     @GetMapping("/{id}")
     @PostAuthorize("hasAuthority('CATEGORY_READ_PRIVILEGE')")
     public ResponseEntity<CategoryDTO> findById(@PathVariable(value="id") Integer id) {
         
         ModelMapper modelMapper = new ModelMapper();
         Category category = service.findById(id);
         
         if(category == null) {
             throw new ObjectNotFoundException("Object "+Category.class.getName()+" not found! id "+id);
         }
         
         CategoryDTO categoryDTO = modelMapper.map(category,CategoryDTO.class);
         return ResponseEntity.ok().body(categoryDTO);
     }
     
     @PostMapping()
     @PostAuthorize("hasAuthority('CATEGORY_WRITE_PRIVILEGE')")
     public ResponseEntity<Void> insert(@Valid @RequestBody CategoryDTO dto){
         
         ModelMapper modelMapper = new ModelMapper();
         Category obj = modelMapper.map(dto,Category.class);
         
         obj = this.service.insert(obj);
         URI uri = ServletUriComponentsBuilder
                   .fromCurrentRequest().path("/{id}")
                   .buildAndExpand(obj.getId())
                   .toUri();
         return ResponseEntity.created(uri).build();
     }
     
     @PutMapping("/{id}")
     @PostAuthorize("hasAuthority('CATEGORY_WRITE_PRIVILEGE')")
     public ResponseEntity<Void> update(@Valid
                                        @RequestBody CategoryDTO dto,
                                        @PathVariable(value="id") Integer id){
         
         ModelMapper modelMapper = new ModelMapper();
         Category obj = modelMapper.map(dto,Category.class);
         obj.setId(id);
         this.service.update(obj);
         return ResponseEntity.noContent().build();
     }
     
     @DeleteMapping("/{id}")
     @PostAuthorize("hasAuthority('CATEGORY_DELETE_PRIVILEGE')")
     public ResponseEntity<Void> delete(@PathVariable(value="id") Integer id) {
         service.delete(id);
         return ResponseEntity.noContent().build();
     }
     
 }
 

Basta rodar o test de integração CategoryResourceTest e conferir se está tudo funcionando

Imagem

Se preferir, pode alterar o resource sing up removendo algum privilégio e rodar no teste novamente.

Dessa forma alguns testes devem quebrar retornando acesso negado.

Repositório do projeto:

https://github.com/davifelipems/spring-backend-template/tree/spring_security_privilege

 

Comentários

 

Quem Sou

Graduado em ADS (Análise e desenvolvimento de sistemas).

Não sou "devoto" de nenhuma linguagem de programação. Procuro aproveitar o melhor de cada uma de acordo com a necessidade do projeto. Prezo por uma arquitetura bem feita, código limpo, puro e simples! 

anuncio atendente