├── .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 | [](https://circleci.com/gh/samdjstevens/java-totp/tree/master) [](https://coveralls.io/github/samdjstevens/java-totp) [](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 |
--------------------------------------------------------------------------------