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.