Este é o quarto 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, Jakarta Security e Rest na nuvem: Parte 2. Conhecendo o Básico do Basic e Jakarta Security e Rest na nuvem: Parte 3. Conhecendo o Básico do Oauth2.
Fonte: https://br.freepik.com/fotos-vetores-gratis/tecnologia
O Oauth2 certamente é um dos protocolos de segurança mais famosos da atualidade. Uma de suas vantagens, é a não exposição das informações sensíveis, como usuário e senha de maneira constante, como é feito pelo mecanismo BASIC. Porém, existe um aumento na sua complexidade, principalmente quando falamos da troca de tokens, que não possuem muita serventia, uma vez que não contém nenhuma informação. Entretanto, podemos fazer com que eles tenham um pouco de responsabilidade, como o transporte de informações de maneira segura. Esse post falará um pouco sobre como integrar Oauth2 com o JWT.
Na parte 3 desta série, falamos sobre o mecanismo Oauth2 e sobre os custos e benefícios envolvendo a complexidade e a possibilidade de não expor em demasia os dados de login e senha. Uma das características marcantes desse mecanismo se encontra na comunicação de segurança a partir do token. Até então, ele tem uma única serventia: Referência. Esse ponteiro funciona como o link, porém, não tem a informação em si. Basicamente, funciona como um mecanismo Oauth2. Enviamos um token, que por sua vez é verificado sua existência no banco de dados para que se procure as informações de autenticação do usuário e as respectivas credenciais. Ou seja, todo estado do usuário se encontra no banco.
Essa abordagem pode acarretar problemas de performance, uma vez que precisamos verificar a autenticidade de tal token, sendo necessário que se busque as informações no banco de dados em toda interação. Ou seja, caso um serviço precise integrar com outros quatro dentro do mesmo mecanismo de autenticação e autorização ele precisará buscar o mesmo estado do usuário todas as quatro vezes.
Uma estratégia interessante seria se pudéssemos usar o token de uma forma mais completa ao invés de ser só um ponteiro. Isso significa que, se conseguíssemos guardar as informações do usuário junto do token em si, teríamos um ganho de desempenho. Isso é possível graças ao JSON Web Tokens, um padrão aberto da indústria para representar a comunicação segura entre as partes. Com o JWT temos a opção de assinar e/ou criptografar as informações que desejamos, nesse caso, seriam o usuário e as roles para serem utilizados. O foco do artigo não é falar sobre o JWT em sim, mas sobre a integração com Oauth2, por isso, caso o leitor queira saber mais informações sobre o assunto existe um Handbook muito legal sobre o JWT.
Vamos começar colocando a mão na massa usando como base o projeto da parte 3, assim, não precisamos começar do zero. O primeiro passo será adicionar o JWT a dependência. Essa biblioteca será responsável por ler e escrever o JWT.
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
Com as dependências definidas, o próximo passo será a modificação do código em si. Nas entidades, o AccessToken será modificado, pois iremos mudar o ID para que possa receber o JWT como String, além de armazenar o secret. Esse secret será único e gerado randomicamente para cada entidade e será responsável por confirmar a assinatura e para verificar a autenticidade do JWT. Vale salientar que a assinatura é responsável por verificar a autenticidade do JWT e para confirmar que ele não foi modificado, ou seja, a assinatura não quer dizer criptografia. Para obter maiores informações sobre JWT e criptografia, podemos acessar o link referente ao JWE.
@Entity
@JsonbVisibility(FieldPropertyVisibilityStrategy.class)
public class AccessToken {
static final String PREFIX = "access_token:";
@Id
private String id;
@JsonbProperty
private String user;
@JsonbProperty
private String token;
@JsonbProperty
private String jwtSecret;
//...
}
Para facilitar a leitura e a manipulação do JWT, será criado o UserJWT. Essa classe gerará o JWT em formato de texto. É interessante observar que existe uma verificação na criação no factory method do UserJWT. Essa verificação se dá pela assinatura, explicado previamente, e também pela data de expiração.
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import sh.platform.sample.security.User;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.Optional;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
class UserJWT {
private static final Logger LOGGER = Logger.getLogger(UserJWT.class.getName());
private static final String ISSUER = "jakarta";
private static final String ROLES = "roles";
private final String user;
private final Set<String> roles;
UserJWT(String user, Set<String> roles) {
this.user = user;
this.roles = roles;
}
public String getUser() {
return user;
}
public Set<String> getRoles() {
if (roles == null) {
return Collections.emptySet();
}
return roles;
}
static String createToken(User user, Token token, Duration duration) {
final LocalDateTime expire = LocalDateTime.now(ZoneOffset.UTC).plusMinutes(duration.toMinutes());
Algorithm algorithm = Algorithm.HMAC256(token.get());
return JWT.create()
.withJWTId(user.getName())
.withIssuer(ISSUER)
.withExpiresAt(Date.from(expire.atZone(ZoneOffset.UTC).toInstant()))
.withClaim(ROLES, new ArrayList<>(user.getRoles()))
.sign(algorithm);
}
static Optional<UserJWT> parse(String jwtText, Token token) {
Algorithm algorithm = Algorithm.HMAC256(token.get());
try {
JWTVerifier verifier = JWT.require(algorithm).withIssuer(ISSUER).build();
final DecodedJWT jwt = verifier.verify(jwtText);
final Claim roles = jwt.getClaim(ROLES);
return Optional.of(new UserJWT(jwt.getId(),
roles.asList(String.class).stream().collect(Collectors.toUnmodifiableSet())));
} catch (JWTVerificationException exp) {
LOGGER.log(Level.WARNING, "There is an error to load the JWT token", exp);
return Optional.empty();
}
}
}
Uma vez modificado a modelagem, o próximo passo é alteração do serviço do Ouaht2. Um ponto interessante é que além do TTL existente, o JWT também verifica a expiração dos dados, ou seja, existe uma dupla verificação de consistência dos dados neste sinal.
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();
final String jwt = UserJWT.createToken(user, token, EXPIRES);
AccessToken accessToken = new AccessToken(jwt, token, user.getName());
RefreshToken refreshToken = new RefreshToken(userToken, jwt, 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 User user = securityService.findBy(refreshToken.getUser());
final Token token = Token.generate();
final String jwt = UserJWT.createToken(user, token, EXPIRES);
AccessToken accessToken = new AccessToken(jwt, token, refreshToken.getUser());
refreshToken.update(accessToken, userToken, template);
template.put(accessToken, EXPIRES);
final Oauth2Response response = Oauth2Response.of(accessToken, refreshToken, EXPIRE_IN);
return response;
}
}
O último passo será a alteração do mecanismo.De uma maneira geral, não serão recuperadasas informações do usuário dentro do banco de dados, mas do JWT em si.
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.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ApplicationScoped
public class Oauth2Authentication implements HttpAuthenticationMechanism {
private static final Pattern CHALLENGE_PATTERN
= Pattern.compile("^Bearer *([^ ]+) *$", Pattern.CASE_INSENSITIVE);
@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 Optional<UserJWT> optionalUserJWT = UserJWT.parse(accessToken.getToken(), accessToken.getJwtSecretAsToken());
if (optionalUserJWT.isPresent()) {
final UserJWT userJWT = optionalUserJWT.get();
return httpMessageContext.notifyContainerAboutLogin(userJWT.getUser(), userJWT.getRoles());
} else {
return httpMessageContext.responseUnauthorized();
}
}
}
Movendo para a nuvem
Como não houve nenhuma mudança de serviços ou de estrutura dos containers, a estrutura e a configuração para enviar a aplicação para nuvem com o Platform.sh será mantida da mesma forma.
Pronto, o mecanismo foi refatorado para não utilizar a informação no banco de dados diminuindo assim a quantidade de requisições. Porém, temos um grande problema com a consistência dos dados, uma vez que ainda existe a possibilidade de uma alteração no banco de dados que será refletido apenas quando o token expirar e o usuário precisaria atualizar seus tokens com o refresh token. Existe uma estratégia para trabalhar com isso, que seria lançar eventos para inviabilizar o atual token, forçando o usuário fazer o processo de refresh token precocemente.
Com isso, encerramos o artigo sobre como integrar o Oauth2 com JWT. Um ponto importante é que sempre existe uma desvantagem entre a consistência dos dados e a disponibilidade, cabendo ao arquiteto entender cada caso e escolher a melhor estratégia. Um ponto importante é que o maior objetivo dessa série de artigos é conhecer o funcionamento da API de segurança. Porém, cada código que criamos é um código que será mantido, assim, utilizar uma solução já existente pode ser a melhor opção. O objetivo dessa série é para falar do funcionamento interno dos mecanismos, além de explorar os recursos dentro do Jakarta Security. Existem soluções de segurança já existentes e que valem a pena dar uma olhada, como o Okta e o Keycloak.
Como sempre, o exemplo do código se encontra do 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 quarto 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, Jakarta Security e Rest na nuvem: Parte 2. Conhecendo o Básico do Basic e Jakarta Security e Rest na nuvem: Parte 3. Conhecendo o Básico do Oauth2.