Protegendo sua API REST com Spring Security e autenticando usuários com token JWT em uma aplicação Spring Boot: um tutorial prático
Neste tutorial, você aprenderá como tornar sua aplicação Spring Boot segura usando Spring Security e token JWT. O Spring Security é um framework amplamente usado para segurança em aplicativos Java, que fornece recursos para autenticação e autorização. O JSON Web Token, ou JWT, é um padrão aberto para criar tokens de autenticação seguros. A combinação do Spring Security e JWT fornece uma solução poderosa e flexível para proteger sua aplicação Sprint Boot.
Links úteis:
Tudo que eu desenvolvi neste tutorial está disponível no seguinte repositório do GitHub: https://github.com/lipeacelino/spring-boot-jwt-security.
Também criei uma collection no Postman com todas as requisições necessárias para você poder testar os endpoints que você irá criar. Basta fazer o download e importar no seu postman: https://github.com/lipeacelino/spring-boot-jwt-security/blob/master/spring-boot-jwt-security.postman_collection.json
A aplicação que desenvolveremos é parte de um outro projeto que fiz, mas que não é essencial para este tutorial.
Apesar disso, há muitas coisas legais que foram desenvolvidas nele, e se você quiser vê-lo, pode encontrá-lo aqui: https://github.com/lipeacelino/pizzurg-api
Sumário
- Criando o projeto com as dependências necessárias
- Criando as entidades do usuário
- Implementando as interfaces UserDetails
- Configurando a criação e validação do token JWT
- Definindo a classe de configuração de segurança do Spring Security
- Criando um filtro de segurança personalizado
- Definindo as demais configurações de segurança do Spring Security
- Criando a estrutura para testarmos as configuração de segurança que criamos
- Testando a autenticação e autorização da nossa aplicação
1. Criando o projeto com as dependências necessárias
Acesse o sprint initializr e crie um projeto com as seguintes dependências:
Para garantir que tudo funcione corretamente é necessário:
- Java 17 (no mínimo)
- Utilizar o Spring Boot 3.x.x ou superior. Ao utilizar o Spring Boot na versão 3.x.x é obrigatório que a sua versão do Java seja no mínimo a 17.
2. Criando as entidades do usuário
Agora nós iremos criar as entidades relacionadas ao usuário:
1. Criaremos o enum RoleName que representará o nome dos papéis que um usuário pode ter:
public enum RoleName {
ROLE_CUSTOMER,
ROLE_ADMINISTRATOR
}
2. Criaremos a entidade Role que representa o papel de um usuário:
@Entity
@Table(name="roles")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
private RoleName name;
}
3. Criaremos a entidade User que representa um usuário:
@Table(name = "users")
@Entity(name = "User")
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String email;
private String password;
@ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST)
@JoinTable(name="users_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name="role_id"))
private List<Role> roles;
}
4. Também é importante criarmos agora o repositório da nossa classe que representa um usuário, porque na próxima seção do tutorial nós iremos precisar dela. Criaremos então, o UserRepository:
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
3. Implementando as interfaces UserDetails
Para configurar a autenticação baseada em token em sua API REST com Spring Security, é importante definir como os detalhes do usuário serão obtidos e validados. Para isso, o Spring Security fornece duas interfaces importantes: UserDetails e UserDetailsService.
O UserDetails é uma interface que representa os detalhes do usuário, como seu nome de usuário, senha e autorizações. Já o UserDetailsService é uma interface que retorna um UserDetails com base no nome de usuário fornecido. Juntas, essas interfaces fornecem uma maneira de obter e validar os detalhes do usuário durante o processo de autenticação.
- Criaremos uma classe chamada UserDetailsImpl que implementa a classe UserDetails:
@Getter
public class UserDetailsImpl implements UserDetails {
private User user; // Classe de usuário que criamos anteriormente
public UserDetailsImpl(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
/*
Este método converte a lista de papéis (roles) associados ao usuário
em uma coleção de GrantedAuthorities, que é a forma que o Spring Security
usa para representar papéis. Isso é feito mapeando cada papel para um
novo SimpleGrantedAuthority, que é uma implementação simples de
GrantedAuthority
*/
return user.getRoles()
.stream()
.map(role -> new SimpleGrantedAuthority(role.getName().name()))
.collect(Collectors.toList());
}
@Override
public String getPassword() {
return user.getPassword();
} // Retorna a credencial do usuário que criamos anteriormente
@Override
public String getUsername() {
return user.getEmail();
} // Retorna o nome de usuário do usuário que criamos anteriormente
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
2. Criaremos uma classe chamada UserDetailsServiceImpl que implementa a classe UserDetailsService:
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByEmail(username).orElseThrow(() -> new RuntimeException("Usuário não encontrado."));
return new UserDetailsImpl(user);
}
}
O método loadUserByUsername() é um método da interface UserDetailsService, e é usado para carregar os detalhes do usuário com base no nome de usuário fornecido. Esse método é chamado automaticamente pelo Spring durante o processo de autenticação, e é responsável por retornar um UserDetails com base no nome de usuário fornecido.
4. Configurando a criação e validação do token JWT
Para implementar a autenticação baseada em token em sua API REST, você precisa de uma maneira segura de gerar e validar os tokens. Uma das maneiras mais populares de fazer isso é usando a biblioteca Auth0, que fornece uma solução completa e confiável para gerar e verificar tokens JWT.
Para isso, nós teremos que adicionar a dependência do Auth0 ao pom.xml do nosso projeto:
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
Após adicionarmos a dependência do Auth0, agora nós criaremos uma classe chamada JwtTokenService com os métodos necessários para gerar um token e recuperar um usuário a partir de um token:
@Service
public class JwtTokenService {
private static final String SECRET_KEY = "4Z^XrroxR@dWxqf$mTTKwW$!@#qGr4P"; // Chave secreta utilizada para gerar e verificar o token
private static final String ISSUER = "pizzurg-api"; // Emissor do token
public String generateToken(UserDetailsImpl user) {
try {
// Define o algoritmo HMAC SHA256 para criar a assinatura do token passando a chave secreta definida
Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
return JWT.create()
.withIssuer(ISSUER) // Define o emissor do token
.withIssuedAt(creationDate()) // Define a data de emissão do token
.withExpiresAt(expirationDate()) // Define a data de expiração do token
.withSubject(user.getUsername()) // Define o assunto do token (neste caso, o nome de usuário)
.sign(algorithm); // Assina o token usando o algoritmo especificado
} catch (JWTCreationException exception){
throw new JWTCreationException("Erro ao gerar token.", exception);
}
}
public String getSubjectFromToken(String token) {
try {
// Define o algoritmo HMAC SHA256 para verificar a assinatura do token passando a chave secreta definida
Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
return JWT.require(algorithm)
.withIssuer(ISSUER) // Define o emissor do token
.build()
.verify(token) // Verifica a validade do token
.getSubject(); // Obtém o assunto (neste caso, o nome de usuário) do token
} catch (JWTVerificationException exception){
throw new JWTVerificationException("Token inválido ou expirado.");
}
}
private Instant creationDate() {
return ZonedDateTime.now(ZoneId.of("America/Recife")).toInstant();
}
private Instant expirationDate() {
return ZonedDateTime.now(ZoneId.of("America/Recife")).plusHours(4).toInstant();
}
}
5. Definindo a classe de configuração de segurança do Spring Security
Antes de irmos para a próxima etapa, é importante definirmos os nossos endpoints que não requerem autenticação, e essa configuração nós fazemos na classe de configuração do Spring Security. Criaremos então a classe SecurityConfiguration, e por hora definiremos apenas os endpoints que não requerem autenticação:
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
public static final String [] ENDPOINTS_WITH_AUTHENTICATION_NOT_REQUIRED = {
"/users/login", //url que usaremos para fazer login
"/users" //url que usaremos para criar um usuário
};
}
Entendendo algumas anotações:
@Configuration: Esta é uma anotação do Spring que indica que a classe anotada é uma classe de configuração. As classes de configuração podem conter métodos anotados com @Bean, que são usados para instanciar, configurar e inicializar objetos a serem gerenciados pelo contêiner Spring.
@EnableWebSecurity: Esta anotação é usada para ativar a segurança da web no projeto Spring Boot. Essa anotação sinaliza ao Spring que a classe anotada deve ser usada para a configuração do Spring Security. Isso permite ao desenvolvedor personalizar as regras de segurança, como regras de autenticação e autorização para rotas específicas, bem como outras configurações de segurança.
6. Criando um filtro de segurança personalizado
Agora nós iremos criar um filtro personalizado para verificar se o usuário é um usuário válido e autenticá-lo. O filtro usará o UserRepository e JwtTokenService que implementamos para encontrar e verificar se o usuário é válido e autenticá-lo.
Criaremos a classe UserAuthenticationFilter que extende a classe OncePerRequestFilter e sobrescreve o método doFilterInternal():
@Component
public class UserAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenService jwtTokenService; // Service que definimos anteriormente
@Autowired
private UserRepository userRepository; // Repository que definimos anteriormente
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// Verifica se o endpoint requer autenticação antes de processar a requisição
if (checkIfEndpointIsNotPublic(request)) {
String token = recoveryToken(request); // Recupera o token do cabeçalho Authorization da requisição
if (token != null) {
String subject = jwtTokenService.getSubjectFromToken(token); // Obtém o assunto (neste caso, o nome de usuário) do token
User user = userRepository.findByEmail(subject).get(); // Busca o usuário pelo email (que é o assunto do token)
UserDetailsImpl userDetails = new UserDetailsImpl(user); // Cria um UserDetails com o usuário encontrado
// Cria um objeto de autenticação do Spring Security
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetails.getUsername(), null, userDetails.getAuthorities());
// Define o objeto de autenticação no contexto de segurança do Spring Security
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
throw new RuntimeException("O token está ausente.");
}
}
filterChain.doFilter(request, response); // Continua o processamento da requisição
}
// Recupera o token do cabeçalho Authorization da requisição
private String recoveryToken(HttpServletRequest request) {
String authorizationHeader = request.getHeader("Authorization");
if (authorizationHeader != null) {
return authorizationHeader.replace("Bearer ", "");
}
return null;
}
// Verifica se o endpoint requer autenticação antes de processar a requisição
private boolean checkIfEndpointIsNotPublic(HttpServletRequest request) {
String requestURI = request.getRequestURI();
return !Arrays.asList(SecurityConfiguration.ENDPOINTS_WITH_AUTHENTICATION_NOT_REQUIRED).contains(requestURI);
}
}
7. Definindo as demais configurações de segurança do Spring Security
Agora nós voltaremos a classe SecurityConfiguration que criamos anteriormente, e adicionaremos o restante das configurações que faltavam:
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Autowired
private UserAuthenticationFilter userAuthenticationFilter;
public static final String [] ENDPOINTS_WITH_AUTHENTICATION_NOT_REQUIRED = {
"/users/login", // Url que usaremos para fazer login
"/users" // Url que usaremos para criar um usuário
};
// Endpoints que requerem autenticação para serem acessados
public static final String [] ENDPOINTS_WITH_AUTHENTICATION_REQUIRED = {
"/users/test"
};
// Endpoints que só podem ser acessador por usuários com permissão de cliente
public static final String [] ENDPOINTS_CUSTOMER = {
"/users/test/customer"
};
// Endpoints que só podem ser acessador por usuários com permissão de administrador
public static final String [] ENDPOINTS_ADMIN = {
"/users/test/administrator"
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity.csrf().disable() // Desativa a proteção contra CSRF
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // Configura a política de criação de sessão como stateless
.and().authorizeHttpRequests() // Habilita a autorização para as requisições HTTP
.requestMatchers(ENDPOINTS_WITH_AUTHENTICATION_NOT_REQUIRED).permitAll()
.requestMatchers(ENDPOINTS_WITH_AUTHENTICATION_REQUIRED).authenticated()
.requestMatchers(ENDPOINTS_ADMIN).hasRole("ADMINISTRATOR") // Repare que não é necessário colocar "ROLE" antes do nome, como fizemos na definição das roles
.requestMatchers(ENDPOINTS_CUSTOMER).hasRole("CUSTOMER")
.anyRequest().denyAll()
// Adiciona o filtro de autenticação de usuário que criamos, antes do filtro de segurança padrão do Spring Security
.and().addFilterBefore(userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Repare que foram adicionados três métodos a essa classe, são eles:
- Primeiro, é importante notar que as três classes estão anotadas com @Bean o que significa que esses métodos retornam objetos que são gerenciados pelo contâiner do spring.
- securityFilterChain(): Este método cria uma SecurityFilterChain, que é a configuração principal de segurança do Spring Security para a aplicação. Ele define a política de autorização para os endpoints da API REST.
- authenticationManager(): Este método retorna uma instância de AuthenticationManager. Essa instância é utilizada pelo Spring Security para realizar a autenticação de um usuário.
- passwordEncoder(): Este método retorna uma instância de PasswordEnconder que é utilizada pelo Spring Security para codificar as senhas dos usuários de forma segura, protegendo as informações confidenciais. No tutorial, estamos usando o algoritmo bcrypt para codificar essas senhas.
8. Criando a estrutura para testarmos as configuração de segurança que criamos
Terminamos as configurações de segurança, agora temos que implementar os endpoints e a lógica da nossa aplicação:
- Começaremos pelos DTOs que criaremos usando o tipo Record:
public record CreateUserDto(
String email,
String password,
RoleName role
) {
}
public record LoginUserDto(
String email,
String password
) {
}
public record RecoveryJwtTokenDto(
String token
) {
}
public record RecoveryUserDto(
Long id,
String email,
List<Role> roles
) {
}
2. Agora implementaremos os controller:
@RestController
@RequestMapping("/users")
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/login")
public ResponseEntity<RecoveryJwtTokenDto> authenticateUser(@RequestBody LoginUserDto loginUserDto) {
RecoveryJwtTokenDto token = userService.authenticateUser(loginUserDto);
return new ResponseEntity<>(token, HttpStatus.OK);
}
@PostMapping
public ResponseEntity<Void> createUser(@RequestBody CreateUserDto createUserDto) {
userService.createUser(createUserDto);
return new ResponseEntity<>(HttpStatus.CREATED);
}
@GetMapping("/test")
public ResponseEntity<String> getAuthenticationTest() {
return new ResponseEntity<>("Autenticado com sucesso", HttpStatus.OK);
}
@GetMapping("/test/customer")
public ResponseEntity<String> getCustomerAuthenticationTest() {
return new ResponseEntity<>("Cliente autenticado com sucesso", HttpStatus.OK);
}
@GetMapping("/test/administrator")
public ResponseEntity<String> getAdminAuthenticationTest() {
return new ResponseEntity<>("Administrador autenticado com sucesso", HttpStatus.OK);
}
}
Nós Criamos três métodos para endpoints diferentes: getAuthenticationTest(), getCustomerAuthenticationTest(), getAdminAuthenticationTest(), que respectivamente, permitem acesso com um usuário autenticado, um usuário autenticado com permissão de cliente, e um usuário autenticado com permissão administrador.
3. E por fim o service:
@Service
public class UserService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtTokenService jwtTokenService;
@Autowired
private UserRepository userRepository;
@Autowired
private SecurityConfiguration securityConfiguration;
// Método responsável por autenticar um usuário e retornar um token JWT
public RecoveryJwtTokenDto authenticateUser(LoginUserDto loginUserDto) {
// Cria um objeto de autenticação com o email e a senha do usuário
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(loginUserDto.email(), loginUserDto.password());
// Autentica o usuário com as credenciais fornecidas
Authentication authentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
// Obtém o objeto UserDetails do usuário autenticado
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
// Gera um token JWT para o usuário autenticado
return new RecoveryJwtTokenDto(jwtTokenService.generateToken(userDetails));
}
// Método responsável por criar um usuário
public void createUser(CreateUserDto createUserDto) {
// Cria um novo usuário com os dados fornecidos
User newUser = User.builder()
.email(createUserDto.email())
// Codifica a senha do usuário com o algoritmo bcrypt
.password(securityConfiguration.passwordEncoder().encode(createUserDto.password()))
// Atribui ao usuário uma permissão específica
.roles(List.of(Role.builder().name(createUserDto.role()).build()))
.build();
// Salva o novo usuário no banco de dados
userRepository.save(newUser);
}
}
9. Testando a autenticação e autorização da nossa aplicação
Agora nós podemos testar nosso código no Postman e verificar a autenticação e autorização que configuramos na nossa aplicação:
1. Criando um usuário:
2. Logando e obtendo um token JWT:
3. Testando endpoint com autorização “comum” (que tanto o cliente quanto o administrador podem acessar), para isso nós copiamos o token gerado na requisição anterior sem as aspas, e colamos no Authorization:
4. Testando endpoint com autorização apenas para usuários que têm permissão de cliente:
5. Testando endpoint com autorização apenas para usuários que têm permissão de administrador utilizando o token de um cliente:
Se você chegou até aqui, você concluiu o tutorial com sucesso :)