From 300b9659f7a00cfdd83d3cf4becf5f41cb5a0b61 Mon Sep 17 00:00:00 2001 From: Max Batischev Date: Tue, 24 Sep 2024 14:08:28 +0300 Subject: [PATCH] Add support JdbcOneTimeTokenService Closes gh-15735 --- .../aot/hint/OneTimeTokenRuntimeHints.java | 40 ++++ .../ott/InMemoryOneTimeTokenService.java | 25 +-- .../ott/JdbcOneTimeTokenService.java | 198 +++++++++++++++++ .../authentication/ott/OneTimeTokenUtils.java | 55 +++++ .../resources/META-INF/spring/aot.factories | 4 +- .../core/ott/jdbc/one-time-tokens.sql | 6 + .../hint/OneTimeTokenRuntimeHintsTests.java | 59 ++++++ .../ott/InMemoryOneTimeTokenServiceTests.java | 36 +++- .../ott/JdbcOneTimeTokenServiceTests.java | 200 ++++++++++++++++++ .../ott/OneTimeTokenUtilsTests.java | 116 ++++++++++ 10 files changed, 709 insertions(+), 30 deletions(-) create mode 100644 core/src/main/java/org/springframework/security/aot/hint/OneTimeTokenRuntimeHints.java create mode 100644 core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java create mode 100644 core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenUtils.java create mode 100644 core/src/main/resources/org/springframework/security/core/ott/jdbc/one-time-tokens.sql create mode 100644 core/src/test/java/org/springframework/security/aot/hint/OneTimeTokenRuntimeHintsTests.java create mode 100644 core/src/test/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenServiceTests.java create mode 100644 core/src/test/java/org/springframework/security/authentication/ott/OneTimeTokenUtilsTests.java diff --git a/core/src/main/java/org/springframework/security/aot/hint/OneTimeTokenRuntimeHints.java b/core/src/main/java/org/springframework/security/aot/hint/OneTimeTokenRuntimeHints.java new file mode 100644 index 00000000000..762aa4360fc --- /dev/null +++ b/core/src/main/java/org/springframework/security/aot/hint/OneTimeTokenRuntimeHints.java @@ -0,0 +1,40 @@ +/* + * 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.aot.hint; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.security.authentication.ott.OneTimeToken; +import org.springframework.security.authentication.ott.OneTimeTokenService; + +/** + * + * A JDBC implementation of an {@link OneTimeTokenService} that uses a + * {@link JdbcOperations} for {@link OneTimeToken} persistence. + * + * @author Max Batischev + * @since 6.4 + */ +class OneTimeTokenRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + hints.resources().registerPattern("org/springframework/security/core/ott/jdbc/one-time-tokens.sql"); + } + +} 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 9683ca49842..db92a23e129 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 @@ -16,14 +16,11 @@ package org.springframework.security.authentication.ott; -import java.time.Clock; -import java.time.Instant; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import org.springframework.lang.NonNull; -import org.springframework.util.Assert; /** * Provides an in-memory implementation of the {@link OneTimeTokenService} interface that @@ -38,15 +35,12 @@ public final class InMemoryOneTimeTokenService implements OneTimeTokenService { private final Map oneTimeTokenByToken = new ConcurrentHashMap<>(); - private Clock clock = Clock.systemUTC(); - @Override @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); - this.oneTimeTokenByToken.put(token, ott); + OneTimeToken ott = OneTimeTokenUtils.generateOneTimeToken(request, + OneTimeTokenUtils.DEFAULT_ONE_TIME_TOKEN_TIME_TO_LIVE); + this.oneTimeTokenByToken.put(ott.getTokenValue(), ott); cleanExpiredTokensIfNeeded(); return ott; } @@ -54,7 +48,7 @@ public OneTimeToken generate(GenerateOneTimeTokenRequest request) { @Override public OneTimeToken consume(OneTimeTokenAuthenticationToken authenticationToken) { OneTimeToken ott = this.oneTimeTokenByToken.remove(authenticationToken.getTokenValue()); - if (ott == null || isExpired(ott)) { + if (ott == null || OneTimeTokenUtils.isExpired(ott)) { return null; } return ott; @@ -65,19 +59,10 @@ private void cleanExpiredTokensIfNeeded() { return; } for (Map.Entry entry : this.oneTimeTokenByToken.entrySet()) { - if (isExpired(entry.getValue())) { + if (OneTimeTokenUtils.isExpired(entry.getValue())) { this.oneTimeTokenByToken.remove(entry.getKey()); } } } - private boolean isExpired(OneTimeToken ott) { - return this.clock.instant().isAfter(ott.getExpiresAt()); - } - - void setClock(Clock clock) { - Assert.notNull(clock, "clock cannot be null"); - this.clock = clock; - } - } 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 new file mode 100644 index 00000000000..18754a5e115 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java @@ -0,0 +1,198 @@ +/* + * 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.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import org.springframework.jdbc.core.ArgumentPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.PreparedStatementSetter; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.SqlParameterValue; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * + * A JDBC implementation of an {@link OneTimeTokenService} that uses a + * {@link JdbcOperations} for {@link OneTimeToken} persistence. + * + *

+ * NOTE: This {@code JdbcOneTimeTokenService} depends on the table definition + * described in "classpath:org/springframework/security/core/ott/jdbc/one-time-tokens.sql" + * and therefore MUST be defined in the database schema. + * + * @author Max Batischev + * @since 6.4 + */ +public final class JdbcOneTimeTokenService implements OneTimeTokenService { + + private final JdbcOperations jdbcOperations; + + private Function> oneTimeTokenParametersMapper = new OneTimeTokenParametersMapper(); + + private RowMapper oneTimeTokenRowMapper = new OneTimeTokenRowMapper(); + + private static final String TABLE_NAME = "one_time_tokens"; + + // @formatter:off + private static final String COLUMN_NAMES = "token_value, " + + "username, " + + "expires_at"; + // @formatter:on + + // @formatter:off + private static final String SAVE_AUTHORIZED_CLIENT_SQL = "INSERT INTO " + TABLE_NAME + + " (" + COLUMN_NAMES + ") VALUES (?, ?, ?)"; + // @formatter:on + + private static final String FILTER = "token_value = ?"; + + private static final String DELETE_ONE_TIME_TOKEN_SQL = "DELETE FROM " + TABLE_NAME + " WHERE " + FILTER; + + // @formatter:off + private static final String SELECT_ONE_TIME_TOKEN_SQL = "SELECT " + COLUMN_NAMES + + " FROM " + TABLE_NAME + + " WHERE " + FILTER; + // @formatter:on + + /** + * Constructs a {@code JdbcOneTimeTokenService} using the provide parameters. + * @param jdbcOperations the JDBC operations + */ + public JdbcOneTimeTokenService(JdbcOperations jdbcOperations) { + Assert.notNull(jdbcOperations, "jdbcOperations cannot be null"); + this.jdbcOperations = jdbcOperations; + } + + @Override + public OneTimeToken generate(GenerateOneTimeTokenRequest request) { + Assert.notNull(request, "generateOneTimeTokenRequest cannot be null"); + + OneTimeToken oneTimeToken = OneTimeTokenUtils.generateOneTimeToken(request, + OneTimeTokenUtils.DEFAULT_ONE_TIME_TOKEN_TIME_TO_LIVE); + insertOneTimeToken(oneTimeToken); + return oneTimeToken; + } + + private void insertOneTimeToken(OneTimeToken oneTimeToken) { + List parameters = this.oneTimeTokenParametersMapper.apply(oneTimeToken); + PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray()); + this.jdbcOperations.update(SAVE_AUTHORIZED_CLIENT_SQL, pss); + } + + @Override + public OneTimeToken consume(OneTimeTokenAuthenticationToken authenticationToken) { + Assert.notNull(authenticationToken, "authenticationToken cannot be null"); + + List tokens = selectOneTimeToken(authenticationToken); + if (CollectionUtils.isEmpty(tokens)) { + return null; + } + OneTimeToken token = tokens.get(0); + deleteOneTimeToken(token); + if (OneTimeTokenUtils.isExpired(token)) { + return null; + } + return token; + } + + private List selectOneTimeToken(OneTimeTokenAuthenticationToken authenticationToken) { + List parameters = List + .of(new SqlParameterValue(Types.VARCHAR, authenticationToken.getTokenValue())); + PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray()); + return this.jdbcOperations.query(SELECT_ONE_TIME_TOKEN_SQL, pss, this.oneTimeTokenRowMapper); + } + + private void deleteOneTimeToken(OneTimeToken oneTimeToken) { + List parameters = List + .of(new SqlParameterValue(Types.VARCHAR, oneTimeToken.getTokenValue())); + PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray()); + this.jdbcOperations.update(DELETE_ONE_TIME_TOKEN_SQL, pss); + } + + /** + * Sets the {@code Function} used for mapping {@link OneTimeToken} to a {@code List} + * of {@link SqlParameterValue}. The default is {@link OneTimeTokenParametersMapper}. + * @param oneTimeTokenParametersMapper the {@code Function} used for mapping + * {@link OneTimeToken} to a {@code List} of {@link SqlParameterValue} + */ + public void setOneTimeTokenParametersMapper( + Function> oneTimeTokenParametersMapper) { + Assert.notNull(oneTimeTokenParametersMapper, "oneTimeTokenParametersMapper cannot be null"); + this.oneTimeTokenParametersMapper = oneTimeTokenParametersMapper; + } + + /** + * Sets the {@link RowMapper} used for mapping the current row in + * {@code java.sql.ResultSet} to {@link OneTimeToken}. The default is + * {@link OneTimeTokenRowMapper}. + * @param oneTimeTokenRowMapper the {@link RowMapper} used for mapping the current row + * in {@code java.sql.ResultSet} to {@link OneTimeToken} + */ + public void setOneTimeTokenRowMapper(RowMapper oneTimeTokenRowMapper) { + Assert.notNull(oneTimeTokenRowMapper, "oneTimeTokenRowMapper cannot be null"); + this.oneTimeTokenRowMapper = oneTimeTokenRowMapper; + } + + /** + * The default {@code Function} that maps {@link OneTimeToken} to a {@code List} of + * {@link SqlParameterValue}. + * + * @author Max Batischev + * @since 6.4 + */ + public static class OneTimeTokenParametersMapper implements Function> { + + @Override + public List apply(OneTimeToken oneTimeToken) { + List parameters = new ArrayList<>(); + parameters.add(new SqlParameterValue(Types.VARCHAR, oneTimeToken.getTokenValue())); + parameters.add(new SqlParameterValue(Types.VARCHAR, oneTimeToken.getUsername())); + parameters.add(new SqlParameterValue(Types.TIMESTAMP, Timestamp.from(oneTimeToken.getExpiresAt()))); + return parameters; + } + + } + + /** + * The default {@link RowMapper} that maps the current row in + * {@code java.sql.ResultSet} to {@link OneTimeToken}. + * + * @author Max Batischev + * @since 6.4 + */ + public static class OneTimeTokenRowMapper implements RowMapper { + + @Override + public OneTimeToken mapRow(ResultSet rs, int rowNum) throws SQLException { + String tokenValue = rs.getString("token_value"); + String userName = rs.getString("username"); + Instant expiresAt = rs.getTimestamp("expires_at").toInstant(); + return new DefaultOneTimeToken(tokenValue, userName, expiresAt); + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenUtils.java b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenUtils.java new file mode 100644 index 00000000000..c62e2bedbef --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenUtils.java @@ -0,0 +1,55 @@ +/* + * 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.Clock; +import java.time.Instant; +import java.util.UUID; + +import org.springframework.util.Assert; + +/** + * + * Utility for default generation and checking of {@link OneTimeToken} time to live. + * + * @author Max Batischev + * @since 6.4 + */ +public final class OneTimeTokenUtils { + + public static long DEFAULT_ONE_TIME_TOKEN_TIME_TO_LIVE = 300; + + private static Clock clock = Clock.systemUTC(); + + private OneTimeTokenUtils() { + } + + public static OneTimeToken generateOneTimeToken(GenerateOneTimeTokenRequest request, long timeToLive) { + Assert.notNull(request, "generateOneTimeTokenRequest cannot be null"); + Assert.isTrue(!(timeToLive <= 0), "timeToLive must be greater than 0"); + + String token = UUID.randomUUID().toString(); + Instant fiveMinutesFromNow = clock.instant().plusSeconds(timeToLive); + return new DefaultOneTimeToken(token, request.getUsername(), fiveMinutesFromNow); + } + + public static boolean isExpired(OneTimeToken token) { + Assert.notNull(token, "oneTimeToken cannot be null"); + return clock.instant().isAfter(token.getExpiresAt()); + } + +} diff --git a/core/src/main/resources/META-INF/spring/aot.factories b/core/src/main/resources/META-INF/spring/aot.factories index 2a24e540732..8596dc6a3fe 100644 --- a/core/src/main/resources/META-INF/spring/aot.factories +++ b/core/src/main/resources/META-INF/spring/aot.factories @@ -1,4 +1,6 @@ org.springframework.aot.hint.RuntimeHintsRegistrar=\ -org.springframework.security.aot.hint.CoreSecurityRuntimeHints +org.springframework.security.aot.hint.CoreSecurityRuntimeHints,\ +org.springframework.security.aot.hint.OneTimeTokenRuntimeHints + org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor=\ org.springframework.security.aot.hint.SecurityHintsAotProcessor diff --git a/core/src/main/resources/org/springframework/security/core/ott/jdbc/one-time-tokens.sql b/core/src/main/resources/org/springframework/security/core/ott/jdbc/one-time-tokens.sql new file mode 100644 index 00000000000..3751c6391ce --- /dev/null +++ b/core/src/main/resources/org/springframework/security/core/ott/jdbc/one-time-tokens.sql @@ -0,0 +1,6 @@ +create table one_time_tokens +( + token_value varchar(36) not null primary key, + username varchar_ignorecase(50) not null, + expires_at timestamp not null +); diff --git a/core/src/test/java/org/springframework/security/aot/hint/OneTimeTokenRuntimeHintsTests.java b/core/src/test/java/org/springframework/security/aot/hint/OneTimeTokenRuntimeHintsTests.java new file mode 100644 index 00000000000..1fcfbd3437f --- /dev/null +++ b/core/src/test/java/org/springframework/security/aot/hint/OneTimeTokenRuntimeHintsTests.java @@ -0,0 +1,59 @@ +/* + * 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.aot.hint; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link OneTimeTokenRuntimeHints} + * + * @author Max Batischev + */ +class OneTimeTokenRuntimeHintsTests { + + private final RuntimeHints hints = new RuntimeHints(); + + @BeforeEach + void setup() { + SpringFactoriesLoader.forResourceLocation("META-INF/spring/aot.factories") + .load(RuntimeHintsRegistrar.class) + .forEach((registrar) -> registrar.registerHints(this.hints, ClassUtils.getDefaultClassLoader())); + } + + @ParameterizedTest + @MethodSource("getOneTimeTokensSqlFiles") + void oneTimeTokensSqlFilesHasHints(String schemaFile) { + assertThat(RuntimeHintsPredicates.resource().forResource(schemaFile)).accepts(this.hints); + } + + private static Stream getOneTimeTokensSqlFiles() { + return Stream.of("org/springframework/security/core/ott/jdbc/one-time-tokens.sql"); + } + +} 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 23b398708b6..ae8e292d47c 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 @@ -16,6 +16,7 @@ package org.springframework.security.authentication.ott; +import java.lang.reflect.Field; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; @@ -25,6 +26,7 @@ import java.util.Objects; import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -39,6 +41,11 @@ class InMemoryOneTimeTokenServiceTests { InMemoryOneTimeTokenService oneTimeTokenService = new InMemoryOneTimeTokenService(); + @BeforeEach + void setUp() { + setClock(Clock.systemUTC()); + } + @Test void generateThenTokenValueShouldBeValidUuidAndProvidedUsernameIsUsed() { GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest("user"); @@ -73,7 +80,7 @@ void consumeWhenTokenIsExpiredThenReturnNull() { OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken( generated.getTokenValue()); Clock tenMinutesFromNow = Clock.fixed(Instant.now().plus(10, ChronoUnit.MINUTES), ZoneOffset.UTC); - this.oneTimeTokenService.setClock(tenMinutesFromNow); + setClock(tenMinutesFromNow); OneTimeToken consumed = this.oneTimeTokenService.consume(authenticationToken); assertThat(consumed).isNull(); } @@ -83,20 +90,20 @@ void generateWhenMoreThan100TokensThenClearExpired() { // @formatter:off List toExpire = generate(50); // 50 tokens will expire in 5 minutes from now Clock twoMinutesFromNow = Clock.fixed(Instant.now().plus(2, ChronoUnit.MINUTES), ZoneOffset.UTC); - this.oneTimeTokenService.setClock(twoMinutesFromNow); + setClock(twoMinutesFromNow); List toKeep = generate(50); // 50 tokens will expire in 7 minutes from now Clock sixMinutesFromNow = Clock.fixed(Instant.now().plus(6, ChronoUnit.MINUTES), ZoneOffset.UTC); - this.oneTimeTokenService.setClock(sixMinutesFromNow); + setClock(sixMinutesFromNow); assertThat(toExpire) - .extracting( - (token) -> this.oneTimeTokenService.consume(new OneTimeTokenAuthenticationToken(token.getTokenValue()))) - .containsOnlyNulls(); + .extracting( + (token) -> this.oneTimeTokenService.consume(new OneTimeTokenAuthenticationToken(token.getTokenValue()))) + .containsOnlyNulls(); assertThat(toKeep) - .extracting( - (token) -> this.oneTimeTokenService.consume(new OneTimeTokenAuthenticationToken(token.getTokenValue()))) - .noneMatch(Objects::isNull); + .extracting( + (token) -> this.oneTimeTokenService.consume(new OneTimeTokenAuthenticationToken(token.getTokenValue()))) + .noneMatch(Objects::isNull); // @formatter:on } @@ -110,4 +117,15 @@ private List generate(int howMany) { return generated; } + private void setClock(Clock clock) { + try { + Field field = OneTimeTokenUtils.class.getDeclaredField("clock"); + field.setAccessible(true); + field.set(null, clock); + } + catch (NoSuchFieldException | IllegalAccessException ex) { + throw new RuntimeException(ex); + } + } + } 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 new file mode 100644 index 00000000000..fb8dd21e731 --- /dev/null +++ b/core/src/test/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenServiceTests.java @@ -0,0 +1,200 @@ +/* + * 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.lang.reflect.Field; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.util.CollectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link JdbcOneTimeTokenService}. + * + * @author Max Batischev + */ +public class JdbcOneTimeTokenServiceTests { + + private static final String USERNAME = "user"; + + private static final String TOKEN_VALUE = "1234"; + + private static final String ONE_TIME_TOKEN_SQL_RESOURCE = "org/springframework/security/core/ott/jdbc/one-time-tokens.sql"; + + private EmbeddedDatabase db; + + private JdbcOperations jdbcOperations; + + private JdbcOneTimeTokenService oneTimeTokenService; + + @BeforeEach + void setUp() { + setClock(Clock.systemUTC()); + this.db = createDb(); + this.jdbcOperations = new JdbcTemplate(this.db); + this.oneTimeTokenService = new JdbcOneTimeTokenService(this.jdbcOperations); + } + + @AfterEach + public void tearDown() { + this.db.shutdown(); + } + + private static EmbeddedDatabase createDb() { + // @formatter:off + return new EmbeddedDatabaseBuilder() + .generateUniqueName(true) + .setType(EmbeddedDatabaseType.HSQL) + .setScriptEncoding("UTF-8") + .addScript(ONE_TIME_TOKEN_SQL_RESOURCE) + .build(); + // @formatter:on + } + + @Test + void constructorWhenJdbcOperationsIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new JdbcOneTimeTokenService(null)) + .withMessage("jdbcOperations cannot be null"); + // @formatter:on + } + + @Test + void generateWhenGenerateOneTimeTokenRequestIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.oneTimeTokenService.generate(null)) + .withMessage("generateOneTimeTokenRequest cannot be null"); + // @formatter:on + } + + @Test + void consumeWhenAuthenticationTokenIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.oneTimeTokenService.consume(null)) + .withMessage("authenticationToken cannot be null"); + // @formatter:on + } + + @Test + void generateThenTokenValueShouldBeValidUuidAndProvidedUsernameIsUsed() { + OneTimeToken oneTimeToken = this.oneTimeTokenService.generate(new GenerateOneTimeTokenRequest(USERNAME)); + + OneTimeToken persistedOneTimeToken = selectOneTimeToken(oneTimeToken.getTokenValue()); + assertThat(persistedOneTimeToken).isNotNull(); + assertThat(persistedOneTimeToken.getUsername()).isNotNull(); + assertThat(persistedOneTimeToken.getTokenValue()).isNotNull(); + assertThat(persistedOneTimeToken.getExpiresAt()).isNotNull(); + } + + @Test + void consumeWhenTokenExistsThenReturnItself() { + OneTimeToken oneTimeToken = this.oneTimeTokenService.generate(new GenerateOneTimeTokenRequest(USERNAME)); + OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken( + oneTimeToken.getTokenValue()); + + OneTimeToken consumedOneTimeToken = this.oneTimeTokenService.consume(authenticationToken); + + assertThat(consumedOneTimeToken).isNotNull(); + assertThat(consumedOneTimeToken.getUsername()).isNotNull(); + assertThat(consumedOneTimeToken.getTokenValue()).isNotNull(); + assertThat(consumedOneTimeToken.getExpiresAt()).isNotNull(); + OneTimeToken persistedOneTimeToken = selectOneTimeToken(consumedOneTimeToken.getTokenValue()); + assertThat(persistedOneTimeToken).isNull(); + } + + @Test + void consumeWhenTokenDoesNotExistsThenReturnNull() { + OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken(TOKEN_VALUE); + + OneTimeToken consumedOneTimeToken = this.oneTimeTokenService.consume(authenticationToken); + + assertThat(consumedOneTimeToken).isNull(); + } + + @Test + void consumeWhenTokenIsExpiredThenReturnNull() { + GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest(USERNAME); + OneTimeToken generated = this.oneTimeTokenService.generate(request); + OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken( + generated.getTokenValue()); + Clock tenMinutesFromNow = Clock.fixed(Instant.now().plus(10, ChronoUnit.MINUTES), ZoneOffset.UTC); + setClock(tenMinutesFromNow); + + OneTimeToken consumed = this.oneTimeTokenService.consume(authenticationToken); + assertThat(consumed).isNull(); + } + + @Test + void setOneTimeTokenRowMapperWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.oneTimeTokenService.setOneTimeTokenRowMapper(null)) + .withMessage("oneTimeTokenRowMapper cannot be null"); + // @formatter:on + } + + @Test + void setOneTimeTokenParametersMapperWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.oneTimeTokenService.setOneTimeTokenParametersMapper(null)) + .withMessage("oneTimeTokenParametersMapper cannot be null"); + // @formatter:on + } + + private OneTimeToken selectOneTimeToken(String tokenValue) { + // @formatter:off + List result = this.jdbcOperations.query( + "select token_value, username, expires_at from one_time_tokens where token_value = ?", + new JdbcOneTimeTokenService.OneTimeTokenRowMapper(), tokenValue); + if (CollectionUtils.isEmpty(result)) { + return null; + } + return result.get(0); + // @formatter:on + } + + private void setClock(Clock clock) { + try { + Field field = OneTimeTokenUtils.class.getDeclaredField("clock"); + field.setAccessible(true); + field.set(null, clock); + } + catch (NoSuchFieldException | IllegalAccessException ex) { + throw new RuntimeException(ex); + } + } + +} diff --git a/core/src/test/java/org/springframework/security/authentication/ott/OneTimeTokenUtilsTests.java b/core/src/test/java/org/springframework/security/authentication/ott/OneTimeTokenUtilsTests.java new file mode 100644 index 00000000000..09de9236b2f --- /dev/null +++ b/core/src/test/java/org/springframework/security/authentication/ott/OneTimeTokenUtilsTests.java @@ -0,0 +1,116 @@ +/* + * 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.lang.reflect.Field; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link OneTimeTokenUtils}. + * + * @author Max Batischev + */ +public class OneTimeTokenUtilsTests { + + private static final String USERNAME = "user"; + + private static final long INVALID_TIME_TO_LIVE = -1; + + @BeforeEach + void setUp() { + setClock(Clock.systemUTC()); + } + + @Test + void generateWhenGenerateOneTimeTokenRequestIsPresentThenReturnToken() { + GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest(USERNAME); + + OneTimeToken oneTimeToken = OneTimeTokenUtils.generateOneTimeToken(request, + OneTimeTokenUtils.DEFAULT_ONE_TIME_TOKEN_TIME_TO_LIVE); + + assertThat(oneTimeToken).isNotNull(); + assertThat(oneTimeToken.getTokenValue()).isNotNull(); + assertThat(oneTimeToken.getExpiresAt()).isNotNull(); + assertThat(oneTimeToken.getUsername()).isEqualTo(USERNAME); + } + + @Test + void generateWhenGenerateOneTimeTokenRequestIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> OneTimeTokenUtils.generateOneTimeToken(null, OneTimeTokenUtils.DEFAULT_ONE_TIME_TOKEN_TIME_TO_LIVE)) + .withMessage("generateOneTimeTokenRequest cannot be null"); + // @formatter:on + } + + @Test + void generateWhenTokenTimeToLiveLessThanZeroNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> OneTimeTokenUtils.generateOneTimeToken(new GenerateOneTimeTokenRequest(USERNAME), INVALID_TIME_TO_LIVE)) + .withMessage("timeToLive must be greater than 0"); + // @formatter:on + } + + @Test + void checkExpirationWhenTokenDoesNotExpireThenReturnFalse() { + OneTimeToken oneTimeToken = OneTimeTokenUtils.generateOneTimeToken(new GenerateOneTimeTokenRequest(USERNAME), + OneTimeTokenUtils.DEFAULT_ONE_TIME_TOKEN_TIME_TO_LIVE); + + assertThat(OneTimeTokenUtils.isExpired(oneTimeToken)).isFalse(); + } + + @Test + void checkExpirationWhenTokenExpiredThenReturnTrue() { + OneTimeToken oneTimeToken = OneTimeTokenUtils.generateOneTimeToken(new GenerateOneTimeTokenRequest(USERNAME), + OneTimeTokenUtils.DEFAULT_ONE_TIME_TOKEN_TIME_TO_LIVE); + Clock tenMinutesFromNow = Clock.fixed(Instant.now().plus(10, ChronoUnit.MINUTES), ZoneOffset.UTC); + setClock(tenMinutesFromNow); + + assertThat(OneTimeTokenUtils.isExpired(oneTimeToken)).isTrue(); + } + + @Test + void checkExpirationWhenOneTimeTokenRIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> OneTimeTokenUtils.isExpired(null)) + .withMessage("oneTimeToken cannot be null"); + // @formatter:on + } + + private void setClock(Clock clock) { + try { + Field field = OneTimeTokenUtils.class.getDeclaredField("clock"); + field.setAccessible(true); + field.set(null, clock); + } + catch (NoSuchFieldException | IllegalAccessException ex) { + throw new RuntimeException(ex); + } + } + +}