diff --git a/api/src/main/java/com/gentics/mesh/etc/config/AuthenticationOptions.java b/api/src/main/java/com/gentics/mesh/etc/config/AuthenticationOptions.java index e8d14fdd73..c89fe3f0c3 100644 --- a/api/src/main/java/com/gentics/mesh/etc/config/AuthenticationOptions.java +++ b/api/src/main/java/com/gentics/mesh/etc/config/AuthenticationOptions.java @@ -26,8 +26,12 @@ public class AuthenticationOptions implements Option { public static final String DEFAULT_ALGORITHM = "HS256"; + public static final String DEFAULT_ISSUER = "Gentics Mesh"; public static final int DEFAULT_TOKEN_EXPIRATION_TIME = 60 * 60; // 1 hour + public static final int DEFAULT_LEEWAY = 0; + + public static final boolean DEFAULT_IGNORE_EXPIRATION = false; public static final String DEFAULT_KEYSTORE_PATH = CONFIG_FOLDERNAME + "/keystore.jceks"; @@ -37,6 +41,10 @@ public class AuthenticationOptions implements Option { public static final String MESH_AUTH_KEYSTORE_PASS_ENV = "MESH_AUTH_KEYSTORE_PASS"; public static final String MESH_AUTH_KEYSTORE_PATH_ENV = "MESH_AUTH_KEYSTORE_PATH"; public static final String MESH_AUTH_JWT_ALGO_ENV = "MESH_AUTH_JWT_ALGO"; + public static final String MESH_AUTH_JWT_LEEWAY_ENV = "MESH_AUTH_JWT_LEEWAY"; + public static final String MESH_AUTH_JWT_AUDIENCE_ENV = "MESH_AUTH_JWT_AUDIENCE"; + public static final String MESH_AUTH_JWT_ISSUER_ENV = "MESH_AUTH_JWT_ISSUER"; + public static final String MESH_AUTH_JWT_IGNORE_EXPIRATION_ENV = "MESH_AUTH_JWT_IGNORE_EXPIRATION"; public static final String MESH_AUTH_ANONYMOUS_ENABLED_ENV = "MESH_AUTH_ANONYMOUS_ENABLED"; public static final String MESH_AUTH_PUBLIC_KEYS_PATH_ENV = "MESH_AUTH_PUBLIC_KEYS_PATH"; @@ -60,6 +68,26 @@ public class AuthenticationOptions implements Option { @EnvironmentVariable(name = MESH_AUTH_JWT_ALGO_ENV, description = "Override the configured algorithm which is used to sign the JWT.") private String algorithm = DEFAULT_ALGORITHM; + @JsonProperty(required = false) + @JsonPropertyDescription("Leeway (in seconds) of how long a JWT should still be considered valid.") + @EnvironmentVariable(name = MESH_AUTH_JWT_LEEWAY_ENV, description = "Override the configured Leeway (in seconds) of how long a JWT should still be considered valid.") + private int leeway = DEFAULT_LEEWAY; + + @JsonProperty(required = false) + @JsonPropertyDescription("The issuer of the JWT which is also written into the token.") + @EnvironmentVariable(name = MESH_AUTH_JWT_ISSUER_ENV, description = "Override the configured issuer of the JWT.") + private String issuer = DEFAULT_ISSUER; + + @JsonProperty(required = false) + @JsonPropertyDescription("The expected audience of the JWT.") + @EnvironmentVariable(name = MESH_AUTH_JWT_AUDIENCE_ENV, description = "Override the configured audience of the JWT.") + private List audience = null; + + @JsonProperty(required = false) + @JsonPropertyDescription("If expired JWT should still be accepted and processed.") + @EnvironmentVariable(name = MESH_AUTH_JWT_IGNORE_EXPIRATION_ENV, description = "Overrides if an expired JWT should still be accepted and processed.") + private boolean ignoreExpiration = DEFAULT_IGNORE_EXPIRATION; + @JsonProperty(required = false) @JsonPropertyDescription("Flag which indicates whether anonymous access should be enabled.") @EnvironmentVariable(name = MESH_AUTH_ANONYMOUS_ENABLED_ENV, description = "Override the configured anonymous enabled flag.") @@ -140,6 +168,46 @@ public AuthenticationOptions setAlgorithm(String algorithm) { return this; } + public int getLeeway() { + return leeway; + } + + @Setter + public AuthenticationOptions setLeeway(int leeway) { + this.leeway = leeway; + return this; + } + + public String getIssuer() { + return issuer; + } + + @Setter + public AuthenticationOptions setIssuer(String issuer) { + this.issuer = issuer; + return this; + } + + public List getAudience() { + return audience; + } + + @Setter + public AuthenticationOptions setAudience(List audience) { + this.audience = audience; + return this; + } + + public boolean isIgnoreExpiration() { + return ignoreExpiration; + } + + @Setter + public AuthenticationOptions setIgnoreExpiration(boolean ignoreExpiration) { + this.ignoreExpiration = ignoreExpiration; + return this; + } + public boolean isEnableAnonymousAccess() { return enableAnonymousAccess; } diff --git a/changelog/src/changelog/entries/2025/02/8159.GPU-485.feature b/changelog/src/changelog/entries/2025/02/8159.GPU-485.feature new file mode 100644 index 0000000000..8890e9c911 --- /dev/null +++ b/changelog/src/changelog/entries/2025/02/8159.GPU-485.feature @@ -0,0 +1 @@ +Authentication: Added JWT options to configure token content (`issuer` - `"iss"`, `audience` - `"aud"`) and token verification (`leeway` and `ignoreExpiration`). \ No newline at end of file diff --git a/common/src/main/java/com/gentics/mesh/auth/provider/MeshJWTAuthProvider.java b/common/src/main/java/com/gentics/mesh/auth/provider/MeshJWTAuthProvider.java index 60780c7660..7809db2a30 100644 --- a/common/src/main/java/com/gentics/mesh/auth/provider/MeshJWTAuthProvider.java +++ b/common/src/main/java/com/gentics/mesh/auth/provider/MeshJWTAuthProvider.java @@ -10,6 +10,8 @@ import org.apache.commons.lang.NotImplementedException; import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import com.gentics.mesh.auth.AuthenticationResult; @@ -22,6 +24,7 @@ import com.gentics.mesh.etc.config.AuthenticationOptions; import com.gentics.mesh.etc.config.MeshOptions; import com.gentics.mesh.shared.SharedKeys; +import com.gentics.mesh.util.JWTUtil; import io.vertx.core.AsyncResult; import io.vertx.core.Future; @@ -29,8 +32,6 @@ import io.vertx.core.Vertx; import io.vertx.core.http.Cookie; import io.vertx.core.json.JsonObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import io.vertx.ext.auth.JWTOptions; import io.vertx.ext.auth.KeyStoreOptions; import io.vertx.ext.auth.User; @@ -50,24 +51,35 @@ public class MeshJWTAuthProvider implements AuthenticationProvider, JWTAuth { private static final Logger log = LoggerFactory.getLogger(MeshJWTAuthProvider.class); - private JWTAuth jwtProvider; - private static final String USERID_FIELD_NAME = "userUuid"; - private static final String API_KEY_TOKEN_CODE_FIELD_NAME = "jti"; + protected final Vertx vertx; protected final Database db; - private BCryptPasswordEncoder passwordEncoder; - + private JWTAuth jwtProvider; + private final BCryptPasswordEncoder passwordEncoder; private final MeshOptions meshOptions; @Inject public MeshJWTAuthProvider(Vertx vertx, MeshOptions meshOptions, BCryptPasswordEncoder passwordEncoder, Database database, BootstrapInitializer boot) { + this.vertx = vertx; this.meshOptions = meshOptions; this.passwordEncoder = passwordEncoder; this.db = database; + } + + /** + * Delayed initialize function, which is only executed once this provider is actually needed + * ({@link #authenticateJWT(JsonObject, Handler) and {@link #generateToken(String, String, String)}}. + * This is done, to make testing possible, as otherwise the provider would be initialized with the + * settings, before the tests could change them (and therefore make it impossible to test different configurations). + */ + public void initialize() { + if (this.jwtProvider != null) { + return; + } // Use the mesh JWT options in order to setup the JWTAuth provider AuthenticationOptions options = meshOptions.getAuthenticationOptions(); @@ -79,8 +91,10 @@ public MeshJWTAuthProvider(Vertx vertx, MeshOptions meshOptions, BCryptPasswordE String keyStorePath = options.getKeystorePath(); String type = "jceks"; JWTAuthOptions config = new JWTAuthOptions(); + // Set JWT options from the config + config.setJWTOptions(JWTUtil.createJWTOptions(options)); config.setKeyStore(new KeyStoreOptions().setPath(keyStorePath).setPassword(keystorePassword).setType(type)); - jwtProvider = JWTAuth.create(vertx, new JWTAuthOptions(config)); + this.jwtProvider = JWTAuth.create(vertx, new JWTAuthOptions(config)); } /** @@ -92,6 +106,8 @@ public MeshJWTAuthProvider(Vertx vertx, MeshOptions meshOptions, BCryptPasswordE * @param resultHandler */ public void authenticateJWT(JsonObject authInfo, Handler> resultHandler) { + initialize(); + // Decode and validate the JWT. A JWTUser will be returned which contains the decoded token. // We will use this information to load the Mesh User from the graph. jwtProvider.authenticate(authInfo, rh -> { @@ -100,20 +116,21 @@ public void authenticateJWT(JsonObject authInfo, Handler { return tx.userDao().findByUsername(username); }); - if (user != null) { - String accountPasswordHash = db.tx(user::getPasswordHash); - // TODO check if user is enabled - boolean hashMatches = false; - if (StringUtils.isEmpty(accountPasswordHash) && password != null) { - if (log.isDebugEnabled()) { - log.debug("The account password hash or token password string are invalid."); - } - throw error(UNAUTHORIZED, "auth_login_failed"); - } else { - if (log.isDebugEnabled()) { - log.debug("Validating password using the bcrypt password encoder"); - } - hashMatches = passwordEncoder.matches(password, accountPasswordHash); + if (user == null) { + if (log.isDebugEnabled()) { + log.debug("Could not load user with username {" + username + "}."); } - if (hashMatches) { - boolean forcedPasswordChange = db.tx(user::isForcedPasswordChange); - if (forcedPasswordChange && newPassword == null) { - throw error(BAD_REQUEST, "auth_login_password_change_required"); - } else if (!forcedPasswordChange && newPassword != null) { - throw error(BAD_REQUEST, "auth_login_newpassword_failed"); - } else { - if (forcedPasswordChange) { - db.tx(tx -> { return tx.userDao().setPassword(user, newPassword); }); - } - return user; - } - } else { - throw error(UNAUTHORIZED, "auth_login_failed"); + // TODO Don't let the user know that we know that he did not exist? + throw error(UNAUTHORIZED, "auth_login_failed"); + } + + String accountPasswordHash = db.tx(user::getPasswordHash); + // TODO check if user is enabled + boolean hashMatches = false; + if (StringUtils.isEmpty(accountPasswordHash) && password != null) { + if (log.isDebugEnabled()) { + log.debug("The account password hash or token password string are invalid."); } + throw error(UNAUTHORIZED, "auth_login_failed"); } else { if (log.isDebugEnabled()) { - log.debug("Could not load user with username {" + username + "}."); + log.debug("Validating password using the bcrypt password encoder"); } - // TODO Don't let the user know that we know that he did not exist? + hashMatches = passwordEncoder.matches(password, accountPasswordHash); + } + if (hashMatches) { + boolean forcedPasswordChange = db.tx(user::isForcedPasswordChange); + if (forcedPasswordChange && newPassword == null) { + throw error(BAD_REQUEST, "auth_login_password_change_required"); + } else if (!forcedPasswordChange && newPassword != null) { + throw error(BAD_REQUEST, "auth_login_newpassword_failed"); + } else { + if (forcedPasswordChange) { + db.tx(tx -> { return tx.userDao().setPassword(user, newPassword); }); + } + return user; + } + } else { throw error(UNAUTHORIZED, "auth_login_failed"); } } @@ -207,18 +225,19 @@ private HibUser authenticate(String username, String password, String newPasswor * @return The new token */ public String generateToken(User user) { - if (user instanceof MeshAuthUser) { - AuthenticationOptions options = meshOptions.getAuthenticationOptions(); - JsonObject tokenData = new JsonObject(); - String uuid = db.tx(((MeshAuthUser) user).getDelegate()::getUuid); - tokenData.put(USERID_FIELD_NAME, uuid); - JWTOptions jwtOptions = new JWTOptions().setAlgorithm(options.getAlgorithm()) - .setExpiresInSeconds(options.getTokenExpirationTime()); - return jwtProvider.generateToken(tokenData, jwtOptions); - } else { + initialize(); + + if (!(user instanceof MeshAuthUser)) { log.error("Can't generate token for user of type {" + user.getClass().getName() + "}"); throw error(INTERNAL_SERVER_ERROR, "error_internal"); } + + AuthenticationOptions options = meshOptions.getAuthenticationOptions(); + JsonObject tokenData = new JsonObject(); + String uuid = db.tx(((MeshAuthUser) user).getDelegate()::getUuid); + tokenData.put(USERID_FIELD_NAME, uuid); + JWTOptions jwtOptions = JWTUtil.createJWTOptions(options); + return jwtProvider.generateToken(tokenData, jwtOptions); } /** @@ -231,11 +250,13 @@ public String generateToken(User user) { * @return Generated API key */ public String generateAPIToken(HibUser user, String tokenCode, Integer expireDuration) { + initialize(); + AuthenticationOptions options = meshOptions.getAuthenticationOptions(); JsonObject tokenData = new JsonObject() .put(USERID_FIELD_NAME, user.getUuid()) .put(API_KEY_TOKEN_CODE_FIELD_NAME, tokenCode); - JWTOptions jwtOptions = new JWTOptions().setAlgorithm(options.getAlgorithm()); + JWTOptions jwtOptions = JWTUtil.createJWTOptions(options); if (expireDuration != null) { jwtOptions.setExpiresInMinutes(expireDuration); } @@ -268,7 +289,7 @@ private User loadUserByJWT(JsonObject jwt) throws Exception { // } // Check whether the token might be an API key token - if (!jwt.containsKey("exp")) { + if (!jwt.containsKey(JWTUtil.JWT_FIELD_EXPIRATION)) { String apiKeyToken = jwt.getString(API_KEY_TOKEN_CODE_FIELD_NAME); // TODO: All tokens without exp must have a token code - See https://github.com/gentics/mesh/issues/412 if (apiKeyToken != null) { diff --git a/common/src/main/java/com/gentics/mesh/util/JWTUtil.java b/common/src/main/java/com/gentics/mesh/util/JWTUtil.java new file mode 100644 index 0000000000..0a87d8d89f --- /dev/null +++ b/common/src/main/java/com/gentics/mesh/util/JWTUtil.java @@ -0,0 +1,108 @@ +package com.gentics.mesh.util; + +import java.util.List; + +import com.gentics.mesh.etc.config.AuthenticationOptions; + +import io.vertx.ext.auth.JWTOptions; + +/** + * Utility class which contains static helper functions for JWT creation and manipulation. + */ +public class JWTUtil { + + /** + * Helper class which delegates the currently set Authentication-Options as JWTOption. + * This is currently only useful for testing, as the options change after each test and need to be + * represented in the JWT signing/validation process. + */ + private static class JWTDelegateOptions extends JWTOptions { + private AuthenticationOptions options; + + public JWTDelegateOptions(AuthenticationOptions options) { + this.options = options; + } + + @Override + public int getExpiresInSeconds() { + return options.getTokenExpirationTime(); + } + + @Override + public JWTOptions setExpiresInSeconds(int tokenExpirationTime) { + options.setTokenExpirationTime(tokenExpirationTime / 60); + return this; + } + + @Override + public JWTOptions setExpiresInMinutes(int expiresInMinutes) { + options.setTokenExpirationTime(expiresInMinutes); + return this; + } + + @Override + public String getAlgorithm() { + return options.getAlgorithm(); + } + + @Override + public JWTOptions setAlgorithm(String algorithm) { + options.setAlgorithm(algorithm); + return this; + } + + @Override + public int getLeeway() { + return options.getLeeway(); + } + + @Override + public JWTOptions setLeeway(int leeway) { + options.setLeeway(leeway); + return this; + } + + @Override + public String getIssuer() { + return options.getIssuer(); + } + + @Override + public JWTOptions setIssuer(String issuer) { + options.setIssuer(issuer); + return this; + } + + @Override + public List getAudience() { + return options.getAudience(); + } + + @Override + public JWTOptions setAudience(List audience) { + options.setAudience(audience); + return this; + } + + @Override + public boolean isIgnoreExpiration() { + return options.isIgnoreExpiration(); + } + + @Override + public JWTOptions setIgnoreExpiration(boolean ignoreExpiration) { + options.setIgnoreExpiration(ignoreExpiration); + return this; + } + } + + private JWTUtil() {} + + public static final String JWT_FIELD_EXPIRATION = "exp"; + public static final String JWT_FIELD_ISSUER = "iss"; + public static final String JWT_FIELD_AUDIENCE = "aud"; + + public static JWTOptions createJWTOptions(AuthenticationOptions options) { + return new JWTDelegateOptions(options); + } +} diff --git a/services/jwt-auth/src/main/java/com/gentics/mesh/auth/AuthHandlerContainer.java b/services/jwt-auth/src/main/java/com/gentics/mesh/auth/AuthHandlerContainer.java index 044adb5b91..0065c47ce8 100644 --- a/services/jwt-auth/src/main/java/com/gentics/mesh/auth/AuthHandlerContainer.java +++ b/services/jwt-auth/src/main/java/com/gentics/mesh/auth/AuthHandlerContainer.java @@ -5,10 +5,14 @@ import javax.inject.Inject; import javax.inject.Singleton; -import io.vertx.core.Vertx; -import io.vertx.core.json.JsonObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; + +import com.gentics.mesh.etc.config.MeshOptions; +import com.gentics.mesh.util.JWTUtil; + +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; import io.vertx.ext.auth.jwt.JWTAuth; import io.vertx.ext.auth.jwt.JWTAuthOptions; import io.vertx.ext.web.handler.JWTAuthHandler; @@ -21,24 +25,29 @@ public class AuthHandlerContainer { private static final Logger log = LoggerFactory.getLogger(AuthHandlerContainer.class); - private final Vertx vertx; private JWTAuthHandler authHandler; private int hashCode; @Inject - public AuthHandlerContainer(Vertx vertx) { - this.vertx = vertx; - } + public Vertx vertx; + + @Inject + public MeshOptions options; + + @Inject + public AuthHandlerContainer() {} /** * Create a new JWT handler for the given JWKs. - * + * * @param jwks * @return */ public synchronized JWTAuthHandler create(Set jwks) { if (hashCode != jwks.hashCode()) { JWTAuthOptions jwtOptions = new JWTAuthOptions(); + // Set JWT options from the config + jwtOptions.setJWTOptions(JWTUtil.createJWTOptions(options.getAuthenticationOptions())); // Now add all keys to jwt config for (JsonObject key : jwks) { jwtOptions.addJwk(key); @@ -50,5 +59,4 @@ public synchronized JWTAuthHandler create(Set jwks) { } return authHandler; } - } diff --git a/tests/common/src/main/java/com/gentics/mesh/test/context/MeshTestContext.java b/tests/common/src/main/java/com/gentics/mesh/test/context/MeshTestContext.java index 3486351911..7f6d6547d0 100644 --- a/tests/common/src/main/java/com/gentics/mesh/test/context/MeshTestContext.java +++ b/tests/common/src/main/java/com/gentics/mesh/test/context/MeshTestContext.java @@ -931,7 +931,9 @@ private void setupRestEndpoints(MeshTestSetting settings) throws Exception { MeshRestClient httpClient = MeshRestClient.create(httpConfigBuilder.build(), okHttp); httpClient.setLogin(getData().user().getUsername(), getData().getUserInfo().getPassword()); - httpClient.login().blockingGet(); + if (settings.loginClients()) { + httpClient.login().blockingGet(); + } clients.put("http_v" + CURRENT_API_VERSION, httpClient); // Setup SSL client if needed @@ -968,7 +970,9 @@ private void setupRestEndpoints(MeshTestSetting settings) throws Exception { if (httpsConfig != null) { MeshRestClient httpsClient = MeshRestClient.create(httpsConfig); httpsClient.setLogin(getData().user().getUsername(), getData().getUserInfo().getPassword()); - httpsClient.login().blockingGet(); + if (settings.loginClients()) { + httpsClient.login().blockingGet(); + } clients.put("https_v" + CURRENT_API_VERSION, httpsClient); } diff --git a/tests/common/src/main/java/com/gentics/mesh/test/local/MeshLocalServer.java b/tests/common/src/main/java/com/gentics/mesh/test/local/MeshLocalServer.java index 08daaf8d0c..7a0c18142c 100644 --- a/tests/common/src/main/java/com/gentics/mesh/test/local/MeshLocalServer.java +++ b/tests/common/src/main/java/com/gentics/mesh/test/local/MeshLocalServer.java @@ -369,5 +369,10 @@ public String[] nodeNames() { public AWSTestMode awsContainer() { return AWSTestMode.NONE; } + + @Override + public boolean loginClients() { + return true; + } }; } diff --git a/tests/context-api/src/main/java/com/gentics/mesh/test/MeshTestSetting.java b/tests/context-api/src/main/java/com/gentics/mesh/test/MeshTestSetting.java index 3bd029a50f..bdce9bc5b4 100644 --- a/tests/context-api/src/main/java/com/gentics/mesh/test/MeshTestSetting.java +++ b/tests/context-api/src/main/java/com/gentics/mesh/test/MeshTestSetting.java @@ -123,4 +123,11 @@ * @return */ boolean resetBetweenTests() default true; + + /** + * If it should login the clients after initialization at the beginning of the test. + * + * @return + */ + boolean loginClients() default true; } diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/JWTIgnoreExpiredTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/JWTIgnoreExpiredTest.java new file mode 100644 index 0000000000..3ab03b3cca --- /dev/null +++ b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/JWTIgnoreExpiredTest.java @@ -0,0 +1,85 @@ +package com.gentics.mesh.core.user; + +import static com.gentics.mesh.test.TestSize.PROJECT; +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.function.Consumer; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import com.gentics.mesh.core.data.user.HibUser; +import com.gentics.mesh.core.db.Tx; +import com.gentics.mesh.etc.config.MeshOptions; +import com.gentics.mesh.rest.client.MeshRestClient; +import com.gentics.mesh.test.MeshTestSetting; +import com.gentics.mesh.test.context.AbstractMeshTest; +import com.gentics.mesh.util.JWTTestUtil; +import com.gentics.mesh.util.JWTUtil; +import com.gentics.mesh.util.Tuple; + +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.JWTOptions; +import io.vertx.ext.auth.jwt.JWTAuth; + +@RunWith(Parameterized.class) +@MeshTestSetting(testSize = PROJECT, startServer = true, loginClients = false) +public class JWTIgnoreExpiredTest extends AbstractMeshTest { + + private static final Consumer APPLY_OPTIONS = meshOptions -> { + meshOptions.getAuthenticationOptions() + .setLeeway(0) + .setIgnoreExpiration(true); + }; + + @Parameterized.Parameters(name = "{index}: {0}") + public static Collection paramData() { + return Arrays.asList(new Object[][]{ + {-5, true}, + {-20, true}, + {30, true}, + }); + } + + @Parameterized.Parameter(0) + public Integer offset; + + @Parameterized.Parameter(1) + public Boolean shouldBeValid; + + @Test + public void testTokenExpiration() throws IOException { + try (Tx tx = tx()) { + APPLY_OPTIONS.accept(this.options()); + + Tuple jwt = JWTTestUtil.createAuth(vertx(), this.options(), jwtOptions -> { + // Set it to 0, because we set the expiration (exp) manually below + jwtOptions.setExpiresInSeconds(0); + // And disable the timestamp (iat) + jwtOptions.setNoTimestamp(true); + }); + + HibUser user = user(); + JsonObject tokenData = new JsonObject() + .put("userUuid", user.getUuid()) + .put(JWTUtil.JWT_FIELD_EXPIRATION, (System.currentTimeMillis() / 1000L) + this.offset); + String token = jwt.v1().generateToken(tokenData, jwt.v2()); + + MeshRestClient client = MeshRestClient.create("localhost", port(), false); + client.getAuthentication().setToken(token); + + boolean isValid; + try { + client.me().blockingGet(); + isValid = true; + } catch (Exception e) { + isValid = false; + } + assertEquals(isValid, this.shouldBeValid); + } + } +} diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/JWTLeewayTimeoutValidationTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/JWTLeewayTimeoutValidationTest.java new file mode 100644 index 0000000000..8e97234abf --- /dev/null +++ b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/JWTLeewayTimeoutValidationTest.java @@ -0,0 +1,87 @@ +package com.gentics.mesh.core.user; + +import static com.gentics.mesh.test.TestSize.PROJECT; +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.function.Consumer; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import com.gentics.mesh.core.data.user.HibUser; +import com.gentics.mesh.core.db.Tx; +import com.gentics.mesh.etc.config.MeshOptions; +import com.gentics.mesh.rest.client.MeshRestClient; +import com.gentics.mesh.test.MeshTestSetting; +import com.gentics.mesh.test.context.AbstractMeshTest; +import com.gentics.mesh.util.JWTTestUtil; +import com.gentics.mesh.util.JWTUtil; +import com.gentics.mesh.util.Tuple; + +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.JWTOptions; +import io.vertx.ext.auth.jwt.JWTAuth; + +@RunWith(Parameterized.class) +@MeshTestSetting(testSize = PROJECT, startServer = true, loginClients = false) +public class JWTLeewayTimeoutValidationTest extends AbstractMeshTest { + + private final Consumer APPLY_OPTIONS = meshOptions -> { + meshOptions.getAuthenticationOptions() + .setLeeway(this.leeway) + .setIgnoreExpiration(false); + }; + + @Parameterized.Parameters(name = "{index}: {0}") + public static Collection paramData() { + return Arrays.asList(new Object[][]{ + {10, -5, true}, + {10, -20, false}, + }); + } + + @Parameterized.Parameter(0) + public Integer leeway; + + @Parameterized.Parameter(1) + public Integer offset; + + @Parameterized.Parameter(2) + public Boolean shouldBeValid; + + @Test + public void testTokenExpiration() throws IOException { + try (Tx tx = tx()) { + APPLY_OPTIONS.accept(this.options()); + + Tuple jwt = JWTTestUtil.createAuth(vertx(), this.options(), jwtOptions -> { + // Set it to 0, because we set the expiration (exp) manually below + jwtOptions.setExpiresInSeconds(0); + // And disable the timestamp (iat) + jwtOptions.setNoTimestamp(true); + }); + + HibUser user = user(); + JsonObject tokenData = new JsonObject() + .put("userUuid", user.getUuid()) + .put(JWTUtil.JWT_FIELD_EXPIRATION, (System.currentTimeMillis() / 1000L) + this.offset); + String token = jwt.v1().generateToken(tokenData, jwt.v2()); + + MeshRestClient client = MeshRestClient.create("localhost", port(), false); + client.getAuthentication().setToken(token); + + boolean isValid; + try { + client.me().blockingGet(); + isValid = true; + } catch (Exception e) { + isValid = false; + } + assertEquals(isValid, this.shouldBeValid); + } + } +} diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/JWTPayloadTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/JWTPayloadTest.java new file mode 100644 index 0000000000..2336a99a68 --- /dev/null +++ b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/JWTPayloadTest.java @@ -0,0 +1,100 @@ +package com.gentics.mesh.core.user; + +import static com.gentics.mesh.test.TestSize.PROJECT; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; +import java.util.function.Consumer; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; + +import com.gentics.mesh.core.data.user.HibUser; +import com.gentics.mesh.core.db.Tx; +import com.gentics.mesh.core.rest.common.GenericMessageResponse; +import com.gentics.mesh.etc.config.MeshOptions; +import com.gentics.mesh.rest.client.MeshRestClient; +import com.gentics.mesh.test.MeshOptionChanger; +import com.gentics.mesh.test.MeshTestSetting; +import com.gentics.mesh.test.context.AbstractMeshTest; +import com.gentics.mesh.test.context.MeshTestContext; +import com.gentics.mesh.util.JWTUtil; + +import io.reactivex.Single; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; + +@RunWith(Parameterized.class) +@MeshTestSetting(testSize = PROJECT, startServer = true, loginClients = false) +public class JWTPayloadTest extends AbstractMeshTest { + + @Parameter(0) + public String paramGroupName; + + @Parameter(1) + public Consumer serverOptionsChanger; + + @Parameterized.Parameters(name = "{index}: {0}") + public static Collection paramData() { + return Arrays.asList(new Object[][]{ + { + "Issuer: HELLO WORLD", + (Consumer) meshOptions -> meshOptions.getAuthenticationOptions() + .setIssuer("HELLO WORLD") + }, + { + "Audience: [foo, bar]", + (Consumer) meshOptions -> meshOptions.getAuthenticationOptions() + .setAudience(Arrays.asList("foo", "bar")) + }, + { + "Issuer: HELLO WORLD, Audience: [foo, bar]", + (Consumer) meshOptions -> meshOptions.getAuthenticationOptions() + .setIssuer("HELLO WORLD") + .setAudience(Arrays.asList("foo", "bar")) + }, + }); + } + + @Before + public void setOptions() { + serverOptionsChanger.accept(testContext.getOptions()); + } + + @Test + public void testJWTPayload() throws IOException { + try (Tx tx = tx()) { + this.serverOptionsChanger.accept(this.options()); + + HibUser user = user(); + String username = user.getUsername(); + + MeshRestClient client = MeshRestClient.create("localhost", port(), false); + client.setLogin(username, data().getUserInfo().getPassword()); + + Single future = client.login(); + + GenericMessageResponse loginResponse = future.blockingGet(); + assertNotNull(loginResponse); + assertEquals("OK", loginResponse.getMessage()); + + String token = client.getAuthentication().getToken(); + assertNotNull(token); + + JsonObject payload = new JsonObject(new String(Base64.getDecoder().decode(token.split("\\.")[1]))); + assertEquals(payload.getString(JWTUtil.JWT_FIELD_ISSUER), options().getAuthenticationOptions().getIssuer()); + JsonArray aud = null; + if (options().getAuthenticationOptions().getAudience() != null && !options().getAuthenticationOptions().getAudience().isEmpty()) { + aud = new JsonArray(options().getAuthenticationOptions().getAudience()); + } + assertEquals(payload.getJsonArray(JWTUtil.JWT_FIELD_AUDIENCE), aud); + } + } +} diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/JWTPayloadValidationTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/JWTPayloadValidationTest.java new file mode 100644 index 0000000000..b766b1e0a1 --- /dev/null +++ b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/JWTPayloadValidationTest.java @@ -0,0 +1,116 @@ +package com.gentics.mesh.core.user; + +import static com.gentics.mesh.test.TestSize.PROJECT; +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.function.Consumer; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import com.gentics.mesh.core.data.user.HibUser; +import com.gentics.mesh.core.db.Tx; +import com.gentics.mesh.core.rest.user.UserResponse; +import com.gentics.mesh.etc.config.MeshOptions; +import com.gentics.mesh.json.JsonUtil; +import com.gentics.mesh.rest.client.MeshRestClient; +import com.gentics.mesh.test.MeshTestSetting; +import com.gentics.mesh.test.context.AbstractMeshTest; +import com.gentics.mesh.util.JWTTestUtil; +import com.gentics.mesh.util.Tuple; + +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.JWTOptions; +import io.vertx.ext.auth.jwt.JWTAuth; + +@RunWith(Parameterized.class) +@MeshTestSetting(testSize = PROJECT, startServer = true, loginClients = false) +public class JWTPayloadValidationTest extends AbstractMeshTest { + + private static final Consumer NULL_PROVIDER = meshOptions -> { + meshOptions.getAuthenticationOptions() + .setIssuer(null) + .setAudience(null); + }; + + private static final Consumer META_PROVIDER = meshOptions -> { + meshOptions.getAuthenticationOptions() + .setIssuer("HELLO WORLD") + .setAudience(Arrays.asList("www.example.com", "auth.example.com")); + }; + + @Parameterized.Parameters() + public static Collection paramData() { + return Arrays.asList(new Object[][]{ + { // Server and client send minimal token payload + NULL_PROVIDER, + NULL_PROVIDER, + true + }, + { // Server sends Issuer & Audience, but client sends none + META_PROVIDER, + NULL_PROVIDER, + false + }, + { // Server sends no Issuer or Audience, but client does + NULL_PROVIDER, + META_PROVIDER, + true, + }, + { // Server and Client send meta-data + META_PROVIDER, + META_PROVIDER, + true + } + }); + } + + @Parameterized.Parameter(0) + public Consumer serverOptionsChanger; + + @Parameterized.Parameter(1) + public Consumer clientOptionsChanger; + + @Parameterized.Parameter(2) + public Boolean shouldBeValid; + + private MeshOptions originalOptions; + + @Before + public void backupOptions() { + if (this.originalOptions == null) { + this.originalOptions = JsonUtil.readValue(JsonUtil.toJson(this.options()), this.options().getClass()); + } + } + + @Test + public void testOptionChanges() throws IOException { + try (Tx tx = tx()) { + MeshOptions clientOptions = JsonUtil.readValue(JsonUtil.toJson(this.originalOptions), this.options().getClass()); + this.serverOptionsChanger.accept(this.options()); + this.clientOptionsChanger.accept(clientOptions); + + Tuple jwt = JWTTestUtil.createAuth(vertx(), clientOptions, null); + HibUser user = user(); + JsonObject tokenData = new JsonObject() + .put("userUuid", user.getUuid()); + String token = jwt.v1().generateToken(tokenData, jwt.v2()); + + MeshRestClient client = MeshRestClient.create("localhost", port(), false); + client.getAuthentication().setToken(token); + + try { + UserResponse res = client.me().blockingGet(); + assertEquals(user.getUuid(), res.getUuid()); + assertEquals(shouldBeValid, true); + } catch (Exception e) { + assertEquals(shouldBeValid, false); + } + } + } +} diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/search/AbstractMultiESTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/search/AbstractMultiESTest.java index 2614c13576..9b00ee4cd9 100644 --- a/tests/tests-core/src/main/java/com/gentics/mesh/search/AbstractMultiESTest.java +++ b/tests/tests-core/src/main/java/com/gentics/mesh/search/AbstractMultiESTest.java @@ -192,6 +192,11 @@ public Class customOptionChanger() { public boolean resetBetweenTests() { return delegate.resetBetweenTests(); } + + @Override + public boolean loginClients() { + return true; + } } @Override diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/util/JWTTestUtil.java b/tests/tests-core/src/main/java/com/gentics/mesh/util/JWTTestUtil.java new file mode 100644 index 0000000000..292270ddb5 --- /dev/null +++ b/tests/tests-core/src/main/java/com/gentics/mesh/util/JWTTestUtil.java @@ -0,0 +1,34 @@ +package com.gentics.mesh.util; + +import java.util.function.Consumer; + +import com.gentics.mesh.etc.config.MeshOptions; + +import io.vertx.core.Vertx; +import io.vertx.ext.auth.JWTOptions; +import io.vertx.ext.auth.KeyStoreOptions; +import io.vertx.ext.auth.jwt.JWTAuth; +import io.vertx.ext.auth.jwt.JWTAuthOptions; + +public class JWTTestUtil { + + private JWTTestUtil() {} + + public static Tuple createAuth(Vertx vertx, MeshOptions options, Consumer jwtChanger) { + String keyStorePath = options.getAuthenticationOptions().getKeystorePath(); + String keystorePassword = options.getAuthenticationOptions().getKeystorePassword(); + String type = "jceks"; + JWTAuthOptions config = new JWTAuthOptions(); + JWTOptions jwtOptions = JWTUtil.createJWTOptions(options.getAuthenticationOptions()); + if (jwtChanger != null) { + jwtChanger.accept(jwtOptions); + } + + // Set JWT options from the config + config.setJWTOptions(jwtOptions); + config.setKeyStore(new KeyStoreOptions().setPath(keyStorePath).setPassword(keystorePassword).setType(type)); + JWTAuth jwtProvider = JWTAuth.create(vertx, new JWTAuthOptions(config)); + + return Tuple.tuple(jwtProvider, jwtOptions); + } +}