diff --git a/ccm-core/pom.xml b/ccm-core/pom.xml index 67c2b3c10..3f12eee07 100644 --- a/ccm-core/pom.xml +++ b/ccm-core/pom.xml @@ -70,6 +70,10 @@ commons-beanutils commons-beanutils + + commons-codec + commons-codec + oro @@ -262,7 +266,22 @@ - + + org.jboss.tattletale + tattletale-maven + + + + report + + + + + ${project.build.directory} + ${project.build.directory}/tattletale + + + diff --git a/ccm-core/src/main/java/org/libreccm/core/AbstractEntityRepository.java b/ccm-core/src/main/java/org/libreccm/core/AbstractEntityRepository.java index 888474f6b..b7a782d3f 100644 --- a/ccm-core/src/main/java/org/libreccm/core/AbstractEntityRepository.java +++ b/ccm-core/src/main/java/org/libreccm/core/AbstractEntityRepository.java @@ -18,7 +18,6 @@ */ package org.libreccm.core; -import org.hibernate.validator.cdi.HibernateValidator; import java.util.List; @@ -28,7 +27,6 @@ import javax.persistence.TypedQuery; import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaQuery; import javax.persistence.criteria.Root; -import javax.validation.Validator; /** * A base class providing common method needed by every repository. diff --git a/ccm-core/src/main/java/org/libreccm/core/CoreConstants.java b/ccm-core/src/main/java/org/libreccm/core/CoreConstants.java index 1ea2d4bf0..d88880828 100644 --- a/ccm-core/src/main/java/org/libreccm/core/CoreConstants.java +++ b/ccm-core/src/main/java/org/libreccm/core/CoreConstants.java @@ -26,6 +26,7 @@ package org.libreccm.core; public final class CoreConstants { public static final String CORE_XML_NS = "http://core.libreccm.org"; + public static final String UTF8 = "UTF-8"; private CoreConstants() { //Nothing diff --git a/ccm-core/src/main/java/org/libreccm/core/PasswordHashingFailedException.java b/ccm-core/src/main/java/org/libreccm/core/PasswordHashingFailedException.java new file mode 100644 index 000000000..0c686cbc9 --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/core/PasswordHashingFailedException.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2015 LibreCCM Foundation. + * + * This library 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 2.1 of the License, or (at your option) any later version. + * + * This library 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ + +package org.libreccm.core; + +/** + * + * @author Jens Pelzetter + */ +public class PasswordHashingFailedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * Creates a new instance of PasswordHashingFailed without detail message. + */ + public PasswordHashingFailedException() { + super(); + } + + + /** + * Constructs an instance of PasswordHashingFailed with the specified detail message. + * + * @param msg The detail message. + */ + public PasswordHashingFailedException(final String msg) { + super(msg); + } + + /** + * Constructs an instance of PasswordHashingFailed which wraps the + * specified exception. + * + * @param exception The exception to wrap. + */ + public PasswordHashingFailedException(final Exception exception) { + super(exception); + } + + /** + * Constructs an instance of PasswordHashingFailed with the specified message which also wraps the + * specified exception. + * + * @param msg The detail message. + * @param exception The exception to wrap. + */ + public PasswordHashingFailedException(final String msg, final Exception exception) { + super(msg, exception); + } +} diff --git a/ccm-core/src/main/java/org/libreccm/core/UserManager.java b/ccm-core/src/main/java/org/libreccm/core/UserManager.java index 3253952fd..2b506f306 100644 --- a/ccm-core/src/main/java/org/libreccm/core/UserManager.java +++ b/ccm-core/src/main/java/org/libreccm/core/UserManager.java @@ -18,6 +18,9 @@ */ package org.libreccm.core; +import static org.libreccm.core.CoreConstants.*; + +import org.apache.commons.codec.binary.Base64; import java.io.UnsupportedEncodingException; import java.security.MessageDigest; @@ -30,7 +33,7 @@ import javax.inject.Inject; /** * This class provides complex operations on {@link User} objects like updating * the password. To use this class add an injection point to your class. - * + * * @author Jens Pelzetter */ @RequestScoped @@ -39,74 +42,134 @@ public class UserManager { /** * {@link UserRepository} for interacting with the database. The method * takes care of hashing the password with random salt. - * + * */ @Inject private transient UserRepository userRepository; - + + private byte[] generateHash(final byte[] password, final byte[] salt) { + final byte[] saltedPassword = new byte[password.length + salt.length]; + + System.arraycopy(password, 0, saltedPassword, 0, password.length); + System.arraycopy(salt, 0, saltedPassword, password.length, salt.length); + + try { + final MessageDigest digest = MessageDigest.getInstance( + getHashAlgorithm()); + + final byte[] hashedPassword = digest.digest(saltedPassword); + + return hashedPassword; + } catch (NoSuchAlgorithmException ex) { + throw new PasswordHashingFailedException( + "Failed to generate hash for password", ex); + } + } + /** * Update the password of an user. - * - * @param user The user whose password is to be updated. + * + * @param user The user whose password is to be updated. * @param password The new password. */ public void updatePassword(final User user, final String password) { try { final Random random = new Random(System.currentTimeMillis()); - final byte[] passwordBytes = password.getBytes("UTF-8"); + final byte[] passwordBytes = password.getBytes(UTF8); final byte[] salt = new byte[getSaltLength()]; random.nextBytes(salt); - final byte[] saltedPassword = new byte[passwordBytes.length - + salt.length]; - System.arraycopy(passwordBytes, - 0, - saltedPassword, - 0, - passwordBytes.length); - System.arraycopy(salt, - 0, - saltedPassword, - passwordBytes.length, - salt.length); - final MessageDigest digest = MessageDigest.getInstance( - getHashAlgorithm()); - final byte[] hashedBytes = digest.digest(saltedPassword); - - final String hashedPassword = new String(hashedBytes, "UTF-8"); + final byte[] hashedBytes = generateHash(passwordBytes, salt); + + final Base64 base64 = new Base64(); + final String hashedPassword = base64.encodeToString(hashedBytes); + final String saltStr = base64.encodeToString(salt); user.setPassword(hashedPassword); + user.setSalt(saltStr); userRepository.save(user); - - } catch (NoSuchAlgorithmException ex) { - throw new RuntimeException(String.format( - "Configured hash algorithm '%s 'is not available.", - getHashAlgorithm()), ex); + } catch (UnsupportedEncodingException ex) { - throw new RuntimeException("UTF-8 charset is not supported."); + throw new PasswordHashingFailedException( + "UTF-8 charset is not supported.", ex); + } + } + + /** + * Verify a password for the a specific user. + * + * @param user The user whose password is to be checked. + * @param password The password to verify. + * + * @return {@code true} if the provided password matches the password + * stored, {@code false} if not. + */ + public boolean verifyPasswordForUser(final User user, + final String password) { + final Base64 base64 = new Base64(); + + try { + final byte[] hashed = generateHash( + password.getBytes(UTF8), base64.decode(user.getSalt())); + + final String hashedPassword = base64.encodeAsString(hashed); + + return hashedPassword.equals(user.getPassword()); + + } catch (UnsupportedEncodingException ex) { + throw new PasswordHashingFailedException( + "Failed to generate hash of password", ex); + } + } + + public boolean verifyPasswordForScreenname(final String screenname, + final String password) + throws UserNotFoundException { + + final User user = userRepository.findByScreenName(screenname); + + if (user == null) { + throw new UserNotFoundException(String.format( + "No user identified by screenname '%s' found.", screenname)); + } else { + return verifyPasswordForUser(user, password); + } + } + + public boolean verifyPasswordForEmail(final String emailAddress, + final String password) + throws UserNotFoundException{ + + final User user = userRepository.findByEmailAddress(emailAddress); + + if (user == null) { + throw new UserNotFoundException(String.format( + "No user identified by email address '%s' found.", emailAddress)); + } else { + return verifyPasswordForUser(user, password); } } /** * Gets the hash algorithm to use. - * + * * ToDo: Make configurable. - * + * * @return At the moment SHA-512, will be made configurable. */ - private String getHashAlgorithm() { + public String getHashAlgorithm() { return "SHA-512"; } /** * Returns the length for the salt (number of bytes). - * + * * ToDo: Make configurable. - * + * * @return At the moment 256. Will be made configurable. */ - private int getSaltLength() { + public int getSaltLength() { return 256; } diff --git a/ccm-core/src/main/java/org/libreccm/core/UserNotFoundException.java b/ccm-core/src/main/java/org/libreccm/core/UserNotFoundException.java new file mode 100644 index 000000000..6aa986d47 --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/core/UserNotFoundException.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2015 LibreCCM Foundation. + * + * This library 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 2.1 of the License, or (at your option) any later version. + * + * This library 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ + +package org.libreccm.core; + +/** + * + * @author Jens Pelzetter + */ +public class UserNotFoundException extends Exception { + + private static final long serialVersionUID = 1L; + + /** + * Creates a new instance of UserNotFoundException without detail message. + */ + public UserNotFoundException() { + super(); + } + + + /** + * Constructs an instance of UserNotFoundException with the specified detail message. + * + * @param msg The detail message. + */ + public UserNotFoundException(final String msg) { + super(msg); + } + + /** + * Constructs an instance of UserNotFoundException which wraps the + * specified exception. + * + * @param exception The exception to wrap. + */ + public UserNotFoundException(final Exception exception) { + super(exception); + } + + /** + * Constructs an instance of UserNotFoundException with the specified message which also wraps the + * specified exception. + * + * @param msg The detail message. + * @param exception The exception to wrap. + */ + public UserNotFoundException(final String msg, final Exception exception) { + super(msg, exception); + } +} diff --git a/ccm-core/src/test/java/org/libreccm/core/UserManagerTest.java b/ccm-core/src/test/java/org/libreccm/core/UserManagerTest.java new file mode 100644 index 000000000..e677b6612 --- /dev/null +++ b/ccm-core/src/test/java/org/libreccm/core/UserManagerTest.java @@ -0,0 +1,288 @@ +/* + * Copyright (C) 2015 LibreCCM Foundation. + * + * This library 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 2.1 of the License, or (at your option) any later version. + * + * This library 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ +package org.libreccm.core; + +import static org.hamcrest.Matchers.*; + +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.arquillian.junit.Arquillian; +import org.jboss.arquillian.junit.InSequence; +import org.jboss.arquillian.persistence.PersistenceTest; +import org.jboss.arquillian.persistence.UsingDataSet; +import org.jboss.arquillian.transaction.api.annotation.TransactionMode; +import org.jboss.arquillian.transaction.api.annotation.Transactional; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.EmptyAsset; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.jboss.shrinkwrap.resolver.api.maven.Maven; +import org.jboss.shrinkwrap.resolver.api.maven.PomEquippedResolveStage; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.runner.RunWith; + +import static org.libreccm.core.CoreConstants.*; + +import org.apache.commons.codec.binary.Base64; +import org.jboss.arquillian.container.test.api.ShouldThrowException; +import org.libreccm.tests.categories.IntegrationTest; + +import java.io.File; +import java.io.UnsupportedEncodingException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import javax.inject.Inject; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; + +import static org.junit.Assert.*; + +/** + * + * @author Jens Pelzetter + */ +@Category(IntegrationTest.class) +@RunWith(Arquillian.class) +@PersistenceTest +@Transactional(TransactionMode.COMMIT) +public class UserManagerTest { + + @Inject + private transient UserRepository userRepository; + + @Inject + private transient UserManager userManager; + + @PersistenceContext + private transient EntityManager entityManager; + + public UserManagerTest() { + } + + @BeforeClass + public static void setUpClass() { + } + + @AfterClass + public static void tearDownClass() { + } + + @Before + public void setUp() { + } + + @After + public void tearDown() { + } + + @Deployment + public static WebArchive createDeployment() { + final PomEquippedResolveStage pom = Maven + .resolver() + .loadPomFromFile("pom.xml"); + final PomEquippedResolveStage dependencies = pom. + importCompileAndRuntimeDependencies(); + final File[] libs = dependencies.resolve().withTransitivity().asFile(); + + for (File lib : libs) { + System.err.printf("Adding file '%s' to test archive...%n", + lib.getName()); + } + + return ShrinkWrap + .create(WebArchive.class, + "LibreCCM-org.libreccm.core.UserRepositoryTest.war") + .addPackage(User.class.getPackage()) + .addPackage(org.libreccm.web.Application.class.getPackage()) + .addPackage(org.libreccm.categorization.Category.class. + getPackage()) + .addPackage(org.libreccm.l10n.LocalizedString.class.getPackage()). + addPackage(org.libreccm.jpa.EntityManagerProducer.class + .getPackage()) + .addPackage(org.libreccm.jpa.utils.MimeTypeConverter.class + .getPackage()) + .addPackage(org.libreccm.testutils.EqualsVerifier.class. + getPackage()) + .addPackage(org.libreccm.tests.categories.IntegrationTest.class + .getPackage()) + .addAsLibraries(libs) + .addAsResource("test-persistence.xml", + "META-INF/persistence.xml") + .addAsWebInfResource("test-web.xml", "WEB-INF/web.xml") + .addAsWebInfResource(EmptyAsset.INSTANCE, "WEB-INF/beans.xml"); + + } + + @Test + @InSequence(10) + public void userRepoIsInjected() { + assertThat(userRepository, is(not(nullValue()))); + } + + @Test + @InSequence(20) + public void userManagerIsInjected() { + assertThat(userManager, is(not(nullValue()))); + } + + @Test + @InSequence(30) + public void entityManagerIsInjected() { + assertThat(entityManager, is(not(nullValue()))); + } + + @Test + @UsingDataSet("datasets/org/libreccm/core/UserRepositoryTest/data.json") + @InSequence(100) + public void updatePassword() throws NoSuchAlgorithmException, + UnsupportedEncodingException { + + final User jdoe = userRepository.findById(-10L); + + assertThat(jdoe, is(not(nullValue()))); + assertThat(jdoe.getScreenName(), is("jdoe")); + + final String newPassword = "foobar"; + + userManager.updatePassword(jdoe, newPassword); + + final Base64 base64 = new Base64(); + final User user = entityManager.find(User.class, -10L); + final byte[] passwordBytes = newPassword.getBytes(UTF8); + final String salt = user.getSalt(); + final byte[] saltBytes = base64.decode(salt); + + assertThat(saltBytes.length, is(userManager.getSaltLength())); + + final MessageDigest digest = MessageDigest.getInstance(userManager + .getHashAlgorithm()); + + final byte[] saltedPassword = new byte[passwordBytes.length + + saltBytes.length]; + System.arraycopy(passwordBytes, + 0, + saltedPassword, + 0, + passwordBytes.length); + System.arraycopy(saltBytes, + 0, + saltedPassword, + passwordBytes.length, + saltBytes.length); + final byte[] hashedBytes = digest.digest(saltedPassword); + + final String hashed = base64.encodeToString(hashedBytes); + assertThat(user.getPassword(), is(equalTo(hashed))); + } + + @Test + @UsingDataSet( + "datasets/org/libreccm/core/UserManagerTest/verify-password.json") + @InSequence(200) + public void verifyPasswordForUser() { + final User user = userRepository.findById(-10L); + + //userManager.updatePassword(user, "foobar"); + + final boolean result = userManager.verifyPasswordForUser(user, "foobar"); + + assertThat(result, is(true)); + } + + @Test + @UsingDataSet( + "datasets/org/libreccm/core/UserManagerTest/verify-password.json") + @InSequence(300) + public void verifyPasswordForScreenname() throws UserNotFoundException { + final boolean result = userManager.verifyPasswordForScreenname( + "jdoe", "foobar"); + + assertThat(result, is(true)); + } + + @Test + @UsingDataSet( + "datasets/org/libreccm/core/UserManagerTest/verify-password.json") + @InSequence(400) + public void verifyPasswordForEmail() throws UserNotFoundException { + final boolean result = userManager.verifyPasswordForEmail( + "john.doe@example.com", "foobar"); + + assertThat(result, is(true)); + } + + @Test + @UsingDataSet( + "datasets/org/libreccm/core/UserManagerTest/verify-password.json") + @InSequence(500) + public void verifyPasswordForUserWrongPassword() { + final User user = userRepository.findById(-10L); + + final boolean result = userManager.verifyPasswordForUser(user, "wrong"); + + assertThat(result, is(false)); + } + + @Test + @UsingDataSet( + "datasets/org/libreccm/core/UserManagerTest/verify-password.json") + @InSequence(600) + public void verifyPasswordForScreennameWrongPassword() throws + UserNotFoundException { + final boolean result = userManager.verifyPasswordForScreenname( + "jdoe", "wrong"); + + assertThat(result, is(false)); + } + + @Test + @UsingDataSet( + "datasets/org/libreccm/core/UserManagerTest/verify-password.json") + @InSequence(400) + public void verifyPasswordForEmailWrongPassword() throws + UserNotFoundException { + final boolean result = userManager.verifyPasswordForEmail( + "john.doe@example.com", "wrong"); + + assertThat(result, is(false)); + } + + @Test(expected = UserNotFoundException.class) + @ShouldThrowException(UserNotFoundException.class) + @UsingDataSet( + "datasets/org/libreccm/core/UserManagerTest/verify-password.json") + @InSequence(700) + public void verifyPasswordForScreennameNoUser() throws UserNotFoundException { + userManager.verifyPasswordForScreenname("nobody", "foobar"); + } + + @Test(expected = UserNotFoundException.class) + @ShouldThrowException(UserNotFoundException.class) + @UsingDataSet( + "datasets/org/libreccm/core/UserManagerTest/verify-password.json") + @InSequence(800) + public void verifyPasswordForEmailNoUser() throws UserNotFoundException { + userManager.verifyPasswordForEmail("nobody@example.com", "foobar"); + } + +} diff --git a/ccm-core/src/test/resources/datasets/org/libreccm/core/UserManagerTest/verify-password.json b/ccm-core/src/test/resources/datasets/org/libreccm/core/UserManagerTest/verify-password.json new file mode 100644 index 000000000..76d270f06 --- /dev/null +++ b/ccm-core/src/test/resources/datasets/org/libreccm/core/UserManagerTest/verify-password.json @@ -0,0 +1,31 @@ +{ + "subjects": + [ + { + "subject_id": -10 + } + ], + "subject_email_addresses": + [ + { + "subject_id": -10, + "email_address": "john.doe@example.com", + "bouncing": false, + "verified": true + } + ], + "ccm_users": + [ + { + "banned": false, + "hash_algorithm": "SHA-512", + "family_name": "Doe", + "given_name": "John", + "password": "C+o2w6mp+eLrbluMEgKMVSdP50A9BMethXN8R3yihtkbzt7WfWsde2nmq/t5gq6im3J8i3jw4Y3YrKHou8JQ2A==", + "password_reset_required": false, + "salt": "Fu8FPgqAal4GZp1hDjkOB+t6ITRCcO7HBoN5Xqf29UnVj5NUdUFZRTyKYMBEx6JmZGmHcMDG9OGVCKcEM9oyScSRreJs4B51wM44NM6KeRwbCf+VhBn14DkBrl40ygraNf+AJacKpMyCpFI0O/Am7mMDWL4flskBsylkxaQn3vKfzgN5MVG2szW//I6Q6YEH9AuL8LauS6fKaVynMzzu3xzD8Hjqvvlnzym898eom2lqScPfg5g4e8Ww13HCHAYe6twupAW/BjUNax5HSioEisZN/P1UGrde8uFEj+hbbavrWYZuilPuEu25+/98jyXx6542agqrWN8j0SFYcIyOgA==", + "screen_name": "jdoe", + "subject_id": -10 + } + ] +} \ No newline at end of file