Points Clés
- La sécurité en Java
- L’API Jakarta Security
- OAuth2 et JWT
- Cloud
- PaaS
Oauth2 est sans aucun doute l'un des protocoles de sécurité les plus connus aujourd'hui. L'un de ses avantages est la non-exposition d'informations sensibles, telles que l'utilisateur et le mot de passe, de manière cohérente, comme le fait le mécanisme BASIC. Cependant, il y a une augmentation de sa complexité, surtout quand on parle d'échange de jetons, qui n'ont pas beaucoup d'utilité puisqu'ils ne contiennent aucune information. Cependant, nous pouvons leur faire assumer une petite responsabilité, comme le transport d'informations en toute sécurité. Cet article expliquera comment intégrer OAuth2 avec JWT.
Dans la troisième partie de cette série, nous avons parlé du mécanisme OAuth2 et des coûts et avantages liés à la complexité et à la possibilité de ne pas surexposer les données de connexion et de mot de passe. L'une des caractéristiques remarquables de ce mécanisme se trouve dans la communication de sécurité du jeton. Jusque-là, il n'a qu'une seule utilité : la référence. Ce pointeur fonctionne comme un lien. Cependant, il ne dispose pas des informations proprement dites. Il fonctionne comme un mécanisme OAuth2. Nous envoyons un jeton, qui à son tour est vérifié pour l'existence dans la base de données afin que les informations d'authentification de l'utilisateur et les informations d'identification respectives soient recherchées. Autrement dit, l'état entier de l'utilisateur est dans la base.
Cette approche peut entraîner des problèmes de performances, car nous devons vérifier l'authenticité d'un tel jeton, impliquant de rechercher les informations dans la base de données à chaque interaction. Autrement dit, si un service doit s'intégrer à quatre autres dans le même mécanisme d'authentification et d'autorisation, il devra rechercher le même état de l'utilisateur quatre fois.
Une nouvelle stratégie serait que nous puissions utiliser le jeton de manière complète au lieu d'être simplement un pointeur. Cela signifie que si nous pouvions stocker des informations utilisateur avec le symbole lui-même, nous aurions un gain de performances. Ceci est possible grâce aux JSON Web Token, une norme industrielle ouverte pour représenter une communication sécurisée entre les parties. Avec JWT, nous avons la possibilité de signer et / ou de chiffrer les informations que nous voulons; dans ce cas, ce serait l'utilisateur et les rôles à utiliser. L'objectif de l'article n'est pas de parler de JWT, mais de son intégration avec OAuth2. Si le lecteur veut en savoir plus sur le sujet, il existe un manuel très sympa sur JWT.
Commençons par nous familiariser avec la conception de la partie 3, pour ne pas avoir à partir de zéro. La première étape consistera à ajouter JWT aux dépendances. Cette bibliothèque sera responsable de la lecture et de l'écriture de jetons JWT.
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
Une fois les dépendances définies, l'étape suivante consistera à modifier le code lui-même. Dans les entités, AccessToken sera modifié, car nous changerons l'ID afin qu'il puisse recevoir le JWT sous forme de chaîne, en plus de stocker le secret. Ce secret sera unique et généré aléatoirement pour chaque entité et sera chargé de confirmer la signature et de vérifier l'authenticité du JWT. Il est à noter que la signature est chargée de vérifier l'authenticité du JWT et de confirmer qu'il n'a pas été modifié, c'est-à-dire que la signature ne signifie pas chiffrement. Pour plus d'informations sur JWT et la cryptographie, nous pouvons accéder au lien concernant 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;
//...
}
Pour faciliter la lecture et la manipulation du JWT, la classe UserJWT sera créée. Cette classe générera le JWT au format texte. Il est intéressant de noter qu'il existe un contrôle à la création dans la méthode de fabrique UserJWT. Cette vérification se fait par la signature, expliquée précédemment, et également par la date d'expiration.
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();
}
}
}
Une fois la modélisation modifiée, l'étape suivante consiste à changer le service OAuth2. Le point intéressant est qu'en plus du TTL existant, le JWT vérifie également l'expiration des données. Autrement dit, il y a une double vérification de la cohérence des données.
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;
}
}
La dernière étape sera de changer le mécanisme. En général, les informations utilisateur ne seront pas extraites de la base de données, mais du JWT lui-même.
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();
}
}
}
Déploiement dans le cloud
Comme il n'y a pas eu de changement dans les services ou la structure des conteneurs, la structure et la configuration d'envoi de l'application vers le cloud avec Platform.sh seront maintenues de la même manière. Là, le mécanisme a été remanié pour ne pas utiliser les informations de la base de données, réduisant ainsi le nombre de demandes. Cependant, nous avons un gros problème avec la cohérence des données, car il y a toujours la possibilité d'un changement dans la base de données qui ne sera reflété que lorsque le jeton expirera. L'utilisateur devra mettre à jour ses jetons avec le jeton d'actualisation (refresh token). Il existe une stratégie pour travailler avec cela, qui consisterait à lancer des événements pour rendre le jeton actuel inutilisable, obligeant l'utilisateur à effectuer le processus d'actualisation du jeton précocement. Le point critique est qu'il y a toujours un désavantage entre la cohérence et la disponibilité des données, et c'est à l'architecte de comprendre chaque cas et de choisir la meilleure stratégie. L'objectif principal de cette série d'articles est de savoir comment fonctionne l'API Security. Cependant, chaque ligne de code que nous écrivons est du code qui devra être maintenu, donc utiliser une solution existante peut être la meilleure option. Il existe déjà des solutions de sécurité qui méritent d'être examinées, comme Okta et Keycloak. Comme toujours, l'exemple de code est disponible 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.