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 3 : Connaître OAuth2

Jakarta Security Et REST Dans Le Cloud Partie 3 : Connaître OAuth2

Points Clés

  • La sécurité en Java
  • L’API Jakarta Security
  • OAuth2
  • 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 important. Afin de parler davantage du sujet, nous avons créé cette série sur la sécurité des API Java avec Jakarta EE. Dans cette troisième partie, nous parlerons du processus d'authentification OAuth2, comment l'implémenter dans ce cas avec deux bases de données MongoDB et Redis, en plus de le déployer rapidement vers le cloud.

D'une manière très simple, OAuth 2.0 est un protocole qui permet de donner la permission d'accéder aux ressources (autorisation) entre les systèmes ou les sites avec les avantages d'une plus grande encapsulation des informations critiques telles que le nom d'utilisateur et le mot de passe. Un aperçu d'OAuth 2.0 est présenté ci-dessous:

  1. La première étape est la demande d'autorisation. Il identifie l'authenticité de l'utilisateur
  2. Une fois autorisé, l'étape suivante consiste à demander le jeton
  3. Toutes les demandes seront faites avec ce jeton d'accès

Il s'agit d'une vue très courte, mais si vous voulez en savoir plus sur OAuth 2.0, cela vaut la peine de jeter un œil à la spécification.

L'un des grands avantages de cette approche est la faible exposition des mots de passe, en plus de pouvoir générer un grand nombre de jetons pour le même utilisateur. C’est quelque chose d'utile, étant donné que chaque utilisateur peut avoir plusieurs appareils tels qu’un téléphone portable, une tablette, un ordinateur portable, etc. Il est possible de générer un jeton pour chacun et si vous souhaitez révoquer l'accès à un appareil spécifique, vous devez simplement supprimer le jeton de cet appareil. Un autre point important est qu'aucun de ces appareils n'a accès au login et au mot de passe de l'utilisateur, seulement au jeton. En pensant à l'encapsulation, moins le mot de passe est exposé, mieux c'est pour la sécurité.

D'un autre côté, nous avons considérablement augmenté la complexité de l'architecture, après tout, avant il ne s'agissait que d'un mot de passe, maintenant nous avons un grand volume de jetons gérés par chaque utilisateur.

OAuth2 en termes architecturaux suivra les étapes suivantes :

  1. La première étape, suivant la règle, consiste à faire une demande pour obtenir un nouveau jeton. Pour cette demande, nous supposons que l'utilisateur existe déjà dans la base de données, puisqu'il transmettra son identifiant et son mot de passe pour s'authentifier. Une fois authentifié, l'étape suivante consiste à générer le jeton. Cette création peut être effectuée par le même serveur ou par un autre
  2. Le résultat de cette demande se traduira par une paire de jetons : access_token et refresh_token
  3. A partir de ce moment, chaque demande sera faite avec l‘ "access_token"
  4. L’access_token expirera à un moment donné, le rendant inutile
  5. La prochaine étape sera de le mettre à jour, en créant un nouveau token, grâce au refresh_token. Nous ferons cependant une nouvelle demande cette fois avec le refresh_token qui renverra le résultat déjà mentionné à l'étape 2
  6. Le cycle continuera à nouveau en utilisant l’access_token jusqu'à ce qu'il expire à nouveau et qu'une nouvelle mise à jour soit requise

Un point important ici est ce cycle, qui ne s'appliquera qu'à un seul appareil. S'il est nécessaire d'avoir un autre accès à une autre machine, chacun aura son propre cycle avec son jeton respectif.

Il est possible de séparer la logique capable de générer les jetons des informations de l'utilisateur, de manière logique et physique, c'est-à-dire sur un autre serveur. Mais dans notre exemple, nous allons simplifier la démonstration en démontrant une séparation logique, mais pas physique. La logique du mécanisme d'authentification sera créée en tant que sous-domaine de l'API Security car il existe une dépendance pour authentifier l'utilisateur. Afin de stocker les informations, la même logique, à partir de la seconde partie de l'article, sera maintenue, et pour stocker les informations des jetons, comme elles auront un TTL, nous utiliserons Redis.

Lorsque nous parlons de code, en général, nous profiterons de pratiquement toute la logique de stockage et de gestion des utilisateurs provenant de la seconde partie de l'article, à la différence près que le mécanisme sera différent. En gardant le code de base, les dépendances seront également conservées. Nous en ajouterons d'autres qui traitent de la génération des jetons avec Redis.


<dependency>
  <groupId>org.eclipse.jnosql.artemis</groupId>
  <artifactId>artemis-key-value</artifactId>
  <version>${jnosql.version}</version>
</dependency>
<dependency>
  <groupId>org.eclipse.jnosql.diana</groupId>
  <artifactId>redis-driver</artifactId>
  <version>${jnosql.version}</version>
</dependency>

Concernant la modélisation et la persistance des tokens, trois nouvelles entités seront créées. Une chose importante est que les règles d'encapsulation sont toujours pertinentes. Et ce n'est que pour cette raison que nous utilisons des visibilités privées et des méthodes d'accès public, dans les cas où nous n'avons pas d'autre option. C'est une bonne règle dont Effective Java parle beaucoup, en plus d'être une bonne pratique pour la sécurité. Un point critique est que ces entités, en plus des annotations Jakarta NoSQL, utilisent également des annotations JSONB. La raison en est que Redis stocke les informations sous forme de texte et que la stratégie de stockage utilisée par le pilote Redis est en JSON, en utilisant un type d'implémentation du monde de Jakarta.


@Entity
@JsonbVisibility(FieldPropertyVisibilityStrategy.class)
public class UserToken {

    @Id
    @JsonbProperty
    private String username;

    @Column
    @JsonbProperty
    private Set<Token> tokens;

    //...
}

@Entity
@JsonbVisibility(FieldPropertyVisibilityStrategy.class)
public class RefreshToken {

    @Id
    @JsonbProperty
    private String id;

    @JsonbProperty
    private String token;

    @JsonbProperty
    private String accessToken;

    @JsonbProperty
    private String user;

    //...
}

@Entity
@JsonbVisibility(FieldPropertyVisibilityStrategy.class)
public class AccessToken {

    @Id
    private String id;

    @JsonbProperty
    private String user;
    
    @JsonbProperty
    private String token;

    //...
}

Concernant la règle de génération et de mise à jour de ces jetons, nous avons la classe Oauth2Service qui gérera toute la logique du mécanisme de stockage. La validation des données sera effectuée en utilisant l'API Bean Validation et, à partir du contexte, à la fois pour la création et la mise à jour du jeton, nous créons un groupe pour chaque contexte. Ainsi, comme avec MongoDB, l'API value-key a la possibilité d'utiliser un Repository, cependant, comme les opérations sont assez simples, le KeyValueTemplate sera utilisé. Un point important est lorsque le jeton d'actualisation est rendu persistant, en ayant un second paramètre qui définit le TTL. Cela signifie qu'après un certain temps, les informations seront automatiquement supprimées de Redis.


import jakarta.nosql.mapping.keyvalue.KeyValueTemplate;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import sh.platform.sample.security.SecurityService;
import sh.platform.sample.security.User;

import sh.platform.sample.security.UserNotAuthorizedException;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.validation.Validator;
import java.time.Duration;
import java.util.Arrays;
import java.util.Set;

@ApplicationScoped
class Oauth2Service {

    static final int EXPIRE_IN = 3600;

    static final Duration EXPIRES = Duration.ofSeconds(EXPIRE_IN);

    @Inject
    private SecurityService securityService;

    @Inject
    @ConfigProperty(name = "keyvalue")
    private KeyValueTemplate template;

    @Inject
    private Validator validator;

    public Oauth2Response token(Oauth2Request request) {

        final Set<ConstraintViolation<Oauth2Request>> violations = validator.validate(request, Oauth2Request
            .GenerateToken.class);
        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }

        final User user = securityService.findBy(request.getUsername(), request.getPassword());
        final UserToken userToken = template.get(request.getUsername(), UserToken.class)
            .orElse(new UserToken(user.getName()));

        final Token token = Token.generate();

        AccessToken accessToken = new AccessToken(token, user.getName());
        RefreshToken refreshToken = new RefreshToken(userToken, token, user.getName());

        template.put(refreshToken, EXPIRES);
        template.put(Arrays.asList(userToken, accessToken));

        final Oauth2Response response = Oauth2Response.of(accessToken, refreshToken, EXPIRE_IN);
        return response;
    }

    public Oauth2Response refreshToken(Oauth2Request request) {
  
        final Set<ConstraintViolation<Oauth2Request>> violations = validator.validate(request, Oauth2Request
           .RefreshToken.class);
        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }

        RefreshToken refreshToken = template.get(RefreshToken.PREFIX + request.getRefreshToken(), RefreshToken.class)
            .orElseThrow(() -> new UserNotAuthorizedException("Invalid Token"));

        final UserToken userToken = template.get(refreshToken.getUser(), UserToken.class)
            .orElse(new UserToken(refreshToken.getUser()));

        final Token token = Token.generate();
        AccessToken accessToken = new AccessToken(token, refreshToken.getUser());
        refreshToken.update(accessToken, userToken, template);
        template.put(accessToken, EXPIRES);
        final Oauth2Response response = Oauth2Response.of(accessToken, refreshToken, EXPIRE_IN);
        return response;
    }
}

import sh.platform.sample.security.infra.FieldPropertyVisibilityStrategy;

import javax.json.bind.annotation.JsonbVisibility;
import javax.validation.constraints.NotBlank;
import javax.ws.rs.FormParam;

@JsonbVisibility(FieldPropertyVisibilityStrategy.class)
public class Oauth2Request {

    @FormParam("grand_type")
    @NotBlank
    private String grandType;

    @FormParam("username")
    @NotBlank(groups = {GenerateToken.class})
    private String username;

    @FormParam("password")
    @NotBlank(groups = {GenerateToken.class})
    private String password;

    @FormParam("refresh_token")
    @NotBlank(groups = {RefreshToken.class})
    private String refreshToken;

    public void setGrandType(GrantType grandType) {
        if(grandType != null) {
            this.grandType = grandType.get();
        }
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public void setRefreshToken(String refreshToken) {
        this.refreshToken = refreshToken;
    }

    public GrantType getGrandType() {
        if(grandType != null) {
            return GrantType.parse(grandType);
        }
        return null;
    }

    public String getUsername() {
        return username;
    }

    public String getPassword() {
        return password;
    }

    public String getRefreshToken() {
        return refreshToken;
    }

    public @interface GenerateToken{}

    public @interfaceRefreshToken{}
}

Dans la ressource OAuth2, vous pouvez voir que nous avons une méthode et deux opérations. C'est l'une des raisons pour lesquelles dans le code, la méthode getGrantType, renvoie une énumération avec deux valeurs.


import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.validation.Valid;
import javax.ws.rs.BeanParam;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@ApplicationScoped
@Path("oauth2")
public class Oauth2Resource {

    @Inject
    private Oauth2Service service;

    @POST
    @Path("token")
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    @Produces(MediaType.APPLICATION_JSON)
    public Oauth2Response token(@BeanParam @Valid Oauth2Request request) {
        switch (request.getGrandType()) {
        case PASSWORD:
            return service.token(request);
        case REFRESH_TOKEN:
            return service.refreshToken(request);
        default:
            throw new UnsupportedOperationException("There is not support to another type");
        }
    }
}

Avec la possibilité de créer et de mettre à jour les jetons, nous passons à la seconde étape, qui est basée sur la création du mécanisme. La classe Oauth2Authentication reçoit la requête et recherche l'en-tête «Authorization» en la validant à l'aide d’une expression régulière. Une fois l'en-tête validée, l'étape suivante consiste à vérifier l'existence de ce jeton dans la base de données, dans notre cas nous utilisons Redis. Une fois le refresh token vérifié, nous envoyons l'ID utilisateur au mécanisme de stockage représenté par l’IdentityStoreHandler.

La classe d'identification a besoin de quelques changements par rapport à la seconde partie. Il continue de charger les informations utilisateur et les règles d'autorisation, mais il n'y a pas de validation du mot de passe, car tout cela est effectué par l'identifiant de l'utilisateur.


import jakarta.nosql.mapping.keyvalue.KeyValueTemplate;
import org.eclipse.microprofile.config.inject.ConfigProperty;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.security.enterprise.AuthenticationStatus;
import javax.security.enterprise.authentication.mechanism.http.HttpAuthenticationMechanism;
import javax.security.enterprise.authentication.mechanism.http.HttpMessageContext;
import javax.security.enterprise.credential.CallerOnlyCredential;
import javax.security.enterprise.identitystore.CredentialValidationResult;
import javax.security.enterprise.identitystore.IdentityStoreHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static javax.security.enterprise.identitystore.CredentialValidationResult.Status.VALID;

@ApplicationScoped
public class Oauth2Authentication implements HttpAuthenticationMechanism {

    private static final Pattern CHALLENGE_PATTERN
        = Pattern.compile("^Bearer *([^ ]+) *$", Pattern.CASE_INSENSITIVE);

    @Inject
    private IdentityStoreHandler identityStoreHandler;

    @Inject
    @ConfigProperty(name = "keyvalue")
    private KeyValueTemplate template;

    @Override
    public AuthenticationStatus validateRequest(HttpServletRequest request, HttpServletResponse response,
        HttpMessageContext httpMessageContext) {

        final String authorization = request.getHeader("Authorization");

        Matcher matcher = CHALLENGE_PATTERN.matcher(Optional.ofNullable(authorization).orElse(""));
        if (!matcher.matches()) {
            return httpMessageContext.doNothing();
        }
        final String token = matcher.group(1);
        final Optional<AccessToken> optional = template.get(AccessToken.PREFIX + token, AccessToken.class);

        if (!optional.isPresent()) {
            return httpMessageContext.responseUnauthorized();
        }

        final AccessToken accessToken = optional.get();
        final CredentialValidationResult validate = identityStoreHandler.validate(new CallerOnlyCredential(accessToken.getUser()));
        if (validate.getStatus() == VALID) {
            return httpMessageContext.notifyContainerAboutLogin(validate.getCallerPrincipal(), validate.getCallerGroups());
        } else {
            return httpMessageContext.responseUnauthorized();
        }
    }
}

Un point important est que bien que le mécanisme d'authentification dépend de la base de données, ils ne sont pas connus, grâce à l'API Security. Suivant la règle de domaine et de sous-domaine, le sous-domaine OAuth2 est autorisé à connaître l'API Security, cependant, l'inverse n'est pas autorisé, pour plusieurs raisons. Cette dépendance cyclique pose plusieurs problèmes, par exemple, si à un moment donné nous voulons déplacer la logique vers un serveur, ce sera plus difficile, car il est difficile de maintenir le logiciel de cette manière. Une manière simple de penser la dépendance cyclique est de réfléchir à la question classique de l'œuf et de la poule.

Cependant, voici un problème : lorsqu'un utilisateur est supprimé, il est également important que les jetons respectifs soient supprimés. Une façon de créer cette séparation est en utilisant les événements. Comme nous utilisons CDI, il sera basé sur ses événements.


import jakarta.nosql.mapping.keyvalue.KeyValueTemplate;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import sh.platform.sample.security.RemoveToken;
import sh.platform.sample.security.RemoveUser;
import sh.platform.sample.security.User;
import sh.platform.sample.security.UserForbiddenException;

import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.event.Observes;
import javax.inject.Inject;
import java.util.Collections;
import java.util.Optional;
import java.util.Set;

@ApplicationScoped
class Oauth2Observes {

    @Inject
    @ConfigProperty(name = "keyvalue")
    private KeyValueTemplate template;

    public void observe(@Observes RemoveUser removeUser) {

        //....
    }

    public void observe(@Observes RemoveToken removeToken) {
   
        //....
    }
}

Le déploiement dans le cloud

Dans notre série d'articles, nous utilisons un PaaS afin de simplifier la complexité du matériel et la sécurité d'accès entre les conteneurs. Après tout, ce serait en vain si nous faisions un contrôle strict sur le logiciel, et la base de données avait une adresse IP publique, pour l'ensemble d'Internet. Ainsi, nous maintiendrons la stratégie d'utilisation de Platform.sh. Nous ne mentionnerons que la modification du fichier de services pour ajouter une autre base de données, dans notre cas Redis :


mongodb:
type: mongodb:3.6
disk: 1024

redis:
type: redis-persistent:5.0
disk: 1024

Il sera nécessaire d'effectuer la modification dans le fichier de configuration de l'application. L'objectif est de fournir des informations d'identification afin que le conteneur d'application ait accès au conteneur de base de données.


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

relationships:
mongodb: 'mongodb:mongodb'
redis: 'redis:redis'

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"`
export REDIS_HOST=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".redis[0].host"`
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 \
-Dkeyvalue.settings.jakarta.nosql.host=$REDIS_HOST \
target/microprofile-microbundle.jar --port $PORT

Avec cela, nous avons parlé des concepts et de la conception d'un mécanisme OAuth2 de manière pratique, en utilisant Jakarta Security. Un point important est que, pour chaque requête, nous devons effectuer une recherche dans la base de données, car le jeton n'est rien de plus qu'un pointeur vers les informations, cependant, ce ne sont pas les informations elles-mêmes. Il existe un moyen intéressant d'utiliser le jeton pour mettre des informations, par exemple, en combinant le jeton avec JWT. Cette combinaison d'OAuth2 et JWT fera l’objet de la prochaine partie. Comme toujours, l'exemple de code peut être trouvé sur GitHub.

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