Repository: BastiaanJansen/otp-java Branch: main Commit: b6fd6e49f6f8 Files: 16 Total size: 67.3 KB Directory structure: gitextract_cwl01tct/ ├── .github/ │ └── workflows/ │ ├── build.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── pom.xml └── src/ ├── main/ │ └── java/ │ └── com/ │ └── bastiaanjansen/ │ └── otp/ │ ├── HMACAlgorithm.java │ ├── HOTPGenerator.java │ ├── SecretGenerator.java │ ├── TOTPGenerator.java │ └── helpers/ │ └── URIHelper.java └── test/ └── java/ └── com/ └── bastiaanjansen/ └── otp/ ├── ExampleApp.java ├── HOTPGeneratorTest.java ├── SecretGeneratorTest.java ├── TOTPGeneratorTest.java └── helpers/ └── URIHelperTest.java ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/build.yml ================================================ name: Build & Test on: push: branches: [ main ] pull_request: branches: [ main ] jobs: build: name: Java ${{ matrix.java }} build runs-on: ubuntu-latest strategy: matrix: java: [ 8, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 ] steps: - name: Checkout source code uses: actions/checkout@v3 - name: Set up JDK uses: actions/setup-java@v3 with: distribution: 'zulu' java-package: jdk java-version: ${{ matrix.java }} - name: Build with Maven run: mvn -B package --file pom.xml - run: mkdir artifacts && cp target/*.jar artifacts - name: Upload Maven build artifact uses: actions/upload-artifact@v3 with: name: build-java-${{ matrix.java }}.jar path: artifacts test: name: Java ${{ matrix.java }} test needs: [build] runs-on: ubuntu-latest strategy: matrix: java: [ 8, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 ] steps: - name: Checkout the repository uses: actions/checkout@v3 - name: Set up JDK uses: actions/setup-java@v3 with: distribution: 'zulu' java-version: ${{ matrix.java }} - name: Run tests with Maven run: mvn -B test --file pom.xml ================================================ FILE: .github/workflows/publish.yml ================================================ name: Maven Package on: release: types: [ created ] jobs: build: runs-on: ubuntu-latest strategy: matrix: java: [ 8, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 ] name: Java ${{ matrix.java }} build steps: - name: Checkout source code uses: actions/checkout@v3 - name: Set up JDK uses: actions/setup-java@v3 with: distribution: 'zulu' java-version: ${{ matrix.java }} - name: Build with Maven run: mvn -B package --file pom.xml - run: mkdir artifacts && cp target/*.jar artifacts - name: Upload Maven build artifact uses: actions/upload-artifact@v3 with: name: build-java-${{ matrix.java }}.jar path: artifacts test: needs: [ build ] runs-on: ubuntu-latest strategy: matrix: java: [ 8, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 ] name: Java ${{ matrix.java }} test steps: - name: Checkout the repository uses: actions/checkout@v3 - name: Set up JDK uses: actions/setup-java@v3 with: distribution: 'zulu' java-version: ${{ matrix.java }} - name: Run tests with Maven run: mvn -B test --file pom.xml publish-maven-central: name: Publish to Maven Central Repository needs: [ build, test ] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v3 - name: Set up Java uses: actions/setup-java@v3 with: java-version: '11' distribution: 'adopt' - name: Publish package uses: samuelmeuli/action-maven-publish@v1 with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} gpg_passphrase: ${{ secrets.GPG_PASSPHRASE }} nexus_username: ${{ secrets.NEXUS_USERNAME }} nexus_password: ${{ secrets.NEXUS_PASSWORD }} maven_profiles: "deploy-maven-central" publish-github-registry: name: Publish to GitHub Registry needs: [ build, test ] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v3 - uses: actions/setup-java@v3 with: server-id: github java-version: '11' distribution: 'adopt' - name: Publish package run: mvn --batch-mode --activate-profiles "deploy-github-registry" deploy env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ .idea target .m2 pom.xml.releaseBackup release.properties .DS_Store *.iml ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Bastiaan Jansen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # OTP-Java [![Build & Test](https://github.com/BastiaanJansen/otp-java/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/BastiaanJansen/otp-java/actions/workflows/build.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/91d3addee9e94a0cad9436601d4a4e1e)](https://www.codacy.com/gh/BastiaanJansen/OTP-Java/dashboard?utm_source=github.com&utm_medium=referral&utm_content=BastiaanJansen/OTP-Java&utm_campaign=Badge_Grade) ![](https://img.shields.io/github/license/BastiaanJansen/OTP-Java) ![](https://img.shields.io/github/issues/BastiaanJansen/OTP-Java) A small and easy-to-use one-time password generator for Java implementing [RFC 4226](https://tools.ietf.org/html/rfc4226) (HOTP) and [RFC 6238](https://tools.ietf.org/html/rfc6238) (TOTP). ## Table of Contents * [Features](#features) * [Installation](#installation) * [Usage](#usage) * [HOTP (Counter-based one-time passwords)](#counter-based-one-time-passwords) * [TOTP (Time-based one-time passwords)](#time-based-one-time-passwords) * [Recovery codes](#recovery-codes) ## Features The following features are supported: 1. Generation of secrets 2. Time-based one-time password (TOTP, RFC 6238) generation based on current time, specific time, OTPAuth URI and more for different HMAC algorithms. 3. HMAC-based one-time password (HOTP, RFC 4226) generation based on counter and OTPAuth URI. 4. Verification of one-time passwords 5. Generation of OTP Auth URI's ## Installation ### Maven ```xml com.github.bastiaanjansen otp-java 2.1.0 ``` ### Gradle ```gradle implementation 'com.github.bastiaanjansen:otp-java:2.1.0' ``` ### Scala SBT ```scala libraryDependencies += "com.github.bastiaanjansen" % "otp-java" % "2.1.0" ``` ### Apache Ivy ```xml ``` Or you can download the source from the [GitHub releases page](https://github.com/BastiaanJansen/OTP-Java/releases). ## Usage ### HOTP (Counter-based one-time passwords) #### Initialization HOTP instance To create a `HOTPGenerator` instance, use the `HOTPGenerator.Builder` class as follows: ```java String secret = "VV3KOX7UQJ4KYAKOHMZPPH3US4CJIMH6F3ZKNB5C2OOBQ6V2KIYHM27Q"; HOTPGenerator hotp = new HOTPGenerator.Builder(secret).build(); ``` The above builder creates a HOTP instance with default values for passwordLength = 6 and algorithm = SHA1. Use the builder to change these defaults: ```java HOTPGenerator hotp = new HOTPGenerator.Builder(secret) .withPasswordLength(8) .withAlgorithm(HMACAlgorithm.SHA256) .build(); ``` If you have a shared secret described in [RFC-4226](https://www.rfc-editor.org/rfc/rfc4226), you need to encode it first: ```java byte[] sharedSecret = getMySharedSecret(); byte[] secret = Base32.encode(sharedSecret); ``` When you don't already have a secret, you can let the library generate it: ```java // To generate a Base32-encoded secret with 160 bits byte[] secret = SecretGenerator.generate(); // To generate a Base32-encoded secret with a custom amount of bits byte[] secret = SecretGenerator.generate(512); ``` It is also possible to create a HOTP instance based on an OTPAuth URI. When algorithm or digits are not specified, the default values will be used. ```java URI uri = new URI("otpauth://hotp/issuer?secret=ABCDEFGHIJKLMNOP&algorithm=SHA1&digits=6&counter=8237"); HOTPGenerator hotp = HOTPGenerator.fromURI(uri); ``` Get information about the generator: ```java int passwordLength = hotp.getPasswordLength(); // 6 HMACAlgorithm algorithm = hotp.getAlgorithm(); // HMACAlgorithm.SHA1 ``` #### Generation of HOTP code After creating an instance of the HOTP class, a code can be generated by using the `generate()` method: ```java try { int counter = 5; String code = hotp.generate(counter); // To verify a token: boolean isValid = hotp.verify(code, counter); // Or verify with a delay window boolean isValid = hotp.verify(code, counter, 2); } catch (IllegalStateException e) { // Handle error } ``` ### TOTP (Time-based one-time passwords) #### Initialization TOTP instance TOTP can accept more paramaters: `passwordLength`, `period`, `algorithm` and `secret`. The default values are: passwordLength = 6, period = 30 and algorithm = SHA1. ```java // Generate a secret (or use your own secret) byte[] secret = SecretGenerator.generate(); TOTPGenerator totp = new TOTPGenerator.Builder(secret) .withHOTPGenerator(builder -> { builder.withPasswordLength(6); builder.withAlgorithm(HMACAlgorithm.SHA1); // SHA256 and SHA512 are also supported }) .withPeriod(Duration.ofSeconds(30)) .build(); ``` Or create a `TOTP` instance from an OTPAuth URI: ```java URI uri = new URI("otpauth://totp/issuer?secret=ABCDEFGHIJKLMNOP&algorithm=SHA1&digits=6&period=30"); TOTPGenerator totpGenerator = TOTPGenerator.fromURI(uri); ``` Get information about the generator: ```java int passwordLength = totpGenerator.getPasswordLength(); // 6 HMACAlgorithm algorithm = totpGenerator.getAlgorithm(); // HMACAlgorithm.SHA1 Duration period = totpGenerator.getPeriod(); // Duration.ofSeconds(30) ``` #### Generation of TOTP code After creating an instance of the TOTP class, a code can be generated by using the `now()` method, similarly with the HOTP class: ```java try { String code = totpGenerator.now(); // To verify a token: boolean isValid = totpGenerator.verify(code); } catch (IllegalStateException e) { // Handle error } ``` The above code will generate a time-based one-time password based on the current time. The API supports, besides the current time, the creation of codes based on `timeSince1970` in seconds, `Date`, and `Instant`: ```java try { // Based on current time totpGenerator.now(); // Based on specific date totpGenerator.at(new Date()); // Based on specific local date totpGenerator.at(LocalDate.of(2023, 3, 2)); // Based on seconds past 1970 totpGenerator.at(9238346823); // Based on an instant totpGenerator.at(Instant.now()); } catch (IllegalStateException e) { // Handle error } ``` ### Generation of OTPAuth URI's To easily generate a OTPAuth URI for easy on-boarding, use the `getURI()` method for both `HOTP` and `TOTP`. Example for `TOTP`: ```java TOTPGenerator totpGenerator = new TOTPGenerator.Builder(secret).build(); URI uri = totpGenerator.getURI("issuer", "account"); // otpauth://totp/issuer:account?period=30&digits=6&secret=SECRET&algorithm=SHA1 ``` ## Recovery Codes Often, services provide "backup codes" or "recovery codes" which can be used when the user cannot access the 2FA device anymore. Often because 2FA device is a mobile phone, which can be lost or stolen. Because recovery code generation is not part of the specifications of OTP, it is not possible to generate recovery codes with this library and should be implemented seperately. ## Licence OTP-Java is available under the MIT License. See the LICENCE for more info. [![Stargazers repo roster for @BastiaanJansen/otp-java](https://reporoster.com/stars/BastiaanJansen/otp-java)](https://github.com/BastiaanJansen/otp-java/stargazers) ================================================ FILE: pom.xml ================================================ 4.0.0 org.sonatype.oss oss-parent 9 com.github.bastiaanjansen otp-java 2.1.0 OTP-Java A small and easy-to-use one-time password generator for Java according to RFC 4226 (HOTP) and RFC 6238 (TOTP). ossrh https://oss.sonatype.org/content/repositories/snapshots ossrh https://oss.sonatype.org/service/local/staging/deploy/maven2/ MIT License https://github.com/BastiaanJansen/otp-java/blob/main/LICENSE repo 1.8 1.8 scm:git:https://github.com/BastiaanJansen/otp-java.git http://github.com/BastiaanJansen/otp-java scm:git:https://github.com/BastiaanJansen/otp-java.git org.apache.maven.plugins maven-compiler-plugin 3.10.1 test org.junit.jupiter junit-jupiter 5.9.0 test commons-codec commons-codec 1.15 org.hamcrest java-hamcrest 2.0.0.0 test deploy-maven-central ossrh https://s01.oss.sonatype.org/content/repositories/snapshots ossrh https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/ org.sonatype.plugins nexus-staging-maven-plugin 1.6.8 true ossrh https://oss.sonatype.org/ true org.apache.maven.plugins maven-source-plugin 2.4 attach-sources jar-no-fork org.apache.maven.plugins maven-javadoc-plugin 2.10.4 attach-javadocs jar org.apache.maven.plugins maven-gpg-plugin 1.6 sign-artifacts verify sign --pinentry-mode loopback deploy-github-registry github GitHub Packages https://maven.pkg.github.com/BastiaanJansen/otp-java org.apache.maven.plugins maven-surefire-plugin 2.22.2 ================================================ FILE: src/main/java/com/bastiaanjansen/otp/HMACAlgorithm.java ================================================ package com.bastiaanjansen.otp; /** * HMAC algorithm enum * @author Bastiaan Jansen */ public enum HMACAlgorithm { @Deprecated SHA1("HmacSHA1"), SHA224("HmacSHA224"), SHA256("HmacSHA256"), SHA384("HmacSHA384"), SHA512("HmacSHA512"); private final String name; HMACAlgorithm(String name) { this.name = name; } public String getHMACName() { return name; } } ================================================ FILE: src/main/java/com/bastiaanjansen/otp/HOTPGenerator.java ================================================ package com.bastiaanjansen.otp; import com.bastiaanjansen.otp.helpers.URIHelper; import org.apache.commons.codec.binary.Base32; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.net.URI; import java.net.URISyntaxException; import java.nio.ByteBuffer; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.HashMap; import java.util.Map; import java.util.Optional; import static java.nio.charset.StandardCharsets.UTF_8; public final class HOTPGenerator { private static final String URL_SCHEME = "otpauth"; private static final int DEFAULT_PASSWORD_LENGTH = 6; private static final HMACAlgorithm DEFAULT_HMAC_ALGORITHM = HMACAlgorithm.SHA1; private static final String OTP_TYPE = "hotp"; private final int passwordLength; private final HMACAlgorithm algorithm; private final byte[] secret; private HOTPGenerator(final Builder builder) { this.passwordLength = builder.passwordLength; this.algorithm = builder.algorithm; this.secret = builder.secret; } public static HOTPGenerator fromURI(final URI uri) throws URISyntaxException { Map query = URIHelper.queryItems(uri); byte[] secret = Optional.ofNullable(query.get(URIHelper.SECRET)) .map(s -> s.getBytes(UTF_8)) .orElseThrow(() -> new IllegalArgumentException("Secret query parameter must be set")); Builder builder = new Builder(secret); try { Optional.ofNullable(query.get(URIHelper.DIGITS)) .map(Integer::valueOf) .ifPresent(builder::withPasswordLength); Optional.ofNullable(query.get(URIHelper.ALGORITHM)) .map(String::toUpperCase) .map(HMACAlgorithm::valueOf) .ifPresent(builder::withAlgorithm); } catch (Exception e) { throw new URISyntaxException(uri.toString(), "URI could not be parsed"); } return builder.build(); } public static HOTPGenerator withDefaultValues(final byte[] secret) { return new HOTPGenerator.Builder(secret).build(); } public URI getURI(final int counter, final String issuer) throws URISyntaxException { return getURI(counter, issuer, ""); } public URI getURI(final int counter, final String issuer, final String account) throws URISyntaxException { Map query = new HashMap<>(); query.put(URIHelper.COUNTER, String.valueOf(counter)); return getURI(OTP_TYPE, issuer, account, query); } public int getPasswordLength() { return passwordLength; } public HMACAlgorithm getAlgorithm() { return algorithm; } public boolean verify(final String code, final long counter) { return verify(code, counter, 0); } public boolean verify(final String code, final long counter, final int delayWindow) { if (code.length() != passwordLength) return false; for (int i = -delayWindow; i <= delayWindow; i++) { String currentCode = generate(counter + i); if (code.equals(currentCode)) return true; } return false; } public String generate(final long counter) throws IllegalStateException { if (counter < 0) throw new IllegalArgumentException("Counter must be greater than or equal to 0"); byte[] secretBytes = decodeBase32(secret); byte[] counterBytes = longToBytes(counter); byte[] hash; try { hash = generateHash(secretBytes, counterBytes); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new IllegalStateException(); } return getCodeFromHash(hash); } public URI getURI(final String type, final String issuer, final String account, final Map query) throws URISyntaxException { query.put(URIHelper.DIGITS, String.valueOf(passwordLength)); query.put(URIHelper.ALGORITHM, algorithm.name()); query.put(URIHelper.SECRET, new String(secret, UTF_8)); query.put(URIHelper.ISSUER, issuer); String path = account.isEmpty() ? URIHelper.encode(issuer) : String.format("%s:%s", URIHelper.encode(issuer), URIHelper.encode(account)); return URIHelper.createURI(URL_SCHEME, type, path, query); } /** * Decode a base32 value to bytes array * * @param value base32 value * @return bytes array */ private byte[] decodeBase32(final byte[] value) { Base32 codec = new Base32(); return codec.decode(value); } private byte[] longToBytes(final long value) { return ByteBuffer.allocate(Long.BYTES).putLong(value).array(); } private byte[] generateHash(final byte[] secret, final byte[] data) throws InvalidKeyException, NoSuchAlgorithmException { // Create a secret key with correct SHA algorithm SecretKeySpec signKey = new SecretKeySpec(secret, "RAW"); // Mac is 'message authentication code' algorithm (RFC 2104) Mac mac = Mac.getInstance(algorithm.getHMACName()); mac.init(signKey); // Hash data with generated sign key return mac.doFinal(data); } private String getCodeFromHash(final byte[] hash) { /* Find mask to get last 4 digits: 1. Set all bits to 1: ~0 -> 11111111 -> 255 decimal -> 0xFF 2. Shift n (in this case 4, because we want the last 4 bits) bits to left with << 3. Negate the result: 1111 1100 -> 0000 0011 */ int mask = ~(~0 << 4); /* Get last 4 bits of hash as offset: Use the bitwise AND (&) operator to select last 4 bits Mask should be 00001111 = 15 = 0xF Last byte of hash & 0xF = last 4 bits: Example: Input: decimal 219 as binary: 11011011 & Mask: decimal 15 as binary: 00001111 ----------------------------------------- Output: decimal 11 as binary: 00001011 */ byte lastByte = hash[hash.length - 1]; int offset = lastByte & mask; // Get 4 bytes from hash from offset to offset + 3 byte[] truncatedHashInBytes = { hash[offset], hash[offset + 1], hash[offset + 2], hash[offset + 3] }; // Wrap in ByteBuffer to convert bytes to long ByteBuffer byteBuffer = ByteBuffer.wrap(truncatedHashInBytes); long truncatedHash = byteBuffer.getInt(); // Mask most significant bit truncatedHash &= 0x7FFFFFFF; // Modulo (%) truncatedHash by 10^passwordLength truncatedHash %= Math.pow(10, passwordLength); // Left pad with 0s for an n-digit code return String.format("%0" + passwordLength + "d", truncatedHash); } public static final class Builder { private int passwordLength; private HMACAlgorithm algorithm; /** * Base32 encoded secret */ private final byte[] secret; /** * Creates a new builder. *

* Use {@link SecretGenerator#generate()} to create a secret. *

* If you are using a shared secret from another generator, you would likely need to encode it using * {@link org.apache.commons.codec.binary.Base32#encode(byte[])}} * * @param secret Base32 encoded secret */ public Builder(final byte[] secret) { if (secret.length == 0) throw new IllegalArgumentException("Secret must not be empty"); this.secret = secret; this.passwordLength = DEFAULT_PASSWORD_LENGTH; this.algorithm = DEFAULT_HMAC_ALGORITHM; } /** * @param secret Base32 encoded secret */ public Builder(String secret) { this(secret.getBytes(UTF_8)); } public Builder withPasswordLength(final int passwordLength) { if (!passwordLengthIsValid(passwordLength)) throw new IllegalArgumentException("Password length must be between 6 and 8 digits"); this.passwordLength = passwordLength; return this; } public Builder withAlgorithm(final HMACAlgorithm algorithm) { this.algorithm = algorithm; return this; } public HOTPGenerator build() { return new HOTPGenerator(this); } private boolean passwordLengthIsValid(final int passwordLength) { return passwordLength >= 6 && passwordLength <= 8; } } } ================================================ FILE: src/main/java/com/bastiaanjansen/otp/SecretGenerator.java ================================================ package com.bastiaanjansen.otp; import org.apache.commons.codec.binary.Base32; import java.security.SecureRandom; /** * A secret generator to generate OTP secrets * * @author Bastiaan Jansen */ public class SecretGenerator { private SecretGenerator() {} /** * Default amount of bits for secret generation */ public static final int DEFAULT_BITS = 160; private static final SecureRandom random = new SecureRandom(); private static final Base32 encoder = new Base32(); /** * Generate an OTP base32 secret with default amount of bits * * @return generated secret */ public static byte[] generate() { return generate(DEFAULT_BITS); } /** * Generate an OTP base32 secret * * @param bits length, this should be greater than or equal to the length of the HMAC algorithm type: * SHA1: 160 bits * SHA256: 256 bits * SHA512: 512 bits * @return generated secret */ public static byte[] generate(final int bits) { if (bits <= 0) throw new IllegalArgumentException("Bits must be greater than or equal to 0"); byte[] bytes = new byte[bits / Byte.SIZE]; random.nextBytes(bytes); return encoder.encode(bytes); } } ================================================ FILE: src/main/java/com/bastiaanjansen/otp/TOTPGenerator.java ================================================ package com.bastiaanjansen.otp; import com.bastiaanjansen.otp.helpers.URIHelper; import java.net.URI; import java.net.URISyntaxException; import java.time.*; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import static java.nio.charset.StandardCharsets.UTF_8; public final class TOTPGenerator { private static final String OTP_TYPE = "totp"; private static final Duration DEFAULT_PERIOD = Duration.ofSeconds(30); private static final Clock DEFAULT_CLOCK = Clock.system(ZoneId.systemDefault()); private final Duration period; private final Clock clock; private final HOTPGenerator hotpGenerator; private TOTPGenerator(final Builder builder) { this.period = builder.period; this.clock = builder.clock; this.hotpGenerator = builder.hotpBuilder.build(); } public static TOTPGenerator fromURI(URI uri) throws URISyntaxException { Map query = URIHelper.queryItems(uri); String secret = Optional.ofNullable(query.get(URIHelper.SECRET)) .orElseThrow(() -> new IllegalArgumentException("Secret query parameter must be set")); Builder builder = new Builder(secret); try { Optional.ofNullable(query.get(URIHelper.PERIOD)) .map(Long::parseLong) .map(Duration::ofSeconds) .ifPresent(builder::withPeriod); Optional.ofNullable(query.get(URIHelper.DIGITS)) .map(Integer::valueOf) .ifPresent(builder.hotpBuilder::withPasswordLength); Optional.ofNullable(query.get(URIHelper.ALGORITHM)) .map(String::toUpperCase) .map(HMACAlgorithm::valueOf) .ifPresent(builder.hotpBuilder::withAlgorithm); } catch (Exception e) { throw new URISyntaxException(uri.toString(), "URI could not be parsed"); } return builder.build(); } public static TOTPGenerator withDefaultValues(final byte[] secret) { return new TOTPGenerator.Builder(secret).build(); } public String now() throws IllegalStateException { long counter = calculateCounter(clock, period); return hotpGenerator.generate(counter); } public String now(Clock clock) throws IllegalStateException { long counter = calculateCounter(clock, period); return hotpGenerator.generate(counter); } public String at(final Instant instant) throws IllegalStateException { return at(instant.getEpochSecond()); } public String at(final Date date) throws IllegalStateException { long secondsSince1970 = TimeUnit.MILLISECONDS.toSeconds(date.getTime()); return at(secondsSince1970); } public String at(final LocalDate date) throws IllegalStateException { long secondsSince1970 = date.atStartOfDay(clock.getZone()).toEpochSecond(); return at(secondsSince1970); } public String at(final long secondsPast1970) throws IllegalArgumentException { if (!validateTime(secondsPast1970)) throw new IllegalArgumentException("Time must be above zero"); long counter = calculateCounter(secondsPast1970, period); return hotpGenerator.generate(counter); } public boolean verify(final String code) { long counter = calculateCounter(clock, period); return hotpGenerator.verify(code, counter); } /** * Checks whether a code is valid for a specific counter taking a delay window into account * * @param code an OTP code * @param delayWindow window in which a code can still be deemed valid * @return a boolean, true if code is valid, otherwise false */ public boolean verify(final String code, final int delayWindow) { long counter = calculateCounter(clock, period); return hotpGenerator.verify(code, counter, delayWindow); } public URI getURI(final String issuer) throws URISyntaxException { return getURI(issuer, ""); } public URI getURI(final String issuer, final String account) throws URISyntaxException { Map query = new HashMap<>(); query.put(URIHelper.PERIOD, String.valueOf(period.getSeconds())); return hotpGenerator.getURI(OTP_TYPE, issuer, account, query); } /** * Calculates time until next time window will be reached and a new totp should be generated * * @return a duration object with duration until next time window */ public Duration durationUntilNextTimeWindow() { return durationUntilNextTimeWindow(clock); } public Duration durationUntilNextTimeWindow(Clock clock) { long timeInterval = period.toMillis(); return Duration.ofMillis(timeInterval - clock.millis() % timeInterval); } public Duration getPeriod() { return period; } public Clock getClock() { return clock; } public HMACAlgorithm getAlgorithm() { return hotpGenerator.getAlgorithm(); } public int getPasswordLength() { return hotpGenerator.getPasswordLength(); } private long calculateCounter(final long secondsPast1970, final Duration period) { return TimeUnit.SECONDS.toMillis(secondsPast1970) / period.toMillis(); } private long calculateCounter(final Clock clock, final Duration period) { return clock.millis() / period.toMillis(); } private boolean validateTime(final long time) { return time > 0; } public static final class Builder { private Duration period; private Clock clock; private final HOTPGenerator.Builder hotpBuilder; /** * Creates a new builder. *

* Use {@link SecretGenerator#generate()} to create a secret. *

* If you are using a shared secret from another generator, you would likely need to encode it using * {@link org.apache.commons.codec.binary.Base32#encode(byte[])}} * * @param secret Base32 encoded secret */ public Builder(byte[] secret) { this.period = DEFAULT_PERIOD; this.clock = DEFAULT_CLOCK; this.hotpBuilder = new HOTPGenerator.Builder(secret); } /** * @param secret Base32 encoded secret */ public Builder(String secret) { this(secret.getBytes(UTF_8)); } public Builder withHOTPGenerator(Consumer builder) { builder.accept(hotpBuilder); return this; } public Builder withClock(Clock clock) { this.clock = clock; return this; } public Builder withPeriod(Duration period) { if (period.getSeconds() < 1) throw new IllegalArgumentException("Period must be at least 1 second"); this.period = period; return this; } public TOTPGenerator build() { return new TOTPGenerator(this); } } } ================================================ FILE: src/main/java/com/bastiaanjansen/otp/helpers/URIHelper.java ================================================ package com.bastiaanjansen.otp.helpers; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; import java.util.stream.Collectors; /** * A URI utility class with helper methods * * @author Bastiaan Jansen */ public class URIHelper { public static final String DIGITS = "digits"; public static final String SECRET = "secret"; public static final String ALGORITHM = "algorithm"; public static final String PERIOD = "period"; public static final String COUNTER = "counter"; public static final String ISSUER = "issuer"; private URIHelper() {} /** * Get a map of query items from URI * * @param uri to get query items from * @return map of query items from URI */ public static Map queryItems(URI uri) { Map items = new LinkedHashMap<>(); String query = uri.getQuery(); String[] pairs = query.split("&"); for (String pair: pairs) { int index = pair.indexOf("="); try { items.put( URLDecoder.decode(pair.substring(0, index), StandardCharsets.UTF_8.toString()), URLDecoder.decode(pair.substring(index + 1), StandardCharsets.UTF_8.toString()) ); } catch (UnsupportedEncodingException e) { throw new IllegalStateException("Encoding should be supported"); } } return items; } /** * Create a URI based on a scheme, host, path and query items * * @param scheme of URI * @param host of URI * @param path of URI * @param query of URI * @return created URI * @throws URISyntaxException when URI cannot be created */ public static URI createURI(String scheme, String host, String path, Map query) throws URISyntaxException { String uriString = String.format("%s://%s/%s?", scheme, host, path); String uri = query.keySet().stream() .map(key -> String.format("%s=%s", key, encode(query.get(key)))) .collect(Collectors.joining("&", uriString, "")); return new URI(uri); } public static String encode(String value) { try { return URLEncoder.encode(value, StandardCharsets.UTF_8.toString()); } catch (UnsupportedEncodingException e) { throw new IllegalArgumentException(e); } } } ================================================ FILE: src/test/java/com/bastiaanjansen/otp/ExampleApp.java ================================================ package com.bastiaanjansen.otp; import java.time.Clock; import java.time.Duration; public class ExampleApp { public static void main(String[] args) { // Generate a secret, if you don't have one already byte[] secret = SecretGenerator.generate(); Clock clock = Clock.systemUTC(); // Create a TOTPGenerate instance TOTPGenerator totpGenerator = new TOTPGenerator.Builder(secret) .withHOTPGenerator(builder -> { builder.withAlgorithm(HMACAlgorithm.SHA1); builder.withPasswordLength(6); }) .withClock(clock) .withPeriod(Duration.ofMinutes(15)) .build(); try { String code = totpGenerator.now(); System.out.println("Generated code: " + code); // To verify a code totpGenerator.verify(code); // true } catch (IllegalStateException e) { e.printStackTrace(); } } } ================================================ FILE: src/test/java/com/bastiaanjansen/otp/HOTPGeneratorTest.java ================================================ package com.bastiaanjansen.otp; import org.hamcrest.Matchers; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import java.net.URI; import java.net.URISyntaxException; import java.util.stream.Stream; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; class HOTPGeneratorTest { private final String secret = "vv3kox7uqj4kyakohmzpph3us4cjimh6f3zknb5c2oobq6v2kiyhm27q"; private static Stream testData() { return Stream.of( Arguments.of(6, 1, HMACAlgorithm.SHA1, "560287"), Arguments.of(7, 1, HMACAlgorithm.SHA1, "1560287"), Arguments.of(8, 1, HMACAlgorithm.SHA1, "61560287"), Arguments.of(6, 1000, HMACAlgorithm.SHA1, "401796"), Arguments.of(6, 923892, HMACAlgorithm.SHA1, "793394"), Arguments.of(6, 82764924, HMACAlgorithm.SHA1, "022826"), Arguments.of(6, 1, HMACAlgorithm.SHA256, "361406"), Arguments.of(6, 1, HMACAlgorithm.SHA512, "016738"), Arguments.of(6, 1, HMACAlgorithm.SHA224, "422784"), Arguments.of(6, 1, HMACAlgorithm.SHA384, "466320") ); } @ParameterizedTest @MethodSource("testData") void generateWithCounter(int passwordLength, long counter, HMACAlgorithm algorithm, String otp) { HOTPGenerator generator = new HOTPGenerator.Builder(secret) .withPasswordLength(passwordLength) .withAlgorithm(algorithm) .build(); assertThat(generator.generate(counter), is(otp)); } @ParameterizedTest @ValueSource(ints = {-1, -100}) void generateWithInvalidCounter_throwsIllegalArgumentException(long counter) { HOTPGenerator generator = new HOTPGenerator.Builder(secret).build(); assertThrows(IllegalArgumentException.class, () -> generator.generate(counter)); } @Test void verifyCurrentCode_true() { HOTPGenerator generator = new HOTPGenerator.Builder(secret).build(); String code = generator.generate(1); assertThat(generator.verify(code, 1), is(true)); } @Test void verifyOlderCodeWithDelayWindowIs0_false() { HOTPGenerator generator = new HOTPGenerator.Builder(secret).build(); String code = generator.generate(1); assertThat(generator.verify(code, 2), is(false)); } @Test void verifyOlderCodeWithDelayWindowIs1_true() { HOTPGenerator generator = new HOTPGenerator.Builder(secret).build(); String code = generator.generate(1); assertThat(generator.verify(code, 2, 1), is(true)); } @Test void withDefaultValues_algorithm() { HOTPGenerator generator = HOTPGenerator.withDefaultValues(secret.getBytes()); HMACAlgorithm expected = HMACAlgorithm.SHA1; assertThat(generator.getAlgorithm(), is(expected)); } @Test void withDefaultValues_passwordLength() { HOTPGenerator generator = HOTPGenerator.withDefaultValues(secret.getBytes()); int expected = 6; assertThat(generator.getPasswordLength(), is(expected)); } @Test void getURIWithIssuer_doesNotThrow() { HOTPGenerator generator = new HOTPGenerator.Builder(secret).build(); assertDoesNotThrow(() -> { generator.getURI(10, "issuer"); }); } @Test void getURIWithIssuerWithSpace_doesNotThrow() { HOTPGenerator generator = new HOTPGenerator.Builder(secret).build(); assertDoesNotThrow(() -> generator.getURI(10, "issuer with space")); } @Test void getURIWithIssuerWithSpace_doesEscapeIssuer() throws URISyntaxException { HOTPGenerator generator = new HOTPGenerator.Builder(secret).build(); String url = generator.getURI(10, "issuer with space").toString(); assertThat(url, is("otpauth://hotp/issuer+with+space?digits=6&counter=10&secret=vv3kox7uqj4kyakohmzpph3us4cjimh6f3zknb5c2oobq6v2kiyhm27q&issuer=issuer+with+space&algorithm=SHA1")); } @Test void getURIWithIssuer() throws URISyntaxException { HOTPGenerator generator = new HOTPGenerator.Builder(secret).build(); URI uri = generator.getURI(10, "issuer"); assertThat(uri.toString(), is("otpauth://hotp/issuer?digits=6&counter=10&secret=" + secret + "&issuer=issuer&algorithm=SHA1")); } @Test void getURIWithIssuerWithUrlUnsafeCharacters() throws URISyntaxException { HOTPGenerator generator = new HOTPGenerator.Builder(secret).build(); URI uri = generator.getURI(10, "mac&cheese"); assertThat(uri.toString(), is("otpauth://hotp/mac%26cheese?digits=6&counter=10&secret=" + secret + "&issuer=mac%26cheese&algorithm=SHA1")); } @Test void getURIWithIssuerAndAccount_doesNotThrow() { HOTPGenerator generator = new HOTPGenerator.Builder(secret).build(); assertDoesNotThrow(() -> { generator.getURI(100, "issuer", "account"); }); } @Test void getURIWithIssuerAndAccount() throws URISyntaxException { HOTPGenerator generator = new HOTPGenerator.Builder(secret).build(); URI uri = generator.getURI(100, "issuer", "account"); assertThat(uri.toString(), is("otpauth://hotp/issuer:account?digits=6&counter=100&secret=" + secret + "&issuer=issuer&algorithm=SHA1")); } @Test void getURIWithIssuerAndAccountWithUrlUnsafeCharacters() throws URISyntaxException { HOTPGenerator generator = new HOTPGenerator.Builder(secret).build(); URI uri = generator.getURI(100, "mac&cheese", "ac@cou.nt"); assertThat(uri.toString(), is("otpauth://hotp/mac%26cheese:ac%40cou.nt?digits=6&counter=100&secret=" + secret + "&issuer=mac%26cheese&algorithm=SHA1")); } @Test void fromURIWithAlgorithmUppercase() throws URISyntaxException { URI uri = new URI("otpauth://hotp/issuer?counter=10&algorithm=SHA256&secret=" + secret); HOTPGenerator generator = HOTPGenerator.fromURI(uri); HMACAlgorithm expected = HMACAlgorithm.SHA256; assertThat(generator.getAlgorithm(), is(expected)); } @Test void fromURIWithAlgorithmLowercase() throws URISyntaxException { URI uri = new URI("otpauth://hotp/issuer?counter=10&algorithm=sha256&secret=" + secret); HOTPGenerator generator = HOTPGenerator.fromURI(uri); HMACAlgorithm expected = HMACAlgorithm.SHA256; assertThat(generator.getAlgorithm(), is(expected)); } @Test void fromURIWithDigitsIs7() throws URISyntaxException { URI uri = new URI("otpauth://hotp/issuer?digits=7&counter=10&secret=" + secret); HOTPGenerator generator = HOTPGenerator.fromURI(uri); int expected = 7; assertThat(generator.getPasswordLength(), is(expected)); } @Test void fromURIWithInvalidDigits_throwsURISyntaxException() throws URISyntaxException { URI uri = new URI("otpauth://hotp/issuer?digits=invalid&counter=10&secret=" + secret); assertThrows(URISyntaxException.class, () -> HOTPGenerator.fromURI(uri)); } @Test void fromURIWithInvalidAlgorithm_throwsURISyntaxException() throws URISyntaxException { URI uri = new URI("otpauth://hotp/issuer?counter=10&algorithm=invalid&secret=" + secret); assertThrows(URISyntaxException.class, () -> HOTPGenerator.fromURI(uri)); } @Test void fromURIWithInvalidSecret_throwsIllegalArgumentException() throws URISyntaxException { URI uri = new URI("otpauth://hotp/issuer?counter=10"); assertThrows(IllegalArgumentException.class, () -> HOTPGenerator.fromURI(uri)); } @Nested class BuilderTest { @Test void builderWithEmptySecret_throwsIllegalArgumentException() { assertThrows(IllegalArgumentException.class, () -> new HOTPGenerator.Builder(new byte[]{}).build()); } @Test void builderWithPasswordLengthIs5_throwsIllegalArgumentException() { assertThrows(IllegalArgumentException.class, () -> { new HOTPGenerator.Builder(secret).withPasswordLength(5).build(); }); } @Test void builderWithPasswordLengthIs9_throwsIllegalArgumentException() { assertThrows(IllegalArgumentException.class, () -> { new HOTPGenerator.Builder(secret).withPasswordLength(9).build(); }); } @Test void builderWithPasswordLengthIs6() { HOTPGenerator generator = new HOTPGenerator.Builder(secret).withPasswordLength(6).build(); int expected = 6; assertThat(generator.getPasswordLength(), Matchers.is(expected)); } @Test void builderWithAlgorithmSHA1() { HOTPGenerator generator = new HOTPGenerator.Builder(secret).withAlgorithm(HMACAlgorithm.SHA1).build(); HMACAlgorithm expected = HMACAlgorithm.SHA1; assertThat(generator.getAlgorithm(), Matchers.is(expected)); } @Test void builderWithAlgorithmSHA256() { HOTPGenerator generator = new HOTPGenerator.Builder(secret).withAlgorithm(HMACAlgorithm.SHA256).build(); HMACAlgorithm expected = HMACAlgorithm.SHA256; assertThat(generator.getAlgorithm(), Matchers.is(expected)); } @Test void builderWithAlgorithmSHA512() { HOTPGenerator generator = new HOTPGenerator.Builder(secret).withAlgorithm(HMACAlgorithm.SHA512).build(); HMACAlgorithm expected = HMACAlgorithm.SHA512; assertThat(generator.getAlgorithm(), Matchers.is(expected)); } @ParameterizedTest @ValueSource(ints = { 1, 2, 3, 4, 5, 9, 10 }) void builderWithInvalidPasswordLength_throwsIllegalArgumentException(int passwordLength) { assertThrows(IllegalArgumentException.class, () -> new HOTPGenerator.Builder(secret).withPasswordLength(passwordLength).build()); } @Test void builderWithoutAlgorithm_defaultAlgorithm() { HOTPGenerator generator = new HOTPGenerator.Builder(secret).build(); HMACAlgorithm expected = HMACAlgorithm.SHA1; assertThat(generator.getAlgorithm(), is(expected)); } } } ================================================ FILE: src/test/java/com/bastiaanjansen/otp/SecretGeneratorTest.java ================================================ package com.bastiaanjansen.otp; import org.junit.jupiter.api.Test; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; class SecretGeneratorTest { @Test void generate_defaultLengthIs32() { int expected = 32; assertThat(SecretGenerator.generate().length, is(expected)); } @Test void generate_lengthIs56() { int expected = 56; assertThat(SecretGenerator.generate(256).length, is(expected)); } @Test void generateWithZeroBits_throwsIllegalArgument() { assertThrows(IllegalArgumentException.class, () -> SecretGenerator.generate(0)); } @Test void generateWithLessThanZeroBits_throwsIllegalArgument() { assertThrows(IllegalArgumentException.class, () -> SecretGenerator.generate(-1)); } } ================================================ FILE: src/test/java/com/bastiaanjansen/otp/TOTPGeneratorTest.java ================================================ package com.bastiaanjansen.otp; import org.hamcrest.Matchers; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import java.net.URI; import java.net.URISyntaxException; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.util.Date; import java.util.stream.Stream; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; class TOTPGeneratorTest { private final static String secret = "vv3kox7uqj4kyakohmzpph3us4cjimh6f3zknb5c2oobq6v2kiyhm27q"; private static Stream secondsPast1970TestData() { return Stream.of( Arguments.of(6, 1, HMACAlgorithm.SHA1, "455216"), Arguments.of(7, 1, HMACAlgorithm.SHA1, "7455216"), Arguments.of(8, 1, HMACAlgorithm.SHA1, "17455216"), Arguments.of(6, 1000, HMACAlgorithm.SHA1, "687469"), Arguments.of(6, 923892, HMACAlgorithm.SHA1, "909546"), Arguments.of(6, 82764924, HMACAlgorithm.SHA1, "408978"), Arguments.of(6, 1, HMACAlgorithm.SHA256, "755370"), Arguments.of(6, 1, HMACAlgorithm.SHA512, "303161") ); } private static Stream instantTestData() { return Stream.of( Arguments.of(6, Instant.ofEpochSecond(1), HMACAlgorithm.SHA1, "455216"), Arguments.of(7, Instant.ofEpochSecond(1000), HMACAlgorithm.SHA1, "0687469"), Arguments.of(8, Instant.ofEpochSecond(923892), HMACAlgorithm.SHA1, "39909546"), Arguments.of(6, Instant.ofEpochSecond(82764924), HMACAlgorithm.SHA256, "999993"), Arguments.of(6, Instant.ofEpochSecond(82764924), HMACAlgorithm.SHA512, "300089") ); } private static Stream dateTestData() { return Stream.of( Arguments.of(6, Date.from(Instant.ofEpochSecond(1)), HMACAlgorithm.SHA1, "455216"), Arguments.of(7, Date.from(Instant.ofEpochSecond(100)), HMACAlgorithm.SHA1, "9650012"), Arguments.of(8, Date.from(Instant.ofEpochSecond(723)), HMACAlgorithm.SHA1, "12251322"), Arguments.of(6, Date.from(Instant.ofEpochSecond(123)), HMACAlgorithm.SHA256, "376047"), Arguments.of(6, Date.from(Instant.ofEpochSecond(9802467)), HMACAlgorithm.SHA512, "040816") ); } private static Stream clockTestData() { return Stream.of( Arguments.of(6, Clock.fixed(Instant.ofEpochSecond(1), ZoneId.of("UTC")), HMACAlgorithm.SHA1, "455216"), Arguments.of(7, Clock.fixed(Instant.ofEpochSecond(100), ZoneId.of("UTC")), HMACAlgorithm.SHA1, "9650012"), Arguments.of(8, Clock.fixed(Instant.ofEpochSecond(723), ZoneId.of("UTC")), HMACAlgorithm.SHA1, "12251322"), Arguments.of(6, Clock.fixed(Instant.ofEpochSecond(123), ZoneId.of("UTC")), HMACAlgorithm.SHA256, "376047"), Arguments.of(6, Clock.fixed(Instant.ofEpochSecond(9802467), ZoneId.of("UTC")), HMACAlgorithm.SHA512, "040816") ); } @ParameterizedTest @MethodSource("secondsPast1970TestData") void generateAtSecondsPast1970(int passwordLength, int secondsPast1970, HMACAlgorithm algorithm, String otp) { TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> { builder.withPasswordLength(passwordLength); builder.withAlgorithm(algorithm); }).build(); assertThat(generator.at(secondsPast1970), is(otp)); } @ParameterizedTest @MethodSource("instantTestData") void generateAtInstant(int passwordLength, Instant instant, HMACAlgorithm algorithm, String otp) { TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> { builder.withPasswordLength(passwordLength); builder.withAlgorithm(algorithm); }).build(); assertThat(generator.at(instant), is(otp)); } @ParameterizedTest @MethodSource("dateTestData") void generateAtDate(int passwordLength, Date date, HMACAlgorithm algorithm, String otp) { TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> { builder.withPasswordLength(passwordLength); builder.withAlgorithm(algorithm); }).build(); assertThat(generator.at(date), is(otp)); } @ParameterizedTest @MethodSource("clockTestData") void generateAtNow(int passwordLength, Clock clock, HMACAlgorithm algorithm, String otp) { TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> { builder.withPasswordLength(passwordLength); builder.withAlgorithm(algorithm); }) .withClock(clock) .build(); assertThat(generator.now(), is(otp)); } @ParameterizedTest @ValueSource(ints = {0, -1}) void generateWithInvalidSecondsPast1970_throwsIllegalArgumentException(int secondsPast1970) { TOTPGenerator generator = new TOTPGenerator.Builder(secret).build(); assertThrows(IllegalArgumentException.class, () -> generator.at(secondsPast1970)); } @Test void verifyCurrentCode_true() { TOTPGenerator generator = new TOTPGenerator.Builder(secret).build(); String code = generator.now(); assertThat(generator.verify(code), is(true)); } @Test void verifyOlderCodeWithDelayWindowIs0_false() { TOTPGenerator generator = new TOTPGenerator.Builder(secret).build(); String code = generator.at(Instant.now().minusSeconds(30)); assertThat(generator.verify(code), is(false)); } @Test void verifyOlderCodeWithDelayWindowIs1_true() { TOTPGenerator generator = new TOTPGenerator.Builder(secret).build(); String code = generator.at(Instant.now().minusSeconds(30)); assertThat(generator.verify(code, 1), is(true)); } @Test void getURIWithIssuer() throws URISyntaxException { TOTPGenerator generator = new TOTPGenerator.Builder(secret).build(); URI uri = generator.getURI("issuer"); assertThat(uri.toString(), is("otpauth://totp/issuer?period=30&digits=6&secret=" + secret + "&issuer=issuer&algorithm=SHA1")); } @Test void getURIWithIssuerWithUrlUnsafeCharacters() throws URISyntaxException { TOTPGenerator generator = new TOTPGenerator.Builder(secret).build(); URI uri = generator.getURI("mac&cheese"); assertThat(uri.toString(), is("otpauth://totp/mac%26cheese?period=30&digits=6&secret=" + secret + "&issuer=mac%26cheese&algorithm=SHA1")); } @Test void getURIWithIssuerAndAccount_doesNotThrow() { TOTPGenerator generator = new TOTPGenerator.Builder(secret).build(); assertDoesNotThrow(() -> { generator.getURI("issuer", "account"); }); } @Test void getURIWithIssuerAndAccount() throws URISyntaxException { TOTPGenerator generator = new TOTPGenerator.Builder(secret).build(); URI uri = generator.getURI("issuer", "account"); assertThat(uri.toString(), is("otpauth://totp/issuer:account?period=30&digits=6&secret=" + secret + "&issuer=issuer&algorithm=SHA1")); } @Test void getURIWithIssuerAndAccountWithUrlUnsafeCharacters() throws URISyntaxException { TOTPGenerator generator = new TOTPGenerator.Builder(secret).build(); URI uri = generator.getURI("mac&cheese", "ac@cou.nt"); assertThat(uri.toString(), is("otpauth://totp/mac%26cheese:ac%40cou.nt?period=30&digits=6&secret=" + secret + "&issuer=mac%26cheese&algorithm=SHA1")); } @Test void fromURIWithPeriod() throws URISyntaxException { URI uri = new URI("otpauth://totp/issuer:account?period=60&secret=" + secret); TOTPGenerator generator = TOTPGenerator.fromURI(uri); Duration expected = Duration.ofSeconds(60); assertThat(generator.getPeriod(), is(expected)); } @Test void fromURIWithAlgorithmUppercase() throws URISyntaxException { URI uri = new URI("otpauth://totp/issuer:account?algorithm=SHA1&secret=" + secret); TOTPGenerator generator = TOTPGenerator.fromURI(uri); HMACAlgorithm expected = HMACAlgorithm.SHA1; assertThat(generator.getAlgorithm(), is(expected)); } @Test void fromURIWithAlgorithmLowercase() throws URISyntaxException { URI uri = new URI("otpauth://totp/issuer:account?algorithm=sha1&secret=" + secret); TOTPGenerator generator = TOTPGenerator.fromURI(uri); HMACAlgorithm expected = HMACAlgorithm.SHA1; assertThat(generator.getAlgorithm(), is(expected)); } @Test void fromURIWithPasswordLength() throws URISyntaxException { URI uri = new URI("otpauth://totp/issuer:account?digits=6&secret=" + secret); TOTPGenerator generator = TOTPGenerator.fromURI(uri); int expected = 6; assertThat(generator.getPasswordLength(), is(expected)); } @Test void fromURIWithInvalidPeriod_throwsURISyntaxException() throws URISyntaxException { URI uri = new URI("otpauth://totp/issuer:account?period=invalid&secret=" + secret); assertThrows(URISyntaxException.class, () -> TOTPGenerator.fromURI(uri)); } @Test void fromURIWithPasswordLengthIs5_throwsURISyntaxException() throws URISyntaxException { URI uri = new URI("otpauth://totp/issuer:account?digits=5&secret=" + secret); assertThrows(URISyntaxException.class, () -> TOTPGenerator.fromURI(uri)); } @Test void fromURIWithPasswordLengthIs9_throwsURISyntaxException() throws URISyntaxException { URI uri = new URI("otpauth://totp/issuer:account?digits=9&secret=" + secret); assertThrows(URISyntaxException.class, () -> TOTPGenerator.fromURI(uri)); } @ParameterizedTest @ValueSource(strings = { "otpauth://totp/issuer:account?digits=6&secret=", "otpauth://totp/issuer:account?digits=8&secret=", "otpauth://totp/issuer:account?digits=7&secret=" }) void fromURI_doesNotThrow(String url) throws URISyntaxException { URI uri = new URI(url + secret); assertDoesNotThrow(() -> { TOTPGenerator.fromURI(uri); }); } @Test void fromURIWithInvalidAlgorithm_throwsURISyntaxException() throws URISyntaxException { URI uri = new URI("otpauth://totp/issuer:account?algorithm=invalid&secret=" + secret); assertThrows(URISyntaxException.class, () -> TOTPGenerator.fromURI(uri)); } @Nested class BuilderTest { @Test void builderWithEmptySecret_throwsIllegalArgumentException() { assertThrows(IllegalArgumentException.class, () -> new TOTPGenerator.Builder(new byte[]{}).build()); } @Test void builderWithPasswordLengthIs5_throwsIllegalArgumentException() { assertThrows(IllegalArgumentException.class, () -> { new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> builder.withPasswordLength(5)).build(); }); } @Test void builderWithPasswordLengthIs9_throwsIllegalArgumentException() { assertThrows(IllegalArgumentException.class, () -> { new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> builder.withPasswordLength(9)).build(); }); } @Test void builderWithPasswordLengthIs6() { TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> builder.withPasswordLength(6)).build(); int expected = 6; assertThat(generator.getPasswordLength(), Matchers.is(expected)); } @Test void builderWithAlgorithmSHA1() { TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> builder.withAlgorithm(HMACAlgorithm.SHA1)).build(); HMACAlgorithm expected = HMACAlgorithm.SHA1; assertThat(generator.getAlgorithm(), Matchers.is(expected)); } @Test void builderWithAlgorithmSHA256() { TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> builder.withAlgorithm(HMACAlgorithm.SHA256)).build(); HMACAlgorithm expected = HMACAlgorithm.SHA256; assertThat(generator.getAlgorithm(), Matchers.is(expected)); } @Test void builderWithAlgorithmSHA512() { TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> builder.withAlgorithm(HMACAlgorithm.SHA512)).build(); HMACAlgorithm expected = HMACAlgorithm.SHA512; assertThat(generator.getAlgorithm(), Matchers.is(expected)); } @ParameterizedTest @ValueSource(ints = {1, 2, 3, 4, 5, 9, 10}) void builderWithInvalidPasswordLength_throwsIllegalArgumentException(int passwordLength) { assertThrows(IllegalArgumentException.class, () -> new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> builder.withPasswordLength(passwordLength)).build()); } @Test void builderWithoutPeriod_defaultPeriod() { TOTPGenerator generator = new TOTPGenerator.Builder(secret).build(); Duration expected = Duration.ofSeconds(30); assertThat(generator.getPeriod(), is(expected)); } @Test void builderWithoutAlgorithm_defaultAlgorithm() { TOTPGenerator generator = new TOTPGenerator.Builder(secret).build(); HMACAlgorithm expected = HMACAlgorithm.SHA1; assertThat(generator.getAlgorithm(), is(expected)); } } } ================================================ FILE: src/test/java/com/bastiaanjansen/otp/helpers/URIHelperTest.java ================================================ package com.bastiaanjansen.otp.helpers; import org.junit.jupiter.api.Test; import java.net.URI; import java.net.URISyntaxException; import java.util.HashMap; import java.util.Map; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; class URIHelperTest { @Test void queryItemsWithOneQueryItem() throws URISyntaxException { URI uri = new URI("otpauth://totp/issuer:account?algorithm=SHA1"); Map query = URIHelper.queryItems(uri); String expected = "SHA1"; assertThat(query.get("algorithm"), is(expected)); } @Test void queryItemsWithTwoQueryItems() throws URISyntaxException { URI uri = new URI("otpauth://totp/issuer:account?algorithm=SHA1&secret=ABC"); Map query = URIHelper.queryItems(uri); String expected = "ABC"; assertThat(query.get("secret"), is(expected)); } @Test void createURI_doesNotThrow() { Map query = new HashMap<>(); assertDoesNotThrow(() -> URIHelper.createURI("scheme", "host", "path", query)); } @Test void createURIWithOneQueryItem() throws URISyntaxException { Map query = new HashMap<>(); query.put("test", "1"); URI uri = URIHelper.createURI("scheme", "host", "path", query); String expected = "scheme://host/path?test=1"; assertThat(uri.toString(), is(expected)); } @Test void createURIWithTwoQueryItems() throws URISyntaxException { Map query = new HashMap<>(); query.put("test", "1"); query.put("test2", "2"); URI uri = URIHelper.createURI("scheme", "host", "path", query); String expected = "scheme://host/path?test2=2&test=1"; assertThat(uri.toString(), is(expected)); } @Test void createURIWithUrlUnsafeCharacters() throws URISyntaxException { Map query = new HashMap<>(); query.put("test", "value 1"); query.put("test2", "value?&2"); URI uri = URIHelper.createURI("scheme", "host", "path", query); String expected = "scheme://host/path?test2=value%3F%262&test=value+1"; assertThat(uri.toString(), is(expected)); } }