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
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.
<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.javapublic 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.
@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.javapublic 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.
JWT_SECRET="yoursecret"
In our 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:
@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.
@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.
@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.javapublic 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.
@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!