diff --git a/buildSrc/src/main/groovy/info.ankin.projects.app-conventions.gradle b/buildSrc/src/main/groovy/info.ankin.projects.app-conventions.gradle index 76fd7cf..7489453 100644 --- a/buildSrc/src/main/groovy/info.ankin.projects.app-conventions.gradle +++ b/buildSrc/src/main/groovy/info.ankin.projects.app-conventions.gradle @@ -4,3 +4,9 @@ plugins { id 'org.graalvm.buildtools.native' id 'com.github.johnrengelman.shadow' } + +dependencies { + constraints { + implementation 'info.picocli:picocli:4.6.3' + } +} diff --git a/cli/htpasswd-cli/build.gradle b/cli/htpasswd-cli/build.gradle new file mode 100644 index 0000000..3089b44 --- /dev/null +++ b/cli/htpasswd-cli/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'info.ankin.projects.app-conventions' + id 'info.ankin.projects.spring-conventions' +} + +dependencies { + implementation 'info.picocli:picocli' + implementation 'org.slf4j:slf4j-api' + implementation 'org.slf4j:slf4j-simple' + implementation project(':lib:htpasswd') +} diff --git a/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/HtpasswdCli.java b/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/HtpasswdCli.java new file mode 100644 index 0000000..644805c --- /dev/null +++ b/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/HtpasswdCli.java @@ -0,0 +1,129 @@ +package info.ankin.projects.cli.htpasswd; + +import info.ankin.projects.htpasswd.Htpasswd; +import info.ankin.projects.htpasswd.HtpasswdEntry; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.stream.Collectors; + +@Slf4j +@CommandLine.Command( + name = "htpasswd", + description = "utility for managing htpasswd files", + mixinStandardHelpOptions = true +) +public class HtpasswdCli implements Callable { + private static final int MIN_PARAMETERS = 2; + + @CommandLine.Mixin + Options options; + + @CommandLine.Parameters(arity = "2..") + List parameters; + + public static void main(String[] args) { + System.exit(new CommandLine(new HtpasswdCli()).execute(args)); + } + + @Override + public Integer call() throws Exception { + Options.OperationMode operationMode = options.operationMode(); + int maxParameters = operationMode == Options.OperationMode.display ? 2 : 3; + if (parameters.size() < MIN_PARAMETERS || parameters.size() > maxParameters) + throw new IllegalArgumentException("too many parameters (" + parameters.size() + ", more than" + maxParameters + ")"); + + Params params = new Params(parameters); + + Htpasswd htpasswd = new Htpasswd(new SecureRandom(), options.htpasswdProperties()); + + switch (operationMode) { + case add: { + if (params.getPasswordFile() == null) + throw new IllegalArgumentException("need passwordFile to add"); + + List entries = Files.readAllLines(Path.of(params.getPasswordFile())) + .stream().map(htpasswd::parse) + .collect(Collectors.toList()); + + entries.add(htpasswd.create(params.getUsername(), + options.passwordEncryption(), + params.getPassword().toCharArray())); + + Files.write(Path.of(params.getPasswordFile()), + entries.stream() + .map(HtpasswdEntry::toLine) + .collect(Collectors.joining(System.lineSeparator())) + .getBytes(StandardCharsets.UTF_8)); + break; + } + case create: { + String s = htpasswd.create(params.getUsername(), + options.passwordEncryption(), + params.getPassword().toCharArray()).toLine(); + + Files.write(Path.of(params.getPasswordFile()), s.getBytes(StandardCharsets.UTF_8)); + break; + } + case delete: { + List entries = Files.readAllLines(Path.of(params.getPasswordFile())) + .stream().map(htpasswd::parse) + .collect(Collectors.toList()); + + entries.removeIf(e -> e.getUsername().equals(params.getUsername())); + + Files.write(Path.of(params.getPasswordFile()), + entries.stream() + .map(HtpasswdEntry::toLine) + .collect(Collectors.joining(System.lineSeparator())) + .getBytes(StandardCharsets.UTF_8)); + break; + } + case display: { + String s = htpasswd.create(params.getUsername(), + options.passwordEncryption(), + params.getPassword().toCharArray()).toLine(); + + System.out.print(s); + break; + } + case verify: { + throw new UnsupportedOperationException("not yet implemented"); + } + } + + log.info("{}", parameters); + return 0; + } + + @AllArgsConstructor + @Data + static class Params { + private final String WRONG_PARAMS = "wrong length of parameters (not 2 or 3): "; + + String passwordFile; + String username; + String password; + + public Params(List params) { + if (params.size() == 3) { + passwordFile = params.get(0); + username = params.get(1); + password = params.get(2); + } else if (params.size() == 2) { + username = params.get(0); + password = params.get(1); + } else { + throw new IllegalStateException(WRONG_PARAMS + params.size()); + } + } + } +} diff --git a/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/Options.java b/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/Options.java new file mode 100644 index 0000000..a4753e5 --- /dev/null +++ b/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/Options.java @@ -0,0 +1,114 @@ +package info.ankin.projects.cli.htpasswd; + +import info.ankin.projects.cli.htpasswd.exception.MultipleEncryptionException; +import info.ankin.projects.cli.htpasswd.exception.MultipleModesException; +import info.ankin.projects.htpasswd.HtpasswdProperties; +import info.ankin.projects.htpasswd.SupportedEncryption; +import lombok.Data; +import lombok.SneakyThrows; +import picocli.CommandLine; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Data +@CommandLine.Command +public class Options { + + @CommandLine.Option(names = "-c", + description = "Create a new file.") + boolean createMode; + + @CommandLine.Option(names = "-n", + description = "Don't update file; display results on stdout.") + boolean displayMode; + + @CommandLine.Option(names = "-b", + description = "Use the password from the command line rather than prompting for it.") + boolean noPrompt; + + @CommandLine.Option(names = "-i", + description = "Read password from stdin without verification (for script usage).") + boolean stdinPassword; + + @CommandLine.Option(names = "-m", + description = "Force MD5 encryption of the password (default).") + boolean useMd5; + + @CommandLine.Option(names = "-B", + description = "Force bcrypt encryption of the password (very secure).") + boolean useBcrypt; + + @CommandLine.Option(names = "-C", + description = "Set the computing time used for the bcrypt algorithm" + + "(higher is more secure but slower, default: 5, valid: 4 to 17).") + Integer bcryptCost = 12; + + @CommandLine.Option(names = "-d", + description = "Force CRYPT encryption of the password (8 chars max, insecure).") + boolean useCrypt; + + @CommandLine.Option(names = "-s", + description = "Force SHA encryption of the password (insecure).") + boolean useSha; + + @CommandLine.Option(names = "-p", + description = "Do not encrypt the password (plaintext, insecure).") + boolean usePlain; + + @CommandLine.Option(names = "-D", + description = "Delete the specified user.") + boolean deleteMode; + + @CommandLine.Option(names = "-v", + description = "Verify password for the specified user.") + boolean verifyMode; + + SupportedEncryption passwordEncryption() { + EnumMap map = new EnumMap<>(SupportedEncryption.class); + map.put(SupportedEncryption.bcrypt, isUseBcrypt()); + map.put(SupportedEncryption.crypt, isUseCrypt()); + map.put(SupportedEncryption.md5, isUseMd5()); + map.put(SupportedEncryption.plain, isUsePlain()); + map.put(SupportedEncryption.sha, isUseSha()); + + return maxOneOrThrow(map, SupportedEncryption.bcrypt, MultipleEncryptionException::new); + } + + OperationMode operationMode() { + EnumMap map = new EnumMap<>(OperationMode.class); + map.put(OperationMode.create, isCreateMode()); + map.put(OperationMode.delete, isDeleteMode()); + map.put(OperationMode.display, isDisplayMode()); + map.put(OperationMode.verify, isVerifyMode()); + return maxOneOrThrow(map, OperationMode.add, MultipleModesException::new); + } + + @SneakyThrows + > T maxOneOrThrow(Map map, T defaultIfOne, Function exceptionCreator) { + List> list = map.entrySet().stream().filter(Map.Entry::getValue).collect(Collectors.toList()); + if (list.size() == 0) return defaultIfOne; + if (list.size() == 1) return list.get(0).getKey(); + + String multiple = list.stream().map(Map.Entry::getKey).map(Enum::name).collect(Collectors.joining(", ")); + throw exceptionCreator.apply("Can't select multiple: " + multiple); + } + + HtpasswdProperties htpasswdProperties() { + return new HtpasswdProperties() + .setBcryptCost(getBcryptCost()) + ; + } + + enum OperationMode { + add, + create, + delete, + display, + verify, + } + +} diff --git a/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/exception/HtpasswdCliException.java b/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/exception/HtpasswdCliException.java new file mode 100644 index 0000000..7a4846f --- /dev/null +++ b/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/exception/HtpasswdCliException.java @@ -0,0 +1,7 @@ +package info.ankin.projects.cli.htpasswd.exception; + +public class HtpasswdCliException extends RuntimeException { + public HtpasswdCliException(String message) { + super(message); + } +} diff --git a/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/exception/MultipleEncryptionException.java b/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/exception/MultipleEncryptionException.java new file mode 100644 index 0000000..aa4ad9f --- /dev/null +++ b/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/exception/MultipleEncryptionException.java @@ -0,0 +1,7 @@ +package info.ankin.projects.cli.htpasswd.exception; + +public class MultipleEncryptionException extends HtpasswdCliException { + public MultipleEncryptionException(String message) { + super(message); + } +} diff --git a/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/exception/MultipleModesException.java b/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/exception/MultipleModesException.java new file mode 100644 index 0000000..6ad7e98 --- /dev/null +++ b/cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/exception/MultipleModesException.java @@ -0,0 +1,7 @@ +package info.ankin.projects.cli.htpasswd.exception; + +public class MultipleModesException extends HtpasswdCliException { + public MultipleModesException(String message) { + super(message); + } +} diff --git a/lib/htpasswd/build.gradle b/lib/htpasswd/build.gradle new file mode 100644 index 0000000..8346b59 --- /dev/null +++ b/lib/htpasswd/build.gradle @@ -0,0 +1,10 @@ +plugins { + id 'info.ankin.projects.library-conventions' + id 'info.ankin.projects.spring-conventions' +} + +dependencies { + implementation 'org.bouncycastle:bcprov-jdk15on:1.70' + implementation 'commons-codec:commons-codec' + implementation 'org.apache.commons:commons-lang3' +} diff --git a/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/Htpasswd.java b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/Htpasswd.java new file mode 100644 index 0000000..97aab24 --- /dev/null +++ b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/Htpasswd.java @@ -0,0 +1,129 @@ +package info.ankin.projects.htpasswd; + +import info.ankin.projects.htpasswd.exception.UnknownEncryptionException; +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.codec.digest.Crypt; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.codec.digest.Md5Crypt; +import org.bouncycastle.crypto.generators.OpenBSDBCrypt; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; + +public class Htpasswd { + private final SecureRandom secureRandom; + private final HtpasswdProperties htpasswdProperties; + + public Htpasswd(SecureRandom secureRandom, + HtpasswdProperties htpasswdProperties) { + this.secureRandom = secureRandom; + this.htpasswdProperties = htpasswdProperties; + } + + public HtpasswdEntry parse(String line) { + if (line.indexOf(':') < 0) return null; + String[] parts = line.split(":"); + return new HtpasswdEntry(parts[0], recognize(parts[1]), parts[1]); + } + + public PasswordEncryption recognize(String line) { + if (line.startsWith("$apr1")) return PasswordEncryption.md5; + if (line.startsWith("{SHA}")) return PasswordEncryption.sha; + if (line.startsWith("$")) return PasswordEncryption.bcrypt; + return PasswordEncryption.crypt_or_plain; + } + + public HtpasswdEntry create(String username, + SupportedEncryption encryption, + char[] password) { + return new HtpasswdEntry(username, + toPasswordEncryption(encryption), + encrypt(encryption, password)); + } + + private PasswordEncryption toPasswordEncryption(SupportedEncryption encryption) { + switch (encryption) { + case bcrypt: + return PasswordEncryption.bcrypt; + case crypt: + case plain: + return PasswordEncryption.crypt_or_plain; + case md5: + return PasswordEncryption.md5; + case sha: + return PasswordEncryption.sha; + } + throw new UnknownEncryptionException(); + } + + public boolean check(HtpasswdEntry entry, char[] input) { + return verify(entry.getPassword(), entry.getEncryption(), input); + } + + public HtpasswdEntry changePassword(HtpasswdEntry entry, char[] password) { + return changePassword(entry, password, SupportedEncryption.bcrypt); + } + + public HtpasswdEntry changePassword(HtpasswdEntry entry, char[] password, SupportedEncryption encryption) { + return create(entry.getUsername(), encryption, password); + } + + public String encrypt(SupportedEncryption encryption, char[] password) { + if (encryption == null) return null; + byte[] bytes = toBytes(password); + switch (encryption) { + case crypt: + return Crypt.crypt(bytes); + case bcrypt: + return OpenBSDBCrypt.generate(todoVerifyMe(password), randomBcryptBytes(), htpasswdProperties.getBcryptCost()); + case md5: + return Md5Crypt.apr1Crypt(bytes); + case sha: + return ("{SHA}" + Base64.encodeBase64String(DigestUtils.sha1(bytes))); + case plain: + return new String(password); + default: + throw new UnknownEncryptionException(); + } + } + + // is this really how we should be doing this, no probably not + private byte[] todoVerifyMe(char[] password) { + return new String(password).getBytes(StandardCharsets.UTF_8); + } + + public boolean verify(String hash, PasswordEncryption encryption, char[] input) { + if (encryption == null) return false; + + switch (encryption) { + case crypt_or_plain: + return Crypt.crypt(toBytes(input), hash).equals(hash) || Arrays.equals(hash.toCharArray(), input); + case bcrypt: + return OpenBSDBCrypt.checkPassword(hash, input); + case md5: + return Md5Crypt.apr1Crypt(toBytes(input), hash).equals(hash); + case sha: + return hash.equals(encrypt(SupportedEncryption.sha, input)); + default: + throw new UnknownEncryptionException(); + } + } + + private byte[] toBytes(char[] password) { + CharBuffer charBuffer = CharBuffer.wrap(password); + ByteBuffer byteBuffer = StandardCharsets.UTF_8.encode(charBuffer); + // https://stackoverflow.com/a/33791944 + byte[] array = new byte[byteBuffer.limit()]; + byteBuffer.get(array); + return array; + } + + private byte[] randomBcryptBytes() { + byte[] randomBcryptBytes = new byte[16]; + secureRandom.nextBytes(randomBcryptBytes); + return randomBcryptBytes; + } +} diff --git a/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/HtpasswdEntry.java b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/HtpasswdEntry.java new file mode 100644 index 0000000..640ae2a --- /dev/null +++ b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/HtpasswdEntry.java @@ -0,0 +1,16 @@ +package info.ankin.projects.htpasswd; + +import lombok.Value; + +@Value +public class HtpasswdEntry { + public static final String HTPASSWD_LINE_SEPARATOR = ":"; + + String username; + PasswordEncryption encryption; + String password; + + public String toLine() { + return username + HTPASSWD_LINE_SEPARATOR + new String(password); + } +} diff --git a/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/HtpasswdProperties.java b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/HtpasswdProperties.java new file mode 100644 index 0000000..09d857a --- /dev/null +++ b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/HtpasswdProperties.java @@ -0,0 +1,10 @@ +package info.ankin.projects.htpasswd; + +import lombok.Data; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@Data +public class HtpasswdProperties { + int bcryptCost = 12; +} diff --git a/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/PasswordEncryption.java b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/PasswordEncryption.java new file mode 100644 index 0000000..3695d10 --- /dev/null +++ b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/PasswordEncryption.java @@ -0,0 +1,11 @@ +package info.ankin.projects.htpasswd; + +/** + * Encryption of a password entry + */ +public enum PasswordEncryption { + bcrypt, + crypt_or_plain, + md5, + sha, +} diff --git a/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/SupportedEncryption.java b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/SupportedEncryption.java new file mode 100644 index 0000000..a62f065 --- /dev/null +++ b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/SupportedEncryption.java @@ -0,0 +1,12 @@ +package info.ankin.projects.htpasswd; + +/** + * Encryption to create a password with + */ +public enum SupportedEncryption { + bcrypt, + crypt, + md5, + plain, + sha, +} diff --git a/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/exception/UnknownEncryptionException.java b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/exception/UnknownEncryptionException.java new file mode 100644 index 0000000..5cbcb48 --- /dev/null +++ b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/exception/UnknownEncryptionException.java @@ -0,0 +1,4 @@ +package info.ankin.projects.htpasswd.exception; + +public class UnknownEncryptionException extends RuntimeException { +} diff --git a/lib/htpasswd/src/test/java/info/ankin/projects/htpasswd/HtpasswdTest.java b/lib/htpasswd/src/test/java/info/ankin/projects/htpasswd/HtpasswdTest.java new file mode 100644 index 0000000..38480bc --- /dev/null +++ b/lib/htpasswd/src/test/java/info/ankin/projects/htpasswd/HtpasswdTest.java @@ -0,0 +1,77 @@ +package info.ankin.projects.htpasswd; + +import org.junit.jupiter.api.Test; + +import java.security.SecureRandom; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@code htpasswd} flags: + *
+ * -b  Use the password from the command line rather than prompting for it.
+ * -m  Force MD5 encryption of the password (default).
+ * -B  Force bcrypt encryption of the password (very secure).
+ * -d  Force CRYPT encryption of the password (8 chars max, insecure).
+ * -s  Force SHA encryption of the password (insecure).
+ * -p  Do not encrypt the password (plaintext, insecure).
+ * 
+ */ +class HtpasswdTest { + static final String sampleData = "abc_B:$2y$05$E.CCdKfoLq0wTa0xtZWqKeiCpJ8yl5Qdh8OEDz5alA1scbJ9yAke6\n" + + "abc_d:JX/JbiqtIyQeQ\n" + + "abc_m:$apr1$oGQDCI3x$KOpxsCOmTuabfahpbuZL3.\n" + + "abc_p:def\n" + + "abc_s:{SHA}WJwiM1o4HxItEpIl9cC6MFbtWBE="; + + Htpasswd htpasswd = new Htpasswd(new SecureRandom(), new HtpasswdProperties().setBcryptCost(4)); + + @Test + void test_recognizesAllTypes() { + List split = List.of(sampleData.split("\n")); + + List lines = split.stream().map(htpasswd::parse).collect(Collectors.toList()); + + for (HtpasswdEntry line : lines) { + assertTrue(htpasswd.check(line, "def".toCharArray()), "matches for " + line.getEncryption()); + } + } + + @Test + void test_changePasswordToBcrypt() { + for (var e : SupportedEncryption.values()) { + // create entry and verify + var line = htpasswd.create("test_changePasswordToBcrypt", e, ("password" + e).toCharArray()); + assertTrue(htpasswd.check(line, ("password" + e).toCharArray()), "new line " + line + " from " + e); + + // verify not new password + char[] newPassword = "password1".toCharArray(); + assertFalse(htpasswd.check(line, newPassword)); + // change password and verify + line = htpasswd.changePassword(line, newPassword); + assertTrue(htpasswd.check(line, newPassword)); + } + } + + @Test + void test_changePasswordToAny() { + for (var e : SupportedEncryption.values()) { + // create entry and verify + var line = htpasswd.create("test_changePasswordToBcrypt", e, ("password" + e).toCharArray()); + assertTrue(htpasswd.check(line, ("password" + e).toCharArray()), "new line " + line + " from " + e); + + // verify not new password + char[] newPassword = "password1".toCharArray(); + assertFalse(htpasswd.check(line, newPassword)); + // change password and verify + for (var to : SupportedEncryption.values()) { + line = htpasswd.changePassword(line, newPassword, to); + assertTrue(htpasswd.check(line, newPassword)); + + } + } + } +} diff --git a/settings.gradle b/settings.gradle index 0c94eef..149654f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,8 +1,10 @@ rootProject.name = 'java-projects' +include 'cli:htpasswd-cli' include 'cli:jgitcat' include 'cli:killport' include 'cli:yaml2json' include 'json-schema:schema' +include 'lib:htpasswd' include 'lib:spring:https-customizer' include 'lib:spring:https-customizer-autoconfigure' include 'lib:spring:app'