├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── README.md ├── pom.xml ├── totp-spring-boot-starter ├── README.md ├── pom.xml └── src │ └── main │ ├── java │ └── dev │ │ └── samstevens │ │ └── totp │ │ └── spring │ │ └── autoconfigure │ │ ├── TotpAutoConfiguration.java │ │ └── TotpProperties.java │ └── resources │ └── META-INF │ └── spring.factories └── totp ├── pom.xml └── src ├── main └── java │ └── dev │ └── samstevens │ └── totp │ ├── TotpInfo.java │ ├── code │ ├── CodeGenerator.java │ ├── CodeVerifier.java │ ├── DefaultCodeGenerator.java │ ├── DefaultCodeVerifier.java │ └── HashingAlgorithm.java │ ├── exceptions │ ├── CodeGenerationException.java │ ├── QrGenerationException.java │ └── TimeProviderException.java │ ├── qr │ ├── QrData.java │ ├── QrDataFactory.java │ ├── QrGenerator.java │ └── ZxingPngQrGenerator.java │ ├── recovery │ └── RecoveryCodeGenerator.java │ ├── secret │ ├── DefaultSecretGenerator.java │ └── SecretGenerator.java │ ├── time │ ├── NtpTimeProvider.java │ ├── SystemTimeProvider.java │ └── TimeProvider.java │ └── util │ └── Utils.java └── test └── java └── dev └── samstevens └── totp ├── IOUtils.java ├── code ├── DefaultCodeGeneratorTest.java └── DefaultCodeVerifierTest.java ├── qr ├── QrDataFactoryTest.java ├── QrDataTest.java └── ZxingPngQrGeneratorTest.java ├── recovery └── RecoveryCodeGeneratorTest.java ├── secret └── DefaultSecretGeneratorTest.java ├── time ├── NtpTimeProviderTest.java └── SystemTimeProviderTest.java └── util └── DataUriEncodingTest.java /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Java Maven CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-java/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/openjdk:8-jdk 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/postgres:9.4 16 | 17 | working_directory: ~/repo 18 | 19 | environment: 20 | # Customize the JVM maximum heap limit 21 | MAVEN_OPTS: -Xmx3200m 22 | 23 | steps: 24 | - checkout 25 | 26 | # Download and cache dependencies 27 | - restore_cache: 28 | keys: 29 | - v1-dependencies-{{ checksum "pom.xml" }} 30 | # fallback to using the latest cache if no exact match is found 31 | - v1-dependencies- 32 | 33 | - run: mvn clean compile 34 | 35 | - save_cache: 36 | paths: 37 | - ~/.m2 38 | key: v1-dependencies-{{ checksum "pom.xml" }} 39 | 40 | # run tests! 41 | - run: mvn integration-test 42 | 43 | # upload code coverage 44 | - run: "mvn -DrepoToken=$COVERALLS_REPO_TOKEN jacoco:report coveralls:report" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | target 3 | *.iml 4 | 5 | # Eclipse 6 | .settings 7 | .project 8 | .classpath 9 | bin 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sam Stevens 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Time-based One Time Password (MFA) Library for Java 2 | 3 | [![CircleCI](https://circleci.com/gh/samdjstevens/java-totp/tree/master.svg?style=svg&circle-token=10b865d8ba6091caba7a73a5a2295bd642ab79d5)](https://circleci.com/gh/samdjstevens/java-totp/tree/master) [![Coverage Status](https://coveralls.io/repos/github/samdjstevens/java-totp/badge.svg)](https://coveralls.io/github/samdjstevens/java-totp) [![Maven Central](https://img.shields.io/maven-central/v/dev.samstevens.totp/totp.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:%22dev.samstevens.totp%22%20AND%20a:%22totp%22) 4 | 5 | A java library to help generate and verify time-based one time passwords for Multi-Factor Authentication. 6 | 7 | Generates QR codes that are recognisable by applications like Google Authenticator, and verify the one time passwords they produce. 8 | 9 | Inspired by [PHP library for Two Factor Authentication](https://github.com/RobThree/TwoFactorAuth), a similar library for PHP. 10 | 11 | ## Requirements 12 | 13 | - Java 8+ 14 | 15 | 16 | 17 | ## Spring Boot 18 | 19 | The quickest way to start using this library in a Spring Boot project is to require the TOTP Spring Boot Starter. See [Using Java-TOTP with Spring Boot](totp-spring-boot-starter/README.md) for more information, or read on to learn about the library. 20 | 21 | 22 | 23 | ## Installation 24 | 25 | #### Maven 26 | 27 | To add this library to your java project using Maven, add the following dependency: 28 | 29 | ```xml 30 | 31 | dev.samstevens.totp 32 | totp 33 | 1.7.1 34 | 35 | ``` 36 | 37 | #### Gradle 38 | 39 | To add the dependency using Gradle, add the following to the build script: 40 | 41 | ``` 42 | dependencies { 43 | compile 'dev.samstevens.totp:totp:1.7.1' 44 | } 45 | ``` 46 | 47 | 48 | 49 | ## Usage 50 | 51 | - [Generating secrets](#generating-a-shared-secret) 52 | - [Generating QR codes](#generating-a-qr-code) 53 | - [Verifying one time passwords](#verifying-one-time-passwords) 54 | - [Using different time providers](#using-different-time-providers) 55 | - [Recovery codes](#recovery-codes) 56 | 57 | 58 | 59 | ### Generating a shared secret 60 | 61 | To generate a secret, use the `dev.samstevens.totp.secret.DefaultSecretGenerator` class. 62 | ```java 63 | SecretGenerator secretGenerator = new DefaultSecretGenerator(); 64 | String secret = secretGenerator.generate(); 65 | // secret = "BP26TDZUZ5SVPZJRIHCAUVREO5EWMHHV" 66 | ``` 67 | 68 | By default, this class generates secrets that are 32 characters long, but this number is configurable via 69 | 70 | the class constructor. 71 | 72 | ```java 73 | // Generates secrets that are 64 characters long 74 | SecretGenerator secretGenerator = new DefaultSecretGenerator(64); 75 | ``` 76 | 77 | 78 | 79 | ### Generating a QR code 80 | 81 | Once a shared secret has been generated, this must be given to the user so they can add it to an MFA application, such as Google Authenticator. Whilst they could just enter the secret manually, a much better and more common option is to generate a QR code containing the secret (and other information), which can then be scanned by the application. 82 | 83 | To generate such a QR code, first create a `dev.samstevens.totp.qr.QrData` instance with the relevant information. 84 | 85 | ```java 86 | QrData data = new QrData.Builder() 87 | .label("example@example.com") 88 | .secret(secret) 89 | .issuer("AppName") 90 | .algorithm(HashingAlgorithm.SHA1) // More on this below 91 | .digits(6) 92 | .period(30) 93 | .build(); 94 | ``` 95 | 96 | Once you have a `QrData` object holding the relevant details, a PNG image of the code can be generated using the `dev.samstevens.totp.qr.ZxingPngQrGenerator` class. 97 | 98 | ```java 99 | QrGenerator generator = new ZxingPngQrGenerator(); 100 | byte[] imageData = generator.generate(data) 101 | ``` 102 | 103 | The `generate` method returns a byte array of the raw image data. The mime type of the data that is generated by the generator can be retrieved using the `getImageMimeType` method. 104 | 105 | ```java 106 | String mimeType = generator.getImageMimeType(); 107 | // mimeType = "image/png" 108 | ``` 109 | 110 | The image data can then be outputted to the browser, or saved to a temporary file to show it to the user. 111 | 112 | #### Embedding the QR code within HTML 113 | 114 | To avoid the QR code image having to be saved to disk, or passing the shared secret to another endpoint that generates and returns the image, it can be encoded in a [Data URI](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs), and embedded directly in the HTML served to the user. 115 | 116 | ```java 117 | import static dev.samstevens.totp.util.Utils.getDataUriForImage; 118 | ... 119 | String dataUri = getDataUriForImage(imageData, mimeType); 120 | // dataUri = data:image/png;base64,iVBORw0KGgoAAAANSU... 121 | ``` 122 | 123 | The QR code image can now be embedded directly in HTML via the data URI. Below is an example using [Thymeleaf](https://www.thymeleaf.org/): 124 | 125 | ```html 126 | 127 | ``` 128 | 129 | 130 | 131 | ### Verifying one time passwords 132 | 133 | After a user sets up their MFA, it's a good idea to get them to enter two of the codes generated by their app to verify the setup was successful. To verify a code submitted by the user, do the following: 134 | 135 | ```java 136 | TimeProvider timeProvider = new SystemTimeProvider(); 137 | CodeGenerator codeGenerator = new DefaultCodeGenerator(); 138 | CodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); 139 | 140 | // secret = the shared secret for the user 141 | // code = the code submitted by the user 142 | boolean successful = verifier.isValidCode(secret, code) 143 | ``` 144 | 145 | This same process is used when verifying the submitted code every time the user needs to in the future. 146 | 147 | #### Using different hashing algorithms 148 | 149 | By default, the `DefaultCodeGenerator` uses the SHA1 algorithm to generate/verify codes, but SHA256 and SHA512 are also supported. To use a different algorithm, pass in the desired `HashingAlgorithm` into the constructor: 150 | 151 | ```java 152 | CodeGenerator codeGenerator = new DefaultCodeGenerator(HashingAlgorithm.SHA512); 153 | ``` 154 | 155 | When verifying a given code, **you must use the same hashing algorithm** that was specified when the QR code was generated for the secret, otherwise the user submitted codes will not match. 156 | 157 | #### Setting the time period and discrepancy 158 | 159 | The one time password codes generated in the authenticator apps only last for a certain time period before they are re-generated, and most implementations of TOTP allow room for codes that have recently expired, or will only "become valid" soon in the future to be accepted as valid, to allow for a small time drift between the server and the authenticator app (discrepancy). 160 | 161 | By default on a `DefaultCodeVerifier` the time period is set to the standard 30 seconds, and the discrepancy to 1, to allow a time drift of +/-30 seconds. These values can be changed by calling the appropriate setters: 162 | 163 | ```java 164 | DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); 165 | // sets the time period for codes to be valid for to 60 seconds 166 | verifier.setTimePeriod(60); 167 | 168 | // allow codes valid for 2 time periods before/after to pass as valid 169 | verifier.setAllowedTimePeriodDiscrepancy(2); 170 | ``` 171 | 172 | Like the hashing algorithm, **the time period must be the same** as the one specified when the QR code for the secret was created. 173 | 174 | #### Setting how many digits long the generated codes are 175 | 176 | Most TOTP implementations generate codes that are 6 digits long, but codes can have a length of any positive non-zero integer. The default number of digits in a code generated by a `DefaultCodeGenerator` instance is 6, but can be set to a different value by passing the number as the second parameter in the constructor: 177 | 178 | ```java 179 | CodeGenerator codeGenerator = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 4); 180 | ``` 181 | 182 | The above generator will generate codes of 4 digits, using the SHA1 algorithm. 183 | 184 | Once again, **the number of digits must be the same** as what was specified when the QR code for the secret was created. 185 | 186 | 187 | 188 | ### Using different time providers 189 | 190 | When verifying user submitted codes with a `DefaultCodeVerifier`, a `TimeProvider` is needed to get the current time (unix) time. In the example code above, a `SystemTimeProvider` is used, but this is not the only option. 191 | 192 | #### Getting the time from the system 193 | 194 | Most applications should be able to use the `SystemTimeProvider` class to provide the time, which gets the time from the system clock. If the system clock is reliable, it is reccomended that this provider is used. 195 | 196 | 197 | #### Getting the time from an NTP Server 198 | 199 | If the system clock cannot be used to accurately get the current time, then you can fetch it from an NTP server with the `dev.samstevens.totp.time.NtpTimeProvider` class, passing in the NTP server hostname you wish you use. 200 | 201 | ```java 202 | TimeProvider timeProvider = new NtpTimeProvider("pool.ntp.org"); 203 | ``` 204 | 205 | The default timeout for the requests to the NTP server is 3 seconds, but this can be set by passing in the desired number of milliseconds as the second parameter in the constructor: 206 | 207 | ```java 208 | TimeProvider timeProvider = new NtpTimeProvider("pool.ntp.org", 5000); 209 | ``` 210 | 211 | **Using this time provider requires that the [Apache Commons Net](https://commons.apache.org/proper/commons-net) library is available on the classpath**. Add the dependency to your project with Maven/Gradle like this: 212 | 213 | **Maven**: 214 | 215 | ```xml 216 | 217 | commons-net 218 | commons-net 219 | 3.6 220 | 221 | ``` 222 | 223 | **Gradle**: 224 | 225 | ``` 226 | dependencies { 227 | compile 'commons-net:commons-net:3.6' 228 | } 229 | ``` 230 | 231 | ### Recovery Codes 232 | 233 | Recovery codes can be used to allow users to gain access to their MFA protected account without providing a TOTP, bypassing the MFA process. This is usually given as an option to the user so that in the event of losing access to the device which they have registered the MFA secret with, they are still able to log in. 234 | 235 | Usually, upon registering an account for MFA, several one-time use codes will be generated and presented to the user, with instructions to keep them very safe. When the user is presented with the prompt for a TOTP in the future, they can opt to enter one of the recovery codes instead to gain access to their account. 236 | 237 | Most of the logic needed for implementing recovery codes (storage, associating them with a user, checking for existance, etc) is implementation specific, but the codes themselves can be generated via this library. 238 | 239 | The default implementation provided in this library generates recovery codes : 240 | 241 | - of 16 characters 242 | - composed of numbers and lower case characters from latin alphabet (for a total of 36 possible characters) 243 | - split in groups separated with dash for better readability 244 | 245 | Thoses settings guarantees recovery codes security (with an entropy of 82 bits) while keeping codes simple to read and enter by end user when needed. 246 | 247 | 248 | ```java 249 | import dev.samstevens.totp.recovery.RecoveryCodeGenerator; 250 | ... 251 | // Generate 16 random recovery codes 252 | RecoveryCodeGenerator recoveryCodes = new RecoveryCodeGenerator(); 253 | String[] codes = recoveryCodes.generateCodes(16); 254 | // codes = ["tf8i-exmo-3lcb-slkm", "boyv-yq75-z99k-r308", "w045-mq6w-mg1i-q12o", ...] 255 | ``` 256 | 257 | 258 | 259 | ## Running Tests 260 | 261 | To run the tests for the library with Maven, run `mvn test`. 262 | 263 | 264 | 265 | 266 | ## License 267 | 268 | This project is licensed under the [MIT license](https://opensource.org/licenses/MIT). 269 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | dev.samstevens.totp 5 | totp-parent 6 | pom 7 | 1.7.1 8 | 9 | UTF-8 10 | 1.8 11 | 1.8 12 | 13 | 14 | ${project.groupId}:${project.artifactId} 15 | A library to help implement time-based one time passwords to enable MFA. 16 | 17 | https://github.com/samdjstevens/java-totp 18 | 19 | 20 | 21 | Sam Stevens 22 | samdjstevens@googlemail.com 23 | https://github.com/samdjstevens 24 | 25 | 26 | 27 | 28 | scm:git:git@github.com:samdjstevens/java-totp.git 29 | totp-1.7.1 30 | https://github.com/samdjstevens/java-totp 31 | 32 | 33 | 34 | 35 | MIT License 36 | http://www.opensource.org/licenses/mit-license.php 37 | repo 38 | 39 | 40 | 41 | 42 | 43 | ossrh 44 | https://oss.sonatype.org/content/repositories/snapshots 45 | 46 | 47 | ossrh 48 | https://oss.sonatype.org/service/local/staging/deploy/maven2/ 49 | 50 | 51 | 52 | 53 | totp-spring-boot-starter 54 | totp 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | maven-clean-plugin 63 | 3.1.0 64 | 65 | 66 | 67 | maven-resources-plugin 68 | 3.0.2 69 | 70 | 71 | maven-compiler-plugin 72 | 3.8.1 73 | 74 | 75 | maven-surefire-plugin 76 | 2.22.2 77 | 78 | false 79 | 80 | 81 | 82 | maven-jar-plugin 83 | 3.0.2 84 | 85 | 86 | maven-install-plugin 87 | 2.5.2 88 | 89 | 90 | maven-deploy-plugin 91 | 2.8.2 92 | 93 | 94 | 95 | maven-site-plugin 96 | 3.8.2 97 | 98 | 99 | maven-project-info-reports-plugin 100 | 3.0.0 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | org.apache.maven.plugins 109 | maven-compiler-plugin 110 | 111 | 8 112 | 8 113 | 114 | 115 | 116 | 117 | org.apache.maven.plugins 118 | maven-source-plugin 119 | 3.2.0 120 | 121 | 122 | attach-sources 123 | 124 | jar-no-fork 125 | 126 | 127 | 128 | 129 | 130 | org.apache.maven.plugins 131 | maven-javadoc-plugin 132 | 3.1.1 133 | 134 | 135 | attach-javadocs 136 | 137 | jar 138 | 139 | 140 | 141 | 142 | 143 | org.sonatype.plugins 144 | nexus-staging-maven-plugin 145 | 1.6.7 146 | true 147 | 148 | ossrh 149 | https://oss.sonatype.org/ 150 | true 151 | 152 | 153 | 154 | org.apache.maven.plugins 155 | maven-gpg-plugin 156 | 1.6 157 | 158 | 159 | sign-artifacts 160 | verify 161 | 162 | sign 163 | 164 | 165 | 166 | 167 | 168 | 169 | org.jacoco 170 | jacoco-maven-plugin 171 | 0.8.5 172 | 173 | 174 | 175 | prepare-agent 176 | 177 | 178 | 179 | 180 | report 181 | test 182 | 183 | report 184 | 185 | 186 | 187 | 188 | 189 | org.eluder.coveralls 190 | coveralls-maven-plugin 191 | 4.3.0 192 | 193 | 194 | 195 | 196 | -------------------------------------------------------------------------------- /totp-spring-boot-starter/README.md: -------------------------------------------------------------------------------- 1 | # Using Java-TOTP with Spring Boot 2 | 3 | 4 | 5 | ## Installation 6 | 7 | To get started using the library in a Spring Boot project, add the `totp-spring-boot-starter` dependency: 8 | 9 | #### Maven 10 | 11 | ```xml 12 | 13 | dev.samstevens.totp 14 | totp-spring-boot-starter 15 | 1.7.1 16 | 17 | ``` 18 | 19 | #### Gradle 20 | 21 | ``` 22 | dependencies { 23 | compile 'dev.samstevens.totp:totp-spring-boot-starter:1.7.1' 24 | } 25 | ``` 26 | 27 | 28 | 29 | ## Usage 30 | 31 | #### Generating QR codes 32 | 33 | ```java 34 | @Controller 35 | public class MfaSetupController { 36 | 37 | @Autowired 38 | private SecretGenerator secretGenerator; 39 | 40 | @Autowired 41 | private QrDataFactory qrDataFactory; 42 | 43 | @Autowired 44 | private QrGenerator qrGenerator; 45 | 46 | @GetMapping("/mfa/setup") 47 | public String setupDevice() throws QrGenerationException { 48 | // Generate and store the secret 49 | String secret = secretGenerator.generate(); 50 | 51 | QrData data = qrDataFactory.newBuilder() 52 | .label("example@example.com") 53 | .secret(secret) 54 | .issuer("AppName") 55 | .build(); 56 | 57 | // Generate the QR code image data as a base64 string which 58 | // can be used in an tag: 59 | String qrCodeImage = getDataUriForImage( 60 | qrGenerator.generate(data), 61 | qrGenerator.getImageMimeType() 62 | ); 63 | ... 64 | } 65 | } 66 | ``` 67 | 68 | 69 | 70 | #### Verifying a code 71 | 72 | To verify a code that is submitted by a user, inject the `CodeVerifier` service and call `isValidCode`: 73 | 74 | 75 | ```java 76 | @Controller 77 | public class MfaVerifyController { 78 | @Autowired 79 | private CodeVerifier verifier; 80 | 81 | @PostMapping("/mfa/verify") 82 | @ResponseBody 83 | public String verify(@RequestParam String code) { 84 | // secret is fetched from some storage 85 | 86 | if (verifier.isValidCode(secret, code)) { 87 | return "CORRECT CODE"; 88 | } 89 | 90 | return "INCORRECT CODE"; 91 | } 92 | } 93 | ``` 94 | 95 | 96 | 97 | #### Generating recovery codes 98 | 99 | To generate recovery codes, use the `RecoveryCodeGenerator` service: 100 | 101 | ```java 102 | Controller 103 | public class MfaRecoveryCodesController { 104 | @Autowired 105 | private RecoveryCodeGenerator recoveryCodeGenerator; 106 | 107 | @GetMapping("/mfa/recovery-codes") 108 | public String recoveryCodes() { 109 | String[] codes = recoveryCodeGenerator.generateCodes(16); 110 | ... 111 | } 112 | } 113 | ``` 114 | 115 | 116 | 117 | 118 | 119 | 120 | ## Configuring 121 | 122 | Configuring the various options that are available with the library can be achieved by setting application properties or defining beans. 123 | 124 | 125 | 126 | ##### Secret Length 127 | 128 | Set the `totp.secret.length` property to the desired number of characters in `application.properties`: 129 | 130 | ``` 131 | totp.secret.length=128 132 | ``` 133 | 134 | 135 | 136 | ##### Code Length 137 | 138 | Set the `totp.code.length` property to the desired number of characters in `application.properties`: 139 | 140 | ``` 141 | totp.code.length=8 142 | ``` 143 | 144 | 145 | 146 | ##### Time Period 147 | 148 | Set the `totp.time.period` property to the desired number of characters in `application.properties`: 149 | 150 | ``` 151 | totp.time.period=15 152 | ``` 153 | 154 | 155 | 156 | ##### Time Discrepancy 157 | 158 | Set the `totp.time.discrepancy` property to the desired number of characters in `application.properties`: 159 | 160 | ``` 161 | totp.time.discrepancy=2 162 | ``` 163 | 164 | 165 | 166 | ##### Hashing Algorithm 167 | 168 | The default hashing algorithm is SHA1. To change it to another algorithm, define a `HashingAlgorithm` bean which returns the desired algorithm: 169 | 170 | ```java 171 | @Configuration 172 | public class AppConfig { 173 | @Bean 174 | public HashingAlgorithm hashingAlgorithm() { 175 | return HashingAlgorithm.SHA256; 176 | } 177 | } 178 | ``` 179 | 180 | 181 | 182 | ##### Time Provider 183 | 184 | The default time provider uses the system time to fetch the time. To change this, define a `TimeProvider` bean that returns a `TimeProvider` instance. 185 | 186 | ```java 187 | @Configuration 188 | public class AppConfig { 189 | @Bean 190 | public TimeProvider timeProvider() { 191 | return new NtpTimeProvider("pool.ntp.org"); 192 | } 193 | } 194 | ``` 195 | -------------------------------------------------------------------------------- /totp-spring-boot-starter/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | 7 | dev.samstevens.totp 8 | totp-parent 9 | 1.7.1 10 | 11 | 12 | ${project.groupId}:${project.artifactId} 13 | totp-spring-boot-starter 14 | 15 | A Spring Boot starter to autoconfigure the Java-TOTP library. 16 | 17 | 18 | 19 | ${project.groupId} 20 | totp 21 | 1.7.1 22 | 23 | 24 | org.springframework.boot 25 | spring-boot-autoconfigure 26 | 2.2.5.RELEASE 27 | 28 | 29 | org.springframework.boot 30 | spring-boot-configuration-processor 31 | 2.2.5.RELEASE 32 | true 33 | 34 | 35 | 36 | 37 | 38 | org.apache.maven.plugins 39 | maven-compiler-plugin 40 | 3.8.0 41 | 42 | 43 | 44 | org.springframework.boot 45 | spring-boot-configuration-processor 46 | 2.2.5.RELEASE 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /totp-spring-boot-starter/src/main/java/dev/samstevens/totp/spring/autoconfigure/TotpAutoConfiguration.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.spring.autoconfigure; 2 | 3 | import dev.samstevens.totp.TotpInfo; 4 | import dev.samstevens.totp.code.*; 5 | import dev.samstevens.totp.qr.QrDataFactory; 6 | import dev.samstevens.totp.qr.QrGenerator; 7 | import dev.samstevens.totp.qr.ZxingPngQrGenerator; 8 | import dev.samstevens.totp.recovery.RecoveryCodeGenerator; 9 | import dev.samstevens.totp.secret.DefaultSecretGenerator; 10 | import dev.samstevens.totp.secret.SecretGenerator; 11 | import dev.samstevens.totp.time.SystemTimeProvider; 12 | import dev.samstevens.totp.time.TimeProvider; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; 15 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 16 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 17 | import org.springframework.context.annotation.Bean; 18 | import org.springframework.context.annotation.Configuration; 19 | 20 | @Configuration 21 | @ConditionalOnClass(TotpInfo.class) 22 | @EnableConfigurationProperties(TotpProperties.class) 23 | public class TotpAutoConfiguration { 24 | 25 | private TotpProperties props; 26 | 27 | @Autowired 28 | public TotpAutoConfiguration(TotpProperties props) { 29 | this.props = props; 30 | } 31 | 32 | @Bean 33 | @ConditionalOnMissingBean 34 | public SecretGenerator secretGenerator() { 35 | int length = props.getSecret().getLength(); 36 | return new DefaultSecretGenerator(length); 37 | } 38 | 39 | @Bean 40 | @ConditionalOnMissingBean 41 | public TimeProvider timeProvider() { 42 | return new SystemTimeProvider(); 43 | } 44 | 45 | @Bean 46 | @ConditionalOnMissingBean 47 | public HashingAlgorithm hashingAlgorithm() { 48 | return HashingAlgorithm.SHA1; 49 | } 50 | 51 | @Bean 52 | @ConditionalOnMissingBean 53 | public QrDataFactory qrDataFactory(HashingAlgorithm hashingAlgorithm) { 54 | return new QrDataFactory(hashingAlgorithm, getCodeLength(), getTimePeriod()); 55 | } 56 | 57 | @Bean 58 | @ConditionalOnMissingBean 59 | public QrGenerator qrGenerator() { 60 | return new ZxingPngQrGenerator(); 61 | } 62 | 63 | @Bean 64 | @ConditionalOnMissingBean 65 | public CodeGenerator codeGenerator(HashingAlgorithm algorithm) { 66 | return new DefaultCodeGenerator(algorithm, getCodeLength()); 67 | } 68 | 69 | @Bean 70 | @ConditionalOnMissingBean 71 | public CodeVerifier codeVerifier(CodeGenerator codeGenerator, TimeProvider timeProvider) { 72 | DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); 73 | verifier.setTimePeriod(getTimePeriod()); 74 | verifier.setAllowedTimePeriodDiscrepancy(props.getTime().getDiscrepancy()); 75 | 76 | return verifier; 77 | } 78 | 79 | @Bean 80 | @ConditionalOnMissingBean 81 | public RecoveryCodeGenerator recoveryCodeGenerator() { 82 | return new RecoveryCodeGenerator(); 83 | } 84 | 85 | private int getCodeLength() { 86 | return props.getCode().getLength(); 87 | } 88 | 89 | private int getTimePeriod() { 90 | return props.getTime().getPeriod(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /totp-spring-boot-starter/src/main/java/dev/samstevens/totp/spring/autoconfigure/TotpProperties.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.spring.autoconfigure; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | 5 | @ConfigurationProperties(prefix = "totp") 6 | public class TotpProperties { 7 | 8 | private static final int DEFAULT_SECRET_LENGTH = 32; 9 | private static final int DEFAULT_CODE_LENGTH = 6; 10 | private static final int DEFAULT_TIME_PERIOD = 30; 11 | private static final int DEFAULT_TIME_DISCREPANCY = 1; 12 | 13 | private final Secret secret = new Secret(); 14 | private final Code code = new Code(); 15 | private final Time time = new Time(); 16 | 17 | public Secret getSecret() { 18 | return secret; 19 | } 20 | 21 | public Code getCode() { 22 | return code; 23 | } 24 | 25 | public Time getTime() { 26 | return time; 27 | } 28 | 29 | public static class Secret { 30 | private int length = DEFAULT_SECRET_LENGTH; 31 | 32 | public int getLength() { 33 | return length; 34 | } 35 | 36 | public void setLength(int length) { 37 | this.length = length; 38 | } 39 | } 40 | 41 | public static class Code { 42 | private int length = DEFAULT_CODE_LENGTH; 43 | 44 | public int getLength() { 45 | return length; 46 | } 47 | 48 | public void setLength(int length) { 49 | this.length = length; 50 | } 51 | } 52 | 53 | public static class Time { 54 | private int period = DEFAULT_TIME_PERIOD; 55 | private int discrepancy = DEFAULT_TIME_DISCREPANCY; 56 | 57 | public int getPeriod() { 58 | return period; 59 | } 60 | 61 | public void setPeriod(int period) { 62 | this.period = period; 63 | } 64 | 65 | public int getDiscrepancy() { 66 | return discrepancy; 67 | } 68 | 69 | public void setDiscrepancy(int discrepancy) { 70 | this.discrepancy = discrepancy; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /totp-spring-boot-starter/src/main/resources/META-INF/spring.factories: -------------------------------------------------------------------------------- 1 | org.springframework.boot.autoconfigure.EnableAutoConfiguration=dev.samstevens.totp.spring.autoconfigure.TotpAutoConfiguration -------------------------------------------------------------------------------- /totp/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | 6 | dev.samstevens.totp 7 | totp-parent 8 | 1.7.1 9 | 10 | 11 | totp 12 | ${project.groupId}:${project.artifactId} 13 | 14 | A library to help implement time-based one time passwords to enable MFA. 15 | 16 | 17 | 18 | org.junit.jupiter 19 | junit-jupiter-api 20 | 5.6.0 21 | test 22 | 23 | 24 | org.junit.jupiter 25 | junit-jupiter-engine 26 | 5.6.0 27 | test 28 | 29 | 30 | org.junit.jupiter 31 | junit-jupiter-params 32 | 5.6.0 33 | test 34 | 35 | 36 | org.mockito 37 | mockito-core 38 | 3.2.4 39 | test 40 | 41 | 42 | commons-codec 43 | commons-codec 44 | 1.13 45 | 46 | 47 | commons-net 48 | commons-net 49 | 3.6 50 | true 51 | 52 | 53 | com.google.zxing 54 | core 55 | 3.4.0 56 | 57 | 58 | com.google.zxing 59 | javase 60 | 3.4.0 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/TotpInfo.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp; 2 | 3 | public class TotpInfo { 4 | public static String VERSION = "1.7.1"; 5 | } 6 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/code/CodeGenerator.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.code; 2 | 3 | import dev.samstevens.totp.exceptions.CodeGenerationException; 4 | 5 | public interface CodeGenerator { 6 | /** 7 | * @param secret The shared secret/key to generate the code with. 8 | * @param counter The current time bucket number. Number of seconds since epoch / bucket period. 9 | * @return The n-digit code for the secret/counter. 10 | * @throws CodeGenerationException Thrown if the code generation fails for any reason. 11 | */ 12 | String generate(String secret, long counter) throws CodeGenerationException; 13 | } 14 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/code/CodeVerifier.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.code; 2 | 3 | public interface CodeVerifier { 4 | /** 5 | * @param secret The shared secret/key to check the code against. 6 | * @param code The n-digit code given by the end user to check. 7 | * @return If the code is valid or not. 8 | */ 9 | boolean isValidCode(String secret, String code); 10 | } 11 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/code/DefaultCodeGenerator.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.code; 2 | 3 | import dev.samstevens.totp.exceptions.CodeGenerationException; 4 | import org.apache.commons.codec.binary.Base32; 5 | import javax.crypto.Mac; 6 | import javax.crypto.spec.SecretKeySpec; 7 | import java.security.InvalidKeyException; 8 | import java.security.InvalidParameterException; 9 | import java.security.NoSuchAlgorithmException; 10 | 11 | public class DefaultCodeGenerator implements CodeGenerator { 12 | 13 | private final HashingAlgorithm algorithm; 14 | private final int digits; 15 | 16 | public DefaultCodeGenerator() { 17 | this(HashingAlgorithm.SHA1, 6); 18 | } 19 | 20 | public DefaultCodeGenerator(HashingAlgorithm algorithm) { 21 | this(algorithm, 6); 22 | } 23 | 24 | public DefaultCodeGenerator(HashingAlgorithm algorithm, int digits) { 25 | if (algorithm == null) { 26 | throw new InvalidParameterException("HashingAlgorithm must not be null."); 27 | } 28 | if (digits < 1) { 29 | throw new InvalidParameterException("Number of digits must be higher than 0."); 30 | } 31 | 32 | this.algorithm = algorithm; 33 | this.digits = digits; 34 | } 35 | 36 | @Override 37 | public String generate(String key, long counter) throws CodeGenerationException { 38 | try { 39 | byte[] hash = generateHash(key, counter); 40 | return getDigitsFromHash(hash); 41 | } catch (Exception e) { 42 | throw new CodeGenerationException("Failed to generate code. See nested exception.", e); 43 | } 44 | } 45 | 46 | /** 47 | * Generate a HMAC-SHA1 hash of the counter number. 48 | */ 49 | private byte[] generateHash(String key, long counter) throws InvalidKeyException, NoSuchAlgorithmException { 50 | byte[] data = new byte[8]; 51 | long value = counter; 52 | for (int i = 8; i-- > 0; value >>>= 8) { 53 | data[i] = (byte) value; 54 | } 55 | 56 | // Create a HMAC-SHA1 signing key from the shared key 57 | Base32 codec = new Base32(); 58 | byte[] decodedKey = codec.decode(key); 59 | SecretKeySpec signKey = new SecretKeySpec(decodedKey, algorithm.getHmacAlgorithm()); 60 | Mac mac = Mac.getInstance(algorithm.getHmacAlgorithm()); 61 | mac.init(signKey); 62 | 63 | // Create a hash of the counter value 64 | return mac.doFinal(data); 65 | } 66 | 67 | /** 68 | * Get the n-digit code for a given hash. 69 | */ 70 | private String getDigitsFromHash(byte[] hash) { 71 | int offset = hash[hash.length - 1] & 0xF; 72 | 73 | long truncatedHash = 0; 74 | 75 | for (int i = 0; i < 4; ++i) { 76 | truncatedHash <<= 8; 77 | truncatedHash |= (hash[offset + i] & 0xFF); 78 | } 79 | 80 | truncatedHash &= 0x7FFFFFFF; 81 | truncatedHash %= Math.pow(10, digits); 82 | 83 | // Left pad with 0s for a n-digit code 84 | return String.format("%0" + digits + "d", truncatedHash); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/code/DefaultCodeVerifier.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.code; 2 | 3 | import dev.samstevens.totp.exceptions.CodeGenerationException; 4 | import dev.samstevens.totp.time.TimeProvider; 5 | 6 | public class DefaultCodeVerifier implements CodeVerifier { 7 | 8 | private final CodeGenerator codeGenerator; 9 | private final TimeProvider timeProvider; 10 | private int timePeriod = 30; 11 | private int allowedTimePeriodDiscrepancy = 1; 12 | 13 | public DefaultCodeVerifier(CodeGenerator codeGenerator, TimeProvider timeProvider) { 14 | this.codeGenerator = codeGenerator; 15 | this.timeProvider = timeProvider; 16 | } 17 | 18 | public void setTimePeriod(int timePeriod) { 19 | this.timePeriod = timePeriod; 20 | } 21 | 22 | public void setAllowedTimePeriodDiscrepancy(int allowedTimePeriodDiscrepancy) { 23 | this.allowedTimePeriodDiscrepancy = allowedTimePeriodDiscrepancy; 24 | } 25 | 26 | @Override 27 | public boolean isValidCode(String secret, String code) { 28 | // Get the current number of seconds since the epoch and 29 | // calculate the number of time periods passed. 30 | long currentBucket = Math.floorDiv(timeProvider.getTime(), timePeriod); 31 | 32 | // Calculate and compare the codes for all the "valid" time periods, 33 | // even if we get an early match, to avoid timing attacks 34 | boolean success = false; 35 | for (int i = -allowedTimePeriodDiscrepancy; i <= allowedTimePeriodDiscrepancy; i++) { 36 | success = checkCode(secret, currentBucket + i, code) || success; 37 | } 38 | 39 | return success; 40 | } 41 | 42 | /** 43 | * Check if a code matches for a given secret and counter. 44 | */ 45 | private boolean checkCode(String secret, long counter, String code) { 46 | try { 47 | String actualCode = codeGenerator.generate(secret, counter); 48 | return timeSafeStringComparison(actualCode, code); 49 | } catch (CodeGenerationException e) { 50 | return false; 51 | } 52 | } 53 | 54 | /** 55 | * Compare two strings for equality without leaking timing information. 56 | */ 57 | private boolean timeSafeStringComparison(String a, String b) { 58 | byte[] aBytes = a.getBytes(); 59 | byte[] bBytes = b.getBytes(); 60 | 61 | if (aBytes.length != bBytes.length) { 62 | return false; 63 | } 64 | 65 | int result = 0; 66 | for (int i = 0; i < aBytes.length; i++) { 67 | result |= aBytes[i] ^ bBytes[i]; 68 | } 69 | 70 | return result == 0; 71 | } 72 | } -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/code/HashingAlgorithm.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.code; 2 | 3 | public enum HashingAlgorithm { 4 | 5 | SHA1("HmacSHA1", "SHA1"), 6 | SHA256("HmacSHA256", "SHA256"), 7 | SHA512("HmacSHA512", "SHA512"); 8 | 9 | private final String hmacAlgorithm; 10 | private final String friendlyName; 11 | 12 | HashingAlgorithm(String hmacAlgorithm, String friendlyName) { 13 | this.hmacAlgorithm = hmacAlgorithm; 14 | this.friendlyName = friendlyName; 15 | } 16 | 17 | public String getHmacAlgorithm() { 18 | return hmacAlgorithm; 19 | } 20 | 21 | public String getFriendlyName() { 22 | return friendlyName; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/exceptions/CodeGenerationException.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.exceptions; 2 | 3 | public class CodeGenerationException extends Exception { 4 | public CodeGenerationException(String message, Throwable cause) { 5 | super(message, cause); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/exceptions/QrGenerationException.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.exceptions; 2 | 3 | public class QrGenerationException extends Exception { 4 | public QrGenerationException(String message, Throwable cause) { 5 | super(message, cause); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/exceptions/TimeProviderException.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.exceptions; 2 | 3 | public class TimeProviderException extends RuntimeException { 4 | public TimeProviderException(String message) { 5 | super(message); 6 | } 7 | public TimeProviderException(String message, Throwable cause) { 8 | super(message, cause); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/qr/QrData.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.qr; 2 | 3 | import dev.samstevens.totp.code.HashingAlgorithm; 4 | 5 | import java.io.UnsupportedEncodingException; 6 | import java.net.URLEncoder; 7 | import java.nio.charset.StandardCharsets; 8 | 9 | @SuppressWarnings("WeakerAccess") 10 | public class QrData { 11 | 12 | private final String type; 13 | private final String label; 14 | private final String secret; 15 | private final String issuer; 16 | private final String algorithm; 17 | private final int digits; 18 | private final int period; 19 | 20 | /** 21 | * Force use of builder to create instances. 22 | */ 23 | private QrData(String type, String label, String secret, String issuer, String algorithm, int digits, int period) { 24 | this.type = type; 25 | this.label = label; 26 | this.secret = secret; 27 | this.issuer = issuer; 28 | this.algorithm = algorithm; 29 | this.digits = digits; 30 | this.period = period; 31 | } 32 | 33 | public String getType() { 34 | return type; 35 | } 36 | 37 | public String getLabel() { 38 | return label; 39 | } 40 | 41 | public String getSecret() { 42 | return secret; 43 | } 44 | 45 | public String getIssuer() { 46 | return issuer; 47 | } 48 | 49 | public String getAlgorithm() { 50 | return algorithm; 51 | } 52 | 53 | public int getDigits() { 54 | return digits; 55 | } 56 | 57 | public int getPeriod() { 58 | return period; 59 | } 60 | 61 | /** 62 | * @return The URI/message to encode into the QR image, in the format specified here: 63 | * https://github.com/google/google-authenticator/wiki/Key-Uri-Format 64 | */ 65 | public String getUri() { 66 | return "otpauth://" + 67 | uriEncode(type) + "/" + 68 | uriEncode(label) + "?" + 69 | "secret=" + uriEncode(secret) + 70 | "&issuer=" + uriEncode(issuer) + 71 | "&algorithm=" + uriEncode(algorithm) + 72 | "&digits=" + digits + 73 | "&period=" + period; 74 | } 75 | 76 | private String uriEncode(String text) { 77 | // Null check 78 | if (text == null) { 79 | return ""; 80 | } 81 | 82 | try { 83 | return URLEncoder.encode(text, StandardCharsets.UTF_8.toString()).replaceAll("\\+", "%20"); 84 | } catch (UnsupportedEncodingException e) { 85 | // This should never throw, as we are certain the charset specified (UTF-8) is valid 86 | throw new RuntimeException("Could not URI encode QrData."); 87 | } 88 | } 89 | 90 | public static class Builder { 91 | private String label; 92 | private String secret; 93 | private String issuer; 94 | private HashingAlgorithm algorithm = HashingAlgorithm.SHA1; 95 | private int digits = 6; 96 | private int period = 30; 97 | 98 | public Builder label(String label) { 99 | this.label = label; 100 | return this; 101 | } 102 | 103 | public Builder secret(String secret) { 104 | this.secret = secret; 105 | return this; 106 | } 107 | 108 | public Builder issuer(String issuer) { 109 | this.issuer = issuer; 110 | return this; 111 | } 112 | 113 | public Builder algorithm(HashingAlgorithm algorithm) { 114 | this.algorithm = algorithm; 115 | return this; 116 | } 117 | 118 | public Builder digits(int digits) { 119 | this.digits = digits; 120 | return this; 121 | } 122 | 123 | public Builder period(int period) { 124 | this.period = period; 125 | return this; 126 | } 127 | 128 | public QrData build() { 129 | return new QrData("totp", label, secret, issuer, algorithm.getFriendlyName(), digits, period); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/qr/QrDataFactory.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.qr; 2 | 3 | import dev.samstevens.totp.code.HashingAlgorithm; 4 | 5 | public class QrDataFactory { 6 | 7 | private HashingAlgorithm defaultAlgorithm; 8 | private int defaultDigits; 9 | private int defaultTimePeriod; 10 | 11 | public QrDataFactory(HashingAlgorithm defaultAlgorithm, int defaultDigits, int defaultTimePeriod) { 12 | this.defaultAlgorithm = defaultAlgorithm; 13 | this.defaultDigits = defaultDigits; 14 | this.defaultTimePeriod = defaultTimePeriod; 15 | } 16 | 17 | public QrData.Builder newBuilder() { 18 | return new QrData.Builder() 19 | .algorithm(defaultAlgorithm) 20 | .digits(defaultDigits) 21 | .period(defaultTimePeriod); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/qr/QrGenerator.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.qr; 2 | 3 | import dev.samstevens.totp.exceptions.QrGenerationException; 4 | 5 | public interface QrGenerator { 6 | /** 7 | * @return The mime type of the image that the generator generates, e.g. image/png 8 | */ 9 | String getImageMimeType(); 10 | 11 | /** 12 | * @param data The QrData object to encode in the generated image. 13 | * @return The raw image data as a byte array. 14 | * @throws QrGenerationException thrown if image generation fails for any reason. 15 | */ 16 | byte[] generate(QrData data) throws QrGenerationException; 17 | } -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/qr/ZxingPngQrGenerator.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.qr; 2 | 3 | import com.google.zxing.BarcodeFormat; 4 | import com.google.zxing.Writer; 5 | import com.google.zxing.client.j2se.MatrixToImageWriter; 6 | import com.google.zxing.common.BitMatrix; 7 | import com.google.zxing.qrcode.QRCodeWriter; 8 | import dev.samstevens.totp.exceptions.QrGenerationException; 9 | import java.io.ByteArrayOutputStream; 10 | 11 | public class ZxingPngQrGenerator implements QrGenerator { 12 | 13 | private final Writer writer; 14 | private int imageSize = 350; 15 | 16 | public ZxingPngQrGenerator() { 17 | this(new QRCodeWriter()); 18 | } 19 | 20 | public ZxingPngQrGenerator(Writer writer) { 21 | this.writer = writer; 22 | } 23 | 24 | public void setImageSize(int imageSize) { 25 | this.imageSize = imageSize; 26 | } 27 | 28 | public int getImageSize() { 29 | return imageSize; 30 | } 31 | 32 | public String getImageMimeType() { 33 | return "image/png"; 34 | } 35 | 36 | @Override 37 | public byte[] generate(QrData data) throws QrGenerationException { 38 | try { 39 | BitMatrix bitMatrix = writer.encode(data.getUri(), BarcodeFormat.QR_CODE, imageSize, imageSize); 40 | ByteArrayOutputStream pngOutputStream = new ByteArrayOutputStream(); 41 | MatrixToImageWriter.writeToStream(bitMatrix, "PNG", pngOutputStream); 42 | 43 | return pngOutputStream.toByteArray(); 44 | } catch (Exception e) { 45 | throw new QrGenerationException("Failed to generate QR code. See nested exception.", e); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/recovery/RecoveryCodeGenerator.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.recovery; 2 | 3 | import java.security.InvalidParameterException; 4 | import java.security.SecureRandom; 5 | import java.util.Arrays; 6 | import java.util.Random; 7 | 8 | public class RecoveryCodeGenerator { 9 | 10 | // Recovery code must reach a minimum entropy to be secured 11 | // code entropy = log( {characters-count} ^ {code-length} ) / log(2) 12 | // the settings used below allows the code to reach an entropy of 82 bits : 13 | // log(36^16) / log(2) == 82.7... 14 | 15 | // Recovery code must be simple to read and enter by end user when needed : 16 | // - generate a code composed of numbers and lower case characters from latin alphabet (36 possible characters) 17 | // - split code in groups separated with dash for better readability, for example 4ckn-xspn-et8t-xgr0 18 | private static final char[] CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789".toCharArray(); 19 | private static final int CODE_LENGTH = 16; 20 | private static final int GROUPS_NBR = 4; 21 | 22 | private Random random = new SecureRandom(); 23 | 24 | public String[] generateCodes(int amount) { 25 | // Must generate at least one code 26 | if (amount < 1) { 27 | throw new InvalidParameterException("Amount must be at least 1."); 28 | } 29 | 30 | // Create an array and fill with generated codes 31 | String[] codes = new String[amount]; 32 | Arrays.setAll(codes, i -> generateCode()); 33 | 34 | return codes; 35 | } 36 | 37 | private String generateCode() { 38 | final StringBuilder code = new StringBuilder(CODE_LENGTH + (CODE_LENGTH/GROUPS_NBR) - 1); 39 | 40 | for (int i = 0; i < CODE_LENGTH; i++) { 41 | // Append random character from authorized ones 42 | code.append(CHARACTERS[random.nextInt(CHARACTERS.length)]); 43 | 44 | // Split code into groups for increased readability 45 | if ((i+1) % GROUPS_NBR == 0 && (i+1) != CODE_LENGTH) { 46 | code.append("-"); 47 | } 48 | } 49 | 50 | return code.toString(); 51 | } 52 | 53 | } -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/secret/DefaultSecretGenerator.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.secret; 2 | 3 | import org.apache.commons.codec.binary.Base32; 4 | import java.security.SecureRandom; 5 | 6 | @SuppressWarnings("WeakerAccess") 7 | public class DefaultSecretGenerator implements SecretGenerator { 8 | 9 | private final SecureRandom randomBytes = new SecureRandom(); 10 | private final static Base32 encoder = new Base32(); 11 | private final int numCharacters; 12 | 13 | public DefaultSecretGenerator() { 14 | this.numCharacters = 32; 15 | } 16 | 17 | /** 18 | * @param numCharacters The number of characters the secret should consist of. 19 | */ 20 | public DefaultSecretGenerator(int numCharacters) { 21 | this.numCharacters = numCharacters; 22 | } 23 | 24 | @Override 25 | public String generate() { 26 | return new String(encoder.encode(getRandomBytes())); 27 | } 28 | 29 | private byte[] getRandomBytes() { 30 | // 5 bits per char in base32 31 | byte[] bytes = new byte[(numCharacters * 5) / 8]; 32 | randomBytes.nextBytes(bytes); 33 | 34 | return bytes; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/secret/SecretGenerator.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.secret; 2 | 3 | public interface SecretGenerator { 4 | /** 5 | * @return A random base32 encoded string to use as the shared secret/key between the server and the client. 6 | */ 7 | String generate(); 8 | } 9 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/time/NtpTimeProvider.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.time; 2 | 3 | import dev.samstevens.totp.exceptions.TimeProviderException; 4 | import org.apache.commons.net.ntp.NTPUDPClient; 5 | import org.apache.commons.net.ntp.TimeInfo; 6 | import java.net.InetAddress; 7 | import java.net.UnknownHostException; 8 | 9 | public class NtpTimeProvider implements TimeProvider { 10 | 11 | private final NTPUDPClient client; 12 | private final InetAddress ntpHost; 13 | 14 | public NtpTimeProvider(String ntpHostname) throws UnknownHostException { 15 | // default timeout of 3 seconds 16 | this(ntpHostname, 3000); 17 | } 18 | 19 | public NtpTimeProvider(String ntpHostname, int timeout) throws UnknownHostException { 20 | this(ntpHostname, timeout, "org.apache.commons.net.ntp.NTPUDPClient"); 21 | } 22 | 23 | // Package-private, for tests only 24 | NtpTimeProvider(String ntpHostname, String dependentClass) throws UnknownHostException { 25 | // default timeout of 3 seconds 26 | this(ntpHostname, 3000, dependentClass); 27 | } 28 | 29 | private NtpTimeProvider(String ntpHostname, int timeout, String dependentClass) throws UnknownHostException { 30 | // Check the optional commons-net dependency is on the classpath 31 | checkHasDependency(dependentClass); 32 | 33 | client = new NTPUDPClient(); 34 | client.setDefaultTimeout(timeout); 35 | ntpHost = InetAddress.getByName(ntpHostname); 36 | } 37 | 38 | @Override 39 | public long getTime() throws TimeProviderException { 40 | TimeInfo timeInfo; 41 | try { 42 | timeInfo = client.getTime(ntpHost); 43 | timeInfo.computeDetails(); 44 | } catch (Exception e) { 45 | throw new TimeProviderException("Failed to provide time from NTP server. See nested exception.", e); 46 | } 47 | 48 | if (timeInfo.getOffset() == null) { 49 | throw new TimeProviderException("Failed to calculate NTP offset"); 50 | } 51 | 52 | return (System.currentTimeMillis() + timeInfo.getOffset()) / 1000; 53 | } 54 | 55 | private void checkHasDependency(String dependentClass) { 56 | try { 57 | Class ntpClientClass = Class.forName(dependentClass); 58 | } catch (ClassNotFoundException e) { 59 | throw new RuntimeException("The Apache Commons Net library must be on the classpath to use the NtpTimeProvider."); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/time/SystemTimeProvider.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.time; 2 | 3 | import dev.samstevens.totp.exceptions.TimeProviderException; 4 | import java.time.Instant; 5 | 6 | public class SystemTimeProvider implements TimeProvider { 7 | @Override 8 | public long getTime() throws TimeProviderException { 9 | return Instant.now().getEpochSecond(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/time/TimeProvider.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.time; 2 | 3 | import dev.samstevens.totp.exceptions.TimeProviderException; 4 | 5 | public interface TimeProvider { 6 | /** 7 | * @return The number of seconds since Jan 1st 1970, 00:00:00 UTC. 8 | */ 9 | long getTime() throws TimeProviderException; 10 | } 11 | -------------------------------------------------------------------------------- /totp/src/main/java/dev/samstevens/totp/util/Utils.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.util; 2 | 3 | import org.apache.commons.codec.binary.Base64; 4 | 5 | public class Utils { 6 | private static Base64 base64Codec = new Base64(); 7 | 8 | // Class not meant to be instantiated 9 | private Utils() { 10 | } 11 | 12 | /** 13 | * Given the raw data of an image and the mime type, returns 14 | * a data URI string representing the image for embedding in 15 | * HTML/CSS. 16 | * 17 | * @param data The raw bytes of the image. 18 | * @param mimeType The mime type of the image. 19 | * @return The data URI string representing the image. 20 | */ 21 | public static String getDataUriForImage(byte[] data, String mimeType) { 22 | String encodedData = new String(base64Codec.encode(data)); 23 | 24 | return String.format("data:%s;base64,%s", mimeType, encodedData); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /totp/src/test/java/dev/samstevens/totp/IOUtils.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp; 2 | 3 | import java.io.FileOutputStream; 4 | import java.io.IOException; 5 | 6 | public class IOUtils { 7 | 8 | /** 9 | * Helper method to write data to a file. 10 | */ 11 | public static void writeFile(byte[] contents, String filePath) throws IOException { 12 | try (FileOutputStream stream = new FileOutputStream(filePath)) { 13 | stream.write(contents); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /totp/src/test/java/dev/samstevens/totp/code/DefaultCodeGeneratorTest.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.code; 2 | 3 | import dev.samstevens.totp.exceptions.CodeGenerationException; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.params.ParameterizedTest; 6 | import org.junit.jupiter.params.provider.Arguments; 7 | import org.junit.jupiter.params.provider.MethodSource; 8 | import java.security.InvalidParameterException; 9 | import java.util.stream.Stream; 10 | import static org.junit.jupiter.api.Assertions.*; 11 | import static org.junit.jupiter.params.provider.Arguments.arguments; 12 | 13 | public class DefaultCodeGeneratorTest { 14 | 15 | @ParameterizedTest 16 | @MethodSource("expectedCodesProvider") 17 | public void testCodeIsGenerated(String secret, int time, HashingAlgorithm algorithm, String expectedCode) throws CodeGenerationException { 18 | String code = generateCode(algorithm, secret, time); 19 | 20 | assertEquals(expectedCode, code); 21 | } 22 | 23 | static Stream expectedCodesProvider() { 24 | return Stream.of( 25 | arguments("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 1567631536, HashingAlgorithm.SHA1, "082371"), 26 | arguments("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 1567631536, HashingAlgorithm.SHA256, "272978"), 27 | arguments("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 1567631536, HashingAlgorithm.SHA512, "325200"), 28 | 29 | arguments("makrzl2hict4ojeji2iah4kndmq6sgka", 1582750403, HashingAlgorithm.SHA1, "848586"), 30 | arguments("makrzl2hict4ojeji2iah4kndmq6sgka", 1582750403, HashingAlgorithm.SHA256, "965726"), 31 | arguments("makrzl2hict4ojeji2iah4kndmq6sgka", 1582750403, HashingAlgorithm.SHA512, "741306") 32 | ); 33 | } 34 | 35 | @Test 36 | public void testDigitLength() throws CodeGenerationException { 37 | DefaultCodeGenerator g = new DefaultCodeGenerator(HashingAlgorithm.SHA1); 38 | String code = g.generate("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 1567631536); 39 | assertEquals(6, code.length()); 40 | 41 | g = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 8); 42 | code = g.generate("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 1567631536); 43 | assertEquals(8, code.length()); 44 | 45 | g = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 4); 46 | code = g.generate("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 1567631536); 47 | assertEquals(4, code.length()); 48 | } 49 | 50 | @Test 51 | public void testInvalidHashingAlgorithmThrowsException() { 52 | assertThrows(InvalidParameterException.class, () -> { 53 | new DefaultCodeGenerator(null, 6); 54 | }); 55 | } 56 | 57 | @Test 58 | public void testInvalidDigitLengthThrowsException() { 59 | assertThrows(InvalidParameterException.class, () -> { 60 | new DefaultCodeGenerator(HashingAlgorithm.SHA1, 0); 61 | }); 62 | } 63 | 64 | @Test 65 | public void testInvalidKeyThrowsCodeGenerationException() throws CodeGenerationException { 66 | CodeGenerationException e = assertThrows(CodeGenerationException.class, () -> { 67 | DefaultCodeGenerator g = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 4); 68 | g.generate("1234", 1567631536); 69 | }); 70 | assertNotNull(e.getCause()); 71 | } 72 | 73 | private String generateCode(HashingAlgorithm algorithm, String secret, int time) throws CodeGenerationException { 74 | long currentBucket = Math.floorDiv(time, 30); 75 | DefaultCodeGenerator g = new DefaultCodeGenerator(algorithm); 76 | return g.generate(secret, currentBucket); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /totp/src/test/java/dev/samstevens/totp/code/DefaultCodeVerifierTest.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.code; 2 | 3 | import dev.samstevens.totp.exceptions.CodeGenerationException; 4 | import dev.samstevens.totp.time.TimeProvider; 5 | import org.junit.jupiter.api.Test; 6 | import static org.junit.jupiter.api.Assertions.*; 7 | import static org.mockito.Mockito.*; 8 | 9 | public class DefaultCodeVerifierTest { 10 | 11 | @Test 12 | public void testCodeIsValid() { 13 | String secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; 14 | long timeToRunAt = 1567975936; 15 | String correctCode = "862707"; 16 | int timePeriod = 30; 17 | 18 | // allow for a -/+ ~30 second discrepancy 19 | assertTrue(isValidCode(secret, correctCode, timeToRunAt - timePeriod, timePeriod)); 20 | assertTrue(isValidCode(secret, correctCode, timeToRunAt, timePeriod)); 21 | assertTrue(isValidCode(secret, correctCode, timeToRunAt + timePeriod, timePeriod)); 22 | 23 | // but no more 24 | assertFalse(isValidCode(secret, correctCode, timeToRunAt + timePeriod + 15, timePeriod)); 25 | 26 | // test wrong code fails 27 | assertFalse(isValidCode(secret, "123", timeToRunAt, timePeriod)); 28 | } 29 | 30 | @Test 31 | public void testCodeGenerationFailureReturnsFalse() throws CodeGenerationException { 32 | 33 | String secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; 34 | 35 | TimeProvider timeProvider = mock(TimeProvider.class); 36 | when(timeProvider.getTime()).thenReturn(1567975936L); 37 | 38 | CodeGenerator codeGenerator = mock(CodeGenerator.class); 39 | when(codeGenerator.generate(anyString(), anyLong())).thenThrow(new CodeGenerationException("Test", new RuntimeException())); 40 | 41 | DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); 42 | verifier.setAllowedTimePeriodDiscrepancy(1); 43 | 44 | 45 | assertEquals(false, verifier.isValidCode(secret, "1234")); 46 | } 47 | 48 | private boolean isValidCode(String secret, String code, long time, int timePeriod) { 49 | TimeProvider timeProvider = mock(TimeProvider.class); 50 | when(timeProvider.getTime()).thenReturn(time); 51 | 52 | DefaultCodeVerifier verifier = new DefaultCodeVerifier(new DefaultCodeGenerator(), timeProvider); 53 | verifier.setTimePeriod(timePeriod); 54 | 55 | return verifier.isValidCode(secret, code); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /totp/src/test/java/dev/samstevens/totp/qr/QrDataFactoryTest.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.qr; 2 | 3 | import dev.samstevens.totp.code.HashingAlgorithm; 4 | import org.junit.jupiter.api.Test; 5 | import static org.junit.jupiter.api.Assertions.*; 6 | 7 | public class QrDataFactoryTest { 8 | 9 | @Test 10 | public void testFactorySetsDefaultsOnBuilder() 11 | { 12 | QrDataFactory qrDataFactory = new QrDataFactory(HashingAlgorithm.SHA256, 6, 30); 13 | QrData data = qrDataFactory.newBuilder().build(); 14 | 15 | assertEquals(HashingAlgorithm.SHA256.getFriendlyName(), data.getAlgorithm()); 16 | assertEquals(6, data.getDigits()); 17 | assertEquals(30, data.getPeriod()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /totp/src/test/java/dev/samstevens/totp/qr/QrDataTest.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.qr; 2 | 3 | import dev.samstevens.totp.code.HashingAlgorithm; 4 | import org.junit.jupiter.api.Test; 5 | import static org.junit.jupiter.api.Assertions.*; 6 | 7 | public class QrDataTest { 8 | 9 | @Test 10 | public void testUriGeneration() { 11 | QrData data = new QrData.Builder() 12 | .label("example@example.com") 13 | .secret("the-secret-here") 14 | .issuer("AppName AppCorp") 15 | .algorithm(HashingAlgorithm.SHA256) 16 | .digits(6) 17 | .period(30) 18 | .build(); 19 | 20 | assertEquals( 21 | "otpauth://totp/example%40example.com?secret=the-secret-here&issuer=AppName%20AppCorp&algorithm=SHA256&digits=6&period=30", 22 | data.getUri() 23 | ); 24 | } 25 | 26 | @Test 27 | public void testNullFieldUriGeneration() { 28 | QrData data = new QrData.Builder() 29 | .label(null) 30 | .secret(null) 31 | .issuer("AppName AppCorp") 32 | .algorithm(HashingAlgorithm.SHA256) 33 | .digits(6) 34 | .period(30) 35 | .build(); 36 | 37 | assertEquals( 38 | "otpauth://totp/?secret=&issuer=AppName%20AppCorp&algorithm=SHA256&digits=6&period=30", 39 | data.getUri() 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /totp/src/test/java/dev/samstevens/totp/qr/ZxingPngQrGeneratorTest.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.qr; 2 | 3 | import com.google.zxing.Writer; 4 | import com.google.zxing.WriterException; 5 | import dev.samstevens.totp.exceptions.QrGenerationException; 6 | import org.junit.jupiter.api.Test; 7 | import javax.imageio.ImageIO; 8 | import java.awt.image.BufferedImage; 9 | import java.io.File; 10 | import java.io.IOException; 11 | import static dev.samstevens.totp.IOUtils.*; 12 | import static org.junit.jupiter.api.Assertions.*; 13 | import static org.mockito.ArgumentMatchers.*; 14 | import static org.mockito.Mockito.mock; 15 | import static org.mockito.Mockito.when; 16 | 17 | public class ZxingPngQrGeneratorTest { 18 | 19 | @Test 20 | public void testSomething() throws QrGenerationException, IOException { 21 | ZxingPngQrGenerator generator = new ZxingPngQrGenerator(); 22 | 23 | QrData data = new QrData.Builder() 24 | .label("example@example.com") 25 | .secret("EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB") 26 | .issuer("AppName") 27 | .digits(6) 28 | .period(30) 29 | .build(); 30 | 31 | writeFile(generator.generate(data), "./test_qr.png"); 32 | } 33 | 34 | @Test 35 | public void testMimeType() { 36 | assertEquals("image/png", new ZxingPngQrGenerator().getImageMimeType()); 37 | } 38 | 39 | @Test 40 | public void testImageSize() throws QrGenerationException, IOException { 41 | ZxingPngQrGenerator generator = new ZxingPngQrGenerator(); 42 | generator.setImageSize(500); 43 | byte[] data = generator.generate(getData()); 44 | 45 | // Write the data to a temp file and read it into a BufferedImage to get the dimensions 46 | String filename = "/tmp/test_qr.png"; 47 | writeFile(data, filename); 48 | File file = new File(filename); 49 | BufferedImage image = ImageIO.read(file); 50 | 51 | assertEquals(500, generator.getImageSize()); 52 | assertEquals(500, image.getWidth()); 53 | assertEquals(500, image.getHeight()); 54 | 55 | // Delete the temp file 56 | file.delete(); 57 | } 58 | 59 | @Test 60 | public void testExceptionIsWrapped() throws WriterException { 61 | Throwable exception = new RuntimeException(); 62 | Writer writer = mock(Writer.class); 63 | when(writer.encode(anyString(), any(), anyInt(), anyInt())).thenThrow(exception); 64 | 65 | ZxingPngQrGenerator generator = new ZxingPngQrGenerator(writer); 66 | 67 | QrGenerationException e = assertThrows(QrGenerationException.class, () -> { 68 | generator.generate(getData()); 69 | }); 70 | 71 | assertEquals("Failed to generate QR code. See nested exception.", e.getMessage()); 72 | assertEquals(exception, e.getCause()); 73 | } 74 | 75 | private QrData getData() { 76 | return new QrData.Builder() 77 | .label("example@example.com") 78 | .secret("EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB") 79 | .issuer("AppName") 80 | .digits(6) 81 | .period(30) 82 | .build(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /totp/src/test/java/dev/samstevens/totp/recovery/RecoveryCodeGeneratorTest.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.recovery; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import java.security.InvalidParameterException; 5 | import java.util.Arrays; 6 | import java.util.HashSet; 7 | import java.util.Set; 8 | import static org.junit.jupiter.api.Assertions.*; 9 | 10 | public class RecoveryCodeGeneratorTest { 11 | 12 | @Test 13 | public void testCorrectAmountGenerated() { 14 | RecoveryCodeGenerator generator = new RecoveryCodeGenerator(); 15 | String[] codes = generator.generateCodes(16); 16 | 17 | // Assert 16 non null codes generated 18 | assertEquals(16, codes.length); 19 | for (String code : codes) { 20 | assertNotNull(code); 21 | } 22 | } 23 | 24 | @Test 25 | public void testCodesMatchFormat() { 26 | RecoveryCodeGenerator generator = new RecoveryCodeGenerator(); 27 | String[] codes = generator.generateCodes(16); 28 | 29 | // Assert each one is the correct format 30 | for (String code : codes) { 31 | assertTrue(code.matches("[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}"), code); 32 | } 33 | } 34 | 35 | @Test 36 | public void testCodesAreUnique() { 37 | RecoveryCodeGenerator generator = new RecoveryCodeGenerator(); 38 | String[] codes = generator.generateCodes(25); 39 | 40 | Set uniqueCodes = new HashSet<>(Arrays.asList(codes)); 41 | 42 | assertEquals(25, uniqueCodes.size()); 43 | } 44 | 45 | @Test 46 | public void testInvalidNumberThrowsException() { 47 | RecoveryCodeGenerator generator = new RecoveryCodeGenerator(); 48 | 49 | assertThrows(InvalidParameterException.class, () -> { 50 | generator.generateCodes(-1); 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /totp/src/test/java/dev/samstevens/totp/secret/DefaultSecretGeneratorTest.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.secret; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import static org.junit.jupiter.api.Assertions.*; 5 | 6 | public class DefaultSecretGeneratorTest { 7 | 8 | @Test 9 | public void testSecretGenerated() { 10 | DefaultSecretGenerator generator = new DefaultSecretGenerator(); 11 | String secret = generator.generate(); 12 | assertNotNull(secret); 13 | assertTrue(secret.length() > 0); 14 | } 15 | 16 | @Test 17 | public void testCharacterLengths() { 18 | for (int charCount : new int[]{16, 32, 64, 128}) { 19 | DefaultSecretGenerator generator = new DefaultSecretGenerator(charCount); 20 | String secret = generator.generate(); 21 | assertEquals(charCount, secret.length()); 22 | } 23 | } 24 | 25 | @Test 26 | public void testValidBase32Encoded() { 27 | DefaultSecretGenerator generator = new DefaultSecretGenerator(); 28 | String secret = generator.generate(); 29 | 30 | // Test the string contains only A-Z, 2-7 with optional ending =s 31 | assertTrue(secret.matches("^[A-Z2-7]+=*$")); 32 | 33 | // And the length must be a multiple of 8 34 | assertEquals(0, secret.length() % 8); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /totp/src/test/java/dev/samstevens/totp/time/NtpTimeProviderTest.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.time; 2 | 3 | import dev.samstevens.totp.exceptions.TimeProviderException; 4 | import org.junit.jupiter.api.Test; 5 | import java.net.UnknownHostException; 6 | import static org.junit.jupiter.api.Assertions.*; 7 | 8 | public class NtpTimeProviderTest { 9 | 10 | @Test 11 | public void testProvidesTime() throws UnknownHostException { 12 | TimeProvider time = new NtpTimeProvider("pool.ntp.org"); 13 | long currentTime = time.getTime(); 14 | 15 | // epoch should be 10 digits for the foreseeable future... 16 | assertEquals(10, String.valueOf(currentTime).length()); 17 | } 18 | 19 | @Test 20 | public void testUnknownHostThrowsException() { 21 | assertThrows(UnknownHostException.class, () -> { 22 | new NtpTimeProvider("sdfsf/safsf"); 23 | }); 24 | } 25 | 26 | @Test 27 | public void testNonNtpHostThrowsException() throws UnknownHostException { 28 | TimeProvider time = new NtpTimeProvider("www.example.com"); 29 | 30 | TimeProviderException e = assertThrows(TimeProviderException.class, time::getTime); 31 | assertNotNull(e.getCause()); 32 | } 33 | 34 | @Test 35 | public void testRequiresDependency() { 36 | RuntimeException e = assertThrows(RuntimeException.class, () -> { 37 | // Use package-private constructor to test depending on a non-existing "fake.class.Here" class 38 | new NtpTimeProvider("www.example.com", "fake.class.Here"); 39 | }); 40 | 41 | assertEquals("The Apache Commons Net library must be on the classpath to use the NtpTimeProvider.", e.getMessage()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /totp/src/test/java/dev/samstevens/totp/time/SystemTimeProviderTest.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.time; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import java.time.Instant; 5 | import static org.junit.jupiter.api.Assertions.*; 6 | 7 | public class SystemTimeProviderTest { 8 | 9 | @Test 10 | public void testProvidesTime() 11 | { 12 | long currentTime = Instant.now().getEpochSecond(); 13 | TimeProvider time = new SystemTimeProvider(); 14 | long providedTime = time.getTime(); 15 | 16 | // allow +=5 second discrepancy for test environments 17 | assertTrue(currentTime - 5 <= providedTime); 18 | assertTrue(providedTime <= currentTime + 5); 19 | 20 | // epoch should be 10 digits for the foreseeable future... 21 | assertEquals(10, String.valueOf(currentTime).length()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /totp/src/test/java/dev/samstevens/totp/util/DataUriEncodingTest.java: -------------------------------------------------------------------------------- 1 | package dev.samstevens.totp.util; 2 | 3 | import static dev.samstevens.totp.util.Utils.getDataUriForImage; 4 | import static org.junit.jupiter.api.Assertions.assertEquals; 5 | import java.util.Base64; 6 | import org.junit.jupiter.api.Test; 7 | 8 | public class DataUriEncodingTest { 9 | 10 | @Test 11 | public void testDataUriEncode() { 12 | 13 | // Source data : 14 | // 1×1 white px PNG image base64 encoded : 15 | final String pngImage = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="; 16 | final byte[] imageData = Base64.getDecoder().decode(pngImage); 17 | 18 | assertEquals("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=", 19 | getDataUriForImage(imageData, "image/png")); 20 | } 21 | } 22 | --------------------------------------------------------------------------------