diff --git a/web/src/main/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHints.java b/web/src/main/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHints.java new file mode 100644 index 0000000000..c35cf5b81c --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHints.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.web.aot.hint; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository; + +/** + * + * A JDBC implementation of an {@link PublicKeyCredentialUserEntityRepository} that uses a + * {@link JdbcOperations} for {@link PublicKeyCredentialUserEntity} persistence. + * + * @author Max Batischev + * @since 6.5 + */ +class PublicKeyCredentialUserEntityRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + hints.resources().registerPattern("org/springframework/security/user-entities-schema.sql"); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepository.java b/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepository.java new file mode 100644 index 0000000000..bfeaafb0e8 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepository.java @@ -0,0 +1,193 @@ +/* + * 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.web.webauthn.management; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import org.springframework.dao.DuplicateKeyException; +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.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.util.Assert; + +/** + * A JDBC implementation of an {@link PublicKeyCredentialUserEntityRepository} that uses a + * {@link JdbcOperations} for {@link PublicKeyCredentialUserEntity} persistence. + * + * NOTE: This {@code PublicKeyCredentialUserEntityRepository} depends on the table + * definition described in + * "classpath:org/springframework/security/user-entities-schema.sql" and therefore MUST be + * defined in the database schema. + * + * @author Max Batischev + * @since 6.5 + * @see PublicKeyCredentialUserEntityRepository + * @see PublicKeyCredentialUserEntity + * @see JdbcOperations + * @see RowMapper + */ +public final class JdbcPublicKeyCredentialUserEntityRepository implements PublicKeyCredentialUserEntityRepository { + + private RowMapper userEntityRowMapper = new UserEntityRecordRowMapper(); + + private Function> userEntityParametersMapper = new UserEntityParametersMapper(); + + private final JdbcOperations jdbcOperations; + + private static final String TABLE_NAME = "user_entities"; + + // @formatter:off + private static final String COLUMN_NAMES = "id, " + + "name, " + + "display_name "; + // @formatter:on + + // @formatter:off + private static final String SAVE_USER_SQL = "INSERT INTO " + TABLE_NAME + + " (" + COLUMN_NAMES + ") VALUES (?, ?, ?)"; + // @formatter:on + + private static final String ID_FILTER = "id = ? "; + + private static final String USER_NAME_FILTER = "name = ? "; + + // @formatter:off + private static final String FIND_USER_BY_ID_SQL = "SELECT " + COLUMN_NAMES + + " FROM " + TABLE_NAME + + " WHERE " + ID_FILTER; + // @formatter:on + + // @formatter:off + private static final String FIND_USER_BY_NAME_SQL = "SELECT " + COLUMN_NAMES + + " FROM " + TABLE_NAME + + " WHERE " + USER_NAME_FILTER; + // @formatter:on + + private static final String DELETE_USER_SQL = "DELETE FROM " + TABLE_NAME + " WHERE " + ID_FILTER; + + // @formatter:off + private static final String UPDATE_USER_SQL = "UPDATE " + TABLE_NAME + + " SET name = ?, display_name = ? " + + " WHERE " + ID_FILTER; + // @formatter:on + + /** + * Constructs a {@code JdbcPublicKeyCredentialUserEntityRepository} using the provided + * parameters. + * @param jdbcOperations the JDBC operations + */ + public JdbcPublicKeyCredentialUserEntityRepository(JdbcOperations jdbcOperations) { + Assert.notNull(jdbcOperations, "jdbcOperations cannot be null"); + this.jdbcOperations = jdbcOperations; + } + + @Override + public PublicKeyCredentialUserEntity findById(Bytes id) { + Assert.notNull(id, "id cannot be null"); + List result = this.jdbcOperations.query(FIND_USER_BY_ID_SQL, + this.userEntityRowMapper, id.toBase64UrlString()); + return !result.isEmpty() ? result.get(0) : null; + } + + @Override + public PublicKeyCredentialUserEntity findByUsername(String username) { + Assert.hasText(username, "name cannot be null or empty"); + List result = this.jdbcOperations.query(FIND_USER_BY_NAME_SQL, + this.userEntityRowMapper, username); + return !result.isEmpty() ? result.get(0) : null; + } + + @Override + public void save(PublicKeyCredentialUserEntity userEntity) { + Assert.notNull(userEntity, "userEntity cannot be null"); + boolean existsUserEntity = null != this.findById(userEntity.getId()); + if (existsUserEntity) { + updateUserEntity(userEntity); + } + else { + try { + insertUserEntity(userEntity); + } + catch (DuplicateKeyException ex) { + updateUserEntity(userEntity); + } + } + } + + private void insertUserEntity(PublicKeyCredentialUserEntity userEntity) { + List parameters = this.userEntityParametersMapper.apply(userEntity); + PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray()); + this.jdbcOperations.update(SAVE_USER_SQL, pss); + } + + private void updateUserEntity(PublicKeyCredentialUserEntity userEntity) { + List parameters = this.userEntityParametersMapper.apply(userEntity); + SqlParameterValue userEntityId = parameters.remove(0); + parameters.add(userEntityId); + PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray()); + this.jdbcOperations.update(UPDATE_USER_SQL, pss); + } + + @Override + public void delete(Bytes id) { + Assert.notNull(id, "id cannot be null"); + SqlParameterValue[] parameters = new SqlParameterValue[] { + new SqlParameterValue(Types.VARCHAR, id.toBase64UrlString()), }; + PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters); + this.jdbcOperations.update(DELETE_USER_SQL, pss); + } + + private static class UserEntityParametersMapper + implements Function> { + + @Override + public List apply(PublicKeyCredentialUserEntity userEntity) { + List parameters = new ArrayList<>(); + + parameters.add(new SqlParameterValue(Types.VARCHAR, userEntity.getId().toBase64UrlString())); + parameters.add(new SqlParameterValue(Types.VARCHAR, userEntity.getName())); + parameters.add(new SqlParameterValue(Types.VARCHAR, userEntity.getDisplayName())); + + return parameters; + } + + } + + private static class UserEntityRecordRowMapper implements RowMapper { + + @Override + public PublicKeyCredentialUserEntity mapRow(ResultSet rs, int rowNum) throws SQLException { + Bytes id = Bytes.fromBase64(new String(rs.getString("id").getBytes())); + String name = rs.getString("name"); + String displayName = rs.getString("display_name"); + + return ImmutablePublicKeyCredentialUserEntity.builder().id(id).name(name).displayName(displayName).build(); + } + + } + +} diff --git a/web/src/main/resources/META-INF/spring/aot.factories b/web/src/main/resources/META-INF/spring/aot.factories index 4c3991233f..2a3c8ad768 100644 --- a/web/src/main/resources/META-INF/spring/aot.factories +++ b/web/src/main/resources/META-INF/spring/aot.factories @@ -1,3 +1,4 @@ org.springframework.aot.hint.RuntimeHintsRegistrar=\ org.springframework.security.web.aot.hint.WebMvcSecurityRuntimeHints,\ -org.springframework.security.web.aot.hint.UserCredentialRuntimeHints +org.springframework.security.web.aot.hint.UserCredentialRuntimeHints,\ +org.springframework.security.web.aot.hint.PublicKeyCredentialUserEntityRuntimeHints diff --git a/web/src/main/resources/org/springframework/security/user-entities-schema.sql b/web/src/main/resources/org/springframework/security/user-entities-schema.sql new file mode 100644 index 0000000000..ec66c66519 --- /dev/null +++ b/web/src/main/resources/org/springframework/security/user-entities-schema.sql @@ -0,0 +1,7 @@ +create table user_entities +( + id varchar(1000) not null, + name varchar(100) not null, + display_name varchar(200), + primary key (id) +); diff --git a/web/src/test/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHintsTests.java b/web/src/test/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHintsTests.java new file mode 100644 index 0000000000..4909a64303 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHintsTests.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.web.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 PublicKeyCredentialUserEntityRuntimeHints} + * + * @author Max Batischev + */ +public class PublicKeyCredentialUserEntityRuntimeHintsTests { + + 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("getUserEntitiesSqlFiles") + void userEntitiesSqlFilesHasHints(String schemaFile) { + assertThat(RuntimeHintsPredicates.resource().forResource(schemaFile)).accepts(this.hints); + } + + private static Stream getUserEntitiesSqlFiles() { + return Stream.of("org/springframework/security/user-entities-schema.sql"); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepositoryTests.java b/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepositoryTests.java new file mode 100644 index 0000000000..503108ac4e --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepositoryTests.java @@ -0,0 +1,182 @@ +/* + * 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.web.webauthn.management; + +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.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialUserEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link JdbcPublicKeyCredentialUserEntityRepository} + * + * @author Max Batischev + */ +public class JdbcPublicKeyCredentialUserEntityRepositoryTests { + + private EmbeddedDatabase db; + + private JdbcPublicKeyCredentialUserEntityRepository repository; + + private static final String USER_ENTITIES_SQL_RESOURCE = "org/springframework/security/user-entities-schema.sql"; + + @BeforeEach + void setUp() { + this.db = createDb(); + JdbcOperations jdbcOperations = new JdbcTemplate(this.db); + this.repository = new JdbcPublicKeyCredentialUserEntityRepository(jdbcOperations); + } + + @AfterEach + void tearDown() { + this.db.shutdown(); + } + + private static EmbeddedDatabase createDb() { + // @formatter:off + return new EmbeddedDatabaseBuilder() + .generateUniqueName(true) + .setType(EmbeddedDatabaseType.HSQL) + .setScriptEncoding("UTF-8") + .addScript(USER_ENTITIES_SQL_RESOURCE) + .build(); + // @formatter:on + } + + @Test + void constructorWhenJdbcOperationsIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new JdbcPublicKeyCredentialUserEntityRepository(null)) + .withMessage("jdbcOperations cannot be null"); + // @formatter:on + } + + @Test + void saveWhenUserEntityIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.repository.save(null)) + .withMessage("userEntity cannot be null"); + // @formatter:on + } + + @Test + void findByUserEntityIdWheIdIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.repository.findById(null)) + .withMessage("id cannot be null"); + // @formatter:on + } + + @Test + void findByUserNameWheUserNameIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.repository.findByUsername(null)) + .withMessage("name cannot be null or empty"); + // @formatter:on + } + + @Test + void saveUserEntityWhenSaveThenReturnsSaved() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + + this.repository.save(userEntity); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId()); + assertThat(savedUserEntity).isNotNull(); + assertThat(savedUserEntity.getId()).isEqualTo(userEntity.getId()); + assertThat(savedUserEntity.getDisplayName()).isEqualTo(userEntity.getDisplayName()); + assertThat(savedUserEntity.getName()).isEqualTo(userEntity.getName()); + } + + @Test + void saveUserEntityWhenUserEntityExistsThenUpdates() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + this.repository.save(userEntity); + + this.repository.save(testUserEntity(userEntity.getId())); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId()); + assertThat(savedUserEntity).isNotNull(); + assertThat(savedUserEntity.getId()).isEqualTo(userEntity.getId()); + assertThat(savedUserEntity.getDisplayName()).isEqualTo("user2"); + assertThat(savedUserEntity.getName()).isEqualTo("user2"); + } + + @Test + void findUserEntityByUserNameWhenUserEntityExistsThenReturnsSaved() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + this.repository.save(userEntity); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findByUsername(userEntity.getName()); + + assertThat(savedUserEntity).isNotNull(); + } + + @Test + void deleteUserEntityWhenRecordExistThenSuccess() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + this.repository.save(userEntity); + + this.repository.delete(userEntity.getId()); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId()); + assertThat(savedUserEntity).isNull(); + } + + @Test + void findUserEntityByIdWhenUserEntityDoesNotExistThenReturnsNull() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId()); + assertThat(savedUserEntity).isNull(); + } + + @Test + void findUserEntityByUserNameWhenUserEntityDoesNotExistThenReturnsEmpty() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findByUsername(userEntity.getName()); + assertThat(savedUserEntity).isNull(); + } + + private PublicKeyCredentialUserEntity testUserEntity(Bytes id) { + // @formatter:off + return ImmutablePublicKeyCredentialUserEntity.builder() + .name("user2") + .id(id) + .displayName("user2") + .build(); + // @formatter:on + } + +}