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:
- 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;
- O resultado dessa requisição resultará em um par de tokens: O access_token e o refresh_token;
- A partir desse momento, toda requisição será feita com o "access_token";
- O access_token irá expirar em algum momento tornando-o inútil;
- 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;
- 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.