implement password-hashing (not fully integrated yet) #67
107
src/main/java/net/hostsharing/hsadminng/hash/HashProcessor.java
Normal file
107
src/main/java/net/hostsharing/hsadminng/hash/HashProcessor.java
Normal file
@ -0,0 +1,107 @@
|
||||
package net.hostsharing.hsadminng.hash;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
import jakarta.validation.ValidationException;
|
||||
|
||||
import static net.hostsharing.hsadminng.hash.HashProcessor.Algorithm.SHA512;
|
||||
|
||||
public class HashProcessor {
|
||||
|
||||
private static final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public enum Algorithm {
|
||||
SHA512
|
||||
}
|
||||
|
||||
private static final Base64.Encoder BASE64 = Base64.getEncoder();
|
||||
private static final String SALT_CHARACTERS =
|
||||
"abcdefghijklmnopqrstuvwxyz" +
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
|
||||
"0123456789$_";
|
||||
|
||||
private final MessageDigest generator;
|
||||
private byte[] saltBytes;
|
||||
|
||||
@SneakyThrows
|
||||
public static HashProcessor hashAlgorithm(final Algorithm algorithm) {
|
||||
return new HashProcessor(algorithm);
|
||||
}
|
||||
|
||||
private HashProcessor(final Algorithm algorithm) throws NoSuchAlgorithmException {
|
||||
generator = MessageDigest.getInstance(algorithm.name());
|
||||
}
|
||||
|
||||
public String generate(final String password) {
|
||||
final byte[] saltedPasswordDigest = calculateSaltedDigest(password);
|
||||
final byte[] hashBytes = appendSaltToSaltedDigest(saltedPasswordDigest);
|
||||
return BASE64.encodeToString(hashBytes);
|
||||
}
|
||||
|
||||
private byte[] appendSaltToSaltedDigest(final byte[] saltedPasswordDigest) {
|
||||
final byte[] hashBytes = new byte[saltedPasswordDigest.length + 1 + saltBytes.length];
|
||||
System.arraycopy(saltedPasswordDigest, 0, hashBytes, 0, saltedPasswordDigest.length);
|
||||
hashBytes[saltedPasswordDigest.length] = ':';
|
||||
System.arraycopy(saltBytes, 0, hashBytes, saltedPasswordDigest.length+1, saltBytes.length);
|
||||
return hashBytes;
|
||||
}
|
||||
|
||||
private byte[] calculateSaltedDigest(final String password) {
|
||||
generator.reset();
|
||||
generator.update(password.getBytes());
|
||||
generator.update(saltBytes);
|
||||
return generator.digest();
|
||||
}
|
||||
|
||||
public HashProcessor withSalt(final byte[] saltBytes) {
|
||||
this.saltBytes = saltBytes;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HashProcessor withSalt(final String salt) {
|
||||
return withSalt(salt.getBytes());
|
||||
}
|
||||
|
||||
public HashProcessor withRandomSalt() {
|
||||
final var stringBuilder = new StringBuilder(16);
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
int randomIndex = secureRandom.nextInt(SALT_CHARACTERS.length());
|
||||
stringBuilder.append(SALT_CHARACTERS.charAt(randomIndex));
|
||||
}
|
||||
return withSalt(stringBuilder.toString());
|
||||
}
|
||||
|
||||
public HashVerifier withHash(final String hash) {
|
||||
return new HashVerifier(hash);
|
||||
}
|
||||
|
||||
private static String getLastPart(String input, char delimiter) {
|
||||
final var lastIndex = input.lastIndexOf(delimiter);
|
||||
if (lastIndex == -1) {
|
||||
throw new IllegalArgumentException("cannot determine salt, expected: 'digest:salt', but no ':' found");
|
||||
}
|
||||
return input.substring(lastIndex + 1);
|
||||
}
|
||||
|
||||
public class HashVerifier {
|
||||
|
||||
private final String hash;
|
||||
|
||||
public HashVerifier(final String hash) {
|
||||
this.hash = hash;
|
||||
withSalt(getLastPart(new String(Base64.getDecoder().decode(hash)), ':'));
|
||||
}
|
||||
|
||||
public void verify(String password) {
|
||||
final var computedHash = hashAlgorithm(SHA512).withSalt(saltBytes).generate(password);
|
||||
if ( !computedHash.equals(hash) ) {
|
||||
throw new ValidationException("invalid password");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||
|
||||
import net.hostsharing.hsadminng.hash.HashProcessor;
|
||||
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity;
|
||||
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
|
||||
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
|
||||
@ -30,7 +31,7 @@ class HsUnixUserHostingAssetValidator extends HsHostingAssetEntityValidator {
|
||||
.withDefault("/bin/false"),
|
||||
stringProperty("homedir").readOnly().computedBy(HsUnixUserHostingAssetValidator::computeHomedir),
|
||||
stringProperty("totpKey").matchesRegEx("^0x([0-9A-Fa-f]{2})+$").minLength(20).maxLength(256).undisclosed().writeOnly().optional(),
|
||||
passwordProperty("password").minLength(8).maxLength(40).hashedUsing("SHA-512").writeOnly());
|
||||
passwordProperty("password").minLength(8).maxLength(40).hashedUsing(HashProcessor.Algorithm.SHA512).writeOnly());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -92,6 +92,7 @@ public abstract class HsEntityValidator<E extends PropertiesProvider> {
|
||||
public Map<String, Object> postProcess(final E entity, final Map<String, Object> config) {
|
||||
final var copy = new HashMap<>(config);
|
||||
stream(propertyValidators).forEach(p -> {
|
||||
// FIXME: maybe move to ValidatableProperty.postProcess(...)?
|
||||
if ( p.isWriteOnly()) {
|
||||
copy.remove(p.propertyName);
|
||||
}
|
||||
|
@ -1,15 +1,24 @@
|
||||
package net.hostsharing.hsadminng.hs.validation;
|
||||
|
||||
import net.hostsharing.hsadminng.hash.HashProcessor.Algorithm;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static java.util.Optional.ofNullable;
|
||||
import static net.hostsharing.hsadminng.hash.HashProcessor.hashAlgorithm;
|
||||
import static net.hostsharing.hsadminng.mapper.Array.insertAfterEntry;
|
||||
|
||||
@Setter
|
||||
public class PasswordProperty extends StringProperty<PasswordProperty> {
|
||||
|
||||
private static final String[] KEY_ORDER = insertAfterEntry(StringProperty.KEY_ORDER, "computed", "hashedUsing");
|
||||
|
||||
private Algorithm hashedUsing;
|
||||
|
||||
private PasswordProperty(final String propertyName) {
|
||||
super(propertyName);
|
||||
super(propertyName, KEY_ORDER);
|
||||
undisclosed();
|
||||
}
|
||||
|
||||
@ -23,7 +32,13 @@ public class PasswordProperty extends StringProperty<PasswordProperty> {
|
||||
validatePassword(result, propValue);
|
||||
}
|
||||
|
||||
public PasswordProperty hashedUsing(final String hashAlgoritm) {
|
||||
public PasswordProperty hashedUsing(final Algorithm algorithm) {
|
||||
this.hashedUsing = algorithm;
|
||||
// FIXME: computedBy is too late, we need preprocess
|
||||
computedBy((entity)
|
||||
-> ofNullable(entity.getDirectValue(propertyName, String.class))
|
||||
.map(password -> hashAlgorithm(algorithm).withRandomSalt().generate(password))
|
||||
.orElse(null));
|
||||
return self();
|
||||
}
|
||||
|
||||
|
@ -10,7 +10,7 @@ import java.util.regex.Pattern;
|
||||
@Setter
|
||||
public class StringProperty<P extends StringProperty<P>> extends ValidatableProperty<P, String> {
|
||||
|
||||
private static final String[] KEY_ORDER = Array.join(
|
||||
protected static final String[] KEY_ORDER = Array.join(
|
||||
ValidatableProperty.KEY_ORDER_HEAD,
|
||||
Array.of("matchesRegEx", "minLength", "maxLength"),
|
||||
ValidatableProperty.KEY_ORDER_TAIL,
|
||||
@ -24,6 +24,10 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp
|
||||
super(String.class, propertyName, KEY_ORDER);
|
||||
}
|
||||
|
||||
protected StringProperty(final String propertyName, final String[] keyOrder) {
|
||||
super(String.class, propertyName, keyOrder);
|
||||
}
|
||||
|
||||
public static StringProperty<?> stringProperty(final String propertyName) {
|
||||
return new StringProperty<>(propertyName);
|
||||
}
|
||||
|
@ -6,6 +6,8 @@ import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static java.util.Arrays.asList;
|
||||
|
||||
/**
|
||||
* Java has List.of(...), Set.of(...) and Map.of(...) all with varargs parameter,
|
||||
* but no Array.of(...). Here it is.
|
||||
@ -48,4 +50,17 @@ public class Array {
|
||||
public static <T> T[] emptyArray() {
|
||||
return of();
|
||||
}
|
||||
|
||||
public static <T> T[] insertAfterEntry(final T[] array, final T entryToFind, final T newEntry) {
|
||||
final var arrayList = new ArrayList<>(asList(array));
|
||||
final var index = arrayList.indexOf(entryToFind);
|
||||
if (index < 0) {
|
||||
throw new IllegalArgumentException("entry "+ entryToFind + " not found in " + Arrays.toString(array));
|
||||
}
|
||||
arrayList.add(index + 1, newEntry);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
final var extendedArray = (T[]) java.lang.reflect.Array.newInstance(array.getClass().getComponentType(), array.length);
|
||||
return arrayList.toArray(extendedArray);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,41 @@
|
||||
package net.hostsharing.hsadminng.hash;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
import static net.hostsharing.hsadminng.hash.HashProcessor.Algorithm.SHA512;
|
||||
import static net.hostsharing.hsadminng.hash.HashProcessor.hashAlgorithm;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
class HashProcessorUnitTest {
|
||||
|
||||
final String OTHER_PASSWORD = "other password";
|
||||
final String GIVEN_PASSWORD = "given password";
|
||||
final String GIVEN_PASSWORD_HASH = "foKDNQP0oZo0pjFpss5vNl0kfHOs6MKMaJUUbpJTg6hqI1WY+KbU/PKQIg2xt/mwDMmW5WR0pdUZnTv8RPTfhjprZUNqTXJsUXczQnczYUxE";
|
||||
final String GIVEN_SALT = "given salt";
|
||||
|
||||
@Test
|
||||
void verifiesHashedPasswordWithRandomSalt() {
|
||||
final var hash = hashAlgorithm(SHA512).withRandomSalt().generate(GIVEN_PASSWORD);
|
||||
hashAlgorithm(SHA512).withHash(hash).verify(GIVEN_PASSWORD); // throws exception if wrong
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifiesHashedPasswordWithGivenSalt() {
|
||||
final var hash = hashAlgorithm(SHA512).withSalt(GIVEN_SALT).generate(GIVEN_PASSWORD);
|
||||
|
||||
final var decoded = new String(Base64.getDecoder().decode(hash));
|
||||
assertThat(decoded).endsWith(":" + GIVEN_SALT);
|
||||
hashAlgorithm(SHA512).withHash(hash).verify(GIVEN_PASSWORD); // throws exception if wrong
|
||||
}
|
||||
|
||||
@Test
|
||||
void throwsExceptionForInvalidPassword() {
|
||||
final var throwable = catchThrowable(() ->
|
||||
hashAlgorithm(SHA512).withHash(GIVEN_PASSWORD_HASH).verify(OTHER_PASSWORD));
|
||||
|
||||
assertThat(throwable).hasMessage("invalid password");
|
||||
}
|
||||
}
|
@ -125,7 +125,7 @@ class HsUnixUserHostingAssetValidatorUnitTest {
|
||||
"{type=enumeration, propertyName=shell, values=[/bin/false, /bin/bash, /bin/csh, /bin/dash, /usr/bin/tcsh, /usr/bin/zsh, /usr/bin/passwd], defaultValue=/bin/false}",
|
||||
"{type=string, propertyName=homedir, readOnly=true, computed=true}",
|
||||
"{type=string, propertyName=totpKey, matchesRegEx=^0x([0-9A-Fa-f]{2})+$, minLength=20, maxLength=256, writeOnly=true, undisclosed=true}",
|
||||
"{type=password, propertyName=password, minLength=8, maxLength=40, writeOnly=true, undisclosed=true}"
|
||||
"{type=password, propertyName=password, minLength=8, maxLength=40, writeOnly=true, hashedUsing=SHA512, undisclosed=true}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -6,13 +6,18 @@ import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static net.hostsharing.hsadminng.hash.HashProcessor.Algorithm.SHA512;
|
||||
import static net.hostsharing.hsadminng.hash.HashProcessor.hashAlgorithm;
|
||||
import static net.hostsharing.hsadminng.hs.validation.PasswordProperty.passwordProperty;
|
||||
import static net.hostsharing.hsadminng.mapper.PatchableMapWrapper.entry;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PasswordPropertyUnitTest {
|
||||
|
||||
private final ValidatableProperty<PasswordProperty, String> passwordProp = passwordProperty("password").minLength(8).maxLength(40).writeOnly();
|
||||
private final ValidatableProperty<PasswordProperty, String> passwordProp =
|
||||
passwordProperty("password").minLength(8).maxLength(40).hashedUsing(SHA512).writeOnly();
|
||||
private final List<String> violations = new ArrayList<>();
|
||||
|
||||
@ParameterizedTest
|
||||
@ -89,4 +94,27 @@ class PasswordPropertyUnitTest {
|
||||
.contains("password' must not contain colon (':')")
|
||||
.doesNotContain(givenPassword);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldComputeHash() {
|
||||
|
||||
// when
|
||||
final var result = passwordProp.compute(new PropertiesProvider() {
|
||||
|
||||
@Override
|
||||
public Map<String, Object> directProps() {
|
||||
return Map.ofEntries(
|
||||
entry(passwordProp.propertyName, "some password")
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getContextValue(final String propName) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// then
|
||||
hashAlgorithm(SHA512).withHash(result).verify("some password"); // throws exception if wrong
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user