diff --git a/src/main/java/io/supertokens/ActiveUsers.java b/src/main/java/io/supertokens/ActiveUsers.java index 55224cb49..7c4601958 100644 --- a/src/main/java/io/supertokens/ActiveUsers.java +++ b/src/main/java/io/supertokens/ActiveUsers.java @@ -1,6 +1,8 @@ package io.supertokens; +import io.supertokens.pluginInterface.authRecipe.sqlStorage.AuthRecipeSQLStorage; import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException; import io.supertokens.pluginInterface.multitenancy.AppIdentifierWithStorage; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.storageLayer.StorageLayer; @@ -37,4 +39,18 @@ public static int countUsersActiveSince(Main main, long time) return countUsersActiveSince(new AppIdentifierWithStorage(null, null, StorageLayer.getStorage(main)), main, time); } + + public static void removeActiveUser(AppIdentifierWithStorage appIdentifierWithStorage, String userId) + throws StorageQueryException { + try { + ((AuthRecipeSQLStorage) appIdentifierWithStorage.getActiveUsersStorage()).startTransaction(con -> { + appIdentifierWithStorage.getActiveUsersStorage().deleteUserActive_Transaction(con, appIdentifierWithStorage, userId); + ((AuthRecipeSQLStorage) appIdentifierWithStorage.getActiveUsersStorage()).commitTransaction(con); + return null; + }); + + } catch (StorageTransactionLogicException e) { + throw new StorageQueryException(e.actualException); + } + } } diff --git a/src/main/java/io/supertokens/webserver/api/accountlinking/LinkAccountsAPI.java b/src/main/java/io/supertokens/webserver/api/accountlinking/LinkAccountsAPI.java index b48009cfa..10f7a7bd1 100644 --- a/src/main/java/io/supertokens/webserver/api/accountlinking/LinkAccountsAPI.java +++ b/src/main/java/io/supertokens/webserver/api/accountlinking/LinkAccountsAPI.java @@ -17,6 +17,7 @@ package io.supertokens.webserver.api.accountlinking; import com.google.gson.JsonObject; +import io.supertokens.ActiveUsers; import io.supertokens.AppIdentifierWithStorageAndUserIdMapping; import io.supertokens.Main; import io.supertokens.authRecipe.AuthRecipe; @@ -26,6 +27,7 @@ import io.supertokens.featureflag.exceptions.FeatureNotEnabledException; import io.supertokens.pluginInterface.RECIPE_ID; import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; +import io.supertokens.pluginInterface.authRecipe.LoginMethod; import io.supertokens.pluginInterface.emailpassword.exceptions.UnknownUserIdException; import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.multitenancy.AppIdentifierWithStorage; @@ -39,6 +41,8 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; public class LinkAccountsAPI extends WebserverAPI { @@ -96,6 +100,9 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I AuthRecipe.LinkAccountsResult linkAccountsResult = AuthRecipe.linkAccounts(main, primaryUserIdAppIdentifierWithStorage, recipeUserId, primaryUserId); + // Remove linked account user id from active user + ActiveUsers.removeActiveUser(recipeUserIdAppIdentifierWithStorage, recipeUserId); + UserIdMapping.populateExternalUserIdForUsers(primaryUserIdAppIdentifierWithStorage, new AuthRecipeUserInfo[]{linkAccountsResult.user}); JsonObject response = new JsonObject(); response.addProperty("status", "OK"); diff --git a/src/test/java/io/supertokens/test/accountlinking/api/ActiveUserTest.java b/src/test/java/io/supertokens/test/accountlinking/api/ActiveUserTest.java new file mode 100644 index 000000000..c07765164 --- /dev/null +++ b/src/test/java/io/supertokens/test/accountlinking/api/ActiveUserTest.java @@ -0,0 +1,194 @@ +/* + * 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.test.accountlinking.api; + +import com.google.gson.JsonObject; +import io.supertokens.ActiveUsers; +import io.supertokens.Main; +import io.supertokens.ProcessState; +import io.supertokens.authRecipe.AuthRecipe; +import io.supertokens.emailpassword.EmailPassword; +import io.supertokens.emailpassword.exceptions.EmailChangeNotAllowedException; +import io.supertokens.featureflag.EE_FEATURES; +import io.supertokens.featureflag.FeatureFlagTestContent; +import io.supertokens.passwordless.Passwordless; +import io.supertokens.passwordless.exceptions.*; +import io.supertokens.pluginInterface.STORAGE_TYPE; +import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; +import io.supertokens.pluginInterface.emailpassword.exceptions.DuplicateEmailException; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException; +import io.supertokens.pluginInterface.passwordless.exception.DuplicateLinkCodeHashException; +import io.supertokens.storageLayer.StorageLayer; +import io.supertokens.test.TestingProcessManager; +import io.supertokens.test.Utils; +import io.supertokens.test.httpRequest.HttpRequestForTesting; +import io.supertokens.thirdparty.ThirdParty; +import io.supertokens.utils.SemVer; +import io.supertokens.webserver.WebserverAPI; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class ActiveUserTest { + @Rule + public TestRule watchman = Utils.getOnFailure(); + + @AfterClass + public static void afterTesting() { + Utils.afterTesting(); + } + + @Before + public void beforeEach() { + Utils.reset(); + } + + + AuthRecipeUserInfo createEmailPasswordUser(Main main, String email, String password) + throws DuplicateEmailException, StorageQueryException { + return EmailPassword.signUp(main, email, password); + } + + AuthRecipeUserInfo createThirdPartyUser(Main main, String thirdPartyId, String thirdPartyUserId, String email) + throws EmailChangeNotAllowedException, StorageQueryException { + return ThirdParty.signInUp(main, thirdPartyId, thirdPartyUserId, email).user; + } + + AuthRecipeUserInfo createPasswordlessUserWithEmail(Main main, String email) + throws DuplicateLinkCodeHashException, StorageQueryException, NoSuchAlgorithmException, IOException, + RestartFlowException, InvalidKeyException, Base64EncodingException, DeviceIdHashMismatchException, + StorageTransactionLogicException, IncorrectUserInputCodeException, ExpiredUserInputCodeException { + Passwordless.CreateCodeResponse code = Passwordless.createCode(main, email, null, + null, "123456"); + return Passwordless.consumeCode(main, code.deviceId, code.deviceIdHash, + code.userInputCode, null).user; + } + + AuthRecipeUserInfo createPasswordlessUserWithPhone(Main main, String phone) + throws DuplicateLinkCodeHashException, StorageQueryException, NoSuchAlgorithmException, IOException, + RestartFlowException, InvalidKeyException, Base64EncodingException, DeviceIdHashMismatchException, + StorageTransactionLogicException, IncorrectUserInputCodeException, ExpiredUserInputCodeException { + Passwordless.CreateCodeResponse code = Passwordless.createCode(main, null, phone, + null, "123456"); + return Passwordless.consumeCode(main, code.deviceId, code.deviceIdHash, + code.userInputCode, null).user; + } + + @Test + public void testActiveUserIsRemovedAfterLinkingAccounts() throws Exception { + String[] args = {"../"}; + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args, false); + FeatureFlagTestContent.getInstance(process.getProcess()) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{ + EE_FEATURES.ACCOUNT_LINKING, EE_FEATURES.MULTI_TENANCY}); + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { + return; + } + + AuthRecipeUserInfo user1 = createEmailPasswordUser(process.getProcess(), "test@example.com", "password"); + AuthRecipeUserInfo user2 = createThirdPartyUser(process.getProcess(), "google", "google-user", "test@example.com"); + + { + // Update active user + JsonObject responseBody = new JsonObject(); + responseBody.addProperty("email", "test@example.com"); + responseBody.addProperty("password", "password"); + + HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/signin", responseBody, 1000, 1000, null, SemVer.v4_0.get(), + "emailpassword"); + } + { + JsonObject emailObject = new JsonObject(); + emailObject.addProperty("id", "test@example.com"); + + JsonObject signUpRequestBody = new JsonObject(); + signUpRequestBody.addProperty("thirdPartyId", "google"); + signUpRequestBody.addProperty("thirdPartyUserId", "google-user"); + signUpRequestBody.add("email", emailObject); + + HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/signinup", signUpRequestBody, 1000, 1000, null, + SemVer.v4_0.get(), "thirdparty"); + } + + int userCount = ActiveUsers.countUsersActiveSince(process.getProcess(), System.currentTimeMillis() - 10000); + assertEquals(2, userCount); + + { + // Link accounts + AuthRecipe.createPrimaryUser(process.main, user2.getSupertokensUserId()); + + JsonObject params = new JsonObject(); + params.addProperty("recipeUserId", user1.getSupertokensUserId()); + params.addProperty("primaryUserId", user2.getSupertokensUserId()); + + HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/accountlinking/user/link", params, 1000, 1000, null, + WebserverAPI.getLatestCDIVersion().get(), ""); + } + + // Now there should be only one active user + userCount = ActiveUsers.countUsersActiveSince(process.getProcess(), System.currentTimeMillis() - 10000); + assertEquals(1, userCount); + + // Sign in to the accounts once again + { + // Update active user + JsonObject responseBody = new JsonObject(); + responseBody.addProperty("email", "test@example.com"); + responseBody.addProperty("password", "password"); + + HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/signin", responseBody, 1000, 1000, null, SemVer.v4_0.get(), + "emailpassword"); + } + { + JsonObject emailObject = new JsonObject(); + emailObject.addProperty("id", "test@example.com"); + + JsonObject signUpRequestBody = new JsonObject(); + signUpRequestBody.addProperty("thirdPartyId", "google"); + signUpRequestBody.addProperty("thirdPartyUserId", "google-user"); + signUpRequestBody.add("email", emailObject); + + HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/signinup", signUpRequestBody, 1000, 1000, null, + SemVer.v4_0.get(), "thirdparty"); + } + + // there should still be only one active user + userCount = ActiveUsers.countUsersActiveSince(process.getProcess(), System.currentTimeMillis() - 10000); + assertEquals(1, userCount); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } +}