Jakarta Security and REST on Cloud: Part 4 Combining JWT With OAuth2
Join the DZone community and get the full member experience.
Join For FreeOAuth2 is undoubtedly one of the most famous security protocols today. One of its advantages is the non-exposure of sensitive information, such as user and password, consistently, as done by the BASIC mechanism. However, there is an increase in its complexity, especially when we talk about exchanging tokens, which do not have much use since it does not contain any information. However, we can make them have a little responsibility, such as transporting information safely. This post will talk a little about how to integrate OAuth2 with JWTs.
In part 3 of this series, we talked about the Oauth2 mechanism and the costs and benefits involving complexity and the possibility of not overexposing login and password data. One of the outstanding characteristics of this mechanism is found in the security communication from the token. Until then, it has only one use: Reference. This pointer works like a link. However, it does not have the information itself. It works as an Oauth2 mechanism. We send a token, which in turn is checked for existence in the database so that the user's authentication information and respective credentials are searched. That is, the user's entire state is in the bank.
This approach can cause performance problems, since we need to verify the authenticity of such a token, being necessary to search the information in the database in every interaction. That is, if a service needs to integrate with four others within the same authentication and authorization mechanism, it will need to seek the same state of the user all four times.
A new strategy would be to use the token in a complete way instead of just as a pointer. This means that if we were able to store user information with the symbol itself, we would have a performance gain. This is possible thanks to JSON Web Tokens, an open industry standard for representing secure communication between parties. With JWTs, we have the option to sign and/or encrypt the information we want; in this case, it would be the user and the roles to be used. The focus of the article is not to talk about JWT, but about the integration with Oauth2. If the reader wants to know more information about the subject, there is a very cool Handbook about JWTs.
Let's start by getting hands-on using the design of part 3, so we don't have to start from scratch. The first step will be to add the JWT to the dependency. This library will be responsible for reading and writing the JWT.
xxxxxxxxxx
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
With the dependencies defined, the next step will be to modify the code itself. In entities, AccessToken
will be modified, as we will change the ID so that it can receive the JWT as String, in addition to storing the secret. This secret will be unique and randomly generated for each entity and will be responsible for confirming the signature and for verifying the authenticity of the JWT. It is worth noting that the signature is responsible for verifying the authenticity of the JWT and for confirming that it has not been modified, that is, the signature does not mean encryption. For more information on JWT and cryptography, we can access the link for JWE.
xxxxxxxxxx
FieldPropertyVisibilityStrategy.class) (
public class AccessToken {
static final String PREFIX = "access_token:";
private String id;
private String user;
private String token;
private String jwtSecret;
//...
}
To facilitate the reading and manipulation of the JWT, the UserJWT
will be created. This class will generate the JWT in text format. It is interesting to note that there is a check on creation in the UserJWT
factory method. This verification is done by the signature, previously explained, and also by the expiration date.
xxxxxxxxxx
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();
}
}
}
Once the modeling has modified, the next step is to change the OAuth2 service. The interesting point is that in addition to the existing TTL, the JWT also checks the data expiration. That is, there is a double check of data consistency in this signal.
xxxxxxxxxx
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;
class Oauth2Service {
static final int EXPIRE_IN = 3600;
static final Duration EXPIRES = Duration.ofSeconds(EXPIRE_IN);
private SecurityService securityService;
name = "keyvalue") (
private KeyValueTemplate template;
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;
}
}
The last step will be to change the mechanism. In general, user information will not be retrieved from the database, but from the JWT itself.
xxxxxxxxxx
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;
public class Oauth2Authentication implements HttpAuthenticationMechanism {
private static final Pattern CHALLENGE_PATTERN
= Pattern.compile("^Bearer *([^ ]+) *$", Pattern.CASE_INSENSITIVE);
name = "keyvalue") (
private KeyValueTemplate template;
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();
}
}
}
Moving to the Cloud
As there was no change in services or structure of the containers, the structure and configuration for sending the application to the cloud with Platform.sh will be maintained in the same way.
There, the mechanism was refactored not to use the information in the database, thus reducing the number of requests. However, we have a big problem with data consistency, since there is still the possibility of a change in the database that will be reflected only when the token expires. The user would need to update their tokens with the refresh token. There is a strategy to work with this, which would be to launch events to make the current token unfeasible, forcing the user to do the refresh token process early.
The critical point is that there is always a disadvantage between data consistency and availability, and it is up to the architect to understand each case and choose the best strategy. The main objective of this series of articles is to know how the security API works. However, every line of code we write is code that will need to be maintained, so using an existing solution may be the best option. There are security solutions that already exist and are worth taking a look at, like Okta and Keycloak.
As always, the code example is from GitHub.
Opinions expressed by DZone contributors are their own.
Comments