From b9d549fe814daacfa12ded154e71644bbe87ca9d Mon Sep 17 00:00:00 2001 From: David Ankin Date: Mon, 15 Aug 2022 23:14:51 -0400 Subject: [PATCH 1/4] htpasswd library copied - todo some character encoding --- lib/htpasswd/build.gradle | 10 ++ .../ankin/projects/htpasswd/Htpasswd.java | 127 ++++++++++++++++++ .../projects/htpasswd/HtpasswdEntry.java | 16 +++ .../projects/htpasswd/HtpasswdProperties.java | 10 ++ .../projects/htpasswd/PasswordEncryption.java | 11 ++ .../htpasswd/SupportedEncryption.java | 12 ++ .../exception/UnknownEncryptionException.java | 4 + .../ankin/projects/htpasswd/HtpasswdTest.java | 77 +++++++++++ settings.gradle | 1 + 9 files changed, 268 insertions(+) create mode 100644 lib/htpasswd/build.gradle create mode 100644 lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/Htpasswd.java create mode 100644 lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/HtpasswdEntry.java create mode 100644 lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/HtpasswdProperties.java create mode 100644 lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/PasswordEncryption.java create mode 100644 lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/SupportedEncryption.java create mode 100644 lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/exception/UnknownEncryptionException.java create mode 100644 lib/htpasswd/src/test/java/info/ankin/projects/htpasswd/HtpasswdTest.java 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..17c3f75 --- /dev/null +++ b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/Htpasswd.java @@ -0,0 +1,127 @@ +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].toCharArray()); + } + + 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; + } + return null; + } + + 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 char[] encrypt(SupportedEncryption encryption, char[] password) { + if (encryption == null) return null; + byte[] bytes = toBytes(password); + switch (encryption) { + case crypt: + return Crypt.crypt(bytes).toCharArray(); + case bcrypt: + return OpenBSDBCrypt.generate(todoVerifyMe(password), randomBcryptBytes(), htpasswdProperties.getBcryptCost()).toCharArray(); + case md5: + return Md5Crypt.apr1Crypt(bytes).toCharArray(); + case sha: + return ("{SHA}" + Base64.encodeBase64String(DigestUtils.sha1(bytes))).toCharArray(); + case plain: + return 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(char[] password, PasswordEncryption encryption, char[] input) { + if (encryption == null) return false; + String hash = new String(password); + + switch (encryption) { + case crypt_or_plain: + return Crypt.crypt(toBytes(input), hash).equals(hash) || Arrays.equals(password, input); + case bcrypt: + return OpenBSDBCrypt.checkPassword(hash, input); + case md5: + return Md5Crypt.apr1Crypt(toBytes(input), hash).equals(hash); + case sha: + return Arrays.equals(password, 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); + return byteBuffer.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..926c5a7 --- /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; + char[] 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..fb887c4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,6 +3,7 @@ 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' From 6f69ed608267b76b0ee45edb8ec81f48c3418efe Mon Sep 17 00:00:00 2001 From: David Ankin Date: Mon, 15 Aug 2022 23:17:13 -0400 Subject: [PATCH 2/4] throw unknown instead of returning null for encryption conversion --- .../src/main/java/info/ankin/projects/htpasswd/Htpasswd.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 17c3f75..86fc0e7 100644 --- a/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/Htpasswd.java +++ b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/Htpasswd.java @@ -56,7 +56,7 @@ private PasswordEncryption toPasswordEncryption(SupportedEncryption encryption) case sha: return PasswordEncryption.sha; } - return null; + throw new UnknownEncryptionException(); } public boolean check(HtpasswdEntry entry, char[] input) { From 737e3fd2102611573b98603cd22e0ee3b22e403e Mon Sep 17 00:00:00 2001 From: David Ankin Date: Wed, 17 Aug 2022 06:52:50 -0400 Subject: [PATCH 3/4] store encrypted strings as strings --- .../ankin/projects/htpasswd/Htpasswd.java | 26 ++++++++++--------- .../projects/htpasswd/HtpasswdEntry.java | 2 +- 2 files changed, 15 insertions(+), 13 deletions(-) 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 index 86fc0e7..97aab24 100644 --- a/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/Htpasswd.java +++ b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/Htpasswd.java @@ -26,7 +26,7 @@ public Htpasswd(SecureRandom secureRandom, 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].toCharArray()); + return new HtpasswdEntry(parts[0], recognize(parts[1]), parts[1]); } public PasswordEncryption recognize(String line) { @@ -71,20 +71,20 @@ public HtpasswdEntry changePassword(HtpasswdEntry entry, char[] password, Suppor return create(entry.getUsername(), encryption, password); } - public char[] encrypt(SupportedEncryption encryption, char[] 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).toCharArray(); + return Crypt.crypt(bytes); case bcrypt: - return OpenBSDBCrypt.generate(todoVerifyMe(password), randomBcryptBytes(), htpasswdProperties.getBcryptCost()).toCharArray(); + return OpenBSDBCrypt.generate(todoVerifyMe(password), randomBcryptBytes(), htpasswdProperties.getBcryptCost()); case md5: - return Md5Crypt.apr1Crypt(bytes).toCharArray(); + return Md5Crypt.apr1Crypt(bytes); case sha: - return ("{SHA}" + Base64.encodeBase64String(DigestUtils.sha1(bytes))).toCharArray(); + return ("{SHA}" + Base64.encodeBase64String(DigestUtils.sha1(bytes))); case plain: - return password; + return new String(password); default: throw new UnknownEncryptionException(); } @@ -95,19 +95,18 @@ private byte[] todoVerifyMe(char[] password) { return new String(password).getBytes(StandardCharsets.UTF_8); } - public boolean verify(char[] password, PasswordEncryption encryption, char[] input) { + public boolean verify(String hash, PasswordEncryption encryption, char[] input) { if (encryption == null) return false; - String hash = new String(password); switch (encryption) { case crypt_or_plain: - return Crypt.crypt(toBytes(input), hash).equals(hash) || Arrays.equals(password, input); + 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 Arrays.equals(password, encrypt(SupportedEncryption.sha, input)); + return hash.equals(encrypt(SupportedEncryption.sha, input)); default: throw new UnknownEncryptionException(); } @@ -116,7 +115,10 @@ public boolean verify(char[] password, PasswordEncryption encryption, char[] inp private byte[] toBytes(char[] password) { CharBuffer charBuffer = CharBuffer.wrap(password); ByteBuffer byteBuffer = StandardCharsets.UTF_8.encode(charBuffer); - return byteBuffer.array(); + // https://stackoverflow.com/a/33791944 + byte[] array = new byte[byteBuffer.limit()]; + byteBuffer.get(array); + return array; } private byte[] 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 index 926c5a7..640ae2a 100644 --- a/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/HtpasswdEntry.java +++ b/lib/htpasswd/src/main/java/info/ankin/projects/htpasswd/HtpasswdEntry.java @@ -8,7 +8,7 @@ public class HtpasswdEntry { String username; PasswordEncryption encryption; - char[] password; + String password; public String toLine() { return username + HTPASSWD_LINE_SEPARATOR + new String(password); From c7d32debd08f2784f32302c4e707076b0d69373a Mon Sep 17 00:00:00 2001 From: David Ankin Date: Wed, 17 Aug 2022 17:21:10 -0400 Subject: [PATCH 4/4] start htpasswd cli --- ...info.ankin.projects.app-conventions.gradle | 6 + cli/htpasswd-cli/build.gradle | 11 ++ .../projects/cli/htpasswd/HtpasswdCli.java | 129 ++++++++++++++++++ .../ankin/projects/cli/htpasswd/Options.java | 114 ++++++++++++++++ .../exception/HtpasswdCliException.java | 7 + .../MultipleEncryptionException.java | 7 + .../exception/MultipleModesException.java | 7 + settings.gradle | 1 + 8 files changed, 282 insertions(+) create mode 100644 cli/htpasswd-cli/build.gradle create mode 100644 cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/HtpasswdCli.java create mode 100644 cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/Options.java create mode 100644 cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/exception/HtpasswdCliException.java create mode 100644 cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/exception/MultipleEncryptionException.java create mode 100644 cli/htpasswd-cli/src/main/java/info/ankin/projects/cli/htpasswd/exception/MultipleModesException.java 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/settings.gradle b/settings.gradle index fb887c4..149654f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,5 @@ rootProject.name = 'java-projects' +include 'cli:htpasswd-cli' include 'cli:jgitcat' include 'cli:killport' include 'cli:yaml2json'