Skip to content

Commit

Permalink
feat: Implement an upgrade plugin to update passwords hashing algorit…
Browse files Browse the repository at this point in the history
…hm - EXO-65151 - Meeds-io/MIPs#69

Prior to this change, As a part of the upgrade mechanism of old hashed passwords we need to create an UP to re-hash old passwords by the new hash algo.
This PR creates an UP which has to update the old passwords hash by the new algo by re-hashing and adding the needed salt attribute.
  • Loading branch information
hakermi committed Jul 31, 2023
1 parent 83f400a commit 48c41c4
Show file tree
Hide file tree
Showing 4 changed files with 310 additions and 0 deletions.
7 changes: 7 additions & 0 deletions data-upgrade-users/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,12 @@
<artifactId>social-component-service</artifactId>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>org.exoplatform.gatein.portal</groupId>
<artifactId>exo.portal.component.identity</artifactId>
<type>test-jar</type>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -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<Object[]> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,40 @@
</value-param>
</init-params>
</component-plugin>
<component-plugin>
<name>UserPasswordHashMigration</name>
<set-method>addUpgradePlugin</set-method>
<type>org.exoplatform.migration.UserPasswordHashMigration</type>
<description>Update users passwords hash algorithm</description>
<init-params>
<value-param>
<name>product.group.id</name>
<description>The groupId of the product</description>
<value>org.exoplatform.platform</value>
</value-param>
<value-param>
<name>plugin.upgrade.target.version</name>
<description>The plugin target version (will not be executed if previous version is equal or higher than 6.3.1)
</description>
<value>6.5.0</value>
</value-param>
<value-param>
<name>plugin.execution.order</name>
<description>The plugin execution order</description>
<value>100</value>
</value-param>
<value-param>
<name>plugin.upgrade.execute.once</name>
<description>The plugin must be executed only once</description>
<value>true</value>
</value-param>
<value-param>
<name>plugin.upgrade.async.execution</name>
<description>The plugin will be executed in an asynchronous mode</description>
<value>true</value>
</value-param>
</init-params>
</component-plugin>
</external-component-plugins>

</configuration>
Original file line number Diff line number Diff line change
@@ -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<Object[]> 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());
}
}

0 comments on commit 48c41c4

Please sign in to comment.