BT

Diffuser les Connaissances et l'Innovation dans le Développement Logiciel d'Entreprise

Contribuez

Sujets

Sélectionner votre région

Accueil InfoQ Articles Jakarta Security Et REST Dans Le Cloud Partie 2 : Apprendre Les Bases

Jakarta Security Et REST Dans Le Cloud Partie 2 : Apprendre Les Bases

Points Clés

  • La sécurité en Java
  • L’API Jakarta Security
  • L’authentification BASIC
  • Cloud
  • PaaS

Source: https://www.freepik.com/fotos-vetores-gratis/tecnologia

La sécurité est généralement un sujet que nous oublions toujours lorsque nous parlons d'architecture logicielle, mais cela ne signifie pas qu'il n'est pas essentiel. Pour en savoir plus sur le sujet, nous avons créé cette série sur la sécurité des API Java avec Jakarta EE. Dans cette seconde partie, nous parlerons du processus d'authentification BASIC, comment l'implémenter avec une base de données non relationnelle, dans notre cas, MongoDB et comment le mettre en œuvre rapidement dans le cloud.

BASIC est un processus d'authentification où l'utilisateur doit entrer ses informations d'identification, telles que le nom d'utilisateur et le mot de passe, via chaque requête dans l'en-tête lorsqu'il souhaite faire une demande au serveur. Dans ce cas, l'en-tête de la requête aura une autorisation : Basic <credential> où les informations d'identification sont le nom d'utilisateur et le mot de passe séparés par «:» encodé à l'aide de Base 64.

Le grand avantage de ce mécanisme réside dans la simplicité de mise en œuvre. Cependant, il y a quelques problèmes :

  1. Dans chaque requête, il est nécessaire de transmettre les informations d'identification et les informations sensibles de l'utilisateur
  2. Contrairement à ce que beaucoup de gens imaginent, Base 64 n'est pas un chiffrement, et il est très facile de le décoder pour trouver les données de l'utilisateur
  3. En pensant à une architecture distribuée avec des microservices, toutes les requêtes devront effectuer une validation pour authentifier et autoriser l'utilisateur, ce qui peut entraîner une surcharge dans le service de sécurité
  4. En pensant à nouveau à un environnement distribué avec des microservices, si la première requête nécessite plus d'informations d'autres services, vous devrez toujours transmettre les informations critiques aux locaux. Cela facilite toujours les attaques de type man-in-the-middle  qui permettrait à quelqu'un peut capturer ces informations. D'où l'importance d'utiliser une communication sécurisée

Après avoir expliqué certains des avantages et des inconvénients de l'utilisation de BASIC comme mécanisme d’authentification, nous continuerons l'article en créant une intégration avec une base de données. Dans ce cas, nous utiliserons une base de données NoSQL, MongoDB. De manière très simple, nous utiliserons l'exemple précédent comme base, nous allons donc créer une nouvelle implémentation d'IdentityStore. Cependant, au lieu de laisser les informations en mémoire, nous les obtiendrons de la base de données.

Sur la base de notre exemple Hello World utilisant l'API de sécurité, nous ajouterons des dépendances pour travailler avec la base de données et l'intégrer. Puisque nous travaillons avec Jakarta et NoSQL, notre meilleure option est sans aucun doute 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>

Commençons par le code, dont la première étape est la modélisation. Pour cette raison, nous créerons notre utilisateur avec le minimum de champs nécessaires ; dans ce cas, le nom, le mot de passe et les rôles que l'utilisateur aura :

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;
    //...
}

Une chose importante à propos du mot de passe est que nous ne devons jamais le stocker directement. La meilleure option sera toujours de sauvegarder le hachage du mot de passe et de le comparer. En termes de conception de code, une question se pose :

Comment créer une instance et mettre à jour le mot de passe de l'utilisateur ? En laissant l'attribut public, nous savons déjà que ce n'est pas une solution. Mais qu'en est-il si nous utilisons le getter et le setter classiques ? Pour répondre à cette question, j'en utiliserai une autre : y a-t-il une garantie que celui qui utilise le setter gérera le hachage avant de le modifier ?

La réponse est simple : non. Cela montre qu'il existe des problèmes d'encapsulation, même en utilisant les attributs getter et setter privé-public. Pour garantir que chaque fois que l'utilisateur mettra à jour le mot de passe, il ne sera pas enregistré sans le hachage, nous créerons une méthode de mise à jour à partir de laquelle nous passerons «Pbkdf2PasswordHash», et nous déléguerons tout le travail à cette dépendance. L'objectif est de démontrer qu'un modèle riche aide à créer une API de sécurité différente d'un modèle simple.

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

Un autre point est que nous avons gardé la méthode visible uniquement pour le package. Il est essentiel de souligner que plus la visibilité est faible, mieux c'est. C'est quelque chose que Java rend très clair sur la minimisation de l'accessibilité des champs, et cela vaut la peine de le suivre.

Une fois la méthode de mise à jour terminée, comment procéderons-nous à l'insertion ? C'est plus simple. Nous pouvons utiliser le modèle Builder, en déplaçant la logique de création, y compris la logique de hachage dans la classe. Nous maintenons donc le principe de la seule responsabilité de SOLID tout en évitant le manque d'encapsulation.

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

Un point important à souligner est qu'il est inutile de faire attention au stockage du mot de passe si ces informations, même avec le hachage, finissent par fuir. Ainsi, nous devons être prudents lorsque nous utilisons la notation pour ignorer la sérialisation de ces informations ou pour rendre explicites les informations qui seront transmises par le service avec une couche DTO.
Dans cette intégration, nous devons également disposer d'un service pour créer et modifier des utilisateurs, et nous le ferons avec la classe SecurityService, comme indiqué dans le code ci-dessous :

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()));
    }
}

Il y a deux points importants à noter dans la classe SecurityService. Le premier se trouve dans l'interface SecurityContext, qui représente les informations de l'utilisateur s'il est connecté, grâce à Jakarta Security. L'autre point concerne les exceptions, car ce sont toutes des exceptions d'exécution courantes, mais elles sont suivies d'un mappeur d'exceptions JAX-RS. Cette approche vise à ne pas « divulguer » les règles du contrôleur, qui dans le cas de JAX-RS est dans la règle métier. Ainsi, nous avons lancé une exception métier qui sera ensuite traduite en exception JAX-RS. Il s'agit d'une bonne pratique très connue, à la fois pour DDD, l'architecture propre et l'architecture hexagonale.

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(UserNotAuthorizedException exception) {
        return Response.status(Response.Status.UNAUTHORIZED).build();
    }
}

Nous aurons la classe SecurityResource comme ressource qui sera responsable de la création et de la gestion des utilisateurs, et la classe aura certaines opérations que seul un profil d'utilisateur peut effectuer. Dans notre exemple, nous avons choisi d'utiliser le DTO lors de l'exécution de la conversion manuellement, car nous avons peu de champs, cependant, s'il y a plus de champs, l'utilisation d'un mappeur est toujours recommandée.

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();
    }
}

Une fonctionnalité qui permet d'économiser beaucoup de temps pour valider les données d'entrée est certainement Bean Validation. Cependant, il est essentiel de s'assurer que ces messages de validation sont transmis et pas seulement le code d'erreur. Par exemple, les informations indiquant que le mot de passe est obligatoire et qu'il doit comporter au moins six caractères. Une façon d'envoyer les informations d'erreur à l'utilisateur consiste à utiliser à nouveau le mappeur d'exceptions 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();
  }
}

La dernière étape de notre code est la création d'IdentityStore, où nous rechercherons les informations de l'utilisateur. Comme mentionné, nous effectuons la requête en fonction de l'utilisateur et du hachage généré à partir du mot de passe.

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;
    }

}

Le code est maintenant prêt, ne nécessitant que des tests locaux. Docker est certainement une option très simple et intuitive.
Cela fait, nous pouvons déjà effectuer quelques tests de notre application.

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

Déployons dans le cloud

Nous avons déjà mentionné que nous utiliserons un PaaS pour faciliter le déploiement dans le cloud de notre application. En plus des principes, n'hésitez pas à revoir la première partie de cette série. Sur la base de l'exemple Hello World, nous mentionnerons les modifications. Comme MongoDB sera utilisé, le fichier de service sera modifié pour ajouter MongoDB à gérer par la plateforme.

mongodb:
  type: mongodb:3.6
  disk: 1024

Le fichier de configuration d'application aura deux modifications. Le premier est lié à la relation que cette application aura avec la base de données MongoDB et l'autre consiste à remplacer les informations d'accès à la base de données afin que notre application ne soit pas au courant de ces informations d'identification. Une bonne pratique que nous avons déjà mentionnée provient de The Twelve Factor App. Après avoir apporté les modifications nécessaires, poussez simplement sur Platform.sh pour créer l'instance et gérer tout, afin que nous puissions nous concentrer sur le métier.

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

Sur ce, nous avons parlé un peu de l’authentification BASIC et de ses avantages et inconvénients en utilisant Jakarta Security et MongoDB comme base de données. Nous avons mentionné comment l'encapsulation est également liée aux problèmes de sécurité et comment elle affecte le logiciel. Rendez-vous dans la prochaine partie de la série où nous parlerons d'OAuth. Vous pouvez avoir accès à tout le code de cette seconde partie.

A propos de l'auteur

Otávio Santana est un ingénieur logiciel avec une vaste expérience dans le développement open source, avec plusieurs contributions à JBoss Weld, Hibernate, Apache Commons et à d'autres projets. Axé sur le développement multilingue et les applications haute performance, Otávio a travaillé sur de grands projets dans les domaines de la finance, du gouvernement, des médias sociaux et du commerce électronique. Membre du comité exécutif du JCP et de plusieurs groupes d'experts JSR, il est également un champion Java et a reçu le JCP Outstanding Award et le Duke's Choice Award.

 

Evaluer cet article

Pertinence
Style

Contenu Éducatif

BT