O Spring Security é o módulo que fornece suporte para autenticação, autorização e proteção contra ataques comuns em aplicações Web. A autenticação é como verificamos a identidade de usuários para termos acesso às funcionalidades de um sistema. Uma maneira comum de autenticação é a verificação de nome e senha fornecidos pelo usuário.

Depois que a autenticação é realizada, podemos executar a autorização, que é a permissão de acesso a recursos conforme um determinado papel que o usuário autenticado desempenha no sistema.

Neste Codelab você aprenderá como adicionar autenticação e autorização a uma aplicação Java Web utilizando o framework Spring Boot com Thymeleaf e acesso a dados com JdbcTemplate.

O que você vai aprender

Repositório Github

A aplicação consiste de uma página web para gerenciar um condomínio. (consulte este documento para mais detalhes). Teremos três papéis que os usuários poderão desempenhar na aplicação:

Papel

Autorização

USUARIO

permissão para visualizar os relatórios de apartamentos e proprietários.

MORADOR

permissão para visualizar os relatórios de apartamentos e proprietários e criar novos registros.

ADMIN

todas as permissões.

Para representar as informações de usuários e suas permissões vamos usar o seguinte modelo de dados:

A tabela de usuários armazena suas credenciais para autenticação e a tabela de papeis armazena suas autorizações (permissões). O relacionamento de entidade entre usuários e papeis é muitos-para-muitos porque um usuário pode ter um ou mais papeis e um papel pode ser atribuído a um ou mais usuários. É por isso que precisamos ter a tabela intermediária usuarios_papeis para representar esse relacionamento.

Script de criação do banco de dados:

CREATE TABLE IF NOT EXISTS `papeis` (
  `papel_id` INT(11) NOT NULL AUTO_INCREMENT,
  `nome` VARCHAR(45) NOT NULL,
  PRIMARY KEY (`papel_id`))
ENGINE = InnoDB
DEFAULT CHARACTER SET = utf8;

CREATE TABLE IF NOT EXISTS `usuarios` (
  `usuario_id` INT(11) NOT NULL AUTO_INCREMENT,
  `email` VARCHAR(45) NOT NULL,
  `senha` VARCHAR(64) NOT NULL,
  PRIMARY KEY (`usuario_id`))
ENGINE = InnoDB
DEFAULT CHARACTER SET = utf8;

CREATE TABLE IF NOT EXISTS `usuarios_papeis` (
  `usuario_id` INT(11) NOT NULL,
  `papel_id` INT(11) NOT NULL,
  CONSTRAINT `papel_fk`
    FOREIGN KEY (`papel_id`)
    REFERENCES `papeis` (`papel_id`),
  CONSTRAINT `user_fk`
    FOREIGN KEY (`usuario_id`)
    REFERENCES `usuarios` (`usuario_id`))
ENGINE = InnoDB
DEFAULT CHARACTER SET = utf8;

-- Script de inserção de dados
INSERT INTO `papeis` (`papel_id`, `nome`) VALUES (1, 'USUARIO');
INSERT INTO `papeis` (`papel_id`, `nome`) VALUES (2, 'MORADOR');
INSERT INTO `papeis` (`papel_id`, `nome`) VALUES (3, 'ADMIN');

INSERT INTO `usuarios` (`usuario_id`, `email`, `senha`) VALUES (1, 'mercurio@teste.com', '$2a$10$wSa39/yk/UTovsqPt817X.c0I8xlS2s76YQy4ViDxag0mlxUoYUq2');
INSERT INTO `usuarios` (`usuario_id`, `email`, `senha`) VALUES (2, 'venus@teste.com', '$2a$10$v8Wr0mf6HgmIG0ANimKJOuOIt/09qIkXIF7wCwzq8.U/LTqTs9ovq');
INSERT INTO `usuarios` (`usuario_id`, `email`, `senha`) VALUES (3, 'terra@teste.com', '$2a$10$5sci59bfdcED4XxxuN9gx.SJBPsdNknirJSkLbTCouf2mFzLmX/Gi');
INSERT INTO `usuarios` (`usuario_id`, `email`, `senha`) VALUES (4, 'marte@teste.com', '$2a$10$Wl1gojjJgFhXztvHIULT3e0hiEMrDbCWCys0p6LnfrqxcxYkgh9OW');
INSERT INTO `usuarios` (`usuario_id`, `email`, `senha`) VALUES (5, 'jupiter@teste.com', '$2a$10$5sci59bfdcED4XxxuN9gx.SJBPsdNknirJSkLbTCouf2mFzLmX/Gi');

INSERT INTO `usuarios_papeis` (`usuario_id`, `papel_id`) VALUES (1, 1); -- usuário mercurio tem papel USUARIO
INSERT INTO `usuarios_papeis` (`usuario_id`, `papel_id`) VALUES (2, 2); -- usuário venus tem papel MORADOR
INSERT INTO `usuarios_papeis` (`usuario_id`, `papel_id`) VALUES (3, 2); -- usuário terra tem papel MORADOR
INSERT INTO `usuarios_papeis` (`usuario_id`, `papel_id`) VALUES (3, 1); -- usuário terra tem papel USUARIO
INSERT INTO `usuarios_papeis` (`usuario_id`, `papel_id`) VALUES (4, 1); -- usuário marte tem papel USUARIO
INSERT INTO `usuarios_papeis` (`usuario_id`, `papel_id`) VALUES (5, 3); -- usuário jupiter tem papel ADMIN

Observe que as senhas dos usuários estão criptografadas. A criptografia de senha é necessário para aumentar a segurança da aplicação. Vamos usar o algoritmo bcrypt para criptografar as senhas. Note que cada usuário tem um código diferente para a senha, no entanto, todas as senhas têm o mesmo valor (123456). Para gerar este códigos você pode usar este website. O Spring Security fornece uma implementação deste algoritmo para codificar as senhas cadastradas pelo usuário. Como não teremos a implementação de CRUD para usuários neste codelab, vamos inserir manualmente os dados de usuários usando o script mostrado acima.

Para adicionar segurança à aplicação vamos adicionar as seguintes dependências ao arquivo pom.xml

<dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
        <groupId>org.thymeleaf.extras</groupId>
        <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

Observação: Em um caso de uso de cadastro de usuário, deve-se adicionar uma etapa de criptografia na senha para adicionar segurança, veja o exemplo abaixo:

@PostMapping(value = "novo-usuario")
  public String cadastraNovoUsuario(Usuario usuario) {
    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    String encodedPassword = passwordEncoder.encode(usuario.getSenha());
    usuario.setSenha(encodedPassword);
    usuarioRepository.gravaUsuario(usuario);
    return "redirect:/usuarios";
}

Vamos usar a autenticação de nome de usuário / senha, que utiliza um formulário HTML para obter do usuário um nome e uma senha para autenticação.

Fonte: spring.io

Quando a página de login é especificada na configuração do Spring Security, você é responsável por renderizar a página.

Existem alguns pontos-chave sobre o formulário HTML padrão:

Primeiro vamos adicionar uma nova pasta (security) à nossa estrutura do projeto:

Antes de iniciar a codificação da autenticação, precisamos adicionar duas classes na camada model e uma classe na camada repository

As seguintes classes representam os dados persistentes (camada model):

Arquivo model/Papel.java

package com.professorangoti.condominio.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Papel {
  private Long id;
  private String nome;
}

Arquivo model/Usuario.java

package com.professorangoti.condominio.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Usuario {
  private Long id;
  private String email, senha,  papeis;
}

e a classe de acesso a dados do usuario repository/UsuarioRepository.java:

package com.professorangoti.condominio.repository;

import java.sql.ResultSet;
import java.sql.SQLException;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import com.professorangoti.condominio.model.Usuario;

@Repository
public class UsuarioRepository {
  @Autowired
  JdbcTemplate jdbc;

  public Usuario buscaPorEmail(String email) {
    String sql = "SELECT usuarios.*, group_concat(distinct papeis.nome separator ', ') papeis FROM usuarios "
        + "inner join usuarios_papeis ON usuarios.usuario_id = usuarios_papeis.usuario_id "
        + "inner JOIN papeis ON usuarios_papeis.papel_id = papeis.papel_id "
        + "where email = ? "
        + "group by usuarios.usuario_id, usuarios.email;";
    return jdbc.queryForObject(sql, this::mapper, email);
  }

  private Usuario mapper(ResultSet registro, int linha) throws SQLException {
    return new Usuario(registro.getLong("usuario_id"), 
                       registro.getString("email"), 
                       registro.getString("senha"), 
                       registro.getString("papeis"));
  }
}

Configurando a segurança

Como as credenciais do usuário estão armazenadas em banco de dados com a senha criptografada, precisamos configurar um Bean DaoAuthenticationProvider que usa um UserDetailsService e PasswordEncoder para autenticar um nome de usuário e senha.

Veja em DaoAuthenticationProvider :: Spring Security

Fonte: spring.io

Arquivo security/CondominioSecurity.java

package com.professorangoti.condominio.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class CondominioSecurity {

  @Bean
  public UserDetailsService userDetailsService() {
    return new CondominioUserDetailsService();
  }

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

  @Bean
  public DaoAuthenticationProvider authenticationProvider() {
    DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
    authProvider.setUserDetailsService(userDetailsService());
    authProvider.setPasswordEncoder(passwordEncoder());
    return authProvider;
  }

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(authorize -> authorize
        .antMatchers("/","/home").permitAll()
        .anyRequest().authenticated())
        .formLogin(form -> form.loginPage("/login").permitAll())
        .logout().logoutSuccessUrl("/");
    return http.build();
  }
}

Implementar a interface UserDetails

Arquivo security/CondominioUserDetails.java

package com.professorangoti.condominio.security;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import com.professorangoti.condominio.model.Usuario;

public class CondominioUserDetails implements UserDetails {

  private Usuario usuario;

  public CondominioUserDetails(Usuario usuario) {
    this.usuario = usuario;
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    String[] papeis = usuario.getPapeis().split(",");
    List<SimpleGrantedAuthority> authorities = new ArrayList<>();
    for (String papel : papeis) {
      authorities.add(new SimpleGrantedAuthority(papel));
    }
    return authorities;
  }

  @Override
  public String getPassword() {
    return usuario.getSenha();
  }

  @Override
  public String getUsername() {
    return usuario.getEmail();
  }

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

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

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

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

Implementar a interface UserDetailsService

Arquivo security/CondominioUserDetailsService.java

package com.professorangoti.condominio.security;


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 com.professorangoti.condominio.model.Usuario;
import com.professorangoti.condominio.repository.UsuarioRepository;


public class CondominioUserDetailsService implements UserDetailsService {

  @Autowired
  private UsuarioRepository usuarioRepository;

  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    Usuario usuario = usuarioRepository.buscaPorEmail(username);
    if (usuario == null) {
      throw new UsernameNotFoundException("Usuário não autenticado!");
    }
    System.out.println(usuario.getPapeis());
    return new CondominioUserDetails(usuario);
  }
}

O método SecurityFilterChain filterChain(HttpSecurity http) estabelece as regras de segurança da aplicação. O objeto http é usado para escrever as regras da autorização e autenticação. Usamos o método authorizeHttpRequests para restringir o acesso com base na URL da requisição HTTP. As regras são aplicadas na ordem apresentada.

Regra

Autorização

antMatchers("/","/home").permitAll()

autoriza o acesso livre aos recursos mapeados com a URL "/" ou "/home"

anyRequest().authenticated())

qualquer outra requisição precisa de autenticação

formLogin(form -> form.loginPage("/login").permitAll())

configura a URL da página de login a ser exibida. Necessita definir um controle mapeado com GET /login.

logout().logoutSuccessUrl("/")

configura a página a ser exibida após o logout.

Adicionar um controle para /login

Inserir este método no arquivo HomeController.java

@GetMapping("/login")
    public String login() {
        return "login";
}

Adicionar um template com formulário para o login

Arquivo resources/templates/login.html

<!DOCTYPE html>
<html th:include="template :: modelo">
<div th:fragment="conteudo">
  <nav aria-label="breadcrumb">
    <ol class="breadcrumb">
      <li class="breadcrumb-item"><a href="/home">Home</a></li>
      <li class="breadcrumb-item active" aria-current="page">Autenticação (login)</li>
    </ol>
  </nav>
  <div th:if="${param.error}">
    <h6 class="alert alert-danger">Usuario/senha inválidos</h6>
  </div>
  <form th:action="@{/login}" method="post">
    <div class="mb-3">
      <label class="form-label">Email</label>
      <input class="form-control" type="text" name="username" placeholder="E-mail" />
    </div>
    <div class="mb-3">
      <label class="form-label">Senha</label>
      <input class="form-control" type="password" name="password" placeholder="Senha" />
    </div>
    <input class="btn btn-primary" type="submit" value="Log in" />
  </form>
</div>

</html>

Alteração do fragmento da barra de navegação para adicionar botão de logout e saudação do usuário logado

Arquivo resources/templates/template.html

 <!-- Alteração do fragmento da barra de navegação para adicionar botão de logout e saudação do usuário logado -->
            <div class="d-flex col-2" sec:authorize="isAuthenticated()">
                <span class="text-white small" sec:authentication="name">opa</span>
            </div>
            <div class="align-self-center">
                <form th:action="@{/logout}" method="post" sec:authorize="isAuthenticated()">
                    <input class="btn btn-secondary btn-sm p-1 small" type="submit" value="Sair" />
                </form>
                <a href="/login" sec:authorize="!isAuthenticated()" class="btn btn-secondary btn-sm p-1 small">Entrar</a>
            </div>
            <!-- Alteração do fragmento da barra de navegação para adicionar botão de logout e saudação do usuário logado -->

A autorização consiste em estabelecer regras relacionando usuários com permissões de acesso a recursos da aplicação. Os recursos de uma aplicação Spring Web MVC consistem em mapear URLs e métodos de uma classe da camada controller.

Tabela de configuração de autorização

#

Endpoint

Perfil permitido

1

/upload

MORADOR, ADMIN

1

/cad_apto

MORADOR, ADMIN

1

/cad_prop

MORADOR, ADMIN

2

/fotos_apto

USUARIO, MORADOR, ADMIN

2

/rel_apto

USUARIO, MORADOR, ADMIN

2

/rel_prop

USUARIO, MORADOR, ADMIN

3

/excluir_foto

ADMIN

3

/excluir_prop

ADMIN

4

/home ou /

aberto

4

/login

aberto

Tabela de usuários cadastrados

Papel

Usuário

MORADOR

venus@teste.com

terra@teste.com

USUARIO

mercurio@teste.com

marte@teste.com

ADMIN

jupiter@teste.com

As autorizações também serão feitas dentro do arquivo CondominioSecurity.java. Para cada conjunto de endpoints teremos um filtro que declara qual perfil tem autorização de acesso.

O primeiro conjunto (#1) composto pelos endpoints: /upload, /cad_apto e /cad_apto permite o acesso aos papeis MORADOR e ADMIN e a regra fica assim:

antMatchers("/upload", "/cad_apto", "/cad_prop").hasAnyAuthority("MORADOR", "ADMIN")

O segundo conjunto (#2) composto pelos endpoints: /fotos_apto, /rel_apto, /rel_prop permite o acesso aos papeis USUARIO, MORADOR, ADMIN e a regra fica assim:

antMatchers("/fotos_apto", "/rel_apto", "/rel_prop").hasAnyAuthority("USUARIO", "MORADOR", "ADMIN")

O terceiro conjunto (#3) composto pelos endpoints: /excluir_foto, /excluir_prop permite o acesso somente ao ADMIN e a regra fica assim:

antMatchers("/excluir_foto", "/excluir_prop").hasAnyAuthority("ADMIN") 

O quarto conjunto (#4) composto pelos endpoints: /home, /, /login permite o acesso aberto (sem necessidade de autenticação) e a regra fica assim:

antMatchers("/", "/home").permitAll()

formLogin(form -> form.loginPage("/login").permitAll())

e o método fica assim no final da configuração:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(authorize -> authorize
        .antMatchers("/upload", "/cad_apto", "/cad_prop").hasAnyAuthority("MORADOR", "ADMIN") // permissão para criar
        .antMatchers("/fotos_apto", "/rel_apto", "/rel_prop").hasAnyAuthority("USUARIO", "MORADOR", "ADMIN") // permissão para visualizar
        .antMatchers("/excluir_foto", "/excluir_prop").hasAnyAuthority("ADMIN") // permissão para editar/excluir
        .antMatchers("/", "/home").permitAll()
        .anyRequest().authenticated())

        .formLogin(form -> form.loginPage("/login").permitAll())
        .logout().logoutSuccessUrl("/");
    return http.build();
}

https://github.com/thymeleaf/thymeleaf-extras-springsecurity

Este módulo oferece alguns atributos que usaremos nas páginas da aplicação para exibir as funções do sistema conforme o papel do usuário autenticado.

Lista dos atributos:

Renderiza o elemento se o usuário autenticado estiver autorizado a ver a URL especificada. Exemplo:

<a class="dropdown-item" href="/rel_prop" sec:authorize-url="/rel_prop">Listagem</a>

Renderiza seu conteúdo quando a expressão do atributo é avaliada como verdadeira. A expressão isAuthenticated() retorna true se o usuário realizou a autenticação. Exemplo:

<a href="/login" sec:authorize="!isAuthenticated()" class="btn btn-secondary btn-sm p-1 small">Entrar</a>

Este atributo oferece acesso às propriedades do objeto de autenticação. Neste exemplo, queremos exibir na barra de navegação o nome do usuário e seu perfil (authorities).

<div class="col-3 text-white small" sec:authorize="isAuthenticated()">
                <span sec:authentication="name">opa</span>
                <span sec:authentication="principal.authorities"></span>
            </div>