Merge branch '3.1.x' into 3.2.x

Closes gh-2827 in 3.2.x
Closes gh-2831
This commit is contained in:
Marcus Hert Da Coregio
2024-03-01 15:37:35 -03:00
17 changed files with 966 additions and 4 deletions

View File

@@ -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 <<session-storage-details,know how is the JDBC schema defined>>
- I want to <<customizing-table-name,customize the table name>>
- I want to <<customize-sql-queries,customize the SQL queries>>
- I want to save the <<session-attributes-as-json,session attributes as JSON>> instead of an array of bytes
- I want to <<specifying-datasource,use a different `DataSource`>> for Spring Session JDBC
- I want to <<customizing-transaction-operations,customize the JDBC transactions>>
[[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]
----
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-jdbc</artifactId>
</dependency>
----
======
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 <<customizing-table-name,customize the table name>>, the table used to store attributes is named by using the provided table name suffixed with `_ATTRIBUTES`.
If further customizations are needed, you can <<customize-sql-queries,customize the SQL queries used by the repository>>.
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<JdbcIndexedSessionRepository>` 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<JdbcIndexedSessionRepository> {
@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<JdbcIndexedSessionRepository> {
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<JdbcIndexedSessionRepository>` 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<Object> {
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<Object> {
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<JdbcIndexedSessionRepository> 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;
}
}
----
======

View File

@@ -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 {

View File

@@ -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()
}

View File

@@ -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";
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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<JdbcIndexedSessionRepository> 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<Object> {
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<Object> {
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);
}
}
}

View File

@@ -0,0 +1,3 @@
spring.security.user.password=password
spring.session.jdbc.schema=classpath:schema.sql
spring.session.jdbc.initialize-schema=always

View File

@@ -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
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,11 @@
<html xmlns:th="https://www.thymeleaf.org" xmlns:layout="https://github.com/ultraq/thymeleaf-layout-dialect" layout:decorate="~{layout}">
<head>
<title>Secured Content</title>
</head>
<body>
<div layout:fragment="content">
<h1>Secured Page</h1>
<p>This page is secured using Spring Boot, Spring Session, and Spring Security.</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,121 @@
<!DOCTYPE html SYSTEM "https://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-spring4-3.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="https://www.thymeleaf.org"
xmlns:layout="https://github.com/ultraq/thymeleaf-layout-dialect">
<head>
<title layout:title-pattern="$LAYOUT_TITLE - $CONTENT_TITLE">Spring Session Sample</title>
<link rel="icon" type="image/x-icon" th:href="@{/favicon.ico}" href="../static/favicon.ico"/>
<link th:href="@{/webjars/bootstrap/css/bootstrap.min.css}" href="/webjars/bootstrap/css/bootstrap.min.css" rel="stylesheet"></link>
<style type="text/css">
/* Sticky footer styles
-------------------------------------------------- */
html,
body {
height: 100%;
/* The html and body elements cannot have any padding or margin. */
}
/* Wrapper for page content to push down footer */
#wrap {
min-height: 100%;
height: auto !important;
height: 100%;
/* Negative indent footer by it's height */
margin: 0 auto -60px;
}
/* Set the fixed height of the footer here */
#push,
#footer {
height: 60px;
}
#footer {
background-color: #f5f5f5;
}
/* Lastly, apply responsive CSS fixes as necessary */
@media (max-width: 767px) {
#footer {
margin-left: -20px;
margin-right: -20px;
padding-left: 20px;
padding-right: 20px;
}
}
/* Custom page CSS
-------------------------------------------------- */
/* Not required for template or sticky footer method. */
.container {
width: auto;
max-width: 680px;
}
.container .credit {
margin: 20px 0;
text-align: center;
}
a {
color: green;
}
.navbar-form {
margin-left: 1em;
}
</style>
<link th:href="@{/webjars/bootstrap/css/bootstrap-responsive.min.css}" href="/webjars/bootstrap/css/bootstrap-responsive.min.css" rel="stylesheet"></link>
<!-- HTML5 shim, for IE6-8 support of HTML5 elements -->
<!--[if lt IE 9]>
<script th:src="@{/webjars/html5shiv/html5shiv.min.js}" src="/webjars/html5shiv/html5shiv.min.js"></script>
<![endif]-->
</head>
<body>
<div id="wrap">
<div class="navbar navbar-inverse navbar-static-top">
<div class="navbar-inner">
<div class="container">
<a class="brand" th:href="@{/}"><img th:src="@{/images/logo.png}" alt="Spring Security Sample"/></a>
<div class="nav-collapse collapse">
<div th:if="${currentUserName != null}">
<form class="navbar-form pull-right" th:action="@{/logout}" method="post">
<input type="submit" value="Log out" />
</form>
<p id="un" class="navbar-text pull-right" th:text="${currentUserName}">
sample_user
</p>
</div>
<ul class="nav">
</ul>
</div>
</div>
</div>
</div>
<!-- Begin page content -->
<div class="container">
<div class="alert alert-success"
th:if="${globalMessage}"
th:text="${globalMessage}">
Some Success message
</div>
<div layout:fragment="content">
Fake content
</div>
</div>
<div id="push"><!-- --></div>
</div>
<div id="footer">
<div class="container">
<p class="muted credit">Visit the <a href="https://projects.spring.io/spring-session/">Spring Session</a> site for more <a href="https://github.com/spring-projects/spring-session/tree/main/spring-session-samples">samples</a>.</p>
</div>
</div>
</body>
</html>

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}