diff --git a/CHANGELOG.md b/CHANGELOG.md index 407a4628..73efa2e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [6.0.0] - 2024-03-13 + +- Replace `TotpNotEnabledException` with `UnknownUserTotpIdException` +- ActiveUsersSQLStorage interface changes + - Adds `deleteUserActive_Transaction` function +- ActiveUsersStorage interface changes + - Removes `countUsersEnabledTotp`, `countUsersEnabledTotpAndActiveSince` and `deleteUserActive_Transaction` functions + - Adds `countUsersThatHaveMoreThanOneLoginMethodOrTOTPEnabledAndActiveSince` function +- AuthRecipeStorage interface changes + - Adds `getUsersCountWithMoreThanOneLoginMethodOrTOTPEnabled` function +- TenantConfig changes + - Adds `firstFactors` and `requiredSecondaryFactors` fields +- Adds `createdAt` field to `TOTPDevice` +- TOTPSQLStorage interface changes + - Adds `getDeviceByName_Transaction` and `createDevice_Transaction` functions +- Adds a new `useStaticKey` param to `updateSessionInfo_Transaction` + - This enables smooth switching between `useDynamicAccessTokenSigningKey` settings by allowing refresh calls to + change the signing key type of a session + Adds `appIdentifier` parameter to `getUserIdMappingForSuperTokensIds` in `UserIdMappingStorage` + ## [5.0.0] - 2024-03-05 - Removes types `AppIdentifierWithStorage` and `TenantIdentifierWithStorage` @@ -93,7 +113,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [2.22.0] - 2023-03-30 -- Adds Support for Dashboard Search +- Adds Support for Dashboard Search ## [2.21.0] - 2023-03-27 diff --git a/build.gradle b/build.gradle index 188c2b13..72803d4c 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ plugins { id 'java-library' } -version = "5.0.0" +version = "6.0.0" repositories { mavenCentral() diff --git a/jar/plugin-interface-2.23.0.jar b/jar/plugin-interface-2.23.0.jar new file mode 100644 index 00000000..7edb243e Binary files /dev/null and b/jar/plugin-interface-2.23.0.jar differ diff --git a/jar/plugin-interface-6.0.0.jar b/jar/plugin-interface-6.0.0.jar new file mode 100644 index 00000000..ab648a2f Binary files /dev/null and b/jar/plugin-interface-6.0.0.jar differ diff --git a/src/main/java/io/supertokens/pluginInterface/ActiveUsersSQLStorage.java b/src/main/java/io/supertokens/pluginInterface/ActiveUsersSQLStorage.java new file mode 100644 index 00000000..a093dc6a --- /dev/null +++ b/src/main/java/io/supertokens/pluginInterface/ActiveUsersSQLStorage.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.pluginInterface; + +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.AppIdentifier; +import io.supertokens.pluginInterface.sqlStorage.SQLStorage; +import io.supertokens.pluginInterface.sqlStorage.TransactionConnection; + +public interface ActiveUsersSQLStorage extends ActiveUsersStorage, SQLStorage { + /* Delete a user from active users table */ + void deleteUserActive_Transaction(TransactionConnection con, AppIdentifier appIdentifier, String userId) + throws StorageQueryException; +} diff --git a/src/main/java/io/supertokens/pluginInterface/ActiveUsersStorage.java b/src/main/java/io/supertokens/pluginInterface/ActiveUsersStorage.java index 9f4e3252..91fe4c21 100644 --- a/src/main/java/io/supertokens/pluginInterface/ActiveUsersStorage.java +++ b/src/main/java/io/supertokens/pluginInterface/ActiveUsersStorage.java @@ -3,7 +3,6 @@ import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.pluginInterface.nonAuthRecipe.NonAuthRecipeStorage; -import io.supertokens.pluginInterface.sqlStorage.TransactionConnection; public interface ActiveUsersStorage extends NonAuthRecipeStorage { /* Update the last active time of a user to now */ @@ -12,14 +11,7 @@ public interface ActiveUsersStorage extends NonAuthRecipeStorage { /* Count the number of users who did some activity after given timestamp */ int countUsersActiveSince(AppIdentifier appIdentifier, long time) throws StorageQueryException; - /* Count the number of users who have enabled TOTP */ - int countUsersEnabledTotp(AppIdentifier appIdentifier) throws StorageQueryException; - - /* Count the number of users who have enabled TOTP and are active */ - int countUsersEnabledTotpAndActiveSince(AppIdentifier appIdentifier, long time) throws StorageQueryException; - - void deleteUserActive_Transaction(TransactionConnection con, AppIdentifier appIdentifier, String userId) - throws StorageQueryException; - int countUsersThatHaveMoreThanOneLoginMethodAndActiveSince(AppIdentifier appIdentifier, long sinceTime) throws StorageQueryException; + + int countUsersThatHaveMoreThanOneLoginMethodOrTOTPEnabledAndActiveSince(AppIdentifier appIdentifier, long timestamp) throws StorageQueryException; } diff --git a/src/main/java/io/supertokens/pluginInterface/RECIPE_ID.java b/src/main/java/io/supertokens/pluginInterface/RECIPE_ID.java index 190d6b25..6aee253f 100644 --- a/src/main/java/io/supertokens/pluginInterface/RECIPE_ID.java +++ b/src/main/java/io/supertokens/pluginInterface/RECIPE_ID.java @@ -22,7 +22,7 @@ public enum RECIPE_ID { EMAIL_PASSWORD("emailpassword"), THIRD_PARTY("thirdparty"), SESSION("session"), EMAIL_VERIFICATION("emailverification"), JWT("jwt"), PASSWORDLESS("passwordless"), USER_METADATA("usermetadata"), USER_ROLES("userroles"), USER_ID_MAPPING("useridmapping"), DASHBOARD("dashboard"), TOTP("totp"), - MULTITENANCY("multitenancy"), ACCOUNT_LINKING("accountlinking"); + MULTITENANCY("multitenancy"), ACCOUNT_LINKING("accountlinking"), MFA("mfa"); private final String name; diff --git a/src/main/java/io/supertokens/pluginInterface/StorageUtils.java b/src/main/java/io/supertokens/pluginInterface/StorageUtils.java index a5edd1ac..19bfb89a 100644 --- a/src/main/java/io/supertokens/pluginInterface/StorageUtils.java +++ b/src/main/java/io/supertokens/pluginInterface/StorageUtils.java @@ -16,7 +16,6 @@ package io.supertokens.pluginInterface; -import io.supertokens.pluginInterface.authRecipe.AuthRecipeStorage; import io.supertokens.pluginInterface.authRecipe.sqlStorage.AuthRecipeSQLStorage; import io.supertokens.pluginInterface.dashboard.sqlStorage.DashboardSQLStorage; import io.supertokens.pluginInterface.emailpassword.sqlStorage.EmailPasswordSQLStorage; @@ -118,12 +117,12 @@ public static TOTPSQLStorage getTOTPStorage(Storage storage) { return (TOTPSQLStorage) storage; } - public static ActiveUsersStorage getActiveUsersStorage(Storage storage) { + public static ActiveUsersSQLStorage getActiveUsersStorage(Storage storage) { if (storage.getType() != STORAGE_TYPE.SQL) { // we only support SQL for now throw new UnsupportedOperationException(""); } - return (ActiveUsersStorage) storage; + return (ActiveUsersSQLStorage) storage; } public static MultitenancyStorage getMultitenancyStorage(Storage storage) { diff --git a/src/main/java/io/supertokens/pluginInterface/authRecipe/AuthRecipeStorage.java b/src/main/java/io/supertokens/pluginInterface/authRecipe/AuthRecipeStorage.java index f554ffab..a4642f43 100644 --- a/src/main/java/io/supertokens/pluginInterface/authRecipe/AuthRecipeStorage.java +++ b/src/main/java/io/supertokens/pluginInterface/authRecipe/AuthRecipeStorage.java @@ -64,4 +64,6 @@ AuthRecipeUserInfo getPrimaryUserByThirdPartyInfo(TenantIdentifier tenantIdentif boolean checkIfUsesAccountLinking(AppIdentifier appIdentifier) throws StorageQueryException; int getUsersCountWithMoreThanOneLoginMethod(AppIdentifier appIdentifier) throws StorageQueryException; + + int getUsersCountWithMoreThanOneLoginMethodOrTOTPEnabled(AppIdentifier appIdentifier) throws StorageQueryException; } diff --git a/src/main/java/io/supertokens/pluginInterface/multitenancy/TenantConfig.java b/src/main/java/io/supertokens/pluginInterface/multitenancy/TenantConfig.java index f68ee35d..70900423 100644 --- a/src/main/java/io/supertokens/pluginInterface/multitenancy/TenantConfig.java +++ b/src/main/java/io/supertokens/pluginInterface/multitenancy/TenantConfig.java @@ -20,6 +20,7 @@ import com.google.gson.JsonObject; import com.google.gson.annotations.SerializedName; import io.supertokens.pluginInterface.Storage; +import io.supertokens.pluginInterface.utils.Utils; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -41,17 +42,29 @@ public class TenantConfig { @SerializedName("passwordless") public final PasswordlessConfig passwordlessConfig; + @Nullable + @SerializedName("firstFactors") + public final String[] firstFactors; + + @Nullable + @SerializedName("requiredSecondaryFactors") + public final String[] requiredSecondaryFactors; + @Nonnull public final JsonObject coreConfig; public TenantConfig(@Nonnull TenantIdentifier tenantIdentifier, @Nonnull EmailPasswordConfig emailPasswordConfig, @Nonnull ThirdPartyConfig thirdPartyConfig, - @Nonnull PasswordlessConfig passwordlessConfig, @Nullable JsonObject coreConfig) { + @Nonnull PasswordlessConfig passwordlessConfig, + @Nullable String[] firstFactors, @Nullable String[] requiredSecondaryFactors, + @Nullable JsonObject coreConfig) { this.tenantIdentifier = tenantIdentifier; this.coreConfig = coreConfig == null ? new JsonObject() : coreConfig; this.emailPasswordConfig = emailPasswordConfig; this.passwordlessConfig = passwordlessConfig; this.thirdPartyConfig = thirdPartyConfig; + this.firstFactors = firstFactors == null || firstFactors.length == 0 ? null : firstFactors; + this.requiredSecondaryFactors = requiredSecondaryFactors == null || requiredSecondaryFactors.length == 0 ? null : requiredSecondaryFactors; } public TenantConfig(TenantConfig other) { @@ -62,6 +75,8 @@ public TenantConfig(TenantConfig other) { this.emailPasswordConfig = new EmailPasswordConfig(other.emailPasswordConfig.enabled); this.passwordlessConfig = new PasswordlessConfig(other.passwordlessConfig.enabled); this.thirdPartyConfig = new ThirdPartyConfig(other.thirdPartyConfig.enabled, other.thirdPartyConfig.providers.clone()); + this.firstFactors = other.firstFactors == null ? null : other.firstFactors.clone(); + this.requiredSecondaryFactors = other.requiredSecondaryFactors == null ? null : other.requiredSecondaryFactors.clone(); } public boolean deepEquals(TenantConfig other) { @@ -72,6 +87,8 @@ public boolean deepEquals(TenantConfig other) { this.emailPasswordConfig.equals(other.emailPasswordConfig) && this.passwordlessConfig.equals(other.passwordlessConfig) && this.thirdPartyConfig.equals(other.thirdPartyConfig) && + Utils.unorderedArrayEquals(this.firstFactors, other.firstFactors) && // order is not important + Utils.unorderedArrayEquals(this.requiredSecondaryFactors, other.requiredSecondaryFactors) && // order is not important this.coreConfig.equals(other.coreConfig); } @@ -94,9 +111,16 @@ public JsonObject toJson(boolean shouldProtectDbConfig, Storage storage, String[ JsonObject tenantConfigObject = gson.toJsonTree(this).getAsJsonObject(); tenantConfigObject.add("thirdParty", this.thirdPartyConfig.toJson()); - tenantConfigObject.addProperty("tenantId", this.tenantIdentifier.getTenantId()); + if (tenantConfigObject.has("firstFactors") && tenantConfigObject.get("firstFactors").getAsJsonArray().size() == 0) { + tenantConfigObject.remove("firstFactors"); + } + + if (tenantConfigObject.has("requiredSecondaryFactors") && tenantConfigObject.get("requiredSecondaryFactors").getAsJsonArray().size() == 0) { + tenantConfigObject.remove("requiredSecondaryFactors"); + } + if (shouldProtectDbConfig) { String[] protectedConfigs = storage.getProtectedConfigsFromSuperTokensSaaSUsers(); for (String config : protectedConfigs) { diff --git a/src/main/java/io/supertokens/pluginInterface/multitenancy/ThirdPartyConfig.java b/src/main/java/io/supertokens/pluginInterface/multitenancy/ThirdPartyConfig.java index d77a3e75..9ee2352b 100644 --- a/src/main/java/io/supertokens/pluginInterface/multitenancy/ThirdPartyConfig.java +++ b/src/main/java/io/supertokens/pluginInterface/multitenancy/ThirdPartyConfig.java @@ -20,6 +20,7 @@ import com.google.gson.GsonBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import io.supertokens.pluginInterface.utils.Utils; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -48,38 +49,6 @@ public JsonObject toJson() { return result; } - public static boolean unorderedArrayEquals(Object[] array1, Object[] array2) { - if (array1 == null && array2 == null) { - return true; - } else if (array1 == null || array2 == null) { - return false; - } - - List items1 = List.of(array1); - List items2 = new ArrayList<>(); - items2.addAll(Arrays.asList(array2)); - - if (items1.size() != items2.size()) return false; - - for (Object p1 : items1) { - boolean found = false; - for (Object p2 : items2) { - if (p1.equals(p2)) { - found = true; - break; - } - } - - if (!found) { - return false; - } else { - items2.remove(p1); - } - } - - return true; - } - public static class Provider { @Nonnull @@ -189,7 +158,7 @@ public boolean equals(Object other) { Provider otherProvider = (Provider) other; return Objects.equals(otherProvider.thirdPartyId, this.thirdPartyId) && Objects.equals(otherProvider.name, this.name) && - unorderedArrayEquals(otherProvider.clients, this.clients) && + Utils.unorderedArrayEquals(otherProvider.clients, this.clients) && Objects.equals(otherProvider.authorizationEndpoint, this.authorizationEndpoint) && Objects.equals(otherProvider.authorizationEndpointQueryParams, this.authorizationEndpointQueryParams) && @@ -245,7 +214,7 @@ public boolean equals(Object other) { return Objects.equals(otherProviderClient.clientType, this.clientType) && otherProviderClient.clientId.equals(this.clientId) && Objects.equals(otherProviderClient.clientSecret, this.clientSecret) && - unorderedArrayEquals(otherProviderClient.scope, this.scope) && + Utils.unorderedArrayEquals(otherProviderClient.scope, this.scope) && otherProviderClient.forcePKCE == this.forcePKCE && Objects.equals(otherProviderClient.additionalConfig, this.additionalConfig); } @@ -310,7 +279,7 @@ public boolean equals(Object other) { if (other instanceof ThirdPartyConfig) { ThirdPartyConfig otherThirdPartyConfig = (ThirdPartyConfig) other; return otherThirdPartyConfig.enabled == this.enabled && - unorderedArrayEquals(otherThirdPartyConfig.providers, this.providers); + Utils.unorderedArrayEquals(otherThirdPartyConfig.providers, this.providers); } return false; } diff --git a/src/main/java/io/supertokens/pluginInterface/session/noSqlStorage/SessionNoSQLStorage_1.java b/src/main/java/io/supertokens/pluginInterface/session/noSqlStorage/SessionNoSQLStorage_1.java index 95274c5e..89c15a21 100644 --- a/src/main/java/io/supertokens/pluginInterface/session/noSqlStorage/SessionNoSQLStorage_1.java +++ b/src/main/java/io/supertokens/pluginInterface/session/noSqlStorage/SessionNoSQLStorage_1.java @@ -47,5 +47,5 @@ public interface SessionNoSQLStorage_1 extends SessionStorage, NoSQLStorage_1 { SessionInfoWithLastUpdated getSessionInfo_Transaction(String sessionHandle) throws StorageQueryException; boolean updateSessionInfo_Transaction(String sessionHandle, String refreshTokenHash2, long expiry, - String lastUpdatedSign) throws StorageQueryException; + String lastUpdatedSign, boolean useStaticKey) throws StorageQueryException; } diff --git a/src/main/java/io/supertokens/pluginInterface/session/sqlStorage/SessionSQLStorage.java b/src/main/java/io/supertokens/pluginInterface/session/sqlStorage/SessionSQLStorage.java index d4556199..0c0436c8 100644 --- a/src/main/java/io/supertokens/pluginInterface/session/sqlStorage/SessionSQLStorage.java +++ b/src/main/java/io/supertokens/pluginInterface/session/sqlStorage/SessionSQLStorage.java @@ -54,7 +54,7 @@ SessionInfo getSessionInfo_Transaction(TenantIdentifier tenantIdentifier, Transa void updateSessionInfo_Transaction(TenantIdentifier tenantIdentifier, TransactionConnection con, String sessionHandle, String refreshTokenHash2, - long expiry) throws StorageQueryException; + long expiry, boolean useStaticKey) throws StorageQueryException; void deleteSessionsOfUser_Transaction(TransactionConnection con, AppIdentifier appIdentifier, String userId) throws StorageQueryException; diff --git a/src/main/java/io/supertokens/pluginInterface/totp/TOTPDevice.java b/src/main/java/io/supertokens/pluginInterface/totp/TOTPDevice.java index e29f091e..54c11268 100644 --- a/src/main/java/io/supertokens/pluginInterface/totp/TOTPDevice.java +++ b/src/main/java/io/supertokens/pluginInterface/totp/TOTPDevice.java @@ -7,15 +7,17 @@ public class TOTPDevice { public final int period; public final int skew; public final boolean verified; + public final long createdAt; public TOTPDevice(String userId, String deviceName, String secretKey, int period, - int skew, boolean verified) { + int skew, boolean verified, long createdAt) { this.userId = userId; this.deviceName = deviceName; this.secretKey = secretKey; this.period = period; this.skew = skew; this.verified = verified; + this.createdAt = createdAt; } @Override diff --git a/src/main/java/io/supertokens/pluginInterface/totp/exception/TotpNotEnabledException.java b/src/main/java/io/supertokens/pluginInterface/totp/exception/UnknownTotpUserIdException.java similarity index 68% rename from src/main/java/io/supertokens/pluginInterface/totp/exception/TotpNotEnabledException.java rename to src/main/java/io/supertokens/pluginInterface/totp/exception/UnknownTotpUserIdException.java index bb702ef5..a1dcd834 100644 --- a/src/main/java/io/supertokens/pluginInterface/totp/exception/TotpNotEnabledException.java +++ b/src/main/java/io/supertokens/pluginInterface/totp/exception/UnknownTotpUserIdException.java @@ -1,5 +1,5 @@ package io.supertokens.pluginInterface.totp.exception; -public class TotpNotEnabledException extends Exception { +public class UnknownTotpUserIdException extends Exception { private static final long serialVersionUID = 6848053563771647272L; } diff --git a/src/main/java/io/supertokens/pluginInterface/totp/sqlStorage/TOTPSQLStorage.java b/src/main/java/io/supertokens/pluginInterface/totp/sqlStorage/TOTPSQLStorage.java index 55d46060..546148fb 100644 --- a/src/main/java/io/supertokens/pluginInterface/totp/sqlStorage/TOTPSQLStorage.java +++ b/src/main/java/io/supertokens/pluginInterface/totp/sqlStorage/TOTPSQLStorage.java @@ -9,8 +9,9 @@ import io.supertokens.pluginInterface.totp.TOTPDevice; import io.supertokens.pluginInterface.totp.TOTPStorage; import io.supertokens.pluginInterface.totp.TOTPUsedCode; -import io.supertokens.pluginInterface.totp.exception.TotpNotEnabledException; +import io.supertokens.pluginInterface.totp.exception.DeviceAlreadyExistsException; import io.supertokens.pluginInterface.totp.exception.UsedCodeAlreadyExistsException; +import io.supertokens.pluginInterface.totp.exception.UnknownTotpUserIdException; public interface TOTPSQLStorage extends TOTPStorage, SQLStorage { public int deleteDevice_Transaction(TransactionConnection con, AppIdentifier appIdentifier, String userId, @@ -38,7 +39,11 @@ public TOTPUsedCode[] getAllUsedCodesDescOrder_Transaction(TransactionConnection * Insert a used TOTP code for an existing user: */ void insertUsedCode_Transaction(TransactionConnection con, TenantIdentifier tenantIdentifier, TOTPUsedCode code) - throws StorageQueryException, TotpNotEnabledException, UsedCodeAlreadyExistsException, + throws StorageQueryException, UnknownTotpUserIdException, UsedCodeAlreadyExistsException, TenantOrAppNotFoundException; + TOTPDevice getDeviceByName_Transaction(TransactionConnection con, AppIdentifier appIdentifier, String userId, String deviceName) throws StorageQueryException; + + TOTPDevice createDevice_Transaction(TransactionConnection con, AppIdentifier appIdentifier, TOTPDevice device) + throws StorageQueryException, DeviceAlreadyExistsException, TenantOrAppNotFoundException; } diff --git a/src/main/java/io/supertokens/pluginInterface/useridmapping/UserIdMappingStorage.java b/src/main/java/io/supertokens/pluginInterface/useridmapping/UserIdMappingStorage.java index 7b1ebd4c..eab19ae9 100644 --- a/src/main/java/io/supertokens/pluginInterface/useridmapping/UserIdMappingStorage.java +++ b/src/main/java/io/supertokens/pluginInterface/useridmapping/UserIdMappingStorage.java @@ -50,6 +50,7 @@ boolean updateOrDeleteExternalUserIdInfo(AppIdentifier appIdentifier, String use // This function will be used to retrieve the userId mapping for a list of userIds. The key of the HashMap will be // superTokensUserId and the value will be the externalUserId. If a mapping does not exist for an input userId, // it will not be in a part of the returned HashMap - HashMap getUserIdMappingForSuperTokensIds(ArrayList userIds) throws StorageQueryException; + HashMap getUserIdMappingForSuperTokensIds(AppIdentifier appIdentifier, + ArrayList userIds) throws StorageQueryException; } diff --git a/src/main/java/io/supertokens/pluginInterface/utils/Utils.java b/src/main/java/io/supertokens/pluginInterface/utils/Utils.java new file mode 100644 index 00000000..0daa85d3 --- /dev/null +++ b/src/main/java/io/supertokens/pluginInterface/utils/Utils.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.pluginInterface.utils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +public class Utils { + public static boolean unorderedStringArrayEquals(String[] arr1, String[] arr2) { + if (arr1 == null && arr2 == null) { + return true; + } + + if (arr1 == null || arr2 == null) { + return false; + } + + Set set1 = Set.of(arr1); + Set set2 = Set.of(arr2); + + return set1.equals(set2); + } + + public static boolean unorderedArrayEquals(Object[] array1, Object[] array2) { + if (array1 == null && array2 == null) { + return true; + } else if (array1 == null || array2 == null) { + return false; + } + + List items1 = List.of(array1); + List items2 = new ArrayList<>(); + items2.addAll(Arrays.asList(array2)); + + if (items1.size() != items2.size()) return false; + + for (Object p1 : items1) { + boolean found = false; + for (Object p2 : items2) { + if (p1.equals(p2)) { + found = true; + break; + } + } + + if (!found) { + return false; + } else { + items2.remove(p1); + } + } + + return true; + } +}