diff --git a/data-upgrade-users/pom.xml b/data-upgrade-users/pom.xml index 324e8b454..f98cb6882 100644 --- a/data-upgrade-users/pom.xml +++ b/data-upgrade-users/pom.xml @@ -35,5 +35,12 @@ social-component-service provided + + + org.exoplatform.gatein.portal + exo.portal.component.identity + test-jar + test + diff --git a/data-upgrade-users/src/main/java/org/exoplatform/migration/UserPasswordHashMigration.java b/data-upgrade-users/src/main/java/org/exoplatform/migration/UserPasswordHashMigration.java new file mode 100644 index 000000000..15d56b20b --- /dev/null +++ b/data-upgrade-users/src/main/java/org/exoplatform/migration/UserPasswordHashMigration.java @@ -0,0 +1,139 @@ +/* + * This file is part of the Meeds project (https://meeds.io/). + * Copyright (C) 2023 Meeds Association + * contact@meeds.io + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.exoplatform.migration; + +import org.apache.commons.codec.binary.Hex; +import org.exoplatform.commons.persistence.impl.EntityManagerService; +import org.exoplatform.commons.upgrade.UpgradePluginExecutionContext; +import org.exoplatform.commons.upgrade.UpgradeProductPlugin; +import org.exoplatform.container.PortalContainer; +import org.exoplatform.container.component.RequestLifeCycle; +import org.exoplatform.container.xml.InitParams; +import org.exoplatform.services.log.ExoLogger; +import org.exoplatform.services.log.Log; +import org.exoplatform.services.organization.idm.PicketLinkIDMService; +import org.exoplatform.web.security.security.SecureRandomService; +import org.picketlink.idm.api.User; + +import javax.persistence.EntityManager; +import javax.persistence.Query; +import java.security.SecureRandom; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +public class UserPasswordHashMigration extends UpgradeProductPlugin { + + private static final Log LOG = ExoLogger.getExoLogger(UserPasswordHashMigration.class); + + private final EntityManagerService entityManagerService; + + private final PicketLinkIDMService picketLinkIDMService; + + private final SecureRandomService secureRandomService; + + private static final String PASSWORD_SALT_USER_ATTRIBUTE = "passwordSalt128"; + + private static final String DEFAULT_ENCODER = "org.exoplatform.web.security.hash.Argon2IdPasswordEncoder"; + + private boolean hasErrors = false; + + public UserPasswordHashMigration(EntityManagerService entityManagerService, + PicketLinkIDMService picketLinkIDMService, + SecureRandomService secureRandomService, + InitParams initParams) { + super(initParams); + this.entityManagerService = entityManagerService; + this.picketLinkIDMService = picketLinkIDMService; + this.secureRandomService = secureRandomService; + } + + @Override + public void processUpgrade(String s, String s1) { + LOG.info("Start upgrade of users passwords hashing algorithm"); + long startupTime = System.currentTimeMillis(); + + PortalContainer container = PortalContainer.getInstance(); + RequestLifeCycle.begin(container); + AtomicInteger updatedPasswords = new AtomicInteger(); + EntityManager entityManager = this.entityManagerService.getEntityManager(); + try { + String sqlString = "SELECT jbid_io.NAME, jbid_io_creden.TEXT FROM" + + " (jbid_io_creden INNER JOIN jbid_io ON jbid_io_creden.IDENTITY_OBJECT_ID = jbid_io.ID)" + + " INNER JOIN( SELECT jbid_io_attr.IDENTITY_OBJECT_ID," + + " min(CASE WHEN jbid_io_attr.NAME = 'passwordSalt128' THEN jbid_io_attr.NAME ELSE NULL END) AS salt128" + + " FROM jbid_io_attr GROUP BY jbid_io_attr.IDENTITY_OBJECT_ID" + + " HAVING salt128 IS NOT NULL) jia ON jbid_io_creden.IDENTITY_OBJECT_ID = jia.IDENTITY_OBJECT_ID;"; + + Query nativeQuery = entityManager.createNativeQuery(sqlString); + List result = nativeQuery.getResultList(); + result.forEach(item -> { + String userName = (String) item[0]; + String passwordHash = (String) item[1]; + try { + String saltString = Hex.encodeHexString(generateRandomSalt()); + User user = picketLinkIDMService.getIdentitySession().getPersistenceManager().findUser(userName); + picketLinkIDMService.getExtendedAttributeManager().addAttribute(userName, PASSWORD_SALT_USER_ATTRIBUTE, saltString); + picketLinkIDMService.getExtendedAttributeManager().updatePassword(user, passwordHash); + int count = updatedPasswords.getAndIncrement(); + if (count % 50 == 0 || count == result.size()) { + LOG.info("{}/{} passwords have been updated", count, result.size()); + } + } catch (Exception e) { + hasErrors = true; + LOG.error("Error while creating attribute salt and updating password hash for user : {}", userName, e); + } + }); + if (hasErrors) { + throw new IllegalStateException("UserPasswordHashMigration upgrade failed due to previous errors"); + } + } catch (Exception e) { + LOG.error("Error while getting old users passwords hash", e); + throw new IllegalStateException("UserPasswordHashMigration upgrade failed due to previous errors"); + } finally { + RequestLifeCycle.end(); + } + LOG.info("End upgrade of users passwords hashing algorithm. {} passwords has been updated. It took {} ms", + updatedPasswords.get(), + (System.currentTimeMillis() - startupTime)); + } + + @Override + public boolean shouldProceedToUpgrade(String newVersion, + String previousGroupVersion, + UpgradePluginExecutionContext previousUpgradePluginExecution) { + try { + return picketLinkIDMService.getExtendedAttributeManager() + .getDefaultCredentialEncoder() + .getClass() + .getName() + .equals(DEFAULT_ENCODER) + && super.shouldProceedToUpgrade(newVersion, previousGroupVersion, previousUpgradePluginExecution); + } catch (Exception e) { + LOG.error("Error while checking current default credential encoder", e); + return false; + } + } + + private byte[] generateRandomSalt() { + SecureRandom secureRandom = secureRandomService.getSecureRandom(); + byte[] salt = new byte[16]; + secureRandom.nextBytes(salt); + return salt; + } +} diff --git a/data-upgrade-users/src/main/resources/conf/portal/configuration.xml b/data-upgrade-users/src/main/resources/conf/portal/configuration.xml index 1f94e0e9b..8be0406f5 100644 --- a/data-upgrade-users/src/main/resources/conf/portal/configuration.xml +++ b/data-upgrade-users/src/main/resources/conf/portal/configuration.xml @@ -93,6 +93,40 @@ + + UserPasswordHashMigration + addUpgradePlugin + org.exoplatform.migration.UserPasswordHashMigration + Update users passwords hash algorithm + + + product.group.id + The groupId of the product + org.exoplatform.platform + + + plugin.upgrade.target.version + The plugin target version (will not be executed if previous version is equal or higher than 6.3.1) + + 6.5.0 + + + plugin.execution.order + The plugin execution order + 100 + + + plugin.upgrade.execute.once + The plugin must be executed only once + true + + + plugin.upgrade.async.execution + The plugin will be executed in an asynchronous mode + true + + + diff --git a/data-upgrade-users/src/test/java/org/exoplatform/migration/UserPasswordHashMigrationTest.java b/data-upgrade-users/src/test/java/org/exoplatform/migration/UserPasswordHashMigrationTest.java new file mode 100644 index 000000000..2086628ed --- /dev/null +++ b/data-upgrade-users/src/test/java/org/exoplatform/migration/UserPasswordHashMigrationTest.java @@ -0,0 +1,130 @@ +/* + * This file is part of the Meeds project (https://meeds.io/). + * Copyright (C) 2023 Meeds Association + * contact@meeds.io + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.exoplatform.migration; + +import org.exoplatform.commons.persistence.impl.EntityManagerService; +import org.exoplatform.commons.upgrade.UpgradePluginExecutionContext; +import org.exoplatform.container.PortalContainer; +import org.exoplatform.container.xml.InitParams; +import org.exoplatform.container.xml.ValueParam; +import org.exoplatform.services.organization.idm.PicketLinkIDMService; +import org.exoplatform.web.security.hash.Argon2IdPasswordEncoder; +import org.exoplatform.web.security.security.SecureRandomService; +import org.gatein.portal.idm.impl.store.attribute.ExtendedAttributeManager; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.picketlink.idm.api.IdentitySession; +import org.picketlink.idm.api.PersistenceManager; +import org.picketlink.idm.spi.configuration.metadata.IdentityConfigurationMetaData; +import org.picketlink.idm.spi.configuration.metadata.RealmConfigurationMetaData; + +import javax.persistence.EntityManager; +import javax.persistence.Query; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@RunWith(MockitoJUnitRunner.class) +public class UserPasswordHashMigrationTest { + + @Mock + private EntityManagerService entityManagerService; + + @Mock + private PicketLinkIDMService picketLinkIDMService; + + private SecureRandomService secureRandomService; + + private PortalContainer container; + + private UserPasswordHashMigration userPasswordHashMigration; + + private static final String DEFAULT_ENCODER = "org.exoplatform.web.security.hash.Argon2IdPasswordEncoder"; + + @Before + public void setUp() { + container = PortalContainer.getInstance(); + secureRandomService = container.getComponentInstanceOfType(SecureRandomService.class); + InitParams initParams = new InitParams(); + ValueParam valueParam = new ValueParam(); + valueParam.setName("product.group.id"); + valueParam.setValue("org.exoplatform.platform"); + ValueParam valueParamVersion = new ValueParam(); + valueParamVersion.setName("plugin.upgrade.target.version"); + valueParamVersion.setValue("6.5.0"); + ValueParam oldAppNamevalueParam = new ValueParam(); + oldAppNamevalueParam.setName("plugin.execution.order"); + oldAppNamevalueParam.setValue("100"); + ValueParam oldAppIdvalueParam = new ValueParam(); + oldAppIdvalueParam.setName("plugin.upgrade.execute.once"); + oldAppIdvalueParam.setValue("true"); + ValueParam newAppIdvalueParam = new ValueParam(); + newAppIdvalueParam.setName("plugin.upgrade.async.execution"); + newAppIdvalueParam.setValue("true"); + initParams.addParameter(valueParam); + initParams.addParameter(valueParamVersion); + initParams.addParameter(oldAppNamevalueParam); + initParams.addParameter(oldAppIdvalueParam); + initParams.addParameter(newAppIdvalueParam); + userPasswordHashMigration = new UserPasswordHashMigration(entityManagerService, + picketLinkIDMService, + secureRandomService, + initParams); + } + + @Test + public void tesProcessUpgrade() throws Exception { + List result = new ArrayList<>(); + result.add(new String[] { "user", "passwordHash" }); + EntityManager entityManager = mock(EntityManager.class); + Query query = mock(Query.class); + when(entityManager.createNativeQuery(anyString())).thenReturn(query); + when(query.getResultList()).thenReturn(result); + when(entityManagerService.getEntityManager()).thenReturn(entityManager); + + IdentitySession identitySession = mock(IdentitySession.class); + PersistenceManager persistenceManager = mock(PersistenceManager.class); + ExtendedAttributeManager extendedAttributeManager = mock(ExtendedAttributeManager.class); + when(identitySession.getPersistenceManager()).thenReturn(persistenceManager); + when(picketLinkIDMService.getIdentitySession()).thenReturn(identitySession); + when(picketLinkIDMService.getExtendedAttributeManager()).thenReturn(extendedAttributeManager); + userPasswordHashMigration.processUpgrade(null, null); + verify(extendedAttributeManager, times(1)).updatePassword(any(), anyString()); + verify(extendedAttributeManager, times(1)).addAttribute(anyString(), anyString(), anyString()); + + when(extendedAttributeManager.getDefaultCredentialEncoder()).thenReturn(new Argon2IdPasswordEncoder()); + boolean proceedToUpgrade = userPasswordHashMigration.shouldProceedToUpgrade(null, null, null); + assertFalse(proceedToUpgrade); + + UpgradePluginExecutionContext upgradePluginExecutionContext = new UpgradePluginExecutionContext("6.4.0", 0); + proceedToUpgrade = userPasswordHashMigration.shouldProceedToUpgrade("6.5.0", "6.4.0", upgradePluginExecutionContext); + assertTrue(proceedToUpgrade); + // Case of exceptions existing during upgrade + doThrow(new RuntimeException()).when(picketLinkIDMService).getIdentitySession(); + Throwable exception = assertThrows(IllegalStateException.class, () -> userPasswordHashMigration.processUpgrade(null, null)); + assertEquals("UserPasswordHashMigration upgrade failed due to previous errors", exception.getMessage()); + } +}