From c2a982c4a780f7653c01eb1783303fbbaa775860 Mon Sep 17 00:00:00 2001 From: Marcus Hert Da Coregio Date: Fri, 1 Mar 2024 15:35:51 -0300 Subject: [PATCH] Add JDBC to Configurations section Closes gh-2827 Closes gh-2829 --- .../ROOT/pages/configuration/jdbc.adoc | 411 ++++++++++++++++++ .../spring-session-docs.gradle | 17 +- ...ion-sample-boot-jdbc-json-attribute.gradle | 26 ++ .../src/main/java/sample/IndexController.java | 36 ++ .../sample/JdbcJsonAttributeApplication.java | 32 ++ .../java/sample/UserControllerAdvise.java | 37 ++ .../java/sample/config/SecurityConfig.java | 49 +++ .../java/sample/config/SessionConfig.java | 81 ++++ .../src/main/resources/application.properties | 3 + .../src/main/resources/schema.sql | 24 + .../src/main/resources/static/favicon.ico | Bin 0 -> 1150 bytes .../src/main/resources/static/images/logo.png | Bin 0 -> 1123 bytes .../src/main/resources/templates/index.html | 11 + .../src/main/resources/templates/layout.html | 121 ++++++ .../JdbcJsonAttributeTestApplication.java | 11 + .../java/sample/JdbcJsonAttributeTests.java | 85 ++++ .../java/sample/TestContainersConfig.java | 26 ++ 17 files changed, 966 insertions(+), 4 deletions(-) create mode 100644 spring-session-docs/modules/ROOT/pages/configuration/jdbc.adoc create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/spring-session-sample-boot-jdbc-json-attribute.gradle create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/IndexController.java create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/JdbcJsonAttributeApplication.java create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/UserControllerAdvise.java create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/config/SecurityConfig.java create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/config/SessionConfig.java create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/application.properties create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/schema.sql create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/static/favicon.ico create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/static/images/logo.png create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/templates/index.html create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/templates/layout.html create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/test/java/sample/JdbcJsonAttributeTestApplication.java create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/test/java/sample/JdbcJsonAttributeTests.java create mode 100644 spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/test/java/sample/TestContainersConfig.java diff --git a/spring-session-docs/modules/ROOT/pages/configuration/jdbc.adoc b/spring-session-docs/modules/ROOT/pages/configuration/jdbc.adoc new file mode 100644 index 00000000..b75b4df0 --- /dev/null +++ b/spring-session-docs/modules/ROOT/pages/configuration/jdbc.adoc @@ -0,0 +1,411 @@ +[[jdbc-configurations]] += JDBC + +Now that you have your application xref:guides/boot-jdbc.adoc[configured to use JDBC], you might want to start customizing things: + +- I want to use Spring Session JDBC +- I want to <> +- I want to <> +- I want to <> +- I want to save the <> instead of an array of bytes +- I want to <> for Spring Session JDBC +- I want to <> + +[[adding-spring-session-jdbc]] +== Adding Spring Session JDBC To Your Application + +To use Spring Session JDBC, you must add the `org.springframework.session:spring-session-jdbc` dependency to your application + +[tabs] +====== +Gradle:: ++ +[source,groovy] +---- +implementation 'org.springframework.session:spring-session-jdbc' +---- +Maven:: ++ +[source,xml] +---- + + org.springframework.session + spring-session-jdbc + +---- +====== + +If you are using Spring Boot, it will take care of enabling Spring Session JDBC, see {spring-boot-ref-docs}/web.html#web.spring-session[its documentation] for more details. +Otherwise, you will need to add `@EnableJdbcHttpSession` to a configuration class: + +[tabs] +====== +Java:: ++ +[source,java] +---- +@Configuration +@EnableJdbcHttpSession +public class SessionConfig { + //... +} +---- +====== + +And that is it, your application now should be configured to use Spring Session JDBC. + +[[session-storage-details]] +== Understanding the Session Storage Details + +By default, the implementation uses `SPRING_SESSION` and `SPRING_SESSION_ATTRIBUTES` tables to store sessions. +Note that when you <>, the table used to store attributes is named by using the provided table name suffixed with `_ATTRIBUTES`. +If further customizations are needed, you can <>. + +Due to the differences between the various database vendors, especially when it comes to storing binary data, make sure to use SQL scripts specific to your database. +Scripts for most major database vendors are packaged as `org/springframework/session/jdbc/schema-\*.sql`, where `*` is the target database type. + +For example, with PostgreSQL, you can use the following schema script: + +==== +[source,sql,indent=0] +---- +include::{session-jdbc-main-resources-dir}org/springframework/session/jdbc/schema-postgresql.sql[] +---- +==== + +[[customizing-table-name]] +== Customizing the Table Name + +To customize the database table name, you can use the `tableName` attribute from the `@EnableJdbcHttpSession` annotation: + +[tabs] +====== +Java:: ++ +[source,java] +---- +@Configuration +@EnableJdbcHttpSession(tableName = "MY_TABLE_NAME") +public class SessionConfig { + //... +} +---- +====== + +Another alternative is to expose an implementation of `SessionRepositoryCustomizer` as a bean to change the table directly in the implementation: + +[tabs] +====== +Java:: ++ +[source,java] +---- +@Configuration +@EnableJdbcHttpSession +public class SessionConfig { + + @Bean + public TableNameCustomizer tableNameCustomizer() { + return new TableNameCustomizer(); + } + +} + +public class TableNameCustomizer + implements SessionRepositoryCustomizer { + + @Override + public void customize(JdbcIndexedSessionRepository sessionRepository) { + sessionRepository.setTableName("MY_TABLE_NAME"); + } + +} +---- +====== + +[[customize-sql-queries]] +== Customizing the SQL Queries + +At times, it is useful to be able to customize the SQL queries executed by Spring Session JDBC. +There are scenarios where there may be concurrent modifications to the session or its attributes in the database, for example, a request might want to insert an attribute that already exists, resulting in a duplicate key exception. +Because of that, you can apply RDBMS specific queries that handles such scenarios. +To customize the SQL queries that Spring Session JDBC executes against your database, you can use the `set*Query` methods from `JdbcIndexedSessionRepository`. + +[tabs] +====== +Java:: ++ +[source,java] +---- +@Configuration +@EnableJdbcHttpSession +public class SessionConfig { + + @Bean + public QueryCustomizer tableNameCustomizer() { + return new QueryCustomizer(); + } + +} + +public class QueryCustomizer + implements SessionRepositoryCustomizer { + + private static final String CREATE_SESSION_ATTRIBUTE_QUERY = """ + INSERT INTO %TABLE_NAME%_ATTRIBUTES (SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES) <1> + VALUES (?, ?, ?) + ON CONFLICT (SESSION_PRIMARY_ID, ATTRIBUTE_NAME) + DO NOTHING + """; + + @Override + public void customize(JdbcIndexedSessionRepository sessionRepository) { + sessionRepository.setCreateSessionAttributeQuery(CREATE_SESSION_ATTRIBUTE_QUERY); + } + +} +---- +====== + +<1> The `%TABLE_NAME%` placeholder in the query will be replaced by the configured table name being used by `JdbcIndexedSessionRepository`. + +[TIP] +==== +Spring Session JDBC ships with a few implementations of `SessionRepositoryCustomizer` that configure optimized SQL queries for the most common RDBMS. +==== + +[[session-attributes-as-json]] +== Saving Session Attributes as JSON + +Sometimes it is useful to save the session attributes in different formats, like JSON, which might have native support in the RDBMS allowing better function and operators compatibility in SQL queries. + +By default, Spring Session JDBC saves the session attributes values as an array of bytes, such array is result from the JDK Serialization of the attribute value. + +For this example, we are going to use https://www.postgresql.org/[PostgreSQL] as our RDBMS. +Let's start by creating the `SPRING_SESSION_ATTRIBUTES` table with a `jsonb` type for the `attribute_values` column. + +[tabs] +====== +SQL:: ++ +[source,sql] +---- +CREATE TABLE SPRING_SESSION +( + -- ... +); + +-- indexes... + +CREATE TABLE SPRING_SESSION_ATTRIBUTES +( + -- ... + ATTRIBUTE_BYTES JSONB NOT NULL, + -- ... +); + +---- +====== + +To customize how the attribute values are serialized, first we need to provide to Spring Session JDBC a {spring-framework-ref-docs}/core/validation/convert.html#core-convert-ConversionService-API[custom `ConversionService`] responsible for converting from `Object` to `byte[]` and vice-versa. +To do that, we can create a bean of type `ConversionService` named `springSessionConversionService`. + +[tabs] +====== +Java:: ++ +[source,java] +---- +import org.springframework.beans.factory.BeanClassLoaderAware; +import org.springframework.core.serializer.support.DeserializingConverter; +import org.springframework.core.serializer.support.SerializingConverter; + +@Configuration +@EnableJdbcHttpSession +public class SessionConfig implements BeanClassLoaderAware { + + private ClassLoader classLoader; + + @Bean("springSessionConversionService") + public GenericConversionService springSessionConversionService(ObjectMapper objectMapper) { <1> + ObjectMapper copy = objectMapper.copy(); <2> + copy.registerModules(SecurityJackson2Modules.getModules(this.classLoader)); <3> + GenericConversionService converter = new GenericConversionService(); + converter.addConverter(Object.class, byte[].class, new SerializingConverter(new JsonSerializer(copy))); <4> + converter.addConverter(byte[].class, Object.class, new DeserializingConverter(new JsonDeserializer(copy))); <4> + return converter; + } + + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + this.classLoader = classLoader; + } + + static class JsonSerializer implements Serializer { + + private final ObjectMapper objectMapper; + + JsonSerializer(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void serialize(Object object, OutputStream outputStream) throws IOException { + this.objectMapper.writeValue(outputStream, object); + } + + } + + static class JsonDeserializer implements Deserializer { + + private final ObjectMapper objectMapper; + + JsonDeserializer(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public Object deserialize(InputStream inputStream) throws IOException { + return this.objectMapper.readValue(inputStream, Object.class); + } + + } + +} +---- +====== + +<1> Inject the `ObjectMapper` that is used by default in the application. +You can create a new one if you prefer. +<2> Create a copy of that `ObjectMapper` so we only apply the changes to the copy. +<3> Since we are using Spring Security, we must register its Jackson Modules that tells Jackson how to properly serialize/deserialize Spring Security's objects. +You might need to do the same for other objects that are persisted in the session. +<4> Add the `JsonSerializer`/`JsonDeserializer` that we created into the `ConversionService`. + +Now that we configured how Spring Session JDBC converts our attributes values into `byte[]`, we must customize the query that insert the session attributes. +The customization is necessary because Spring Session JDBC sets content as bytes in the SQL statement, however, `bytea` is not compatible with `jsonb`, therefore we need to encode the `bytea` value to text and then convert it to `jsonb`. + +[tabs] +====== +Java:: ++ +[source,java] +---- +@Configuration +@EnableJdbcHttpSession +public class SessionConfig { + + private static final String CREATE_SESSION_ATTRIBUTE_QUERY = """ + INSERT INTO %TABLE_NAME%_ATTRIBUTES (SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES) + VALUES (?, ?, encode(?, 'escape')::jsonb) <1> + """; + + @Bean + SessionRepositoryCustomizer customizer() { + return (sessionRepository) -> sessionRepository.setCreateSessionAttributeQuery(CREATE_SESSION_ATTRIBUTE_QUERY); + } + +} +---- +====== + +<1> Uses the https://www.postgresql.org/docs/current/functions-binarystring.html[PostgreSQL encode] function to convert from `bytea` to `text` + +And that's it, you should now be able to see the session attributes saved as JSON in the database. +There is a sample available where you can see the whole implementation and run the tests. + +[[specifying-datasource]] +== Specifying an alternative `DataSource` + +By default, Spring Session JDBC uses the primary `DataSource` bean that is available in the application. +However, there are some scenarios where an application might have multiple ``DataSource``s beans, in such scenarios you can tell Spring Session JDBC which `DataSource` to use by qualifying the bean with `@SpringSessionDataSource`: + +[tabs] +====== +Java:: ++ +[source,java] +---- +import org.springframework.session.jdbc.config.annotation.SpringSessionDataSource; + +@Configuration +@EnableJdbcHttpSession +public class SessionConfig { + + @Bean + public DataSource dataSourceOne() { + // create and configure datasource + return dataSourceOne; + } + + @Bean + @SpringSessionDataSource <1> + public DataSource dataSourceTwo() { + // create and configure datasource + return dataSourceTwo; + } + +} +---- +====== + +<1> We annotate the `dataSourceTwo` bean with `@SpringSessionDataSource` to tell Spring Session JDBC that it should use that bean as the `DataSource`. + +[[customizing-transaction-operations]] +== Customizing How Spring Session JDBC Uses Transactions + +All JDBC operations are performed in a transactional manner. +Transactions are performed with propagation set to `REQUIRES_NEW` in order to avoid unexpected behavior due to interference with existing transactions (for example, running a save operation in a thread that already participates in a read-only transaction). +To customize how Spring Session JDBC uses transactions, you can provide a `TransactionOperations` bean named `springSessionTransactionOperations`. +For example, if you want to disable transactions as a whole, you can do: + +[tabs] +====== +Java:: ++ +[source,java] +---- +import org.springframework.transaction.support.TransactionOperations; + +@Configuration +@EnableJdbcHttpSession +public class SessionConfig { + + @Bean("springSessionTransactionOperations") + public TransactionOperations springSessionTransactionOperations() { + return TransactionOperations.withoutTransaction(); + } + +} +---- +====== + +If you want more control, you can also provide the `TransactionManager` that is used by the configured `TransactionTemplate`. +By default, Spring Session will try to resolve the primary `TransactionManager` bean from the application context. +In some scenarios, for example when there are multiple ``DataSource``s, it is very likely that there will be multiple ``TransactionManager``s, you can tell which `TransactionManager` bean that you want to use with Spring Session JDBC by qualifying it with `@SpringSessionTransactionManager`: + +[tabs] +====== +Java:: ++ +[source,java] +---- +@Configuration +@EnableJdbcHttpSession +public class SessionConfig { + + @Bean + @SpringSessionTransactionManager + public TransactionManager transactionManager1() { + return new MyTransactionManager(); + } + + @Bean + public TransactionManager transactionManager2() { + return otherTransactionManager; + } + +} +---- +====== + diff --git a/spring-session-docs/spring-session-docs.gradle b/spring-session-docs/spring-session-docs.gradle index 03e42d15..32f1e5f4 100644 --- a/spring-session-docs/spring-session-docs.gradle +++ b/spring-session-docs/spring-session-docs.gradle @@ -49,20 +49,29 @@ tasks.named("generateAntoraYml") { def generateAttributes() { - def springBootVersion = libs.versions.org.springframework.boot.get() - springBootVersion = springBootVersion.contains("-") - ? springBootVersion.substring(0, springBootVersion.indexOf("-")) - : springBootVersion + def springBootVersion = getLibVersion(libs.versions.org.springframework.boot.get()) + def springSecurityVersion = getLibVersion(libs.org.springframework.security.spring.security.bom.get().version) + def springFrameworkVersion = getLibVersion(libs.org.springframework.spring.framework.bom.get().version) def ghTag = snapshotBuild ? 'main' : project.version def docsUrl = 'https://docs.spring.io' def springBootRefDocs = "${docsUrl}/spring-boot/docs/${springBootVersion}/reference/html" + def springSecurityRefDocs = "${docsUrl}/spring-security/reference/${springSecurityVersion}" + def springFrameworkRefDocs = "${docsUrl}/spring-framework/reference/${springFrameworkVersion}" return ['gh-tag':ghTag, 'spring-boot-version': springBootVersion, 'spring-boot-ref-docs': springBootRefDocs.toString(), 'spring-session-version': project.version, + 'spring-security-ref-docs': springSecurityRefDocs.toString(), + 'spring-framework-ref-docs': springFrameworkRefDocs.toString(), 'docs-url': docsUrl] } +static def getLibVersion(String version) { + return version.contains("-") + ? version.substring(0, version.indexOf("-")) + : version +} + sourceSets { test { java { diff --git a/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/spring-session-sample-boot-jdbc-json-attribute.gradle b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/spring-session-sample-boot-jdbc-json-attribute.gradle new file mode 100644 index 00000000..cdf8f52a --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/spring-session-sample-boot-jdbc-json-attribute.gradle @@ -0,0 +1,26 @@ +apply plugin: 'io.spring.convention.spring-sample-boot' + +dependencies { + management platform(project(":spring-session-dependencies")) + implementation project(':spring-session-jdbc') + implementation "org.springframework.boot:spring-boot-starter-jdbc" + implementation "org.springframework.boot:spring-boot-starter-web" + implementation "org.springframework.boot:spring-boot-starter-thymeleaf" + implementation "org.springframework.boot:spring-boot-starter-security" + implementation "nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect" + implementation "org.webjars:bootstrap" + implementation "org.webjars:html5shiv" + implementation "org.webjars:webjars-locator-core" + + runtimeOnly 'org.postgresql:postgresql' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:postgresql' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/IndexController.java b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/IndexController.java new file mode 100644 index 00000000..1362599b --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/IndexController.java @@ -0,0 +1,36 @@ +/* + * Copyright 2014-2019 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 sample; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * Controller for sending the user to the login view. + * + * @author Rob Winch + * + */ +@Controller +public class IndexController { + + @RequestMapping("/") + public String index() { + return "index"; + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/JdbcJsonAttributeApplication.java b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/JdbcJsonAttributeApplication.java new file mode 100644 index 00000000..80e051a5 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/JdbcJsonAttributeApplication.java @@ -0,0 +1,32 @@ +/* + * Copyright 2014-2019 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 sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Rob Winch + */ +@SpringBootApplication +public class JdbcJsonAttributeApplication { + + public static void main(String[] args) { + SpringApplication.run(JdbcJsonAttributeApplication.class, args); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/UserControllerAdvise.java b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/UserControllerAdvise.java new file mode 100644 index 00000000..a1d43d80 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/UserControllerAdvise.java @@ -0,0 +1,37 @@ +/* + * Copyright 2014-2022 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 sample; + +import java.security.Principal; + +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +/** + * {@link ControllerAdvice} to expose security related attributes. + * + * @author Rob Winch + */ +@ControllerAdvice +public class UserControllerAdvise { + + @ModelAttribute("currentUserName") + String currentUser(Principal principal) { + return (principal != null) ? principal.getName() : null; + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/config/SecurityConfig.java b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/config/SecurityConfig.java new file mode 100644 index 00000000..04276382 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/config/SecurityConfig.java @@ -0,0 +1,49 @@ +/* + * Copyright 2014-2022 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 sample.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Spring Security configuration. + * + * @author Rob Winch + * @author Vedran Pavic + */ +@Configuration +public class SecurityConfig { + + // @formatter:off + // tag::config[] + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + .formLogin((formLogin) -> formLogin + .permitAll() + ) + .build(); + } + // end::config[] + // @formatter:on + +} diff --git a/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/config/SessionConfig.java b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/config/SessionConfig.java new file mode 100644 index 00000000..ee2f4e50 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/java/sample/config/SessionConfig.java @@ -0,0 +1,81 @@ +package sample.config; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.beans.factory.BeanClassLoaderAware; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.core.serializer.Deserializer; +import org.springframework.core.serializer.Serializer; +import org.springframework.core.serializer.support.DeserializingConverter; +import org.springframework.core.serializer.support.SerializingConverter; +import org.springframework.security.jackson2.SecurityJackson2Modules; +import org.springframework.session.config.SessionRepositoryCustomizer; +import org.springframework.session.jdbc.JdbcIndexedSessionRepository; + +@Configuration(proxyBeanMethods = false) +public class SessionConfig implements BeanClassLoaderAware { + + private static final String CREATE_SESSION_ATTRIBUTE_QUERY = """ + INSERT INTO %TABLE_NAME%_ATTRIBUTES (SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES) + VALUES (?, ?, encode(?, 'escape')::jsonb) + """; + + private ClassLoader classLoader; + + @Bean + SessionRepositoryCustomizer customizer() { + return (sessionRepository) -> sessionRepository.setCreateSessionAttributeQuery(CREATE_SESSION_ATTRIBUTE_QUERY); + } + + @Bean("springSessionConversionService") + public GenericConversionService springSessionConversionService(ObjectMapper objectMapper) { + ObjectMapper copy = objectMapper.copy(); + copy.registerModules(SecurityJackson2Modules.getModules(this.classLoader)); + GenericConversionService converter = new GenericConversionService(); + converter.addConverter(Object.class, byte[].class, new SerializingConverter(new JsonSerializer(copy))); + converter.addConverter(byte[].class, Object.class, new DeserializingConverter(new JsonDeserializer(copy))); + return converter; + } + + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + this.classLoader = classLoader; + } + + static class JsonSerializer implements Serializer { + + private final ObjectMapper objectMapper; + + JsonSerializer(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void serialize(Object object, OutputStream outputStream) throws IOException { + this.objectMapper.writeValue(outputStream, object); + } + + } + + static class JsonDeserializer implements Deserializer { + + private final ObjectMapper objectMapper; + + JsonDeserializer(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public Object deserialize(InputStream inputStream) throws IOException { + return this.objectMapper.readValue(inputStream, Object.class); + } + + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/application.properties b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/application.properties new file mode 100644 index 00000000..e8e848d1 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/application.properties @@ -0,0 +1,3 @@ +spring.security.user.password=password +spring.session.jdbc.schema=classpath:schema.sql +spring.session.jdbc.initialize-schema=always diff --git a/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/schema.sql b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/schema.sql new file mode 100644 index 00000000..be268dec --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/schema.sql @@ -0,0 +1,24 @@ +CREATE TABLE SPRING_SESSION +( + PRIMARY_ID CHAR(36) NOT NULL, + SESSION_ID CHAR(36) NOT NULL, + CREATION_TIME BIGINT NOT NULL, + LAST_ACCESS_TIME BIGINT NOT NULL, + MAX_INACTIVE_INTERVAL INT NOT NULL, + EXPIRY_TIME BIGINT NOT NULL, + PRINCIPAL_NAME VARCHAR(100), + CONSTRAINT SPRING_SESSION_PK PRIMARY KEY (PRIMARY_ID) +); + +CREATE UNIQUE INDEX SPRING_SESSION_IX1 ON SPRING_SESSION (SESSION_ID); +CREATE INDEX SPRING_SESSION_IX2 ON SPRING_SESSION (EXPIRY_TIME); +CREATE INDEX SPRING_SESSION_IX3 ON SPRING_SESSION (PRINCIPAL_NAME); + +CREATE TABLE SPRING_SESSION_ATTRIBUTES +( + SESSION_PRIMARY_ID CHAR(36) NOT NULL, + ATTRIBUTE_NAME VARCHAR(200) NOT NULL, + ATTRIBUTE_BYTES JSONB NOT NULL, + CONSTRAINT SPRING_SESSION_ATTRIBUTES_PK PRIMARY KEY (SESSION_PRIMARY_ID, ATTRIBUTE_NAME), + CONSTRAINT SPRING_SESSION_ATTRIBUTES_FK FOREIGN KEY (SESSION_PRIMARY_ID) REFERENCES SPRING_SESSION (PRIMARY_ID) ON DELETE CASCADE +); diff --git a/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/static/favicon.ico b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..bfb9974019d4b8b978cf34ea67f0c6804f9b76c6 GIT binary patch literal 1150 zcmZQzU<5(|0R|wcz>vYhz#zuJz@P!dKp~(AL>x#lFaYI*xFHzK2NLHVUHb1iG2*{Y zgB?lOds5VYkY4wx3IDZQ-O0jkQ{(?@bp=p{C&v8O!h&CXe)}H=o_u_bRSig==8O!i zc=x^YFzx?WUq6CX4M?xX{1U9V`}7vL{^Em6v8n;-RbSqS702#f2-g1(NLsD$!KwzN zS8ZJ%R&20o^8Y{o{{6r8@eNjaxV~+({;Q#3&F!=Q_uf49|L5O7|6hFn@<03RE(|p= zJ*s<_{YS^WcP_&H4q|}(5Orn`hB}ZwmBSk_@QDwvQS^g2lONu|PzTm~V&{JqG(7$3 zJq-P6H_u_H1L;#fa{vQto;~z`$*ZUT-~ayo|MAc7|C=7&#!v&-2UM#}0rtFhmTdiw fS5E!E^7Zq7x2tDJHpBVK>Hp`xeEbh92gs!XBi)`m literal 0 HcmV?d00001 diff --git a/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/static/images/logo.png b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/static/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..393230883fb4e04829339f7c21b6ab6de7393891 GIT binary patch literal 1123 zcmV-p1f2VcP)P000yS1^@s74{)X{0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU$1xZ9fRCwBAJakr>0R&#%?*!8<7oReN z=>H6CRSb*_9RHalf3;=rzRt=3!VW|AET)fs_IS z5aXsJDqv;5UUXCgspCNG%qCu4_4m8Q8J4{_Ss=DB)G%-}OK$)A_qiv-M@As~;az|L zf?EIsP`7cheE7}?GEN=HaRK6^jLh%PvWj*xv3z@3xac)oRV-9P3=lIhyklZuIsRdC zEQk#P>`Ve}KmUCIvzcu9Tf*+YVP*hfGu{$8D7y_HfEf3kMztIrynEOQW2*Ct&A;*G zO}I7VlHICc5w;GSdsSFH1nBbCHtgWCZ;JNm@ z`XjO?7RGP?S?;`M26}|$>d$}7+Q^c380tVF%ErLJjAqwYZkA^sS%0xHF}~wxlbiD5 z`(qoR=no*41>&DbDvtqiaJE+C3lIk&fG}bK9l%ly6jZSM`Nu#MU}XG#RzRlJh~>*q zMz|m_S~GwYKiDe_40nN83W%$i`8HWIa~)&&`WqMkpMdet@v+_G7x%2~|M)9$Tk#!Y zFw1)&gOROi`~TlS|Ne#uGBU8e{?G6W7-39r7#RMspu`I>^-8__`jg@PS4KGI{tDI)dzZ%dlS>M59FF!%vUTz)~b4X4$^~`TvW7 ziIMs5zyE)kk;ES}`AIdM1t$C=P~wMS3!!9Ap!k0ap%i9haWBalIY!4O?f(oAd%r+4 zo)k2;d7%b_#Jhnp-2~zQ1Q@}XCIrw@0t|34GX2=V#dp;1>j!5(mhZn98K?y0fw-87 z<#h&!;7V3zwkKdg)?fU+ET4a(DSgvn03;_eFu%5AV7&(n_piW02N=Hpff?mLFf{&g z0%=ZYR2CvOD9l@{`Ma!o3LDu1eZb}An>(a<~Q?=8{fF=xc@W!MoZ)b z{O|{gu_$Ev|5TXe{Z}Tay7jj||2@OO@D(hD)hsRsjrwm4H=1C4P6p+J-x==O;Vpp} zelf7T{l>^3$L4GI;MZmjLQek2%+jdD-YkChTV1vRXZ6WjKj)c!`-fi-2P5B87D!gv za)A+O=YLR%j|aSGxW@31vGu^;|BMVrzfaux>)%&1LJk9(o(#_DK=28Oy@5ho@oC^@ z5m@o{&xh3zgTK{rGV>dM|NF@cm+`=a;1wp + + Secured Content + + +
+

Secured Page

+

This page is secured using Spring Boot, Spring Session, and Spring Security.

+
+ + diff --git a/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/templates/layout.html b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/templates/layout.html new file mode 100644 index 00000000..bb42db27 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/main/resources/templates/layout.html @@ -0,0 +1,121 @@ + + + + Spring Session Sample + + + + + + + + + + + +
+ + +
+
+ Some Success message +
+
+ Fake content +
+
+ +
+
+ + + + diff --git a/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/test/java/sample/JdbcJsonAttributeTestApplication.java b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/test/java/sample/JdbcJsonAttributeTestApplication.java new file mode 100644 index 00000000..88acc8f6 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/test/java/sample/JdbcJsonAttributeTestApplication.java @@ -0,0 +1,11 @@ +package sample; + +import org.springframework.boot.SpringApplication; + +public class JdbcJsonAttributeTestApplication { + + public static void main(String[] args) { + SpringApplication.from(JdbcJsonAttributeApplication::main).with(TestContainersConfig.class).run(args); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/test/java/sample/JdbcJsonAttributeTests.java b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/test/java/sample/JdbcJsonAttributeTests.java new file mode 100644 index 00000000..b7e17e65 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/test/java/sample/JdbcJsonAttributeTests.java @@ -0,0 +1,85 @@ +package sample; + +import java.sql.Types; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.Cookie; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.jackson2.SecurityJackson2Modules; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@Import(TestContainersConfig.class) +public class JdbcJsonAttributeTests { + + @Autowired + MockMvc mvc; + + @Autowired + ObjectMapper objectMapper; + + ObjectMapper objectMapperWithModules; + + @Autowired + JdbcClient jdbcClient; + + @BeforeEach + void setup() { + ObjectMapper copy = this.objectMapper.copy(); + copy.registerModules(SecurityJackson2Modules.getModules(getClass().getClassLoader())); + this.objectMapperWithModules = copy; + this.jdbcClient.sql("DELETE FROM spring_session_attributes").update(); + this.jdbcClient.sql("DELETE FROM spring_session").update(); + } + + @Test + void loginShouldSaveSecurityContextAsJson() throws Exception { + Cookie sessionCookie = this.mvc.perform(formLogin().user("user").password("password")) + .andExpect(authenticated()) + .andReturn() + .getResponse() + .getCookie("SESSION"); + String sessionId = new String(Base64.getDecoder().decode(sessionCookie.getValue())); + Object attributeBytes = this.jdbcClient.sql(""" + SELECT attribute_bytes::text FROM spring_session_attributes + INNER JOIN spring_session s ON s.primary_id = session_primary_id + WHERE attribute_name = 'SPRING_SECURITY_CONTEXT' + AND s.session_id = :id + """).param("id", sessionId).query().singleValue(); + SecurityContext securityContext = this.objectMapperWithModules.readValue((String) attributeBytes, + SecurityContext.class); + assertThat(securityContext).isNotNull(); + assertThat(securityContext.getAuthentication().getName()).isEqualTo("user"); + } + + @Test + void loginWhenQueryUsingJsonbOperatorThenReturns() throws Exception { + this.mvc.perform(formLogin().user("user").password("password")).andExpect(authenticated()); + Object attributeBytes = this.jdbcClient.sql(""" + SELECT attribute_bytes::text FROM spring_session_attributes + WHERE attribute_bytes -> 'authentication' -> 'principal' ->> 'username' = 'user' + """).query().singleValue(); + SecurityContext securityContext = this.objectMapperWithModules.readValue((String) attributeBytes, + SecurityContext.class); + assertThat(securityContext).isNotNull(); + assertThat(securityContext.getAuthentication().getName()).isEqualTo("user"); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/test/java/sample/TestContainersConfig.java b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/test/java/sample/TestContainersConfig.java new file mode 100644 index 00000000..e39a1f34 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute/src/test/java/sample/TestContainersConfig.java @@ -0,0 +1,26 @@ +package sample; + +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; + +@TestConfiguration(proxyBeanMethods = false) +public class TestContainersConfig { + + static PostgreSQLContainer postgresql = new PostgreSQLContainer<>(DockerImageName.parse("postgres:16.1")) + .withExposedPorts(5432); + + static { + postgresql.start(); + } + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return postgresql; + } + +}