BT

Disseminando conhecimento e inovação em desenvolvimento de software corporativo.

Contribuir

Tópicos

Escolha a região

Início Artigos Jakarta Security e Rest na nuvem: Parte 2. Conhecendo o Básico do Basic

Jakarta Security e Rest na nuvem: Parte 2. Conhecendo o Básico do Basic

Este é o segundo artigo da Série “Jakarta Security e Rest na nuvem”. Consulte também: Jakarta Security e Rest na nuvem: Parte 1. Hello World da segurança.


Fonte: https://br.freepik.com/fotos-vetores-gratis/tecnologia

Segurança é, geralmente, um tópico que sempre deixamos de lado quando falamos de arquitetura de software, porém isso não quer dizer que não seja importante. Com o intuito de falar mais sobre o assunto, criamos esta série sobre segurança nas APIs Java com Jakarta EE. Nessa segunda parte, falaremos sobre o processo de autenticação basic, como implementá-la com um banco de dados não relacional, nesse caso o MongoDB e como levá-la rapidamente para a nuvem.

O Basic é um processo de autenticação onde o usuário precisa informar as suas respectivas credenciais, como usuário e senha, através de cada requisição no cabeçalho quando quiser realizar uma solicitação ao servidor. No caso, o header da requisição terá uma Authorization: Basic <credenciais> onde a credencial é o usuário e senha separado por ":" codificado usando a Base 64.

A grande vantagem desse mecanismo se encontra na simplicidade da implementação. Porém, existem alguns problemas:

  • Em cada requisição é necessário passar as credenciais do usuário e informações sensíveis. Quanto menos informações sensíveis forem enviadas, melhor;
  • Ao contrário do que muitas pessoas imaginam, o Base 64 não é uma criptografia, mas sim uma codificação, ou seja, é muito fácil decodificá-la para encontrar os dados do usuário;
  • Pensando numa arquitetura distribuída com microservices, todas as requisições terão de realizar uma validação para autenticar e autorizar o usuário, podendo resultar numa sobrecarga no serviço de segurança;
  • Novamente pensando num ambiente distribuído com microservices, caso a primeira requisição precise de mais informações de outros serviços, sempre terá de passar as informações críticas para as dependências. Isso sempre potencializa o ataque man-in-the-middle e alguém pode capturar essa informação. Por isso a importância de utilizar uma comunicação segura.

Explicado um pouco das vantagens e desvantagens de se utilizar o Basic como mecanismo, daremos continuidade ao artigo criando a integração com um banco de dados. Nesse caso vamos utilizar um banco NoSQL, o MongoDB. De uma maneira bem simples, iremos utilizar como base o exemplo anterior, por isso, iremos criar uma nova implementação do IdentityStore, porém, ao invés de deixar as informações na memória, iremos buscá-las no banco de dados.

Usando como base o nosso Hello World na API de segurança, adicionaremos o mecanismo nas dependências para trabalhar e integrar com o banco de dados. Uma vez que estamos trabalhando tanto com o Jakarta e NoSQL, a nossa melhor opção certamente é o Jakarta NoSQL.

        <dependency>
            <groupId>org.eclipse.jnosql.artemis</groupId>
            <artifactId>artemis-document</artifactId>
            <version>${jnosql.version}</version>
        </dependency>
        <dependency>
            <groupId>org.eclipse.jnosql.diana</groupId>
            <artifactId>mongodb-driver</artifactId>
            <version>${jnosql.version}</version>
        </dependency>

Começando pelo código, certamente o primeiro passo está na modelagem, e por isso, iremos criar nosso usuário, com o mínimo de campos necessários, no caso o nickname, o password e as roles que o usuário terá:

import jakarta.nosql.mapping.Column;
import jakarta.nosql.mapping.Entity;
import jakarta.nosql.mapping.Id;

import javax.security.enterprise.identitystore.Pbkdf2PasswordHash;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;

import static java.util.Objects.requireNonNull;

@Entity
public class User {

    @Id
    private String name;

    @Column
    private String password;

    @Column
    private Set<Role> roles;
    //resto do código
}

Uma coisa muito importante sobre o password, é que nunca devemos armazená-la de maneira direta. A melhor opção sempre será salvar o hash do password e compará-la. Em termo de design de código surge uma dúvida:

Como faremos para criar uma instância e atualizar o password do usuário? Deixar o atributo público já sabemos que não é uma saída. Mas e se usarmos o clássico getter e setter? Para responder essa pergunta, vou utilizar outra: Existe alguma garantia que quem utilizar o setter tratará o hash antes de modificá-lo?

A resposta é um simples, não. Isso demonstra que existem problemas de encapsulamento mesmo usando os atributos privados getter e setter públicos. Para garantir que toda vez que o usuário atualizar o password não seja salvo sem o hash, criaremos um método de atualização do qual passaremos o 'Pbkdf2PasswordHash' e iremos deixar todo o trabalho para esta dependência. O objetivo é demonstrar que um modelo rico ajuda a criar uma API a prova de falhas diferente de um modelo simples.

    void updatePassword(String password, Pbkdf2PasswordHash passwordHash) {
        this.password = passwordHash.generate(password.toCharArray());
    }

Um outro ponto é que mantivemos o método visível apenas para o pacote. É muito importante salientar que, quanto menor a visibilidade, melhor. É algo que o Java deixa bem claro em relação a minimizar a acessibilidade dos campos, valendo a pena segui-lo.

Com o método de atualização concluído, como faremos a inserção? Isso é mais simples. Podemos utilizar o padrão Builder, movendo a lógica de criação, incluindo a lógica do hash na classe. De modo que mantivemos o princípio de responsabilidade única do SOLID além de evitar a falta de encapsulamento.

      User user = User.builder()
                .withPasswordHash(passwordHash)
                .withPassword(password)
                .withName(name)
                .withRoles(roles)
                .build();

Um ponto importante a ser salientado é que não adianta ter o cuidado com o armazenamento do password caso essa informação, mesmo com o hash, acabe vazando. Assim, precisamos estar atentos ao utilizar a notação para ignorar a serialização dessa informação ou tornar explícito as informações que serão transmitidas pelo serviço com uma camada DTO.

Nessa integração, também precisamos ter um serviço para criar e alterar os usuários, e faremos isso com a classe SecurityService, como mostra o código abaixo:

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.security.enterprise.SecurityContext;
import javax.security.enterprise.identitystore.Pbkdf2PasswordHash;
import java.security.Principal;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@ApplicationScoped
class SecurityService {

    @Inject
    private UserRepository repository;

    @Inject
    private Pbkdf2PasswordHash passwordHash;

    @Inject
    private SecurityContext securityContext;

    void create(UserDTO userDTO) {
        if (repository.existsById(userDTO.getName())) {
            throw new UserAlreadyExistException("There is an user with this id: " + userDTO.getName());
        } else {
            User user = User.builder()
                    .withPasswordHash(passwordHash)
                    .withPassword(userDTO.getPassword())
                    .withName(userDTO.getName())
                    .withRoles(getRole())
                    .build();
            repository.save(user);
        }
    }

    void delete(String id) {
        repository.deleteById(id);
    }

    void updatePassword(String id, UserDTO dto) {

        final Principal principal = securityContext.getCallerPrincipal();
        if (isForbidden(id, securityContext, principal)) {
            throw new UserForbiddenException();
        }

        final User user = repository.findById(id)
                .orElseThrow(() -> new UserNotFoundException(id));
        user.updatePassword(dto.getPassword(), passwordHash);
        repository.save(user);
    }


    public void addRole(String id, RoleDTO dto) {
        final User user = repository.findById(id)
                .orElseThrow(() -> new UserNotFoundException(id));

        user.addRoles(dto.getRoles());
        repository.save(user);

    }

    public void removeRole(String id, RoleDTO dto) {
        final User user = repository.findById(id)
                .orElseThrow(() -> new UserNotFoundException(id));
        user.removeRoles(dto.getRoles());
        repository.save(user);
    }

    public UserDTO getUser() {
        final Principal principal = securityContext.getCallerPrincipal();
        if (principal == null) {
            throw new UserNotAuthorizedException();
        }
        final User user = repository.findById(principal.getName())
                .orElseThrow(() -> new UserNotFoundException(principal.getName()));
        UserDTO dto = toDTO(user);
        return dto;
    }

    public List<UserDTO> getUsers() {
        return repository.findAll()
                .map(this::toDTO)
                .collect(Collectors.toList());
    }

    private UserDTO toDTO(User user) {
        UserDTO dto = new UserDTO();
        dto.setName(user.getName());
        dto.setRoles(user.getRoles());
        return dto;
    }

    private Set<Role> getRole() {
        if (repository.count() == 0) {
            return Collections.singleton(Role.ADMIN);
        } else {
            return Collections.singleton(Role.USER);
        }
    }

    private boolean isForbidden(String id, SecurityContext context, Principal principal) {
        return !(context.isCallerInRole(Role.ADMIN.name()) || id.equals(principal.getName()));
    }


}

Existem dois pontos importantes para salientar na classe SecurityService. O primeiro está na interface SecurityContext, que representa as informações do usuário, caso esteja logado, graças ao Jakarta Security. O outro ponto, está nas exceções, pois todas são exceções runtime comuns, porém, são seguidas de um mapper de exceção do JAX-RS. Essa abordagem tem como objetivo, não "vazar" as regras do controller, que neste caso, o JAX-RS fica dentro da regra de negócio. Assim, lançamos uma exceção de negócio que em seguida será traduzida para uma exceção do JAX-RS. Essa abordagem tende a facilitar os testes evitando layer leak ou vazamento de camadas. Essa é uma boa prática muito famosa, tanto pelo DDD, Clean Architecture, quanto pela arquitetura hexagonal.

public class UserForbiddenException extends RuntimeException {
}

public class UserNotAuthorizedException extends RuntimeException {
}

@Provider
public class UserForbiddenExceptionMapper implements ExceptionMapper<UserForbiddenException> {
    @Override
    public Response toResponse(UserForbiddenException exception) {
        return Response.status(Response.Status.FORBIDDEN).build();
    }
}

@Provider
public class UserNotAuthorizedExceptionMapper implements ExceptionMapper<UserNotAuthorizedException> {

    @Override
    public Response toResponse(UserForbiddenException exception) {
        return Response.status(Response.Status.UNAUTHORIZED).build();
    }
}

Teremos o SecurityResource no recurso que se responsabilizará em criar e gerenciar os usuários, e a classe terá algumas operações que apenas um perfil de usuário poderá executar. No nosso exemplo, optamos por utilizar o DTO com a realização da conversão manualmente, devido ao fato de termos poucos campos, porém, se houver mais campos, o uso de algum mapper é sempre recomendado.

import javax.annotation.security.RolesAllowed;
import javax.inject.Inject;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import java.util.List;

@Path("security")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class SecurityResource {

    @Inject
    private SecurityService service;

    @POST
    public void create(@Valid UserDTO userDTO) {
        service.create(userDTO);
    }

    @DELETE
    @Path("{id}")
    @RolesAllowed("ADMIN")
    public void delete(@PathParam("id") String id) {
        service.delete(id);
    }


    @Path("{id}")
    @PUT
    public void changePassword(@PathParam("id") String id, @Valid UserDTO dto) {
        service.updatePassword(id, dto);
    }

    @Path("roles/{id}")
    @PUT
    @RolesAllowed("ADMIN")
    public void addRole(@PathParam("id") String id, RoleDTO dto){
        service.addRole(id, dto);
    }

    @Path("roles/{id}")
    @DELETE
    @RolesAllowed("ADMIN")
    public void removeRole(@PathParam("id") String id, RoleDTO dto){
        service.removeRole(id, dto);
    }

    @Path("me")
    @GET
    public UserDTO getMe() {
        return service.getUser();
    }

    @Path("users")
    @GET
    @RolesAllowed("ADMIN")
    public List<UserDTO> getUsers() {
        return service.getUsers();
    }
}

Um recurso que economiza e muito na validação dos dados de entrada, certamente é o Bean Validation. Porém, é importante garantir que tais mensagens de validação sejam transmitidas e não somente o código de erro. Por exemplo, a informação que a senha é obrigatória e que precisa ter no mínimo 6 caracteres. Uma maneira de enviar as informações de erro para o usuário é utilizar novamente o Mapper de exceção do JAX-RS.

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Provider
public class BeanValConstrainViolationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {

    @Override
    public Response toResponse(ConstraintViolationException e) {
        Set<ConstraintViolation<?>> cv = e.getConstraintViolations();
        final List<String> errors = cv.stream().map(c -> c.getPropertyPath() + " " + c.getMessage()).collect(Collectors.toList());
        return Response.status(Response.Status.BAD_REQUEST)
                .entity(new ErrorMessage(errors))
                .type(MediaType.APPLICATION_JSON)
                .build();
    }


}

O último passo do nosso código é a criação do IdentityStore, onde buscaremos as informações do usuário. Como mencionamos, fazemos a query baseada no usuário e no hash que foi gerado a partir da senha.

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.security.enterprise.credential.Credential;
import javax.security.enterprise.credential.Password;
import javax.security.enterprise.credential.UsernamePasswordCredential;
import javax.security.enterprise.identitystore.CredentialValidationResult;
import javax.security.enterprise.identitystore.IdentityStore;
import javax.security.enterprise.identitystore.Pbkdf2PasswordHash;
import java.util.Optional;

import static javax.security.enterprise.identitystore.CredentialValidationResult.INVALID_RESULT;

@ApplicationScoped
public class MongoDBIdentity implements IdentityStore {

    @Inject
    private UserRepository repository;

    @Inject
    private Pbkdf2PasswordHash passwordHash;

    @Override
    public int priority() {
        return 10;
    }

    @Override
    public CredentialValidationResult validate(Credential credential) {

        if (credential instanceof UsernamePasswordCredential) {
            UsernamePasswordCredential userCredential = UsernamePasswordCredential
                    .class.cast(credential);

            final Password userPassword = userCredential.getPassword();
            final Optional<User> userOptional = repository.findById(userCredential.getCaller());
            if (userOptional.isPresent()) {
                final User user = userOptional.get();
                if (passwordHash.verify(userPassword.getValue(), user.getPassword())) {
                    return new CredentialValidationResult(user.getName(), user.getRoles());
                }
            }

        }
        return INVALID_RESULT;
    }

}

O código agora está pronto, sendo apenas necessário realizar os testes locais. Uma opção muito fácil e intuitiva certamente é o Docker.

Com isso feito, já podemos realizar alguns testes da nossa aplicação.

curl --location --request POST 'http://localhost:8080/security' --header 'Content-Type: application/json' --data-raw '{"name": "otavio", "password": "otavio"}'

curl --location --request GET 'http://localhost:8080/admin' //returns 401

curl --location --request GET 'http://localhost:8080/admin' --header 'Authorization: Basic b3RhdmlvOm90YXZpbw==' //returns 200

Movendo para a nuvem

Já mencionamos que utilizaremos um PaaS para facilitar o deploy na nuvem da nossa aplicação. Além dos princípios, esteja a vontade para revisar a primeira parte dessa série. Com base no exemplo Hello World, mencionaremos as modificações. Como será utilizado o MongoDB, será realizada a modificação no arquivo de serviço para adicionar o MongoDB que será gerenciado pela plataforma.

mongodb:
  type: mongodb:3.6
  disk: 1024

O arquivo de configuração da aplicação terá duas modificações. A primeira está relacionado ao relacionamento que essa aplicação terá com o banco de dados MongoDB e, a outra, é para sobrescrever as informações de acesso ao banco de modo que a nossa aplicação não tenha ciência dessas credenciais. Uma boa prática que já mencionamos, é oriunda do The Twelve Factor App. Feito as devidas modificações, basta fazer um push que a Platform.sh criará a instância e gerenciará tudo, para que nos foquemos no negócio.

name: app
type: "java:11"
disk: 1024
hooks:
    build:  mvn clean package payara-micro:bundle

relationships:
    mongodb: 'mongodb:mongodb'

web:
    commands:
        start: |
            export MONGO_PORT=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].port"`
            export MONGO_HOST=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].host"`
            export MONGO_ADDRESS="${MONGO_HOST}:${MONGO_PORT}"
            export MONGO_PASSWORD=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].password"`
            export MONGO_USER=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].username"`
            export MONGO_DATABASE=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].path"`
            java -jar -Xmx$(jq .info.limits.memory /run/config.json)m -XX:+ExitOnOutOfMemoryError \
            -Ddocument.settings.jakarta.nosql.host=$MONGO_ADDRESS \
            -Ddocument.database=$MONGO_DATABASE -Ddocument.settings.jakarta.nosql.user=$MONGO_USER \
            -Ddocument.settings.jakarta.nosql.password=$MONGO_PASSWORD \
            -Ddocument.settings.mongodb.authentication.source=$MONGO_DATABASE \
            target/microprofile-microbundle.jar --port $PORT

Com isso falamos um pouco sobre o BASIC e suas vantagens e desvantagens utilizando o Jakarta Security e MongoDB como banco de dados. Mencionamos como encapsulamento também está relacionado a problemas de segurança e como isso impacta o software. Te vejo na próxima parte da série em que falaremos sobre o Auth. Certamente, você pode ter acesso a todo o código da segunda parte aqui.

Sobre o Autor

Otávio Santana é engenheiro de software, com grande experiência em desenvolvimento opensource, com diversas contribuições ao JBoss Weld, Hibernate, Apache Commons e outros projetos. Focado em desenvolvimento poliglota e aplicações de alto desempenho, Otávio trabalhou em grandes projetos nas áreas de finanças, governo, mídias sociais e e-commerce. Membro do comitê executivo do JCP e de vários Expert Groups de JSRs, é também Java Champion e recebeu os prêmios JCP Outstanding Award e Duke's Choice Award.


Este é o segundo artigo da Série “Jakarta Security e Rest na nuvem”. Consulte também: Jakarta Security e Rest na nuvem: Parte 1. Hello World da segurança.

Avalie esse artigo

Relevância
Estilo/Redação

Conteúdo educacional

BT