No vasto mundo do desenvolvimento web, a autenticação é a guardiã de todos os reinos digitais. Neste tutorial veremos como proteger, autenticar e autorizar os usuários de uma aplicação Spring-Boot de forma nativa e seguindo as boas práticas da estrutura.

Usaremos as seguintes tecnologias:

  • Java 17
  • Spring-boot 3.1.5
  • JWT
  • Hibernate/JPA
  • PostgreSQL
  • lombok

Código fonte do projeto usado neste tutorial.

Primeiros passos

Para proteger nosso aplicativo, precisaremos de duas dependências em nosso pom.xml: a primeira é o pacote de segurança nativo do Spring e a outra nos ajudará a criar e validar nossos tokens jwt.

pom.xml
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
   <groupId>com.auth0</groupId>
   <artifactId>java-jwt</artifactId>
   <version>4.4.0</version>
</dependency>

Entidade e repositório do usuário

Primeiro precisaremos de um enum para representar as funções do usuário, isso nos ajudará a definir as permissões de cada usuário em nossa aplicação.

/enums/UserRole.java
public enum UserRole {
  ADMIN("admin"),
  USER("user");

  private String role;

  UserRole(String role) {
    this.role = role;
  }

  public String getValue() {
    return role;
  }
}

No enum temos dois papéis representativos: ADMIN e USER, o ADMIN terá acesso a todos os endpoints de nossa aplicação, enquanto USER terá acesso apenas a endpoints específicos.

A entidade do usuário será o núcleo de nosso sistema de autenticação, ela conterá as credenciais do usuário e as funções que o usuário possui. Implementaremos a interface UserDetails para representar nossa entidade de usuário, essa interface é fornecida pelo pacote de segurança do spring e é a maneira recomendada de representar a entidade do usuário em uma aplicação spring-boot.

/entities/UserEntity.java
@Table()
@Entity(name = "users")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(of = "id")
public class User implements UserDetails {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private String login;

  private String password;

  @Enumerated(EnumType.STRING)
  private UserRole role;

  public User(String login, String password, UserRole role) {
    this.login = login;
    this.password = password;
    this.role = role;
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    if (this.role == UserRole.ADMIN) {
      return List.of(new SimpleGrantedAuthority("ROLE_ADMIN"), new SimpleGrantedAuthority("ROLE_USER"));
    }
    return List.of(new SimpleGrantedAuthority("ROLE_USER"));
  }

  @Override
  public String getUsername() {
    return login;
  }

  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @Override
  public boolean isEnabled() {
    return true;
  }
}

UserDetails tem uma série de métodos que podemos substituir para personalizar o processo de autenticação, você também pode implementar essas propriedades no banco de dados, mas por enquanto usaremos apenas as necessárias para fazer nosso sistema de autenticação funcionar: id, username, password e role.

Para o repositório do usuário, temos o seguinte código:

/repositories/UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
  UserDetails findByLogin(String login);
}

Estendendo o JpaRepository teremos acesso a uma série de métodos para manipular nossos usuários no banco de dados. Além disso, o método findByLogin será usado pelo spring security para encontrar o usuário no banco de dados e validar as credenciais.

Token provider

Nós precisamos definir uma chave para assinar nossos tokens. Usaremos a anotação @Value para recuperar a chave secreta do application.yml. E no application.yml definiremos a chave secreta como uma variável de ambiente, isso nos ajudará a manter a chave secreta segura e fora do código-fonte.

.env
JWT_SECRET="yoursecret"

In our application.yml:

/resources/application.yml
security:
  jwt:
    token:
      secret-key: ${JWT_SECRET}

Para nossa aplicação ler as variáveis de ambiente, precisamos declarar a anotação PropertySource em nossa classe principal indicando onde está localizado o arquivo .env. No nosso caso, está localizado na raiz do projeto, então usaremos a variável user.dir para obter o caminho da raiz do projeto. A classe principal ficará assim:

SpringAuthApplication.java
@SpringBootApplication
@PropertySource("file:${user.dir}/.env")
public class SpringAuthApplication {
	public static void main(String[] args) {
		SpringApplication.run(SpringAuthApplication.class, args);
	}
}

Finalmente podemos definir a classe que será responsável por gerar e validar nossos tokens:

/config/auth/TokenProvider.java
@Service
public class TokenProvider {
  @Value("${security.jwt.token.secret-key}")
  private String JWT_SECRET;

  public String generateAccessToken(User user) {
    try {
      Algorithm algorithm = Algorithm.HMAC256(JWT_SECRET);
      return JWT.create()
          .withSubject(user.getUsername())
          .withClaim("username", user.getUsername())
          .withExpiresAt(genAccessExpirationDate())
          .sign(algorithm);
    } catch (JWTCreationException exception) {
      throw new JWTCreationException("Error while generating token", exception);
    }
  }

  public String validateToken(String token) {
    try {
      Algorithm algorithm = Algorithm.HMAC256(JWT_SECRET);
      return JWT.require(algorithm)
          .build()
          .verify(token)
          .getSubject();
    } catch (JWTVerificationException exception) {
      throw new JWTVerificationException("Error while validating token", exception);
    }
  }

  private Instant genAccessExpirationDate() {
    return LocalDateTime.now().plusHours(2).toInstant(ZoneOffset.of("-03:00"));
  }
}

Em generateAccessToken definimos o algoritmo, o subject e a data de expiração dos tokens. No método validateToken checamos se o token é válido.

Security filter

Then we need to define a filter to intercept the requests and validate the token. We'll be extending the OncePerRequestFilter spring security class to intercept the requests and validate the token. Então precisamos definir um filtro para interceptar as solicitações e validar o token. Estenderemos a classe OncePerRequestFilter do spring security para interceptar as solicitações e validar o token.

/config/auth/SecurityFilter.java
@Component
public class SecurityFilter extends OncePerRequestFilter {
  @Autowired
  TokenProvider tokenService;
  @Autowired
  UserRepository userRepository;

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {
    var token = this.recoverToken(request);
    if (token != null) {
      var login = tokenService.validateToken(token);
      var user = userRepository.findByLogin(login);
      var authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
      SecurityContextHolder.getContext().setAuthentication(authentication);
    }
    filterChain.doFilter(request, response);
  }

  private String recoverToken(HttpServletRequest request) {
    var authHeader = request.getHeader("Authorization");
    if (authHeader == null)
      return null;
    return authHeader.replace("Bearer ", "");
  }
}

Em doFilterInternal recuperamos o token da requisição, removemos o "Bearer" da string usando o método auxiliar recoverToken, validamos o token e definimos a autenticação no SecurityContextHolder. O SecurityContextHolder é uma classe do spring security que guarda a autenticação da solicitação atual, para que possamos acessar as informações do usuário nos controladores.

Auth config

Aqui precisamos definir mais alguns métodos necessários para fazer nosso sistema de autenticação funcionar. Primeiro definimos no topo da classe as anotações Configuration e @EnableWebSecurity para habilitar a segurança web em nossa aplicação. Em seguida, definimos o bean SecurityFilterChain para definir os endpoints que serão protegidos por nosso sistema de autenticação.

/config/AuthConfig.java
@Configuration
@EnableWebSecurity
public class AuthConfig {
  @Autowired
  SecurityFilter securityFilter;

  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
    return httpSecurity
        .csrf(csrf -> csrf.disable())
        .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(authorize -> authorize
            .requestMatchers(HttpMethod.POST, "/api/v1/auth/*").permitAll()
            .requestMatchers(HttpMethod.POST, "/api/v1/books").hasRole("ADMIN")
            .anyRequest().authenticated())
        .addFilterBefore(securityFilter, UsernamePasswordAuthenticationFilter.class)
        .build();
  }

  @Bean
  AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration)
      throws Exception {
    return authenticationConfiguration.getAuthenticationManager();
  }

  @Bean
  PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
}

No método authorizeHttpRequests definimos os endpoints que serão protegidos e os tipos de usuários que terão acesso a cada endpoint. Em nosso caso, os endpoints /api/v1/auth/* serão públicos, o endpoint /api/v1/books será protegido e apenas os usuários com a função ADMIN terão acesso a ele. Os outros endpoints serão protegidos e apenas os usuários autenticados terão acesso a eles.

No método addFilterBefore definimos o filtro que criamos anteriormente. E finalmente definimos os beans AuthenticationManager e PasswordEncoder que são necessários para fazer o sistema de autenticação funcionar.

Auth DTOs

Precisaremos de dois DTOs para receber as credenciais do usuário e outro DTO para retornar o token quando o usuário fizer login.

/dtos/SignUpDto.java
public record SignUpDto(
    String login,
    String password,
    UserRole role) {
}
/dtos/SignInDto.java
public record SignInDto(
    String login,
    String password) {
}
/dtos/JwtDto.java
public record JwtDto(
    String accessToken) {
}

Auth service

Aqui definimos o service implementando UserDetailsService que será responsável por criar os usuários e salvá-los no banco de dados ou carregar as informações do usuário pelo por seu nome.

/services/AuthService.java
@Service
public class AuthService implements UserDetailsService {

  @Autowired
  UserRepository repository;

  @Override
  public UserDetails loadUserByUsername(String username) {
    var user = repository.findByLogin(username);
    return user;
  }

  public UserDetails signUp(SignUpDto data) throws InvalidJwtException {
    if (repository.findByLogin(data.login()) != null) {
      throw new InvalidJwtException("Username already exists");
    }
    String encryptedPassword = new BCryptPasswordEncoder().encode(data.password());
    User newUser = new User(data.login(), encryptedPassword, data.role());
    return repository.save(newUser);
  }
}

Em signUp verificamos se o nome de usuário já está registrado, em seguida, criptografamos a senha usando o BCryptPasswordEncoder e salvamos as informações do usuário no repositório.

Auth controller

E finalmente definimos o controller. Ele será responsável por receber a solicitação, autenticar os usuários e gerar os tokens.

/controllers/AuthController.java
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
  @Autowired
  private AuthenticationManager authenticationManager;
  @Autowired
  private AuthService service;
  @Autowired
  private TokenProvider tokenService;

  @PostMapping("/signup")
  public ResponseEntity<?> signUp(@RequestBody @Valid SignUpDto data) {
    service.signUp(data);
    return ResponseEntity.status(HttpStatus.CREATED).build();
  }

  @PostMapping("/signin")
  public ResponseEntity<JwtDto> signIn(@RequestBody @Valid SignInDto data) {
    var usernamePassword = new UsernamePasswordAuthenticationToken(data.login(), data.password());
    var authUser = authenticationManager.authenticate(usernamePassword);
    var accessToken = tokenService.generateAccessToken((User) authUser.getPrincipal());
    return ResponseEntity.ok(new JwtDto(accessToken));
  }
}

No método signUp recebemos os dados do usuário, criamos um novo usuário e o salvamos no banco de dados. No método signIn recebemos as credenciais do usuário, autenticamos usando o AuthenticationManager e geramos o token.

Testando a autenticação

Para criar um novo usuário, enviamos uma requisição POST para o endpoint /api/v1/auth/signup com um corpo contendo o login, senha e uma das funções disponíveis (USER ou ADMIN):

{
  "login": "myusername",
  "password": "123456",
  "role": "USER"
}

Para conseguir um token de autenticação, enviamos outra requisição POST com as credenciais do usuário que criamos anteriormente para o endpoint /api/v1/auth/signin.

Para testar o sistema de autenticação criaremos um simples controller de livros com dois endpoints, um para criar um novo livro e outro para listar todos os livros.

@RestController
@RequestMapping("/api/v1/books")
public class BookController {

  @GetMapping
  public ResponseEntity<List<String>> findAll() {
    return ResponseEntity.ok(List.of("Book1", "Book2", "Book3"));
  }

  @PostMapping
  public ResponseEntity<String> create(@RequestBody String data) {
    return ResponseEntity.ok(data);
  }
} 

No endpoint /api/v1/books o método GET estará disponível para os usuários com a função USER, e o método POST será protegido e apenas os usuários com a função ADMIN poderão criar um livro.


Eita! Muito código né? 😅
Espero que você tenha gostado e aprendido algo novo! Se você tiver alguma dúvida ou sugestão, sinta-se à vontade para me enviar uma mensagem no Twitter/X.

Obrigado pela leitura!