diff --git a/core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.java b/core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.java index 6365bdb5f1d..a19c4d452d7 100644 --- a/core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.java +++ b/core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.java @@ -36,6 +36,26 @@ */ public final class InMemoryOneTimeTokenService implements OneTimeTokenService { + private final OneTimeTokenSettings oneTimeTokenSettings; + + public InMemoryOneTimeTokenService() { + this(null); + } + + /** + * Constructs a {@code InMemoryOneTimeTokenService} using the provided parameters. + * @param oneTimeTokenSettings the {@link OneTimeTokenSettings} to use when generating + * OneTimeTokens + * @since 6.4.2 + * @see OneTimeTokenSettings + */ + public InMemoryOneTimeTokenService(OneTimeTokenSettings oneTimeTokenSettings) { + if (oneTimeTokenSettings == null) { + oneTimeTokenSettings = OneTimeTokenSettings.withDefaults().build(); + } + this.oneTimeTokenSettings = oneTimeTokenSettings; + } + private final Map oneTimeTokenByToken = new ConcurrentHashMap<>(); private Clock clock = Clock.systemUTC(); @@ -44,8 +64,8 @@ public final class InMemoryOneTimeTokenService implements OneTimeTokenService { @NonNull public OneTimeToken generate(GenerateOneTimeTokenRequest request) { String token = UUID.randomUUID().toString(); - Instant fiveMinutesFromNow = this.clock.instant().plusSeconds(300); - OneTimeToken ott = new DefaultOneTimeToken(token, request.getUsername(), fiveMinutesFromNow); + Instant expireTime = this.clock.instant().plus(this.oneTimeTokenSettings.getTimeToLive()); + OneTimeToken ott = new DefaultOneTimeToken(token, request.getUsername(), expireTime); this.oneTimeTokenByToken.put(token, ott); cleanExpiredTokensIfNeeded(); return ott; diff --git a/core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java b/core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java index 014541373ad..519cf587e1b 100644 --- a/core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java +++ b/core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java @@ -21,7 +21,6 @@ import java.sql.Timestamp; import java.sql.Types; import java.time.Clock; -import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -63,6 +62,8 @@ public final class JdbcOneTimeTokenService implements OneTimeTokenService, Dispo private final JdbcOperations jdbcOperations; + private final OneTimeTokenSettings oneTimeTokenSettings; + private Function> oneTimeTokenParametersMapper = new OneTimeTokenParametersMapper(); private RowMapper oneTimeTokenRowMapper = new OneTimeTokenRowMapper(); @@ -107,9 +108,25 @@ public final class JdbcOneTimeTokenService implements OneTimeTokenService, Dispo * @param jdbcOperations the JDBC operations */ public JdbcOneTimeTokenService(JdbcOperations jdbcOperations) { + this(jdbcOperations, null); + } + + /** + * Constructs a {@code JdbcOneTimeTokenService} using the provided parameters. + * @param jdbcOperations the JDBC operations + * @param oneTimeTokenSettings the {@link OneTimeTokenSettings} to use when generating + * OneTimeTokens + * @since 6.4.2 + * @see OneTimeTokenSettings + */ + public JdbcOneTimeTokenService(JdbcOperations jdbcOperations, OneTimeTokenSettings oneTimeTokenSettings) { Assert.notNull(jdbcOperations, "jdbcOperations cannot be null"); this.jdbcOperations = jdbcOperations; this.taskScheduler = createTaskScheduler(DEFAULT_CLEANUP_CRON); + if (oneTimeTokenSettings == null) { + oneTimeTokenSettings = OneTimeTokenSettings.withDefaults().build(); + } + this.oneTimeTokenSettings = oneTimeTokenSettings; } /** @@ -132,8 +149,8 @@ public void setCleanupCron(String cleanupCron) { public OneTimeToken generate(GenerateOneTimeTokenRequest request) { Assert.notNull(request, "generateOneTimeTokenRequest cannot be null"); String token = UUID.randomUUID().toString(); - Instant fiveMinutesFromNow = this.clock.instant().plus(Duration.ofMinutes(5)); - OneTimeToken oneTimeToken = new DefaultOneTimeToken(token, request.getUsername(), fiveMinutesFromNow); + Instant expireTime = this.clock.instant().plus(this.oneTimeTokenSettings.getTimeToLive()); + OneTimeToken oneTimeToken = new DefaultOneTimeToken(token, request.getUsername(), expireTime); insertOneTimeToken(oneTimeToken); return oneTimeToken; } diff --git a/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenSettings.java b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenSettings.java new file mode 100644 index 00000000000..11e5b1c2976 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenSettings.java @@ -0,0 +1,64 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.authentication.ott; + +import java.time.Duration; + +/** + * A facility for {@link OneTimeToken} configuration settings. + * + * @author Micah Moore (Zetetic LLC) + * @since 6.4.2 + */ +public final class OneTimeTokenSettings { + + private static final Duration DEFAULT_TIME_TO_LIVE = Duration.ofMinutes(5L); + + private final Duration timeToLive; + + private OneTimeTokenSettings(Duration timeToLive) { + this.timeToLive = timeToLive; + } + + public Duration getTimeToLive() { + return this.timeToLive; + } + + public static Builder withDefaults() { + return new Builder(DEFAULT_TIME_TO_LIVE); + } + + public static final class Builder { + + private Duration timeToLive; + + Builder(Duration timeToLive) { + this.timeToLive = timeToLive; + } + + public Builder timeToLive(Duration timeToLive) { + this.timeToLive = timeToLive; + return this; + } + + public OneTimeTokenSettings build() { + return new OneTimeTokenSettings(this.timeToLive); + } + + } + +} diff --git a/core/src/test/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenServiceTests.java b/core/src/test/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenServiceTests.java index 8e76c1538c8..3af7303c8eb 100644 --- a/core/src/test/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenServiceTests.java +++ b/core/src/test/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenServiceTests.java @@ -17,6 +17,7 @@ package org.springframework.security.authentication.ott; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; @@ -38,8 +39,13 @@ */ class InMemoryOneTimeTokenServiceTests { + static final Duration CUSTOM_EXPIRE_DURATION = Duration.ofMinutes(30L); + InMemoryOneTimeTokenService oneTimeTokenService = new InMemoryOneTimeTokenService(); + InMemoryOneTimeTokenService oneTimeTokenServiceWithTokenSettings = new InMemoryOneTimeTokenService( + OneTimeTokenSettings.withDefaults().timeToLive(CUSTOM_EXPIRE_DURATION).build()); + @Test void generateThenTokenValueShouldBeValidUuidAndProvidedUsernameIsUsed() { GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest("user"); @@ -110,6 +116,41 @@ void setClockWhenNullThenThrowIllegalArgumentException() { // @formatter:on } + @Test + void generateOneTimeTokenWithCustomExpireTimeThenTokenShouldHaveCustomExpiration() { + GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest("user"); + OneTimeToken generated = this.oneTimeTokenServiceWithTokenSettings.generate(request); + Instant twentyNineMinutesFromNow = Instant.now().plus(29, ChronoUnit.MINUTES); + Instant thirtyOneMinutesFromNow = Instant.now().plus(31, ChronoUnit.MINUTES); + assertThat(generated.getExpiresAt()).isBetween(twentyNineMinutesFromNow, thirtyOneMinutesFromNow); + } + + @Test + void consumeWhenTokenWithCustomExpireTimeIsValidThenReturnToken() { + GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest("user"); + OneTimeToken generated = this.oneTimeTokenServiceWithTokenSettings.generate(request); + OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken( + generated.getTokenValue()); + Clock fifteenMinutesFromNow = Clock.fixed(Instant.now().plus(15, ChronoUnit.MINUTES), ZoneOffset.UTC); + this.oneTimeTokenServiceWithTokenSettings.setClock(fifteenMinutesFromNow); + OneTimeToken consumed = this.oneTimeTokenServiceWithTokenSettings.consume(authenticationToken); + assertThat(consumed.getTokenValue()).isEqualTo(generated.getTokenValue()); + assertThat(consumed.getUsername()).isEqualTo(generated.getUsername()); + assertThat(consumed.getExpiresAt()).isEqualTo(generated.getExpiresAt()); + } + + @Test + void consumeWhenTokenWithCustomExpireTimeIsExpiredThenReturnNull() { + GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest("user"); + OneTimeToken generated = this.oneTimeTokenServiceWithTokenSettings.generate(request); + OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken( + generated.getTokenValue()); + Clock thirtyOneMinutesFromNow = Clock.fixed(Instant.now().plus(31, ChronoUnit.MINUTES), ZoneOffset.UTC); + this.oneTimeTokenServiceWithTokenSettings.setClock(thirtyOneMinutesFromNow); + OneTimeToken consumed = this.oneTimeTokenService.consume(authenticationToken); + assertThat(consumed).isNull(); + } + private List generate(int howMany) { List generated = new ArrayList<>(howMany); for (int i = 0; i < howMany; i++) { diff --git a/core/src/test/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenServiceTests.java b/core/src/test/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenServiceTests.java index 29081134136..fd68d13d8ce 100644 --- a/core/src/test/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenServiceTests.java +++ b/core/src/test/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenServiceTests.java @@ -50,23 +50,30 @@ class JdbcOneTimeTokenServiceTests { private static final String ONE_TIME_TOKEN_SQL_RESOURCE = "org/springframework/security/core/ott/jdbc/one-time-tokens-schema.sql"; + static final Duration CUSTOM_EXPIRE_DURATION = Duration.ofMinutes(30L); + private EmbeddedDatabase db; private JdbcOperations jdbcOperations; private JdbcOneTimeTokenService oneTimeTokenService; + private JdbcOneTimeTokenService oneTimeTokenServiceWithTokenSettings; + @BeforeEach void setUp() { this.db = createDb(); this.jdbcOperations = new JdbcTemplate(this.db); this.oneTimeTokenService = new JdbcOneTimeTokenService(this.jdbcOperations); + this.oneTimeTokenServiceWithTokenSettings = new JdbcOneTimeTokenService(this.jdbcOperations, + OneTimeTokenSettings.withDefaults().timeToLive(CUSTOM_EXPIRE_DURATION).build()); } @AfterEach void tearDown() throws Exception { this.db.shutdown(); this.oneTimeTokenService.destroy(); + this.oneTimeTokenServiceWithTokenSettings.destroy(); } private static EmbeddedDatabase createDb() { @@ -188,4 +195,39 @@ void setCleanupChronWhenAlreadyNullThenNoException() { this.oneTimeTokenService.setCleanupCron(null); } + @Test + void generateOneTimeTokenWithCustomExpireTimeThenTokenShouldHaveCustomExpiration() { + GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest(USERNAME); + OneTimeToken generated = this.oneTimeTokenServiceWithTokenSettings.generate(request); + Instant twentyNineMinutesFromNow = Instant.now().plus(29, ChronoUnit.MINUTES); + Instant thirtyOneMinutesFromNow = Instant.now().plus(31, ChronoUnit.MINUTES); + assertThat(generated.getExpiresAt()).isBetween(twentyNineMinutesFromNow, thirtyOneMinutesFromNow); + } + + @Test + void consumeWhenTokenWithCustomExpireTimeIsValidThenReturnToken() { + GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest(USERNAME); + OneTimeToken generated = this.oneTimeTokenServiceWithTokenSettings.generate(request); + OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken( + generated.getTokenValue()); + Clock fifteenMinutesFromNow = Clock.fixed(Instant.now().plus(15, ChronoUnit.MINUTES), ZoneOffset.UTC); + this.oneTimeTokenServiceWithTokenSettings.setClock(fifteenMinutesFromNow); + OneTimeToken consumed = this.oneTimeTokenServiceWithTokenSettings.consume(authenticationToken); + assertThat(consumed.getTokenValue()).isEqualTo(generated.getTokenValue()); + assertThat(consumed.getUsername()).isEqualTo(generated.getUsername()); + assertThat(consumed.getExpiresAt()).isEqualTo(generated.getExpiresAt()); + } + + @Test + void consumeWhenTokenWithCustomExpireTimeIsExpiredThenReturnNull() { + GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest(USERNAME); + OneTimeToken generated = this.oneTimeTokenServiceWithTokenSettings.generate(request); + OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken( + generated.getTokenValue()); + Clock thirtyOneMinutesFromNow = Clock.fixed(Instant.now().plus(31, ChronoUnit.MINUTES), ZoneOffset.UTC); + this.oneTimeTokenServiceWithTokenSettings.setClock(thirtyOneMinutesFromNow); + OneTimeToken consumed = this.oneTimeTokenServiceWithTokenSettings.consume(authenticationToken); + assertThat(consumed).isNull(); + } + }