diff --git a/.github/dco.yml b/.github/dco.yml new file mode 100644 index 000000000..0c4b142e9 --- /dev/null +++ b/.github/dco.yml @@ -0,0 +1,2 @@ +require: + members: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..c5f9cf197 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,92 @@ +version: 2 + +registries: + spring-milestones: + type: maven-repository + url: https://repo.spring.io/milestone + +updates: + + - package-ecosystem: "gradle" + target-branch: "main" + directory: "/" + schedule: + interval: "daily" + time: "03:00" + timezone: "Etc/UTC" + labels: [ "type: dependency-upgrade" ] + registries: + - "spring-milestones" + ignore: + - dependency-name: "org.junit:junit-bom" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.mockito:mockito-bom" + update-types: [ "version-update:semver-major" ] + - dependency-name: "*" + update-types: [ "version-update:semver-major" ] + + - package-ecosystem: "gradle" + target-branch: "3.3.x" + directory: "/" + schedule: + interval: "daily" + time: "03:00" + timezone: "Etc/UTC" + labels: [ "type: dependency-upgrade" ] + registries: + - "spring-milestones" + ignore: + - dependency-name: "org.junit:junit-bom" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.mockito:mockito-bom" + update-types: [ "version-update:semver-major" ] + - dependency-name: "*" + update-types: [ "version-update:semver-major", "version-update:semver-minor" ] + + - package-ecosystem: "gradle" + target-branch: "3.2.x" + directory: "/" + schedule: + interval: "daily" + time: "03:00" + timezone: "Etc/UTC" + labels: [ "type: dependency-upgrade" ] + ignore: + - dependency-name: "org.junit:junit-bom" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.mockito:mockito-bom" + update-types: [ "version-update:semver-major" ] + - dependency-name: "*" + update-types: [ "version-update:semver-major", "version-update:semver-minor" ] + + # GitHub Actions + + - package-ecosystem: github-actions + target-branch: "main" + milestone: 154 + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + target-branch: "3.3.x" + milestone: 152 + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + target-branch: "3.2.x" + milestone: 151 + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + target-branch: "docs-build" + directory: "/" + schedule: + interval: weekly + + - package-ecosystem: npm + target-branch: docs-build + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/release-scheduler.yml b/.github/workflows/release-scheduler.yml index 04f640d10..c4e6b5083 100644 --- a/.github/workflows/release-scheduler.yml +++ b/.github/workflows/release-scheduler.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: # List of active maintenance branches. - branch: [ main, 3.2.x, 3.1.x ] + branch: [ main, 3.3.x, 3.2.x ] runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/update-antora-ui-spring.yml b/.github/workflows/update-antora-ui-spring.yml new file mode 100644 index 000000000..1004d233e --- /dev/null +++ b/.github/workflows/update-antora-ui-spring.yml @@ -0,0 +1,22 @@ +name: Update Antora UI Spring + +on: + schedule: + - cron: '0 10 * * *' # Once per day at 10am UTC + workflow_dispatch: + +permissions: + pull-requests: write + issues: write + contents: write + +jobs: + update-antora-ui-spring-docs-build: + runs-on: ubuntu-latest + name: Update on docs-build + steps: + - uses: spring-io/spring-doc-actions/update-antora-spring-ui@852920ba3fb1f28b35a2f13201133bc00ef33677 + name: Update + with: + docs-branch: 'docs-build' + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.adoc b/CONTRIBUTING.adoc index e3c14c80d..a9828d32d 100644 --- a/CONTRIBUTING.adoc +++ b/CONTRIBUTING.adoc @@ -33,7 +33,7 @@ Switch to a branch named `..x` from the smallest milestone in the The spring team will ensure the code gets merged forward into additional branches. -== Sign the Contributor License Agreement -If you have not previously done so, please fill out and -submit the https://cla.pivotal.io/sign/spring[Contributor License Agreement]. +== Add Signed-off-by Trailer +All commits must include a __Signed-off-by__ trailer at the end of each commit message to indicate that the contributor agrees to the Developer Certificate of Origin. +For additional details, please refer to the blog post https://spring.io/blog/2025/01/06/hello-dco-goodbye-cla-simplifying-contributions-to-spring[Hello DCO, Goodbye CLA: Simplifying Contributions to Spring]. diff --git a/build.gradle b/build.gradle index 078e4c9c6..6ef64296b 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,10 @@ buildscript { } } +plugins { + id "com.github.ben-manes.versions" +} + apply plugin: 'io.spring.convention.root' apply plugin: 'io.spring.security.release' @@ -40,6 +44,11 @@ subprojects { tasks.withType(Test) { useJUnitPlatform() } + + // Spring Framework 6.1 requires -parameters to be able to introspect method parameter names + tasks.withType(JavaCompile) { + options.compilerArgs.add("-parameters") + } } nohttp { diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 979e23c82..d1598dc5f 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -7,7 +7,6 @@ plugins { sourceCompatibility = JavaVersion.VERSION_17 repositories { - jcenter() gradlePluginPortal() mavenCentral() maven { url 'https://repo.spring.io/plugins-release/' } @@ -57,32 +56,32 @@ configurations { } dependencies { - implementation 'com.google.code.gson:gson:2.8.9' + implementation 'com.google.code.gson:gson:2.10.1' implementation 'net.sourceforge.saxon:saxon:9.1.0.8' - implementation 'org.yaml:snakeyaml:1.30' + implementation 'org.yaml:snakeyaml:1.33' implementation localGroovy() - implementation 'io.github.gradle-nexus:publish-plugin:1.1.0' - implementation 'io.spring.gradle:dependency-management-plugin:1.0.15.RELEASE' - implementation 'io.projectreactor:reactor-core:3.4.38' - implementation 'com.apollographql.apollo:apollo-runtime:2.4.5' - implementation 'com.github.ben-manes:gradle-versions-plugin:0.38.0' - implementation 'com.github.spullara.mustache.java:compiler:0.9.13' - implementation 'io.spring.javaformat:spring-javaformat-gradle-plugin:0.0.41' + implementation 'io.github.gradle-nexus:publish-plugin:1.3.0' + implementation 'io.spring.gradle:dependency-management-plugin:1.1.7' + implementation 'io.projectreactor:reactor-core:3.6.13' + implementation 'com.apollographql.apollo:apollo-runtime:2.5.14' + implementation 'com.github.ben-manes:gradle-versions-plugin:0.51.0' + implementation 'com.github.spullara.mustache.java:compiler:0.9.14' + implementation 'io.spring.javaformat:spring-javaformat-gradle-plugin:0.0.43' implementation 'io.spring.nohttp:nohttp-gradle:0.0.11' - implementation 'net.sourceforge.htmlunit:htmlunit:2.37.0' + implementation 'net.sourceforge.htmlunit:htmlunit:2.70.0' implementation 'org.hidetake:gradle-ssh-plugin:2.10.1' - implementation 'org.jfrog.buildinfo:build-info-extractor-gradle:4.29.4' - implementation 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.7.1' + implementation 'org.jfrog.buildinfo:build-info-extractor-gradle:4.33.23' + implementation 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.8.0.1969' implementation libs.com.squareup.okhttp3.okhttp implementation libs.io.spring.security.release.plugin - testImplementation platform('org.junit:junit-bom:5.8.2') + testImplementation platform('org.junit:junit-bom:5.10.5') testImplementation "org.junit.jupiter:junit-jupiter-api" testImplementation "org.junit.jupiter:junit-jupiter-params" testImplementation "org.junit.jupiter:junit-jupiter-engine" testImplementation 'org.apache.commons:commons-io:1.3.2' - testImplementation 'org.assertj:assertj-core:3.21.0' + testImplementation 'org.assertj:assertj-core:3.25.3' testImplementation 'org.mockito:mockito-core:3.12.4' testImplementation 'org.mockito:mockito-junit-jupiter:3.12.4' testImplementation 'com.squareup.okhttp3:mockwebserver:3.14.9' diff --git a/buildSrc/src/main/groovy/io/spring/gradle/convention/RepositoryConventionPlugin.groovy b/buildSrc/src/main/groovy/io/spring/gradle/convention/RepositoryConventionPlugin.groovy index c24208142..407163d82 100644 --- a/buildSrc/src/main/groovy/io/spring/gradle/convention/RepositoryConventionPlugin.groovy +++ b/buildSrc/src/main/groovy/io/spring/gradle/convention/RepositoryConventionPlugin.groovy @@ -35,11 +35,6 @@ class RepositoryConventionPlugin implements Plugin { mavenLocal() } mavenCentral() - jcenter() { - content { - includeGroup "org.gretty" - } - } if (isSnapshot) { maven { name = 'artifactory-snapshot' diff --git a/buildSrc/src/main/java/org/springframework/gradle/maven/PublishLocalPlugin.java b/buildSrc/src/main/java/org/springframework/gradle/maven/PublishLocalPlugin.java index 54f9e4971..34dcaeda6 100644 --- a/buildSrc/src/main/java/org/springframework/gradle/maven/PublishLocalPlugin.java +++ b/buildSrc/src/main/java/org/springframework/gradle/maven/PublishLocalPlugin.java @@ -1,9 +1,7 @@ package org.springframework.gradle.maven; -import org.gradle.api.Action; import org.gradle.api.Plugin; import org.gradle.api.Project; -import org.gradle.api.artifacts.repositories.MavenArtifactRepository; import org.gradle.api.publish.PublishingExtension; import org.gradle.api.publish.maven.plugins.MavenPublishPlugin; @@ -12,18 +10,12 @@ public class PublishLocalPlugin implements Plugin { @Override public void apply(Project project) { - project.getPlugins().withType(MavenPublishPlugin.class).all(new Action() { - @Override - public void execute(MavenPublishPlugin mavenPublish) { - PublishingExtension publishing = project.getExtensions().getByType(PublishingExtension.class); - publishing.getRepositories().maven(new Action() { - @Override - public void execute(MavenArtifactRepository maven) { - maven.setName("local"); - maven.setUrl(new File(project.getRootProject().getBuildDir(), "publications/repos")); - } - }); - } + project.getPlugins().withType(MavenPublishPlugin.class).all(mavenPublish -> { + PublishingExtension publishing = project.getExtensions().getByType(PublishingExtension.class); + publishing.getRepositories().maven(maven -> { + maven.setName("local"); + maven.setUrl(new File(project.getRootProject().getBuildDir(), "publications/repos")); + }); }); } } diff --git a/buildSrc/src/test/java/io/spring/gradle/convention/JavadocApiPluginITest.java b/buildSrc/src/test/java/io/spring/gradle/convention/JavadocApiPluginITest.java index ae43f3871..30bc5f07a 100644 --- a/buildSrc/src/test/java/io/spring/gradle/convention/JavadocApiPluginITest.java +++ b/buildSrc/src/test/java/io/spring/gradle/convention/JavadocApiPluginITest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.io.TempDir; import java.io.File; +import java.nio.charset.Charset; import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; @@ -30,7 +31,7 @@ public void multiModuleApi() throws Exception { File allClasses = new File(testKit.getRootDir(), "build/api/allclasses-noframe.html"); File index = new File(testKit.getRootDir(), "build/api/allclasses-index.html"); File listing = allClasses.exists() ? allClasses : index; - String listingText = FileUtils.readFileToString(listing); + String listingText = FileUtils.readFileToString(listing, Charset.defaultCharset()); assertThat(listingText).contains("sample/Api.html"); assertThat(listingText).contains("sample/Impl.html"); assertThat(listingText).doesNotContain("sample/Sample.html"); diff --git a/buildSrc/src/test/java/io/spring/gradle/convention/SpringMavenPluginITest.java b/buildSrc/src/test/java/io/spring/gradle/convention/SpringMavenPluginITest.java index 3f0855dd5..ef94edb85 100644 --- a/buildSrc/src/test/java/io/spring/gradle/convention/SpringMavenPluginITest.java +++ b/buildSrc/src/test/java/io/spring/gradle/convention/SpringMavenPluginITest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.io.TempDir; import java.io.File; +import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.util.LinkedHashMap; @@ -56,6 +57,6 @@ public void signArchivesWhenInMemory() throws Exception { } public String getSigningKey() throws Exception { - return IOUtils.toString(getClass().getResource("/test-private.pgp")); + return IOUtils.toString(getClass().getResource("/test-private.pgp"), Charset.defaultCharset()); } } diff --git a/git/hooks/prepare-forward-merge b/git/hooks/prepare-forward-merge index cb0a25f0d..a7e35bffd 100755 --- a/git/hooks/prepare-forward-merge +++ b/git/hooks/prepare-forward-merge @@ -4,7 +4,7 @@ require 'net/http' require 'yaml' require 'logger' -$main_branch = "3.1.x" +$main_branch = "3.4.x" $log = Logger.new(STDOUT) $log.level = Logger::WARN diff --git a/gradle.properties b/gradle.properties index 99241c927..9430b5086 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 org.gradle.parallel=true -version=3.1.7-SNAPSHOT +version=3.4.2-SNAPSHOT diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 05a294388..79824bc1a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,79 +1,69 @@ [versions] -ch-qos-logback = "1.4.14" +ch-qos-logback = "1.5.16" jakarta-websocket = "2.1.1" org-apache-derby = "10.16.1.1" -org-codehaus-groovy = "3.0.21" -org-gretty = "4.1.3" -org-mongodb = "4.8.2" -org-slf4j = "2.0.13" -org-testcontainers = "1.17.6" -org-springframework-boot = "3.1.11" +org-codehaus-groovy = "3.0.19" +org-gretty = "4.1.1" +org-mockito = "5.11.0" +org-mongodb = "5.2.1" +org-seleniumhq-selenium = "4.13.0" +org-slf4j = "2.0.16" +org-testcontainers = "1.19.8" +org-springframework-boot = "3.3.7" [libraries] ch-qos-logback-logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "ch-qos-logback" } ch-qos-logback-logback-core = { module = "ch.qos.logback:logback-core", version.ref = "ch-qos-logback" } -com-fasterxml-jackson-jackson-bom = "com.fasterxml.jackson:jackson-bom:2.14.3" -com-fasterxml-jackson-core-jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.14.3" +com-fasterxml-jackson-jackson-bom = "com.fasterxml.jackson:jackson-bom:2.17.3" +com-fasterxml-jackson-core-jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.17.3" com-google-code-findbugs-jsr305 = "com.google.code.findbugs:jsr305:3.0.2" -com-h2database-h2 = "com.h2database:h2:2.1.214" -com-hazelcast = "com.hazelcast:hazelcast:5.1.7" +com-h2database-h2 = "com.h2database:h2:2.2.224" +com-hazelcast = "com.hazelcast:hazelcast:5.4.0" com-ibm-db2-jcc = "com.ibm.db2:jcc:11.5.9.0" -com-maxmind-geoip2 = "com.maxmind.geoip2:geoip2:2.16.1" +com-maxmind-geoip2 = "com.maxmind.geoip2:geoip2:2.17.0" com-microsoft-sqlserver-mssql-jdbc = "com.microsoft.sqlserver:mssql-jdbc:11.2.3.jre17" -com-oracle-database-jdbc-ojdbc8 = "com.oracle.database.jdbc:ojdbc8:21.7.0.0" -com-zaxxer-HikariCP = "com.zaxxer:HikariCP:5.0.1" +com-oracle-database-jdbc-ojdbc8 = "com.oracle.database.jdbc:ojdbc8:21.13.0.0" +com-zaxxer-HikariCP = "com.zaxxer:HikariCP:5.1.0" edu-umd-cs-mtc-multithreadedtc = "edu.umd.cs.mtc:multithreadedtc:1.01" -io-lettuce-lettuce-core = "io.lettuce:lettuce-core:6.2.7.RELEASE" -io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2022.0.19" -io-spring-javaformat-spring-javaformat-checkstyle = "io.spring.javaformat:spring-javaformat-checkstyle:0.0.41" +io-lettuce-lettuce-core = "io.lettuce:lettuce-core:6.3.2.RELEASE" +io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2023.0.14" +io-spring-javaformat-spring-javaformat-checkstyle = "io.spring.javaformat:spring-javaformat-checkstyle:0.0.43" io-spring-nohttp-nohttp-checkstyle = "io.spring.nohttp:nohttp-checkstyle:0.0.11" jakarta-servlet-jakarta-servlet-api = "jakarta.servlet:jakarta.servlet-api:6.0.0" -jakarta-servlet-jsp-jstl-jakarta-servlet-jsp-jstl-api = "jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api:3.0.0" +jakarta-servlet-jsp-jstl-jakarta-servlet-jsp-jstl-api = "jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api:3.0.2" jakarta-websocket-jakarta-websocket-api = { module = "jakarta.websocket:jakarta.websocket-api", version.ref = "jakarta-websocket" } jakarta-websocket-jakarta-websocket-client-api = { module = "jakarta.websocket:jakarta.websocket-client-api", version.ref = "jakarta-websocket" } mysql-mysql-connector-java = "mysql:mysql-connector-java:8.0.33" -nz-net-ultraq-thymeleaf-thymeleaf-layout-dialect = "nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.1.0" +nz-net-ultraq-thymeleaf-thymeleaf-layout-dialect = "nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.3.0" com-squareup-okhttp3-okhttp = "com.squareup.okhttp3:okhttp:3.14.9" org-apache-derby-derby = { module = "org.apache.derby:derby", version.ref = "org-apache-derby" } org-apache-derby-derbytools = { module = "org.apache.derby:derbytools", version.ref = "org-apache-derby" } org-apache-httpcomponents-httpclient = "org.apache.httpcomponents:httpclient:4.5.14" -org-apache-logging-log4j-log4j-core = "org.apache.logging.log4j:log4j-core:2.17.2" +org-apache-logging-log4j-log4j-core = "org.apache.logging.log4j:log4j-core:2.23.1" org-aspectj-aspectjweaver = "org.aspectj:aspectjweaver:1.9.22.1" -org-assertj-assertj-core = "org.assertj:assertj-core:3.23.1" -org-codehaus-groovy-groovy-cli-commons = { module = "org.codehaus.groovy:groovy-cli-commons", version.ref = "org-codehaus-groovy" } -org-codehaus-groovy-groovy-json = { module = "org.codehaus.groovy:groovy-json", version.ref = "org-codehaus-groovy" } -org-glassfish-web-jakarta-servlet-jsp-jstl = "org.glassfish.web:jakarta.servlet.jsp.jstl:3.0.1" -org-gretty-gretty-runner-jetty11 = { module = "org.gretty:gretty-runner-jetty11", version.ref = "org-gretty" } -org-gretty-gretty-runner-tomcat10 = { module = "org.gretty:gretty-runner-tomcat10", version.ref = "org-gretty" } -org-gretty-gretty-starter = { module = "org.gretty:gretty-starter", version.ref = "org-gretty" } +org-assertj-assertj-core = "org.assertj:assertj-core:3.25.3" org-hamcrest = "org.hamcrest:hamcrest:2.2" -org-hsqldb = "org.hsqldb:hsqldb:2.7.2" -org-junit-junit-bom = "org.junit:junit-bom:5.8.2" -org-mariadb-jdbc-mariadb-java-client = "org.mariadb.jdbc:mariadb-java-client:3.0.11" -org-mockito-mockito-bom = "org.mockito:mockito-bom:4.8.1" +org-hsqldb = "org.hsqldb:hsqldb:2.7.4" +org-junit-junit-bom = "org.junit:junit-bom:5.10.5" +org-mariadb-jdbc-mariadb-java-client = "org.mariadb.jdbc:mariadb-java-client:3.3.3" +org-mockito-mockito-bom = { module = "org.mockito:mockito-bom", version.ref = "org-mockito" } org-mongodb-mongodb-driver-core = { module = "org.mongodb:mongodb-driver-core", version.ref = "org-mongodb" } org-mongodb-mongodb-driver-reactivestreams = { module = "org.mongodb:mongodb-driver-reactivestreams", version.ref = "org-mongodb" } org-mongodb-mongodb-driver-sync = { module = "org.mongodb:mongodb-driver-sync", version.ref = "org-mongodb" } -org-postgresql = "org.postgresql:postgresql:42.5.6" -org-seleniumhq-selenium-htmlunit-driver = "org.seleniumhq.selenium:htmlunit-driver:4.5.2" -org-seleniumhq-selenium-selenium-support = "org.seleniumhq.selenium:selenium-support:4.5.3" -org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.1" +org-postgresql = "org.postgresql:postgresql:42.7.5" +org-seleniumhq-selenium-htmlunit-driver = { module = "org.seleniumhq.selenium:htmlunit-driver", version.ref = "org-seleniumhq-selenium" } +org-seleniumhq-selenium-selenium-support = { module = "org.seleniumhq.selenium:selenium-support", version.ref = "org-seleniumhq-selenium" } +org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3" org-slf4j-jcl-over-slf4j = { module = "org.slf4j:jcl-over-slf4j", version.ref = "org-slf4j" } org-slf4j-log4j-over-slf4j = { module = "org.slf4j:log4j-over-slf4j", version.ref = "org-slf4j" } org-slf4j-slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "org-slf4j" } -org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2022.0.12" -org-springframework-security-spring-security-bom = "org.springframework.security:spring-security-bom:6.1.9" -org-springframework-spring-framework-bom = "org.springframework:spring-framework-bom:6.0.20" +org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2024.1.1" +org-springframework-security-spring-security-bom = "org.springframework.security:spring-security-bom:6.4.2" +org-springframework-spring-framework-bom = "org.springframework:spring-framework-bom:6.2.1" org-springframework-boot-spring-boot-dependencies = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "org-springframework-boot" } org-springframework-boot-spring-boot-gradle-plugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "org-springframework-boot" } org-testcontainers-testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "org-testcontainers" } -org-thymeleaf-extras-thymeleaf-extras-springsecurity6 = "org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.2.RELEASE" -org-webjars-bootstrap = "org.webjars:bootstrap:2.3.2" -org-webjars-html5shiv = "org.webjars:html5shiv:3.7.3-1" -org-webjars-knockout = "org.webjars:knockout:3.5.1" -org-webjars-sockjs-client = "org.webjars:sockjs-client:1.5.1" -org-webjars-stomp-websocket = "org.webjars:stomp-websocket:2.3.4" -org-webjars-webjars-locator-core = "org.webjars:webjars-locator-core:0.52" +org-awaitility-awaitility = "org.awaitility:awaitility:4.2.2" io-spring-security-release-plugin = "io.spring.gradle:spring-security-release-plugin:1.0.3" [plugins] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9f4197d5f..94113f200 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle b/settings.gradle index dd6e562d5..244c2e5c1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,7 +6,7 @@ pluginManagement { } plugins { - id "com.gradle.develocity" version "3.17.4" + id "com.gradle.develocity" version "3.17.6" id "io.spring.ge.conventions" version "0.0.17" } diff --git a/spring-session-core/spring-session-core.gradle b/spring-session-core/spring-session-core.gradle index 6e146eae5..16a50ecc1 100644 --- a/spring-session-core/spring-session-core.gradle +++ b/spring-session-core/spring-session-core.gradle @@ -21,7 +21,6 @@ dependencies { testImplementation "io.projectreactor:reactor-test" testImplementation "org.mockito:mockito-core" testImplementation "org.mockito:mockito-junit-jupiter" - testImplementation "org.mockito:mockito-inline" testImplementation "edu.umd.cs.mtc:multithreadedtc" testImplementation "org.springframework:spring-test" testImplementation "org.assertj:assertj-core" diff --git a/spring-session-core/src/main/java/org/springframework/session/MapSession.java b/spring-session-core/src/main/java/org/springframework/session/MapSession.java index f7ffafcce..997cb8524 100644 --- a/spring-session-core/src/main/java/org/springframework/session/MapSession.java +++ b/spring-session-core/src/main/java/org/springframework/session/MapSession.java @@ -74,6 +74,8 @@ public final class MapSession implements Session, Serializable { */ private Duration maxInactiveInterval = DEFAULT_MAX_INACTIVE_INTERVAL; + private transient SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + /** * Creates a new instance with a secure randomly generated identifier. */ @@ -81,6 +83,17 @@ public MapSession() { this(generateId()); } + /** + * Creates a new instance using the specified {@link SessionIdGenerator} to generate + * the session id. + * @param sessionIdGenerator the {@link SessionIdGenerator} to use. + * @since 3.2 + */ + public MapSession(SessionIdGenerator sessionIdGenerator) { + this(sessionIdGenerator.generate()); + this.sessionIdGenerator = sessionIdGenerator; + } + /** * Creates a new instance with the specified id. This is preferred to the default * constructor when the id is known to prevent unnecessary consumption on entropy @@ -141,7 +154,7 @@ public String getOriginalId() { @Override public String changeSessionId() { - String changedId = generateId(); + String changedId = this.sessionIdGenerator.generate(); setId(changedId); return changedId; } @@ -232,6 +245,15 @@ private static String generateId() { return UUID.randomUUID().toString(); } + /** + * Sets the {@link SessionIdGenerator} to use when generating a new session id. + * @param sessionIdGenerator the {@link SessionIdGenerator} to use. + * @since 3.2 + */ + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + this.sessionIdGenerator = sessionIdGenerator; + } + private static final long serialVersionUID = 7160779239673823561L; } diff --git a/spring-session-core/src/main/java/org/springframework/session/MapSessionRepository.java b/spring-session-core/src/main/java/org/springframework/session/MapSessionRepository.java index bff80f36a..270fe8472 100644 --- a/spring-session-core/src/main/java/org/springframework/session/MapSessionRepository.java +++ b/spring-session-core/src/main/java/org/springframework/session/MapSessionRepository.java @@ -43,6 +43,8 @@ public class MapSessionRepository implements SessionRepository { private final Map sessions; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + /** * Creates a new instance backed by the provided {@link java.util.Map}. This allows * injecting a distributed {@link java.util.Map}. @@ -71,7 +73,9 @@ public void save(MapSession session) { if (!session.getId().equals(session.getOriginalId())) { this.sessions.remove(session.getOriginalId()); } - this.sessions.put(session.getId(), new MapSession(session)); + MapSession saved = new MapSession(session); + saved.setSessionIdGenerator(this.sessionIdGenerator); + this.sessions.put(session.getId(), saved); } @Override @@ -84,7 +88,9 @@ public MapSession findById(String id) { deleteById(saved.getId()); return null; } - return new MapSession(saved); + MapSession result = new MapSession(saved); + result.setSessionIdGenerator(this.sessionIdGenerator); + return result; } @Override @@ -94,9 +100,14 @@ public void deleteById(String id) { @Override public MapSession createSession() { - MapSession result = new MapSession(); + MapSession result = new MapSession(this.sessionIdGenerator); result.setMaxInactiveInterval(this.defaultMaxInactiveInterval); return result; } + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + Assert.notNull(sessionIdGenerator, "sessionIdGenerator cannot be null"); + this.sessionIdGenerator = sessionIdGenerator; + } + } diff --git a/spring-session-core/src/main/java/org/springframework/session/PrincipalNameIndexResolver.java b/spring-session-core/src/main/java/org/springframework/session/PrincipalNameIndexResolver.java index f4d7e87b6..3d79d86cb 100644 --- a/spring-session-core/src/main/java/org/springframework/session/PrincipalNameIndexResolver.java +++ b/spring-session-core/src/main/java/org/springframework/session/PrincipalNameIndexResolver.java @@ -38,6 +38,15 @@ public PrincipalNameIndexResolver() { super(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME); } + /** + * Create a new instance specifying the name of the index to be resolved. + * @param indexName the name of the index to be resolved + * @since 3.3 + */ + public PrincipalNameIndexResolver(String indexName) { + super(indexName); + } + public String resolveIndexValueFor(S session) { String principalName = session.getAttribute(getIndexName()); if (principalName != null) { diff --git a/spring-session-core/src/main/java/org/springframework/session/ReactiveFindByIndexNameSessionRepository.java b/spring-session-core/src/main/java/org/springframework/session/ReactiveFindByIndexNameSessionRepository.java new file mode 100644 index 000000000..d5b3ab24b --- /dev/null +++ b/spring-session-core/src/main/java/org/springframework/session/ReactiveFindByIndexNameSessionRepository.java @@ -0,0 +1,60 @@ +/* + * Copyright 2014-2023 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.session; + +import java.util.Map; + +import reactor.core.publisher.Mono; + +/** + * Allow finding sessions by the specified index name and index value. + * + * @param the type of Session being managed by this + * {@link ReactiveFindByIndexNameSessionRepository} + * @author Marcus da Coregio + * @since 3.3 + */ +public interface ReactiveFindByIndexNameSessionRepository { + + /** + * A session index that contains the current principal name (i.e. username). + *

+ * It is the responsibility of the developer to ensure the index is populated since + * Spring Session is not aware of the authentication mechanism being used. + */ + String PRINCIPAL_NAME_INDEX_NAME = "PRINCIPAL_NAME_INDEX_NAME"; + + /** + * Find a {@link Map} of the session id to the {@link Session} of all sessions that + * contain the specified index name index value. + * @param indexName the name of the index (i.e. {@link #PRINCIPAL_NAME_INDEX_NAME}) + * @param indexValue the value of the index to search for. + * @return a {@code Map} (never {@code null}) of the session id to the {@code Session} + */ + Mono> findByIndexNameAndIndexValue(String indexName, String indexValue); + + /** + * A shortcut for {@link #findByIndexNameAndIndexValue(String, String)} that uses + * {@link #PRINCIPAL_NAME_INDEX_NAME} for the index name. + * @param principalName the principal name + * @return a {@code Map} (never {@code null}) of the session id to the {@code Session} + */ + default Mono> findByPrincipalName(String principalName) { + return findByIndexNameAndIndexValue(PRINCIPAL_NAME_INDEX_NAME, principalName); + } + +} diff --git a/spring-session-core/src/main/java/org/springframework/session/ReactiveMapSessionRepository.java b/spring-session-core/src/main/java/org/springframework/session/ReactiveMapSessionRepository.java index 3bd1cb030..141349f9b 100644 --- a/spring-session-core/src/main/java/org/springframework/session/ReactiveMapSessionRepository.java +++ b/spring-session-core/src/main/java/org/springframework/session/ReactiveMapSessionRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -20,6 +20,7 @@ import java.util.Map; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import org.springframework.session.events.SessionDeletedEvent; import org.springframework.session.events.SessionExpiredEvent; @@ -37,6 +38,7 @@ *

* * @author Rob Winch + * @author Yanming Zhou * @since 2.0 */ public class ReactiveMapSessionRepository implements ReactiveSessionRepository { @@ -45,6 +47,8 @@ public class ReactiveMapSessionRepository implements ReactiveSessionRepository sessions; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + /** * Creates a new instance backed by the provided {@link Map}. This allows injecting a * distributed {@link Map}. @@ -84,6 +88,7 @@ public Mono findById(String id) { return Mono.defer(() -> Mono.justOrEmpty(this.sessions.get(id)) .filter((session) -> !session.isExpired()) .map(MapSession::new) + .doOnNext((session) -> session.setSessionIdGenerator(this.sessionIdGenerator)) .switchIfEmpty(deleteById(id).then(Mono.empty()))); // @formatter:on } @@ -95,11 +100,27 @@ public Mono deleteById(String id) { @Override public Mono createSession() { - return Mono.defer(() -> { - MapSession result = new MapSession(); - result.setMaxInactiveInterval(this.defaultMaxInactiveInterval); - return Mono.just(result); - }); + // @formatter:off + return Mono.fromSupplier(() -> this.sessionIdGenerator.generate()) + .subscribeOn(Schedulers.boundedElastic()) + .publishOn(Schedulers.parallel()) + .map((sessionId) -> { + MapSession result = new MapSession(sessionId); + result.setMaxInactiveInterval(this.defaultMaxInactiveInterval); + result.setSessionIdGenerator(this.sessionIdGenerator); + return result; + }); + // @formatter:on + } + + /** + * Sets the {@link SessionIdGenerator} to use. + * @param sessionIdGenerator the non-null {@link SessionIdGenerator} to use + * @since 3.2 + */ + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + Assert.notNull(sessionIdGenerator, "sessionIdGenerator cannot be null"); + this.sessionIdGenerator = sessionIdGenerator; } } diff --git a/spring-session-core/src/main/java/org/springframework/session/SessionIdGenerator.java b/spring-session-core/src/main/java/org/springframework/session/SessionIdGenerator.java new file mode 100644 index 000000000..59f4e4022 --- /dev/null +++ b/spring-session-core/src/main/java/org/springframework/session/SessionIdGenerator.java @@ -0,0 +1,32 @@ +/* + * Copyright 2014-2023 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.session; + +import org.springframework.lang.NonNull; + +/** + * An interface for specifying a strategy for generating session identifiers. + * + * @author Marcus da Coregio + * @since 3.2 + */ +public interface SessionIdGenerator { + + @NonNull + String generate(); + +} diff --git a/spring-session-core/src/main/java/org/springframework/session/UuidSessionIdGenerator.java b/spring-session-core/src/main/java/org/springframework/session/UuidSessionIdGenerator.java new file mode 100644 index 000000000..536c4ebe2 --- /dev/null +++ b/spring-session-core/src/main/java/org/springframework/session/UuidSessionIdGenerator.java @@ -0,0 +1,50 @@ +/* + * Copyright 2014-2023 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.session; + +import java.util.UUID; + +import org.springframework.lang.NonNull; + +/** + * A {@link SessionIdGenerator} that generates a random UUID to be used as the session id. + * + * @author Marcus da Coregio + * @since 3.2 + */ +public final class UuidSessionIdGenerator implements SessionIdGenerator { + + private static final UuidSessionIdGenerator INSTANCE = new UuidSessionIdGenerator(); + + private UuidSessionIdGenerator() { + } + + @Override + @NonNull + public String generate() { + return UUID.randomUUID().toString(); + } + + /** + * Returns the singleton instance of {@link UuidSessionIdGenerator}. + * @return the singleton instance of {@link UuidSessionIdGenerator} + */ + public static UuidSessionIdGenerator getInstance() { + return INSTANCE; + } + +} diff --git a/spring-session-core/src/main/java/org/springframework/session/config/annotation/web/http/SpringHttpSessionConfiguration.java b/spring-session-core/src/main/java/org/springframework/session/config/annotation/web/http/SpringHttpSessionConfiguration.java index b684ec337..bd952987a 100644 --- a/spring-session-core/src/main/java/org/springframework/session/config/annotation/web/http/SpringHttpSessionConfiguration.java +++ b/spring-session-core/src/main/java/org/springframework/session/config/annotation/web/http/SpringHttpSessionConfiguration.java @@ -108,9 +108,7 @@ public class SpringHttpSessionConfiguration implements InitializingBean, Applica @Override public void afterPropertiesSet() { - CookieSerializer cookieSerializer = (this.cookieSerializer != null) ? this.cookieSerializer - : createDefaultCookieSerializer(); - this.defaultHttpSessionIdResolver.setCookieSerializer(cookieSerializer); + this.defaultHttpSessionIdResolver.setCookieSerializer(getCookieSerializer()); } @Bean @@ -154,6 +152,21 @@ public void setHttpSessionListeners(List listeners) { this.httpSessionListeners = listeners; } + private CookieSerializer getCookieSerializer() { + if (this.cookieSerializer != null) { + if (this.cookieSerializer instanceof DefaultCookieSerializer defaultCookieSerializer + && this.usesSpringSessionRememberMeServices + && defaultCookieSerializer.getRememberMeRequestAttribute() == null) { + this.logger.warn("Spring Session Remember Me support is enabled " + + "and the DefaultCookieSerializer is provided explicitly. " + + "The DefaultCookieSerializer must be configured with " + + "setRememberMeRequestAttribute(String) in order to support Remember Me."); + } + return this.cookieSerializer; + } + return createDefaultCookieSerializer(); + } + private CookieSerializer createDefaultCookieSerializer() { DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer(); if (this.servletContext != null) { diff --git a/spring-session-core/src/main/java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistry.java b/spring-session-core/src/main/java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistry.java new file mode 100644 index 000000000..71dac0961 --- /dev/null +++ b/spring-session-core/src/main/java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistry.java @@ -0,0 +1,135 @@ +/* + * Copyright 2014-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.session.security; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.session.ReactiveSessionInformation; +import org.springframework.security.core.session.ReactiveSessionRegistry; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.Session; +import org.springframework.util.Assert; + +/** + * A {@link ReactiveSessionRegistry} that retrieves session information from Spring + * Session, rather than maintaining it itself. This allows concurrent session management + * with Spring Security in a clustered environment. + *

+ * Relies on being able to derive the same String-based representation of the principal + * given to {@link #getAllSessions(Object)} as used by Spring Session in order to look up + * the user's sessions. + *

+ * + * @param the {@link Session} type. + * @author Marcus da Coregio + * @since 3.3 + */ +public final class SpringSessionBackedReactiveSessionRegistry implements ReactiveSessionRegistry { + + private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; + + private final ReactiveSessionRepository sessionRepository; + + private final ReactiveFindByIndexNameSessionRepository indexedSessionRepository; + + public SpringSessionBackedReactiveSessionRegistry(ReactiveSessionRepository sessionRepository, + ReactiveFindByIndexNameSessionRepository indexedSessionRepository) { + Assert.notNull(sessionRepository, "sessionRepository cannot be null"); + Assert.notNull(indexedSessionRepository, "indexedSessionRepository cannot be null"); + this.sessionRepository = sessionRepository; + this.indexedSessionRepository = indexedSessionRepository; + } + + @Override + public Flux getAllSessions(Object principal) { + Authentication authenticationToken = getAuthenticationToken(principal); + return this.indexedSessionRepository.findByPrincipalName(authenticationToken.getName()) + .flatMapMany((sessionMap) -> Flux.fromIterable(sessionMap.entrySet())) + .map((entry) -> new SpringSessionBackedReactiveSessionInformation(entry.getValue())); + } + + @Override + public Mono saveSessionInformation(ReactiveSessionInformation information) { + return Mono.empty(); + } + + @Override + public Mono getSessionInformation(String sessionId) { + return this.sessionRepository.findById(sessionId).map(SpringSessionBackedReactiveSessionInformation::new); + } + + @Override + public Mono removeSessionInformation(String sessionId) { + return Mono.empty(); + } + + @Override + public Mono updateLastAccessTime(String sessionId) { + return Mono.empty(); + } + + private static Authentication getAuthenticationToken(Object principal) { + return new AbstractAuthenticationToken(AuthorityUtils.NO_AUTHORITIES) { + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return principal; + } + + }; + } + + class SpringSessionBackedReactiveSessionInformation extends ReactiveSessionInformation { + + SpringSessionBackedReactiveSessionInformation(S session) { + super(resolvePrincipalName(session), session.getId(), session.getLastAccessedTime()); + } + + private static String resolvePrincipalName(Session session) { + String principalName = session + .getAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME); + if (principalName != null) { + return principalName; + } + SecurityContext securityContext = session.getAttribute(SPRING_SECURITY_CONTEXT); + if (securityContext != null && securityContext.getAuthentication() != null) { + return securityContext.getAuthentication().getName(); + } + return ""; + } + + @Override + public Mono invalidate() { + return super.invalidate() + .then(Mono.defer(() -> SpringSessionBackedReactiveSessionRegistry.this.sessionRepository + .deleteById(getSessionId()))); + } + + } + +} diff --git a/spring-session-core/src/main/java/org/springframework/session/web/http/DefaultCookieSerializer.java b/spring-session-core/src/main/java/org/springframework/session/web/http/DefaultCookieSerializer.java index 6c97919c2..afb9b231a 100644 --- a/spring-session-core/src/main/java/org/springframework/session/web/http/DefaultCookieSerializer.java +++ b/spring-session-core/src/main/java/org/springframework/session/web/http/DefaultCookieSerializer.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2021 the original author or authors. + * Copyright 2014-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. @@ -86,6 +86,8 @@ public class DefaultCookieSerializer implements CookieSerializer { private String sameSite = "Lax"; + private boolean partitioned; + /* * @see * org.springframework.session.web.http.CookieSerializer#readCookieValues(jakarta. @@ -153,6 +155,9 @@ public void writeCookieValue(CookieValue cookieValue) { if (this.sameSite != null) { sb.append("; SameSite=").append(this.sameSite); } + if (this.partitioned) { + sb.append("; Partitioned"); + } response.addHeader("Set-Cookie", sb.toString()); } @@ -434,4 +439,23 @@ private String getCookiePath(HttpServletRequest request) { return this.cookiePath; } + /** + * Gets the name of the request attribute that is checked to see if the cookie should + * be written with {@link Integer#MAX_VALUE}. + * @return the remember me request attribute + * @since 3.2 + */ + public String getRememberMeRequestAttribute() { + return this.rememberMeRequestAttribute; + } + + /** + * Allows defining whether the generated cookie carries the Partitioned attribute. + * @param partitioned whether the generate cookie is partitioned + * @since 3.4 + */ + public void setPartitioned(boolean partitioned) { + this.partitioned = partitioned; + } + } diff --git a/spring-session-core/src/main/java/org/springframework/session/web/http/SessionRepositoryFilter.java b/spring-session-core/src/main/java/org/springframework/session/web/http/SessionRepositoryFilter.java index 46f17e53c..eaf92eb57 100644 --- a/spring-session-core/src/main/java/org/springframework/session/web/http/SessionRepositoryFilter.java +++ b/spring-session-core/src/main/java/org/springframework/session/web/http/SessionRepositoryFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -204,6 +204,8 @@ private final class SessionRepositoryRequestWrapper extends HttpServletRequestWr private boolean requestedSessionInvalidated; + private boolean hasCommittedInInclude; + private SessionRepositoryRequestWrapper(HttpServletRequest request, HttpServletResponse response) { super(request); this.response = response; @@ -407,7 +409,10 @@ public void forward(ServletRequest request, ServletResponse response) throws Ser @Override public void include(ServletRequest request, ServletResponse response) throws ServletException, IOException { - SessionRepositoryRequestWrapper.this.commitSession(); + if (!SessionRepositoryRequestWrapper.this.hasCommittedInInclude) { + SessionRepositoryRequestWrapper.this.commitSession(); + SessionRepositoryRequestWrapper.this.hasCommittedInInclude = true; + } this.delegate.include(request, response); } diff --git a/spring-session-core/src/main/java/org/springframework/session/web/socket/config/annotation/AbstractSessionWebSocketMessageBrokerConfigurer.java b/spring-session-core/src/main/java/org/springframework/session/web/socket/config/annotation/AbstractSessionWebSocketMessageBrokerConfigurer.java index 484cb00e3..bc5c06c84 100644 --- a/spring-session-core/src/main/java/org/springframework/session/web/socket/config/annotation/AbstractSessionWebSocketMessageBrokerConfigurer.java +++ b/spring-session-core/src/main/java/org/springframework/session/web/socket/config/annotation/AbstractSessionWebSocketMessageBrokerConfigurer.java @@ -112,7 +112,7 @@ public void configureWebSocketTransport(WebSocketTransportRegistration registrat } @Bean - public WebSocketRegistryListener webSocketRegistryListener() { + public static WebSocketRegistryListener webSocketRegistryListener() { return new WebSocketRegistryListener(); } @@ -163,6 +163,11 @@ public WebMvcStompEndpointRegistry setErrorHandler(StompSubProtocolErrorHandler return this.registry.setErrorHandler(errorHandler); } + @Override + public WebMvcStompEndpointRegistry setPreserveReceiveOrder(boolean preserveReceiveOrder) { + return this.registry.setPreserveReceiveOrder(preserveReceiveOrder); + } + } } diff --git a/spring-session-core/src/test/java/org/springframework/session/MapSessionTests.java b/spring-session-core/src/test/java/org/springframework/session/MapSessionTests.java index aa5fece4b..db3bd127e 100644 --- a/spring-session-core/src/test/java/org/springframework/session/MapSessionTests.java +++ b/spring-session-core/src/test/java/org/springframework/session/MapSessionTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2019 the original author or authors. + * Copyright 2014-2023 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. @@ -19,6 +19,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Set; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -42,6 +43,19 @@ void constructorNullSession() { .withMessage("session cannot be null"); } + @Test + void constructorWhenSessionIdGeneratorThenUsesStrategy() { + MapSession session = new MapSession(new FixedSessionIdGenerator("my-id")); + assertThat(session.getId()).isEqualTo("my-id"); + } + + @Test + void constructorWhenDefaultThenUuid() { + String id = this.session.getId(); + UUID uuid = UUID.fromString(id); + assertThat(uuid).isNotNull(); + } + @Test void getAttributeWhenNullThenNull() { String result = this.session.getAttribute("attrName"); @@ -143,6 +157,41 @@ void getAttributeNamesAndRemove() { assertThat(this.session.getAttributeNames()).isEmpty(); } + @Test + void changeSessionIdWhenSessionIdStrategyThenUsesStrategy() { + MapSession session = new MapSession(new IncrementalSessionIdGenerator()); + String idBeforeChange = session.getId(); + String idAfterChange = session.changeSessionId(); + assertThat(idBeforeChange).isEqualTo("1"); + assertThat(idAfterChange).isEqualTo("2"); + } + + static class FixedSessionIdGenerator implements SessionIdGenerator { + + private final String id; + + FixedSessionIdGenerator(String id) { + this.id = id; + } + + @Override + public String generate() { + return this.id; + } + + } + + static class IncrementalSessionIdGenerator implements SessionIdGenerator { + + private int counter = 1; + + @Override + public String generate() { + return String.valueOf(this.counter++); + } + + } + static class CustomSession implements Session { @Override diff --git a/spring-session-core/src/test/java/org/springframework/session/ReactiveMapSessionRepositoryTests.java b/spring-session-core/src/test/java/org/springframework/session/ReactiveMapSessionRepositoryTests.java index 9e4e11e6f..1b483853b 100644 --- a/spring-session-core/src/test/java/org/springframework/session/ReactiveMapSessionRepositoryTests.java +++ b/spring-session-core/src/test/java/org/springframework/session/ReactiveMapSessionRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -152,4 +152,31 @@ void getAttributeNamesAndRemove() { assertThat(session.getAttributeNames()).isEmpty(); } + @Test + void createSessionWhenSessionIdGeneratorThenUses() { + this.repository.setSessionIdGenerator(() -> "test"); + MapSession session = this.repository.createSession().block(); + assertThat(session.getId()).isEqualTo("test"); + assertThat(session.changeSessionId()).isEqualTo("test"); + } + + @Test + void setSessionIdGeneratorWhenNullThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setSessionIdGenerator(null)) + .withMessage("sessionIdGenerator cannot be null"); + } + + @Test + void findByIdWhenChangeSessionIdThenUsesSessionIdGenerator() { + this.repository.setSessionIdGenerator(() -> "test"); + + MapSession session = this.repository.createSession().block(); + this.repository.save(session).block(); + + MapSession savedSession = this.repository.findById("test").block(); + + assertThat(savedSession.getId()).isEqualTo("test"); + assertThat(savedSession.changeSessionId()).isEqualTo("test"); + } + } diff --git a/spring-session-core/src/test/java/org/springframework/session/config/annotation/web/http/SpringHttpSessionConfigurationTests.java b/spring-session-core/src/test/java/org/springframework/session/config/annotation/web/http/SpringHttpSessionConfigurationTests.java index c9b8abd1a..cb04e0551 100644 --- a/spring-session-core/src/test/java/org/springframework/session/config/annotation/web/http/SpringHttpSessionConfigurationTests.java +++ b/spring-session-core/src/test/java/org/springframework/session/config/annotation/web/http/SpringHttpSessionConfigurationTests.java @@ -19,8 +19,11 @@ import java.util.concurrent.ConcurrentHashMap; import jakarta.servlet.ServletContext; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; import org.springframework.beans.factory.UnsatisfiedDependencyException; import org.springframework.context.annotation.AnnotationConfigApplicationContext; @@ -38,6 +41,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; /** * Tests for {@link SpringHttpSessionConfiguration}. @@ -110,6 +117,19 @@ void rememberMeServicesConfiguration() { .isEqualTo(SpringSessionRememberMeServices.REMEMBER_ME_LOGIN_ATTR); } + @Test + void rememberMeServicesAndCustomDefaultCookieSerializerThenWarnIfRememberMeRequestAttributeNotSet() { + try (MockedStatic logFactoryMockedStatic = mockStatic(LogFactory.class)) { + Log logMock = mock(); + logFactoryMockedStatic.when(() -> LogFactory.getLog(any(Class.class))).thenReturn(logMock); + registerAndRefresh(RememberMeServicesConfiguration.class, CustomDefaultCookieSerializerConfiguration.class); + verify(logMock).warn("Spring Session Remember Me support is enabled " + + "and the DefaultCookieSerializer is provided explicitly. " + + "The DefaultCookieSerializer must be configured with " + + "setRememberMeRequestAttribute(String) in order to support Remember Me."); + } + } + @Configuration @EnableSpringHttpSession static class EmptyConfiguration { @@ -158,4 +178,15 @@ SpringSessionRememberMeServices rememberMeServices() { } + @Configuration + @EnableSpringHttpSession + static class CustomDefaultCookieSerializerConfiguration { + + @Bean + DefaultCookieSerializer defaultCookieSerializer() { + return new DefaultCookieSerializer(); + } + + } + } diff --git a/spring-session-core/src/test/java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistryTests.java b/spring-session-core/src/test/java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistryTests.java new file mode 100644 index 000000000..ce4df35b6 --- /dev/null +++ b/spring-session-core/src/test/java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistryTests.java @@ -0,0 +1,166 @@ +/* + * Copyright 2014-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.session.security; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.core.session.ReactiveSessionInformation; +import org.springframework.session.MapSession; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.ReactiveMapSessionRepository; + +import static org.assertj.core.api.Assertions.assertThat; + +class SpringSessionBackedReactiveSessionRegistryTests { + + static MapSession johnSession1 = new MapSession(); + static MapSession johnSession2 = new MapSession(); + static MapSession johnSession3 = new MapSession(); + + SpringSessionBackedReactiveSessionRegistry sessionRegistry; + + ReactiveFindByIndexNameSessionRepository indexedSessionRepository = new StubIndexedSessionRepository(); + + ReactiveMapSessionRepository sessionRepository = new ReactiveMapSessionRepository(new ConcurrentHashMap<>()); + + static { + johnSession1.setAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, "johndoe"); + johnSession2.setAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, "johndoe"); + johnSession3.setAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, "johndoe"); + } + + @BeforeEach + void setup() { + this.sessionRegistry = new SpringSessionBackedReactiveSessionRegistry<>(this.sessionRepository, + this.indexedSessionRepository); + this.sessionRepository.save(johnSession1).block(); + this.sessionRepository.save(johnSession2).block(); + this.sessionRepository.save(johnSession3).block(); + } + + @Test + void saveSessionInformationThenDoNothing() { + StepVerifier.create(this.sessionRegistry.saveSessionInformation(null)).expectComplete().verify(); + } + + @Test + void removeSessionInformationThenDoNothing() { + StepVerifier.create(this.sessionRegistry.removeSessionInformation(null)).expectComplete().verify(); + } + + @Test + void updateLastAccessTimeThenDoNothing() { + StepVerifier.create(this.sessionRegistry.updateLastAccessTime(null)).expectComplete().verify(); + } + + @Test + void getSessionInformationWhenPrincipalIndexNamePresentThenPrincipalResolved() { + MapSession session = this.sessionRepository.createSession().block(); + session.setAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, "johndoe"); + this.sessionRepository.save(session).block(); + StepVerifier.create(this.sessionRegistry.getSessionInformation(session.getId())) + .assertNext((sessionInformation) -> { + assertThat(sessionInformation.getSessionId()).isEqualTo(session.getId()); + assertThat(sessionInformation.getLastAccessTime()).isEqualTo(session.getLastAccessedTime()); + assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe"); + }) + .verifyComplete(); + } + + @Test + void getSessionInformationWhenSecurityContextAttributePresentThenPrincipalResolved() { + MapSession session = this.sessionRepository.createSession().block(); + TestingAuthenticationToken authentication = new TestingAuthenticationToken("johndoe", "n/a"); + SecurityContextImpl securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(authentication); + session.setAttribute("SPRING_SECURITY_CONTEXT", securityContext); + this.sessionRepository.save(session).block(); + StepVerifier.create(this.sessionRegistry.getSessionInformation(session.getId())) + .assertNext((sessionInformation) -> { + assertThat(sessionInformation.getSessionId()).isEqualTo(session.getId()); + assertThat(sessionInformation.getLastAccessTime()).isEqualTo(session.getLastAccessedTime()); + assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe"); + }) + .verifyComplete(); + } + + @Test + void getSessionInformationWhenNoResolvablePrincipalThenPrincipalBlank() { + MapSession session = this.sessionRepository.createSession().block(); + this.sessionRepository.save(session).block(); + StepVerifier.create(this.sessionRegistry.getSessionInformation(session.getId())) + .assertNext((sessionInformation) -> { + assertThat(sessionInformation.getSessionId()).isEqualTo(session.getId()); + assertThat(sessionInformation.getLastAccessTime()).isEqualTo(session.getLastAccessedTime()); + assertThat(sessionInformation.getPrincipal()).isEqualTo(""); + }) + .verifyComplete(); + } + + @Test + void getSessionInformationWhenInvalidateThenRemovedFromSessionRepository() { + MapSession session = this.sessionRepository.createSession().block(); + this.sessionRepository.save(session).block(); + Mono publisher = this.sessionRegistry.getSessionInformation(session.getId()) + .flatMap(ReactiveSessionInformation::invalidate); + StepVerifier.create(publisher).verifyComplete(); + StepVerifier.create(this.sessionRepository.findById(session.getId())).expectComplete().verify(); + } + + @Test + void getAllSessionsWhenSessionsExistsThenReturned() { + Flux sessions = this.sessionRegistry.getAllSessions("johndoe"); + StepVerifier.create(sessions) + .assertNext((sessionInformation) -> assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe")) + .assertNext((sessionInformation) -> assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe")) + .assertNext((sessionInformation) -> assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe")) + .verifyComplete(); + } + + @Test + void getAllSessionsWhenInvalidateThenSessionsRemovedFromRepository() { + this.sessionRegistry.getAllSessions("johndoe").flatMap(ReactiveSessionInformation::invalidate).blockLast(); + StepVerifier.create(this.sessionRepository.findById(johnSession1.getId())).expectComplete().verify(); + StepVerifier.create(this.sessionRepository.findById(johnSession2.getId())).expectComplete().verify(); + StepVerifier.create(this.sessionRepository.findById(johnSession3.getId())).expectComplete().verify(); + } + + static class StubIndexedSessionRepository implements ReactiveFindByIndexNameSessionRepository { + + Map johnSessions = Map.of(johnSession1.getId(), johnSession1, johnSession2.getId(), + johnSession2, johnSession3.getId(), johnSession3); + + @Override + public Mono> findByIndexNameAndIndexValue(String indexName, String indexValue) { + if ("johndoe".equals(indexValue)) { + return Mono.just(this.johnSessions); + } + return Mono.empty(); + } + + } + +} diff --git a/spring-session-core/src/test/java/org/springframework/session/security/SpringSessionBackedSessionRegistryTests.java b/spring-session-core/src/test/java/org/springframework/session/security/SpringSessionBackedSessionRegistryTests.java index aaac09f4b..7621e7128 100644 --- a/spring-session-core/src/test/java/org/springframework/session/security/SpringSessionBackedSessionRegistryTests.java +++ b/spring-session-core/src/test/java/org/springframework/session/security/SpringSessionBackedSessionRegistryTests.java @@ -30,6 +30,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.quality.Strictness; import org.springframework.security.core.AuthenticatedPrincipal; import org.springframework.security.core.Authentication; @@ -154,7 +155,7 @@ void expireNow() { private Session createSession(String sessionId, String userName, Instant lastAccessed) { MapSession session = new MapSession(sessionId); session.setLastAccessedTime(lastAccessed); - Authentication authentication = mock(Authentication.class, withSettings().lenient()); + Authentication authentication = mock(Authentication.class, withSettings().strictness(Strictness.LENIENT)); given(authentication.getName()).willReturn(userName); SecurityContextImpl securityContext = new SecurityContextImpl(); securityContext.setAuthentication(authentication); diff --git a/spring-session-core/src/test/java/org/springframework/session/web/http/DefaultCookieSerializerTests.java b/spring-session-core/src/test/java/org/springframework/session/web/http/DefaultCookieSerializerTests.java index 358b884c1..890fe53f8 100644 --- a/spring-session-core/src/test/java/org/springframework/session/web/http/DefaultCookieSerializerTests.java +++ b/spring-session-core/src/test/java/org/springframework/session/web/http/DefaultCookieSerializerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2021 the original author or authors. + * Copyright 2014-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. @@ -460,6 +460,13 @@ void writeCookieSetSameSiteNull() { assertThat(getCookie().getSameSite()).isNull(); } + @Test + void writeCookieWhenPartitionedTrueThenSetPartitionedAttribute() { + this.serializer.setPartitioned(true); + this.serializer.writeCookieValue(cookieValue(this.sessionId)); + assertThat(getCookie().isPartitioned()).isTrue(); + } + void setCookieName(String cookieName) { this.cookieName = cookieName; this.serializer.setCookieName(cookieName); diff --git a/spring-session-core/src/test/java/org/springframework/session/web/http/OnCommittedResponseWrapperTests.java b/spring-session-core/src/test/java/org/springframework/session/web/http/OnCommittedResponseWrapperTests.java index 3f8d9b03f..bc7dff5e5 100644 --- a/spring-session-core/src/test/java/org/springframework/session/web/http/OnCommittedResponseWrapperTests.java +++ b/spring-session-core/src/test/java/org/springframework/session/web/http/OnCommittedResponseWrapperTests.java @@ -38,7 +38,7 @@ class OnCommittedResponseWrapperTests { private static final String NL = "\r\n"; - @Mock(lenient = true) + @Mock(strictness = Mock.Strictness.LENIENT) HttpServletResponse delegate; @Mock diff --git a/spring-session-core/src/test/java/org/springframework/session/web/http/SessionRepositoryFilterTests.java b/spring-session-core/src/test/java/org/springframework/session/web/http/SessionRepositoryFilterTests.java index b7f53386e..7b602e20f 100644 --- a/spring-session-core/src/test/java/org/springframework/session/web/http/SessionRepositoryFilterTests.java +++ b/spring-session-core/src/test/java/org/springframework/session/web/http/SessionRepositoryFilterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -1319,6 +1319,32 @@ public void doFilter(HttpServletRequest wrappedRequest) { assertThat(bindingListener.getCounter()).isEqualTo(1); } + @Test // gh-2284 + void doFilterIncludeCommitSessionOnce() throws Exception { + MapSession session = this.sessionRepository.createSession(); + this.sessionRepository.save(session); + SessionRepository sessionRepository = spy(this.sessionRepository); + setSessionCookie(session.getId()); + + given(sessionRepository.findById(session.getId())).willReturn(session); + + this.filter = new SessionRepositoryFilter<>(sessionRepository); + + doFilter(new DoInFilter() { + @Override + public void doFilter(HttpServletRequest wrappedRequest, HttpServletResponse wrappedResponse) + throws IOException, ServletException { + String id = wrappedRequest.getSession().getId(); + wrappedRequest.getRequestDispatcher("/").include(wrappedRequest, wrappedResponse); + assertThat(SessionRepositoryFilterTests.this.sessionRepository.findById(id)).isNotNull(); + wrappedRequest.getRequestDispatcher("/").include(wrappedRequest, wrappedResponse); + verify(sessionRepository, times(1)).findById(session.getId()); + verify(sessionRepository, times(1)).save(session); + verifyNoMoreInteractions(sessionRepository); + } + }); + } + // --- helper methods private void assertNewSession() { diff --git a/spring-session-core/src/test/java/org/springframework/session/web/server/session/SpringSessionWebSessionStoreTests.java b/spring-session-core/src/test/java/org/springframework/session/web/server/session/SpringSessionWebSessionStoreTests.java index 5740848c5..8faa4d884 100644 --- a/spring-session-core/src/test/java/org/springframework/session/web/server/session/SpringSessionWebSessionStoreTests.java +++ b/spring-session-core/src/test/java/org/springframework/session/web/server/session/SpringSessionWebSessionStoreTests.java @@ -47,7 +47,7 @@ @ExtendWith(MockitoExtension.class) class SpringSessionWebSessionStoreTests { - @Mock(lenient = true) + @Mock(strictness = Mock.Strictness.LENIENT) private ReactiveSessionRepository sessionRepository; @Mock diff --git a/spring-session-core/src/test/java/org/springframework/session/web/socket/handler/WebSocketRegistryListenerTests.java b/spring-session-core/src/test/java/org/springframework/session/web/socket/handler/WebSocketRegistryListenerTests.java index 9460e96a7..27df577f3 100644 --- a/spring-session-core/src/test/java/org/springframework/session/web/socket/handler/WebSocketRegistryListenerTests.java +++ b/spring-session-core/src/test/java/org/springframework/session/web/socket/handler/WebSocketRegistryListenerTests.java @@ -48,13 +48,13 @@ @ExtendWith(MockitoExtension.class) class WebSocketRegistryListenerTests { - @Mock(lenient = true) + @Mock(strictness = Mock.Strictness.LENIENT) private WebSocketSession wsSession; - @Mock(lenient = true) + @Mock(strictness = Mock.Strictness.LENIENT) private WebSocketSession wsSession2; - @Mock(lenient = true) + @Mock(strictness = Mock.Strictness.LENIENT) private Message message; @Mock diff --git a/spring-session-core/src/test/java/org/springframework/session/web/socket/server/SessionRepositoryMessageInterceptorTests.java b/spring-session-core/src/test/java/org/springframework/session/web/socket/server/SessionRepositoryMessageInterceptorTests.java index bb9e8c198..b9e40a1fb 100644 --- a/spring-session-core/src/test/java/org/springframework/session/web/socket/server/SessionRepositoryMessageInterceptorTests.java +++ b/spring-session-core/src/test/java/org/springframework/session/web/socket/server/SessionRepositoryMessageInterceptorTests.java @@ -54,7 +54,7 @@ @ExtendWith(MockitoExtension.class) class SessionRepositoryMessageInterceptorTests { - @Mock(lenient = true) + @Mock(strictness = Mock.Strictness.LENIENT) SessionRepository sessionRepository; @Mock diff --git a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoDbDeleteJacksonSessionVerificationTest.java b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoDbDeleteJacksonSessionVerificationTest.java index 211320aaa..930955744 100644 --- a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoDbDeleteJacksonSessionVerificationTest.java +++ b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoDbDeleteJacksonSessionVerificationTest.java @@ -36,6 +36,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; @@ -56,7 +57,7 @@ */ @ExtendWith(SpringExtension.class) @ContextConfiguration -public class MongoDbDeleteJacksonSessionVerificationTest { +class MongoDbDeleteJacksonSessionVerificationTest { @Autowired ApplicationContext ctx; @@ -72,70 +73,62 @@ void setUp() { void logoutShouldDeleteOldSessionFromMongoDB() { // 1. Login and capture the SESSION cookie value. - - FluxExchangeResult loginResult = this.client.post() - .uri("/login") - .contentType(MediaType.APPLICATION_FORM_URLENCODED) // - .body(BodyInserters // - .fromFormData("username", "admin") // - .with("password", "password")) // - .exchange() // - .returnResult(String.class); + // @formatter:off + FluxExchangeResult loginResult = this.client.post().uri("/login") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters + .fromFormData("username", "admin") + .with("password", "password")) + .exchange() + .returnResult(String.class); + // @formatter:on AssertionsForClassTypes.assertThat(loginResult.getResponseHeaders().getLocation()).isEqualTo(URI.create("/")); String originalSessionId = loginResult.getResponseCookies().getFirst("SESSION").getValue(); // 2. Fetch a protected resource using the SESSION cookie. - - this.client.get() - .uri("/hello") // - .cookie("SESSION", originalSessionId) // - .exchange() // - .expectStatus() - .isOk() // - .returnResult(String.class) - .getResponseBody() // - .as(StepVerifier::create) // - .expectNext("HelloWorld") // - .verifyComplete(); + // @formatter:off + this.client.get().uri("/hello") + .cookie("SESSION", originalSessionId) + .exchange() + .expectStatus().isOk() + .returnResult(String.class).getResponseBody() + .as(StepVerifier::create) + .expectNext("HelloWorld") + .verifyComplete(); + // @formatter:on // 3. Logout using the SESSION cookie, and capture the new SESSION cookie. - - String newSessionId = this.client.post() - .uri("/logout") // - .cookie("SESSION", originalSessionId) // - .exchange() // - .expectStatus() - .isFound() // - .returnResult(String.class) - .getResponseCookies() - .getFirst("SESSION") - .getValue(); + // @formatter:off + String newSessionId = this.client.post().uri("/logout") + .cookie("SESSION", originalSessionId) + .exchange() + .expectStatus().isFound() + .returnResult(String.class).getResponseCookies().getFirst("SESSION").getValue(); + // @formatter:on AssertionsForClassTypes.assertThat(newSessionId).isNotEqualTo(originalSessionId); // 4. Verify the new SESSION cookie is not yet authorized. - - this.client.get() - .uri("/hello") // - .cookie("SESSION", newSessionId) // - .exchange() // - .expectStatus() - .isFound() // - .expectHeader() - .value(HttpHeaders.LOCATION, (value) -> AssertionsForClassTypes.assertThat(value).isEqualTo("/login")); + // @formatter:off + this.client.get().uri("/hello") + .cookie("SESSION", newSessionId) + .exchange() + .expectStatus().isFound() + .expectHeader() + .value(HttpHeaders.LOCATION, (value) -> AssertionsForClassTypes.assertThat(value).isEqualTo("/login")); + // @formatter:on // 5. Verify the original SESSION cookie no longer works. - - this.client.get() - .uri("/hello") // - .cookie("SESSION", originalSessionId) // - .exchange() // - .expectStatus() - .isFound() // - .expectHeader() - .value(HttpHeaders.LOCATION, (value) -> AssertionsForClassTypes.assertThat(value).isEqualTo("/login")); + // @formatter:off + this.client.get().uri("/hello") + .cookie("SESSION", originalSessionId) + .exchange() + .expectStatus().isFound() + .expectHeader() + .value(HttpHeaders.LOCATION, (value) -> AssertionsForClassTypes.assertThat(value).isEqualTo("/login")); + // @formatter:on } @RestController @@ -154,26 +147,24 @@ static class SecurityConfig { @Bean SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { - return http // - .logout()// - /**/.and() // - .formLogin() // - /**/.and() // - .csrf() - .disable() // - .authorizeExchange() // - .anyExchange() - .authenticated() // - /**/.and() // - .build(); + // @formatter:off + return http + .logout(Customizer.withDefaults()) + .formLogin(Customizer.withDefaults()) + .csrf((csrf) -> csrf.disable()) + .authorizeExchange((ae) -> ae.anyExchange().authenticated()) + .build(); + // @formatter:on } @Bean MapReactiveUserDetailsService userDetailsService() { - return new MapReactiveUserDetailsService(User.withUsername("admin") // - .password("{noop}password") // - .roles("USER,ADMIN") // - .build()); + // @formatter:off + return new MapReactiveUserDetailsService(User.withUsername("admin") + .password("{noop}password") + .roles("USER,ADMIN") + .build()); + // @formatter:on } @Bean diff --git a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoDbLogoutVerificationTest.java b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoDbLogoutVerificationTest.java index 69c8c3cc0..200495718 100644 --- a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoDbLogoutVerificationTest.java +++ b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoDbLogoutVerificationTest.java @@ -36,6 +36,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; @@ -56,7 +57,7 @@ */ @ExtendWith(SpringExtension.class) @ContextConfiguration -public class MongoDbLogoutVerificationTest { +class MongoDbLogoutVerificationTest { @Autowired ApplicationContext ctx; @@ -154,28 +155,24 @@ static class SecurityConfig { @Bean SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { - - return http // - .logout()// - /**/.and() // - .formLogin() // - /**/.and() // - .csrf() - .disable() // - .authorizeExchange() // - .anyExchange() - .authenticated() // - /**/.and() // - .build(); + // @formatter:off + return http + .logout(Customizer.withDefaults()) + .formLogin(Customizer.withDefaults()) + .csrf((csrf) -> csrf.disable()) + .authorizeExchange((ae) -> ae.anyExchange().authenticated()) + .build(); + // @formatter:on } @Bean MapReactiveUserDetailsService userDetailsService() { - - return new MapReactiveUserDetailsService(User.withUsername("admin") // - .password("{noop}password") // - .roles("USER,ADMIN") // - .build()); + // @formatter:off + return new MapReactiveUserDetailsService(User.withUsername("admin") + .password("{noop}password") + .roles("USER,ADMIN") + .build()); + // @formatter:on } } diff --git a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoRepositoryJacksonITest.java b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoRepositoryJacksonITest.java index d94db319f..158d75db4 100644 --- a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoRepositoryJacksonITest.java +++ b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoRepositoryJacksonITest.java @@ -40,7 +40,7 @@ * @author Greg Turnquist */ @ContextConfiguration -public class MongoRepositoryJacksonITest extends AbstractMongoRepositoryITest { +class MongoRepositoryJacksonITest extends AbstractMongoRepositoryITest { @Test void findByCustomIndex() throws Exception { diff --git a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoRepositoryJdkSerializationITest.java b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoRepositoryJdkSerializationITest.java index 01ee64d26..7b3459807 100644 --- a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoRepositoryJdkSerializationITest.java +++ b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/MongoRepositoryJdkSerializationITest.java @@ -38,7 +38,7 @@ * @author Greg Turnquist */ @ContextConfiguration -public class MongoRepositoryJdkSerializationITest extends AbstractMongoRepositoryITest { +class MongoRepositoryJdkSerializationITest extends AbstractMongoRepositoryITest { @Test void findByDeletedSecurityPrincipalNameReload() throws Exception { diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoIndexedSessionRepository.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoIndexedSessionRepository.java index e2a3d2f7e..af56f8cce 100644 --- a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoIndexedSessionRepository.java +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoIndexedSessionRepository.java @@ -36,6 +36,8 @@ import org.springframework.lang.Nullable; import org.springframework.session.FindByIndexNameSessionRepository; import org.springframework.session.MapSession; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.events.SessionCreatedEvent; import org.springframework.session.events.SessionDeletedEvent; import org.springframework.session.events.SessionExpiredEvent; @@ -81,6 +83,8 @@ public class MongoIndexedSessionRepository private ApplicationEventPublisher eventPublisher; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + public MongoIndexedSessionRepository(MongoOperations mongoOperations) { this.mongoOperations = mongoOperations; } @@ -88,9 +92,7 @@ public MongoIndexedSessionRepository(MongoOperations mongoOperations) { @Override public MongoSession createSession() { - MongoSession session = new MongoSession(); - - session.setMaxInactiveInterval(this.defaultMaxInactiveInterval); + MongoSession session = new MongoSession(this.sessionIdGenerator, this.defaultMaxInactiveInterval.toSeconds()); publishEvent(new SessionCreatedEvent(this, session)); @@ -116,10 +118,13 @@ public MongoSession findById(String id) { MongoSession session = MongoSessionUtils.convertToSession(this.mongoSessionConverter, sessionWrapper); - if (session != null && session.isExpired()) { - publishEvent(new SessionExpiredEvent(this, session)); - deleteById(id); - return null; + if (session != null) { + if (session.isExpired()) { + publishEvent(new SessionExpiredEvent(this, session)); + deleteById(id); + return null; + } + session.setSessionIdGenerator(this.sessionIdGenerator); } return session; @@ -141,6 +146,7 @@ public Map findByIndexNameAndIndexValue(String indexName, .orElse(Collections.emptyList()) .stream() .map((dbSession) -> MongoSessionUtils.convertToSession(this.mongoSessionConverter, dbSession)) + .peek((session) -> session.setSessionIdGenerator(this.sessionIdGenerator)) .collect(Collectors.toMap(MongoSession::getId, (mapSession) -> mapSession)); } @@ -217,4 +223,14 @@ public void setMongoSessionConverter(final AbstractMongoSessionConverter mongoSe this.mongoSessionConverter = mongoSessionConverter; } + /** + * Set the {@link SessionIdGenerator} to use to generate session ids. + * @param sessionIdGenerator the {@link SessionIdGenerator} to use + * @since 3.2 + */ + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + Assert.notNull(sessionIdGenerator, "sessionIdGenerator cannot be null"); + this.sessionIdGenerator = sessionIdGenerator; + } + } diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoSession.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoSession.java index 39a47ffc1..8be09ea46 100644 --- a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoSession.java +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoSession.java @@ -23,12 +23,14 @@ import java.util.Map; import java.util.Objects; import java.util.Set; -import java.util.UUID; import java.util.stream.Collectors; import org.springframework.lang.Nullable; import org.springframework.session.MapSession; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; +import org.springframework.util.Assert; /** * Session object providing additional information about the datetime of expiration. @@ -37,7 +39,7 @@ * @author Greg Turnquist * @since 1.2 */ -class MongoSession implements Session { +public final class MongoSession implements Session { /** * Mongo doesn't support {@literal dot} in field names. We replace it with a unicode @@ -46,15 +48,14 @@ class MongoSession implements Session { * NOTE: This was originally stored in unicode format. Delomboking the code caused it * to get converted to another encoding, which isn't supported on all systems, so we * migrated back to unicode. The same character is being represented ensuring binary - * compatibility. - * - * See https://www.compart.com/en/unicode/U+F607 + * compatibility. See https://www.compart.com/en/unicode/U+F607 */ private static final char DOT_COVER_CHAR = '\uF607'; private String id; - private String originalSessionId; + private final String originalSessionId; private long createdMillis = System.currentTimeMillis(); @@ -64,24 +65,56 @@ class MongoSession implements Session { private Date expireAt; - private Map attrs = new HashMap<>(); + private final Map attrs = new HashMap<>(); - MongoSession() { - this(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS); + private transient SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + + /** + * Constructs a new instance using the provided session id. + * @param sessionId the session id to use + * @since 3.2 + */ + public MongoSession(String sessionId) { + this(sessionId, MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS); } - MongoSession(long maxInactiveIntervalInSeconds) { - this(UUID.randomUUID().toString(), maxInactiveIntervalInSeconds); + public MongoSession() { + this(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS); } - MongoSession(String id, long maxInactiveIntervalInSeconds) { + public MongoSession(long maxInactiveIntervalInSeconds) { + this(UuidSessionIdGenerator.getInstance().generate(), maxInactiveIntervalInSeconds); + } + public MongoSession(String id, long maxInactiveIntervalInSeconds) { this.id = id; this.originalSessionId = id; this.intervalSeconds = maxInactiveIntervalInSeconds; setLastAccessedTime(Instant.ofEpochMilli(this.createdMillis)); } + /** + * Constructs a new instance using the provided {@link SessionIdGenerator}. + * @param sessionIdGenerator the {@link SessionIdGenerator} to use + * @since 3.2 + */ + public MongoSession(SessionIdGenerator sessionIdGenerator) { + this(sessionIdGenerator.generate(), MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS); + this.sessionIdGenerator = sessionIdGenerator; + } + + /** + * Constructs a new instance using the provided {@link SessionIdGenerator} and max + * inactive interval. + * @param sessionIdGenerator the {@link SessionIdGenerator} to use + * @param maxInactiveIntervalInSeconds the max inactive interval in seconds + * @since 3.2 + */ + MongoSession(SessionIdGenerator sessionIdGenerator, long maxInactiveIntervalInSeconds) { + this(sessionIdGenerator.generate(), maxInactiveIntervalInSeconds); + this.sessionIdGenerator = sessionIdGenerator; + } + static String coverDot(String attributeName) { return attributeName.replace('.', DOT_COVER_CHAR); } @@ -93,13 +126,14 @@ static String uncoverDot(String attributeName) { @Override public String changeSessionId() { - String changedId = UUID.randomUUID().toString(); + String changedId = this.sessionIdGenerator.generate(); this.id = changedId; return changedId; } @Override @Nullable + @SuppressWarnings("unchecked") public T getAttribute(String attributeName) { return (T) this.attrs.get(coverDot(attributeName)); } @@ -141,7 +175,6 @@ public Instant getLastAccessedTime() { @Override public void setLastAccessedTime(Instant lastAccessedTime) { - this.accessedMillis = lastAccessedTime.toEpochMilli(); this.expireAt = Date.from(lastAccessedTime.plus(Duration.ofSeconds(this.intervalSeconds))); } @@ -200,4 +233,23 @@ String getOriginalSessionId() { return this.originalSessionId; } + /** + * Sets the session id. + * @param id the id to set + * @since 3.2 + */ + void setId(String id) { + this.id = id; + } + + /** + * Sets the {@link SessionIdGenerator} to use. + * @param sessionIdGenerator the {@link SessionIdGenerator} to use + * @since 3.2 + */ + void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + Assert.notNull(sessionIdGenerator, "sessionIdGenerator cannot be null"); + this.sessionIdGenerator = sessionIdGenerator; + } + } diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepository.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepository.java index 78b9ebc3c..a16aebae8 100644 --- a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepository.java +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepository.java @@ -22,6 +22,7 @@ import org.apache.commons.logging.LogFactory; import org.bson.Document; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import org.springframework.beans.factory.InitializingBean; import org.springframework.context.ApplicationEvent; @@ -34,6 +35,8 @@ import org.springframework.data.mongodb.core.query.Query; import org.springframework.session.MapSession; import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.events.SessionCreatedEvent; import org.springframework.session.events.SessionDeletedEvent; import org.springframework.util.Assert; @@ -76,6 +79,8 @@ public class ReactiveMongoSessionRepository private ApplicationEventPublisher eventPublisher; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + public ReactiveMongoSessionRepository(ReactiveMongoOperations mongoOperations) { this.mongoOperations = mongoOperations; } @@ -93,11 +98,18 @@ public ReactiveMongoSessionRepository(ReactiveMongoOperations mongoOperations) { */ @Override public Mono createSession() { - - return Mono.justOrEmpty(this.defaultMaxInactiveInterval.toSeconds()) // - .map(MongoSession::new) // - .doOnNext((mongoSession) -> publishEvent(new SessionCreatedEvent(this, mongoSession))) // - .switchIfEmpty(Mono.just(new MongoSession())); + // @formatter:off + return Mono.fromSupplier(() -> this.sessionIdGenerator.generate()) + .zipWith(Mono.just(this.defaultMaxInactiveInterval.toSeconds())) + .map((tuple) -> new MongoSession(tuple.getT1(), tuple.getT2())) + .doOnNext((mongoSession) -> mongoSession.setMaxInactiveInterval(this.defaultMaxInactiveInterval)) + .doOnNext( + (mongoSession) -> mongoSession.setSessionIdGenerator(this.sessionIdGenerator)) + .doOnNext((mongoSession) -> publishEvent(new SessionCreatedEvent(this, mongoSession))) + .switchIfEmpty(Mono.just(new MongoSession(this.sessionIdGenerator))) + .subscribeOn(Schedulers.boundedElastic()) + .publishOn(Schedulers.parallel()); + // @formatter:on } @Override @@ -127,6 +139,7 @@ public Mono findById(String id) { return findSession(id) // .map((document) -> MongoSessionUtils.convertToSession(this.mongoSessionConverter, document)) // .filter((mongoSession) -> !mongoSession.isExpired()) // + .doOnNext((mongoSession) -> mongoSession.setSessionIdGenerator(this.sessionIdGenerator)) .switchIfEmpty(Mono.defer(() -> this.deleteById(id).then(Mono.empty()))); } @@ -216,4 +229,9 @@ public void setBlockingMongoOperations(final MongoOperations blockingMongoOperat this.blockingMongoOperations = blockingMongoOperations; } + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + Assert.notNull(sessionIdGenerator, "sessionIdGenerator cannot be null"); + this.sessionIdGenerator = sessionIdGenerator; + } + } diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/http/MongoHttpSessionConfiguration.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/http/MongoHttpSessionConfiguration.java index 86cc11ea3..d81ed4429 100644 --- a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/http/MongoHttpSessionConfiguration.java +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/http/MongoHttpSessionConfiguration.java @@ -36,6 +36,8 @@ import org.springframework.session.IndexResolver; import org.springframework.session.MapSession; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.config.SessionRepositoryCustomizer; import org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration; import org.springframework.session.data.mongo.AbstractMongoSessionConverter; @@ -70,6 +72,8 @@ public class MongoHttpSessionConfiguration implements BeanClassLoaderAware, Embe private IndexResolver indexResolver; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + @Bean public MongoIndexedSessionRepository mongoSessionRepository(MongoOperations mongoOperations) { @@ -98,6 +102,7 @@ public MongoIndexedSessionRepository mongoSessionRepository(MongoOperations mong if (StringUtils.hasText(this.collectionName)) { repository.setCollectionName(this.collectionName); } + repository.setSessionIdGenerator(this.sessionIdGenerator); this.sessionRepositoryCustomizers .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(repository)); @@ -160,4 +165,9 @@ public void setIndexResolver(IndexResolver indexResolver) { this.indexResolver = indexResolver; } + @Autowired(required = false) + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + this.sessionIdGenerator = sessionIdGenerator; + } + } diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/reactive/ReactiveMongoWebSessionConfiguration.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/reactive/ReactiveMongoWebSessionConfiguration.java index 008cbb035..ef1429ef4 100644 --- a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/reactive/ReactiveMongoWebSessionConfiguration.java +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/reactive/ReactiveMongoWebSessionConfiguration.java @@ -37,6 +37,8 @@ import org.springframework.session.IndexResolver; import org.springframework.session.MapSession; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.config.ReactiveSessionRepositoryCustomizer; import org.springframework.session.config.annotation.web.server.SpringWebSessionConfiguration; import org.springframework.session.data.mongo.AbstractMongoSessionConverter; @@ -74,6 +76,8 @@ public class ReactiveMongoWebSessionConfiguration private IndexResolver indexResolver; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + @Bean public ReactiveMongoSessionRepository reactiveMongoSessionRepository(ReactiveMongoOperations operations) { @@ -112,6 +116,8 @@ public ReactiveMongoSessionRepository reactiveMongoSessionRepository(ReactiveMon this.sessionRepositoryCustomizers .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(repository)); + repository.setSessionIdGenerator(this.sessionIdGenerator); + return repository; } @@ -180,4 +186,9 @@ public void setIndexResolver(IndexResolver indexResolver) { this.indexResolver = indexResolver; } + @Autowired(required = false) + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + this.sessionIdGenerator = sessionIdGenerator; + } + } diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/JacksonMongoSessionConverterTests.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/JacksonMongoSessionConverterTests.java index a47cb2d55..918693015 100644 --- a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/JacksonMongoSessionConverterTests.java +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/JacksonMongoSessionConverterTests.java @@ -35,7 +35,7 @@ * @author Jakub Kubrynski * @author Greg Turnquist */ -public class JacksonMongoSessionConverterTests extends AbstractMongoSessionConverterTests { +class JacksonMongoSessionConverterTests extends AbstractMongoSessionConverterTests { JacksonMongoSessionConverter mongoSessionConverter = new JacksonMongoSessionConverter(); diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/JdkMongoSessionConverterTests.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/JdkMongoSessionConverterTests.java index 52c369f5e..f809f12b6 100644 --- a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/JdkMongoSessionConverterTests.java +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/JdkMongoSessionConverterTests.java @@ -30,7 +30,7 @@ * @author Rob Winch * @author Greg Turnquist */ -public class JdkMongoSessionConverterTests extends AbstractMongoSessionConverterTests { +class JdkMongoSessionConverterTests extends AbstractMongoSessionConverterTests { Duration inactiveInterval = Duration.ofMinutes(30); diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/MongoIndexedSessionRepositoryTests.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/MongoIndexedSessionRepositoryTests.java index a2d1b9e99..ab555e133 100644 --- a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/MongoIndexedSessionRepositoryTests.java +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/MongoIndexedSessionRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -16,6 +16,8 @@ package org.springframework.session.data.mongo; +import java.time.Duration; +import java.time.Instant; import java.util.Collections; import java.util.Map; import java.util.UUID; @@ -34,8 +36,10 @@ import org.springframework.data.mongodb.core.query.Query; import org.springframework.session.FindByIndexNameSessionRepository; import org.springframework.session.MapSession; +import org.springframework.session.SessionIdGenerator; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -51,7 +55,7 @@ * @author Greg Turnquist */ @ExtendWith(MockitoExtension.class) -public class MongoIndexedSessionRepositoryTests { +class MongoIndexedSessionRepositoryTests { @Mock private AbstractMongoSessionConverter converter; @@ -217,4 +221,62 @@ void shouldReturnEmptyMapForNotSupportedIndex() { assertThat(sessionsMap).isEmpty(); } + @Test + void createSessionWhenSessionIdGeneratorThenUses() { + this.repository.setSessionIdGenerator(new FixedSessionIdGenerator("123")); + MongoSession session = this.repository.createSession(); + assertThat(session.getId()).isEqualTo("123"); + assertThat(session.changeSessionId()).isEqualTo("123"); + } + + @Test + void setSessionIdGeneratorWhenNullThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setSessionIdGenerator(null)); + } + + @Test + void findByIdWhenChangeSessionIdThenUsesSessionIdGenerator() { + this.repository.setSessionIdGenerator(new FixedSessionIdGenerator("456")); + + Document sessionDocument = new Document(); + + given(this.mongoOperations.findById("123", Document.class, + MongoIndexedSessionRepository.DEFAULT_COLLECTION_NAME)) + .willReturn(sessionDocument); + + MongoSession session = new MongoSession("123"); + + given(this.converter.convert(sessionDocument, TypeDescriptor.valueOf(Document.class), + TypeDescriptor.valueOf(MongoSession.class))) + .willReturn(session); + + MongoSession retrievedSession = this.repository.findById("123"); + assertThat(retrievedSession.getId()).isEqualTo("123"); + String newSessionId = retrievedSession.changeSessionId(); + assertThat(newSessionId).isEqualTo("456"); + } + + @Test + void createSessionWhenMaxInactiveIntervalSetThenUse() { + this.repository.setDefaultMaxInactiveInterval(Duration.ofSeconds(60)); + MongoSession session = this.repository.createSession(); + Instant now = Instant.now(); + assertThat(session.getExpireAt()).isBetween(now.plusSeconds(59), Instant.now().plusSeconds(61)); + } + + static class FixedSessionIdGenerator implements SessionIdGenerator { + + private final String id; + + FixedSessionIdGenerator(String id) { + this.id = id; + } + + @Override + public String generate() { + return this.id; + } + + } + } diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/MongoSessionTests.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/MongoSessionTests.java index d26c98a05..7ce9918e2 100644 --- a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/MongoSessionTests.java +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/MongoSessionTests.java @@ -27,7 +27,7 @@ * @author Rob Winch * @author Greg Turnquist */ -public class MongoSessionTests { +class MongoSessionTests { @Test void isExpiredWhenIntervalNegativeThenFalse() { diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepositoryTests.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepositoryTests.java index 2b6192438..193a2c860 100644 --- a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepositoryTests.java +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -17,6 +17,7 @@ package org.springframework.session.data.mongo; import java.time.Duration; +import java.time.Instant; import java.util.UUID; import com.mongodb.BasicDBObject; @@ -40,6 +41,7 @@ import org.springframework.session.events.SessionDeletedEvent; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.BDDMockito.any; import static org.mockito.BDDMockito.eq; import static org.mockito.BDDMockito.given; @@ -55,7 +57,7 @@ * @author Greg Turnquist */ @ExtendWith(MockitoExtension.class) -public class ReactiveMongoSessionRepositoryTests { +class ReactiveMongoSessionRepositoryTests { @Mock private AbstractMongoSessionConverter converter; @@ -217,4 +219,53 @@ void shouldInvokeMethodToCreateIndexesImperatively() { verify(this.converter, times(1)).ensureIndexes(indexOperations); } + @Test + void createSessionWhenSessionIdGeneratorThenUses() { + this.repository.setSessionIdGenerator(() -> "test"); + + this.repository.createSession().as(StepVerifier::create).assertNext((mongoSession) -> { + assertThat(mongoSession.getId()).isEqualTo("test"); + assertThat(mongoSession.changeSessionId()).isEqualTo("test"); + }).verifyComplete(); + } + + @Test + void setSessionIdGeneratorWhenNullThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setSessionIdGenerator(null)) + .withMessage("sessionIdGenerator cannot be null"); + } + + @Test + void findByIdWhenChangeSessionIdThenUsesSessionIdGenerator() { + this.repository.setSessionIdGenerator(() -> "test"); + + String sessionId = UUID.randomUUID().toString(); + Document sessionDocument = new Document(); + + given(this.mongoOperations.findById(sessionId, Document.class, + ReactiveMongoSessionRepository.DEFAULT_COLLECTION_NAME)) + .willReturn(Mono.just(sessionDocument)); + + MongoSession session = new MongoSession(sessionId); + + given(this.converter.convert(sessionDocument, TypeDescriptor.valueOf(Document.class), + TypeDescriptor.valueOf(MongoSession.class))) + .willReturn(session); + + this.repository.findById(sessionId).as(StepVerifier::create).assertNext((mongoSession) -> { + String oldId = mongoSession.getId(); + String newId = mongoSession.changeSessionId(); + assertThat(oldId).isEqualTo(sessionId); + assertThat(newId).isEqualTo("test"); + }).verifyComplete(); + } + + @Test + void createSessionWhenMaxInactiveIntervalSetThenUse() { + this.repository.setDefaultMaxInactiveInterval(Duration.ofSeconds(60)); + MongoSession session = this.repository.createSession().block(); + Instant now = Instant.now(); + assertThat(session.getExpireAt()).isBetween(now.plusSeconds(59), Instant.now().plusSeconds(61)); + } + } diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/config/annotation/web/http/MongoHttpSessionConfigurationTests.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/config/annotation/web/http/MongoHttpSessionConfigurationTests.java index d6e2604ae..742c9a1a4 100644 --- a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/config/annotation/web/http/MongoHttpSessionConfigurationTests.java +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/config/annotation/web/http/MongoHttpSessionConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -34,6 +34,8 @@ import org.springframework.mock.env.MockEnvironment; import org.springframework.session.IndexResolver; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.config.SessionRepositoryCustomizer; import org.springframework.session.data.mongo.AbstractMongoSessionConverter; import org.springframework.session.data.mongo.JacksonMongoSessionConverter; @@ -52,7 +54,7 @@ * @author Eddú Meléndez * @author Vedran Pavic */ -public class MongoHttpSessionConfigurationTests { +class MongoHttpSessionConfigurationTests { private static final String COLLECTION_NAME = "testSessions"; @@ -200,6 +202,20 @@ void importConfigAndCustomize() { assertThat(sessionRepository).extracting("defaultMaxInactiveInterval").isEqualTo(Duration.ZERO); } + @Test + void registerWhenSessionIdGeneratorBeanThenUses() { + registerAndRefresh(SessionIdGeneratorConfiguration.class); + MongoIndexedSessionRepository sessionRepository = this.context.getBean(MongoIndexedSessionRepository.class); + assertThat(sessionRepository).extracting("sessionIdGenerator").isInstanceOf(TestSessionIdGenerator.class); + } + + @Test + void registerWhenNoSessionIdGeneratorBeanThenDefault() { + registerAndRefresh(DefaultConfiguration.class); + MongoIndexedSessionRepository sessionRepository = this.context.getBean(MongoIndexedSessionRepository.class); + assertThat(sessionRepository).extracting("sessionIdGenerator").isInstanceOf(UuidSessionIdGenerator.class); + } + private void registerAndRefresh(Class... annotatedClasses) { this.context.register(annotatedClasses); @@ -350,4 +366,25 @@ SessionRepositoryCustomizer sessionRepositoryCust } + @Configuration(proxyBeanMethods = false) + @EnableMongoHttpSession + @Import(MongoConfiguration.class) + static class SessionIdGeneratorConfiguration { + + @Bean + SessionIdGenerator sessionIdGenerator() { + return new TestSessionIdGenerator(); + } + + } + + static class TestSessionIdGenerator implements SessionIdGenerator { + + @Override + public String generate() { + return "test"; + } + + } + } diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/config/annotation/web/reactive/ReactiveMongoWebSessionConfigurationTests.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/config/annotation/web/reactive/ReactiveMongoWebSessionConfigurationTests.java index 62791ec59..d8c5ec1b4 100644 --- a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/config/annotation/web/reactive/ReactiveMongoWebSessionConfigurationTests.java +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/config/annotation/web/reactive/ReactiveMongoWebSessionConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -35,6 +35,8 @@ import org.springframework.session.IndexResolver; import org.springframework.session.ReactiveSessionRepository; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.config.ReactiveSessionRepositoryCustomizer; import org.springframework.session.config.annotation.web.server.EnableSpringWebSession; import org.springframework.session.data.mongo.AbstractMongoSessionConverter; @@ -59,7 +61,7 @@ * @author Greg Turnquist * @author Vedran Pavic */ -public class ReactiveMongoWebSessionConfigurationTests { +class ReactiveMongoWebSessionConfigurationTests { private AnnotationConfigApplicationContext context; @@ -221,6 +223,26 @@ void importConfigAndCustomize() { assertThat(sessionRepository).extracting("defaultMaxInactiveInterval").isEqualTo(Duration.ZERO); } + @Test + void registerWhenSessionIdGeneratorBeanThenUses() { + registerAndRefresh(GoodConfig.class, SessionIdGeneratorConfiguration.class); + ReactiveMongoSessionRepository sessionRepository = this.context.getBean(ReactiveMongoSessionRepository.class); + assertThat(sessionRepository).extracting("sessionIdGenerator").isInstanceOf(TestSessionIdGenerator.class); + } + + @Test + void registerWhenNoSessionIdGeneratorBeanThenDefault() { + registerAndRefresh(GoodConfig.class); + ReactiveMongoSessionRepository sessionRepository = this.context.getBean(ReactiveMongoSessionRepository.class); + assertThat(sessionRepository).extracting("sessionIdGenerator").isInstanceOf(UuidSessionIdGenerator.class); + } + + private void registerAndRefresh(Class... annotatedClasses) { + this.context = new AnnotationConfigApplicationContext(); + this.context.register(annotatedClasses); + this.context.refresh(); + } + /** * Reflectively extract the {@link AbstractMongoSessionConverter} from the * {@link ReactiveMongoSessionRepository}. This is to avoid expanding the surface area @@ -410,4 +432,23 @@ ReactiveSessionRepositoryCustomizer sessionRepos } + @Configuration(proxyBeanMethods = false) + static class SessionIdGeneratorConfiguration { + + @Bean + SessionIdGenerator sessionIdGenerator() { + return new TestSessionIdGenerator(); + } + + } + + static class TestSessionIdGenerator implements SessionIdGenerator { + + @Override + public String generate() { + return "test"; + } + + } + } diff --git a/spring-session-data-redis/spring-session-data-redis.gradle b/spring-session-data-redis/spring-session-data-redis.gradle index 8fc071a2d..fa027249b 100644 --- a/spring-session-data-redis/spring-session-data-redis.gradle +++ b/spring-session-data-redis/spring-session-data-redis.gradle @@ -21,8 +21,10 @@ dependencies { testImplementation "org.springframework:spring-web" testImplementation "org.springframework.security:spring-security-core" testImplementation "org.junit.jupiter:junit-jupiter-api" + testImplementation "org.awaitility:awaitility" + testImplementation "io.lettuce:lettuce-core" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" - integrationTestCompile "io.lettuce:lettuce-core" integrationTestCompile "org.testcontainers:testcontainers" + integrationTestCompile "com.redis:testcontainers-redis:1.7.0" } diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/AbstractRedisITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/AbstractRedisITests.java index f5616ac05..b4d9a2aa8 100644 --- a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/AbstractRedisITests.java +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/AbstractRedisITests.java @@ -16,7 +16,7 @@ package org.springframework.session.data.redis; -import org.testcontainers.containers.GenericContainer; +import com.redis.testcontainers.RedisContainer; import org.springframework.context.annotation.Bean; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; @@ -34,16 +34,17 @@ public abstract class AbstractRedisITests { protected static class BaseConfig { @Bean - public GenericContainer redisContainer() { - GenericContainer redisContainer = new GenericContainer(DOCKER_IMAGE).withExposedPorts(6379); + public RedisContainer redisContainer() { + RedisContainer redisContainer = new RedisContainer( + RedisContainer.DEFAULT_IMAGE_NAME.withTag(RedisContainer.DEFAULT_TAG)); redisContainer.start(); return redisContainer; } @Bean - public LettuceConnectionFactory redisConnectionFactory() { - RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration(redisContainer().getHost(), - redisContainer().getFirstMappedPort()); + public LettuceConnectionFactory redisConnectionFactory(RedisContainer redisContainer) { + RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration(redisContainer.getHost(), + redisContainer.getFirstMappedPort()); return new LettuceConnectionFactory(configuration); } diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepositoryConfigurationITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepositoryConfigurationITests.java new file mode 100644 index 000000000..11aa3c959 --- /dev/null +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepositoryConfigurationITests.java @@ -0,0 +1,184 @@ +/* + * Copyright 2014-2023 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.session.data.redis; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.Session; +import org.springframework.session.config.ReactiveSessionRepositoryCustomizer; +import org.springframework.session.data.SessionEventRegistry; +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository.RedisSession; +import org.springframework.session.data.redis.config.ConfigureReactiveRedisAction; +import org.springframework.session.data.redis.config.annotation.web.server.EnableRedisIndexedWebSession; +import org.springframework.session.events.SessionCreatedEvent; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@ExtendWith(SpringExtension.class) +class ReactiveRedisIndexedSessionRepositoryConfigurationITests { + + ReactiveRedisIndexedSessionRepository repository; + + ReactiveRedisOperations sessionRedisOperations; + + AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); + + SecurityContext securityContext; + + @BeforeEach + void setup() { + this.securityContext = SecurityContextHolder.createEmptyContext(); + this.securityContext.setAuthentication(new UsernamePasswordAuthenticationToken("username-" + UUID.randomUUID(), + "na", AuthorityUtils.createAuthorityList("ROLE_USER"))); + } + + @Test + void cleanUpTaskWhenSessionIsExpiredThenAllRelatedKeysAreDeleted() { + registerConfig(OneSecCleanUpIntervalConfig.class); + RedisSession session = this.repository.createSession().block(); + session.setAttribute("SPRING_SECURITY_CONTEXT", this.securityContext); + this.repository.save(session).block(); + await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> { + assertThat(this.repository.findById(session.getId()).block()).isNull(); + Boolean hasSessionKey = this.sessionRedisOperations.hasKey("spring:session:sessions:" + session.getId()) + .block(); + Boolean hasSessionIndexesKey = this.sessionRedisOperations + .hasKey("spring:session:sessions:" + session.getId() + ":idx") + .block(); + Boolean hasPrincipalIndexKey = this.sessionRedisOperations + .hasKey("spring:session:sessions:index:" + + ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME + ":" + + this.securityContext.getAuthentication().getName()) + .block(); + Long expirationsSize = this.sessionRedisOperations.opsForZSet() + .size("spring:session:sessions:expirations") + .block(); + assertThat(hasSessionKey).isFalse(); + assertThat(hasSessionIndexesKey).isFalse(); + assertThat(hasPrincipalIndexKey).isFalse(); + assertThat(expirationsSize).isZero(); + }); + } + + @Test + void onSessionCreatedWhenUsingJsonSerializerThenEventDeserializedCorrectly() throws InterruptedException { + registerConfig(SessionEventRegistryJsonSerializerConfig.class); + RedisSession session = this.repository.createSession().block(); + this.repository.save(session).block(); + SessionEventRegistry registry = this.context.getBean(SessionEventRegistry.class); + SessionCreatedEvent event = registry.getEvent(session.getId()); + Session eventSession = event.getSession(); + assertThat(eventSession).usingRecursiveComparison() + .withComparatorForFields(new InstantComparator(), "cached.creationTime", "cached.lastAccessedTime") + .isEqualTo(session); + } + + @Test + void sessionExpiredWhenNoCleanUpTaskAndNoKeyspaceEventsThenNoCleanup() { + registerConfig(DisableCleanupTaskAndNoKeyspaceEventsConfig.class); + RedisSession session = this.repository.createSession().block(); + this.repository.save(session).block(); + await().during(Duration.ofSeconds(3)).untilAsserted(() -> { + Boolean exists = this.sessionRedisOperations.hasKey("spring:session:sessions:" + session.getId()).block(); + assertThat(exists).isTrue(); + }); + } + + private void registerConfig(Class clazz) { + this.context.register(clazz); + this.context.refresh(); + this.repository = this.context.getBean(ReactiveRedisIndexedSessionRepository.class); + this.sessionRedisOperations = this.repository.getSessionRedisOperations(); + } + + static class InstantComparator implements Comparator { + + @Override + public int compare(Instant o1, Instant o2) { + return o1.truncatedTo(ChronoUnit.SECONDS).compareTo(o2.truncatedTo(ChronoUnit.SECONDS)); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableRedisIndexedWebSession(maxInactiveIntervalInSeconds = 1) + @Import(AbstractRedisITests.BaseConfig.class) + static class OneSecCleanUpIntervalConfig { + + @Bean + ReactiveSessionRepositoryCustomizer customizer() { + return (sessionRepository) -> sessionRepository.setCleanupInterval(Duration.ofSeconds(1)); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableRedisIndexedWebSession + @Import(AbstractRedisITests.BaseConfig.class) + static class SessionEventRegistryJsonSerializerConfig { + + @Bean + SessionEventRegistry sessionEventRegistry() { + return new SessionEventRegistry(); + } + + @Bean + RedisSerializer springSessionDefaultRedisSerializer() { + return RedisSerializer.json(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableRedisIndexedWebSession(maxInactiveIntervalInSeconds = 1) + @Import(AbstractRedisITests.BaseConfig.class) + static class DisableCleanupTaskAndNoKeyspaceEventsConfig { + + @Bean + ReactiveSessionRepositoryCustomizer customizer() { + return ReactiveRedisIndexedSessionRepository::disableCleanupTask; + } + + @Bean + ConfigureReactiveRedisAction configureReactiveRedisAction() { + return (connection) -> connection.serverCommands().setConfig("notify-keyspace-events", "").then(); + } + + } + +} diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepositoryITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepositoryITests.java new file mode 100644 index 000000000..b59dc533d --- /dev/null +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepositoryITests.java @@ -0,0 +1,728 @@ +/* + * Copyright 2014-2023 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.session.data.redis; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.data.SessionEventRegistry; +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository.RedisSession; +import org.springframework.session.data.redis.config.annotation.SpringSessionRedisOperations; +import org.springframework.session.data.redis.config.annotation.web.server.EnableRedisIndexedWebSession; +import org.springframework.session.events.SessionCreatedEvent; +import org.springframework.session.events.SessionDeletedEvent; +import org.springframework.session.events.SessionExpiredEvent; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.awaitility.Awaitility.await; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration +@WebAppConfiguration +@SuppressWarnings({ "ConstantConditions" }) +class ReactiveRedisIndexedSessionRepositoryITests { + + private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; + + private static final String INDEX_NAME = ReactiveRedisIndexedSessionRepository.PRINCIPAL_NAME_INDEX_NAME; + + @Autowired + private ReactiveRedisIndexedSessionRepository repository; + + @Autowired + private SessionEventRegistry eventRegistry; + + @SpringSessionRedisOperations + private ReactiveRedisOperations redis; + + private SecurityContext context; + + private SecurityContext changedContext; + + @BeforeEach + void setup() { + this.context = SecurityContextHolder.createEmptyContext(); + this.context.setAuthentication(new UsernamePasswordAuthenticationToken("username-" + UUID.randomUUID(), "na", + AuthorityUtils.createAuthorityList("ROLE_USER"))); + + this.changedContext = SecurityContextHolder.createEmptyContext(); + this.changedContext.setAuthentication(new UsernamePasswordAuthenticationToken( + "changedContext-" + UUID.randomUUID(), "na", AuthorityUtils.createAuthorityList("ROLE_USER"))); + } + + @Test + void findByIdWhenSavedThenFound() { + RedisSession session = this.repository.createSession().block(); + session.setAttribute("foo", "bar"); + this.repository.save(session).block(); + RedisSession savedSession = this.repository.findById(session.getId()).block(); + assertThat(savedSession).isNotNull(); + assertThat(savedSession.getId()).isEqualTo(session.getId()); + assertThat(savedSession.getAttribute("foo")).isEqualTo("bar"); + } + + @Test + void saveWhenHasSecurityContextAttributeThenPrincipalIndexKeySaved() { + RedisSession session = this.repository.createSession().block(); + session.setAttribute("foo", "bar"); + + String username = "user"; + Authentication authentication = UsernamePasswordAuthenticationToken.authenticated(username, "password", + AuthorityUtils.createAuthorityList("ROLE_USER")); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + session.setAttribute(SPRING_SECURITY_CONTEXT, context); + this.repository.save(session).block(); + + String usernameSessionKey = "spring:session:sessions:index:" + + ReactiveRedisIndexedSessionRepository.PRINCIPAL_NAME_INDEX_NAME + ":" + username; + Boolean sessionExistsOnPrincipalKey = this.redis.opsForSet() + .isMember(usernameSessionKey, session.getId()) + .block(); + assertThat(sessionExistsOnPrincipalKey).isTrue(); + } + + @Test + void saveWhenSuccessThenSessionCreatedEvent() throws InterruptedException { + RedisSession session = this.repository.createSession().block(); + session.setAttribute("foo", "bar"); + + this.repository.save(session).block(); + + SessionCreatedEvent event = this.eventRegistry.getEvent(session.getId()); + assertThat(event).isNotNull(); + RedisSession eventSession = event.getSession(); + compareSessions(session, eventSession); + } + + @Test + void findByPrincipalNameWhenExistsThenReturn() { + RedisSession session = this.repository.createSession().block(); + String principalName = "principal"; + session.setAttribute(ReactiveRedisIndexedSessionRepository.PRINCIPAL_NAME_INDEX_NAME, principalName); + + this.repository.save(session).block(); + + Map principalSessions = this.repository + .findByIndexNameAndIndexValue(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, + principalName) + .block(); + + assertThat(principalSessions).hasSize(1); + assertThat(principalSessions.keySet()).containsOnly(session.getId()); + + this.repository.deleteById(session.getId()).block(); + + principalSessions = this.repository + .findByIndexNameAndIndexValue(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, + principalName) + .block(); + assertThat(principalSessions).isEmpty(); + } + + @Test + void findByPrincipalNameWhenExpireKeyEventThenRemovesIndexAndSessionExpiredEvent() { + String principalName = "findByPrincipalNameExpireRemovesIndex" + UUID.randomUUID(); + RedisSession toSave = this.repository.createSession().block(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave).block(); + + this.eventRegistry.clear(); + String key = "spring:session:sessions:expires:" + toSave.getId(); + assertThat(this.redis.expire(key, Duration.ofSeconds(1)).block()).isTrue(); + + await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> { + SessionExpiredEvent event = this.eventRegistry.getEvent(toSave.getId()); + RedisSession eventSession = event.getSession(); + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + assertThat(findByPrincipalName).hasSize(0); + assertThat(findByPrincipalName.keySet()).doesNotContain(toSave.getId()); + assertThat(event).isNotNull(); + compareSessions(toSave, eventSession); + }); + } + + private static void compareSessions(RedisSession session1, RedisSession session2) { + assertThat(session2.getCreationTime().truncatedTo(ChronoUnit.SECONDS)) + .isEqualTo(session1.getCreationTime().truncatedTo(ChronoUnit.SECONDS)); + assertThat(session2.getMaxInactiveInterval().truncatedTo(ChronoUnit.SECONDS)) + .isEqualTo(session1.getMaxInactiveInterval().truncatedTo(ChronoUnit.SECONDS)); + assertThat(session2.getId()).isEqualTo(session1.getId()); + assertThat(session2.getAttributeNames()).isEqualTo(session1.getAttributeNames()); + } + + @Test + void findByPrincipalNameWhenDeletedKeyEventThenRemovesIndex() { + String principalName = "findByPrincipalNameExpireRemovesIndex" + UUID.randomUUID(); + RedisSession toSave = this.repository.createSession().block(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave).block(); + + String key = "spring:session:sessions:expires:" + toSave.getId(); + assertThat(this.redis.delete(key).block()).isEqualTo(1); + + await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> { + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + assertThat(findByPrincipalName).hasSize(0); + assertThat(findByPrincipalName.keySet()).doesNotContain(toSave.getId()); + SessionDeletedEvent event = this.eventRegistry.getEvent(toSave.getId()); + assertThat(event).isNotNull(); + RedisSession eventSession = event.getSession(); + compareSessions(toSave, eventSession); + }); + } + + @Test + void findByPrincipalNameWhenNoPrincipalNameChangeThenKeepIndex() { + String principalName = "findByPrincipalNameNoPrincipalNameChange" + UUID.randomUUID(); + RedisSession toSave = this.repository.createSession().block(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave).block(); + + toSave.setAttribute("other", "value"); + this.repository.save(toSave).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + } + + @Test + void findByPrincipalNameWhenNoPrincipalNameChangeAndFindByIdThenKeepIndex() { + String principalName = "findByPrincipalNameNoPrincipalNameChange" + UUID.randomUUID(); + RedisSession toSave = this.repository.createSession().block(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave).block(); + toSave = this.repository.findById(toSave.getId()).block(); + + toSave.setAttribute("other", "value"); + this.repository.save(toSave).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + } + + @Test + void findByPrincipalNameWhenDeletedPrincipalAttributeThenEmpty() { + String principalName = "findByDeletedPrincipalName" + UUID.randomUUID(); + RedisSession toSave = this.repository.createSession().block(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave).block(); + + toSave.removeAttribute(INDEX_NAME); + this.repository.save(toSave).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + + assertThat(findByPrincipalName).isEmpty(); + } + + @Test + void findByPrincipalNameWhenDeletedPrincipalAttributeAndFindByIdThenEmpty() { + String principalName = "findByDeletedPrincipalName" + UUID.randomUUID(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(INDEX_NAME, principalName); + + this.repository.save(session).block(); + session = this.repository.findById(session.getId()).block(); + + session.removeAttribute(INDEX_NAME); + this.repository.save(session).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + + assertThat(findByPrincipalName).isEmpty(); + } + + @Test + void findByPrincipalNameWhenChangedSecurityContextAttributeThenIndexMovedToNewPrincipal() { + String principalName = this.context.getAuthentication().getName(); + String principalNameChanged = this.changedContext.getAuthentication().getName(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + + session.setAttribute(SPRING_SECURITY_CONTEXT, this.changedContext); + this.repository.save(session).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalNameChanged).block(); + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(session.getId()); + } + + @Test + void findByPrincipalNameWhenChangedSecurityContextAttributeAndFindByIdThenIndexMovedToNewPrincipal() { + String principalName = this.context.getAuthentication().getName(); + String principalNameChanged = this.changedContext.getAuthentication().getName(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + session = this.repository.findById(session.getId()).block(); + + session.setAttribute(SPRING_SECURITY_CONTEXT, this.changedContext); + this.repository.save(session).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalNameChanged).block(); + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(session.getId()); + } + + @Test + void findByPrincipalNameWhenNoSecurityContextChangeThenKeepIndex() { + String principalName = this.context.getAuthentication().getName(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + + session.setAttribute("other", "value"); + this.repository.save(session).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(session.getId()); + } + + @Test + void findByPrincipalNameWhenNoSecurityContextChangeAndFindByIdThenKeepIndex() { + String principalName = this.context.getAuthentication().getName(); + RedisSession toSave = this.repository.createSession().block(); + toSave.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(toSave).block(); + toSave = this.repository.findById(toSave.getId()).block(); + + toSave.setAttribute("other", "value"); + this.repository.save(toSave).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + } + + @Test + void findByPrincipalNameWhenDeletedSecurityContextAttributeThenEmpty() { + String principalName = this.context.getAuthentication().getName(); + RedisSession toSave = this.repository.createSession().block(); + toSave.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(toSave).block(); + + toSave.removeAttribute(SPRING_SECURITY_CONTEXT); + this.repository.save(toSave).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + + assertThat(findByPrincipalName).isEmpty(); + } + + @Test + void findByPrincipalNameWhenDeletedSecurityContextAttributeAndFindByIdThenEmpty() { + String principalName = this.context.getAuthentication().getName(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + session = this.repository.findById(session.getId()).block(); + + session.removeAttribute(SPRING_SECURITY_CONTEXT); + this.repository.save(session).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + + .block(); + + assertThat(findByPrincipalName).isEmpty(); + } + + @Test + void findByPrincipalNameWhenChangedPrincipalAttributeThenEmpty() { + String principalName = this.context.getAuthentication().getName(); + String principalNameChanged = this.changedContext.getAuthentication().getName(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + + session.setAttribute(SPRING_SECURITY_CONTEXT, this.changedContext); + this.repository.save(session).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalNameChanged).block(); + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(session.getId()); + } + + @Test + void findByPrincipalNameWhenChangedPrincipalAttributeAndFindByIdThenEmpty() { + String principalName = this.context.getAuthentication().getName(); + String principalNameChanged = this.changedContext.getAuthentication().getName(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + session = this.repository.findById(session.getId()).block(); + + session.setAttribute(SPRING_SECURITY_CONTEXT, this.changedContext); + this.repository.save(session).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalNameChanged).block(); + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(session.getId()); + } + + // gh-1791 + @Test + void changeSessionIdWhenSessionExpiredThenRemovesAllPrincipalIndex() { + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + String usernameSessionKey = "spring:session:sessions:index:" + INDEX_NAME + ":" + getSecurityName(); + + RedisSession findById = this.repository.findById(session.getId()).block(); + String originalFindById = findById.getId(); + + assertThat(this.redis.opsForSet().members(usernameSessionKey).collectList().block()).contains(originalFindById); + + String changeSessionId = findById.changeSessionId(); + findById.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(findById).block(); + + assertThat(this.redis.opsForSet().members(usernameSessionKey).collectList().block()).contains(changeSessionId); + + String key = "spring:session:sessions:expires:" + changeSessionId; + assertThat(this.redis.expire(key, Duration.ofSeconds(1)).block()).isTrue(); // expire + // the + // key + + await().atMost(Duration.ofSeconds(5)) + .untilAsserted(() -> assertThat(this.redis.opsForSet().members(usernameSessionKey).collectList().block()) + .isEmpty()); + } + + @Test + void changeSessionIdWhenSessionDeletedThenRemovesAllPrincipalIndex() { + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + String usernameSessionKey = "spring:session:sessions:index:" + INDEX_NAME + ":" + getSecurityName(); + + RedisSession findById = this.repository.findById(session.getId()).block(); + String originalFindById = findById.getId(); + + assertThat(this.redis.opsForSet().members(usernameSessionKey).collectList().block()).contains(originalFindById); + + String changeSessionId = findById.changeSessionId(); + findById.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(findById).block(); + + assertThat(this.redis.opsForSet().members(usernameSessionKey).collectList().block()).contains(changeSessionId); + + String key = "spring:session:sessions:expires:" + changeSessionId; + assertThat(this.redis.delete(key).block()).isEqualTo(1); + + await().atMost(Duration.ofSeconds(5)) + .untilAsserted(() -> assertThat(this.redis.opsForSet().members(usernameSessionKey).collectList().block()) + .isEmpty()); + } + + @Test + void changeSessionIdWhenPrincipalNameChangesThenNewPrincipalMapsToNewSessionId() { + String principalName = "findByChangedPrincipalName" + UUID.randomUUID(); + String principalNameChanged = "findByChangedPrincipalName" + UUID.randomUUID(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(INDEX_NAME, principalName); + + this.repository.save(session).block(); + + RedisSession findById = this.repository.findById(session.getId()).block(); + String changeSessionId = findById.changeSessionId(); + findById.setAttribute(INDEX_NAME, principalNameChanged); + this.repository.save(findById).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + + .block(); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalNameChanged).block(); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(changeSessionId); + } + + // gh-1987 + @Test + void changeSessionIdWhenPrincipalNameChangesFromNullThenIndexShouldNotBeCreated() { + String principalName = null; + String principalNameChanged = "findByChangedPrincipalName" + UUID.randomUUID(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(INDEX_NAME, principalName); + + this.repository.save(session).block(); + + RedisSession findById = this.repository.findById(session.getId()).block(); + String changeSessionId = findById.changeSessionId(); + findById.setAttribute(INDEX_NAME, principalNameChanged); + this.repository.save(findById).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalNameChanged).block(); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(changeSessionId); + } + + @Test + void changeSessionIdWhenOnlyChangeId() { + String attrName = "changeSessionId"; + String attrValue = "changeSessionId-value"; + RedisSession session = this.repository.createSession().block(); + session.setAttribute(attrName, attrValue); + + this.repository.save(session).block(); + + RedisSession findById = this.repository.findById(session.getId()).block(); + + assertThat(findById.getAttribute(attrName)).isEqualTo(attrValue); + + String originalFindById = findById.getId(); + String changeSessionId = findById.changeSessionId(); + + this.repository.save(findById).block(); + + assertThat(this.repository.findById(originalFindById).block()).isNull(); + + RedisSession findByChangeSessionId = this.repository.findById(changeSessionId).block(); + + assertThat(findByChangeSessionId.getAttribute(attrName)).isEqualTo(attrValue); + } + + @Test + void changeSessionIdWhenChangeTwice() { + RedisSession session = this.repository.createSession().block(); + + this.repository.save(session).block(); + + String originalId = session.getId(); + String changeId1 = session.changeSessionId(); + String changeId2 = session.changeSessionId(); + + this.repository.save(session).block(); + + assertThat(this.repository.findById(originalId).block()).isNull(); + assertThat(this.repository.findById(changeId1).block()).isNull(); + assertThat(this.repository.findById(changeId2).block()).isNotNull(); + } + + @Test + void changeSessionIdWhenSetAttributeOnChangedSession() { + String attrName = "changeSessionId"; + String attrValue = "changeSessionId-value"; + + RedisSession session = this.repository.createSession().block(); + + this.repository.save(session).block(); + + RedisSession findById = this.repository.findById(session.getId()).block(); + + findById.setAttribute(attrName, attrValue); + + String originalFindById = findById.getId(); + String changeSessionId = findById.changeSessionId(); + + this.repository.save(findById).block(); + + assertThat(this.repository.findById(originalFindById).block()).isNull(); + + RedisSession findByChangeSessionId = this.repository.findById(changeSessionId).block(); + + assertThat(findByChangeSessionId.getAttribute(attrName)).isEqualTo(attrValue); + } + + @Test + void changeSessionIdWhenHasNotSaved() { + RedisSession session = this.repository.createSession().block(); + String originalId = session.getId(); + session.changeSessionId(); + + this.repository.save(session).block(); + + assertThat(this.repository.findById(session.getId()).block()).isNotNull(); + assertThat(this.repository.findById(originalId).block()).isNull(); + } + + // gh-962 + @Test + void changeSessionIdSaveTwice() { + RedisSession toSave = this.repository.createSession().block(); + String originalId = toSave.getId(); + toSave.changeSessionId(); + + this.repository.save(toSave).block(); + this.repository.save(toSave).block(); + + assertThat(this.repository.findById(toSave.getId()).block()).isNotNull(); + assertThat(this.repository.findById(originalId).block()).isNull(); + } + + // gh-1137 + @Test + void changeSessionIdWhenSessionIsDeleted() { + RedisSession toSave = this.repository.createSession().block(); + String sessionId = toSave.getId(); + this.repository.save(toSave).block(); + + this.repository.deleteById(sessionId).block(); + + toSave.changeSessionId(); + this.repository.save(toSave).block(); + + assertThat(this.repository.findById(toSave.getId()).block()).isNull(); + assertThat(this.repository.findById(sessionId).block()).isNull(); + } + + @Test // gh-1270 + void changeSessionIdSaveConcurrently() { + RedisSession toSave = this.repository.createSession().block(); + String originalId = toSave.getId(); + this.repository.save(toSave).block(); + + RedisSession copy1 = this.repository.findById(originalId).block(); + RedisSession copy2 = this.repository.findById(originalId).block(); + + copy1.changeSessionId(); + this.repository.save(copy1).block(); + copy2.changeSessionId(); + this.repository.save(copy2).block(); + + assertThat(this.repository.findById(originalId).block()).isNull(); + assertThat(this.repository.findById(copy1.getId()).block()).isNotNull(); + assertThat(this.repository.findById(copy2.getId()).block()).isNull(); + } + + // gh-1743 + @Test + void saveChangeSessionIdWhenFailedRenameOperationExceptionContainsMoreDetailsThenIgnoreError() { + RedisSession toSave = this.repository.createSession().block(); + String sessionId = toSave.getId(); + + this.repository.save(toSave).block(); + RedisSession session = this.repository.findById(sessionId).block(); + this.repository.deleteById(sessionId).block(); + session.changeSessionId(); + + assertThatNoException().isThrownBy(() -> this.repository.save(session).block()); + } + + private String getSecurityName() { + return this.context.getAuthentication().getName(); + } + + @Configuration(proxyBeanMethods = false) + @EnableRedisIndexedWebSession + @Import(AbstractRedisITests.BaseConfig.class) + static class Config { + + @Bean + SessionEventRegistry sessionEventRegistry() { + return new SessionEventRegistry(); + } + + } + +} diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisSessionRepositoryITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisSessionRepositoryITests.java index 8de600e76..f5c9a816f 100644 --- a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisSessionRepositoryITests.java +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisSessionRepositoryITests.java @@ -282,6 +282,7 @@ void saveWhenPutAllIsDelayedThenExpireShouldBeSet() { assertThat(expireDuration).isNotEqualTo(Duration.ZERO); reset(spy); + ReflectionTestUtils.setField(this.repository, "sessionRedisOperations", this.sessionRedisOperations); } @Configuration diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisSessionRepositoryKeyMissITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisSessionRepositoryKeyMissITests.java new file mode 100644 index 000000000..d8747c8ba --- /dev/null +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisSessionRepositoryKeyMissITests.java @@ -0,0 +1,150 @@ +/* + * Copyright 2014-2023 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.session.data.redis; + +import java.time.Instant; +import java.util.Map; +import java.util.function.BiFunction; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Mono; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.ReactiveHashOperations; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.session.MapSession; +import org.springframework.session.config.ReactiveSessionRepositoryCustomizer; +import org.springframework.session.data.redis.ReactiveRedisSessionRepository.RedisSession; +import org.springframework.session.data.redis.config.annotation.web.server.EnableRedisWebSession; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willAnswer; +import static org.mockito.Mockito.spy; + +/** + * Key miss error tests for {@link ReactiveRedisSessionRepository} + * + * @author Marcus da Coregio + * @see Related + * GitHub Issue + */ +@ExtendWith(SpringExtension.class) +class ReactiveRedisSessionRepositoryKeyMissITests extends AbstractRedisITests { + + private ReactiveRedisSessionRepository sessionRepository; + + private ReactiveRedisOperations spyOperations; + + AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); + + @Test + void findByIdWhenSessionDeletedWhileSavingDeltaThenThrowIllegalStateException() { + this.context.register(Config.class); + refreshAndPrepareFields(); + RedisSession session = createAndSaveSession(Instant.now()); + session.setAttribute("new", "value"); + + ReactiveHashOperations opsForHash = spy(this.spyOperations.opsForHash()); + given(this.spyOperations.opsForHash()).willReturn(opsForHash); + willAnswer((invocation) -> this.sessionRepository.deleteById(session.getId()) + .then((Mono) invocation.callRealMethod())).given(opsForHash).putAll(any(), any()); + + this.sessionRepository.save(session).block(); + assertThatIllegalStateException().isThrownBy(() -> this.sessionRepository.findById(session.getId()).block()) + .withMessage("creationTime key must not be null"); + } + + @Test + void findByIdWhenSessionDeletedWhileSavingDeltaAndSafeMapperThenSessionIsNull() { + this.context.register(RedisSessionMapperConfig.class); + refreshAndPrepareFields(); + RedisSession session = createAndSaveSession(Instant.now()); + session.setAttribute("new", "value"); + + ReactiveHashOperations opsForHash = spy(this.spyOperations.opsForHash()); + given(this.spyOperations.opsForHash()).willReturn(opsForHash); + willAnswer((invocation) -> this.sessionRepository.deleteById(session.getId()) + .then((Mono) invocation.callRealMethod())).given(opsForHash).putAll(any(), any()); + + this.sessionRepository.save(session).block(); + assertThat(this.sessionRepository.findById(session.getId()).block()).isNull(); + } + + @SuppressWarnings("unchecked") + private void refreshAndPrepareFields() { + this.context.refresh(); + this.sessionRepository = this.context.getBean(ReactiveRedisSessionRepository.class); + ReactiveRedisOperations redisOperations = (ReactiveRedisOperations) ReflectionTestUtils + .getField(this.sessionRepository, "sessionRedisOperations"); + this.spyOperations = spy(redisOperations); + ReflectionTestUtils.setField(this.sessionRepository, "sessionRedisOperations", this.spyOperations); + } + + private RedisSession createAndSaveSession(Instant lastAccessedTime) { + RedisSession session = this.sessionRepository.createSession().block(); + session.setLastAccessedTime(lastAccessedTime); + session.setAttribute("attribute1", "value1"); + this.sessionRepository.save(session).block(); + return this.sessionRepository.findById(session.getId()).block(); + } + + @Configuration + @EnableRedisWebSession + static class Config extends BaseConfig { + + } + + @Configuration + @EnableRedisWebSession + static class RedisSessionMapperConfig extends BaseConfig { + + @Bean + ReactiveSessionRepositoryCustomizer redisSessionRepositoryCustomizer() { + return (redisSessionRepository) -> redisSessionRepository + .setRedisSessionMapper(new SafeRedisSessionMapper(redisSessionRepository)); + } + + } + + static class SafeRedisSessionMapper implements BiFunction, Mono> { + + private final RedisSessionMapper delegate = new RedisSessionMapper(); + + private final ReactiveRedisSessionRepository sessionRepository; + + SafeRedisSessionMapper(ReactiveRedisSessionRepository sessionRepository) { + this.sessionRepository = sessionRepository; + } + + @Override + public Mono apply(String sessionId, Map map) { + return Mono.fromSupplier(() -> this.delegate.apply(sessionId, map)) + .onErrorResume(IllegalStateException.class, + (ex) -> this.sessionRepository.deleteById(sessionId).then(Mono.empty())); + } + + } + +} diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryDynamicITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryDynamicITests.java new file mode 100644 index 000000000..b7247356c --- /dev/null +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryDynamicITests.java @@ -0,0 +1,148 @@ +/* + * Copyright 2014-2023 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.session.data.redis; + +import java.time.Instant; +import java.util.Map; +import java.util.function.BiFunction; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.BoundHashOperations; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.session.MapSession; +import org.springframework.session.config.SessionRepositoryCustomizer; +import org.springframework.session.data.redis.RedisIndexedSessionRepository.RedisSession; +import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisIndexedHttpSession; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.spy; + +/** + * Key miss error tests for {@link RedisIndexedSessionRepository} + * + * @author Marcus da Coregio + * @see Related + * GitHub Issue + */ +@ExtendWith(SpringExtension.class) +class RedisIndexedSessionRepositoryDynamicITests extends AbstractRedisITests { + + private RedisIndexedSessionRepository sessionRepository; + + private RedisOperations spyOperations; + + AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); + + @Test + void findByIdWhenSessionDeletedWhileSavingDeltaThenThrowIllegalStateException() { + this.context.register(Config.class); + refreshAndPrepareFields(); + RedisSession session = createAndSaveSession(Instant.now()); + session.setAttribute("new", "value"); + + BoundHashOperations opsForHash = spy(this.spyOperations.boundHashOps(anyString())); + given(this.spyOperations.boundHashOps(anyString())).willReturn(opsForHash); + + this.sessionRepository.save(session); + assertThatIllegalStateException().isThrownBy(() -> this.sessionRepository.findById(session.getId())) + .withMessage("creationTime key must not be null"); + } + + @Test + void findByIdWhenSessionDeletedWhileSavingDeltaAndSafeMapperThenSessionIsNull() { + this.context.register(RedisSessionMapperConfig.class); + refreshAndPrepareFields(); + RedisSession session = createAndSaveSession(Instant.now()); + session.setAttribute("new", "value"); + + BoundHashOperations opsForHash = spy(this.spyOperations.boundHashOps(anyString())); + given(this.spyOperations.boundHashOps(anyString())).willReturn(opsForHash); + + this.sessionRepository.save(session); + assertThat(this.sessionRepository.findById(session.getId())).isNull(); + } + + @SuppressWarnings("unchecked") + private void refreshAndPrepareFields() { + this.context.refresh(); + this.sessionRepository = this.context.getBean(RedisIndexedSessionRepository.class); + RedisOperations redisOperations = (RedisOperations) ReflectionTestUtils + .getField(this.sessionRepository, "sessionRedisOperations"); + this.spyOperations = spy(redisOperations); + ReflectionTestUtils.setField(this.sessionRepository, "sessionRedisOperations", this.spyOperations); + } + + private RedisSession createAndSaveSession(Instant lastAccessedTime) { + RedisSession session = this.sessionRepository.createSession(); + session.setLastAccessedTime(lastAccessedTime); + session.setAttribute("attribute1", "value1"); + this.sessionRepository.save(session); + return this.sessionRepository.findById(session.getId()); + } + + @Configuration + @EnableRedisIndexedHttpSession + static class Config extends BaseConfig { + + } + + @Configuration + @EnableRedisIndexedHttpSession + static class RedisSessionMapperConfig extends BaseConfig { + + @Bean + SessionRepositoryCustomizer redisSessionRepositoryCustomizer() { + return (redisSessionRepository) -> redisSessionRepository + .setRedisSessionMapper(new SafeRedisSessionMapper(redisSessionRepository.getSessionRedisOperations())); + } + + } + + static class SafeRedisSessionMapper implements BiFunction, MapSession> { + + private final RedisSessionMapper delegate = new RedisSessionMapper(); + + private final RedisOperations redisOperations; + + SafeRedisSessionMapper(RedisOperations redisOperations) { + this.redisOperations = redisOperations; + } + + @Override + public MapSession apply(String sessionId, Map map) { + try { + return this.delegate.apply(sessionId, map); + } + catch (IllegalStateException ex) { + this.redisOperations.delete("spring:session:sessions:" + sessionId); + return null; + } + } + + } + +} diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryITests.java index 29b603dca..b9798a95f 100644 --- a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryITests.java +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryITests.java @@ -19,6 +19,7 @@ import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.UUID; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -135,6 +136,22 @@ void saves() throws InterruptedException { .isEqualTo(expectedAttributeValue); } + @Test + void saveThenSaveSessionKeyAndShadowKeyWith5MinutesDifference() { + RedisSession toSave = this.repository.createSession(); + String expectedAttributeName = "a"; + String expectedAttributeValue = "b"; + toSave.setAttribute(expectedAttributeName, expectedAttributeValue); + this.repository.save(toSave); + + Long sessionKeyExpire = this.redis.getExpire("RedisIndexedSessionRepositoryITests:sessions:" + toSave.getId(), + TimeUnit.SECONDS); + Long shadowKeyExpire = this.redis + .getExpire("RedisIndexedSessionRepositoryITests:sessions:expires:" + toSave.getId(), TimeUnit.SECONDS); + long differenceInSeconds = sessionKeyExpire - shadowKeyExpire; + assertThat(differenceInSeconds).isEqualTo(300); + } + @Test void putAllOnSingleAttrDoesNotRemoveOld() { RedisSession toSave = this.repository.createSession(); diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/RedisSessionRepositoryKeyMissITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/RedisSessionRepositoryKeyMissITests.java new file mode 100644 index 000000000..ec6e954a7 --- /dev/null +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/RedisSessionRepositoryKeyMissITests.java @@ -0,0 +1,157 @@ +/* + * Copyright 2014-2023 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.session.data.redis; + +import java.time.Instant; +import java.util.Map; +import java.util.function.BiFunction; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.session.MapSession; +import org.springframework.session.config.SessionRepositoryCustomizer; +import org.springframework.session.data.redis.RedisSessionRepository.RedisSession; +import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willAnswer; +import static org.mockito.Mockito.spy; + +/** + * Key miss error tests for {@link RedisSessionRepository} + * + * @author Marcus da Coregio + * @see Related + * GitHub Issue + */ +@ExtendWith(SpringExtension.class) +class RedisSessionRepositoryKeyMissITests extends AbstractRedisITests { + + private RedisSessionRepository sessionRepository; + + private RedisOperations spyOperations; + + AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); + + @Test + void findByIdWhenSessionDeletedWhileSavingDeltaThenThrowIllegalStateException() { + this.context.register(Config.class); + refreshAndPrepareFields(); + RedisSession session = createAndSaveSession(Instant.now()); + session.setAttribute("new", "value"); + + HashOperations opsForHash = spy(this.spyOperations.opsForHash()); + given(this.spyOperations.opsForHash()).willReturn(opsForHash); + willAnswer((invocation) -> { + this.sessionRepository.deleteById(session.getId()); + return invocation.callRealMethod(); + }).given(opsForHash).putAll(any(), any()); + + this.sessionRepository.save(session); + assertThatIllegalStateException().isThrownBy(() -> this.sessionRepository.findById(session.getId())) + .withMessage("creationTime key must not be null"); + } + + @Test + void findByIdWhenSessionDeletedWhileSavingDeltaAndSafeMapperThenSessionIsNull() { + this.context.register(RedisSessionMapperConfig.class); + refreshAndPrepareFields(); + RedisSession session = createAndSaveSession(Instant.now()); + session.setAttribute("new", "value"); + + HashOperations opsForHash = spy(this.spyOperations.opsForHash()); + given(this.spyOperations.opsForHash()).willReturn(opsForHash); + willAnswer((invocation) -> { + this.sessionRepository.deleteById(session.getId()); + return invocation.callRealMethod(); + }).given(opsForHash).putAll(any(), any()); + + this.sessionRepository.save(session); + assertThat(this.sessionRepository.findById(session.getId())).isNull(); + } + + @SuppressWarnings("unchecked") + private void refreshAndPrepareFields() { + this.context.refresh(); + this.sessionRepository = this.context.getBean(RedisSessionRepository.class); + RedisOperations redisOperations = (RedisOperations) ReflectionTestUtils + .getField(this.sessionRepository, "sessionRedisOperations"); + this.spyOperations = spy(redisOperations); + ReflectionTestUtils.setField(this.sessionRepository, "sessionRedisOperations", this.spyOperations); + } + + private RedisSession createAndSaveSession(Instant lastAccessedTime) { + RedisSession session = this.sessionRepository.createSession(); + session.setLastAccessedTime(lastAccessedTime); + session.setAttribute("attribute1", "value1"); + this.sessionRepository.save(session); + return this.sessionRepository.findById(session.getId()); + } + + @Configuration + @EnableRedisHttpSession + static class Config extends BaseConfig { + + } + + @Configuration + @EnableRedisHttpSession + static class RedisSessionMapperConfig extends BaseConfig { + + @Bean + SessionRepositoryCustomizer redisSessionRepositoryCustomizer() { + return (redisSessionRepository) -> redisSessionRepository + .setRedisSessionMapper(new SafeRedisSessionMapper(redisSessionRepository)); + } + + } + + static class SafeRedisSessionMapper implements BiFunction, MapSession> { + + private final RedisSessionMapper delegate = new RedisSessionMapper(); + + private final RedisSessionRepository sessionRepository; + + SafeRedisSessionMapper(RedisSessionRepository sessionRepository) { + this.sessionRepository = sessionRepository; + } + + @Override + public MapSession apply(String sessionId, Map map) { + try { + return this.delegate.apply(sessionId, map); + } + catch (IllegalStateException ex) { + this.sessionRepository.deleteById(sessionId); + return null; + } + } + + } + +} diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStoreITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStoreITests.java new file mode 100644 index 000000000..9ee2582a1 --- /dev/null +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStoreITests.java @@ -0,0 +1,121 @@ +/* + * Copyright 2014-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.session.data.redis; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.session.data.redis.RedisIndexedSessionRepository.RedisSession; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link SortedSetRedisSessionExpirationStore} + * + * @author Marcus da Coregio + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = SortedSetRedisSessionExpirationStoreITests.Config.class) +@WebAppConfiguration +class SortedSetRedisSessionExpirationStoreITests { + + @Autowired + private SortedSetRedisSessionExpirationStore expirationStore; + + @Autowired + private RedisTemplate redisTemplate; + + private static final Instant mockedTime = LocalDateTime.of(2024, 5, 8, 10, 30, 0) + .atZone(ZoneOffset.UTC) + .toInstant(); + + private static final Clock clock; + + static { + clock = Clock.fixed(mockedTime, ZoneOffset.UTC); + } + + @Test + void saveThenStoreSessionWithItsExpiration() { + Instant expireAt = mockedTime.plusSeconds(5); + RedisSession session = createSession("123", expireAt); + this.expirationStore.save(session); + Double score = this.redisTemplate.opsForZSet().score("spring:session:sessions:expirations", "123"); + assertThat(score).isEqualTo(expireAt.toEpochMilli()); + } + + @Test + void removeWhenSessionIdExistsThenRemoved() { + RedisSession session = createSession("toBeRemoved", mockedTime); + this.expirationStore.save(session); + Double score = this.redisTemplate.opsForZSet().score("spring:session:sessions:expirations", "toBeRemoved"); + assertThat(score).isEqualTo(mockedTime.toEpochMilli()); + this.expirationStore.remove("toBeRemoved"); + score = this.redisTemplate.opsForZSet().score("spring:session:sessions:expirations", "toBeRemoved"); + assertThat(score).isNull(); + } + + private RedisSession createSession(String sessionId, Instant expireAt) { + RedisSession session = mock(); + given(session.getId()).willReturn(sessionId); + given(session.getLastAccessedTime()).willReturn(expireAt); + given(session.getMaxInactiveInterval()).willReturn(Duration.ZERO); + return session; + } + + @Configuration(proxyBeanMethods = false) + @Import(AbstractRedisITests.BaseConfig.class) + static class Config { + + @Bean + RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(RedisSerializer.string()); + redisTemplate.setHashKeySerializer(RedisSerializer.string()); + redisTemplate.setConnectionFactory(redisConnectionFactory); + return redisTemplate; + } + + @Bean + RedisSessionExpirationStore redisSessionExpirationStore(RedisTemplate redisTemplate) { + SortedSetRedisSessionExpirationStore store = new SortedSetRedisSessionExpirationStore(redisTemplate, + RedisIndexedSessionRepository.DEFAULT_NAMESPACE); + store.setClock(SortedSetRedisSessionExpirationStoreITests.clock); + return store; + } + + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepository.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepository.java new file mode 100644 index 000000000..d6ba106d7 --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepository.java @@ -0,0 +1,794 @@ +/* + * Copyright 2014-2023 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.session.data.redis; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.NestedExceptionUtils; +import org.springframework.data.redis.connection.ReactiveSubscription; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.session.IndexResolver; +import org.springframework.session.MapSession; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.SaveMode; +import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; +import org.springframework.session.events.SessionCreatedEvent; +import org.springframework.session.events.SessionDeletedEvent; +import org.springframework.session.events.SessionDestroyedEvent; +import org.springframework.session.events.SessionExpiredEvent; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * A {@link ReactiveSessionRepository} that is implemented using Spring Data's + * {@link ReactiveRedisOperations}. + * + *

Storage Details

The sections below outline how Redis is updated for each + * operation. An example of creating a new session can be found below. The subsequent + * sections describe the details. + * + *
+ * HMSET spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381 creationTime 1702400400000 maxInactiveInterval 1800 lastAccessedTime 1702400400000 sessionAttr:attrName someAttrValue sessionAttr:attrName2 someAttrValue2
+ * EXPIRE spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381 2100
+ * APPEND spring:session:sessions:expires:648377f7-c76f-4f45-b847-c0268bb48381 ""
+ * EXPIRE spring:session:sessions:expires:648377f7-c76f-4f45-b847-c0268bb48381 1800
+ * ZADD spring:session:sessions:expirations "1.702402961162E12" "648377f7-c76f-4f45-b847-c0268bb48381"
+ * SADD spring:session:sessions:index:PRINCIPAL_NAME_INDEX_NAME:user "648377f7-c76f-4f45-b847-c0268bb48381"
+ * SADD spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381:idx "spring:session:sessions:index:PRINCIPAL_NAME_INDEX_NAME:user"
+ * 
+ * + *

Saving a Session

+ * + *

+ * Each session is stored in Redis as a + * Hash. Each session is set and + * updated using the HMSET command. An + * example of how each session is stored can be seen below. + *

+ * + *
+ * HMSET spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381 creationTime 1702400400000 maxInactiveInterval 1800 lastAccessedTime 1702400400000 sessionAttr:attrName someAttrValue sessionAttr:attrName2 someAttrValue2
+ * 
+ * + *

+ * In this example, the session following statements are true about the session: + *

+ *
    + *
  • The session id is 648377f7-c76f-4f45-b847-c0268bb48381
  • + *
  • The session was created at 1702400400000 in milliseconds since midnight of 1/1/1970 + * GMT.
  • + *
  • The session expires in 1800 seconds (30 minutes).
  • + *
  • The session was last accessed at 1702400400000 in milliseconds since midnight of + * 1/1/1970 GMT.
  • + *
  • The session has two attributes. The first is "attrName" with the value of + * "someAttrValue". The second session attribute is named "attrName2" with the value of + * "someAttrValue2".
  • + *
+ * + *

Optimized Writes

+ * + *

+ * The {@link ReactiveRedisIndexedSessionRepository.RedisSession} keeps track of the + * properties that have changed and only updates those. This means if an attribute is + * written once and read many times we only need to write that attribute once. For + * example, assume the session attribute "attrName2" from earlier was updated. The + * following would be executed upon saving: + *

+ * + *
+ * HMSET spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381 sessionAttr:attrName2 newValue
+ * 
+ * + *

SessionCreatedEvent

+ * + *

+ * When a session is created an event is sent to Redis with the channel of + * "spring:session:event:0:created:648377f7-c76f-4f45-b847-c0268bb48381" such that + * "648377f7-c76f-4f45-b847-c0268bb48381" is the session id. The body of the event will be + * the session that was created. + *

+ * + *

SessionDeletedEvent and SessionExpiredEvent

If you configured you Redis server + * to send keyspace events when keys are expired or deleted, either via + * {@link org.springframework.session.data.redis.config.annotation.ConfigureNotifyKeyspaceEventsReactiveAction} + * or via external configuration, then deleted and expired sessions will be published as + * {@link SessionDeletedEvent} and {@link SessionExpiredEvent} respectively. + * + *

Expiration

+ * + *

+ * An expiration is associated to each session using the + * EXPIRE command based upon the + * {@link ReactiveRedisIndexedSessionRepository.RedisSession#getMaxInactiveInterval()} . + * For example: + *

+ * + *
+ * EXPIRE spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381 2100
+ * 
+ * + *

+ * You will note that the expiration that is set is 5 minutes after the session actually + * expires. This is necessary so that the value of the session can be accessed when the + * session expires. An expiration is set on the session itself five minutes after it + * actually expires to ensure it is cleaned up, but only after we perform any necessary + * processing. + *

+ * + *

+ * NOTE: The {@link #findById(String)} method ensures that no expired sessions will + * be returned. This means there is no need to check the expiration before using a + * session. + *

+ * + *

+ * Spring Session relies on the expired and delete + * keyspace + * notifications from Redis to fire a SessionDestroyedEvent. It is the + * SessionDestroyedEvent that ensures resources associated with the Session are cleaned + * up. For example, when using Spring Session's WebSocket support the Redis expired or + * delete event is what triggers any WebSocket connections associated with the session to + * be closed. + *

+ * + *

+ * Expiration is not tracked directly on the session key itself since this would mean the + * session data would no longer be available. Instead a special session expires key is + * used. In our example the expires key is: + *

+ * + *
+ * APPEND spring:session:sessions:expires:648377f7-c76f-4f45-b847-c0268bb48381 ""
+ * EXPIRE spring:session:sessions:expires:648377f7-c76f-4f45-b847-c0268bb48381 1800
+ * 
+ * + *

+ * When a session key is deleted or expires, the keyspace notification triggers a lookup + * of the actual session and a {@link SessionDestroyedEvent} is fired. + *

+ * + *

+ * One problem with relying on Redis expiration exclusively is that Redis makes no + * guarantee of when the expired event will be fired if the key has not been accessed. For + * additional details see How Redis expires + * keys section in the Redis Expire documentation. + *

+ * + *

+ * To circumvent the fact that expired events are not guaranteed to happen we can ensure + * that each key is accessed when it is expected to expire. This means that if the TTL is + * expired on the key, Redis will remove the key and fire the expired event when we try to + * access the key. + *

+ * + *

+ * For this reason, each session expiration is also tracked by storing the session id in a + * sorted set ranked by its expiration time. This allows a background task to access the + * potentially expired sessions to ensure that Redis expired events are fired in a more + * deterministic fashion. For example: + *

+ * + *
+ * ZADD spring:session:sessions:expirations "1.702402961162E12" "648377f7-c76f-4f45-b847-c0268bb48381"
+ * 
+ * + *

+ * NOTE: We do not explicitly delete the keys since in some instances there may be + * a race condition that incorrectly identifies a key as expired when it is not. Short of + * using distributed locks (which would kill our performance) there is no way to ensure + * the consistency of the expiration mapping. By simply accessing the key, we ensure that + * the key is only removed if the TTL on that key is expired. + *

+ * + *

Secondary Indexes

By default, Spring Session will also index the sessions by + * identifying if the session contains any attribute that can be mapped to a principal + * using an {@link org.springframework.session.PrincipalNameIndexResolver}. All resolved + * indexes for a session are stored in a Redis Set, for example:
+ * SADD spring:session:sessions:index:PRINCIPAL_NAME_INDEX_NAME:user "648377f7-c76f-4f45-b847-c0268bb48381"
+ * SADD spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381:idx "spring:session:sessions:index:PRINCIPAL_NAME_INDEX_NAME:user"
+ * 
+ * + * Therefore, you can check all indexes for a given session by getting the members of the + * {@code "spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381:idx"} Redis set. + * + * @author Marcus da Coregio + * @since 3.3 + */ +public class ReactiveRedisIndexedSessionRepository + implements ReactiveSessionRepository, + ReactiveFindByIndexNameSessionRepository, DisposableBean, + InitializingBean { + + private static final Log logger = LogFactory.getLog(ReactiveRedisIndexedSessionRepository.class); + + /** + * The default namespace for each key and channel in Redis used by Spring Session. + */ + public static final String DEFAULT_NAMESPACE = "spring:session"; + + /** + * The default Redis database used by Spring Session. + */ + public static final int DEFAULT_DATABASE = 0; + + private final ReactiveRedisOperations sessionRedisOperations; + + private final ReactiveRedisTemplate keyEventsOperations; + + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + + private BiFunction, Mono> redisSessionMapper = new RedisSessionMapperAdapter(); + + private Duration defaultMaxInactiveInterval = Duration.ofSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS); + + private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE; + + private ApplicationEventPublisher eventPublisher = (event) -> { + }; + + private String sessionCreatedChannelPrefix; + + private String sessionDeletedChannel; + + private String sessionExpiredChannel; + + private String expiredKeyPrefix; + + private final List subscriptions = new ArrayList<>(); + + /** + * The namespace for every key used by Spring Session in Redis. + */ + private String namespace = DEFAULT_NAMESPACE + ":"; + + private int database = DEFAULT_DATABASE; + + private ReactiveRedisSessionIndexer indexer; + + private SortedSetReactiveRedisSessionExpirationStore expirationStore; + + private Duration cleanupInterval = Duration.ofSeconds(60); + + private Clock clock = Clock.systemUTC(); + + /** + * Creates a new instance with the provided {@link ReactiveRedisOperations}. + * @param sessionRedisOperations the {@link ReactiveRedisOperations} to use for + * managing the sessions. Cannot be null. + * @param keyEventsOperations the {@link ReactiveRedisTemplate} to use to subscribe to + * keyspace events. Cannot be null. + */ + public ReactiveRedisIndexedSessionRepository(ReactiveRedisOperations sessionRedisOperations, + ReactiveRedisTemplate keyEventsOperations) { + Assert.notNull(sessionRedisOperations, "sessionRedisOperations cannot be null"); + Assert.notNull(keyEventsOperations, "keyEventsOperations cannot be null"); + this.sessionRedisOperations = sessionRedisOperations; + this.keyEventsOperations = keyEventsOperations; + this.indexer = new ReactiveRedisSessionIndexer(sessionRedisOperations, this.namespace); + this.expirationStore = new SortedSetReactiveRedisSessionExpirationStore(sessionRedisOperations, this.namespace); + configureSessionChannels(); + } + + @Override + public void afterPropertiesSet() throws Exception { + subscribeToRedisEvents(); + setupCleanupTask(); + } + + private void setupCleanupTask() { + if (!this.cleanupInterval.isZero()) { + Disposable cleanupExpiredSessionsTask = Flux.interval(this.cleanupInterval, this.cleanupInterval) + .onBackpressureDrop((count) -> logger + .debug("Skipping clean-up expired sessions because the previous one is still running.")) + .concatMap((count) -> cleanUpExpiredSessions()) + .subscribe(); + this.subscriptions.add(cleanupExpiredSessionsTask); + } + } + + private Flux cleanUpExpiredSessions() { + return this.expirationStore.retrieveExpiredSessions(this.clock.instant()).flatMap(this::touch); + } + + private Mono touch(String sessionId) { + return this.sessionRedisOperations.hasKey(getExpiredKey(sessionId)).then(); + } + + @Override + public void destroy() { + for (Disposable subscription : this.subscriptions) { + subscription.dispose(); + } + this.subscriptions.clear(); + } + + @Override + public Mono> findByIndexNameAndIndexValue(String indexName, String indexValue) { + return this.indexer.getSessionIds(indexName, indexValue) + .flatMap(this::findById) + .collectMap(RedisSession::getId); + } + + @Override + public Mono createSession() { + return Mono.fromSupplier(() -> this.sessionIdGenerator.generate()) + .subscribeOn(Schedulers.boundedElastic()) + .publishOn(Schedulers.parallel()) + .map(MapSession::new) + .doOnNext((session) -> session.setMaxInactiveInterval(this.defaultMaxInactiveInterval)) + .map((session) -> new RedisSession(session, true)); + } + + @Override + public Mono save(RedisSession session) { + // @formatter:off + return session.save() + .then(Mono.defer(() -> this.indexer.update(session))) + .then(Mono.defer(() -> this.expirationStore.add(session.getId(), session.getLastAccessedTime().plus(session.getMaxInactiveInterval())))); + // @formatter:on + } + + @Override + public Mono findById(String id) { + return getSession(id, false); + } + + private Mono getSession(String sessionId, boolean allowExpired) { + // @formatter:off + String sessionKey = getSessionKey(sessionId); + return this.sessionRedisOperations.opsForHash().entries(sessionKey) + .collectMap((entry) -> entry.getKey().toString(), Map.Entry::getValue) + .filter((map) -> !map.isEmpty()) + .flatMap((map) -> this.redisSessionMapper.apply(sessionId, map)) + .filter((session) -> allowExpired || !session.isExpired()) + .map((session) -> new RedisSession(session, false)); + // @formatter:on + } + + @Override + public Mono deleteById(String id) { + return deleteAndReturn(id).then(); + } + + private Mono deleteAndReturn(String id) { + // @formatter:off + return getSession(id, true) + .flatMap((session) -> this.sessionRedisOperations.delete(getExpiredKey(session.getId())) + .thenReturn(session)) + .flatMap((session) -> this.sessionRedisOperations.delete(getSessionKey(session.getId())).thenReturn(session)) + .flatMap((session) -> this.indexer.delete(session.getId()).thenReturn(session)) + .flatMap((session) -> this.expirationStore.remove(session.getId()).thenReturn(session)); + // @formatter:on + } + + /** + * Subscribes to {@code __keyevent@0__:expired} and {@code __keyevent@0__:del} Redis + * Keyspaces events and to {@code spring:session:event:0:created:*} Redis Channel + * event in order to clean up the sessions and publish the related Spring Session + * events. + */ + private void subscribeToRedisEvents() { + Disposable sessionCreatedSubscription = this.sessionRedisOperations + .listenToPattern(getSessionCreatedChannelPrefix() + "*") + .flatMap(this::onSessionCreatedChannelMessage) + .subscribe(); + Disposable sessionDestroyedSubscription = this.keyEventsOperations + .listenToChannel(getSessionDeletedChannel(), getSessionExpiredChannel()) + .flatMap(this::onKeyDestroyedMessage) + .subscribe(); + this.subscriptions.addAll(Arrays.asList(sessionCreatedSubscription, sessionDestroyedSubscription)); + } + + @SuppressWarnings("unchecked") + private Mono onSessionCreatedChannelMessage(ReactiveSubscription.Message message) { + return Mono.just(message.getChannel()) + .filter((channel) -> channel.startsWith(getSessionCreatedChannelPrefix())) + .map((channel) -> { + int sessionIdBeginIndex = channel.lastIndexOf(":") + 1; + return channel.substring(sessionIdBeginIndex); + }) + .flatMap((sessionId) -> { + Map entries = (Map) message.getMessage(); + return this.redisSessionMapper.apply(sessionId, entries); + }) + .map((loaded) -> { + RedisSession session = new RedisSession(loaded, false); + return new SessionCreatedEvent(this, session); + }) + .doOnNext(this::publishEvent) + .then(); + } + + private Mono onKeyDestroyedMessage(ReactiveSubscription.Message message) { + // @formatter:off + return Mono.just(message.getMessage()) + .filter((key) -> key.startsWith(getExpiredKeyPrefix())).map((key) -> { + int sessionIdBeginIndex = key.lastIndexOf(":") + 1; + return key.substring(sessionIdBeginIndex); + }) + .flatMap(this::deleteAndReturn) + .map((session) -> { + if (message.getChannel().equals(this.sessionDeletedChannel)) { + return new SessionDeletedEvent(this, session); + } + return new SessionExpiredEvent(this, session); + }) + .doOnNext(this::publishEvent) + .then(); + // @formatter:on + } + + private void publishEvent(Object event) { + this.eventPublisher.publishEvent(event); + } + + /** + * Sets the Redis database index used by Spring Session. + * @param database the database index to use + */ + public void setDatabase(int database) { + this.database = database; + configureSessionChannels(); + } + + /** + * Sets the namespace for keys used by Spring Session. Defaults to 'spring:session:'. + * @param namespace the namespace to set + */ + public void setRedisKeyNamespace(String namespace) { + Assert.hasText(namespace, "namespace cannot be null or empty"); + this.namespace = namespace.endsWith(":") ? namespace : namespace.trim() + ":"; + this.indexer.setNamespace(this.namespace); + this.expirationStore.setNamespace(this.namespace); + configureSessionChannels(); + } + + /** + * Sets the interval that the clean-up of expired sessions task should run. Defaults + * to 60 seconds. Use {@link Duration#ZERO} to disable it. + * @param cleanupInterval the interval to use + */ + public void setCleanupInterval(Duration cleanupInterval) { + Assert.notNull(cleanupInterval, "cleanupInterval cannot be null"); + this.cleanupInterval = cleanupInterval; + } + + /** + * Disables the clean-up task. This is just a shortcut to invoke + * {@link #setCleanupInterval(Duration)} passing {@link Duration#ZERO} + */ + public void disableCleanupTask() { + setCleanupInterval(Duration.ZERO); + } + + /** + * Sets the {@link Clock} to use. Defaults to {@link Clock#systemUTC()}. + * @param clock the clock to use + */ + public void setClock(Clock clock) { + Assert.notNull(clock, "clock cannot be null"); + this.clock = clock; + } + + public void setDefaultMaxInactiveInterval(Duration defaultMaxInactiveInterval) { + Assert.notNull(defaultMaxInactiveInterval, "defaultMaxInactiveInterval must not be null"); + this.defaultMaxInactiveInterval = defaultMaxInactiveInterval; + } + + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + Assert.notNull(sessionIdGenerator, "sessionIdGenerator cannot be null"); + this.sessionIdGenerator = sessionIdGenerator; + } + + public void setRedisSessionMapper(BiFunction, Mono> redisSessionMapper) { + Assert.notNull(redisSessionMapper, "redisSessionMapper cannot be null"); + this.redisSessionMapper = redisSessionMapper; + } + + public void setSaveMode(SaveMode saveMode) { + Assert.notNull(saveMode, "saveMode cannot be null"); + this.saveMode = saveMode; + } + + public ReactiveRedisOperations getSessionRedisOperations() { + return this.sessionRedisOperations; + } + + public void setEventPublisher(ApplicationEventPublisher eventPublisher) { + Assert.notNull(eventPublisher, "eventPublisher cannot be null"); + this.eventPublisher = eventPublisher; + } + + public void setIndexResolver(IndexResolver indexResolver) { + Assert.notNull(indexResolver, "indexResolver cannot be null"); + this.indexer.setIndexResolver(indexResolver); + } + + private static String getAttributeNameWithPrefix(String attributeName) { + return RedisSessionMapper.ATTRIBUTE_PREFIX + attributeName; + } + + private String getSessionKey(String sessionId) { + return this.namespace + "sessions:" + sessionId; + } + + private String getExpiredKey(String sessionId) { + return getExpiredKeyPrefix() + sessionId; + } + + private String getExpiredKeyPrefix() { + return this.expiredKeyPrefix; + } + + private void configureSessionChannels() { + this.sessionCreatedChannelPrefix = this.namespace + "event:" + this.database + ":created:"; + this.sessionDeletedChannel = "__keyevent@" + this.database + "__:del"; + this.sessionExpiredChannel = "__keyevent@" + this.database + "__:expired"; + this.expiredKeyPrefix = this.namespace + "sessions:expires:"; + } + + public String getSessionCreatedChannel(String sessionId) { + return getSessionCreatedChannelPrefix() + sessionId; + } + + public String getSessionCreatedChannelPrefix() { + return this.sessionCreatedChannelPrefix; + } + + public String getSessionDeletedChannel() { + return this.sessionDeletedChannel; + } + + public String getSessionExpiredChannel() { + return this.sessionExpiredChannel; + } + + public final class RedisSession implements Session { + + private final MapSession cached; + + private Map delta = new HashMap<>(); + + private boolean isNew; + + private String originalSessionId; + + private Map indexes = new HashMap<>(); + + public RedisSession(MapSession cached, boolean isNew) { + this.cached = cached; + this.isNew = isNew; + this.originalSessionId = cached.getId(); + if (this.isNew) { + this.delta.put(RedisSessionMapper.CREATION_TIME_KEY, cached.getCreationTime().toEpochMilli()); + this.delta.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, + (int) cached.getMaxInactiveInterval().getSeconds()); + this.delta.put(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, cached.getLastAccessedTime().toEpochMilli()); + } + if (this.isNew || (ReactiveRedisIndexedSessionRepository.this.saveMode == SaveMode.ALWAYS)) { + getAttributeNames().forEach((attributeName) -> this.delta.put(getAttributeNameWithPrefix(attributeName), + cached.getAttribute(attributeName))); + } + } + + @Override + public String getId() { + return this.cached.getId(); + } + + @Override + public String changeSessionId() { + String newSessionId = ReactiveRedisIndexedSessionRepository.this.sessionIdGenerator.generate(); + this.cached.setId(newSessionId); + return newSessionId; + } + + @Override + public T getAttribute(String attributeName) { + T attributeValue = this.cached.getAttribute(attributeName); + if (attributeValue != null + && ReactiveRedisIndexedSessionRepository.this.saveMode.equals(SaveMode.ON_GET_ATTRIBUTE)) { + this.delta.put(getAttributeNameWithPrefix(attributeName), attributeValue); + } + return attributeValue; + } + + @Override + public Set getAttributeNames() { + return this.cached.getAttributeNames(); + } + + @Override + public void setAttribute(String attributeName, Object attributeValue) { + this.cached.setAttribute(attributeName, attributeValue); + this.delta.put(getAttributeNameWithPrefix(attributeName), attributeValue); + } + + @Override + public void removeAttribute(String attributeName) { + this.cached.removeAttribute(attributeName); + this.delta.put(getAttributeNameWithPrefix(attributeName), null); + } + + @Override + public Instant getCreationTime() { + return this.cached.getCreationTime(); + } + + @Override + public void setLastAccessedTime(Instant lastAccessedTime) { + this.cached.setLastAccessedTime(lastAccessedTime); + this.delta.put(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, getLastAccessedTime().toEpochMilli()); + } + + @Override + public Instant getLastAccessedTime() { + return this.cached.getLastAccessedTime(); + } + + @Override + public void setMaxInactiveInterval(Duration interval) { + this.cached.setMaxInactiveInterval(interval); + this.delta.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, (int) getMaxInactiveInterval().getSeconds()); + } + + @Override + public Duration getMaxInactiveInterval() { + return this.cached.getMaxInactiveInterval(); + } + + @Override + public boolean isExpired() { + return this.cached.isExpired(); + } + + public Map getIndexes() { + return Collections.unmodifiableMap(this.indexes); + } + + private boolean hasChangedSessionId() { + return !getId().equals(this.originalSessionId); + } + + private Mono save() { + return Mono + .defer(() -> saveChangeSessionId().then(saveDelta()).doOnSuccess((unused) -> this.isNew = false)); + } + + private Mono saveDelta() { + if (this.delta.isEmpty()) { + return Mono.empty(); + } + + String sessionKey = getSessionKey(getId()); + Mono update = ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations.opsForHash() + .putAll(sessionKey, new HashMap<>(this.delta)); + + String expiredKey = getExpiredKey(getId()); + Mono setTtl; + Mono updateExpireKey = ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations + .opsForValue() + .append(expiredKey, "") + .hasElement(); + if (getMaxInactiveInterval().getSeconds() >= 0) { + Duration fiveMinutesFromActualExpiration = getMaxInactiveInterval().plus(Duration.ofMinutes(5)); + setTtl = ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations.expire(sessionKey, + fiveMinutesFromActualExpiration); + updateExpireKey = updateExpireKey + .flatMap((length) -> ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations + .expire(expiredKey, getMaxInactiveInterval())); + } + else { + setTtl = ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations.persist(sessionKey); + updateExpireKey = ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations.delete(expiredKey) + .hasElement(); + } + + Mono publishCreated = Mono.empty(); + if (this.isNew) { + String sessionCreatedChannelKey = getSessionCreatedChannel(getId()); + publishCreated = ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations + .convertAndSend(sessionCreatedChannelKey, this.delta) + .then(); + } + + return update.flatMap((updated) -> setTtl) + .then(updateExpireKey) + .then(publishCreated) + .then(Mono.fromRunnable(() -> this.delta = new HashMap<>(this.delta.size()))) + .then(); + } + + private Mono saveChangeSessionId() { + if (!hasChangedSessionId()) { + return Mono.empty(); + } + + String sessionId = getId(); + + Mono replaceSessionId = Mono.fromRunnable(() -> this.originalSessionId = sessionId).then(); + + if (this.isNew) { + return Mono.from(replaceSessionId); + } + else { + String originalSessionKey = getSessionKey(this.originalSessionId); + String sessionKey = getSessionKey(sessionId); + String originalExpiredKey = getExpiredKey(this.originalSessionId); + String expiredKey = getExpiredKey(sessionId); + + return renameKey(originalSessionKey, sessionKey) + .then(Mono.defer(() -> renameKey(originalExpiredKey, expiredKey))) + .then(Mono.defer(this::replaceSessionIdOnIndexes)) + .then(Mono.defer(() -> replaceSessionId)); + } + + } + + private Mono replaceSessionIdOnIndexes() { + return ReactiveRedisIndexedSessionRepository.this.indexer.delete(this.originalSessionId) + .then(ReactiveRedisIndexedSessionRepository.this.indexer.update(this)); + } + + private Mono renameKey(String oldKey, String newKey) { + return ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations.rename(oldKey, newKey) + .onErrorResume((ex) -> { + String message = NestedExceptionUtils.getMostSpecificCause(ex).getMessage(); + return StringUtils.startsWithIgnoreCase(message, "ERR no such key"); + }, (ex) -> Mono.empty()) + .then(); + } + + } + + private static final class RedisSessionMapperAdapter + implements BiFunction, Mono> { + + private final RedisSessionMapper mapper = new RedisSessionMapper(); + + @Override + public Mono apply(String sessionId, Map map) { + return Mono.fromSupplier(() -> this.mapper.apply(sessionId, map)); + } + + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisSessionIndexer.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisSessionIndexer.java new file mode 100644 index 000000000..9d0496aa8 --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisSessionIndexer.java @@ -0,0 +1,152 @@ +/* + * Copyright 2014-2023 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.session.data.redis; + +import java.util.HashMap; +import java.util.Map; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.function.Tuples; + +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.session.DelegatingIndexResolver; +import org.springframework.session.IndexResolver; +import org.springframework.session.PrincipalNameIndexResolver; +import org.springframework.session.Session; +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository.RedisSession; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Uses an {@link IndexResolver} to keep track of the indexes for a + * {@link ReactiveRedisIndexedSessionRepository.RedisSession}. Only updates indexes that + * have changed. + * + * @author Marcus da Coregio + */ +final class ReactiveRedisSessionIndexer { + + private final ReactiveRedisOperations sessionRedisOperations; + + private String namespace; + + private IndexResolver indexResolver = new DelegatingIndexResolver<>( + new PrincipalNameIndexResolver<>(ReactiveRedisIndexedSessionRepository.PRINCIPAL_NAME_INDEX_NAME)); + + private String indexKeyPrefix; + + ReactiveRedisSessionIndexer(ReactiveRedisOperations sessionRedisOperations, String namespace) { + Assert.notNull(sessionRedisOperations, "sessionRedisOperations cannot be null"); + Assert.hasText(namespace, "namespace cannot be empty"); + this.sessionRedisOperations = sessionRedisOperations; + this.namespace = namespace; + updateIndexKeyPrefix(); + } + + Mono update(RedisSession redisSession) { + return getIndexes(redisSession.getId()).map((originalIndexes) -> { + Map indexes = this.indexResolver.resolveIndexesFor(redisSession); + Map indexToDelete = new HashMap<>(); + Map indexToAdd = new HashMap<>(); + for (Map.Entry entry : indexes.entrySet()) { + if (!originalIndexes.containsKey(entry.getKey())) { + indexToAdd.put(entry.getKey(), entry.getValue()); + continue; + } + if (!originalIndexes.get(entry.getKey()).equals(entry.getValue())) { + indexToDelete.put(entry.getKey(), originalIndexes.get(entry.getKey())); + indexToAdd.put(entry.getKey(), entry.getValue()); + } + } + if (CollectionUtils.isEmpty(indexes) && !CollectionUtils.isEmpty(originalIndexes)) { + indexToDelete.putAll(originalIndexes); + } + return Tuples.of(indexToDelete, indexToAdd); + }).flatMap((indexes) -> updateIndexes(indexes.getT1(), indexes.getT2(), redisSession.getId())); + } + + private Mono updateIndexes(Map indexToDelete, Map indexToAdd, + String sessionId) { + // @formatter:off + return Flux.fromIterable(indexToDelete.entrySet()) + .flatMap((entry) -> { + String indexKey = getIndexKey(entry.getKey(), entry.getValue()); + return removeSessionFromIndex(indexKey, sessionId).thenReturn(indexKey); + }) + .flatMap((indexKey) -> this.sessionRedisOperations.opsForSet().remove(getSessionIndexesKey(sessionId), indexKey)) + .thenMany(Flux.fromIterable(indexToAdd.entrySet())) + .flatMap((entry) -> { + String indexKey = getIndexKey(entry.getKey(), entry.getValue()); + return this.sessionRedisOperations.opsForSet().add(indexKey, sessionId).thenReturn(indexKey); + }) + .flatMap((indexKey) -> this.sessionRedisOperations.opsForSet().add(getSessionIndexesKey(sessionId), indexKey)) + .then(); + // @formatter:on + } + + Mono delete(String sessionId) { + String sessionIndexesKey = getSessionIndexesKey(sessionId); + return this.sessionRedisOperations.opsForSet() + .members(sessionIndexesKey) + .flatMap((indexKey) -> removeSessionFromIndex((String) indexKey, sessionId)) + .then(this.sessionRedisOperations.delete(sessionIndexesKey)) + .then(); + } + + private Mono removeSessionFromIndex(String indexKey, String sessionId) { + return this.sessionRedisOperations.opsForSet().remove(indexKey, sessionId).then(); + } + + Mono> getIndexes(String sessionId) { + String sessionIndexesKey = getSessionIndexesKey(sessionId); + return this.sessionRedisOperations.opsForSet() + .members(sessionIndexesKey) + .cast(String.class) + .collectMap((indexKey) -> indexKey.substring(this.indexKeyPrefix.length()).split(":")[0], + (indexKey) -> indexKey.substring(this.indexKeyPrefix.length()).split(":")[1]); + } + + Flux getSessionIds(String indexName, String indexValue) { + String indexKey = getIndexKey(indexName, indexValue); + return this.sessionRedisOperations.opsForSet().members(indexKey).cast(String.class); + } + + private void updateIndexKeyPrefix() { + this.indexKeyPrefix = this.namespace + "sessions:index:"; + } + + private String getSessionIndexesKey(String sessionId) { + return this.namespace + "sessions:" + sessionId + ":idx"; + } + + private String getIndexKey(String indexName, String indexValue) { + return this.indexKeyPrefix + indexName + ":" + indexValue; + } + + void setNamespace(String namespace) { + Assert.hasText(namespace, "namespace cannot be empty"); + this.namespace = namespace; + updateIndexKeyPrefix(); + } + + void setIndexResolver(IndexResolver indexResolver) { + Assert.notNull(indexResolver, "indexResolver cannot be null"); + this.indexResolver = indexResolver; + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisSessionRepository.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisSessionRepository.java index 932ec4909..6205d0e2f 100644 --- a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisSessionRepository.java +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisSessionRepository.java @@ -21,9 +21,11 @@ import java.util.HashMap; import java.util.Map; import java.util.Set; +import java.util.function.BiFunction; import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import org.springframework.core.NestedExceptionUtils; import org.springframework.data.redis.core.ReactiveRedisOperations; @@ -31,6 +33,8 @@ import org.springframework.session.ReactiveSessionRepository; import org.springframework.session.SaveMode; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -61,6 +65,10 @@ public class ReactiveRedisSessionRepository private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + + private BiFunction, Mono> redisSessionMapper = new RedisSessionMapperAdapter(); + /** * Create a new {@link ReactiveRedisSessionRepository} instance. * @param sessionRedisOperations the {@link ReactiveRedisOperations} to use for @@ -119,12 +127,16 @@ public ReactiveRedisOperations getSessionRedisOperations() { @Override public Mono createSession() { - return Mono.defer(() -> { - MapSession cached = new MapSession(); - cached.setMaxInactiveInterval(this.defaultMaxInactiveInterval); - RedisSession session = new RedisSession(cached, true); - return Mono.just(session); - }); + // @formatter:off + return Mono.fromSupplier(() -> this.sessionIdGenerator.generate()) + .subscribeOn(Schedulers.boundedElastic()) + .publishOn(Schedulers.parallel()) + .map((sessionId) -> { + MapSession cached = new MapSession(sessionId); + cached.setMaxInactiveInterval(this.defaultMaxInactiveInterval); + return new RedisSession(cached, true); + }); + // @formatter:on } @Override @@ -146,7 +158,7 @@ public Mono findById(String id) { return this.sessionRedisOperations.opsForHash().entries(sessionKey) .collectMap((e) -> e.getKey().toString(), Map.Entry::getValue) .filter((map) -> !map.isEmpty()) - .map(new RedisSessionMapper(id)) + .flatMap((map) -> this.redisSessionMapper.apply(id, map)) .filter((session) -> !session.isExpired()) .map((session) -> new RedisSession(session, false)) .switchIfEmpty(Mono.defer(() -> deleteById(id).then(Mono.empty()))); @@ -168,6 +180,26 @@ private String getSessionKey(String sessionId) { return this.namespace + "sessions:" + sessionId; } + /** + * Set the {@link SessionIdGenerator} to use to generate session ids. + * @param sessionIdGenerator the {@link SessionIdGenerator} to use + * @since 3.2 + */ + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + Assert.notNull(sessionIdGenerator, "sessionIdGenerator cannot be null"); + this.sessionIdGenerator = sessionIdGenerator; + } + + /** + * Set the {@link BiFunction} used to convert a {@link Map} to a {@link MapSession}. + * @param redisSessionMapper the mapper to use, cannot be null + * @since 3.2 + */ + public void setRedisSessionMapper(BiFunction, Mono> redisSessionMapper) { + Assert.notNull(redisSessionMapper, "redisSessionMapper cannot be null"); + this.redisSessionMapper = redisSessionMapper; + } + /** * A custom implementation of {@link Session} that uses a {@link MapSession} as the * basis for its mapping. It keeps track of any attributes that have changed. When @@ -207,7 +239,9 @@ public String getId() { @Override public String changeSessionId() { - return this.cached.changeSessionId(); + String newSessionId = ReactiveRedisSessionRepository.this.sessionIdGenerator.generate(); + this.cached.setId(newSessionId); + return newSessionId; } @Override @@ -331,4 +365,16 @@ private Mono saveChangeSessionId() { } + private static final class RedisSessionMapperAdapter + implements BiFunction, Mono> { + + private final RedisSessionMapper mapper = new RedisSessionMapper(); + + @Override + public Mono apply(String sessionId, Map map) { + return Mono.fromSupplier(() -> this.mapper.apply(sessionId, map)); + } + + } + } diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisIndexedSessionRepository.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisIndexedSessionRepository.java index 96a62e575..13c9c5714 100644 --- a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisIndexedSessionRepository.java +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisIndexedSessionRepository.java @@ -18,11 +18,15 @@ import java.time.Duration; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.BiFunction; +import java.util.function.Function; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -36,6 +40,8 @@ import org.springframework.data.redis.connection.Message; import org.springframework.data.redis.connection.MessageListener; import org.springframework.data.redis.core.BoundHashOperations; +import org.springframework.data.redis.core.BoundSetOperations; +import org.springframework.data.redis.core.BoundValueOperations; import org.springframework.data.redis.core.RedisOperations; import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer; @@ -52,12 +58,15 @@ import org.springframework.session.PrincipalNameIndexResolver; import org.springframework.session.SaveMode; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.events.SessionCreatedEvent; import org.springframework.session.events.SessionDeletedEvent; import org.springframework.session.events.SessionDestroyedEvent; import org.springframework.session.events.SessionExpiredEvent; import org.springframework.session.web.http.SessionRepositoryFilter; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; /** @@ -100,7 +109,7 @@ * APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe "" * EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800 * SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe - * EXPIRE spring:session:expirations1439245080000 2100 + * EXPIRE spring:session:expirations:1439245080000 2100 * * *

Saving a Session

@@ -235,7 +244,7 @@ * *
  * SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
- * EXPIRE spring:session:expirations1439245080000 2100
+ * EXPIRE spring:session:expirations:1439245080000 2100
  * 
* *

@@ -303,7 +312,7 @@ public class RedisIndexedSessionRepository private final RedisOperations sessionRedisOperations; - private final RedisSessionExpirationPolicy expirationPolicy; + private RedisSessionExpirationStore expirationStore; private ApplicationEventPublisher eventPublisher = (event) -> { }; @@ -322,6 +331,10 @@ public class RedisIndexedSessionRepository private ThreadPoolTaskScheduler taskScheduler; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + + private BiFunction, MapSession> redisSessionMapper = new RedisSessionMapper(); + /** * Creates a new instance. For an example, refer to the class level javadoc. * @param sessionRedisOperations the {@link RedisOperations} to use for managing the @@ -330,8 +343,8 @@ public class RedisIndexedSessionRepository public RedisIndexedSessionRepository(RedisOperations sessionRedisOperations) { Assert.notNull(sessionRedisOperations, "sessionRedisOperations cannot be null"); this.sessionRedisOperations = sessionRedisOperations; - this.expirationPolicy = new RedisSessionExpirationPolicy(sessionRedisOperations, this::getExpirationsKey, - this::getSessionKey); + this.expirationStore = new MinuteBasedRedisSessionExpirationStore(sessionRedisOperations, + this::getExpirationsKey); configureSessionChannels(); } @@ -479,7 +492,7 @@ public void save(RedisSession session) { } public void cleanUpExpiredSessions() { - this.expirationPolicy.cleanExpiredSessions(); + this.expirationStore.cleanupExpiredSessions(); } @Override @@ -519,8 +532,8 @@ private RedisSession getSession(String id, boolean allowExpired) { if ((entries == null) || entries.isEmpty()) { return null; } - MapSession loaded = new RedisSessionMapper(id).apply(entries); - if (!allowExpired && loaded.isExpired()) { + MapSession loaded = this.redisSessionMapper.apply(id, entries); + if (loaded == null || (!allowExpired && loaded.isExpired())) { return null; } RedisSession result = new RedisSession(loaded, false); @@ -536,7 +549,7 @@ public void deleteById(String sessionId) { } cleanupPrincipalIndex(session); - this.expirationPolicy.onDelete(session); + this.expirationStore.remove(sessionId); String expireKey = getExpiredKey(session.getId()); this.sessionRedisOperations.delete(expireKey); @@ -547,7 +560,7 @@ public void deleteById(String sessionId) { @Override public RedisSession createSession() { - MapSession cached = new MapSession(); + MapSession cached = new MapSession(this.sessionIdGenerator); cached.setMaxInactiveInterval(this.defaultMaxInactiveInterval); RedisSession session = new RedisSession(cached, true); session.flushImmediateIfNecessary(); @@ -564,9 +577,11 @@ public void onMessage(Message message, byte[] pattern) { String sessionId = channel.substring(channel.lastIndexOf(":") + 1); @SuppressWarnings("unchecked") Map entries = (Map) this.defaultSerializer.deserialize(message.getBody()); - MapSession loaded = new RedisSessionMapper(sessionId).apply(entries); - RedisSession session = new RedisSession(loaded, false); - handleCreated(session); + MapSession loaded = this.redisSessionMapper.apply(sessionId, entries); + if (loaded != null) { + RedisSession session = new RedisSession(loaded, false); + handleCreated(session); + } return; } @@ -595,6 +610,7 @@ public void onMessage(Message message, byte[] pattern) { } cleanupPrincipalIndex(session); + this.expirationStore.remove(session.getId()); if (isDeleted) { handleDeleted(session); @@ -641,6 +657,18 @@ public void setRedisKeyNamespace(String namespace) { configureSessionChannels(); } + /** + * Set the {@link RedisSessionExpirationStore} to use, defaults to + * {@link MinuteBasedRedisSessionExpirationStore}. + * @param expirationStore the {@link RedisSessionExpirationStore} to use, cannot be + * null + * @since 3.4 + */ + public void setExpirationStore(RedisSessionExpirationStore expirationStore) { + Assert.notNull(expirationStore, "expirationStore cannot be null"); + this.expirationStore = expirationStore; + } + /** * Gets the Hash key for this session by prefixing it appropriately. * @param sessionId the session id @@ -716,6 +744,27 @@ static String getSessionAttrNameKey(String attributeName) { return RedisSessionMapper.ATTRIBUTE_PREFIX + attributeName; } + /** + * Set the {@link SessionIdGenerator} to use to generate session ids. + * @param sessionIdGenerator the {@link SessionIdGenerator} to use + * @since 3.2 + */ + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + Assert.notNull(sessionIdGenerator, "sessionIdGenerator cannot be null"); + this.sessionIdGenerator = sessionIdGenerator; + } + + /** + * Set the {@link BiFunction} used to map {@link MapSession} to a + * {@link ReactiveRedisSessionRepository.RedisSession}. + * @param redisSessionMapper the mapper to use, cannot be null + * @since 3.2 + */ + public void setRedisSessionMapper(BiFunction, MapSession> redisSessionMapper) { + Assert.notNull(redisSessionMapper, "redisSessionMapper cannot be null"); + this.redisSessionMapper = redisSessionMapper; + } + /** * A custom implementation of {@link Session} that uses a {@link MapSession} as the * basis for its mapping. It keeps track of any attributes that have changed. When @@ -724,7 +773,7 @@ static String getSessionAttrNameKey(String attributeName) { * * @author Rob Winch */ - final class RedisSession implements Session { + public final class RedisSession implements Session { private final MapSession cached; @@ -780,7 +829,9 @@ public String getId() { @Override public String changeSessionId() { - return this.cached.changeSessionId(); + String newSessionId = RedisIndexedSessionRepository.this.sessionIdGenerator.generate(); + this.cached.setId(newSessionId); + return newSessionId; } @Override @@ -873,10 +924,41 @@ private void saveDelta() { RedisIndexedSessionRepository.this.sessionRedisOperations.convertAndSend(sessionCreatedKey, this.delta); this.isNew = false; } + + long sessionExpireInSeconds = getMaxInactiveInterval().getSeconds(); + + createShadowKey(sessionExpireInSeconds); + + long fiveMinutesAfterExpires = sessionExpireInSeconds + TimeUnit.MINUTES.toSeconds(5); + RedisIndexedSessionRepository.this.sessionRedisOperations.boundHashOps(getSessionKey(getId())) + .expire(fiveMinutesAfterExpires, TimeUnit.SECONDS); + + RedisIndexedSessionRepository.this.expirationStore.save(this); this.delta = new HashMap<>(this.delta.size()); - Long originalExpiration = (this.originalLastAccessTime != null) - ? this.originalLastAccessTime.plus(getMaxInactiveInterval()).toEpochMilli() : null; - RedisIndexedSessionRepository.this.expirationPolicy.onExpirationUpdated(originalExpiration, this); + } + + private void createShadowKey(long sessionExpireInSeconds) { + String keyToExpire = "expires:" + getId(); + String sessionKey = getSessionKey(keyToExpire); + + if (sessionExpireInSeconds < 0) { + BoundValueOperations valueOps = RedisIndexedSessionRepository.this.sessionRedisOperations + .boundValueOps(sessionKey); + valueOps.append(""); + valueOps.persist(); + RedisIndexedSessionRepository.this.sessionRedisOperations.boundHashOps(getSessionKey(getId())) + .persist(); + } + + if (sessionExpireInSeconds == 0) { + RedisIndexedSessionRepository.this.sessionRedisOperations.delete(sessionKey); + } + else { + BoundValueOperations valueOps = RedisIndexedSessionRepository.this.sessionRedisOperations + .boundValueOps(sessionKey); + valueOps.append(""); + valueOps.expire(sessionExpireInSeconds, TimeUnit.SECONDS); + } } private void saveChangeSessionId() { @@ -909,6 +991,7 @@ private void saveChangeSessionId() { RedisIndexedSessionRepository.this.sessionRedisOperations.boundSetOps(originalPrincipalRedisKey) .add(sessionId); } + RedisIndexedSessionRepository.this.expirationStore.remove(this.originalSessionId); } this.originalSessionId = sessionId; } @@ -922,4 +1005,100 @@ private void handleErrNoSuchKeyError(NonTransientDataAccessException ex) { } + private final class MinuteBasedRedisSessionExpirationStore implements RedisSessionExpirationStore { + + private static final String SESSION_EXPIRES_PREFIX = "expires:"; + + private final RedisOperations redis; + + private final Function lookupExpirationKey; + + MinuteBasedRedisSessionExpirationStore(RedisOperations redis, + Function lookupExpirationKey) { + this.redis = redis; + this.lookupExpirationKey = lookupExpirationKey; + } + + @Override + public void save(RedisSession session) { + Long originalExpiration = (session.originalLastAccessTime != null) + ? session.originalLastAccessTime.plus(session.getMaxInactiveInterval()).toEpochMilli() : null; + String keyToExpire = SESSION_EXPIRES_PREFIX + session.getId(); + long toExpire = roundUpToNextMinute(expiresInMillis(session)); + + if (originalExpiration != null) { + long originalRoundedUp = roundUpToNextMinute(originalExpiration); + if (toExpire != originalRoundedUp) { + String expireKey = getExpirationKey(originalRoundedUp); + this.redis.boundSetOps(expireKey).remove(keyToExpire); + } + } + + String expirationsKey = getExpirationsKey(toExpire); + long sessionExpireInSeconds = session.getMaxInactiveInterval().getSeconds(); + long fiveMinutesAfterExpires = sessionExpireInSeconds + TimeUnit.MINUTES.toSeconds(5); + this.redis.boundSetOps(expirationsKey).expire(fiveMinutesAfterExpires, TimeUnit.SECONDS); + + String expireKey = getExpirationKey(toExpire); + BoundSetOperations expireOperations = this.redis.boundSetOps(expireKey); + expireOperations.add(keyToExpire); + } + + @Override + public void remove(String sessionId) { + RedisSession session = getSession(sessionId, true); + if (session != null) { + long toExpire = roundUpToNextMinute(expiresInMillis(session)); + String expireKey = getExpirationKey(toExpire); + String entryToRemove = SESSION_EXPIRES_PREFIX + session.getId(); + this.redis.boundSetOps(expireKey).remove(entryToRemove); + } + } + + @Override + public void cleanupExpiredSessions() { + long now = System.currentTimeMillis(); + long prevMin = roundDownMinute(now); + String expirationKey = getExpirationKey(prevMin); + Set sessionsToExpire = this.redis.boundSetOps(expirationKey).members(); + this.redis.delete(expirationKey); + if (CollectionUtils.isEmpty(sessionsToExpire)) { + return; + } + for (Object sessionId : sessionsToExpire) { + touch(getSessionKey((String) sessionId)); + } + } + + /** + * By trying to access the session we only trigger a deletion if the TTL is + * expired. This is done to handle + * gh-93 + * @param sessionKey the key + */ + private void touch(String sessionKey) { + RedisIndexedSessionRepository.this.sessionRedisOperations.hasKey(sessionKey); + } + + String getExpirationKey(long expires) { + return this.lookupExpirationKey.apply(expires); + } + + private static long expiresInMillis(Session session) { + return session.getLastAccessedTime().plus(session.getMaxInactiveInterval()).toEpochMilli(); + } + + private static long roundUpToNextMinute(long timeInMs) { + Instant instant = Instant.ofEpochMilli(timeInMs).plus(1, ChronoUnit.MINUTES); + Instant nextMinute = instant.truncatedTo(ChronoUnit.MINUTES); + return nextMinute.toEpochMilli(); + } + + private static long roundDownMinute(long timeInMs) { + Instant downMinute = Instant.ofEpochMilli(timeInMs).truncatedTo(ChronoUnit.MINUTES); + return downMinute.toEpochMilli(); + } + + } + } diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionExpirationStore.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionExpirationStore.java new file mode 100644 index 000000000..6d35e994e --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionExpirationStore.java @@ -0,0 +1,53 @@ +/* + * Copyright 2014-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.session.data.redis; + +/** + * An interface for storing {@link RedisIndexedSessionRepository.RedisSession} instances + * with their expected expiration time. This approach is necessary because Redis does not + * guarantee when the expired event will be fired if the key has not been accessed. For + * more details, see the Redis documentation on + * how + * keys expire. To address the uncertainty of expired events, sessions can be stored + * with their expected expiration time, ensuring each key is accessed when it is expected + * to expire. This interface defines common operations for tracking sessions and their + * expiration times, and allows for a strategy to clean up expired sessions. + * + * @author Marcus da Coregio + * @since 3.4 + */ +public interface RedisSessionExpirationStore { + + /** + * Saves the session and its expected expiration time, so it can be found later on by + * its expiration time in order for clean up to happen. + * @param session the session to save + */ + void save(RedisIndexedSessionRepository.RedisSession session); + + /** + * Removes the session id from the expiration store. + * @param sessionId the session id + */ + void remove(String sessionId); + + /** + * Performs clean up on the expired sessions. + */ + void cleanupExpiredSessions(); + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionMapper.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionMapper.java index 476d03d38..ab2f926c8 100644 --- a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionMapper.java +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionMapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2019 the original author or authors. + * Copyright 2014-2023 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. @@ -19,6 +19,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Map; +import java.util.function.BiFunction; import java.util.function.Function; import org.springframework.session.MapSession; @@ -30,9 +31,10 @@ * {@link MapSession}. * * @author Vedran Pavic + * @author Marcus da Coregio * @since 2.2.0 */ -final class RedisSessionMapper implements Function, MapSession> { +public final class RedisSessionMapper implements BiFunction, MapSession> { /** * The key in the hash representing {@link Session#getCreationTime()}. @@ -56,17 +58,15 @@ final class RedisSessionMapper implements Function, MapSessi */ static final String ATTRIBUTE_PREFIX = "sessionAttr:"; - private final String sessionId; - - RedisSessionMapper(String sessionId) { - Assert.hasText(sessionId, "sessionId must not be empty"); - this.sessionId = sessionId; + private static void handleMissingKey(String key) { + throw new IllegalStateException(key + " key must not be null"); } @Override - public MapSession apply(Map map) { + public MapSession apply(String sessionId, Map map) { + Assert.hasText(sessionId, "sessionId must not be empty"); Assert.notEmpty(map, "map must not be empty"); - MapSession session = new MapSession(this.sessionId); + MapSession session = new MapSession(sessionId); Long creationTime = (Long) map.get(CREATION_TIME_KEY); if (creationTime == null) { handleMissingKey(CREATION_TIME_KEY); @@ -90,8 +90,4 @@ public MapSession apply(Map map) { return session; } - private static void handleMissingKey(String key) { - throw new IllegalStateException(key + " key must not be null"); - } - } diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionRepository.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionRepository.java index e317dfb63..c63b50449 100644 --- a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionRepository.java +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -21,13 +21,16 @@ import java.util.HashMap; import java.util.Map; import java.util.Set; +import java.util.function.BiFunction; import org.springframework.data.redis.core.RedisOperations; import org.springframework.session.FlushMode; import org.springframework.session.MapSession; import org.springframework.session.SaveMode; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; import org.springframework.session.SessionRepository; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.util.Assert; /** @@ -56,6 +59,10 @@ public class RedisSessionRepository implements SessionRepository, MapSession> redisSessionMapper = new RedisSessionMapper(); + /** * Create a new {@link RedisSessionRepository} instance. * @param sessionRedisOperations the {@link RedisOperations} to use for managing @@ -106,7 +113,7 @@ public void setSaveMode(SaveMode saveMode) { @Override public RedisSession createSession() { - MapSession cached = new MapSession(); + MapSession cached = new MapSession(this.sessionIdGenerator); cached.setMaxInactiveInterval(this.defaultMaxInactiveInterval); RedisSession session = new RedisSession(cached, true); session.flushIfRequired(); @@ -132,8 +139,8 @@ public RedisSession findById(String sessionId) { if (entries.isEmpty()) { return null; } - MapSession session = new RedisSessionMapper(sessionId).apply(entries); - if (session.isExpired()) { + MapSession session = this.redisSessionMapper.apply(sessionId, entries); + if (session == null || session.isExpired()) { deleteById(sessionId); return null; } @@ -162,6 +169,27 @@ private static String getAttributeKey(String attributeName) { return RedisSessionMapper.ATTRIBUTE_PREFIX + attributeName; } + /** + * Set the {@link SessionIdGenerator} to use to generate session ids. + * @param sessionIdGenerator the {@link SessionIdGenerator} to use + * @since 3.2 + */ + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + Assert.notNull(sessionIdGenerator, "sessionIdGenerator cannot be null"); + this.sessionIdGenerator = sessionIdGenerator; + } + + /** + * Set the {@link BiFunction} used to map {@link MapSession} to a + * {@link ReactiveRedisSessionRepository.RedisSession}. + * @param redisSessionMapper the mapper to use, cannot be null + * @since 3.2 + */ + public void setRedisSessionMapper(BiFunction, MapSession> redisSessionMapper) { + Assert.notNull(redisSessionMapper, "redisSessionMapper cannot be null"); + this.redisSessionMapper = redisSessionMapper; + } + /** * An internal {@link Session} implementation used by this {@link SessionRepository}. */ @@ -198,7 +226,9 @@ public String getId() { @Override public String changeSessionId() { - return this.cached.changeSessionId(); + String newSessionId = RedisSessionRepository.this.sessionIdGenerator.generate(); + this.cached.setId(newSessionId); + return newSessionId; } @Override diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SortedSetReactiveRedisSessionExpirationStore.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SortedSetReactiveRedisSessionExpirationStore.java new file mode 100644 index 000000000..464877302 --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SortedSetReactiveRedisSessionExpirationStore.java @@ -0,0 +1,98 @@ +/* + * Copyright 2014-2023 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.session.data.redis; + +import java.time.Instant; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.data.domain.Range; +import org.springframework.data.redis.connection.Limit; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.util.Assert; + +/** + * Uses a sorted set to store the expiration times for sessions. The score of each entry + * is the expiration time of the session. The value is the session id. + * + * @author Marcus da Coregio + */ +final class SortedSetReactiveRedisSessionExpirationStore { + + private final ReactiveRedisOperations sessionRedisOperations; + + private String namespace; + + private int retrieveCount = 100; + + SortedSetReactiveRedisSessionExpirationStore(ReactiveRedisOperations sessionRedisOperations, + String namespace) { + Assert.notNull(sessionRedisOperations, "sessionRedisOperations cannot be null"); + Assert.hasText(namespace, "namespace cannot be null or empty"); + this.sessionRedisOperations = sessionRedisOperations; + this.namespace = namespace; + } + + /** + * Add the session id associated with the expiration time into the sorted set. + * @param sessionId the session id + * @param expiration the expiration time + * @return a {@link Mono} that completes when the operation completes + */ + Mono add(String sessionId, Instant expiration) { + long expirationInMillis = expiration.toEpochMilli(); + return this.sessionRedisOperations.opsForZSet().add(getExpirationsKey(), sessionId, expirationInMillis).then(); + } + + /** + * Remove the session id from the sorted set. + * @param sessionId the session id + * @return a {@link Mono} that completes when the operation completes + */ + Mono remove(String sessionId) { + return this.sessionRedisOperations.opsForZSet().remove(getExpirationsKey(), sessionId).then(); + } + + /** + * Retrieve the session ids that have the expiration time less than the value passed + * in {@code expiredBefore}. + * @param expiredBefore the expiration time + * @return a {@link Flux} that emits the session ids + */ + Flux retrieveExpiredSessions(Instant expiredBefore) { + Range range = Range.closed(0D, (double) expiredBefore.toEpochMilli()); + Limit limit = Limit.limit().count(this.retrieveCount); + return this.sessionRedisOperations.opsForZSet() + .reverseRangeByScore(getExpirationsKey(), range, limit) + .cast(String.class); + } + + private String getExpirationsKey() { + return this.namespace + "sessions:expirations"; + } + + /** + * Set the namespace for the keys used by this class. + * @param namespace the namespace + */ + void setNamespace(String namespace) { + Assert.hasText(namespace, "namespace cannot be null or empty"); + this.namespace = namespace; + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStore.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStore.java new file mode 100644 index 000000000..37dc163c4 --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStore.java @@ -0,0 +1,141 @@ +/* + * Copyright 2014-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.session.data.redis; + +import java.time.Clock; +import java.time.Instant; +import java.util.Set; + +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.session.Session; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Uses a sorted set to store the expiration times for sessions. The score of each entry + * is the expiration time of the session (calculated via + * {@link Session#getLastAccessedTime()} + {@link Session#getMaxInactiveInterval()}). The + * value is the session id. Note that {@link #cleanupExpiredSessions()} only retrieves up + * to 100 sessions at a time by default, use {@link #setCleanupCount(int)} to increase it + * if needed. + * + * @author Marcus da Coregio + * @since 3.4 + */ +public class SortedSetRedisSessionExpirationStore implements RedisSessionExpirationStore { + + private final RedisOperations redisOps; + + private String namespace; + + private int cleanupCount = 100; + + private Clock clock = Clock.systemUTC(); + + private String expirationsKey; + + public SortedSetRedisSessionExpirationStore(RedisOperations redisOps, String namespace) { + Assert.notNull(redisOps, "redisOps cannot be null"); + this.redisOps = redisOps; + setNamespace(namespace); + } + + /** + * Save the session id associated with the expiration time into the sorted set. + * @param session the session to save + */ + @Override + public void save(RedisIndexedSessionRepository.RedisSession session) { + long expirationInMillis = getExpirationTime(session).toEpochMilli(); + this.redisOps.opsForZSet().add(this.expirationsKey, session.getId(), expirationInMillis); + } + + /** + * Remove the session id from the sorted set. + * @param sessionId the session id + */ + @Override + public void remove(String sessionId) { + this.redisOps.opsForZSet().remove(this.expirationsKey, sessionId); + } + + /** + * Retrieves the sessions that are expected to be expired and invoke + * {@link #touch(String)} on each of the session keys, resolved via + * {@link #getSessionKey(String)}. + */ + @Override + public void cleanupExpiredSessions() { + Set sessionIds = this.redisOps.opsForZSet() + .reverseRangeByScore(this.expirationsKey, 0, this.clock.millis(), 0, this.cleanupCount); + if (CollectionUtils.isEmpty(sessionIds)) { + return; + } + for (Object sessionId : sessionIds) { + String sessionKey = getSessionKey((String) sessionId); + touch(sessionKey); + } + } + + private Instant getExpirationTime(RedisIndexedSessionRepository.RedisSession session) { + return session.getLastAccessedTime().plus(session.getMaxInactiveInterval()); + } + + /** + * Checks if the session exists. By trying to access the session we only trigger a + * deletion if the TTL is expired. This is done to handle + * gh-93 + * @param sessionKey the key + */ + private void touch(String sessionKey) { + this.redisOps.hasKey(sessionKey); + } + + private String getSessionKey(String sessionId) { + return this.namespace + ":sessions:" + sessionId; + } + + /** + * Set the namespace for the keys. + * @param namespace the namespace + */ + public void setNamespace(String namespace) { + Assert.hasText(namespace, "namespace cannot be null or empty"); + this.namespace = namespace; + this.expirationsKey = this.namespace + ":sessions:expirations"; + } + + /** + * Configure the clock used when retrieving expired sessions for clean-up. + * @param clock the clock + */ + public void setClock(Clock clock) { + Assert.notNull(clock, "clock cannot be null"); + this.clock = clock; + } + + /** + * Configures how many sessions will be queried at a time to be cleaned up. Defaults + * to 100. + * @param cleanupCount how many sessions to be queried, must be bigger than 0. + */ + public void setCleanupCount(int cleanupCount) { + Assert.state(cleanupCount > 0, "cleanupCount must be greater than 0"); + this.cleanupCount = cleanupCount; + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/ConfigureReactiveRedisAction.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/ConfigureReactiveRedisAction.java new file mode 100644 index 000000000..1f6e288cc --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/ConfigureReactiveRedisAction.java @@ -0,0 +1,39 @@ +/* + * Copyright 2014-2023 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.session.data.redis.config; + +import reactor.core.publisher.Mono; + +import org.springframework.data.redis.connection.ReactiveRedisConnection; + +/** + * Allows specifying a strategy for configuring and validating Redis using a Reactive + * connection. + * + * @author Marcus da Coregio + * @since 3.3 + */ +public interface ConfigureReactiveRedisAction { + + Mono configure(ReactiveRedisConnection connection); + + /** + * An implementation of {@link ConfigureReactiveRedisAction} that does nothing. + */ + ConfigureReactiveRedisAction NO_OP = (connection) -> Mono.empty(); + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/ConfigureNotifyKeyspaceEventsReactiveAction.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/ConfigureNotifyKeyspaceEventsReactiveAction.java new file mode 100644 index 000000000..08caf708c --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/ConfigureNotifyKeyspaceEventsReactiveAction.java @@ -0,0 +1,88 @@ +/* + * Copyright 2014-2023 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.session.data.redis.config.annotation; + +import java.util.Properties; +import java.util.function.Predicate; + +import reactor.core.publisher.Mono; +import reactor.util.function.Tuples; + +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.redis.connection.ReactiveRedisConnection; +import org.springframework.session.data.redis.config.ConfigureReactiveRedisAction; + +/** + *

+ * Ensures that Redis Keyspace events for Generic commands and Expired events are enabled. + * For example, it might set the following: + *

+ * + *
+ * config set notify-keyspace-events Egx
+ * 
+ * + *

+ * This strategy will not work if the Redis instance has been properly secured. Instead, + * the Redis instance should be configured externally and a Bean of type + * {@link ConfigureReactiveRedisAction#NO_OP} should be exposed. + *

+ * + * @author Rob Winch + * @author Mark Paluch + * @author Marcus da Coregio + * @since 3.3 + */ +public class ConfigureNotifyKeyspaceEventsReactiveAction implements ConfigureReactiveRedisAction { + + static final String CONFIG_NOTIFY_KEYSPACE_EVENTS = "notify-keyspace-events"; + + @Override + public Mono configure(ReactiveRedisConnection connection) { + return getNotifyOptions(connection).map((notifyOptions) -> { + String customizedNotifyOptions = notifyOptions; + if (!customizedNotifyOptions.contains("E")) { + customizedNotifyOptions += "E"; + } + boolean A = customizedNotifyOptions.contains("A"); + if (!(A || customizedNotifyOptions.contains("g"))) { + customizedNotifyOptions += "g"; + } + if (!(A || customizedNotifyOptions.contains("x"))) { + customizedNotifyOptions += "x"; + } + return Tuples.of(notifyOptions, customizedNotifyOptions); + }) + .filter((optionsTuple) -> !optionsTuple.getT1().equals(optionsTuple.getT2())) + .flatMap((optionsTuple) -> connection.serverCommands() + .setConfig(CONFIG_NOTIFY_KEYSPACE_EVENTS, optionsTuple.getT2())) + .filter("OK"::equals) + .doFinally((unused) -> connection.close()) + .then(); + } + + private Mono getNotifyOptions(ReactiveRedisConnection connection) { + return connection.serverCommands() + .getConfig(CONFIG_NOTIFY_KEYSPACE_EVENTS) + .filter(Predicate.not(Properties::isEmpty)) + .map((config) -> config.getProperty(config.stringPropertyNames().iterator().next())) + .onErrorMap(InvalidDataAccessApiUsageException.class, + (ex) -> new IllegalStateException("Unable to configure Reactive Redis to keyspace notifications", + ex)); + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java index bf9465fa8..05d379e58 100644 --- a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java @@ -19,6 +19,7 @@ import java.time.Duration; import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.EmbeddedValueResolverAware; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -27,6 +28,8 @@ import org.springframework.core.type.AnnotationMetadata; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.data.redis.RedisSessionRepository; import org.springframework.session.web.http.SessionRepositoryFilter; import org.springframework.util.StringUtils; @@ -49,6 +52,8 @@ public class RedisHttpSessionConfiguration extends AbstractRedisHttpSessionConfi private StringValueResolver embeddedValueResolver; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + @Bean @Override public RedisSessionRepository sessionRepository() { @@ -60,6 +65,7 @@ public RedisSessionRepository sessionRepository() { } sessionRepository.setFlushMode(getFlushMode()); sessionRepository.setSaveMode(getSaveMode()); + sessionRepository.setSessionIdGenerator(this.sessionIdGenerator); getSessionRepositoryCustomizers() .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(sessionRepository)); return sessionRepository; @@ -87,4 +93,9 @@ public void setImportMetadata(AnnotationMetadata importMetadata) { setSaveMode(attributes.getEnum("saveMode")); } + @Autowired(required = false) + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + this.sessionIdGenerator = sessionIdGenerator; + } + } diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfiguration.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfiguration.java index f7a34d46f..62ef93b77 100644 --- a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfiguration.java +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfiguration.java @@ -44,7 +44,10 @@ import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.session.IndexResolver; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.data.redis.RedisIndexedSessionRepository; +import org.springframework.session.data.redis.RedisSessionExpirationStore; import org.springframework.session.data.redis.config.ConfigureNotifyKeyspaceEventsAction; import org.springframework.session.data.redis.config.ConfigureRedisAction; import org.springframework.session.web.http.SessionRepositoryFilter; @@ -80,6 +83,10 @@ public class RedisIndexedHttpSessionConfiguration private StringValueResolver embeddedValueResolver; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + + private RedisSessionExpirationStore expirationStore; + @Bean @Override public RedisIndexedSessionRepository sessionRepository() { @@ -101,6 +108,10 @@ public RedisIndexedSessionRepository sessionRepository() { sessionRepository.setCleanupCron(this.cleanupCron); int database = resolveDatabase(); sessionRepository.setDatabase(database); + sessionRepository.setSessionIdGenerator(this.sessionIdGenerator); + if (this.expirationStore != null) { + sessionRepository.setExpirationStore(this.expirationStore); + } getSessionRepositoryCustomizers() .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(sessionRepository)); return sessionRepository; @@ -166,6 +177,11 @@ public void setRedisSubscriptionExecutor(Executor redisSubscriptionExecutor) { this.redisSubscriptionExecutor = redisSubscriptionExecutor; } + @Autowired(required = false) + public void setExpirationStore(RedisSessionExpirationStore expirationStore) { + this.expirationStore = expirationStore; + } + @Override public void setEmbeddedValueResolver(StringValueResolver resolver) { this.embeddedValueResolver = resolver; @@ -204,6 +220,11 @@ && getRedisConnectionFactory() instanceof JedisConnectionFactory) { return RedisIndexedSessionRepository.DEFAULT_DATABASE; } + @Autowired(required = false) + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + this.sessionIdGenerator = sessionIdGenerator; + } + /** * Ensures that Redis is configured to send keyspace notifications. This is important * to ensure that expiration and deletion of sessions trigger SessionDestroyedEvents. diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/AbstractRedisWebSessionConfiguration.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/AbstractRedisWebSessionConfiguration.java new file mode 100644 index 000000000..db9ba65dd --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/AbstractRedisWebSessionConfiguration.java @@ -0,0 +1,148 @@ +/* + * Copyright 2014-2023 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.session.data.redis.config.annotation.web.server; + +import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.session.MapSession; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.SaveMode; +import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; +import org.springframework.session.config.ReactiveSessionRepositoryCustomizer; +import org.springframework.session.config.annotation.web.server.SpringWebSessionConfiguration; +import org.springframework.session.data.redis.ReactiveRedisSessionRepository; +import org.springframework.session.data.redis.config.annotation.SpringSessionRedisConnectionFactory; +import org.springframework.util.Assert; + +@Configuration(proxyBeanMethods = false) +@Import(SpringWebSessionConfiguration.class) +public abstract class AbstractRedisWebSessionConfiguration> { + + private Duration maxInactiveInterval = MapSession.DEFAULT_MAX_INACTIVE_INTERVAL; + + private String redisNamespace = ReactiveRedisSessionRepository.DEFAULT_NAMESPACE; + + private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE; + + private ReactiveRedisConnectionFactory redisConnectionFactory; + + private RedisSerializer defaultRedisSerializer = new JdkSerializationRedisSerializer(); + + private List> sessionRepositoryCustomizers; + + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + + public abstract T sessionRepository(); + + public void setMaxInactiveInterval(Duration maxInactiveInterval) { + this.maxInactiveInterval = maxInactiveInterval; + } + + public void setRedisNamespace(String namespace) { + Assert.hasText(namespace, "namespace cannot be empty or null"); + this.redisNamespace = namespace; + } + + public void setSaveMode(SaveMode saveMode) { + Assert.notNull(saveMode, "saveMode cannot be null"); + this.saveMode = saveMode; + } + + public Duration getMaxInactiveInterval() { + return this.maxInactiveInterval; + } + + public String getRedisNamespace() { + return this.redisNamespace; + } + + public SaveMode getSaveMode() { + return this.saveMode; + } + + public SessionIdGenerator getSessionIdGenerator() { + return this.sessionIdGenerator; + } + + public RedisSerializer getDefaultRedisSerializer() { + return this.defaultRedisSerializer; + } + + @Autowired + public void setRedisConnectionFactory( + @SpringSessionRedisConnectionFactory ObjectProvider springSessionRedisConnectionFactory, + ObjectProvider redisConnectionFactory) { + ReactiveRedisConnectionFactory redisConnectionFactoryToUse = springSessionRedisConnectionFactory + .getIfAvailable(); + if (redisConnectionFactoryToUse == null) { + redisConnectionFactoryToUse = redisConnectionFactory.getObject(); + } + this.redisConnectionFactory = redisConnectionFactoryToUse; + } + + @Autowired(required = false) + @Qualifier("springSessionDefaultRedisSerializer") + public void setDefaultRedisSerializer(RedisSerializer defaultRedisSerializer) { + this.defaultRedisSerializer = defaultRedisSerializer; + } + + @Autowired(required = false) + public void setSessionRepositoryCustomizer( + ObjectProvider> sessionRepositoryCustomizers) { + this.sessionRepositoryCustomizers = sessionRepositoryCustomizers.orderedStream().collect(Collectors.toList()); + } + + protected List> getSessionRepositoryCustomizers() { + return this.sessionRepositoryCustomizers; + } + + protected ReactiveRedisTemplate createReactiveRedisTemplate() { + RedisSerializer keySerializer = RedisSerializer.string(); + RedisSerializer defaultSerializer = (this.defaultRedisSerializer != null) ? this.defaultRedisSerializer + : new JdkSerializationRedisSerializer(); + RedisSerializationContext serializationContext = RedisSerializationContext + .newSerializationContext(defaultSerializer) + .key(keySerializer) + .hashKey(keySerializer) + .build(); + return new ReactiveRedisTemplate<>(this.redisConnectionFactory, serializationContext); + } + + public ReactiveRedisConnectionFactory getRedisConnectionFactory() { + return this.redisConnectionFactory; + } + + @Autowired(required = false) + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + this.sessionIdGenerator = sessionIdGenerator; + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/EnableRedisIndexedWebSession.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/EnableRedisIndexedWebSession.java new file mode 100644 index 000000000..23d13781a --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/EnableRedisIndexedWebSession.java @@ -0,0 +1,89 @@ +/* + * Copyright 2014-2023 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.session.data.redis.config.annotation.web.server; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.session.MapSession; +import org.springframework.session.SaveMode; +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository; +import org.springframework.web.server.session.WebSessionManager; + +/** + * Add this annotation to an {@link org.springframework.context.annotation.Configuration} + * class to expose the {@link WebSessionManager} as a bean named {@code webSessionManager} + * and backed by Reactive Redis. In order to leverage the annotation, a single + * {@link ReactiveRedisConnectionFactory} must be provided. For example: + * + *
+ * @Configuration(proxyBeanMethods = false)
+ * @EnableRedisIndexedWebSession
+ * public class RedisIndexedWebSessionConfig {
+ *
+ *     @Bean
+ *     public LettuceConnectionFactory redisConnectionFactory() {
+ *         return new LettuceConnectionFactory();
+ *     }
+ *
+ * }
+ * 
+ * + * More advanced configurations can extend {@link RedisIndexedWebSessionConfiguration} + * instead. + * + * @author Marcus da Coregio + * @since 3.3 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented +@Import(RedisIndexedWebSessionConfiguration.class) +public @interface EnableRedisIndexedWebSession { + + /** + * The session timeout in seconds. By default, it is set to 1800 seconds (30 minutes). + * A negative number means permanently valid. + * @return the seconds a session can be inactive before expiring + */ + int maxInactiveIntervalInSeconds() default MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS; + + /** + * Defines a unique namespace for keys. The value is used to isolate sessions by + * changing the prefix from default {@code spring:session:} to + * {@code :}. + *

+ * For example, if you had an application named "Application A" that needed to keep + * the sessions isolated from "Application B" you could set two different values for + * the applications and they could function within the same Redis instance. + * @return the unique namespace for keys + */ + String redisNamespace() default ReactiveRedisIndexedSessionRepository.DEFAULT_NAMESPACE; + + /** + * Save mode for the session. The default is {@link SaveMode#ON_SET_ATTRIBUTE}, which + * only saves changes made to session. + * @return the save mode + */ + SaveMode saveMode() default SaveMode.ON_SET_ATTRIBUTE; + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/RedisIndexedWebSessionConfiguration.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/RedisIndexedWebSessionConfiguration.java new file mode 100644 index 000000000..a706fc460 --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/RedisIndexedWebSessionConfiguration.java @@ -0,0 +1,205 @@ +/* + * 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 org.springframework.session.data.redis.config.annotation.web.server; + +import java.time.Duration; +import java.util.Map; + +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ImportAware; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.data.redis.connection.ReactiveRedisConnection; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.session.IndexResolver; +import org.springframework.session.Session; +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository; +import org.springframework.session.data.redis.config.ConfigureReactiveRedisAction; +import org.springframework.session.data.redis.config.annotation.ConfigureNotifyKeyspaceEventsReactiveAction; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; +import org.springframework.util.StringValueResolver; +import org.springframework.web.server.session.WebSessionManager; + +/** + * Exposes the {@link WebSessionManager} as a bean named {@code webSessionManager} backed + * by {@link ReactiveRedisIndexedSessionRepository}. In order to use this a single + * {@link ReactiveRedisConnectionFactory} must be exposed as a Bean. + * + * @author Marcus da Coregio + * @since 3.3 + * @see EnableRedisIndexedWebSession + */ +@Configuration(proxyBeanMethods = false) +public class RedisIndexedWebSessionConfiguration + extends AbstractRedisWebSessionConfiguration + implements EmbeddedValueResolverAware, ImportAware { + + private static final boolean lettucePresent; + + private static final boolean jedisPresent; + + private ConfigureReactiveRedisAction configureRedisAction = new ConfigureNotifyKeyspaceEventsReactiveAction(); + + private StringValueResolver embeddedValueResolver; + + private ApplicationEventPublisher eventPublisher; + + private IndexResolver indexResolver; + + static { + ClassLoader classLoader = RedisIndexedWebSessionConfiguration.class.getClassLoader(); + lettucePresent = ClassUtils.isPresent("io.lettuce.core.RedisClient", classLoader); + jedisPresent = ClassUtils.isPresent("redis.clients.jedis.Jedis", classLoader); + } + + @Override + @Bean + public ReactiveRedisIndexedSessionRepository sessionRepository() { + ReactiveRedisTemplate reactiveRedisTemplate = createReactiveRedisTemplate(); + ReactiveRedisIndexedSessionRepository sessionRepository = new ReactiveRedisIndexedSessionRepository( + reactiveRedisTemplate, createReactiveStringRedisTemplate()); + sessionRepository.setDefaultMaxInactiveInterval(getMaxInactiveInterval()); + sessionRepository.setEventPublisher(this.eventPublisher); + if (this.indexResolver != null) { + sessionRepository.setIndexResolver(this.indexResolver); + } + if (StringUtils.hasText(getRedisNamespace())) { + sessionRepository.setRedisKeyNamespace(getRedisNamespace()); + } + int database = resolveDatabase(); + sessionRepository.setDatabase(database); + sessionRepository.setSaveMode(getSaveMode()); + sessionRepository.setSessionIdGenerator(getSessionIdGenerator()); + if (getSessionRepositoryCustomizers() != null) { + getSessionRepositoryCustomizers().forEach((customizer) -> customizer.customize(sessionRepository)); + } + return sessionRepository; + } + + private ReactiveStringRedisTemplate createReactiveStringRedisTemplate() { + return new ReactiveStringRedisTemplate(getRedisConnectionFactory()); + } + + @Bean + public InitializingBean enableRedisKeyspaceNotificationsInitializer() { + return new EnableRedisKeyspaceNotificationsInitializer(getRedisConnectionFactory(), this.configureRedisAction); + } + + @Autowired + public void setEventPublisher(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + /** + * Sets the action to perform for configuring Redis. + * @param configureRedisAction the configuration to apply to Redis. The default is + * {@link ConfigureNotifyKeyspaceEventsReactiveAction} + */ + @Autowired(required = false) + public void setConfigureRedisAction(ConfigureReactiveRedisAction configureRedisAction) { + this.configureRedisAction = configureRedisAction; + } + + @Autowired(required = false) + public void setIndexResolver(IndexResolver indexResolver) { + this.indexResolver = indexResolver; + } + + @Override + public void setEmbeddedValueResolver(StringValueResolver resolver) { + this.embeddedValueResolver = resolver; + } + + @Override + public void setImportMetadata(AnnotationMetadata importMetadata) { + Map attributeMap = importMetadata + .getAnnotationAttributes(EnableRedisIndexedWebSession.class.getName()); + AnnotationAttributes attributes = AnnotationAttributes.fromMap(attributeMap); + if (attributes == null) { + return; + } + setMaxInactiveInterval(Duration.ofSeconds(attributes.getNumber("maxInactiveIntervalInSeconds"))); + String redisNamespaceValue = attributes.getString("redisNamespace"); + if (StringUtils.hasText(redisNamespaceValue)) { + setRedisNamespace(this.embeddedValueResolver.resolveStringValue(redisNamespaceValue)); + } + setSaveMode(attributes.getEnum("saveMode")); + } + + private int resolveDatabase() { + if (lettucePresent && getRedisConnectionFactory() instanceof LettuceConnectionFactory lettuce) { + return lettuce.getDatabase(); + } + if (jedisPresent && getRedisConnectionFactory() instanceof JedisConnectionFactory jedis) { + return jedis.getDatabase(); + } + return ReactiveRedisIndexedSessionRepository.DEFAULT_DATABASE; + } + + /** + * Ensures that Redis is configured to send keyspace notifications. This is important + * to ensure that expiration and deletion of sessions trigger SessionDestroyedEvents. + * Without the SessionDestroyedEvent resources may not get cleaned up properly. For + * example, the mapping of the Session to WebSocket connections may not get cleaned + * up. + */ + static class EnableRedisKeyspaceNotificationsInitializer implements InitializingBean { + + private final ReactiveRedisConnectionFactory connectionFactory; + + private final ConfigureReactiveRedisAction configure; + + EnableRedisKeyspaceNotificationsInitializer(ReactiveRedisConnectionFactory connectionFactory, + ConfigureReactiveRedisAction configure) { + this.connectionFactory = connectionFactory; + this.configure = configure; + } + + @Override + public void afterPropertiesSet() { + if (this.configure == ConfigureReactiveRedisAction.NO_OP) { + return; + } + ReactiveRedisConnection connection = this.connectionFactory.getReactiveConnection(); + try { + this.configure.configure(connection).block(); + } + finally { + try { + connection.close(); + } + catch (Exception ex) { + LogFactory.getLog(getClass()).error("Error closing RedisConnection", ex); + } + } + } + + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/RedisWebSessionConfiguration.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/RedisWebSessionConfiguration.java index 994a713fc..6984312c7 100644 --- a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/RedisWebSessionConfiguration.java +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/RedisWebSessionConfiguration.java @@ -39,6 +39,8 @@ import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.session.MapSession; import org.springframework.session.SaveMode; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.config.ReactiveSessionRepositoryCustomizer; import org.springframework.session.config.annotation.web.server.SpringWebSessionConfiguration; import org.springframework.session.data.redis.ReactiveRedisSessionRepository; @@ -77,6 +79,8 @@ public class RedisWebSessionConfiguration implements BeanClassLoaderAware, Embed private StringValueResolver embeddedValueResolver; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + @Bean public ReactiveRedisSessionRepository sessionRepository() { ReactiveRedisTemplate reactiveRedisTemplate = createReactiveRedisTemplate(); @@ -86,6 +90,7 @@ public ReactiveRedisSessionRepository sessionRepository() { sessionRepository.setRedisKeyNamespace(this.redisNamespace); } sessionRepository.setSaveMode(this.saveMode); + sessionRepository.setSessionIdGenerator(this.sessionIdGenerator); this.sessionRepositoryCustomizers .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(sessionRepository)); return sessionRepository; @@ -170,4 +175,9 @@ private ReactiveRedisTemplate createReactiveRedisTemplate() { return new ReactiveRedisTemplate<>(this.redisConnectionFactory, serializationContext); } + @Autowired(required = false) + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + this.sessionIdGenerator = sessionIdGenerator; + } + } diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/ReactiveRedisSessionIndexerTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/ReactiveRedisSessionIndexerTests.java new file mode 100644 index 000000000..14f70fc90 --- /dev/null +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/ReactiveRedisSessionIndexerTests.java @@ -0,0 +1,197 @@ +/* + * Copyright 2014-2023 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.session.data.redis; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.session.DelegatingIndexResolver; +import org.springframework.session.IndexResolver; +import org.springframework.session.PrincipalNameIndexResolver; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.Session; +import org.springframework.session.SingleIndexResolver; +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository.RedisSession; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link ReactiveRedisSessionIndexer}. + * + * @author Marcus da Coregio + */ +class ReactiveRedisSessionIndexerTests { + + ReactiveRedisSessionIndexer indexer; + + ReactiveRedisOperations sessionRedisOperations = mock(Answers.RETURNS_DEEP_STUBS); + + String indexKeyPrefix = "spring:session:sessions:index:"; + + @BeforeEach + void setup() { + this.indexer = new ReactiveRedisSessionIndexer(this.sessionRedisOperations, "spring:session:"); + } + + @Test + void getIndexesWhenIndexKeyExistsThenReturnsIndexNameAndValue() { + List indexKeys = List.of(this.indexKeyPrefix + "principalName:user", + this.indexKeyPrefix + "index_name:index_value"); + given(this.sessionRedisOperations.opsForSet().members(anyString())).willReturn(Flux.fromIterable(indexKeys)); + Map indexes = this.indexer.getIndexes("1234").block(); + assertThat(indexes).hasSize(2) + .containsEntry("principalName", "user") + .containsEntry("index_name", "index_value"); + } + + @Test + void deleteWhenSessionIdHasIndexesThenRemoveSessionIdFromIndexesAndDeleteSessionIndexKey() { + String index1 = this.indexKeyPrefix + "principalName:user"; + String index2 = this.indexKeyPrefix + "index_name:index_value"; + List indexKeys = List.of(index1, index2); + given(this.sessionRedisOperations.opsForSet().members(anyString())).willReturn(Flux.fromIterable(indexKeys)); + given(this.sessionRedisOperations.delete(anyString())).willReturn(Mono.just(1L)); + given(this.sessionRedisOperations.opsForSet().remove(anyString(), anyString())).willReturn(Mono.just(1L)); + this.indexer.delete("1234").block(); + verify(this.sessionRedisOperations).delete("spring:session:sessions:1234:idx"); + verify(this.sessionRedisOperations.opsForSet()).remove(index1, "1234"); + verify(this.sessionRedisOperations.opsForSet()).remove(index2, "1234"); + } + + @Test + void updateWhenSessionHasNoIndexesSavedThenUpdates() { + RedisSession session = mock(); + given(session.getAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME)) + .willReturn("user"); + given(session.getId()).willReturn("1234"); + given(this.sessionRedisOperations.opsForSet().members(anyString())).willReturn(Flux.empty()); + given(this.sessionRedisOperations.opsForSet().add(anyString(), anyString())).willReturn(Mono.just(1L)); + this.indexer.update(session).block(); + verify(this.sessionRedisOperations.opsForSet()).add(this.indexKeyPrefix + "PRINCIPAL_NAME_INDEX_NAME:user", + "1234"); + } + + @Test + void updateWhenSessionIndexesSavedWithSameValueThenDoesNotUpdate() { + String indexKey = this.indexKeyPrefix + ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME + + ":user"; + RedisSession session = mock(); + given(session.getAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME)) + .willReturn("user"); + given(session.getId()).willReturn("1234"); + given(this.sessionRedisOperations.opsForSet().members(anyString())) + .willReturn(Flux.fromIterable(List.of(indexKey))); + this.indexer.update(session).block(); + verify(this.sessionRedisOperations.opsForSet(), never()).add(anyString(), anyString()); + verify(this.sessionRedisOperations.opsForSet(), never()).remove(anyString(), anyString()); + } + + @Test + void updateWhenSessionIndexesSavedWithDifferentValueThenUpdates() { + String indexKey = this.indexKeyPrefix + ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME + + ":user"; + RedisSession session = mock(); + given(session.getAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME)) + .willReturn("newuser"); + given(session.getId()).willReturn("1234"); + given(this.sessionRedisOperations.opsForSet().members(anyString())) + .willReturn(Flux.fromIterable(List.of(indexKey))); + given(this.sessionRedisOperations.opsForSet().add(anyString(), anyString())).willReturn(Mono.just(1L)); + given(this.sessionRedisOperations.opsForSet().remove(anyString(), anyString())).willReturn(Mono.just(1L)); + this.indexer.update(session).block(); + verify(this.sessionRedisOperations.opsForSet()).add( + this.indexKeyPrefix + ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME + ":newuser", + "1234"); + verify(this.sessionRedisOperations.opsForSet()).remove(indexKey, "1234"); + } + + @Test + void updateWhenMultipleIndexResolvedThenUpdated() { + IndexResolver indexResolver = new DelegatingIndexResolver<>( + new PrincipalNameIndexResolver<>(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME), + new TestIndexResolver<>("test")); + this.indexer.setIndexResolver(indexResolver); + RedisSession session = mock(); + given(session.getAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME)) + .willReturn("user"); + given(session.getAttribute("test")).willReturn("testvalue"); + given(session.getId()).willReturn("1234"); + given(this.sessionRedisOperations.opsForSet().members(anyString())).willReturn(Flux.empty()); + given(this.sessionRedisOperations.opsForSet().add(anyString(), anyString())).willReturn(Mono.just(1L)); + this.indexer.update(session).block(); + verify(this.sessionRedisOperations.opsForSet()).add(this.indexKeyPrefix + "PRINCIPAL_NAME_INDEX_NAME:user", + "1234"); + verify(this.sessionRedisOperations.opsForSet()).add(this.indexKeyPrefix + "test:testvalue", "1234"); + } + + @Test + void setNamespaceShouldUpdateIndexKeyPrefix() { + String originalPrefix = (String) ReflectionTestUtils.getField(this.indexer, "indexKeyPrefix"); + this.indexer.setNamespace("my:namespace:"); + String updatedPrefix = (String) ReflectionTestUtils.getField(this.indexer, "indexKeyPrefix"); + assertThat(originalPrefix).isEqualTo(this.indexKeyPrefix); + assertThat(updatedPrefix).isEqualTo("my:namespace:sessions:index:"); + } + + @Test + void constructorWhenSessionRedisOperationsNullThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> new ReactiveRedisSessionIndexer(null, "spring:session:")) + .withMessage("sessionRedisOperations cannot be null"); + } + + @Test + void constructorWhenNamespaceNullThenException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new ReactiveRedisSessionIndexer(this.sessionRedisOperations, null)) + .withMessage("namespace cannot be empty"); + } + + @Test + void constructorWhenNamespaceEmptyThenException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new ReactiveRedisSessionIndexer(this.sessionRedisOperations, "")) + .withMessage("namespace cannot be empty"); + } + + static class TestIndexResolver extends SingleIndexResolver { + + protected TestIndexResolver(String indexName) { + super(indexName); + } + + @Override + public String resolveIndexValueFor(S session) { + return session.getAttribute(getIndexName()); + } + + } + +} diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/ReactiveRedisSessionRepositoryTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/ReactiveRedisSessionRepositoryTests.java index 3206f09bc..7d6737fb7 100644 --- a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/ReactiveRedisSessionRepositoryTests.java +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/ReactiveRedisSessionRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -438,6 +438,46 @@ void saveWithSaveModeAlways() { verifyNoMoreInteractions(this.hashOperations); } + @Test + void createSessionWhenSessionIdGeneratorThenUses() { + this.repository.setSessionIdGenerator(() -> "test"); + + this.repository.createSession().as(StepVerifier::create).assertNext((redisSession) -> { + assertThat(redisSession.getId()).isEqualTo("test"); + assertThat(redisSession.changeSessionId()).isEqualTo("test"); + }).verifyComplete(); + } + + @Test + void setSessionIdGeneratorWhenNullThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setSessionIdGenerator(null)) + .withMessage("sessionIdGenerator cannot be null"); + } + + @Test + @SuppressWarnings("unchecked") + void findByIdWhenChangeSessionIdThenUsesSessionIdGenerator() { + this.repository.setSessionIdGenerator(() -> "changed-session-id"); + given(this.redisOperations.opsForHash()).willReturn(this.hashOperations); + String attribute1 = "attribute1"; + String attribute2 = "attribute2"; + MapSession expected = new MapSession("test"); + expected.setLastAccessedTime(Instant.now().minusSeconds(60)); + expected.setAttribute(attribute1, "test"); + expected.setAttribute(attribute2, null); + Map map = map(RedisSessionMapper.ATTRIBUTE_PREFIX + attribute1, expected.getAttribute(attribute1), + RedisSessionMapper.ATTRIBUTE_PREFIX + attribute2, expected.getAttribute(attribute2), + RedisSessionMapper.CREATION_TIME_KEY, expected.getCreationTime().toEpochMilli(), + RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, (int) expected.getMaxInactiveInterval().getSeconds(), + RedisSessionMapper.LAST_ACCESSED_TIME_KEY, expected.getLastAccessedTime().toEpochMilli()); + given(this.hashOperations.entries(anyString())).willReturn(Flux.fromIterable(map.entrySet())); + + StepVerifier.create(this.repository.findById("test")).consumeNextWith((session) -> { + assertThat(session.getId()).isEqualTo(expected.getId()); + assertThat(session.changeSessionId()).isEqualTo("changed-session-id"); + }).verifyComplete(); + } + private Map map(Object... objects) { Map result = new HashMap<>(); if (objects == null) { diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryTests.java index d0c9bafc3..f1bc11cbb 100644 --- a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryTests.java +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -463,12 +463,12 @@ void onMessageCreated() { MapSession session = this.cached; byte[] pattern = "".getBytes(StandardCharsets.UTF_8); String channel = "spring:session:event:0:created:" + session.getId(); - JdkSerializationRedisSerializer defaultSerailizer = new JdkSerializationRedisSerializer(); - this.redisRepository.setDefaultSerializer(defaultSerailizer); + JdkSerializationRedisSerializer defaultSerializer = new JdkSerializationRedisSerializer(); + this.redisRepository.setDefaultSerializer(defaultSerializer); Map map = map(RedisSessionMapper.CREATION_TIME_KEY, Instant.EPOCH.toEpochMilli(), RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 0, RedisSessionMapper.LAST_ACCESSED_TIME_KEY, System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5)); - byte[] body = defaultSerailizer.serialize(map); + byte[] body = defaultSerializer.serialize(map); DefaultMessage message = new DefaultMessage(channel.getBytes(StandardCharsets.UTF_8), body); this.redisRepository.setApplicationEventPublisher(this.publisher); @@ -504,11 +504,16 @@ void onMessageDeletedSessionFound() { String deletedId = "deleted-id"; given(this.redisOperations.boundHashOps(getKey(deletedId))) .willReturn(this.boundHashOperations); + long lastAccessedTimeMillis = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5); Map map = map(RedisSessionMapper.CREATION_TIME_KEY, Instant.EPOCH.toEpochMilli(), RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 0, RedisSessionMapper.LAST_ACCESSED_TIME_KEY, - System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5)); + lastAccessedTimeMillis); given(this.boundHashOperations.entries()).willReturn(map); + String backgroundExpireKey = "spring:session:expirations:" + + RedisSessionExpirationPolicy.roundUpToNextMinute(lastAccessedTimeMillis); + given(this.redisOperations.boundSetOps(backgroundExpireKey)).willReturn(this.boundSetOperations); + String channel = "__keyevent@0__:del"; String body = "spring:session:sessions:expires:" + deletedId; DefaultMessage message = new DefaultMessage(channel.getBytes(StandardCharsets.UTF_8), @@ -517,8 +522,8 @@ void onMessageDeletedSessionFound() { this.redisRepository.setApplicationEventPublisher(this.publisher); this.redisRepository.onMessage(message, "".getBytes(StandardCharsets.UTF_8)); - verify(this.redisOperations).boundHashOps(eq(getKey(deletedId))); - verify(this.boundHashOperations).entries(); + verify(this.redisOperations, times(2)).boundHashOps(eq(getKey(deletedId))); + verify(this.boundHashOperations, times(2)).entries(); verify(this.publisher).publishEvent(this.event.capture()); assertThat(this.event.getValue().getSessionId()).isEqualTo(deletedId); verifyNoMoreInteractions(this.defaultSerializer); @@ -555,11 +560,16 @@ void onMessageExpiredSessionFound() { String expiredId = "expired-id"; given(this.redisOperations.boundHashOps(getKey(expiredId))) .willReturn(this.boundHashOperations); + long lastAccessedTimeMillis = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5); Map map = map(RedisSessionMapper.CREATION_TIME_KEY, Instant.EPOCH.toEpochMilli(), RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 1, RedisSessionMapper.LAST_ACCESSED_TIME_KEY, - System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5)); + lastAccessedTimeMillis); given(this.boundHashOperations.entries()).willReturn(map); + String backgroundExpireKey = "spring:session:expirations:" + + RedisSessionExpirationPolicy.roundUpToNextMinute(lastAccessedTimeMillis + 1000); + given(this.redisOperations.boundSetOps(backgroundExpireKey)).willReturn(this.boundSetOperations); + String channel = "__keyevent@0__:expired"; String body = "spring:session:sessions:expires:" + expiredId; DefaultMessage message = new DefaultMessage(channel.getBytes(StandardCharsets.UTF_8), @@ -568,8 +578,8 @@ void onMessageExpiredSessionFound() { this.redisRepository.setApplicationEventPublisher(this.publisher); this.redisRepository.onMessage(message, "".getBytes(StandardCharsets.UTF_8)); - verify(this.redisOperations).boundHashOps(eq(getKey(expiredId))); - verify(this.boundHashOperations).entries(); + verify(this.redisOperations, times(2)).boundHashOps(eq(getKey(expiredId))); + verify(this.boundHashOperations, times(2)).entries(); verify(this.publisher).publishEvent(this.event.capture()); assertThat(this.event.getValue().getSessionId()).isEqualTo(expiredId); verifyNoMoreInteractions(this.defaultSerializer); @@ -910,6 +920,46 @@ void saveWithSaveModeAlways() { assertThat(getDelta()).hasSize(3); } + @Test + void createSessionWhenSessionIdGeneratorThenUses() { + this.redisRepository.setSessionIdGenerator(() -> "test"); + RedisSession session = this.redisRepository.createSession(); + assertThat(session.getId()).isEqualTo("test"); + assertThat(session.changeSessionId()).isEqualTo("test"); + } + + @Test + void setSessionIdGeneratorWhenNullThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.redisRepository.setSessionIdGenerator(null)) + .withMessage("sessionIdGenerator cannot be null"); + } + + @Test + void findByIdWhenChangeSessionIdThenUsesSessionIdGenerator() { + this.redisRepository.setSessionIdGenerator(() -> "test"); + String attribute1 = "attribute1"; + String attribute2 = "attribute2"; + MapSession expected = new MapSession("original"); + expected.setLastAccessedTime(Instant.now().minusSeconds(60)); + expected.setAttribute(attribute1, "test"); + expected.setAttribute(attribute2, null); + given(this.redisOperations.boundHashOps(getKey(expected.getId()))) + .willReturn(this.boundHashOperations); + Map map = map(RedisIndexedSessionRepository.getSessionAttrNameKey(attribute1), + expected.getAttribute(attribute1), RedisIndexedSessionRepository.getSessionAttrNameKey(attribute2), + expected.getAttribute(attribute2), RedisSessionMapper.CREATION_TIME_KEY, + expected.getCreationTime().toEpochMilli(), RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, + (int) expected.getMaxInactiveInterval().getSeconds(), RedisSessionMapper.LAST_ACCESSED_TIME_KEY, + expected.getLastAccessedTime().toEpochMilli()); + given(this.boundHashOperations.entries()).willReturn(map); + + RedisSession session = this.redisRepository.findById(expected.getId()); + String oldSessionId = session.getId(); + String newSessionId = session.changeSessionId(); + assertThat(oldSessionId).isEqualTo("original"); + assertThat(newSessionId).isEqualTo("test"); + } + private String getKey(String id) { return "spring:session:sessions:" + id; } diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionExpirationPolicyTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionExpirationPolicyTests.java index 7d5da8fa3..989c067c8 100644 --- a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionExpirationPolicyTests.java +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionExpirationPolicyTests.java @@ -49,7 +49,7 @@ class RedisSessionExpirationPolicyTests { // Wed Apr 15 10:27:32 CDT 2015 private static final Long ONE_MINUTE_AGO = 1429111652346L; - @Mock(lenient = true) + @Mock(strictness = Mock.Strictness.LENIENT) RedisOperations sessionRedisOperations; @Mock diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionMapperTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionMapperTests.java index 696bf17ca..74b37527a 100644 --- a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionMapperTests.java +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionMapperTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2019 the original author or authors. + * Copyright 2014-2023 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. @@ -22,7 +22,6 @@ import java.util.HashMap; import java.util.Map; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.session.MapSession; @@ -38,34 +37,29 @@ */ class RedisSessionMapperTests { - private RedisSessionMapper mapper; - - @BeforeEach - void setUp() { - this.mapper = new RedisSessionMapper("id"); - } + private RedisSessionMapper mapper = new RedisSessionMapper(); @Test - void constructor_NullId_ShouldThrowException() { - assertThatIllegalArgumentException().isThrownBy(() -> new RedisSessionMapper(null)) + void apply_NullId_ShouldThrowException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.mapper.apply(null, Collections.emptyMap())) .withMessage("sessionId must not be empty"); } @Test - void constructor_EmptyId_ShouldThrowException() { - assertThatIllegalArgumentException().isThrownBy(() -> new RedisSessionMapper(" ")) + void apply_EmptyId_ShouldThrowException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.mapper.apply(" ", Collections.emptyMap())) .withMessage("sessionId must not be empty"); } @Test void apply_NullMap_ShouldThrowException() { - assertThatIllegalArgumentException().isThrownBy(() -> this.mapper.apply(null)) + assertThatIllegalArgumentException().isThrownBy(() -> this.mapper.apply("1234", null)) .withMessage("map must not be empty"); } @Test void apply_EmptyMap_ShouldThrowException() { - assertThatIllegalArgumentException().isThrownBy(() -> this.mapper.apply(Collections.emptyMap())) + assertThatIllegalArgumentException().isThrownBy(() -> this.mapper.apply("1234", Collections.emptyMap())) .withMessage("map must not be empty"); } @@ -74,7 +68,7 @@ void apply_MapWithoutCreationTime_ShouldThrowException() { Map sessionMap = new HashMap<>(); sessionMap.put(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, 0L); sessionMap.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 1800); - assertThatIllegalStateException().isThrownBy(() -> this.mapper.apply(sessionMap)) + assertThatIllegalStateException().isThrownBy(() -> this.mapper.apply("id", sessionMap)) .withMessage(RedisSessionMapper.CREATION_TIME_KEY + " key must not be null"); } @@ -83,7 +77,7 @@ void apply_MapWithoutLastAccessedTime_ShouldThrowException() { Map sessionMap = new HashMap<>(); sessionMap.put(RedisSessionMapper.CREATION_TIME_KEY, 0L); sessionMap.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 1800); - assertThatIllegalStateException().isThrownBy(() -> this.mapper.apply(sessionMap)) + assertThatIllegalStateException().isThrownBy(() -> this.mapper.apply("id", sessionMap)) .withMessage(RedisSessionMapper.LAST_ACCESSED_TIME_KEY + " key must not be null"); } @@ -92,7 +86,7 @@ void apply_MapWithoutMaxInactiveInterval_ShouldThrowException() { Map sessionMap = new HashMap<>(); sessionMap.put(RedisSessionMapper.CREATION_TIME_KEY, 0L); sessionMap.put(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, 0L); - assertThatIllegalStateException().isThrownBy(() -> this.mapper.apply(sessionMap)) + assertThatIllegalStateException().isThrownBy(() -> this.mapper.apply("id", sessionMap)) .withMessage(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY + " key must not be null"); } @@ -104,7 +98,7 @@ void apply_ValidMap_ShouldReturnSession() { sessionMap.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 1800); sessionMap.put(RedisSessionMapper.ATTRIBUTE_PREFIX + "existing", "value"); sessionMap.put(RedisSessionMapper.ATTRIBUTE_PREFIX + "missing", null); - MapSession session = this.mapper.apply(sessionMap); + MapSession session = this.mapper.apply("id", sessionMap); assertThat(session.getId()).isEqualTo("id"); assertThat(session.getCreationTime()).isEqualTo(Instant.ofEpochMilli(0)); assertThat(session.getLastAccessedTime()).isEqualTo(Instant.ofEpochMilli(0)); diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionRepositoryTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionRepositoryTests.java index f5989920e..759c6a0c4 100644 --- a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionRepositoryTests.java +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisSessionRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -59,7 +59,7 @@ class RedisSessionRepositoryTests { private static final String TEST_SESSION_KEY = getSessionKey(TEST_SESSION_ID); - @Mock(lenient = true) + @Mock(strictness = Mock.Strictness.LENIENT) private RedisOperations sessionRedisOperations; @Mock @@ -238,7 +238,7 @@ void save_SessionExistsAndNoChanges_ShouldSaveSession() { } @Test - void save_WithSaveModeOnSetAttribute_SholdSaveSession() { + void save_WithSaveModeOnSetAttribute_ShouldSaveSession() { given(this.sessionRedisOperations.hasKey(eq(TEST_SESSION_KEY))).willReturn(true); this.sessionRepository.setSaveMode(SaveMode.ON_SET_ATTRIBUTE); Map attributes = new HashMap<>(); @@ -311,7 +311,6 @@ void save_SessionNotExists_ShouldThrowException() { } @Test - @SuppressWarnings("unchecked") void findById_SessionExists_ShouldReturnSession() { Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); given(this.sessionHashOperations.entries(eq(TEST_SESSION_KEY))) @@ -334,7 +333,6 @@ void findById_SessionExists_ShouldReturnSession() { } @Test - @SuppressWarnings("unchecked") void findById_SessionExistsAndIsExpired_ShouldReturnNull() { given(this.sessionHashOperations.entries(eq(TEST_SESSION_KEY))) .willReturn(mapOf(RedisSessionMapper.CREATION_TIME_KEY, Instant.EPOCH.toEpochMilli(), @@ -373,6 +371,34 @@ void getSessionRedisOperations__ShouldReturnRedisOperations() { verifyNoMoreInteractions(this.sessionHashOperations); } + @Test + void createSessionWhenSessionIdGeneratorThenUses() { + this.sessionRepository.setSessionIdGenerator(() -> "test"); + RedisSessionRepository.RedisSession session = this.sessionRepository.createSession(); + assertThat(session.getId()).isEqualTo("test"); + assertThat(session.changeSessionId()).isEqualTo("test"); + } + + @Test + void setSessionIdGeneratorWhenNullThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.sessionRepository.setSessionIdGenerator(null)) + .withMessage("sessionIdGenerator cannot be null"); + } + + @Test + void findByIdWhenChangeSessionIdThenUsesSessionIdGenerator() { + this.sessionRepository.setSessionIdGenerator(() -> "test"); + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + given(this.sessionHashOperations.entries(eq(TEST_SESSION_KEY))) + .willReturn(mapOf(RedisSessionMapper.CREATION_TIME_KEY, Instant.EPOCH.toEpochMilli(), + RedisSessionMapper.LAST_ACCESSED_TIME_KEY, now.toEpochMilli(), + RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS, + RedisSessionMapper.ATTRIBUTE_PREFIX + "attribute1", "value1")); + RedisSession session = this.sessionRepository.findById(TEST_SESSION_ID); + assertThat(session.getId()).isEqualTo(TEST_SESSION_ID); + assertThat(session.changeSessionId()).isEqualTo("test"); + } + private static String getSessionKey(String sessionId) { return "spring:session:sessions:" + sessionId; } @@ -382,7 +408,7 @@ private static Instant getExpiry(RedisSession session) { .plusSeconds(session.getMaxInactiveInterval().getSeconds()); } - private static Map mapOf(Object... objects) { + private static Map mapOf(Object... objects) { Map result = new HashMap<>(); if (objects != null) { for (int i = 0; i < objects.length; i += 2) { diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SortedSetReactiveRedisSessionExpirationStoreTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SortedSetReactiveRedisSessionExpirationStoreTests.java new file mode 100644 index 000000000..58dbc0300 --- /dev/null +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SortedSetReactiveRedisSessionExpirationStoreTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2014-2023 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.session.data.redis; + +import java.time.Instant; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.data.domain.Range; +import org.springframework.data.redis.connection.Limit; +import org.springframework.data.redis.core.ReactiveRedisOperations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@SuppressWarnings("unchecked") +class SortedSetReactiveRedisSessionExpirationStoreTests { + + SortedSetReactiveRedisSessionExpirationStore store; + + ReactiveRedisOperations sessionRedisOperations = mock(Answers.RETURNS_DEEP_STUBS); + + String namespace = "spring:session:"; + + @BeforeEach + void setup() { + this.store = new SortedSetReactiveRedisSessionExpirationStore(this.sessionRedisOperations, this.namespace); + given(this.sessionRedisOperations.opsForZSet().add(anyString(), anyString(), anyDouble())) + .willReturn(Mono.empty()); + given(this.sessionRedisOperations.opsForZSet().remove(anyString(), anyString())).willReturn(Mono.empty()); + given(this.sessionRedisOperations.opsForZSet() + .reverseRangeByScore(anyString(), any(Range.class), any(Limit.class))).willReturn(Flux.empty()); + } + + @Test + void addThenStoresSessionIdRankedByExpireAtEpochMilli() { + String sessionId = "1234"; + Instant expireAt = Instant.ofEpochMilli(1702314490000L); + StepVerifier.create(this.store.add(sessionId, expireAt)).verifyComplete(); + verify(this.sessionRedisOperations.opsForZSet()).add(this.namespace + "sessions:expirations", sessionId, + expireAt.toEpochMilli()); + } + + @Test + void removeThenRemovesSessionIdFromSortedSet() { + String sessionId = "1234"; + StepVerifier.create(this.store.remove(sessionId)).verifyComplete(); + verify(this.sessionRedisOperations.opsForZSet()).remove(this.namespace + "sessions:expirations", sessionId); + } + + @Test + void retrieveExpiredSessionsThenUsesExpectedRangeAndLimit() { + Instant now = Instant.now(); + StepVerifier.create(this.store.retrieveExpiredSessions(now)).verifyComplete(); + ArgumentCaptor> rangeCaptor = ArgumentCaptor.forClass(Range.class); + ArgumentCaptor limitCaptor = ArgumentCaptor.forClass(Limit.class); + verify(this.sessionRedisOperations.opsForZSet()).reverseRangeByScore( + eq(this.namespace + "sessions:expirations"), rangeCaptor.capture(), limitCaptor.capture()); + assertThat(rangeCaptor.getValue().getLowerBound().getValue()).hasValue(0D); + assertThat(rangeCaptor.getValue().getUpperBound().getValue()).hasValue((double) now.toEpochMilli()); + assertThat(limitCaptor.getValue().getCount()).isEqualTo(100); + assertThat(limitCaptor.getValue().getOffset()).isEqualTo(0); + } + +} diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStoreTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStoreTests.java new file mode 100644 index 000000000..f276bdfb3 --- /dev/null +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStoreTests.java @@ -0,0 +1,86 @@ +/* + * Copyright 2014-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.session.data.redis; + +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; + +import org.springframework.data.redis.core.RedisTemplate; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link SortedSetRedisSessionExpirationStore} + * + * @author Marcus da Coregio + */ +class SortedSetRedisSessionExpirationStoreTests { + + private SortedSetRedisSessionExpirationStore expirationStore; + + private final RedisTemplate redisTemplate = mock(Answers.RETURNS_DEEP_STUBS); + + @BeforeEach + void setup() { + this.expirationStore = new SortedSetRedisSessionExpirationStore(this.redisTemplate, + RedisIndexedSessionRepository.DEFAULT_NAMESPACE); + } + + @Test + void setNamespaceWhenNullOrEmptyThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.expirationStore.setNamespace(null)) + .withMessage("namespace cannot be null or empty"); + assertThatIllegalArgumentException().isThrownBy(() -> this.expirationStore.setNamespace("")) + .withMessage("namespace cannot be null or empty"); + } + + @Test + void setClockWhenNullThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.expirationStore.setClock(null)) + .withMessage("clock cannot be null"); + } + + @Test + void setCleanupCountWhenZeroOrNegativeThenException() { + assertThatIllegalStateException().isThrownBy(() -> this.expirationStore.setCleanupCount(0)) + .withMessage("cleanupCount must be greater than 0"); + assertThatIllegalStateException().isThrownBy(() -> this.expirationStore.setCleanupCount(-1)) + .withMessage("cleanupCount must be greater than 0"); + } + + @Test + void cleanupExpiredSessionsThenTouchExpiredSessions() { + given(this.redisTemplate.opsForZSet() + .reverseRangeByScore(anyString(), anyDouble(), anyDouble(), anyLong(), anyLong())) + .willReturn(Set.of("1", "2", "3")); + this.expirationStore.cleanupExpiredSessions(); + verify(this.redisTemplate).hasKey("spring:session:sessions:1"); + verify(this.redisTemplate).hasKey("spring:session:sessions:2"); + verify(this.redisTemplate).hasKey("spring:session:sessions:3"); + } + +} diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfigurationClassPathXmlApplicationContextTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfigurationClassPathXmlApplicationContextTests.java index 8baeb5153..103621b60 100644 --- a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfigurationClassPathXmlApplicationContextTests.java +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfigurationClassPathXmlApplicationContextTests.java @@ -41,7 +41,7 @@ */ @ExtendWith(SpringExtension.class) @ContextConfiguration -public class RedisHttpSessionConfigurationClassPathXmlApplicationContextTests { +class RedisHttpSessionConfigurationClassPathXmlApplicationContextTests { // gh-318 @Test diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfigurationXmlCustomExpireTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfigurationXmlCustomExpireTests.java index 464be4e70..69505edc5 100644 --- a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfigurationXmlCustomExpireTests.java +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfigurationXmlCustomExpireTests.java @@ -38,7 +38,7 @@ @ExtendWith(SpringExtension.class) @ContextConfiguration @WebAppConfiguration -public class RedisHttpSessionConfigurationXmlCustomExpireTests { +class RedisHttpSessionConfigurationXmlCustomExpireTests { @Test void contextLoads() { diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfigurationXmlTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfigurationXmlTests.java index 9454b5ec6..ff619a548 100644 --- a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfigurationXmlTests.java +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfigurationXmlTests.java @@ -38,7 +38,7 @@ @ExtendWith(SpringExtension.class) @ContextConfiguration @WebAppConfiguration -public class RedisHttpSessionConfigurationXmlTests { +class RedisHttpSessionConfigurationXmlTests { @Test void contextLoads() { diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpsSessionConfigurationTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpsSessionConfigurationTests.java index a81921638..17f9f8033 100644 --- a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpsSessionConfigurationTests.java +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpsSessionConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -39,6 +39,8 @@ import org.springframework.mock.env.MockEnvironment; import org.springframework.session.FlushMode; import org.springframework.session.SaveMode; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.config.SessionRepositoryCustomizer; import org.springframework.session.data.redis.RedisSessionRepository; import org.springframework.session.data.redis.config.annotation.SpringSessionRedisConnectionFactory; @@ -207,6 +209,20 @@ void importConfigAndCustomize() { assertThat(sessionRepository).extracting("defaultMaxInactiveInterval").isEqualTo(Duration.ZERO); } + @Test + void registerWhenSessionIdGeneratorBeanThenUses() { + registerAndRefresh(RedisConfig.class, SessionIdGeneratorConfiguration.class); + RedisSessionRepository sessionRepository = this.context.getBean(RedisSessionRepository.class); + assertThat(sessionRepository).extracting("sessionIdGenerator").isInstanceOf(TestSessionIdGenerator.class); + } + + @Test + void registerWhenNoSessionIdGeneratorBeanThenDefault() { + registerAndRefresh(RedisConfig.class, DefaultConfiguration.class); + RedisSessionRepository sessionRepository = this.context.getBean(RedisSessionRepository.class); + assertThat(sessionRepository).extracting("sessionIdGenerator").isInstanceOf(UuidSessionIdGenerator.class); + } + private void registerAndRefresh(Class... annotatedClasses) { this.context.register(annotatedClasses); this.context.refresh(); @@ -382,4 +398,30 @@ SessionRepositoryCustomizer sessionRepositoryCustomizer( } + @Configuration(proxyBeanMethods = false) + @EnableRedisHttpSession + static class SessionIdGeneratorConfiguration { + + @Bean + SessionIdGenerator sessionIdGenerator() { + return new TestSessionIdGenerator(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableRedisHttpSession + static class DefaultConfiguration { + + } + + static class TestSessionIdGenerator implements SessionIdGenerator { + + @Override + public String generate() { + return "test"; + } + + } + } diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfigurationMockTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfigurationMockTests.java index 7e2ca4df5..7794120f9 100644 --- a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfigurationMockTests.java +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfigurationMockTests.java @@ -37,7 +37,7 @@ @ExtendWith(MockitoExtension.class) class RedisIndexedHttpSessionConfigurationMockTests { - @Mock(lenient = true) + @Mock(strictness = Mock.Strictness.LENIENT) RedisConnectionFactory factory; @Mock diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfigurationTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfigurationTests.java index 68ee4c6fe..462df49fe 100644 --- a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfigurationTests.java +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -43,6 +43,8 @@ import org.springframework.session.IndexResolver; import org.springframework.session.SaveMode; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.config.SessionRepositoryCustomizer; import org.springframework.session.data.redis.RedisIndexedSessionRepository; import org.springframework.session.data.redis.config.annotation.SpringSessionRedisConnectionFactory; @@ -241,6 +243,20 @@ void importConfigAndCustomize() { assertThat(sessionRepository).extracting("defaultMaxInactiveInterval").isEqualTo(Duration.ZERO); } + @Test + void registerWhenSessionIdGeneratorBeanThenUses() { + registerAndRefresh(RedisConfig.class, SessionIdGeneratorConfiguration.class); + RedisIndexedSessionRepository sessionRepository = this.context.getBean(RedisIndexedSessionRepository.class); + assertThat(sessionRepository).extracting("sessionIdGenerator").isInstanceOf(TestSessionIdGenerator.class); + } + + @Test + void registerWhenNoSessionIdGeneratorBeanThenDefault() { + registerAndRefresh(RedisConfig.class, DefaultConfiguration.class); + RedisIndexedSessionRepository sessionRepository = this.context.getBean(RedisIndexedSessionRepository.class); + assertThat(sessionRepository).extracting("sessionIdGenerator").isInstanceOf(UuidSessionIdGenerator.class); + } + private void registerAndRefresh(Class... annotatedClasses) { this.context.register(annotatedClasses); this.context.refresh(); @@ -445,4 +461,30 @@ SessionRepositoryCustomizer sessionRepositoryCust } + @Configuration(proxyBeanMethods = false) + @EnableRedisIndexedHttpSession + static class SessionIdGeneratorConfiguration { + + @Bean + SessionIdGenerator sessionIdGenerator() { + return new TestSessionIdGenerator(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableRedisIndexedHttpSession + static class DefaultConfiguration { + + } + + static class TestSessionIdGenerator implements SessionIdGenerator { + + @Override + public String generate() { + return "test"; + } + + } + } diff --git a/spring-session-dependencies/spring-session-dependencies.gradle b/spring-session-dependencies/spring-session-dependencies.gradle index 9d861d11a..c1ca48849 100644 --- a/spring-session-dependencies/spring-session-dependencies.gradle +++ b/spring-session-dependencies/spring-session-dependencies.gradle @@ -41,6 +41,7 @@ dependencies { api libs.org.mongodb.mongodb.driver.sync api libs.org.mongodb.mongodb.driver.reactivestreams api libs.org.postgresql + api libs.org.awaitility.awaitility } } diff --git a/spring-session-docs/modules/ROOT/examples/java/docs/HttpSessionConfigurationNoOpConfigureRedisActionXmlTests.java b/spring-session-docs/modules/ROOT/examples/java/docs/HttpSessionConfigurationNoOpConfigureRedisActionXmlTests.java index 080c4c750..7105b3a64 100644 --- a/spring-session-docs/modules/ROOT/examples/java/docs/HttpSessionConfigurationNoOpConfigureRedisActionXmlTests.java +++ b/spring-session-docs/modules/ROOT/examples/java/docs/HttpSessionConfigurationNoOpConfigureRedisActionXmlTests.java @@ -41,7 +41,7 @@ @ExtendWith(SpringExtension.class) @ContextConfiguration @WebAppConfiguration -public class HttpSessionConfigurationNoOpConfigureRedisActionXmlTests { +class HttpSessionConfigurationNoOpConfigureRedisActionXmlTests { @Autowired SessionRepositoryFilter filter; diff --git a/spring-session-docs/modules/ROOT/examples/java/docs/IndexDocTests.java b/spring-session-docs/modules/ROOT/examples/java/docs/IndexDocTests.java index 886f59706..f213e68ea 100644 --- a/spring-session-docs/modules/ROOT/examples/java/docs/IndexDocTests.java +++ b/spring-session-docs/modules/ROOT/examples/java/docs/IndexDocTests.java @@ -64,11 +64,11 @@ void repositoryDemo() { } // tag::repository-demo[] - public class RepositoryDemo { + class RepositoryDemo { private SessionRepository repository; // <1> - public void demo() { + void demo() { S toSave = this.repository.createSession(); // <2> // <3> @@ -98,11 +98,11 @@ void expireRepositoryDemo() { } // tag::expire-repository-demo[] - public class ExpiringRepositoryDemo { + class ExpiringRepositoryDemo { private SessionRepository repository; // <1> - public void demo() { + void demo() { S toSave = this.repository.createSession(); // <2> // ... toSave.setMaxInactiveInterval(Duration.ofSeconds(30)); // <3> diff --git a/spring-session-docs/modules/ROOT/nav.adoc b/spring-session-docs/modules/ROOT/nav.adoc index 0526f80bd..112c2d35e 100644 --- a/spring-session-docs/modules/ROOT/nav.adoc +++ b/spring-session-docs/modules/ROOT/nav.adoc @@ -24,7 +24,9 @@ *** xref:guides/xml-redis.adoc[Redis] *** xref:guides/xml-jdbc.adoc[JDBC] * xref:configurations.adoc[Configurations] -** xref:configuration/redis.adoc[Redis] +** Redis +*** xref:configuration/redis.adoc[Redis HTTP Session] +*** xref:configuration/reactive-redis-indexed.adoc[Redis Indexed Web Session] ** xref:configuration/jdbc.adoc[JDBC] ** xref:configuration/common.adoc[Common Configurations] * xref:http-session.adoc[HttpSession Integration] diff --git a/spring-session-docs/modules/ROOT/pages/configuration/common.adoc b/spring-session-docs/modules/ROOT/pages/configuration/common.adoc index ad405301e..dc2b5fea1 100644 --- a/spring-session-docs/modules/ROOT/pages/configuration/common.adoc +++ b/spring-session-docs/modules/ROOT/pages/configuration/common.adoc @@ -4,7 +4,59 @@ This section contains common configurations that applies to all or most Spring Session modules. It contains configuration examples for the following use cases: +- I need to <> - I need to <> +- I want to <> for {spring-security-ref-docs}/reactive/authentication/concurrent-sessions-control.html[Concurrent Sessions Control] + +[[changing-how-session-ids-are-generated]] +== Changing How Session IDs Are Generated + +By default, Spring Session uses `UuidSessionIdGenerator` which, in turn, uses a `java.util.UUID` to generate a session id. +There might be scenarios where it may be better to include other characters to increase entropy, or you may want to use a different algorithm to generate the session id. +To change this, you can provide a custom `SessionIdGenerator` bean: + +.Changing How Session IDs Are Generated +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +public SessionIdGenerator sessionIdGenerator() { + return new MySessionIdGenerator(); +} + +class MySessionIdGenerator implements SessionIdGenerator { + + @Override + public String generate() { + // ... + } + +} +---- +====== + +After exposing your `SessionIdGenerator` bean, Spring Session will use it to generate session ids. + +If you are manually configuring your `SessionRepository` bean (instead of using `@EnableRedisHttpSession`, for example), you can set the `SessionIdGenerator` directly on the `SessionRepository` implementation: + +.Setting `SessionIdGenerator` directly into `SessionRepository` implementation +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +public RedisSessionRepository redisSessionRepository(RedisOperations redisOperations) { + RedisSessionRepository repository = new RedisSessionRepository(redisOperations) + repository.setSessionIdGenerator(new MySessionIdGenerator()); + return repository; +} +---- +====== [[customizing-session-cookie]] == Customizing Session Cookie @@ -86,3 +138,29 @@ include::{samples-dir}spring-session-sample-boot-webflux-custom-cookie/src/main/ <2> We customize the path of the cookie to be `/` (rather than the default of the context root). <3> We customize the `SameSite` cookie directive to be `Strict`. ==== + +[[spring-session-backed-reactive-session-registry]] +== Providing a Spring Session implementation of `ReactiveSessionRegistry` + +Spring Session provides integration with Spring Security to support its reactive concurrent session control. +This allows limiting the number of active sessions that a single user can have concurrently, but, unlike the default Spring Security support, this also works in a clustered environment. +This is done by providing the `SpringSessionBackedReactiveSessionRegistry` implementation of Spring Security’s `ReactiveSessionRegistry` interface. + +.Defining SpringSessionBackedReactiveSessionRegistry as a bean +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +public SpringSessionBackedReactiveSessionRegistry sessionRegistry( + ReactiveSessionRepository sessionRepository, + ReactiveFindByIndexNameSessionRepository indexedSessionRepository) { + return new SpringSessionBackedReactiveSessionRegistry<>(sessionRepository, indexedSessionRepository); +} +---- +====== + +Please, refer to {spring-security-ref-docs}/reactive/authentication/concurrent-sessions-control.html[Spring Security Concurrent Sessions Control documentation] for more ways of using the `ReactiveSessionRegistry`. +You can also check a sample application https://github.com/spring-projects/spring-session/tree/main/spring-session-samples/spring-session-sample-boot-reactive-max-sessions[here]. diff --git a/spring-session-docs/modules/ROOT/pages/configuration/jdbc.adoc b/spring-session-docs/modules/ROOT/pages/configuration/jdbc.adoc index af51a4247..7406949ef 100644 --- a/spring-session-docs/modules/ROOT/pages/configuration/jdbc.adoc +++ b/spring-session-docs/modules/ROOT/pages/configuration/jdbc.adoc @@ -294,7 +294,7 @@ You can create a new one if you prefer. 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. +Now that we configured how Spring Session JDBC converts our attributes values into `byte[]`, we must customize the query that inserts and updates 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] @@ -336,6 +336,12 @@ public class SessionConfig { And that's it, you should now be able to see the session attributes saved as JSON in the database. There is a https://github.com/spring-projects/spring-session/tree/main/spring-session-samples/spring-session-sample-boot-jdbc-json-attribute[sample available] where you can see the whole implementation and run the tests. +[NOTE] +==== +If your https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/user-details.html#page-title[`UserDetails` implementation] extends Spring Security's `org.springframework.security.core.userdetails.User` class, it is important that you register a custom deserializer for it. +Otherwise, Jackson will use the existing `org.springframework.security.jackson2.UserDeserializer` which won't result in the expected `UserDetails` implementation. See https://github.com/spring-projects/spring-session/issues/3009[gh-3009] for more details. +==== + [[specifying-datasource]] == Specifying an alternative `DataSource` diff --git a/spring-session-docs/modules/ROOT/pages/configuration/reactive-redis-indexed.adoc b/spring-session-docs/modules/ROOT/pages/configuration/reactive-redis-indexed.adoc new file mode 100644 index 000000000..602cdbdf5 --- /dev/null +++ b/spring-session-docs/modules/ROOT/pages/configuration/reactive-redis-indexed.adoc @@ -0,0 +1,227 @@ +[[reactive-indexed-redis-configurations]] += Reactive Redis Indexed Configurations + +To start using the Redis Indexed Web Session support, you need to add the following dependency to your project: + +[tabs] +====== +Maven:: ++ +[source,xml] +---- + + org.springframework.session + spring-session-data-redis + +---- +Gradle:: ++ +[source,groovy] +---- +implementation 'org.springframework.session:spring-session-data-redis' +---- +====== + +And add the `@EnableRedisIndexedWebSession` annotation to a configuration class: + +[source,java,role="primary"] +---- +@Configuration +@EnableRedisIndexedWebSession +public class SessionConfig { + // ... +} +---- + +That is it. Your application now has a reactive Redis backed Indexed Web Session support. +Now that you have your application configured, you might want to start customizing things: + +- I want to <>. +- I want to <> for keys used by Spring Session. +- I want to know <>. +- I want to <>. +- I want to <>. +- I want to <>. + +[[serializing-session-using-json]] +== Serializing the Session using JSON + +By default, Spring Session Data Redis uses Java Serialization to serialize the session attributes. +Sometimes it might be problematic, especially when you have multiple applications that use the same Redis instance but have different versions of the same class. +You can provide a `RedisSerializer` bean to customize how the session is serialized into Redis. +Spring Data Redis provides the `GenericJackson2JsonRedisSerializer` that serializes and deserializes objects using Jackson's `ObjectMapper`. + +==== +.Configuring the RedisSerializer +[source,java] +---- +include::{samples-dir}spring-session-sample-boot-redis-json/src/main/java/sample/config/SessionConfig.java[tags=class] +---- +==== + +The above code snippet is using Spring Security, therefore we are creating a custom `ObjectMapper` that uses Spring Security's Jackson modules. +If you do not need Spring Security Jackson modules, you can inject your application's `ObjectMapper` bean and use it like so: + +==== +[source,java] +---- +@Bean +public RedisSerializer springSessionDefaultRedisSerializer(ObjectMapper objectMapper) { + return new GenericJackson2JsonRedisSerializer(objectMapper); +} +---- +==== + +[NOTE] +==== +The `RedisSerializer` bean name must be `springSessionDefaultRedisSerializer` so it does not conflict with other `RedisSerializer` beans used by Spring Data Redis. +If a different name is provided it won't be picked up by Spring Session. +==== + +[[using-a-different-namespace]] +== Specifying a Different Namespace + +It is not uncommon to have multiple applications that use the same Redis instance or to want to keep the session data separated from other data stored in Redis. +For that reason, Spring Session uses a `namespace` (defaults to `spring:session`) to keep the session data separated if needed. + +You can specify the `namespace` by setting the `redisNamespace` property in the `@EnableRedisIndexedWebSession` annotation: + +==== +.Specifying a different namespace +[source,java,role="primary"] +---- +@Configuration +@EnableRedisIndexedWebSession(redisNamespace = "spring:session:myapplication") +public class SessionConfig { + // ... +} +---- +==== + +[[how-spring-session-cleans-up-expired-sessions]] +== Understanding How Spring Session Cleans Up Expired Sessions + +Spring Session relies on https://redis.io/docs/manual/keyspace-notifications/[Redis Keyspace Events] to clean up expired sessions. +More specifically, it listens to events emitted to the `pass:[__keyevent@*__:expired]` and `pass:[__keyevent@*__:del]` channels and resolve the session id based on the key that was destroyed. + +As an example, let's imagine that we have a session with id `1234` and that the session is set to expire in 30 minutes. +When the expiration time is reached, Redis will emit an event to the `pass:[__keyevent@*__:expired]` channel with the message `spring:session:sessions:expires:1234` which is the key that expired. +Spring Session will then resolve the session id (`1234`) from the key and delete all the related session keys from Redis. + +One problem with relying on Redis expiration exclusively is that Redis makes no guarantee of when the expired event will be fired if the key has not been accessed. +For additional details see https://redis.io/commands/expire/#:~:text=How%20Redis%20expires%20keys[How Redis expires keys] in the Redis documentation. +To circumvent the fact that expired events are not guaranteed to happen we can ensure that each key is accessed when it is expected to expire. +This means that if the TTL is expired on the key, Redis will remove the key and fire the expired event when we try to access the key. +For this reason, each session expiration is also tracked by storing the session id in a sorted set ranked by its expiration time. +This allows a background task to access the potentially expired sessions to ensure that Redis expired events are fired in a more deterministic fashion. +For example: +---- +ZADD spring:session:sessions:expirations "1.702402961162E12" "648377f7-c76f-4f45-b847-c0268bb48381" +---- + +We do not explicitly delete the keys since in some instances there may be a race condition that incorrectly identifies a key as expired when it is not. +Short of using distributed locks (which would kill our performance) there is no way to ensure the consistency of the expiration mapping. +By simply accessing the key, we ensure that the key is only removed if the TTL on that key is expired. + +By default, Spring Session will retrieve up to 100 expired sessions every 60 seconds. +If you want to configure how often the cleanup task runs, please refer to the <> section. + +== Configuring Redis to Send Keyspace Events + +By default, Spring Session tries to configure Redis to send keyspace events using the `ConfigureNotifyKeyspaceEventsReactiveAction` which, in turn, might set the `notify-keyspace-events` configuration property to `Egx`. +However, this strategy will not work if the Redis instance has been properly secured. +In that case, the Redis instance should be configured externally and a Bean of type `ConfigureReactiveRedisAction.NO_OP` should be exposed to disable the autoconfiguration. + +[source,java] +---- +@Bean +public ConfigureReactiveRedisAction configureReactiveRedisAction() { + return ConfigureReactiveRedisAction.NO_OP; +} +---- + +[[changing-the-frequency-of-the-session-cleanup]] +== Changing the Frequency of the Session Cleanup + +Depending on your application's needs, you might want to change the frequency of the session cleanup. +To do that, you can expose a `ReactiveSessionRepositoryCustomizer` bean and set the `cleanupInterval` property: + +[source,java] +---- +@Bean +public ReactiveSessionRepositoryCustomizer reactiveSessionRepositoryCustomizer() { + return (sessionRepository) -> sessionRepository.setCleanupInterval(Duration.ofSeconds(30)); +} +---- + +You can also set invoke `disableCleanupTask()` to disable the cleanup task. + +[source,java] +---- +@Bean +public ReactiveSessionRepositoryCustomizer reactiveSessionRepositoryCustomizer() { + return (sessionRepository) -> sessionRepository.disableCleanupTask(); +} +---- + +[[taking-control-over-the-cleanup-task]] +=== Taking Control Over the Cleanup Task + +Sometimes, the default cleanup task might not be enough for your application's needs. +You might want to adopt a different strategy to clean up expired sessions. +Since you know that the <>, you can <> task and provide your own strategy. +For example: + +[source,java] +---- +@Component +public class SessionEvicter { + + private ReactiveRedisOperations redisOperations; + + @Scheduled + public Mono cleanup() { + Instant now = Instant.now(); + Instant oneMinuteAgo = now.minus(Duration.ofMinutes(1)); + Range range = Range.closed((double) oneMinuteAgo.toEpochMilli(), (double) now.toEpochMilli()); + Limit limit = Limit.limit().count(1000); + return this.redisOperations.opsForZSet().reverseRangeByScore("spring:session:sessions:expirations", range, limit) + // do something with the session ids + .then(); + } + +} +---- + +[[listening-session-events]] +== Listening to Session Events + +Often times it is valuable to react to session events, for example, you might want to do some kind of processing depending on the session lifecycle. + +You configure your application to listen to `SessionCreatedEvent`, `SessionDeletedEvent` and `SessionExpiredEvent` events. +There are a https://docs.spring.io/spring-framework/reference/core/beans/context-introduction.html#context-functionality-events[few ways to listen to application events] in Spring, for this example we are going to use the `@EventListener` annotation. + +==== +[source,java] +---- +@Component +public class SessionEventListener { + + @EventListener + public Mono processSessionCreatedEvent(SessionCreatedEvent event) { + // do the necessary work + } + + @EventListener + public Mono processSessionDeletedEvent(SessionDeletedEvent event) { + // do the necessary work + } + + @EventListener + public Mono processSessionExpiredEvent(SessionExpiredEvent event) { + // do the necessary work + } + +} +---- +==== diff --git a/spring-session-docs/modules/ROOT/pages/configuration/redis.adoc b/spring-session-docs/modules/ROOT/pages/configuration/redis.adoc index 0869d5c81..458f53aa6 100644 --- a/spring-session-docs/modules/ROOT/pages/configuration/redis.adoc +++ b/spring-session-docs/modules/ROOT/pages/configuration/redis.adoc @@ -9,6 +9,8 @@ Now that you have your application configured, you might want to start customizi - I want to <>. - I want to <>. - I want to <> +- I want to <> +- Customizing the <> [[serializing-session-using-json]] == Serializing the Session using JSON @@ -271,3 +273,200 @@ public void removeSession(Principal principal, String sessionIdToDelete) { ==== In the example above, you can use the `getSessions` method to find all sessions of a specific user, and the `removeSession` method to remove a specific session of a user. + +[[configuring-redis-session-mapper]] +== Configuring Redis Session Mapper + + +Spring Session Redis retrieves session information from Redis and stores it in a `Map`. +This map needs to undergo a mapping process to be transformed into a `MapSession` object, which is then utilized within `RedisSession`. + +The default mapper used for this purpose is called `RedisSessionMapper`. +If the session map doesn't contain the minimum necessary keys to construct the session, like `creationTime`, this mapper will throw an exception. +One possible scenario for the absence of required keys is when the session key is deleted concurrently, usually due to expiration, while the save process is in progress. +This occurs because the https://redis.io/commands/hset/[HSET command] is employed to set fields within the key, and if the key doesn't exist, this command will create it. + +If you want to customize the mapping process, you can create your implementation of `BiFunction, MapSession>` and set it into the session repository. +The following example shows how to delegate the mapping process to the default mapper, but if an exception is thrown, the session is deleted from Redis: + +[tabs] +====== +RedisSessionRepository:: ++ +[source,java,role="primary"] +---- +@Configuration +@EnableRedisHttpSession +public class SessionConfig { + + @Bean + SessionRepositoryCustomizer redisSessionRepositoryCustomizer() { + return (redisSessionRepository) -> redisSessionRepository + .setRedisSessionMapper(new SafeRedisSessionMapper(redisSessionRepository)); + } + + static class SafeRedisSessionMapper implements BiFunction, MapSession> { + + private final RedisSessionMapper delegate = new RedisSessionMapper(); + + private final RedisSessionRepository sessionRepository; + + SafeRedisSessionMapper(RedisSessionRepository sessionRepository) { + this.sessionRepository = sessionRepository; + } + + @Override + public MapSession apply(String sessionId, Map map) { + try { + return this.delegate.apply(sessionId, map); + } + catch (IllegalStateException ex) { + this.sessionRepository.deleteById(sessionId); + return null; + } + } + + } + +} +---- + +RedisIndexedSessionRepository:: ++ +[source,java,role="secondary"] +---- +@Configuration +@EnableRedisIndexedHttpSession +public class SessionConfig { + + @Bean + SessionRepositoryCustomizer redisSessionRepositoryCustomizer() { + return (redisSessionRepository) -> redisSessionRepository.setRedisSessionMapper( + new SafeRedisSessionMapper(redisSessionRepository.getSessionRedisOperations())); + } + + static class SafeRedisSessionMapper implements BiFunction, MapSession> { + + private final RedisSessionMapper delegate = new RedisSessionMapper(); + + private final RedisOperations redisOperations; + + SafeRedisSessionMapper(RedisOperations redisOperations) { + this.redisOperations = redisOperations; + } + + @Override + public MapSession apply(String sessionId, Map map) { + try { + return this.delegate.apply(sessionId, map); + } + catch (IllegalStateException ex) { + // if you use a different redis namespace, change the key accordingly + this.redisOperations.delete("spring:session:sessions:" + sessionId); // we do not invoke RedisIndexedSessionRepository#deleteById to avoid an infinite loop because the method also invokes this mapper + return null; + } + } + + } + +} +---- + +ReactiveRedisSessionRepository:: ++ +[source,java,role="tertiary"] +---- +@Configuration +@EnableRedisWebSession +public class SessionConfig { + + @Bean + ReactiveSessionRepositoryCustomizer redisSessionRepositoryCustomizer() { + return (redisSessionRepository) -> redisSessionRepository + .setRedisSessionMapper(new SafeRedisSessionMapper(redisSessionRepository)); + } + + static class SafeRedisSessionMapper implements BiFunction, Mono> { + + private final RedisSessionMapper delegate = new RedisSessionMapper(); + + private final ReactiveRedisSessionRepository sessionRepository; + + SafeRedisSessionMapper(ReactiveRedisSessionRepository sessionRepository) { + this.sessionRepository = sessionRepository; + } + + @Override + public Mono apply(String sessionId, Map map) { + return Mono.fromSupplier(() -> this.delegate.apply(sessionId, map)) + .onErrorResume(IllegalStateException.class, + (ex) -> this.sessionRepository.deleteById(sessionId).then(Mono.empty())); + } + + } + +} +---- +====== + +[[customizing-session-expiration-store]] +== Customizing the Session Expiration Store + +Due to the nature of Redis, there is no guarantee on when an expired event will be fired if the key has not been accessed. +For more details, refer to the Redis documentation https://redis.io/docs/latest/commands/expire/#:~:text=How%20Redis%20expires%20keys[on key expiration]. + +To mitigate the uncertainty of expired events, sessions are also stored with their expected expiration times. +This ensures that each key can be accessed when it is expected to expire. +The `RedisSessionExpirationStore` interface defines the common operations for tracking sessions and their expiration times, and it provides a strategy for cleaning up expired sessions. + +By default, each session expiration is tracked to the nearest minute. +This allows a background task to access the potentially expired sessions to ensure that Redis expired events are fired in a more deterministic fashion. + +For example: +[source] +---- +SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe +EXPIRE spring:session:expirations:1439245080000 2100 +---- + +The background task will then use these mappings to explicitly request each session expires key. +By accessing the key, rather than deleting it, we ensure that Redis deletes the key for us only if the TTL is expired. + +By customizing the session expiration store, you can manage session expiration more effectively based on your needs. +To do that, you should provide a bean of type `RedisSessionExpirationStore` that will be picked up by Spring Session Data Redis configuration: + +[tabs] +====== +SessionConfig:: ++ +[source,java,role="primary"] +---- +import org.springframework.session.data.redis.SortedSetRedisSessionExpirationStore; + +@Configuration +@EnableRedisIndexedHttpSession +public class SessionConfig { + + @Bean + public RedisSessionExpirationStore redisSessionExpirationStore(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(RedisSerializer.string()); + redisTemplate.setHashKeySerializer(RedisSerializer.string()); + redisTemplate.setConnectionFactory(redisConnectionFactory); + redisTemplate.afterPropertiesSet(); + return new SortedSetRedisSessionExpirationStore(redisTemplate, RedisIndexedSessionRepository.DEFAULT_NAMESPACE); + } + +} +---- +====== + +In the code above, the `SortedSetRedisSessionExpirationStore` implementation is being used, which uses a https://redis.io/docs/latest/develop/data-types/sorted-sets/[Sorted Set] to store the session ids with their expiration time as the score. + +[NOTE] +==== +We do not explicitly delete the keys since in some instances there may be a race condition that incorrectly identifies a key as expired when it is not. +Short of using distributed locks (which would kill performance) there is no way to ensure the consistency of the expiration mapping. +By simply accessing the key, we ensure that the key is only removed if the TTL on that key is expired. +However, for your implementations you can choose the strategy that best fits. +==== diff --git a/spring-session-docs/modules/ROOT/pages/guides/boot-findbyusername.adoc b/spring-session-docs/modules/ROOT/pages/guides/boot-findbyusername.adoc index 21c344d5e..b9f59c68e 100644 --- a/spring-session-docs/modules/ROOT/pages/guides/boot-findbyusername.adoc +++ b/spring-session-docs/modules/ROOT/pages/guides/boot-findbyusername.adoc @@ -16,7 +16,7 @@ link:../index.html[Index] The guide assumes you have already added Spring Session to your application by using the built-in Redis configuration support. The guide also assumes you have already applied Spring Security to your application. -However, we the guide is somewhat general purpose and can be applied to any technology with minimal changes, which we discuss later in the guide. +However, the guide is somewhat general purpose and can be applied to any technology with minimal changes, which we discuss later in the guide. NOTE: If you need to learn how to add Spring Session to your project, see the listing of link:../#samples[samples and guides] diff --git a/spring-session-docs/modules/ROOT/pages/whats-new.adoc b/spring-session-docs/modules/ROOT/pages/whats-new.adoc index 1d431e341..94ddee55e 100644 --- a/spring-session-docs/modules/ROOT/pages/whats-new.adoc +++ b/spring-session-docs/modules/ROOT/pages/whats-new.adoc @@ -1 +1,4 @@ -= What's New += What's New in 3.4 + +- https://github.com/spring-projects/spring-session/issues/2787[gh-2787] - Add Partitioned Cookie Support to `DefaultCookieSerializer` +- https://github.com/spring-projects/spring-session/issues/2906[gh-2906] - xref:configuration/redis.adoc#customizing-session-expiration-store[docs] - Allow Customization of Expiration Policy in `RedisIndexedHttpSession` diff --git a/spring-session-hazelcast/src/integration-test/java/org/springframework/session/hazelcast/ClientServerHazelcastIndexedSessionRepositoryITests.java b/spring-session-hazelcast/src/integration-test/java/org/springframework/session/hazelcast/ClientServerHazelcastIndexedSessionRepositoryITests.java index c3250f96a..6ad27e22b 100644 --- a/spring-session-hazelcast/src/integration-test/java/org/springframework/session/hazelcast/ClientServerHazelcastIndexedSessionRepositoryITests.java +++ b/spring-session-hazelcast/src/integration-test/java/org/springframework/session/hazelcast/ClientServerHazelcastIndexedSessionRepositoryITests.java @@ -30,6 +30,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.session.MapSession; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; import org.springframework.session.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -82,7 +83,8 @@ HazelcastInstance hazelcastInstance() { .setEnabled(true) .addClass(Session.class) .addClass(MapSession.class) - .addClass(SessionUpdateEntryProcessor.class); + .addClass(SessionUpdateEntryProcessor.class) + .addClass(SessionIdGenerator.class); return HazelcastClient.newHazelcastClient(clientConfig); } diff --git a/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/HazelcastIndexedSessionRepository.java b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/HazelcastIndexedSessionRepository.java index b41783cb4..b0955eecb 100644 --- a/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/HazelcastIndexedSessionRepository.java +++ b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/HazelcastIndexedSessionRepository.java @@ -48,6 +48,8 @@ import org.springframework.session.PrincipalNameIndexResolver; import org.springframework.session.SaveMode; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.events.AbstractSessionEvent; import org.springframework.session.events.SessionCreatedEvent; import org.springframework.session.events.SessionDeletedEvent; @@ -151,6 +153,8 @@ public class HazelcastIndexedSessionRepository private UUID sessionListenerId; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + /** * Create a new {@link HazelcastIndexedSessionRepository} instance. * @param hazelcastInstance the {@link HazelcastInstance} to use for managing sessions @@ -245,7 +249,7 @@ public void setSaveMode(SaveMode saveMode) { @Override public HazelcastSession createSession() { - MapSession cached = new MapSession(); + MapSession cached = new MapSession(this.sessionIdGenerator); cached.setMaxInactiveInterval(this.defaultMaxInactiveInterval); HazelcastSession session = new HazelcastSession(cached, true); session.flushImmediateIfNecessary(); @@ -349,6 +353,16 @@ public void entryExpired(EntryEvent event) { this.eventPublisher.publishEvent(new SessionExpiredEvent(this, event.getOldValue())); } + /** + * Set the {@link SessionIdGenerator} to use to generate session ids. + * @param sessionIdGenerator the {@link SessionIdGenerator} to use + * @since 3.2 + */ + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + Assert.notNull(sessionIdGenerator, "sessionIdGenerator cannot be null"); + this.sessionIdGenerator = sessionIdGenerator; + } + /** * A custom implementation of {@link Session} that uses a {@link MapSession} as the * basis for its mapping. It keeps track if changes have been made since last save. @@ -405,7 +419,8 @@ public String getId() { @Override public String changeSessionId() { - String newSessionId = this.delegate.changeSessionId(); + String newSessionId = HazelcastIndexedSessionRepository.this.sessionIdGenerator.generate(); + this.delegate.setId(newSessionId); this.sessionIdChanged = true; return newSessionId; } diff --git a/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfiguration.java b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfiguration.java index 1cd7f91b6..959a53f39 100644 --- a/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfiguration.java +++ b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfiguration.java @@ -38,6 +38,8 @@ import org.springframework.session.MapSession; import org.springframework.session.SaveMode; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.config.SessionRepositoryCustomizer; import org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration; import org.springframework.session.hazelcast.HazelcastIndexedSessionRepository; @@ -75,6 +77,8 @@ public class HazelcastHttpSessionConfiguration implements ImportAware { private List> sessionRepositoryCustomizers; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + @Bean public FindByIndexNameSessionRepository sessionRepository() { return createHazelcastIndexedSessionRepository(); @@ -158,9 +162,15 @@ private HazelcastIndexedSessionRepository createHazelcastIndexedSessionRepositor sessionRepository.setDefaultMaxInactiveInterval(this.maxInactiveInterval); sessionRepository.setFlushMode(this.flushMode); sessionRepository.setSaveMode(this.saveMode); + sessionRepository.setSessionIdGenerator(this.sessionIdGenerator); this.sessionRepositoryCustomizers .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(sessionRepository)); return sessionRepository; } + @Autowired(required = false) + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + this.sessionIdGenerator = sessionIdGenerator; + } + } diff --git a/spring-session-hazelcast/src/test/java/org/springframework/session/hazelcast/HazelcastIndexedSessionRepositoryTests.java b/spring-session-hazelcast/src/test/java/org/springframework/session/hazelcast/HazelcastIndexedSessionRepositoryTests.java index ee672b92b..236033d2a 100644 --- a/spring-session-hazelcast/src/test/java/org/springframework/session/hazelcast/HazelcastIndexedSessionRepositoryTests.java +++ b/spring-session-hazelcast/src/test/java/org/springframework/session/hazelcast/HazelcastIndexedSessionRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -61,14 +61,15 @@ * @author Vedran Pavic * @author Aleksandar Stojsavljevic */ +@SuppressWarnings("unchecked") class HazelcastIndexedSessionRepositoryTests { private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; - private HazelcastInstance hazelcastInstance = mock(HazelcastInstance.class); + private final HazelcastInstance hazelcastInstance = mock(HazelcastInstance.class); @SuppressWarnings("unchecked") - private IMap sessions = mock(IMap.class); + private final IMap sessions = mock(IMap.class); private HazelcastIndexedSessionRepository repository; @@ -465,4 +466,31 @@ void saveWithSaveModeAlways() { verifyNoMoreInteractions(this.sessions); } + @Test + void createSessionWhenSessionIdGeneratorThenUses() { + this.repository.setSessionIdGenerator(() -> "test"); + HazelcastSession session = this.repository.createSession(); + assertThat(session.getId()).isEqualTo("test"); + assertThat(session.changeSessionId()).isEqualTo("test"); + } + + @Test + void setSessionIdGeneratorWhenNullThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setSessionIdGenerator(null)) + .withMessage("sessionIdGenerator cannot be null"); + } + + @Test + void findByIdWhenChangeSessionIdThenUsesSessionIdGenerator() { + this.repository.setSessionIdGenerator(() -> "test"); + MapSession saved = new MapSession("original"); + saved.setAttribute("savedName", "savedValue"); + given(this.sessions.get(eq(saved.getId()))).willReturn(saved); + + HazelcastSession session = this.repository.findById(saved.getId()); + + assertThat(session.getId()).isEqualTo(saved.getId()); + assertThat(session.changeSessionId()).isEqualTo("test"); + } + } diff --git a/spring-session-hazelcast/src/test/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfigurationTests.java b/spring-session-hazelcast/src/test/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfigurationTests.java index 42fa5d6ea..86d26849f 100644 --- a/spring-session-hazelcast/src/test/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfigurationTests.java +++ b/spring-session-hazelcast/src/test/java/org/springframework/session/hazelcast/config/annotation/web/http/HazelcastHttpSessionConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -36,6 +36,8 @@ import org.springframework.session.IndexResolver; import org.springframework.session.SaveMode; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.config.SessionRepositoryCustomizer; import org.springframework.session.hazelcast.HazelcastIndexedSessionRepository; import org.springframework.session.hazelcast.config.annotation.SpringSessionHazelcastInstance; @@ -241,6 +243,22 @@ void importConfigAndCustomize() { assertThat(sessionRepository).extracting("defaultMaxInactiveInterval").isEqualTo(Duration.ZERO); } + @Test + void registerWhenSessionIdGeneratorBeanThenUses() { + registerAndRefresh(DefaultConfiguration.class, SessionIdGeneratorConfiguration.class); + HazelcastIndexedSessionRepository sessionRepository = this.context + .getBean(HazelcastIndexedSessionRepository.class); + assertThat(sessionRepository).extracting("sessionIdGenerator").isInstanceOf(TestSessionIdGenerator.class); + } + + @Test + void registerWhenNoSessionIdGeneratorBeanThenDefault() { + registerAndRefresh(DefaultConfiguration.class); + HazelcastIndexedSessionRepository sessionRepository = this.context + .getBean(HazelcastIndexedSessionRepository.class); + assertThat(sessionRepository).extracting("sessionIdGenerator").isInstanceOf(UuidSessionIdGenerator.class); + } + private void registerAndRefresh(Class... annotatedClasses) { this.context.register(annotatedClasses); this.context.refresh(); @@ -468,4 +486,23 @@ SessionRepositoryCustomizer sessionRepository } + @Configuration(proxyBeanMethods = false) + static class SessionIdGeneratorConfiguration { + + @Bean + SessionIdGenerator sessionIdGenerator() { + return new TestSessionIdGenerator(); + } + + } + + static class TestSessionIdGenerator implements SessionIdGenerator { + + @Override + public String generate() { + return "test"; + } + + } + } diff --git a/spring-session-jdbc/src/integration-test/java/org/springframework/session/jdbc/AbstractContainerJdbcIndexedSessionRepositoryITests.java b/spring-session-jdbc/src/integration-test/java/org/springframework/session/jdbc/AbstractContainerJdbcIndexedSessionRepositoryITests.java index 2783e268a..247e22587 100644 --- a/spring-session-jdbc/src/integration-test/java/org/springframework/session/jdbc/AbstractContainerJdbcIndexedSessionRepositoryITests.java +++ b/spring-session-jdbc/src/integration-test/java/org/springframework/session/jdbc/AbstractContainerJdbcIndexedSessionRepositoryITests.java @@ -41,6 +41,7 @@ HikariDataSource dataSource(JdbcDatabaseContainer databaseContainer) { dataSource.setJdbcUrl(databaseContainer.getJdbcUrl()); dataSource.setUsername(databaseContainer.getUsername()); dataSource.setPassword(databaseContainer.getPassword()); + dataSource.setDriverClassName(databaseContainer.getDriverClassName()); return dataSource; } diff --git a/spring-session-jdbc/src/integration-test/java/org/springframework/session/jdbc/DatabaseContainers.java b/spring-session-jdbc/src/integration-test/java/org/springframework/session/jdbc/DatabaseContainers.java index 372076236..4b7cc91a6 100644 --- a/spring-session-jdbc/src/integration-test/java/org/springframework/session/jdbc/DatabaseContainers.java +++ b/spring-session-jdbc/src/integration-test/java/org/springframework/session/jdbc/DatabaseContainers.java @@ -55,7 +55,7 @@ static JdbcDatabaseContainer postgreSql() { } static JdbcDatabaseContainer sqlServer() { - return new MSSQLServerContainerProvider().newInstance("2019-CU17-ubuntu-20.04"); + return new MSSQLServerContainerProvider().newInstance("2022-CU14-ubuntu-22.04"); } } diff --git a/spring-session-jdbc/src/integration-test/resources/container-license-acceptance.txt b/spring-session-jdbc/src/integration-test/resources/container-license-acceptance.txt index 265b9de8f..85d62c3fb 100644 --- a/spring-session-jdbc/src/integration-test/resources/container-license-acceptance.txt +++ b/spring-session-jdbc/src/integration-test/resources/container-license-acceptance.txt @@ -1,2 +1,2 @@ ibmcom/db2:11.5.7.0a -mcr.microsoft.com/mssql/server:2019-CU17-ubuntu-20.04 +mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04 diff --git a/spring-session-jdbc/src/main/java/org/springframework/session/jdbc/JdbcIndexedSessionRepository.java b/spring-session-jdbc/src/main/java/org/springframework/session/jdbc/JdbcIndexedSessionRepository.java index 1cbca6ccc..612f2527e 100644 --- a/spring-session-jdbc/src/main/java/org/springframework/session/jdbc/JdbcIndexedSessionRepository.java +++ b/spring-session-jdbc/src/main/java/org/springframework/session/jdbc/JdbcIndexedSessionRepository.java @@ -62,6 +62,8 @@ import org.springframework.session.PrincipalNameIndexResolver; import org.springframework.session.SaveMode; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.transaction.support.TransactionOperations; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -252,6 +254,8 @@ public class JdbcIndexedSessionRepository implements private ThreadPoolTaskScheduler taskScheduler; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + /** * Create a new {@link JdbcIndexedSessionRepository} instance which uses the provided * {@link JdbcOperations} and {@link TransactionOperations} to manage sessions. @@ -461,7 +465,7 @@ public void setCleanupCron(String cleanupCron) { @Override public JdbcSession createSession() { - MapSession delegate = new MapSession(); + MapSession delegate = new MapSession(this.sessionIdGenerator); delegate.setMaxInactiveInterval(this.defaultMaxInactiveInterval); JdbcSession session = new JdbcSession(delegate, UUID.randomUUID().toString(), true); session.flushIfRequired(); @@ -686,6 +690,16 @@ private Object deserialize(byte[] bytes) { TypeDescriptor.valueOf(Object.class)); } + /** + * Set the {@link SessionIdGenerator} to use to generate session ids. + * @param sessionIdGenerator the {@link SessionIdGenerator} to use + * @since 3.2 + */ + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + Assert.notNull(sessionIdGenerator, "sessionIdGenerator cannot be null"); + this.sessionIdGenerator = sessionIdGenerator; + } + private enum DeltaValue { ADDED, UPDATED, REMOVED @@ -721,7 +735,7 @@ public T get() { */ final class JdbcSession implements Session { - private final Session delegate; + private final MapSession delegate; private final String primaryKey; @@ -773,7 +787,9 @@ public String getId() { @Override public String changeSessionId() { this.changed = true; - return this.delegate.changeSessionId(); + String newSessionId = JdbcIndexedSessionRepository.this.sessionIdGenerator.generate(); + this.delegate.setId(newSessionId); + return newSessionId; } @Override diff --git a/spring-session-jdbc/src/main/java/org/springframework/session/jdbc/config/annotation/web/http/JdbcHttpSessionConfiguration.java b/spring-session-jdbc/src/main/java/org/springframework/session/jdbc/config/annotation/web/http/JdbcHttpSessionConfiguration.java index b933b17de..7cb5ab7fc 100644 --- a/spring-session-jdbc/src/main/java/org/springframework/session/jdbc/config/annotation/web/http/JdbcHttpSessionConfiguration.java +++ b/spring-session-jdbc/src/main/java/org/springframework/session/jdbc/config/annotation/web/http/JdbcHttpSessionConfiguration.java @@ -54,6 +54,8 @@ import org.springframework.session.MapSession; import org.springframework.session.SaveMode; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.config.SessionRepositoryCustomizer; import org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration; import org.springframework.session.jdbc.JdbcIndexedSessionRepository; @@ -115,6 +117,8 @@ public class JdbcHttpSessionConfiguration implements BeanClassLoaderAware, Embed private StringValueResolver embeddedValueResolver; + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + private ApplicationContext applicationContext; @Override @@ -165,6 +169,7 @@ else if (this.conversionService != null) { else { sessionRepository.setConversionService(createConversionServiceWithBeanClassLoader(this.classLoader)); } + sessionRepository.setSessionIdGenerator(this.sessionIdGenerator); this.sessionRepositoryCustomizers .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(sessionRepository)); return sessionRepository; @@ -257,6 +262,11 @@ public void setSessionRepositoryCustomizer( this.sessionRepositoryCustomizers = sessionRepositoryCustomizers.orderedStream().collect(Collectors.toList()); } + @Autowired(required = false) + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + this.sessionIdGenerator = sessionIdGenerator; + } + @Override public void setBeanClassLoader(ClassLoader classLoader) { this.classLoader = classLoader; diff --git a/spring-session-jdbc/src/test/java/org/springframework/session/jdbc/FixedSessionIdGenerator.java b/spring-session-jdbc/src/test/java/org/springframework/session/jdbc/FixedSessionIdGenerator.java new file mode 100644 index 000000000..12657acd9 --- /dev/null +++ b/spring-session-jdbc/src/test/java/org/springframework/session/jdbc/FixedSessionIdGenerator.java @@ -0,0 +1,34 @@ +/* + * Copyright 2014-2023 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.session.jdbc; + +import org.springframework.session.SessionIdGenerator; + +public class FixedSessionIdGenerator implements SessionIdGenerator { + + private final String id; + + public FixedSessionIdGenerator(String id) { + this.id = id; + } + + @Override + public String generate() { + return this.id; + } + +} diff --git a/spring-session-jdbc/src/test/java/org/springframework/session/jdbc/JdbcIndexedSessionRepositoryTests.java b/spring-session-jdbc/src/test/java/org/springframework/session/jdbc/JdbcIndexedSessionRepositoryTests.java index ba98824ff..7b60c5a4d 100644 --- a/spring-session-jdbc/src/test/java/org/springframework/session/jdbc/JdbcIndexedSessionRepositoryTests.java +++ b/spring-session-jdbc/src/test/java/org/springframework/session/jdbc/JdbcIndexedSessionRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2022 the original author or authors. + * Copyright 2014-2023 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. @@ -263,6 +263,12 @@ void setCleanupCronDisabled() { assertThat(this.repository).extracting("taskScheduler").isNull(); } + @Test + void setSessionIdGeneratorWhenNullThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setSessionIdGenerator(null)) + .withMessage("sessionIdGenerator cannot be null"); + } + @Test void createSessionDefaultMaxInactiveInterval() { JdbcSession session = this.repository.createSession(); @@ -774,4 +780,34 @@ void saveAndFreeTemporaryLob() { verify(lobCreator, atLeastOnce()).close(); } + @Test + void createSessionWhenSessionIdGeneratorThenUses() { + this.repository.setSessionIdGenerator(() -> "test"); + JdbcSession session = this.repository.createSession(); + assertThat(session.getId()).isEqualTo("test"); + assertThat(session.changeSessionId()).isEqualTo("test"); + } + + @Test + void setSessionIdGeneratorWhenNullThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setSessionIdGenerator(null)) + .withMessage("sessionIdGenerator cannot be null"); + } + + @Test + @SuppressWarnings("unchecked") + void findByIdWhenChangeSessionIdThenUsesSessionIdGenerator() { + this.repository.setSessionIdGenerator(() -> "test"); + Session saved = this.repository.new JdbcSession(new MapSession(), "primaryKey", false); + saved.setAttribute("savedName", "savedValue"); + given(this.jdbcOperations.query(isA(String.class), isA(PreparedStatementSetter.class), + isA(ResultSetExtractor.class))) + .willReturn(Collections.singletonList(saved)); + + JdbcSession session = this.repository.findById(saved.getId()); + + assertThat(session.getId()).isEqualTo(saved.getId()); + assertThat(session.changeSessionId()).isEqualTo("test"); + } + } diff --git a/spring-session-jdbc/src/test/java/org/springframework/session/jdbc/config/annotation/web/http/JdbcHttpSessionConfigurationTests.java b/spring-session-jdbc/src/test/java/org/springframework/session/jdbc/config/annotation/web/http/JdbcHttpSessionConfigurationTests.java index 35d2083b8..29081c76a 100644 --- a/spring-session-jdbc/src/test/java/org/springframework/session/jdbc/config/annotation/web/http/JdbcHttpSessionConfigurationTests.java +++ b/spring-session-jdbc/src/test/java/org/springframework/session/jdbc/config/annotation/web/http/JdbcHttpSessionConfigurationTests.java @@ -43,7 +43,10 @@ import org.springframework.session.IndexResolver; import org.springframework.session.SaveMode; import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; import org.springframework.session.config.SessionRepositoryCustomizer; +import org.springframework.session.jdbc.FixedSessionIdGenerator; import org.springframework.session.jdbc.JdbcIndexedSessionRepository; import org.springframework.session.jdbc.config.annotation.SpringSessionDataSource; import org.springframework.session.jdbc.config.annotation.SpringSessionTransactionManager; @@ -75,13 +78,11 @@ class JdbcHttpSessionConfigurationTests { private static final String CLEANUP_CRON_EXPRESSION = "0 0 * * * *"; - private AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + private final AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); @AfterEach void closeContext() { - if (this.context != null) { - this.context.close(); - } + this.context.close(); } @Test @@ -325,6 +326,24 @@ void importConfigAndCustomize() { assertThat(sessionRepository).extracting("defaultMaxInactiveInterval").isEqualTo(Duration.ZERO); } + @Test + void sessionIdGeneratorWhenCustomBeanThenUses() { + registerAndRefresh(DataSourceConfiguration.class, CustomSessionIdGeneratorConfiguration.class); + JdbcIndexedSessionRepository sessionRepository = this.context.getBean(JdbcIndexedSessionRepository.class); + SessionIdGenerator sessionIdGenerator = (SessionIdGenerator) ReflectionTestUtils.getField(sessionRepository, + "sessionIdGenerator"); + assertThat(sessionIdGenerator).isInstanceOf(FixedSessionIdGenerator.class); + } + + @Test + void sessionIdGeneratorWhenNoBeanThenDefault() { + registerAndRefresh(DataSourceConfiguration.class, DefaultConfiguration.class); + JdbcIndexedSessionRepository sessionRepository = this.context.getBean(JdbcIndexedSessionRepository.class); + SessionIdGenerator sessionIdGenerator = (SessionIdGenerator) ReflectionTestUtils.getField(sessionRepository, + "sessionIdGenerator"); + assertThat(sessionIdGenerator).isInstanceOf(UuidSessionIdGenerator.class); + } + // gh-2801 @Test void configureWhenMultipleTransactionManagersAndQualifiedTransactionOperationsThenApplicationShouldStart() { @@ -358,6 +377,17 @@ private void registerAndRefresh(Class... annotatedClasses) { this.context.refresh(); } + @Configuration(proxyBeanMethods = false) + @EnableJdbcHttpSession + static class CustomSessionIdGeneratorConfiguration { + + @Bean + SessionIdGenerator sessionIdGenerator() { + return new FixedSessionIdGenerator("my-id"); + } + + } + @Configuration(proxyBeanMethods = false) @EnableJdbcHttpSession static class NoDataSourceConfiguration { @@ -410,7 +440,7 @@ static class CustomMaxInactiveIntervalInSecondsAnnotationConfiguration { static class CustomMaxInactiveIntervalInSecondsSetterConfiguration extends JdbcHttpSessionConfiguration { CustomMaxInactiveIntervalInSecondsSetterConfiguration() { - setMaxInactiveIntervalInSeconds(MAX_INACTIVE_INTERVAL_IN_SECONDS); + setMaxInactiveInterval(Duration.ofSeconds(MAX_INACTIVE_INTERVAL_IN_SECONDS)); } } 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 index 75c4792a0..5c9acc630 100644 --- 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 @@ -26,11 +26,21 @@ public class SessionConfig implements BeanClassLoaderAware { VALUES (?, ?, convert_from(?, 'UTF8')::jsonb) """; + private static final String UPDATE_SESSION_ATTRIBUTE_QUERY = """ + UPDATE %TABLE_NAME%_ATTRIBUTES + SET ATTRIBUTE_BYTES = encode(?, 'escape')::jsonb + WHERE SESSION_PRIMARY_ID = ? + AND ATTRIBUTE_NAME = ? + """; + private ClassLoader classLoader; @Bean SessionRepositoryCustomizer customizer() { - return (sessionRepository) -> sessionRepository.setCreateSessionAttributeQuery(CREATE_SESSION_ATTRIBUTE_QUERY); + return (sessionRepository) -> { + sessionRepository.setCreateSessionAttributeQuery(CREATE_SESSION_ATTRIBUTE_QUERY); + sessionRepository.setUpdateSessionAttributeQuery(UPDATE_SESSION_ATTRIBUTE_QUERY); + }; } @Bean("springSessionConversionService") 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 index 6ca6bb1b8..0ebe919eb 100644 --- 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 @@ -1,9 +1,13 @@ 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; @@ -11,7 +15,7 @@ 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.JdbcTemplate; +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; @@ -23,7 +27,7 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureMockMvc @Import(TestContainersConfig.class) -public class JdbcJsonAttributeTests { +class JdbcJsonAttributeTests { @Autowired MockMvc mvc; @@ -34,15 +38,15 @@ public class JdbcJsonAttributeTests { ObjectMapper objectMapperWithModules; @Autowired - JdbcTemplate jdbcClient; + JdbcClient jdbcClient; @BeforeEach void setup() { ObjectMapper copy = this.objectMapper.copy(); copy.registerModules(SecurityJackson2Modules.getModules(getClass().getClassLoader())); this.objectMapperWithModules = copy; - this.jdbcClient.execute("DELETE FROM spring_session_attributes"); - this.jdbcClient.execute("DELETE FROM spring_session"); + this.jdbcClient.sql("DELETE FROM spring_session_attributes").update(); + this.jdbcClient.sql("DELETE FROM spring_session").update(); } @Test @@ -53,12 +57,12 @@ void loginShouldSaveSecurityContextAsJson() throws Exception { .getResponse() .getCookie("SESSION"); String sessionId = new String(Base64.getDecoder().decode(sessionCookie.getValue())); - Object attributeBytes = this.jdbcClient.queryForObject(""" + 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 = ? - """, Object.class, sessionId); + AND s.session_id = :id + """).param("id", sessionId).query().singleValue(); SecurityContext securityContext = this.objectMapperWithModules.readValue((String) attributeBytes, SecurityContext.class); assertThat(securityContext).isNotNull(); @@ -67,11 +71,11 @@ void loginShouldSaveSecurityContextAsJson() throws Exception { @Test void loginWhenQueryUsingJsonbOperatorThenReturns() throws Exception { - this.mvc.perform(formLogin().user("rüdiger").password("password")).andExpect(authenticated()); - Object attributeBytes = this.jdbcClient.queryForObject(""" + this.mvc.perform(formLogin().user("rüdiger").password("password")).andExpect(authenticated()); + Object attributeBytes = this.jdbcClient.sql(""" SELECT attribute_bytes::text FROM spring_session_attributes WHERE attribute_bytes -> 'authentication' -> 'principal' ->> 'username' = 'rüdiger' - """, Object.class); + """).query().singleValue(); SecurityContext securityContext = this.objectMapperWithModules.readValue((String) attributeBytes, SecurityContext.class); assertThat(securityContext).isNotNull(); diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/spring-session-sample-boot-mongodb-reactive.gradle b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/spring-session-sample-boot-mongodb-reactive.gradle index 55c76d61b..c136f300c 100644 --- a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/spring-session-sample-boot-mongodb-reactive.gradle +++ b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/spring-session-sample-boot-mongodb-reactive.gradle @@ -2,11 +2,12 @@ apply plugin: 'io.spring.convention.spring-sample-boot' dependencies { management platform(project(":spring-session-dependencies")) - implementation project(':spring-session-data-mongodb') - implementation "org.springframework.boot:spring-boot-starter-webflux" - implementation "org.springframework.boot:spring-boot-starter-thymeleaf" - implementation "org.springframework.boot:spring-boot-starter-data-mongodb-reactive" - implementation "org.testcontainers:mongodb" + implementation project(':spring-session-data-mongodb') + implementation "org.springframework.boot:spring-boot-starter-webflux" + implementation "org.springframework.boot:spring-boot-starter-thymeleaf" + implementation "org.springframework.boot:spring-boot-starter-data-mongodb-reactive" + implementation "org.springframework.boot:spring-boot-testcontainers" + implementation "org.testcontainers:mongodb" testImplementation "org.springframework.boot:spring-boot-starter-test" testImplementation "org.seleniumhq.selenium:htmlunit-driver" diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/MongoDbConfig.java b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/MongoDbConfig.java new file mode 100644 index 000000000..14bab6d17 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/MongoDbConfig.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023 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.session.mongodb.examples; + +import org.testcontainers.containers.MongoDBContainer; + +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Yanming Zhou + */ +@Configuration(proxyBeanMethods = false) +public class MongoDbConfig { + + @Bean + @ServiceConnection + MongoDBContainer mongoDbContainer() { + return new MongoDBContainer("mongo:5.0.11"); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/SpringSessionMongoReactiveApplication.java b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/SpringSessionMongoReactiveApplication.java index f1c55a26e..fb8fca91b 100644 --- a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/SpringSessionMongoReactiveApplication.java +++ b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/SpringSessionMongoReactiveApplication.java @@ -16,17 +16,8 @@ package org.springframework.session.mongodb.examples; -import java.util.HashMap; -import java.util.Map; - -import org.testcontainers.containers.MongoDBContainer; - import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.ApplicationContextInitializer; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.core.env.MapPropertySource; import org.springframework.session.data.mongo.config.annotation.web.reactive.EnableMongoWebSession; /** @@ -35,44 +26,14 @@ * * @author Rob Winch * @author Greg Turnquist + * @author Yanming Zhou */ @SpringBootApplication @EnableMongoWebSession public class SpringSessionMongoReactiveApplication { public static void main(String[] args) { - SpringApplication application = new SpringApplication(SpringSessionMongoReactiveApplication.class); - application.addInitializers(new Initializer()); - application.run(args); - } - - /** - * Use Testcontainers to managed MongoDB through Docker. - *

- * - * @see Local - * Development with Testcontainers - */ - static class Initializer implements ApplicationContextInitializer { - - static MongoDBContainer mongo = new MongoDBContainer("mongo:5.0.11"); - - private static Map getProperties() { - mongo.start(); - - HashMap properties = new HashMap<>(); - properties.put("spring.data.mongodb.host", mongo.getHost()); - properties.put("spring.data.mongodb.port", mongo.getFirstMappedPort() + ""); - return properties; - } - - @Override - public void initialize(ConfigurableApplicationContext context) { - ConfigurableEnvironment env = context.getEnvironment(); - env.getPropertySources().addFirst(new MapPropertySource("testcontainers", (Map) getProperties())); - } - + SpringApplication.run(SpringSessionMongoReactiveApplication.class, args); } } diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/test/java/org/springframework/session/mongodb/examples/AttributeTests.java b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/test/java/org/springframework/session/mongodb/examples/AttributeTests.java index 00468e158..a284fd955 100644 --- a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/test/java/org/springframework/session/mongodb/examples/AttributeTests.java +++ b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/test/java/org/springframework/session/mongodb/examples/AttributeTests.java @@ -21,7 +21,6 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.openqa.selenium.WebDriver; import org.openqa.selenium.htmlunit.HtmlUnitDriver; @@ -29,19 +28,16 @@ import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.session.mongodb.examples.pages.HomePage; import org.springframework.session.mongodb.examples.pages.HomePage.Attribute; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.junit.jupiter.SpringExtension; import static org.assertj.core.api.Assertions.assertThat; /** * @author Eddú Meléndez * @author Rob Winch + * @author Yanming Zhou */ -@ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@ContextConfiguration(initializers = SpringSessionMongoReactiveApplication.Initializer.class) -public class AttributeTests { +class AttributeTests { @LocalServerPort int port; diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/spring-session-sample-boot-mongodb-traditional.gradle b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/spring-session-sample-boot-mongodb-traditional.gradle index 7e8d1e86a..2abb0a640 100644 --- a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/spring-session-sample-boot-mongodb-traditional.gradle +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/spring-session-sample-boot-mongodb-traditional.gradle @@ -9,6 +9,7 @@ dependencies { implementation "org.thymeleaf.extras:thymeleaf-extras-springsecurity6" implementation "org.springframework.boot:spring-boot-starter-data-mongodb" implementation "org.springframework.boot:spring-boot-starter-security" + implementation "org.springframework.boot:spring-boot-testcontainers" implementation "org.testcontainers:mongodb" diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/SpringSessionMongoTraditionalBoot.java b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/SpringSessionMongoTraditionalBoot.java index a63334ddf..686c97467 100644 --- a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/SpringSessionMongoTraditionalBoot.java +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/SpringSessionMongoTraditionalBoot.java @@ -16,57 +16,18 @@ package org.springframework.session.mongodb.examples; -import java.util.HashMap; -import java.util.Map; - -import org.testcontainers.containers.MongoDBContainer; - import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.ApplicationContextInitializer; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.core.env.MapPropertySource; /** * @author Rob Winch + * @author Yanming Zhou */ @SpringBootApplication public class SpringSessionMongoTraditionalBoot { public static void main(String[] args) { - SpringApplication application = new SpringApplication(SpringSessionMongoTraditionalBoot.class); - application.addInitializers(new Initializer()); - application.run(args); - } - - /** - * Use Testcontainers to managed MongoDB through Docker. - *

- * - * @see Local - * Developmenet with Testcontainers - */ - static class Initializer implements ApplicationContextInitializer { - - static MongoDBContainer mongo = new MongoDBContainer("mongo:5.0.11"); - - private static Map getProperties() { - mongo.start(); - - HashMap properties = new HashMap<>(); - properties.put("spring.data.mongodb.host", mongo.getHost()); - properties.put("spring.data.mongodb.port", mongo.getFirstMappedPort() + ""); - return properties; - } - - @Override - public void initialize(ConfigurableApplicationContext context) { - ConfigurableEnvironment env = context.getEnvironment(); - env.getPropertySources().addFirst(new MapPropertySource("testcontainers", (Map) getProperties())); - } - + SpringApplication.run(SpringSessionMongoTraditionalBoot.class, args); } } diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/config/MongoDbConfig.java b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/config/MongoDbConfig.java new file mode 100644 index 000000000..67330bfdb --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/config/MongoDbConfig.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023 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.session.mongodb.examples.config; + +import org.testcontainers.containers.MongoDBContainer; + +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Yanming Zhou + */ +@Configuration(proxyBeanMethods = false) +public class MongoDbConfig { + + @Bean + @ServiceConnection + MongoDBContainer mongoDbContainer() { + return new MongoDBContainer("mongo:5.0.11"); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/BootTests.java b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/BootTests.java index c91e193bf..7869dd9fa 100644 --- a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/BootTests.java +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/BootTests.java @@ -21,7 +21,6 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.openqa.selenium.By; import org.openqa.selenium.Cookie; import org.openqa.selenium.WebDriver; @@ -30,11 +29,8 @@ 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.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.session.mongodb.examples.pages.HomePage; import org.springframework.session.mongodb.examples.pages.LoginPage; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.htmlunit.webdriver.MockMvcHtmlUnitDriverBuilder; @@ -42,12 +38,11 @@ /** * @author Pool Dolorier + * @author Yanming Zhou */ -@ExtendWith(SpringExtension.class) @AutoConfigureMockMvc -@SpringBootTest(webEnvironment = WebEnvironment.MOCK) -@ContextConfiguration(initializers = SpringSessionMongoTraditionalBoot.Initializer.class) -public class BootTests { +@SpringBootTest +class BootTests { @Autowired private MockMvc mockMvc; diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/spring-session-sample-boot-reactive-max-sessions.gradle b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/spring-session-sample-boot-reactive-max-sessions.gradle new file mode 100644 index 000000000..dcaa45ba2 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/spring-session-sample-boot-reactive-max-sessions.gradle @@ -0,0 +1,23 @@ +apply plugin: 'io.spring.convention.spring-sample-boot' + +ext['spring-security.version'] = '6.3.0-SNAPSHOT' + +dependencies { + management platform(project(":spring-session-dependencies")) + implementation project(':spring-session-data-redis') + implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.projectreactor:reactor-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.seleniumhq.selenium:selenium-java' + testImplementation 'org.seleniumhq.selenium:htmlunit-driver' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/HelloController.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/HelloController.java new file mode 100644 index 000000000..72c28c526 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/HelloController.java @@ -0,0 +1,32 @@ +/* + * Copyright 2014-2023 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 com.example; + +import reactor.core.publisher.Mono; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class HelloController { + + @GetMapping("/hello") + Mono hello() { + return Mono.just("Hello!"); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/IndexController.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/IndexController.java new file mode 100644 index 000000000..8fdd7f724 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/IndexController.java @@ -0,0 +1,43 @@ +/* + * Copyright 2014-2023 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 com.example; + +import reactor.core.publisher.Mono; + +import org.springframework.security.core.Authentication; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +class IndexController { + + private final ReactiveFindByIndexNameSessionRepository sessionRepository; + + IndexController(ReactiveFindByIndexNameSessionRepository sessionRepository) { + this.sessionRepository = sessionRepository; + } + + @GetMapping("/") + Mono index(Model model, Authentication authentication) { + return this.sessionRepository.findByPrincipalName(authentication.getName()) + .doOnNext((sessions) -> model.addAttribute("sessions", sessions.values())) + .thenReturn("index"); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SecurityConfig.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SecurityConfig.java new file mode 100644 index 000000000..c16fd647b --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SecurityConfig.java @@ -0,0 +1,91 @@ +/* + * Copyright 2014-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 com.example; + +import reactor.core.publisher.Mono; + +import org.springframework.boot.autoconfigure.security.reactive.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.session.ReactiveSessionRegistry; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.session.SessionAuthenticationException; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.authentication.PreventLoginServerMaximumSessionsExceededHandler; +import org.springframework.security.web.server.authentication.SessionLimit; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.Session; +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository; +import org.springframework.session.security.SpringSessionBackedReactiveSessionRegistry; + +@Configuration(proxyBeanMethods = false) +@EnableWebFluxSecurity +public class SecurityConfig { + + @Bean + SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + // @formatter:off + return http + .authorizeExchange(exchanges -> exchanges + .matchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .anyExchange().authenticated()) + .formLogin(Customizer.withDefaults()) + .sessionManagement((sessions) -> sessions + .concurrentSessions((concurrency) -> concurrency + .maximumSessions((authentication) -> { + if (authentication.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_UNLIMITED_SESSIONS"))) { + return Mono.empty(); + } + return Mono.just(1); + }) + .maximumSessionsExceededHandler(new PreventLoginServerMaximumSessionsExceededHandler()) + ) + ) + .build(); + // @formatter:on + } + + @Bean + SpringSessionBackedReactiveSessionRegistry sessionRegistry( + ReactiveSessionRepository sessionRepository, + ReactiveFindByIndexNameSessionRepository indexedSessionRepository) { + return new SpringSessionBackedReactiveSessionRegistry<>(sessionRepository, indexedSessionRepository); + } + + @Bean + MapReactiveUserDetailsService reactiveUserDetailsService() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build(); + UserDetails unlimited = User.withDefaultPasswordEncoder() + .username("unlimited") + .password("password") + .roles("USER", "UNLIMITED_SESSIONS") + .build(); + return new MapReactiveUserDetailsService(user, unlimited); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SessionConfig.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SessionConfig.java new file mode 100644 index 000000000..637652ee2 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SessionConfig.java @@ -0,0 +1,26 @@ +/* + * Copyright 2014-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 com.example; + +import org.springframework.context.annotation.Configuration; +import org.springframework.session.data.redis.config.annotation.web.server.EnableRedisIndexedWebSession; + +@Configuration(proxyBeanMethods = false) +@EnableRedisIndexedWebSession +public class SessionConfig { + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SpringSessionSampleBootReactiveMaxSessions.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SpringSessionSampleBootReactiveMaxSessions.java new file mode 100644 index 000000000..195d0e4e7 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SpringSessionSampleBootReactiveMaxSessions.java @@ -0,0 +1,29 @@ +/* + * Copyright 2014-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 com.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringSessionSampleBootReactiveMaxSessions { + + public static void main(String[] args) { + SpringApplication.run(SpringSessionSampleBootReactiveMaxSessions.class, args); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/resources/application.properties b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/resources/application.properties new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/resources/templates/index.html b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/resources/templates/index.html new file mode 100644 index 000000000..816e88d84 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/resources/templates/index.html @@ -0,0 +1,16 @@ + + + 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-reactive-max-sessions/src/test/java/com/example/BasePage.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/BasePage.java new file mode 100644 index 000000000..775f823d8 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/BasePage.java @@ -0,0 +1,41 @@ +/* + * Copyright 2014-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 com.example; + +import org.openqa.selenium.WebDriver; + +/** + * @author Eddú Meléndez + */ +public class BasePage { + + private WebDriver driver; + + public BasePage(WebDriver driver) { + this.driver = driver; + } + + public WebDriver getDriver() { + return this.driver; + } + + public static void get(WebDriver driver, String get) { + String baseUrl = "http://localhost"; + driver.get(baseUrl + get); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/HomePage.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/HomePage.java new file mode 100644 index 000000000..8ca42643b --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/HomePage.java @@ -0,0 +1,96 @@ +/* + * Copyright 2014-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 com.example; + +import java.util.ArrayList; +import java.util.List; + +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; +import org.openqa.selenium.support.pagefactory.DefaultElementLocatorFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HomePage { + + private WebDriver driver; + + @FindBy(css = "table tbody tr") + List trs; + + List attributes; + + public HomePage(WebDriver driver) { + this.driver = driver; + this.attributes = new ArrayList<>(); + } + + private static void get(WebDriver driver, int port, String get) { + String baseUrl = "http://localhost:" + port; + driver.get(baseUrl + get); + } + + public static LoginPage go(WebDriver driver, int port) { + get(driver, port, "/"); + return PageFactory.initElements(driver, LoginPage.class); + } + + public void assertAt() { + assertThat(this.driver.getTitle()).isEqualTo("Session Attributes"); + } + + public List attributes() { + List rows = new ArrayList<>(); + for (WebElement tr : this.trs) { + rows.add(new Attribute(tr)); + } + this.attributes.addAll(rows); + return this.attributes; + } + + public static class Attribute { + + @FindBy(xpath = ".//td[1]") + WebElement attributeName; + + @FindBy(xpath = ".//td[2]") + WebElement attributeValue; + + public Attribute(SearchContext context) { + PageFactory.initElements(new DefaultElementLocatorFactory(context), this); + } + + /** + * @return the attributeName + */ + public String getAttributeName() { + return this.attributeName.getText(); + } + + /** + * @return the attributeValue + */ + public String getAttributeValue() { + return this.attributeValue.getText(); + } + + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/LoginPage.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/LoginPage.java new file mode 100644 index 000000000..5fcfdd99f --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/LoginPage.java @@ -0,0 +1,66 @@ +/* + * Copyright 2014-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 com.example; + +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; +import org.openqa.selenium.support.pagefactory.DefaultElementLocatorFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +public class LoginPage extends BasePage { + + public LoginPage(WebDriver driver) { + super(driver); + } + + public void assertAt() { + assertThat(getDriver().getTitle()).isEqualTo("Please sign in"); + } + + public Form form() { + return new Form(getDriver()); + } + + public class Form { + + @FindBy(name = "username") + private WebElement username; + + @FindBy(name = "password") + private WebElement password; + + @FindBy(tagName = "button") + private WebElement button; + + public Form(SearchContext context) { + PageFactory.initElements(new DefaultElementLocatorFactory(context), this); + } + + public T login(Class page) { + this.username.sendKeys("user"); + this.password.sendKeys("password"); + this.button.click(); + return PageFactory.initElements(getDriver(), page); + } + + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/SpringSessionSampleBootReactiveMaxSessionsTests.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/SpringSessionSampleBootReactiveMaxSessionsTests.java new file mode 100644 index 000000000..c751ec74e --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/SpringSessionSampleBootReactiveMaxSessionsTests.java @@ -0,0 +1,132 @@ +/* + * Copyright 2014-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 com.example; + +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.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureWebTestClient +@Import(TestcontainersConfig.class) +class SpringSessionSampleBootReactiveMaxSessionsTests { + + @Autowired + WebTestClient client; + + @Autowired + ReactiveRedisConnectionFactory redisConnectionFactory; + + @BeforeEach + void setup() { + this.redisConnectionFactory.getReactiveConnection().serverCommands().flushAll().block(); + } + + @Test + void loginWhenUserAndMaximumSessionsOf1ExceededThenSecondLoginProhibited() { + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "user"); + data.add("password", "password"); + + ResponseCookie firstLoginCookie = loginReturningCookie(data); + login(data).expectStatus().isFound().expectHeader().location("/login?error"); + + performHello(firstLoginCookie).expectStatus().isOk(); + } + + @Test + void loginWhenUserAndMaximumSessionsOf1ExceededThenSecondAndThirdLoginProhibited() { + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "user"); + data.add("password", "password"); + + ResponseCookie firstLoginCookie = loginReturningCookie(data); + ResponseCookie secondLoginCookie = login(data).expectStatus() + .isFound() + .expectHeader() + .location("/login?error") + .returnResult(Void.class) + .getResponseCookies() + .getFirst("SESSION"); + ResponseCookie thirdLoginCookie = login(data).expectStatus() + .isFound() + .expectHeader() + .location("/login?error") + .returnResult(Void.class) + .getResponseCookies() + .getFirst("SESSION"); + assertThat(secondLoginCookie).isNull(); + assertThat(thirdLoginCookie).isNull(); + + performHello(firstLoginCookie).expectStatus().isOk().expectBody(String.class).isEqualTo("Hello!"); + } + + @Test + void loginWhenAuthenticationHasUnlimitedSessionsThenLoginIsAlwaysAllowed() { + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "unlimited"); + data.add("password", "password"); + + ResponseCookie firstLoginCookie = loginReturningCookie(data); + ResponseCookie secondLoginCookie = loginReturningCookie(data); + ResponseCookie thirdLoginCookie = loginReturningCookie(data); + ResponseCookie fourthLoginCookie = loginReturningCookie(data); + ResponseCookie fifthLoginCookie = loginReturningCookie(data); + + performHello(firstLoginCookie).expectStatus().isOk().expectBody(String.class).isEqualTo("Hello!"); + performHello(secondLoginCookie).expectStatus().isOk().expectBody(String.class).isEqualTo("Hello!"); + performHello(thirdLoginCookie).expectStatus().isOk().expectBody(String.class).isEqualTo("Hello!"); + performHello(fourthLoginCookie).expectStatus().isOk().expectBody(String.class).isEqualTo("Hello!"); + performHello(fifthLoginCookie).expectStatus().isOk().expectBody(String.class).isEqualTo("Hello!"); + } + + private WebTestClient.ResponseSpec performHello(ResponseCookie cookie) { + return this.client.get().uri("/hello").cookie(cookie.getName(), cookie.getValue()).exchange(); + } + + private ResponseCookie loginReturningCookie(MultiValueMap data) { + return login(data).expectCookie() + .exists("SESSION") + .returnResult(Void.class) + .getResponseCookies() + .getFirst("SESSION"); + } + + private WebTestClient.ResponseSpec login(MultiValueMap data) { + return this.client.mutateWith(csrf()) + .post() + .uri("/login") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromFormData(data)) + .exchange(); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/TestApplication.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/TestApplication.java new file mode 100644 index 000000000..62ac4b3a0 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/TestApplication.java @@ -0,0 +1,31 @@ +/* + * Copyright 2014-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 com.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.test.context.TestConfiguration; + +@TestConfiguration(proxyBeanMethods = false) +public class TestApplication { + + public static void main(String[] args) { + SpringApplication.from(SpringSessionSampleBootReactiveMaxSessions::main) + .with(TestcontainersConfig.class) + .run(args); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/TestcontainersConfig.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/TestcontainersConfig.java new file mode 100644 index 000000000..e91933f51 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/TestcontainersConfig.java @@ -0,0 +1,35 @@ +/* + * Copyright 2014-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 com.example; + +import org.testcontainers.containers.GenericContainer; +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 { + + @Bean + @ServiceConnection(name = "redis") + GenericContainer redisContainer() { + return new GenericContainer<>(DockerImageName.parse("redis:6.2.6")).withExposedPorts(6379); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/spring-session-sample-boot-reactive-redis-indexed.gradle b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/spring-session-sample-boot-reactive-redis-indexed.gradle new file mode 100644 index 000000000..d4412d8fd --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/spring-session-sample-boot-reactive-redis-indexed.gradle @@ -0,0 +1,21 @@ +apply plugin: 'io.spring.convention.spring-sample-boot' + +dependencies { + management platform(project(":spring-session-dependencies")) + implementation project(':spring-session-data-redis') + implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.projectreactor:reactor-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.seleniumhq.selenium:selenium-java' + testImplementation 'org.seleniumhq.selenium:htmlunit-driver' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/IndexController.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/IndexController.java new file mode 100644 index 000000000..8fdd7f724 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/IndexController.java @@ -0,0 +1,43 @@ +/* + * Copyright 2014-2023 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 com.example; + +import reactor.core.publisher.Mono; + +import org.springframework.security.core.Authentication; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +class IndexController { + + private final ReactiveFindByIndexNameSessionRepository sessionRepository; + + IndexController(ReactiveFindByIndexNameSessionRepository sessionRepository) { + this.sessionRepository = sessionRepository; + } + + @GetMapping("/") + Mono index(Model model, Authentication authentication) { + return this.sessionRepository.findByPrincipalName(authentication.getName()) + .doOnNext((sessions) -> model.addAttribute("sessions", sessions.values())) + .thenReturn("index"); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SecurityConfig.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SecurityConfig.java new file mode 100644 index 000000000..86a7d66c1 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SecurityConfig.java @@ -0,0 +1,60 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.springframework.boot.autoconfigure.security.reactive.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.server.SecurityWebFilterChain; + +@Configuration(proxyBeanMethods = false) +@EnableWebFluxSecurity +public class SecurityConfig { + + @Bean + SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + return http + .authorizeExchange(exchanges -> exchanges.matchers(PathRequest.toStaticResources().atCommonLocations()) + .permitAll() + .anyExchange() + .authenticated()) + .formLogin(Customizer.withDefaults()) + .build(); + } + + @Bean + MapReactiveUserDetailsService reactiveUserDetailsService() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build(); + UserDetails admin = User.withDefaultPasswordEncoder() + .username("admin") + .password("password") + .roles("USER", "ADMIN") + .build(); + return new MapReactiveUserDetailsService(user, admin); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SessionConfig.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SessionConfig.java new file mode 100644 index 000000000..107566c14 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SessionConfig.java @@ -0,0 +1,26 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.springframework.context.annotation.Configuration; +import org.springframework.session.data.redis.config.annotation.web.server.EnableRedisIndexedWebSession; + +@Configuration(proxyBeanMethods = false) +@EnableRedisIndexedWebSession +public class SessionConfig { + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplication.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplication.java new file mode 100644 index 000000000..16cff4554 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringSessionSampleBootReactiveRedisIndexedApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringSessionSampleBootReactiveRedisIndexedApplication.class, args); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/resources/application.properties b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/resources/application.properties new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/resources/templates/index.html b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/resources/templates/index.html new file mode 100644 index 000000000..816e88d84 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/resources/templates/index.html @@ -0,0 +1,16 @@ + + + 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-reactive-redis-indexed/src/test/java/com/example/BasePage.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/BasePage.java new file mode 100644 index 000000000..30926c3b8 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/BasePage.java @@ -0,0 +1,41 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.openqa.selenium.WebDriver; + +/** + * @author Eddú Meléndez + */ +public class BasePage { + + private WebDriver driver; + + public BasePage(WebDriver driver) { + this.driver = driver; + } + + public WebDriver getDriver() { + return this.driver; + } + + public static void get(WebDriver driver, String get) { + String baseUrl = "http://localhost"; + driver.get(baseUrl + get); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/HomePage.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/HomePage.java new file mode 100644 index 000000000..fb55e2712 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/HomePage.java @@ -0,0 +1,96 @@ +/* + * Copyright 2014-2023 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 com.example; + +import java.util.ArrayList; +import java.util.List; + +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; +import org.openqa.selenium.support.pagefactory.DefaultElementLocatorFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HomePage { + + private WebDriver driver; + + @FindBy(css = "table tbody tr") + List trs; + + List attributes; + + public HomePage(WebDriver driver) { + this.driver = driver; + this.attributes = new ArrayList<>(); + } + + private static void get(WebDriver driver, int port, String get) { + String baseUrl = "http://localhost:" + port; + driver.get(baseUrl + get); + } + + public static LoginPage go(WebDriver driver, int port) { + get(driver, port, "/"); + return PageFactory.initElements(driver, LoginPage.class); + } + + public void assertAt() { + assertThat(this.driver.getTitle()).isEqualTo("Session Attributes"); + } + + public List attributes() { + List rows = new ArrayList<>(); + for (WebElement tr : this.trs) { + rows.add(new Attribute(tr)); + } + this.attributes.addAll(rows); + return this.attributes; + } + + public static class Attribute { + + @FindBy(xpath = ".//td[1]") + WebElement attributeName; + + @FindBy(xpath = ".//td[2]") + WebElement attributeValue; + + public Attribute(SearchContext context) { + PageFactory.initElements(new DefaultElementLocatorFactory(context), this); + } + + /** + * @return the attributeName + */ + public String getAttributeName() { + return this.attributeName.getText(); + } + + /** + * @return the attributeValue + */ + public String getAttributeValue() { + return this.attributeValue.getText(); + } + + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/LoginPage.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/LoginPage.java new file mode 100644 index 000000000..795e12e40 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/LoginPage.java @@ -0,0 +1,66 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; +import org.openqa.selenium.support.pagefactory.DefaultElementLocatorFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +public class LoginPage extends BasePage { + + public LoginPage(WebDriver driver) { + super(driver); + } + + public void assertAt() { + assertThat(getDriver().getTitle()).isEqualTo("Please sign in"); + } + + public Form form() { + return new Form(getDriver()); + } + + public class Form { + + @FindBy(name = "username") + private WebElement username; + + @FindBy(name = "password") + private WebElement password; + + @FindBy(tagName = "button") + private WebElement button; + + public Form(SearchContext context) { + PageFactory.initElements(new DefaultElementLocatorFactory(context), this); + } + + public T login(Class page) { + this.username.sendKeys("user"); + this.password.sendKeys("password"); + this.button.click(); + return PageFactory.initElements(getDriver(), page); + } + + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplicationTestApplication.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplicationTestApplication.java new file mode 100644 index 000000000..b7307f10b --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplicationTestApplication.java @@ -0,0 +1,31 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.test.context.TestConfiguration; + +@TestConfiguration(proxyBeanMethods = false) +public class SpringSessionSampleBootReactiveRedisIndexedApplicationTestApplication { + + public static void main(String[] args) { + SpringApplication.from(SpringSessionSampleBootReactiveRedisIndexedApplication::main) + .with(TestcontainersConfig.class) + .run(args); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplicationTests.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplicationTests.java new file mode 100644 index 000000000..31a6cf1d3 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplicationTests.java @@ -0,0 +1,58 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.htmlunit.HtmlUnitDriver; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(TestcontainersConfig.class) +class SpringSessionSampleBootReactiveRedisIndexedApplicationTests { + + WebDriver driver; + + @LocalServerPort + int serverPort; + + @BeforeEach + void setup() { + this.driver = new HtmlUnitDriver(); + } + + @AfterEach + void tearDown() { + this.driver.quit(); + } + + @Test + void indexWhenLoginThenShowSessionIds() { + LoginPage login = HomePage.go(this.driver, this.serverPort); + login.assertAt(); + HomePage home = login.form().login(HomePage.class); + assertThat(home.attributes()).hasSize(1); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/TestcontainersConfig.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/TestcontainersConfig.java new file mode 100644 index 000000000..7be556a06 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/TestcontainersConfig.java @@ -0,0 +1,35 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.testcontainers.containers.GenericContainer; +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 { + + @Bean + @ServiceConnection(name = "redis") + GenericContainer redisContainer() { + return new GenericContainer<>(DockerImageName.parse("redis:6.2.6")).withExposedPorts(6379); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-redis-json/src/main/java/sample/config/SessionConfig.java b/spring-session-samples/spring-session-sample-boot-redis-json/src/main/java/sample/config/SessionConfig.java index b1a521bb6..85523780e 100644 --- a/spring-session-samples/spring-session-sample-boot-redis-json/src/main/java/sample/config/SessionConfig.java +++ b/spring-session-samples/spring-session-sample-boot-redis-json/src/main/java/sample/config/SessionConfig.java @@ -34,6 +34,11 @@ public class SessionConfig implements BeanClassLoaderAware { private ClassLoader loader; + /** + * Note that the bean name for this bean is intentionally + * {@code springSessionDefaultRedisSerializer}. It must be named this way to override + * the default {@link RedisSerializer} used by Spring Session. + */ @Bean public RedisSerializer springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(objectMapper()); diff --git a/spring-session-samples/spring-session-sample-boot-websocket/src/main/java/sample/config/WebSecurityConfig.java b/spring-session-samples/spring-session-sample-boot-websocket/src/main/java/sample/config/WebSecurityConfig.java index 1c767a8c1..81871ec15 100644 --- a/spring-session-samples/spring-session-sample-boot-websocket/src/main/java/sample/config/WebSecurityConfig.java +++ b/spring-session-samples/spring-session-sample-boot-websocket/src/main/java/sample/config/WebSecurityConfig.java @@ -21,7 +21,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; -import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.core.userdetails.UserDetailsService; @@ -29,7 +29,7 @@ import org.springframework.security.web.SecurityFilterChain; @Configuration -@EnableGlobalMethodSecurity(prePostEnabled = true) +@EnableMethodSecurity public class WebSecurityConfig { // @formatter:off