diff --git a/deegree-services/deegree-webservices/pom.xml b/deegree-services/deegree-webservices/pom.xml index 0924b433db..30f9b97740 100644 --- a/deegree-services/deegree-webservices/pom.xml +++ b/deegree-services/deegree-webservices/pom.xml @@ -279,6 +279,10 @@ commons-io commons-io + + commons-codec + commons-codec + org.primefaces primefaces diff --git a/deegree-services/deegree-webservices/src/main/java/org/deegree/console/security/LogBean.java b/deegree-services/deegree-webservices/src/main/java/org/deegree/console/security/LogBean.java index d1f9588c0d..9912a8850b 100644 --- a/deegree-services/deegree-webservices/src/main/java/org/deegree/console/security/LogBean.java +++ b/deegree-services/deegree-webservices/src/main/java/org/deegree/console/security/LogBean.java @@ -29,6 +29,7 @@ import static jakarta.faces.application.FacesMessage.SEVERITY_ERROR; import static jakarta.faces.application.FacesMessage.SEVERITY_WARN; +import static org.slf4j.LoggerFactory.getLogger; import java.io.File; import java.io.IOException; @@ -41,6 +42,7 @@ import jakarta.inject.Named; import org.deegree.commons.config.DeegreeWorkspace; +import org.slf4j.Logger; /** * JSF backing bean for logging in, logging out, checking login status and password @@ -56,7 +58,9 @@ public class LogBean implements Serializable { private static final long serialVersionUID = -4865071415988778817L; - private static final String PASSWORD_FILE = "console.pw"; + private static final Logger LOG = getLogger(LogBean.class); + + protected static final String PASSWORD_FILE = "console.pw"; public static final String CONSOLE = "/index"; @@ -109,7 +113,7 @@ public String getNewPassword2() { return newPassword2; } - public String logIn() throws NoSuchAlgorithmException, IOException { + public String logIn() throws IOException { SaltedPassword storedPassword = passwordFile.getCurrentContent(); if (storedPassword == null) { @@ -121,6 +125,7 @@ public String logIn() throws NoSuchAlgorithmException, IOException { SaltedPassword givenPassword = new SaltedPassword(currentPassword, storedPassword.getSalt()); loggedIn = storedPassword.equals(givenPassword); + LOG.debug("Provided password matches stored password: {}", loggedIn); return FacesContext.getCurrentInstance().getViewRoot().getViewId(); } @@ -145,9 +150,10 @@ public String changePassword() throws NoSuchAlgorithmException, IOException { SaltedPassword newSaltedPassword = new SaltedPassword(newPassword); passwordFile.update(newSaltedPassword); + LOG.info("Password file updated successfully"); } catch (Throwable e) { - e.printStackTrace(); + LOG.error("Failed to update password file due to {}", e.getMessage(), e); FacesMessage fm = new FacesMessage(SEVERITY_ERROR, "Error updating password: " + e.getMessage(), null); FacesContext.getCurrentInstance().addMessage(null, fm); return CHANGE_PASSWORD; diff --git a/deegree-services/deegree-webservices/src/main/java/org/deegree/console/security/PasswordFile.java b/deegree-services/deegree-webservices/src/main/java/org/deegree/console/security/PasswordFile.java index 39b44ff7d0..6f2adffd54 100644 --- a/deegree-services/deegree-webservices/src/main/java/org/deegree/console/security/PasswordFile.java +++ b/deegree-services/deegree-webservices/src/main/java/org/deegree/console/security/PasswordFile.java @@ -28,6 +28,7 @@ package org.deegree.console.security; import static java.lang.Character.digit; +import static org.deegree.console.security.SaltedPassword.SHA256_PREFIX; import java.io.BufferedReader; import java.io.File; @@ -36,10 +37,27 @@ import java.io.InputStreamReader; import java.io.PrintWriter; import java.io.Serializable; +import java.nio.charset.StandardCharsets; import java.util.List; import org.apache.commons.io.IOUtils; +/** + * An instance of the PasswordFile class encapsulates a text file storing a + * SaltedPassword. + * + * As of deegree version 3.6 the encoding format of the salted password has been changed + * to $ID$SALT$PWD. In previous versions of deegree the format has been + * SALT:PWD. + * + * Attention: There is no automatic password migration available. Files created + * with older versions of deegree need to be recreated with deegree 3.6. + * + * @author Markus Schneider + * @author Torsten Friebe + * @since 3.0 + * @see SaltedPassword + */ public class PasswordFile implements Serializable { private static final long serialVersionUID = -8331316987059763053L; @@ -55,7 +73,6 @@ public PasswordFile(File file) { * @return salted password, never null */ public SaltedPassword getCurrentContent() throws IOException { - SaltedPassword saltedPw = null; if (file.exists()) { BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream(file))); @@ -64,7 +81,7 @@ public SaltedPassword getCurrentContent() throws IOException { if (lines.size() != 1) { throw new IOException("Password file has incorrect format."); } - saltedPw = decodeHexEncodedSaltAndPassword(lines.get(0)); + saltedPw = parseSaltedPassword(lines.get(0)); } finally { in.close(); @@ -73,26 +90,14 @@ public SaltedPassword getCurrentContent() throws IOException { return saltedPw; } - private SaltedPassword decodeHexEncodedSaltAndPassword(String encoded) throws IOException { - - int offset = encoded.indexOf(':'); + private SaltedPassword parseSaltedPassword(String encoded) throws IOException { + int offset = encoded.indexOf('$'); if (offset == -1) { throw new IOException("Password file has incorrect format."); } - String hexEncodedSalt = encoded.substring(0, offset); - String hexEncodedSaltedAndHashedPassword = encoded.substring(offset + 1, encoded.length()); - byte[] salt = decodeHexString(hexEncodedSalt); - byte[] saltedAndHashedPassword = decodeHexString(hexEncodedSaltedAndHashedPassword); - return new SaltedPassword(saltedAndHashedPassword, salt); - } + String[] parts = encoded.split("\\$"); - private byte[] decodeHexString(String s) { - int len = s.length(); - byte[] data = new byte[len / 2]; - for (int i = 0; i < len; i += 2) { - data[i / 2] = (byte) ((digit(s.charAt(i), 16) << 4) + digit(s.charAt(i + 1), 16)); - } - return data; + return new SaltedPassword(parts[3].getBytes(StandardCharsets.UTF_8), SHA256_PREFIX + parts[2]); } /** @@ -101,7 +106,6 @@ private byte[] decodeHexString(String s) { * @throws IOException if the password could not be stored */ public void update(SaltedPassword pw) throws IOException { - if (file.exists()) { if (!file.delete()) { throw new IOException("Could not delete existing password file '" + file + "'."); @@ -112,25 +116,13 @@ public void update(SaltedPassword pw) throws IOException { file.getParentFile().mkdirs(); } - PrintWriter writer = new PrintWriter(file, "UTF-8"); - writer.print(encodeHexString(pw.getSalt())); - writer.print(':'); - writer.println(encodeHexString(pw.getSaltedAndHashedPassword())); - writer.close(); + writePasswordToWriter(new PrintWriter(file, StandardCharsets.UTF_8), pw); } - private String encodeHexString(final byte[] bytes) { - if (bytes == null) { - return null; - } - final StringBuilder hex = new StringBuilder(2 * bytes.length); - for (final byte b : bytes) { - final int hiVal = (b & 0xF0) >> 4; - final int loVal = b & 0x0F; - hex.append((char) ('0' + (hiVal + (hiVal / 10 * 7)))); - hex.append((char) ('0' + (loVal + (loVal / 10 * 7)))); - } - return hex.toString(); + protected void writePasswordToWriter(PrintWriter writer, SaltedPassword pw) { + writer.print(pw.toString()); + writer.flush(); + writer.close(); } public boolean exists() { diff --git a/deegree-services/deegree-webservices/src/main/java/org/deegree/console/security/SaltedPassword.java b/deegree-services/deegree-webservices/src/main/java/org/deegree/console/security/SaltedPassword.java index 4432903d9b..f89d9b34bd 100644 --- a/deegree-services/deegree-webservices/src/main/java/org/deegree/console/security/SaltedPassword.java +++ b/deegree-services/deegree-webservices/src/main/java/org/deegree/console/security/SaltedPassword.java @@ -34,61 +34,89 @@ ----------------------------------------------------------------------------*/ package org.deegree.console.security; +import org.apache.commons.codec.digest.Sha2Crypt; + import java.io.UnsupportedEncodingException; -import java.nio.ByteBuffer; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Date; +import java.nio.charset.StandardCharsets; /** * Encapsulates a salt value and the hash of a password that has been salted with the same - * value. + * value using Apache Commons Codec SHA-256 implementation. + * + * As of deegree version 3.6 the encoding format has been changed to the format of the + * extended password format as used in UNIX crypt: + * + *
+ *    $ID$SALT$PWD
+ * 
+ * + * The ID for the SHA-256 and SHA-512 methods are as follows: + * + *
+ *      ID       |    Method
+ *   -------------------------------
+ *      5        |  SHA-256
+ *      6        |  SHA-512
+ * 
+ * + * Currently only the SHA-256 method is used. * * @author Markus Schneider + * @author Torsten Friebe + * @version 3.6 + * @since 3.3 + * @see Apache + * Commons Codec Sha2Crypt + * @see Unix crypt using SHA-256 + * and SHA-512 */ -public class SaltedPassword { +public final class SaltedPassword { - private static final String HASH_ALGORITHM_ID = "SHA-256"; + private static final String CHARSET = StandardCharsets.UTF_8.toString(); - private static final String CHARSET = "UTF-8"; + static final String SHA256_PREFIX = "$5$"; private final byte[] saltedAndHashedPassword; - private final byte[] salt; + private final String salt; - public SaltedPassword(byte[] saltedAndHashedPassword, byte[] salt) { + public SaltedPassword(byte[] saltedAndHashedPassword, String salt) { this.saltedAndHashedPassword = saltedAndHashedPassword; this.salt = salt; } - public SaltedPassword(String plainPassword, byte[] salt) - throws UnsupportedEncodingException, NoSuchAlgorithmException { + public SaltedPassword(String plainPassword, String salt) throws UnsupportedEncodingException { byte[] plainPasswordBinary = plainPassword.getBytes(CHARSET); - byte[] saltedAndHashedPassword = getHashedAndSaltedPassword(plainPasswordBinary, salt); - this.saltedAndHashedPassword = saltedAndHashedPassword; + String saltedPassword = Sha2Crypt.sha256Crypt(plainPasswordBinary, salt); + int delimiterPos = nthIndexOf(saltedPassword, "$", 3); + this.saltedAndHashedPassword = saltedPassword.substring(delimiterPos + 1, saltedPassword.length()) + .getBytes(StandardCharsets.UTF_8); this.salt = salt; } - public SaltedPassword(String plainPassword) throws UnsupportedEncodingException, NoSuchAlgorithmException { - this(plainPassword, getNewSalt()); - } - - private byte[] getHashedAndSaltedPassword(byte[] plainPassword, byte[] salt) - throws NoSuchAlgorithmException, UnsupportedEncodingException { - MessageDigest md = MessageDigest.getInstance(HASH_ALGORITHM_ID); - md.update(plainPassword); - md.update(salt); - return md.digest(); + public SaltedPassword(String plainPassword) throws UnsupportedEncodingException { + byte[] plainPasswordBinary = plainPassword.getBytes(CHARSET); + String saltedPassword = Sha2Crypt.sha256Crypt(plainPasswordBinary); + int delimiterPos = nthIndexOf(saltedPassword, "$", 3); + this.salt = saltedPassword.substring(0, delimiterPos); + this.saltedAndHashedPassword = saltedPassword.substring(delimiterPos + 1, saltedPassword.length()) + .getBytes(StandardCharsets.UTF_8); } public byte[] getSaltedAndHashedPassword() { return saltedAndHashedPassword; } - public byte[] getSalt() { + public String getSalt() { return salt; } + @Override + public String toString() { + return this.getSalt() + "$" + new String(this.getSaltedAndHashedPassword(), StandardCharsets.UTF_8); + } + @Override public boolean equals(Object o) { if (o == null) { @@ -113,10 +141,13 @@ private boolean equalsBytewise(byte[] bytes1, byte[] bytes2) { return true; } - private static byte[] getNewSalt() { - ByteBuffer byteBuffer = ByteBuffer.allocate(Long.SIZE / 8); - byteBuffer.putLong(new Date().getTime()); - return byteBuffer.array(); + private int nthIndexOf(String input, String substring, int nth) { + if (nth == 1) { + return input.indexOf(substring); + } + else { + return input.indexOf(substring, nthIndexOf(input, substring, nth - 1) + substring.length()); + } } } diff --git a/deegree-services/deegree-webservices/src/test/java/org/deegree/console/security/PasswordFileTest.java b/deegree-services/deegree-webservices/src/test/java/org/deegree/console/security/PasswordFileTest.java new file mode 100644 index 0000000000..c0a1c1ee97 --- /dev/null +++ b/deegree-services/deegree-webservices/src/test/java/org/deegree/console/security/PasswordFileTest.java @@ -0,0 +1,79 @@ +package org.deegree.console.security; + +import static org.deegree.console.security.LogBean.PASSWORD_FILE; +import static org.deegree.console.security.SaltedPassword.SHA256_PREFIX; +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.isEmptyOrNullString; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.security.NoSuchAlgorithmException; + +import org.deegree.commons.config.DeegreeWorkspace; +import org.junit.Ignore; +import org.junit.Test; + +/** + * @author Torsten Friebe + * @since 3.6 + */ +public class PasswordFileTest { + + /** + * This unit tests can validate if the existing console.pw file contains a password in + * the new extended password format introduced with deegree version 3.6. + * @throws IOException in case of errors + */ + @Test + @Ignore + public void getCurrentContentFromWorkspaceRoot() throws IOException { + File workspaceDir = new File(DeegreeWorkspace.getWorkspaceRoot()); + File passwordFileFQN = new File(workspaceDir, PASSWORD_FILE); + assertTrue(passwordFileFQN.exists()); + assertTrue(passwordFileFQN.isFile()); + PasswordFile passwordFile = new PasswordFile(passwordFileFQN); + assertTrue(passwordFile.exists()); + SaltedPassword saltedPassword = passwordFile.getCurrentContent(); + assertThat(saltedPassword.toString(), not(isEmptyOrNullString())); + assertThat(saltedPassword.getSalt(), startsWith(SHA256_PREFIX)); + } + + /** + * Attention: Enabling this unit test will overwrite the content of the console.pw + * file! This test is intended as an example how to create a valid console.pw file + * with the new extended passwort format introduced with deegree version 3.6. + * @throws IOException in case of errors + * @since 3.6 + * @see SaltedPassword + */ + @Test + @Ignore + public void update() throws IOException { + File workspaceDir = new File(DeegreeWorkspace.getWorkspaceRoot()); + File passwordFileFQN = new File(workspaceDir, PASSWORD_FILE); + PasswordFile passwordFile = new PasswordFile(passwordFileFQN); + passwordFile.update(new SaltedPassword("deegree3")); + } + + @Test + public void writePasswordToWriter() throws IOException, NoSuchAlgorithmException { + SaltedPassword mypassword = new SaltedPassword("deegree3"); + PasswordFile passwordFile = new PasswordFile(File.createTempFile("pwd", ".tmp")); + StringWriter writer = new StringWriter(); + passwordFile.writePasswordToWriter(new PrintWriter(writer), mypassword); + assertThat(writer.toString(), not(isEmptyOrNullString())); + assertThat(writer.toString(), is(equalTo(mypassword.toString()))); + } + + @Test + public void exists() throws IOException { + PasswordFile passwordFile = new PasswordFile(File.createTempFile("pwd", ".tmp")); + assertTrue(passwordFile.exists()); + } + +} \ No newline at end of file diff --git a/deegree-services/deegree-webservices/src/test/java/org/deegree/console/security/SaltedPasswordTest.java b/deegree-services/deegree-webservices/src/test/java/org/deegree/console/security/SaltedPasswordTest.java new file mode 100644 index 0000000000..a21ff84098 --- /dev/null +++ b/deegree-services/deegree-webservices/src/test/java/org/deegree/console/security/SaltedPasswordTest.java @@ -0,0 +1,60 @@ +package org.deegree.console.security; + +import org.junit.Test; + +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; + +import static org.deegree.console.security.SaltedPassword.SHA256_PREFIX; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertTrue; + +/** + * @author Torsten Friebe + * @since 3.6 + */ +public class SaltedPasswordTest { + + @Test + public void testCreatingFromPlainPassword() throws UnsupportedEncodingException { + SaltedPassword plainpassword = new SaltedPassword("foo"); + String saltedPassword = new String(plainpassword.getSaltedAndHashedPassword(), StandardCharsets.UTF_8); + assertThat(plainpassword.getSalt(), startsWith(SHA256_PREFIX)); + assertThat(saltedPassword, is(notNullValue())); + } + + @Test + public void testCreatingFromSaltedPassword() { + String password = "nxIKX54gpaik7RiymYmMEhDou8.9DjFTzFkJxHKQ3D/"; + String salt = "$5$12345678"; + SaltedPassword saltedpassword = new SaltedPassword(password.getBytes(StandardCharsets.UTF_8), salt); + assertThat(new String(saltedpassword.getSaltedAndHashedPassword(), StandardCharsets.UTF_8), is(password)); + assertThat(saltedpassword.getSalt(), is(salt)); + } + + @Test + public void testCreateNewPasswordWithSaltFromOtherPassword() throws UnsupportedEncodingException { + String plainpassword = "foo"; + SaltedPassword storedPassword = new SaltedPassword(plainpassword); + String saltFromStoredPassword = storedPassword.getSalt(); + SaltedPassword givenPassword = new SaltedPassword(plainpassword, saltFromStoredPassword); + assertTrue(storedPassword.equals(givenPassword)); + } + + @Test + public void testSaltedPasswordAsParts() throws UnsupportedEncodingException { + SaltedPassword password = new SaltedPassword("foo"); + assertThat(password.toString(), startsWith("$")); + String[] parts = password.toString().split("\\$"); + // first is empty as the string starts with $ + assertThat(parts[0], is("")); + // the ID for SHA-256 + assertThat(parts[1], is("5")); + // the salt + assertThat(parts[2], hasLength(8)); + // the hashed password + assertThat(parts[3], hasLength(43)); + } + +} \ No newline at end of file