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 3. Conhecendo o Básico do Oauth2

Jakarta Security e Rest na nuvem: Parte 3. Conhecendo o Básico do Oauth2

Este é o terceiro 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 e Jakarta Security e Rest na nuvem: Parte 2. Conhecendo o Básico do Basic.


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 terceira parte, falaremos sobre o processo de autenticação Oauth2, como implementá-la nesse caso com dois bancos de dados MongoDB e Redis, além de movê-lo rapidamente para a nuvem.

De uma maneira bastante simples, o Auth 2.0 é um protocolo que permite permissão de acesso aos recursos de autorização entre sistemas ou sites com o benefícios de um maior encapsulamento de informações críticas como usuário e senha. Uma visão geral do Auth 2.0 é mostrada a seguir:

  • O primeiro passo é a requisição de autorização. Nela é identificado a autenticidade do usuário;
  • Uma vez autorizado, o próximo passo se encontra na solicitação do token;
  • Com esse token de acesso, todas as requisições serão feitas a partir do mesmo.

Salientando que essa é uma visão bem resumida, mas se quiser saber mais sobre o OAuth 2.0, vale a pena dar uma olhada na especificação.

Uma das grandes vantagens dessa abordagem está na baixa exposição da senha, além de ser posśivel gerar um grande número de tokens para o mesmo usuário. Algo excelente, considerando que cada usuário pode ter vários dispositivos como celular, tablet, notebook, etc. É possível gerar um token para cada um e caso queria revogar o acesso para um determinado dispositivo, bastaria apenas remover o token deste dispositivo. Outro ponto importante é que nenhum desses dispositivos tem acesso ao login e senha do usuário, apenas ao token. Pensando no encapsulamento, quanto menos exposto a senha, melhor para segurança.

Por outro lado, aumentamos e muito a complexidade da arquitetura, afinal, antes era apenas uma senha, agora, temos um grande volume de tokens gerenciados por cada usuário.

O Oauth2 em termos arquiteturais seguirá os seguintes passos:

  1. O primeiro passo, seguindo a regra, é fazer a requisição para obter um novo token. Para essa requisição, partimos do princípio que o usuário já exista na base de dados, uma vez que passará a seu login e senha para ser autenticado. Uma vez autenticado, o próximo passo se gerar o token. Essa criação pode ser realizada pelo mesmo servidor ou por um outro diferente;
  2. O resultado dessa requisição resultará em um par de tokens: O access_token e o refresh_token;
  3. A partir desse momento, toda requisição será feita com o "access_token";
  4. O access_token irá expirar em algum momento tornando-o inútil;
  5. O próximo passo será atualizá-lo, criando um novo token, graças ao refresh_token. Faremos uma nova requisição, porém, dessa vez com o refresh_token que retornará o resultado já mencionado no passo 2;
  6. O ciclo seguirá novamente com o uso do access_token até que expire novamente e seja necessário uma nova atualização.

Um ponto importante aqui, é esse ciclo, que valerá apenas para um dispositivo. Caso seja necessário ter mais um acesso a outra máquina, cada um terá o seu próprio ciclo com o seu respectivo token.

É possível separar a lógica capaz de gerar o tokens das informações do usuário, de maneira lógica e física, ou seja, em outro servidor. Mas no nosso exemplo, iremos simplificar a demonstração, demonstrando uma separação lógica, mas, não física. Será criado a lógica do mecanismo de autenticação como subdomínio da API de segurança uma vez que existe uma dependência para autenticar o usuário. Para armazenar as informações será mantido a mesma lógica, oriunda da segunda parte do artigo, e para armazenar as informações de tokens, uma vez que elas têm um TTL, iremos utilizar o Redis.

Quando falamos do código, de uma maneira geral, iremos aproveitar praticamente toda a lógica de armazenamento e gerenciamento de usuário oriundo da segunda parte do artigo, com a diferença que o mecanismo será outro. Mantendo o código base, também serão mantido as dependências. Iremos adicionar algumas outras que lidam com a geração de tokens com o 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>

Com relação a modelagem e a persistência dos tokens, serão criados três novas entidades. Uma coisa importante é que as regras de encapsulamento continuam sendo importantes. E é apenas por isso, que estamos utilizando a visibilidade privada e métodos de acesso público, em casos que não temos outra opção. Essa é uma boa regra que o Java Efetivo tanto fala, além de ser uma boa prática para segurança. Um ponto importante é que essas entidades além das notações do Jakarta NoSQL também utilizam as notações do JSONB. A razão disso, é que o Redis armazena as informações como texto e, a estratégia de armazenamento que o driver do Redis utiliza, é em JSON, utilizando algum tipo de implementação do mundo 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;

    //...
}

Indo para a regra de geração e de atualização desses tokens, temos a classe Oauth2Service que gerenciará toda a lógica do mecanismo de armazenamento. A validação dos dados será realizada a partir do Bean Validation e, como ela parte do contexto, tanto para criação quanto para a atualização do token, criamos um grupo para cada contexto. Assim, como no MongoDB, a API de value-key tem a possibilidade de utilizar o Repository, porém, como as operações são bastante simples, será utilizado o KeyValueTemplate. Uma ponto importante é quando o refresh token é persistido, tendo um segundo parâmetro que define o TTL. Isso quer dizer que, depois de um determinado tempo, a informação será automaticamente apagada do 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 @interface  RefreshToken{}
}

Dentro do recurso do Oauth2 é possível ver que temos um método e duas operações. É um dos motivos que o no código, o método getGrantType, retorna um enum com duas opções.

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

Com a possibilidade de criar e atualizar os tokens, seguimos para o segundo passo que se baseia na criação do mecanismo. A classe Oauth2Authentication recebe a requisição e busca pelo header "Authorization" validando usando o regex. Uma vez validado o Header, o próximo passo é verificar a existência desse token dentro do banco de dados, nesse caso usamos o Redis. Uma vez verificado a existência do refresh token, enviamos o ID do usuário para o mecanismo de armazenamento representado pelo IdentityStoreHandler.

A classe de identificação precisou de algumas mudanças em comparação com a segunda parte. Ela continua carregando as informações do usuário e as regras de permissão, porém, não existe a validação de senha, pois tudo isso é feito pelo identificador do usuário.

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

Um ponto importante é que apesar do mecanismo de autenticação depender do banco de dados, eles não se conhecem, graças a API de segurança. Seguindo a regra de domínio e subdomínio, é permitido que o subdomínio Oauth2 conheça a API de segurança, porém, não é permitido que o contrário ocorra, por diversos motivos. Essa dependência cíclica traz diversos problemas, como por exemplo, se em algum momento quisermos mover a lógica para um servidor, ficará mais difícil, pois existe uma dificuldade de fazer a manutenção do software desta maneira. Uma jeito simples de pensar na dependência cíclica é refletir sobre a clássica pergunta do ovo e da galinha.

Porém, eis que surge um problema: Quando um usuário for removido, também é importante que os respectivos tokens sejam removidos. Uma maneira de criar essa separação é através de eventos. Como estamos utilizando o CDI, será feita a partir de eventos dele.

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) {
     //....

    }
}

Movendo para a nuvem

Na nossa série de artigos, estamos utilizando um PaaS com o objetivo de simplificar a complexidade do hardware e da segurança de acesso entre os containers. Afinal, seria tudo em vão se fizéssemos um controle rígido no software, e o banco de dados estivesse com um IP público, para toda a internet. Assim, iremos manter a estratégia de utilizar o Platform.sh.

Apenas mencionaremos a mudança do arquivo de serviços para adicionar mais um banco de dados, nesse caso o Redis:

mongodb:
  type: mongodb:3.6
  disk: 1024

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

Será necessário realizar a modificação dentro do arquivo de configuração da aplicação. O objetivo é fornecer credenciais para que o container da aplicação tenha acesso ao container dos bancos de dados.

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

Com isso nós falamos dos conceitos e do desenho de um mecanismo Oauth2 de uma maneira prática, utilizando o Jakarta Security. Um ponto importante é que, para cada requisição, temos a necessidade de realizar uma busca dentro da base de dados, uma vez que o token não passa de um ponteiro para a informação, porém, ela não é a informação em si. Existe uma maneira interessante de se utilizar o token para colocar informações, por exemplo, combinando o token com JWT. Essa combinação entre o Oauth2 e JWT serão cenas do próximo capítulo.

Como sempre, o exemplo do código pode ser encontrado no GitHub.

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 terceiro 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 e Jakarta Security e Rest na nuvem: Parte 2. Conhecendo o Básico do Basic.

Avalie esse artigo

Relevância
Estilo/Redação

Conteúdo educacional

BT