Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82aaa5f54e | |||
| 461ccd817d | |||
| 381c556f78 | |||
| 3b276e6c0d | |||
| f2115fbef8 | |||
| 298335a57e | |||
| c6566ee772 | |||
| 7442214225 | |||
| 0123ae0981 | |||
| 11a648d106 | |||
| 536e09756c | |||
| ac15cdbea3 | |||
| ad812aefc4 | |||
| d8769fb183 | |||
| ae997a4f46 | |||
| bbc6273abf | |||
| 475af0d3db | |||
| f8a61e66d1 | |||
| 8830e39073 | |||
| 304cc21523 | |||
| d111029078 | |||
| 5b923c78ea | |||
| 65a1099ccb | |||
| 9fdd2dc675 | |||
| 2aa3c1ab2e | |||
| 17c58ef088 | |||
| 63306ad8ee | |||
| 24aec1d002 | |||
| 863753580c | |||
| fa92ce3758 | |||
| 561f1bba0f | |||
| 268fce2616 | |||
| c4c547b73b | |||
| d487048224 | |||
| 1d15210ae2 | |||
| 5f377e5ec2 | |||
| e107329766 | |||
| 905b4786e8 | |||
| 36e7420bd0 | |||
| f03e612a14 | |||
| 2c4db99229 | |||
| 0f6f453ea0 | |||
| 1eef373ca0 | |||
| 7a03cd5a55 | |||
| e4fafce066 | |||
| 2104c49597 | |||
| c5b1f78118 | |||
| d2366650ec | |||
| 5338b7a05b | |||
| bd86d0b233 | |||
| f8442794a8 | |||
| 57c2af4900 | |||
| 6da4ea0d0d | |||
| 8f69721f24 | |||
| acd131c3d1 | |||
| 73b077790f | |||
| 62c4d60524 | |||
| d884913175 | |||
| fd5dae5e4d | |||
| d29c761135 | |||
| 700a4533aa | |||
| 3a394c633f | |||
| 79f9bec818 | |||
| 5b019365b7 | |||
| 038da0289e | |||
| f9b4afdfc5 | |||
| 65aaedbc83 | |||
| f325ddbdaa | |||
| bc9bdbaf83 | |||
| 6da29433cc | |||
| 4adfdf66b9 | |||
| e91b81a5aa | |||
| 0949d3b3ea | |||
| d44efc899d | |||
| c5870a746a | |||
| 58bca0e895 | |||
| 1bc3fd9ea9 | |||
| e84b949719 | |||
| 33bd1abb9d | |||
| ea62b200f7 | |||
| 5eb41b8a06 | |||
| 8f0c077fc7 | |||
| 430b9bb8c6 | |||
| 0fa81bfb6d | |||
| 54e9575391 | |||
| e3ad551ab3 | |||
| 7fc97c6ee2 | |||
| 241bf2c148 | |||
| cf06871200 | |||
| e50c2a6a74 | |||
| b175e4487e | |||
| 356131b1ea | |||
| 97a49aa3bf | |||
| e4a78c26d6 | |||
| c50e4c02bb | |||
| a14c9d66b1 | |||
| 2924dd29f7 | |||
| 9d4d9065b4 | |||
| 3f1a794cd9 | |||
| c5de4096b4 | |||
| 95d375c8d8 | |||
| b486d2695a | |||
| 8b34113c2a | |||
| fe2e52f646 | |||
| 0ea87ca3bf | |||
| 71fd23429c | |||
| 85514a9b34 | |||
| d039e0191f | |||
| cfdadfea01 | |||
| f70aa75445 | |||
| 86e0f1341e | |||
| cea6618265 | |||
| 5bf8f641f1 | |||
| 8b9f556d8f | |||
| d9b8a8ec54 | |||
| 4e04c266be | |||
| dc5c65eaf1 | |||
| 0353616be3 | |||
| 47853e1314 | |||
| 90e1658afe | |||
| e1d94d1e06 | |||
| a07c67a365 | |||
| 16cb219e4a | |||
| 944149ba71 | |||
| 9b14465243 | |||
| fdc0fd23df | |||
| 931dfbe8e7 | |||
| b075f0df02 | |||
| 8720a282ce | |||
| 6343002b32 | |||
| 0a9d4dc8fc |
@@ -18,13 +18,11 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- java-version: 21-ea
|
||||
toolchain: 21
|
||||
- java-version: 17
|
||||
toolchain: 17
|
||||
with:
|
||||
java-version: ${{ matrix.java-version }}
|
||||
test-args: --refresh-dependencies -PforceMavenRepositories=snapshot,https://oss.sonatype.org/content/repositories/snapshots -PisOverrideVersionCatalog -PtestToolchain=${{ matrix.toolchain }} -PspringFrameworkVersion=7.+ -PreactorVersion=2025.+ -PspringDataVersion=2025.+ --stacktrace
|
||||
test-args: --refresh-dependencies -PforceMavenRepositories=snapshot,https://oss.sonatype.org/content/repositories/snapshots -PisOverrideVersionCatalog -PtestToolchain=${{ matrix.toolchain }} -PspringFrameworkVersion=7.0.+ -PreactorVersion=2025.0.+ -PspringDataVersion=2025.1.+ -PmicrometerVersion=1.16.+ --stacktrace
|
||||
secrets: inherit
|
||||
send-notification:
|
||||
name: Send Notification
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
secrets: inherit
|
||||
deploy-artifacts:
|
||||
name: Deploy Artifacts
|
||||
needs: [ build]
|
||||
needs: [ build ]
|
||||
uses: spring-io/spring-security-release-tools/.github/workflows/deploy-artifacts.yml@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
|
||||
with:
|
||||
should-deploy-artifacts: ${{ needs.build.outputs.should-deploy-artifacts }}
|
||||
@@ -42,21 +42,9 @@ jobs:
|
||||
with:
|
||||
should-deploy-schema: ${{ needs.build.outputs.should-deploy-artifacts }}
|
||||
secrets: inherit
|
||||
perform-release:
|
||||
name: Perform Release
|
||||
needs: [ deploy-artifacts, deploy-schema ]
|
||||
uses: spring-io/spring-security-release-tools/.github/workflows/perform-release.yml@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
|
||||
with:
|
||||
should-perform-release: ${{ needs.deploy-artifacts.outputs.artifacts-deployed }}
|
||||
project-version: ${{ needs.deploy-artifacts.outputs.project-version }}
|
||||
milestone-repo-url: https://repo1.maven.org/maven2
|
||||
release-repo-url: https://repo1.maven.org/maven2
|
||||
artifact-path: org/springframework/security/spring-security-core
|
||||
slack-announcing-id: spring-security-announcing
|
||||
secrets: inherit
|
||||
send-notification:
|
||||
name: Send Notification
|
||||
needs: [ perform-release ]
|
||||
needs: [ deploy-artifacts, deploy-schema ]
|
||||
if: ${{ !success() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -15,24 +15,24 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Compute Version
|
||||
id: compute-version
|
||||
uses: spring-io/spring-release-actions/compute-version@2420148725bebe44bd59a575a9b1961ca4459b0b # 0.0.4
|
||||
uses: spring-io/spring-release-actions/compute-version@a1f321783a0769dd2aea4fad6c2ae2f95a52b885 # 0.0.5
|
||||
- name: Get Today's Release Version
|
||||
id: todays-release
|
||||
uses: spring-io/spring-release-actions/get-todays-release-version@2420148725bebe44bd59a575a9b1961ca4459b0b # 0.0.4
|
||||
uses: spring-io/spring-release-actions/get-todays-release-version@a1f321783a0769dd2aea4fad6c2ae2f95a52b885 # 0.0.5
|
||||
with:
|
||||
snapshot-version: ${{ steps.compute-version.outputs.version }}
|
||||
milestone-repository: ${{ github.repository }}
|
||||
milestone-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Compute Next Version
|
||||
id: next-version
|
||||
uses: spring-io/spring-release-actions/compute-next-version@2420148725bebe44bd59a575a9b1961ca4459b0b # 0.0.4
|
||||
uses: spring-io/spring-release-actions/compute-next-version@a1f321783a0769dd2aea4fad6c2ae2f95a52b885 # 0.0.5
|
||||
with:
|
||||
version: ${{ steps.todays-release.outputs.release-version }}
|
||||
- name: Schedule Next Milestone
|
||||
uses: spring-io/spring-release-actions/schedule-milestone@2420148725bebe44bd59a575a9b1961ca4459b0b # 0.0.4
|
||||
uses: spring-io/spring-release-actions/schedule-milestone@a1f321783a0769dd2aea4fad6c2ae2f95a52b885 # 0.0.5
|
||||
with:
|
||||
version: ${{ steps.next-version.outputs.version }}
|
||||
version-date: ${{ steps.next-version.outputs.version-date }}
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
if: github.repository_owner == 'spring-projects'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: docs-build
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
name: Finalize Release
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger
|
||||
inputs:
|
||||
version:
|
||||
description: The Spring Security release to finalize (e.g. 7.0.0-RC2)
|
||||
required: true
|
||||
|
||||
env:
|
||||
DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
perform-release:
|
||||
name: Perform Release
|
||||
uses: spring-io/spring-security-release-tools/.github/workflows/perform-release.yml@ed473b4dafba053c63a453d2d88a89df3b3e18b3 # v1
|
||||
with:
|
||||
should-perform-release: true
|
||||
project-version: ${{ inputs.version }}
|
||||
milestone-repo-url: https://repo1.maven.org/maven2
|
||||
release-repo-url: https://repo1.maven.org/maven2
|
||||
artifact-path: org/springframework/security/spring-security-core
|
||||
slack-announcing-id: spring-security-announcing
|
||||
secrets: inherit
|
||||
@@ -19,9 +19,9 @@ jobs:
|
||||
git config --global user.name 'github-actions[bot]'
|
||||
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Check Milestone
|
||||
on:
|
||||
milestone:
|
||||
types: [created, opened, edited]
|
||||
env:
|
||||
DUE_ON: ${{ github.event.milestone.due_on }}
|
||||
TITLE: ${{ github.event.milestone.title }}
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
spring-releasetrain-checks:
|
||||
name: Check DueOn is on a Release Date
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'spring-projects/spring-security' }}
|
||||
permissions:
|
||||
contents: none
|
||||
steps:
|
||||
- name: Print Milestone Being Checked
|
||||
run: echo "Validating DueOn '$DUE_ON' for milestone '$TITLE'"
|
||||
- name: Validate DueOn
|
||||
if: env.DUE_ON != ''
|
||||
run: |
|
||||
export TOOL_VERSION=0.1.1
|
||||
wget "https://repo.maven.apache.org/maven2/io/spring/releasetrain/spring-release-train-tools/$TOOL_VERSION/spring-release-train-tools-$TOOL_VERSION.jar"
|
||||
java -cp "spring-release-train-tools-$TOOL_VERSION.jar" io.spring.releasetrain.CheckMilestoneDueOnMain --dueOn "$DUE_ON" --expectedDayOfWeek MONDAY --expectedMondayCount 3
|
||||
send-notification:
|
||||
name: Send Notification
|
||||
needs: [ spring-releasetrain-checks ]
|
||||
if: ${{ failure() || cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send Notification
|
||||
uses: spring-io/spring-security-release-tools/.github/actions/send-notification@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
|
||||
with:
|
||||
webhook-url: ${{ secrets.SPRING_SECURITY_CI_GCHAT_WEBHOOK_URL }}
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'spring-projects/spring-security' }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up gradle
|
||||
uses: spring-io/spring-gradle-build-action@c8668747d7c264864c8c7f7026d0d277d14a78dc # v2.0.6
|
||||
with:
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'spring-projects/spring-security' }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up gradle
|
||||
uses: spring-io/spring-gradle-build-action@c8668747d7c264864c8c7f7026d0d277d14a78dc # v2.0.6
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Release Announcements - Publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: The version to publish
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
deploy-schema:
|
||||
name: Deploy Schema
|
||||
uses: spring-io/spring-security-release-tools/.github/workflows/deploy-schema.yml@d6c65d3013c0888e2c9cbae9f4beda610994776c # v1.0.16
|
||||
with:
|
||||
should-deploy-schema: true
|
||||
secrets: inherit
|
||||
publish-release-announcements:
|
||||
name: Publish Release Announcements
|
||||
needs: [ deploy-schema ]
|
||||
if: ${{ !github.event.repository.fork }}
|
||||
uses: spring-io/spring-security-release-tools/.github/workflows/release-announcements-publish.yml@d6c65d3013c0888e2c9cbae9f4beda610994776c # v1.0.16
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Release Announcements - Stage
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: The version to stage
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
stage-release-announcements:
|
||||
name: Stage Release Announcements
|
||||
if: ${{ !github.event.repository.fork }}
|
||||
uses: spring-io/spring-security-release-tools/.github/workflows/release-announcements-stage.yml@d6c65d3013c0888e2c9cbae9f4beda610994776c # v1.0.16
|
||||
with:
|
||||
version: ${{ inputs.version || github.ref_name }}
|
||||
secrets: inherit
|
||||
@@ -1,24 +0,0 @@
|
||||
name: Release Scheduler
|
||||
on:
|
||||
schedule:
|
||||
- cron: '15 15 * * MON' # Every Monday at 3:15pm UTC
|
||||
workflow_dispatch:
|
||||
permissions: read-all
|
||||
jobs:
|
||||
dispatch_scheduled_releases:
|
||||
name: Dispatch scheduled releases
|
||||
if: github.repository_owner == 'spring-projects'
|
||||
strategy:
|
||||
matrix:
|
||||
# List of active maintenance branches.
|
||||
branch: [ main, 6.5.x, 6.4.x, 6.3.x ]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Dispatch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}
|
||||
run: gh workflow run update-scheduled-release-version.yml -r ${{ matrix.branch }}
|
||||
@@ -1,23 +0,0 @@
|
||||
name: Update Scheduled Release Version
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger only. Triggered by release-scheduler.yml on main.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-scheduled-release-version:
|
||||
name: Update Scheduled Release Version
|
||||
uses: spring-io/spring-security-release-tools/.github/workflows/update-scheduled-release-version.yml@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
|
||||
secrets: inherit
|
||||
send-notification:
|
||||
name: Send Notification
|
||||
needs: [ update-scheduled-release-version ]
|
||||
if: ${{ failure() || cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send Notification
|
||||
uses: spring-io/spring-security-release-tools/.github/actions/send-notification@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
|
||||
with:
|
||||
webhook-url: ${{ secrets.SPRING_SECURITY_CI_GCHAT_WEBHOOK_URL }}
|
||||
@@ -13,6 +13,16 @@ repositories {
|
||||
gradlePluginPortal()
|
||||
mavenCentral()
|
||||
maven { url = 'https://repo.spring.io/snapshot' }
|
||||
if (System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_URL") != null) {
|
||||
maven {
|
||||
name = "Release Train"
|
||||
url = System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_URL")
|
||||
credentials {
|
||||
username = System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_USERNAME")
|
||||
password = System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
|
||||
+10
@@ -43,6 +43,16 @@ class RepositoryConventionPlugin implements Plugin<Project> {
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
if (System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_URL") != null) {
|
||||
maven {
|
||||
name = "Release Train"
|
||||
url = System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_URL")
|
||||
credentials {
|
||||
username = System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_USERNAME")
|
||||
password = System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isSnapshot) {
|
||||
maven {
|
||||
name = 'artifactory-snapshot'
|
||||
|
||||
+6
@@ -22,6 +22,12 @@ public class MavenPublishingConventionsPlugin implements Plugin<Project> {
|
||||
@Override
|
||||
public void execute(MavenPublishPlugin mavenPublish) {
|
||||
PublishingExtension publishing = project.getExtensions().getByType(PublishingExtension.class);
|
||||
if (project.hasProperty("deploymentRepository")) {
|
||||
publishing.getRepositories().maven((mavenRepository) -> {
|
||||
mavenRepository.setUrl(project.property("deploymentRepository"));
|
||||
mavenRepository.setName("deployment");
|
||||
});
|
||||
}
|
||||
publishing.getPublications().withType(MavenPublication.class)
|
||||
.all((mavenPublication) -> MavenPublishingConventionsPlugin.this.customizePom(mavenPublication.getPom(), project));
|
||||
MavenPublishingConventionsPlugin.this.customizeJavaPlugin(project);
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ public class CasAuthenticationToken extends AbstractAuthenticationToken implemen
|
||||
protected CasAuthenticationToken(Builder<?> builder) {
|
||||
super(builder);
|
||||
Assert.isTrue(!"".equals(builder.principal), "principal cannot be null or empty");
|
||||
Assert.notNull(!"".equals(builder.credentials), "credentials cannot be null or empty");
|
||||
Assert.isTrue(!"".equals(builder.credentials), "credentials cannot be null or empty");
|
||||
Assert.notNull(builder.userDetails, "userDetails cannot be null");
|
||||
Assert.notNull(builder.assertion, "assertion cannot be null");
|
||||
this.keyHash = builder.keyHash;
|
||||
|
||||
+35
@@ -34,6 +34,7 @@ import org.springframework.security.core.userdetails.UserDetails;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
|
||||
/**
|
||||
* Tests {@link CasAuthenticationToken}.
|
||||
@@ -182,4 +183,38 @@ public class CasAuthenticationTokenTests {
|
||||
assertThat(authorities).containsExactlyInAnyOrder("FACTOR_ONE", "FACTOR_TWO");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toBuilderWhenPrincipalIsEmpty() {
|
||||
final Assertion assertion = new AssertionImpl("test");
|
||||
CasAuthenticationToken token = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES,
|
||||
makeUserDetails(), assertion);
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> token.toBuilder().principal(null).build());
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> token.toBuilder().principal("").build());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toBuilderWhenPrincipalIsNotEmpty() {
|
||||
final Assertion assertion = new AssertionImpl("test");
|
||||
CasAuthenticationToken token = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES,
|
||||
makeUserDetails(), assertion);
|
||||
assertThatNoException().isThrownBy(() -> token.toBuilder().principal("principal").build());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toBuilderWhenCredentialsIsEmpty() {
|
||||
final Assertion assertion = new AssertionImpl("test");
|
||||
CasAuthenticationToken token = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES,
|
||||
makeUserDetails(), assertion);
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> token.toBuilder().credentials(null).build());
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> token.toBuilder().credentials("").build());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toBuilderWhenCredentialsIsNotEmpty() {
|
||||
final Assertion assertion = new AssertionImpl("test");
|
||||
CasAuthenticationToken token = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES,
|
||||
makeUserDetails(), assertion);
|
||||
assertThatNoException().isThrownBy(() -> token.toBuilder().credentials("credentials").build());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
-1
@@ -17,6 +17,7 @@
|
||||
package org.springframework.security.config.annotation.authentication.configurers.ldap;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
|
||||
import org.springframework.ldap.core.support.BaseLdapPathContextSource;
|
||||
@@ -590,7 +591,7 @@ public class LdapAuthenticationProviderConfigurer<B extends ProviderManagerBuild
|
||||
}
|
||||
|
||||
private int getDefaultPort() {
|
||||
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT)) {
|
||||
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT, 50, InetAddress.getLoopbackAddress())) {
|
||||
return serverSocket.getLocalPort();
|
||||
}
|
||||
catch (IOException ex) {
|
||||
|
||||
+13
@@ -39,6 +39,9 @@ import org.springframework.util.ClassUtils;
|
||||
@Deprecated
|
||||
final class GlobalMethodSecuritySelector implements ImportSelector {
|
||||
|
||||
private static final boolean isAccessPresent = ClassUtils.isPresent(
|
||||
"org.springframework.security.access.intercept.aopalliance.MethodSecurityMetadataSourceAdvisor", null);
|
||||
|
||||
@Override
|
||||
public String[] selectImports(AnnotationMetadata importingClassMetadata) {
|
||||
Class<EnableGlobalMethodSecurity> annoType = EnableGlobalMethodSecurity.class;
|
||||
@@ -59,16 +62,26 @@ final class GlobalMethodSecuritySelector implements ImportSelector {
|
||||
boolean jsr250Enabled = attributes.getBoolean("jsr250Enabled");
|
||||
List<String> classNames = new ArrayList<>(4);
|
||||
if (isProxy) {
|
||||
assertAccessModulePresent();
|
||||
classNames.add(MethodSecurityMetadataSourceAdvisorRegistrar.class.getName());
|
||||
}
|
||||
classNames.add(autoProxyClassName);
|
||||
if (!skipMethodSecurityConfiguration) {
|
||||
assertAccessModulePresent();
|
||||
classNames.add(GlobalMethodSecurityConfiguration.class.getName());
|
||||
}
|
||||
if (jsr250Enabled) {
|
||||
assertAccessModulePresent();
|
||||
classNames.add(Jsr250MetadataSourceConfiguration.class.getName());
|
||||
}
|
||||
return classNames.toArray(new String[0]);
|
||||
}
|
||||
|
||||
private static void assertAccessModulePresent() {
|
||||
Assert.state(isAccessPresent,
|
||||
() -> "@EnableGlobalMethodSecurity requires the spring-security-access dependency on the "
|
||||
+ "classpath. Please add spring-security-access, or migrate to @EnableMethodSecurity "
|
||||
+ "which does not require it.");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+8
@@ -26,6 +26,7 @@ import org.springframework.context.annotation.AutoProxyRegistrar;
|
||||
import org.springframework.context.annotation.ImportSelector;
|
||||
import org.springframework.core.type.AnnotationMetadata;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
/**
|
||||
@@ -35,6 +36,8 @@ import org.springframework.util.ClassUtils;
|
||||
*/
|
||||
class ReactiveMethodSecuritySelector implements ImportSelector {
|
||||
|
||||
private static final String METHOD_SECURITY_METADATA_SOURCE_ADVISOR = "org.springframework.security.access.intercept.aopalliance.MethodSecurityMetadataSourceAdvisor";
|
||||
|
||||
private static final boolean isDataPresent = ClassUtils
|
||||
.isPresent("org.springframework.security.data.aot.hint.AuthorizeReturnObjectDataHintsRegistrar", null);
|
||||
|
||||
@@ -56,6 +59,11 @@ class ReactiveMethodSecuritySelector implements ImportSelector {
|
||||
imports.add(ReactiveAuthorizationManagerMethodSecurityConfiguration.class.getName());
|
||||
}
|
||||
else {
|
||||
Assert.state(
|
||||
ClassUtils.isPresent(METHOD_SECURITY_METADATA_SOURCE_ADVISOR, ClassUtils.getDefaultClassLoader()),
|
||||
() -> "@EnableReactiveMethodSecurity(useAuthorizationManager = false) requires the "
|
||||
+ "spring-security-access dependency on the classpath. Please add spring-security-access, "
|
||||
+ "or use the default useAuthorizationManager = true which does not require it.");
|
||||
imports.add(ReactiveMethodSecurityConfiguration.class.getName());
|
||||
}
|
||||
if (isDataPresent) {
|
||||
|
||||
+3
-1
@@ -2035,7 +2035,9 @@ public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<Defaul
|
||||
*/
|
||||
public HttpSecurity securityMatcher(String... patterns) {
|
||||
List<RequestMatcher> matchers = new ArrayList<>();
|
||||
PathPatternRequestMatcher.Builder builder = getSharedObject(PathPatternRequestMatcher.Builder.class);
|
||||
ApplicationContext context = getSharedObject(ApplicationContext.class);
|
||||
PathPatternRequestMatcher.Builder builder = context.getBeanProvider(PathPatternRequestMatcher.Builder.class)
|
||||
.getIfUnique(() -> getSharedObject(PathPatternRequestMatcher.Builder.class));
|
||||
for (String pattern : patterns) {
|
||||
matchers.add(builder.matcher(pattern));
|
||||
}
|
||||
|
||||
+2
-3
@@ -379,9 +379,8 @@ public final class WebSecurity extends AbstractConfiguredSecurityBuilder<Filter,
|
||||
}
|
||||
if (filter instanceof AuthorizationFilter authorization) {
|
||||
AuthorizationManager<HttpServletRequest> authorizationManager = authorization.getAuthorizationManager();
|
||||
builder.add(securityFilterChain::matches,
|
||||
(authentication, context) -> (AuthorizationDecision) authorizationManager
|
||||
.authorize(authentication, context.getRequest()));
|
||||
builder.add(securityFilterChain::matches, (authentication, context) -> authorizationManager
|
||||
.authorize(authentication, context.getRequest()));
|
||||
mappings = true;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -238,9 +238,7 @@ class HttpSecurityConfiguration {
|
||||
Map<Class<?>, Object> sharedObjects = new HashMap<>();
|
||||
sharedObjects.put(ApplicationContext.class, this.context);
|
||||
sharedObjects.put(ContentNegotiationStrategy.class, this.contentNegotiationStrategy);
|
||||
sharedObjects.put(PathPatternRequestMatcher.Builder.class,
|
||||
this.context.getBeanProvider(PathPatternRequestMatcher.Builder.class)
|
||||
.getIfUnique(() -> constructRequestMatcherBuilder(this.context)));
|
||||
sharedObjects.put(PathPatternRequestMatcher.Builder.class, constructRequestMatcherBuilder(this.context));
|
||||
return sharedObjects;
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -79,8 +79,10 @@ final class DPoPAuthenticationConfigurer<B extends HttpSecurityBuilder<B>>
|
||||
|
||||
@Override
|
||||
public void configure(B http) {
|
||||
DPoPAuthenticationProvider authenticationProvider = new DPoPAuthenticationProvider(
|
||||
getTokenAuthenticationManager(http));
|
||||
http.authenticationProvider(postProcess(authenticationProvider));
|
||||
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
|
||||
http.authenticationProvider(new DPoPAuthenticationProvider(getTokenAuthenticationManager(http)));
|
||||
AuthenticationFilter authenticationFilter = new AuthenticationFilter(authenticationManager,
|
||||
getAuthenticationConverter());
|
||||
authenticationFilter.setRequestMatcher(getRequestMatcher());
|
||||
|
||||
+3
@@ -33,6 +33,7 @@ import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationManagerResolver;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.ObjectPostProcessor;
|
||||
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
|
||||
@@ -298,6 +299,8 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
|
||||
|
||||
if (dPoPAuthenticationAvailable) {
|
||||
DPoPAuthenticationConfigurer<H> dPoPAuthenticationConfigurer = new DPoPAuthenticationConfigurer<>();
|
||||
dPoPAuthenticationConfigurer.withObjectPostProcessor(
|
||||
(ObjectPostProcessor<Object>) OAuth2ResourceServerConfigurer.this::postProcess);
|
||||
dPoPAuthenticationConfigurer.configure(http);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -17,6 +17,7 @@
|
||||
package org.springframework.security.config.ldap;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
|
||||
import org.w3c.dom.Element;
|
||||
@@ -188,7 +189,7 @@ public class LdapServerBeanDefinitionParser implements BeanDefinitionParser {
|
||||
}
|
||||
|
||||
private String getDefaultPort() {
|
||||
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT)) {
|
||||
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT, 50, InetAddress.getLoopbackAddress())) {
|
||||
return String.valueOf(serverSocket.getLocalPort());
|
||||
}
|
||||
catch (IOException ex) {
|
||||
|
||||
+2
-4
@@ -301,8 +301,7 @@ class SpringSecurityCoreVersionSerializableTests {
|
||||
private static String getCurrentVersion() {
|
||||
String version = System.getProperty("springSecurityVersion");
|
||||
String[] parts = version.split("\\.");
|
||||
parts[2] = "x";
|
||||
return String.join(".", parts);
|
||||
return parts[0] + "." + parts[1] + ".x";
|
||||
}
|
||||
|
||||
private static String getPreviousVersion() {
|
||||
@@ -315,8 +314,7 @@ class SpringSecurityCoreVersionSerializableTests {
|
||||
parts[0] = String.valueOf(Integer.parseInt(parts[0]) - 1);
|
||||
parts[1] = "5"; // FIXME: this should not be hard coded
|
||||
}
|
||||
parts[2] = "x";
|
||||
return String.join(".", parts);
|
||||
return parts[0] + "." + parts[1] + ".x";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.config.annotation.method.configuration;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.context.annotation.AdviceMode;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.test.support.ClassPathExclusions;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
|
||||
/**
|
||||
* Tests for gh-19441: {@code spring-security-access} moved
|
||||
* {@link org.springframework.security.access.intercept.aopalliance.MethodSecurityMetadataSourceAdvisor}
|
||||
* out of {@code spring-security-core} and into the optional
|
||||
* {@code spring-security-access} module. {@link EnableMethodSecurity} and
|
||||
* {@link EnableReactiveMethodSecurity}'s default (AuthorizationManager-based) mode never
|
||||
* needed that class and continue to work without {@code spring-security-access} on the
|
||||
* classpath, but the deprecated legacy method security annotations do need it and
|
||||
* previously failed with a confusing {@link NoClassDefFoundError} instead of an
|
||||
* actionable message.
|
||||
*/
|
||||
@ClassPathExclusions("spring-security-access-*.jar")
|
||||
public class Gh19441Tests {
|
||||
|
||||
@Test
|
||||
public void enableMethodSecurityWhenAccessModuleAbsentThenContextStartsCleanly() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(EnableMethodSecurityConfig.class);
|
||||
context.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enableReactiveMethodSecurityWhenAccessModuleAbsentThenContextStartsCleanly() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(EnableReactiveMethodSecurityConfig.class);
|
||||
context.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enableGlobalMethodSecurityWhenProxyModeAndAccessModuleAbsentThenClearException() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(EnableGlobalMethodSecurityProxyConfig.class);
|
||||
assertThatExceptionOfType(Exception.class).isThrownBy(context::refresh)
|
||||
.havingRootCause()
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.withMessageContaining("spring-security-access");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enableGlobalMethodSecurityWhenAspectJModeAndAccessModuleAbsentThenClearException() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(EnableGlobalMethodSecurityAspectJConfig.class);
|
||||
assertThatExceptionOfType(Exception.class).isThrownBy(context::refresh)
|
||||
.havingRootCause()
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.withMessageContaining("spring-security-access");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enableReactiveMethodSecurityWhenUseAuthorizationManagerFalseAndAccessModuleAbsentThenClearException() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(EnableReactiveMethodSecurityLegacyConfig.class);
|
||||
assertThatExceptionOfType(Exception.class).isThrownBy(context::refresh)
|
||||
.havingRootCause()
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.withMessageContaining("spring-security-access");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enableGlobalMethodSecurityWhenAspectJModeAndJsr250EnabledAndConfigurationSubclassedAndAccessModuleAbsentThenClearException() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(EnableGlobalMethodSecurityAspectJJsr250SubclassedConfig.class);
|
||||
assertThatExceptionOfType(Exception.class).isThrownBy(context::refresh)
|
||||
.havingRootCause()
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.withMessageContaining("spring-security-access");
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableMethodSecurity
|
||||
static class EnableMethodSecurityConfig {
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableReactiveMethodSecurity
|
||||
static class EnableReactiveMethodSecurityConfig {
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
||||
static class EnableGlobalMethodSecurityProxyConfig {
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableGlobalMethodSecurity(prePostEnabled = true, mode = AdviceMode.ASPECTJ)
|
||||
static class EnableGlobalMethodSecurityAspectJConfig {
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableReactiveMethodSecurity(useAuthorizationManager = false)
|
||||
static class EnableReactiveMethodSecurityLegacyConfig {
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableGlobalMethodSecurity(jsr250Enabled = true, mode = AdviceMode.ASPECTJ)
|
||||
static class EnableGlobalMethodSecurityAspectJJsr250SubclassedConfig extends GlobalMethodSecurityConfiguration {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+62
@@ -37,6 +37,7 @@ import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.mock.web.MockServletContext;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.core.userdetails.PasswordEncodedUser;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
@@ -110,6 +111,21 @@ public class WebSecurityTests {
|
||||
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void ignoringWhenBuilderBeanWithBasePathThenHonorsBasePath() throws Exception {
|
||||
loadConfig(IgnoringBuilderBeanConfig.class);
|
||||
this.request.setServletPath("/spring");
|
||||
this.request.setRequestURI("/spring/path");
|
||||
this.springSecurityFilterChain.doFilter(this.request, this.response, this.chain);
|
||||
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
|
||||
setup();
|
||||
this.request.setServletPath("");
|
||||
this.request.setRequestURI("/path");
|
||||
this.springSecurityFilterChain.doFilter(this.request, this.response, this.chain);
|
||||
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
public void loadConfig(Class<?>... configs) {
|
||||
this.context = new AnnotationConfigWebApplicationContext();
|
||||
this.context.register(configs);
|
||||
@@ -201,6 +217,52 @@ public class WebSecurityTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@EnableWebSecurity
|
||||
@Configuration
|
||||
@EnableWebMvc
|
||||
static class IgnoringBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
WebSecurityCustomizer webSecurityCustomizer() {
|
||||
return (web) -> web.ignoring().requestMatchers("/path");
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.httpBasic(withDefaults())
|
||||
.authorizeHttpRequests((requests) -> requests
|
||||
.anyRequest().denyAll());
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserDetailsService userDetailsService() {
|
||||
return new InMemoryUserDetailsManager(PasswordEncodedUser.user());
|
||||
}
|
||||
|
||||
@RestController
|
||||
static class PathController {
|
||||
|
||||
@RequestMapping("/path")
|
||||
String path() {
|
||||
return "path";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class RequestRejectedHandlerConfig {
|
||||
|
||||
+43
@@ -40,6 +40,7 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.users.AuthenticationTestConfiguration;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.core.authority.FactorGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextChangedListener;
|
||||
import org.springframework.security.core.context.SecurityContextHolderStrategy;
|
||||
@@ -153,6 +154,17 @@ public class FormLoginConfigurerTests {
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void loginWhenBuilderBeanWithBasePathThenLoginProcessingUrlIgnoresBasePath() throws Exception {
|
||||
this.spring.register(FormLoginBuilderBeanConfig.class).autowire();
|
||||
// @formatter:off
|
||||
this.mockMvc.perform(formLogin().user("invalid"))
|
||||
.andExpect(status().isFound())
|
||||
.andExpect(redirectedUrl("/login?error"));
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loginWhenFormLoginConfiguredThenHasDefaultSuccessUrl() throws Exception {
|
||||
this.spring.register(FormLoginConfig.class).autowire();
|
||||
@@ -519,6 +531,37 @@ public class FormLoginConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableWebMvc
|
||||
static class FormLoginBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((requests) -> requests
|
||||
.anyRequest().authenticated())
|
||||
.formLogin(withDefaults());
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserDetailsService userDetailsService() {
|
||||
return new InMemoryUserDetailsManager(PasswordEncodedUser.user());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class FormLoginInLambdaConfig {
|
||||
|
||||
+35
@@ -35,6 +35,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.core.context.SecurityContextHolderStrategy;
|
||||
import org.springframework.security.core.userdetails.PasswordEncodedUser;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
@@ -127,6 +128,17 @@ public class LogoutConfigurerTests {
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void logoutWhenBuilderBeanWithBasePathThenLogoutUrlIgnoresBasePath() throws Exception {
|
||||
this.spring.register(LogoutBuilderBeanConfig.class).autowire();
|
||||
// @formatter:off
|
||||
this.mvc.perform(post("/logout").with(csrf()))
|
||||
.andExpect(status().isFound())
|
||||
.andExpect(redirectedUrl("/login?logout"));
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
// SEC-2311
|
||||
@Test
|
||||
public void logoutWhenGetRequestAndCsrfDisabledThenRedirectsToLogin() throws Exception {
|
||||
@@ -524,6 +536,29 @@ public class LogoutConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class LogoutBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.logout(withDefaults());
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class CsrfDisabledConfig {
|
||||
|
||||
+34
@@ -26,6 +26,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@@ -66,6 +67,16 @@ public class PasswordManagementConfigurerTests {
|
||||
.andExpect(redirectedUrl("/custom-change-password-page"));
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void changePasswordWhenBuilderBeanWithBasePathThenChangePasswordUrlIgnoresBasePath() throws Exception {
|
||||
this.spring.register(PasswordManagementBuilderBeanConfig.class).autowire();
|
||||
|
||||
this.mvc.perform(get("/.well-known/change-password"))
|
||||
.andExpect(status().isFound())
|
||||
.andExpect(redirectedUrl("/change-password"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void whenSettingNullChangePasswordPage() {
|
||||
PasswordManagementConfigurer configurer = new PasswordManagementConfigurer();
|
||||
@@ -102,6 +113,29 @@ public class PasswordManagementConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class PasswordManagementBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
return http
|
||||
.passwordManagement(withDefaults())
|
||||
.build();
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class PasswordManagementWithCustomChangePasswordPageConfig {
|
||||
|
||||
+41
@@ -34,6 +34,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
import org.springframework.security.test.web.servlet.RequestCacheResultMatcher;
|
||||
@@ -185,6 +186,21 @@ public class RequestCacheConfigurerTests {
|
||||
this.mvc.perform(formLogin(session)).andExpect(redirectedUrl("/"));
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void getWhenBuilderBeanWithBasePathThenSavedRequestMatcherIgnoresBasePath() throws Exception {
|
||||
this.spring.register(RequestCacheBuilderBeanConfig.class, DefaultSecurityConfig.class).autowire();
|
||||
MockHttpServletRequestBuilder request = get("/messages").header(HttpHeaders.ACCEPT, MediaType.TEXT_HTML);
|
||||
// @formatter:off
|
||||
MockHttpSession session = (MockHttpSession) this.mvc.perform(request)
|
||||
.andExpect(redirectedUrl("/login"))
|
||||
.andReturn()
|
||||
.getRequest()
|
||||
.getSession();
|
||||
// @formatter:on
|
||||
this.mvc.perform(formLogin(session)).andExpect(RequestCacheResultMatcher.redirectToCachedRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getWhenBookmarkedRequestIsAllMediaTypeThenPostAuthenticationRemembers() throws Exception {
|
||||
this.spring.register(RequestCacheDefaultsConfig.class, DefaultSecurityConfig.class).autowire();
|
||||
@@ -401,6 +417,31 @@ public class RequestCacheConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class RequestCacheBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((requests) -> requests
|
||||
.anyRequest().authenticated())
|
||||
.formLogin(withDefaults());
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class RequestCacheDisabledConfig {
|
||||
|
||||
+38
@@ -56,6 +56,7 @@ import org.springframework.security.config.annotation.web.configurers.oauth2.cli
|
||||
import org.springframework.security.config.oauth2.client.CommonOAuth2Provider;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.context.DelegatingApplicationListener;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
@@ -440,6 +441,18 @@ public class OAuth2LoginConfigurerTests {
|
||||
then(redirectStrategy).should().sendRedirect(any(), any(), anyString());
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void oauth2LoginWhenBuilderBeanWithBasePathThenLoginProcessingUrlIgnoresBasePath() throws Exception {
|
||||
loadConfig(OAuth2LoginBuilderBeanConfig.class);
|
||||
String requestUri = "/login/oauth2/code/google";
|
||||
this.request = get(requestUri).build();
|
||||
this.request.setParameter("code", "code123");
|
||||
this.request.setParameter("state", "state123");
|
||||
this.springSecurityFilterChain.doFilter(this.request, this.response, this.filterChain);
|
||||
assertThat(this.response.getRedirectedUrl()).endsWith("/login?error");
|
||||
}
|
||||
|
||||
// gh-5347
|
||||
@Test
|
||||
public void oauth2LoginWithOneClientConfiguredThenRedirectForAuthorization() throws Exception {
|
||||
@@ -788,6 +801,31 @@ public class OAuth2LoginConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class OAuth2LoginBuilderBeanConfig extends CommonSecurityFilterChainConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.oauth2Login((login) -> login
|
||||
.clientRegistrationRepository(
|
||||
new InMemoryClientRegistrationRepository(GOOGLE_CLIENT_REGISTRATION)));
|
||||
// @formatter:on
|
||||
return super.configureFilterChain(http);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class OAuth2LoginConfigFormLogin extends CommonSecurityFilterChainConfig {
|
||||
|
||||
+31
-2
@@ -22,6 +22,7 @@ import java.security.interfaces.ECPrivateKey;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
@@ -30,6 +31,7 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.nimbusds.jose.jwk.ECKey;
|
||||
import com.nimbusds.jose.jwk.JWK;
|
||||
@@ -47,20 +49,26 @@ import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.ObjectPostProcessor;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.jose.TestJwks;
|
||||
import org.springframework.security.oauth2.jose.TestKeys;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.DPoPProofContext;
|
||||
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
|
||||
import org.springframework.security.oauth2.jwt.DPoPProofReplayValidator;
|
||||
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationProvider;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -245,8 +253,10 @@ public class DPoPAuthenticationConfigurerTests {
|
||||
.requestMatchers("/resource2").hasAnyAuthority("SCOPE_resource2.read", "SCOPE_resource2.write")
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2ResourceServer((oauth2) -> oauth2
|
||||
.jwt(Customizer.withDefaults()));
|
||||
.oauth2ResourceServer((oauth2ResourceServer) ->
|
||||
oauth2ResourceServer
|
||||
.jwt(Customizer.withDefaults())
|
||||
.withObjectPostProcessor(dPoPProofVerifierFactoryCustomizer()));
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
@@ -256,6 +266,25 @@ public class DPoPAuthenticationConfigurerTests {
|
||||
return NimbusJwtDecoder.withPublicKey(PROVIDER_RSA_PUBLIC_KEY).build();
|
||||
}
|
||||
|
||||
private ObjectPostProcessor<DPoPAuthenticationProvider> dPoPProofVerifierFactoryCustomizer() {
|
||||
return new ObjectPostProcessor<>() {
|
||||
@Override
|
||||
public <O extends DPoPAuthenticationProvider> O postProcess(O authenticationProvider) {
|
||||
DPoPProofReplayValidator.InMemoryCache inMemoryCache = new DPoPProofReplayValidator.InMemoryCache();
|
||||
inMemoryCache.setMaxSize(50_000);
|
||||
inMemoryCache.setMaxRequestsPerKey(500);
|
||||
DPoPProofReplayValidator dPoPProofReplayValidator = new DPoPProofReplayValidator(inMemoryCache);
|
||||
dPoPProofReplayValidator.setClockSkew(Duration.ofSeconds(60));
|
||||
Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = DPoPProofJwtDecoderFactory
|
||||
.createDefaultJwtValidatorFactory(Collections.singletonList(dPoPProofReplayValidator));
|
||||
DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory();
|
||||
dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory);
|
||||
authenticationProvider.setDPoPProofVerifierFactory(dPoPProofJwtDecoderFactory);
|
||||
return authenticationProvider;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@RestController
|
||||
|
||||
+49
@@ -40,6 +40,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.core.userdetails.PasswordEncodedUser;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
@@ -98,6 +99,19 @@ public class OneTimeTokenLoginConfigurerTests {
|
||||
.andExpectAll(status().isFound(), redirectedUrl("/"), authenticated());
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
void oneTimeTokenWhenBuilderBeanWithBasePathThenGenerateAndLoginUrlsIgnoreBasePath() throws Exception {
|
||||
this.spring.register(OneTimeTokenBuilderBeanConfig.class).autowire();
|
||||
this.mvc.perform(post("/ott/generate").param("username", "user").with(csrf()))
|
||||
.andExpectAll(status().isFound(), redirectedUrl("/login/ott"));
|
||||
|
||||
String token = getLastToken().getTokenValue();
|
||||
|
||||
this.mvc.perform(post("/login/ott").param("token", token).with(csrf()))
|
||||
.andExpectAll(status().isFound(), redirectedUrl("/"), authenticated());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneTimeTokenWhenDifferentAuthenticationUrlsThenCanAuthenticate() throws Exception {
|
||||
this.spring.register(OneTimeTokenDifferentUrlsConfig.class).autowire();
|
||||
@@ -296,6 +310,41 @@ public class OneTimeTokenLoginConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableWebSecurity
|
||||
@Import(UserDetailsServiceConfig.class)
|
||||
static class OneTimeTokenBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http,
|
||||
OneTimeTokenGenerationSuccessHandler ottSuccessHandler) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oneTimeTokenLogin((ott) -> ott
|
||||
.tokenGenerationSuccessHandler(ottSuccessHandler)
|
||||
);
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
TestOneTimeTokenGenerationSuccessHandler ottSuccessHandler() {
|
||||
return new TestOneTimeTokenGenerationSuccessHandler();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableWebSecurity
|
||||
@Import(UserDetailsServiceConfig.class)
|
||||
|
||||
+36
@@ -57,6 +57,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
@@ -244,6 +245,14 @@ public class Saml2LoginConfigurerTests {
|
||||
performSaml2Login("ROLE_AUTH_MANAGER");
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void saml2LoginWhenBuilderBeanWithBasePathThenAuthenticateUriIgnoresBasePath() throws Exception {
|
||||
this.spring.register(Saml2LoginBuilderBeanConfig.class).autowire();
|
||||
MvcResult result = this.mvc.perform(get("/saml2/authenticate/registration-id")).andReturn();
|
||||
assertThat(result.getResponse().getRedirectedUrl()).contains("SAMLRequest");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void authenticationRequestWhenAuthenticationRequestResolverBeanThenUses() throws Exception {
|
||||
this.spring.register(CustomAuthenticationRequestResolverBean.class).autowire();
|
||||
@@ -522,6 +531,33 @@ public class Saml2LoginConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@Import(Saml2LoginConfigBeans.class)
|
||||
static class Saml2LoginBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authz) -> authz
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.saml2Login(Customizer.withDefaults());
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@Import(Saml2LoginConfigBeans.class)
|
||||
|
||||
+38
@@ -49,6 +49,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.SecurityContextHolderStrategy;
|
||||
import org.springframework.security.saml2.core.Saml2Utils;
|
||||
@@ -196,6 +197,17 @@ public class Saml2LogoutConfigurerTests {
|
||||
verify(logoutHandler).logout(any(), any(), any());
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void saml2LogoutWhenBuilderBeanWithBasePathThenLogoutUrlIgnoresBasePath() throws Exception {
|
||||
this.spring.register(Saml2LogoutBuilderBeanConfig.class).autowire();
|
||||
MvcResult result = this.mvc.perform(post("/logout").with(authentication(this.user)).with(csrf()))
|
||||
.andExpect(status().isFound())
|
||||
.andReturn();
|
||||
String location = result.getResponse().getHeader("Location");
|
||||
assertThat(location).startsWith("https://ap.example.org/logout/saml2/request");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saml2LogoutWhenUnauthenticatedThenEntryPoint() throws Exception {
|
||||
this.spring.register(Saml2LogoutDefaultsConfig.class).autowire();
|
||||
@@ -577,6 +589,32 @@ public class Saml2LogoutConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@Import(Saml2LoginConfigBeans.class)
|
||||
static class Saml2LogoutBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain web(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
|
||||
.saml2Login(withDefaults())
|
||||
.saml2Logout(withDefaults());
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@Import(Saml2LoginConfigBeans.class)
|
||||
|
||||
+35
@@ -30,6 +30,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.saml2.provider.service.metadata.OpenSaml5MetadataResolver;
|
||||
import org.springframework.security.saml2.provider.service.metadata.RequestMatcherMetadataResponseResolver;
|
||||
import org.springframework.security.saml2.provider.service.metadata.Saml2MetadataResponse;
|
||||
@@ -124,6 +125,15 @@ public class Saml2MetadataConfigurerTests {
|
||||
this.mvc.perform(get("/saml2/metadata")).andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
void saml2MetadataWhenBuilderBeanWithBasePathThenMetadataUrlIgnoresBasePath() throws Exception {
|
||||
this.spring.register(MetadataBuilderBeanConfig.class).autowire();
|
||||
this.mvc.perform(get("/saml/metadata"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("md:EntityDescriptor")));
|
||||
}
|
||||
|
||||
@EnableWebSecurity
|
||||
@Configuration
|
||||
@Import(RelyingPartyRegistrationConfig.class)
|
||||
@@ -141,6 +151,31 @@ public class Saml2MetadataConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@EnableWebSecurity
|
||||
@Configuration
|
||||
@Import(RelyingPartyRegistrationConfig.class)
|
||||
static class MetadataBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filters(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
|
||||
.saml2Metadata((saml2) -> saml2.metadataUrl("/saml/metadata"));
|
||||
return http.build();
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@EnableWebSecurity
|
||||
@Configuration
|
||||
@Import(RelyingPartyRegistrationConfig.class)
|
||||
|
||||
+1
-3
@@ -78,9 +78,7 @@ public abstract class AbstractSecurityExpressionHandler<T>
|
||||
public final EvaluationContext createEvaluationContext(@Nullable Authentication authentication, T invocation) {
|
||||
SecurityExpressionOperations root = createSecurityExpressionRoot(authentication, invocation);
|
||||
StandardEvaluationContext ctx = createEvaluationContextInternal(authentication, invocation);
|
||||
if (this.beanResolver != null) {
|
||||
ctx.setBeanResolver(this.beanResolver);
|
||||
}
|
||||
ctx.setBeanResolver(this.beanResolver);
|
||||
ctx.setRootObject(root);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
+1
-3
@@ -23,7 +23,6 @@ import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -89,13 +88,12 @@ public class DefaultMethodSecurityExpressionHandler extends AbstractSecurityExpr
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("NullAway") // FIXME: Dataflow analysis limitation
|
||||
public EvaluationContext createEvaluationContext(Supplier<? extends @Nullable Authentication> authentication,
|
||||
MethodInvocation mi) {
|
||||
MethodSecurityExpressionOperations root = createSecurityExpressionRoot(authentication, mi);
|
||||
MethodSecurityEvaluationContext ctx = new MethodSecurityEvaluationContext(root, mi,
|
||||
getParameterNameDiscoverer());
|
||||
Optional.ofNullable(getBeanResolver()).ifPresent(ctx::setBeanResolver);
|
||||
ctx.setBeanResolver(getBeanResolver());
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -184,7 +184,7 @@ public abstract class AbstractUserDetailsReactiveAuthenticationManager
|
||||
* @since 5.2
|
||||
*/
|
||||
public void setPostAuthenticationChecks(UserDetailsChecker postAuthenticationChecks) {
|
||||
Assert.notNull(this.postAuthenticationChecks, "postAuthenticationChecks cannot be null");
|
||||
Assert.notNull(postAuthenticationChecks, "postAuthenticationChecks cannot be null");
|
||||
this.postAuthenticationChecks = postAuthenticationChecks;
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -32,6 +32,7 @@ import org.springframework.util.Assert;
|
||||
* {@link java.util.ServiceLoader} mechanism when context-propagation is on the classpath.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @author Tadaya Tsuyukubo
|
||||
* @since 6.5
|
||||
* @see io.micrometer.context.ContextRegistry
|
||||
*/
|
||||
@@ -53,7 +54,9 @@ public final class SecurityContextHolderThreadLocalAccessor implements ThreadLoc
|
||||
@Override
|
||||
public void setValue(SecurityContext securityContext) {
|
||||
Assert.notNull(securityContext, "securityContext cannot be null");
|
||||
SecurityContextHolder.setContext(securityContext);
|
||||
SecurityContext newContext = SecurityContextHolder.createEmptyContext();
|
||||
newContext.setAuthentication(securityContext.getAuthentication());
|
||||
SecurityContextHolder.setContext(newContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+1
-1
@@ -132,7 +132,7 @@ public class KeyBasedPersistenceTokenService implements TokenService, Initializi
|
||||
// Verification
|
||||
String content = creationTime + ":" + pseudoRandomNumber + ":" + extendedInfo.toString();
|
||||
String expectedSha512Hex = Sha512DigestUtils.shaHex(content + ":" + serverSecret);
|
||||
Assert.isTrue(expectedSha512Hex.equals(sha1Hex), "Key verification failure");
|
||||
Assert.isTrue(Utf8.isEqual(expectedSha512Hex, sha1Hex), "Key verification failure");
|
||||
return new DefaultToken(key, creationTime, extendedInfo.toString());
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@ public class InMemoryUserDetailsManager implements UserDetailsManager, UserDetai
|
||||
else {
|
||||
this.logger.debug("No authentication manager set. Password won't be re-checked.");
|
||||
}
|
||||
MutableUserDetails user = this.users.get(username);
|
||||
MutableUserDetails user = this.users.get(username.toLowerCase(Locale.ROOT));
|
||||
Assert.state(user != null, "Current user doesn't exist in database.");
|
||||
user.setPassword(newPassword);
|
||||
}
|
||||
|
||||
+6
@@ -90,6 +90,12 @@ public class UserDetailsRepositoryReactiveAuthenticationManagerTests {
|
||||
assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> this.manager.setScheduler(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void setPostAuthenticationChecksWhenNullThenIllegalArgumentException() {
|
||||
assertThatExceptionOfType(IllegalArgumentException.class)
|
||||
.isThrownBy(() -> this.manager.setPostAuthenticationChecks(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void authenticateWhenCustomSchedulerThenUsed() {
|
||||
given(this.scheduler.schedule(any())).willAnswer((a) -> {
|
||||
|
||||
+49
-2
@@ -16,11 +16,18 @@
|
||||
|
||||
package org.springframework.security.core.context;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||
@@ -29,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
|
||||
* Tests for {@link SecurityContextHolderThreadLocalAccessor}.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @author Tadaya Tsuyukubo
|
||||
*/
|
||||
public class SecurityContextHolderThreadLocalAccessorTests {
|
||||
|
||||
@@ -65,9 +73,11 @@ public class SecurityContextHolderThreadLocalAccessorTests {
|
||||
@Test
|
||||
public void setValueWhenSecurityContextThenSetsSecurityContextHolder() {
|
||||
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
|
||||
securityContext.setAuthentication(new TestingAuthenticationToken("user", "password"));
|
||||
Authentication authentication = new TestingAuthenticationToken("user", "password");
|
||||
securityContext.setAuthentication(authentication);
|
||||
this.threadLocalAccessor.setValue(securityContext);
|
||||
assertThat(SecurityContextHolder.getContext()).isSameAs(securityContext);
|
||||
assertThat(SecurityContextHolder.getContext()).isNotSameAs(securityContext);
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isSameAs(authentication);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -90,4 +100,41 @@ public class SecurityContextHolderThreadLocalAccessorTests {
|
||||
assertThat(SecurityContextHolder.getContext()).isEqualTo(emptyContext);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void newSecurityContextInDifferentThread() throws Exception {
|
||||
Authentication authA = new TestingAuthenticationToken("foo", "password");
|
||||
Authentication authB = new TestingAuthenticationToken("bar", "password");
|
||||
|
||||
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
|
||||
securityContext.setAuthentication(authA);
|
||||
SecurityContextHolder.setContext(securityContext);
|
||||
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<SecurityContext> contextHolder = new AtomicReference<>();
|
||||
AtomicReference<Authentication> authHolder = new AtomicReference<>();
|
||||
Runnable runnable = () -> {
|
||||
SecurityContext context = SecurityContextHolder.getContext();
|
||||
contextHolder.set(context);
|
||||
authHolder.set(context.getAuthentication());
|
||||
context.setAuthentication(authB);
|
||||
latch.countDown();
|
||||
};
|
||||
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
|
||||
executor.afterPropertiesSet();
|
||||
|
||||
executor.execute(runnable);
|
||||
|
||||
boolean finished = latch.await(10, TimeUnit.SECONDS);
|
||||
assertThat(finished).isTrue();
|
||||
|
||||
assertThat(contextHolder.get()).isNotSameAs(securityContext);
|
||||
assertThat(authHolder.get()).isSameAs(authA);
|
||||
|
||||
SecurityContext current = SecurityContextHolder.getContext();
|
||||
assertThat(current).isSameAs(securityContext);
|
||||
assertThat(current.getAuthentication()).isSameAs(authA);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+16
@@ -123,6 +123,22 @@ public class InMemoryUserDetailsManagerTests {
|
||||
verify(strategy).getContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void changePasswordWhenCurrentUsernameIsNotInLowercaseThenChangesPassword() {
|
||||
UserDetails userNotLowerCase = User.withUserDetails(PasswordEncodedUser.user()).username("User").build();
|
||||
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(userNotLowerCase);
|
||||
Authentication authentication = new UsernamePasswordAuthenticationToken("User", userNotLowerCase.getPassword(),
|
||||
userNotLowerCase.getAuthorities());
|
||||
SecurityContextHolderStrategy strategy = mock(SecurityContextHolderStrategy.class);
|
||||
given(strategy.getContext()).willReturn(new SecurityContextImpl(authentication));
|
||||
manager.setSecurityContextHolderStrategy(strategy);
|
||||
|
||||
String newPassword = "newPassword";
|
||||
manager.changePassword(userNotLowerCase.getPassword(), newPassword);
|
||||
|
||||
assertThat(manager.loadUserByUsername("User").getPassword()).isEqualTo(newPassword);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createUserWhenUserAlreadyExistsThenException() {
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> this.manager.createUser(this.user))
|
||||
|
||||
@@ -21,6 +21,9 @@ import java.nio.CharBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* UTF-8 Charset encoder/decoder.
|
||||
@@ -66,4 +69,21 @@ public final class Utf8 {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant time comparison to prevent against timing attacks.
|
||||
* @param expected the expected {@link CharSequence}
|
||||
* @param actual the actual {@link CharSequence}
|
||||
* @return true if {@code expected} and {@code actual} are equal, false otherwise
|
||||
* @since 5.7.26
|
||||
*/
|
||||
public static boolean isEqual(@Nullable CharSequence expected, @Nullable CharSequence actual) {
|
||||
byte[] expectedBytes = bytesUtf8(expected);
|
||||
byte[] actualBytes = bytesUtf8(actual);
|
||||
return MessageDigest.isEqual(expectedBytes, actualBytes);
|
||||
}
|
||||
|
||||
private static byte @Nullable [] bytesUtf8(@Nullable CharSequence s) {
|
||||
return (s != null) ? Utf8.encode(s) : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
@@ -37,7 +37,9 @@ import org.springframework.security.crypto.util.EncodingUtils;
|
||||
*
|
||||
* @author Keith Donald
|
||||
* @author Dave Syer
|
||||
* @deprecated Use {@link AesCbcBytesEncryptor} or {@link AesGcmBytesEncryptor} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public final class AesBytesEncryptor implements BytesEncryptor {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.crypto.encrypt;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.security.crypto.codec.Hex;
|
||||
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
import org.springframework.security.crypto.util.EncodingUtils;
|
||||
|
||||
/**
|
||||
* {@link BytesEncryptor} that uses 256-bit AES/CBC/PKCS5Padding with a random 16-byte
|
||||
* initialization vector. The IV is prepended to the ciphertext on encrypt and stripped on
|
||||
* decrypt.
|
||||
*
|
||||
* <p>
|
||||
* Note that CBC mode provides confidentiality but <em>not</em> integrity or authenticity.
|
||||
* Applications that require authenticated encryption should prefer
|
||||
* {@link AesGcmBytesEncryptor}. See the <a href=
|
||||
* "https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html">
|
||||
* OWASP Cryptographic Storage Cheat Sheet</a> for guidance on choosing a cipher mode.
|
||||
*
|
||||
* <p>
|
||||
* When key derivation is used via {@link #withPassword(String, CharSequence)}, the key is
|
||||
* derived using PBKDF2WithHmacSHA256 with {@code DEFAULT_PBKDF2_ITERATIONS} iterations
|
||||
* per the <a href=
|
||||
* "https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">
|
||||
* OWASP Password Storage Cheat Sheet</a>. Because derivation is intentionally expensive,
|
||||
* the encryptor instance should be created once and reused rather than constructed
|
||||
* per-operation.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 5.7.26
|
||||
* @see AesGcmBytesEncryptor
|
||||
* @see AesBytesEncryptor
|
||||
*/
|
||||
public final class AesCbcBytesEncryptor implements BytesEncryptor {
|
||||
|
||||
private static final String ALGORITHM = "AES/CBC/PKCS5Padding";
|
||||
|
||||
private static final int IV_LENGTH_BYTES = 16;
|
||||
|
||||
private static final int DEFAULT_PBKDF2_ITERATIONS = 600_000;
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
private final Cipher encryptor;
|
||||
|
||||
private final Cipher decryptor;
|
||||
|
||||
private final BytesKeyGenerator ivGenerator;
|
||||
|
||||
private AesCbcBytesEncryptor(SecretKey secretKey, BytesKeyGenerator ivGenerator) {
|
||||
this.secretKey = new SecretKeySpec(secretKey.getEncoded(), "AES");
|
||||
this.encryptor = CipherUtils.newCipher(ALGORITHM);
|
||||
this.decryptor = CipherUtils.newCipher(ALGORITHM);
|
||||
this.ivGenerator = ivGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encryptor that derives its key from the given password and hex-encoded
|
||||
* salt using PBKDF2WithHmacSHA1.
|
||||
* @param password the password value
|
||||
* @param salt the hex-encoded salt value
|
||||
*/
|
||||
public static Builder withPassword(String password, CharSequence salt) {
|
||||
return new Builder(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encryptor using the supplied {@link SecretKey}.
|
||||
* @param secretKey the secret (symmetric) key
|
||||
*/
|
||||
public static Builder withSecretKey(SecretKey secretKey) {
|
||||
return new Builder(secretKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] encrypt(byte[] bytes) {
|
||||
synchronized (this.encryptor) {
|
||||
byte[] iv = this.ivGenerator.generateKey();
|
||||
CipherUtils.initCipher(this.encryptor, Cipher.ENCRYPT_MODE, this.secretKey, new IvParameterSpec(iv));
|
||||
byte[] ciphertext = CipherUtils.doFinal(this.encryptor, bytes);
|
||||
return EncodingUtils.concatenate(iv, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] decrypt(byte[] encryptedBytes) {
|
||||
int ivLength = this.ivGenerator.getKeyLength();
|
||||
byte[] iv = EncodingUtils.subArray(encryptedBytes, 0, ivLength);
|
||||
byte[] ciphertext = EncodingUtils.subArray(encryptedBytes, ivLength, encryptedBytes.length);
|
||||
synchronized (this.decryptor) {
|
||||
CipherUtils.initCipher(this.decryptor, Cipher.DECRYPT_MODE, this.secretKey, new IvParameterSpec(iv));
|
||||
return CipherUtils.doFinal(this.decryptor, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
private static SecretKey deriveKey(String password, CharSequence salt) {
|
||||
return CipherUtils.newSecretKey("PBKDF2WithHmacSHA256",
|
||||
new PBEKeySpec(password.toCharArray(), Hex.decode(salt), DEFAULT_PBKDF2_ITERATIONS, 256));
|
||||
}
|
||||
|
||||
/**
|
||||
* A Builder for {@link AesCbcBytesEncryptor}.
|
||||
*/
|
||||
public static final class Builder {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
private BytesKeyGenerator ivGenerator = KeyGenerators.secureRandom(IV_LENGTH_BYTES);
|
||||
|
||||
private Builder(SecretKey secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
private Builder(String password, CharSequence salt) {
|
||||
this.secretKey = deriveKey(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link BytesKeyGenerator} to use for generating the initialization
|
||||
* vector.
|
||||
* @param ivGenerator the {@link BytesKeyGenerator} to use for generating the
|
||||
* initialization vector
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder ivGenerator(BytesKeyGenerator ivGenerator) {
|
||||
Objects.requireNonNull(ivGenerator, "ivGenerator cannot be null");
|
||||
this.ivGenerator = ivGenerator;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@link AesCbcBytesEncryptor}.
|
||||
* @return the {@link AesCbcBytesEncryptor}
|
||||
*/
|
||||
public AesCbcBytesEncryptor build() {
|
||||
return new AesCbcBytesEncryptor(this.secretKey, this.ivGenerator);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.crypto.encrypt;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.security.crypto.codec.Hex;
|
||||
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
import org.springframework.security.crypto.util.EncodingUtils;
|
||||
|
||||
/**
|
||||
* {@link BytesEncryptor} that uses 256-bit AES/GCM/NoPadding with a random 16-byte
|
||||
* initialization vector and a 128-bit authentication tag. The IV is prepended to the
|
||||
* ciphertext on encrypt and stripped on decrypt. GCM provides authenticated encryption
|
||||
* (AEAD): both confidentiality and integrity are protected, and decryption throws if the
|
||||
* ciphertext has been tampered with.
|
||||
*
|
||||
* <p>
|
||||
* This class uses a 16-byte (128-bit) IV rather than the 12-byte (96-bit) IV recommended
|
||||
* by NIST SP 800-38D for GCM. Both lengths are cryptographically valid; the 16-byte
|
||||
* choice maintains consistency with the rest of the Spring Security crypto module. For
|
||||
* additional guidance, please see the <a href=
|
||||
* "https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html">
|
||||
* OWASP Cryptographic Storage Cheat Sheet</a>.
|
||||
*
|
||||
* <p>
|
||||
* When key derivation is used via {@link #withPassword(String, CharSequence)}, the key is
|
||||
* derived using PBKDF2WithHmacSHA256 with {@code DEFAULT_PBKDF2_ITERATIONS} iterations
|
||||
* per the <a href=
|
||||
* "https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">
|
||||
* OWASP Password Storage Cheat Sheet</a>. Because derivation is intentionally expensive,
|
||||
* the encryptor instance should be created once and reused rather than constructed
|
||||
* per-operation.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 5.7.26
|
||||
* @see AesCbcBytesEncryptor
|
||||
* @see AesBytesEncryptor
|
||||
*/
|
||||
public final class AesGcmBytesEncryptor implements BytesEncryptor {
|
||||
|
||||
private static final String ALGORITHM = "AES/GCM/NoPadding";
|
||||
|
||||
private static final int IV_LENGTH_BYTES = 16;
|
||||
|
||||
private static final int TAG_LENGTH_BITS = 128;
|
||||
|
||||
private static final int DEFAULT_PBKDF2_ITERATIONS = 600_000;
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
private final Cipher encryptor;
|
||||
|
||||
private final Cipher decryptor;
|
||||
|
||||
private final BytesKeyGenerator ivGenerator;
|
||||
|
||||
private AesGcmBytesEncryptor(SecretKey secretKey, BytesKeyGenerator ivGenerator) {
|
||||
this.secretKey = new SecretKeySpec(secretKey.getEncoded(), "AES");
|
||||
this.encryptor = CipherUtils.newCipher(ALGORITHM);
|
||||
this.decryptor = CipherUtils.newCipher(ALGORITHM);
|
||||
this.ivGenerator = ivGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encryptor that derives its key from the given password and hex-encoded
|
||||
* salt using PBKDF2WithHmacSHA1.
|
||||
* @param password the password value
|
||||
* @param salt the hex-encoded salt value
|
||||
*/
|
||||
public static Builder withPassword(String password, CharSequence salt) {
|
||||
return new Builder(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encryptor using the supplied {@link SecretKey}.
|
||||
* @param secretKey the secret (symmetric) key
|
||||
*/
|
||||
public static Builder withSecretKey(SecretKey secretKey) {
|
||||
return new Builder(secretKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] encrypt(byte[] bytes) {
|
||||
synchronized (this.encryptor) {
|
||||
byte[] iv = this.ivGenerator.generateKey();
|
||||
CipherUtils.initCipher(this.encryptor, Cipher.ENCRYPT_MODE, this.secretKey,
|
||||
new GCMParameterSpec(TAG_LENGTH_BITS, iv));
|
||||
byte[] ciphertext = CipherUtils.doFinal(this.encryptor, bytes);
|
||||
return EncodingUtils.concatenate(iv, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] decrypt(byte[] encryptedBytes) {
|
||||
int ivLength = this.ivGenerator.getKeyLength();
|
||||
byte[] iv = EncodingUtils.subArray(encryptedBytes, 0, ivLength);
|
||||
byte[] ciphertext = EncodingUtils.subArray(encryptedBytes, ivLength, encryptedBytes.length);
|
||||
synchronized (this.decryptor) {
|
||||
CipherUtils.initCipher(this.decryptor, Cipher.DECRYPT_MODE, this.secretKey,
|
||||
new GCMParameterSpec(TAG_LENGTH_BITS, iv));
|
||||
return CipherUtils.doFinal(this.decryptor, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
private static SecretKey deriveKey(String password, CharSequence salt) {
|
||||
return CipherUtils.newSecretKey("PBKDF2WithHmacSHA256",
|
||||
new PBEKeySpec(password.toCharArray(), Hex.decode(salt), DEFAULT_PBKDF2_ITERATIONS, 256));
|
||||
}
|
||||
|
||||
/**
|
||||
* A Builder for {@link AesGcmBytesEncryptor}.
|
||||
*/
|
||||
public static final class Builder {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
private BytesKeyGenerator ivGenerator = KeyGenerators.secureRandom(IV_LENGTH_BYTES);
|
||||
|
||||
private Builder(SecretKey secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
private Builder(String password, CharSequence salt) {
|
||||
this.secretKey = deriveKey(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link BytesKeyGenerator} to use for generating the initialization
|
||||
* vector.
|
||||
* @param ivGenerator the {@link BytesKeyGenerator} to use for generating the
|
||||
* initialization vector
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder ivGenerator(BytesKeyGenerator ivGenerator) {
|
||||
Objects.requireNonNull(ivGenerator, "ivGenerator cannot be null");
|
||||
this.ivGenerator = ivGenerator;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@link AesGcmBytesEncryptor}.
|
||||
* @return the {@link AesGcmBytesEncryptor}
|
||||
*/
|
||||
public AesGcmBytesEncryptor build() {
|
||||
return new AesGcmBytesEncryptor(this.secretKey, this.ivGenerator);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
package org.springframework.security.crypto.encrypt;
|
||||
|
||||
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
|
||||
/**
|
||||
@@ -42,9 +41,14 @@ public final class Encryptors {
|
||||
* not be shared
|
||||
* @param salt a hex-encoded, random, site-global salt value to use to generate the
|
||||
* key
|
||||
* @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)}
|
||||
* instead.
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public static BytesEncryptor stronger(CharSequence password, CharSequence salt) {
|
||||
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16), CipherAlgorithm.GCM);
|
||||
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16),
|
||||
AesBytesEncryptor.CipherAlgorithm.GCM);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,18 +57,16 @@ public final class Encryptors {
|
||||
* Function #2). Salts the password to prevent dictionary attacks against the key. The
|
||||
* provided salt is expected to be hex-encoded; it should be random and at least 8
|
||||
* bytes in length. Also applies a random 16-byte initialization vector to ensure each
|
||||
* encrypted message will be unique. Requires Java 6. NOTE: This mode is not
|
||||
* <a href="https://en.wikipedia.org/wiki/Authenticated_encryption">authenticated</a>
|
||||
* and does not provide any guarantees about the authenticity of the data. For a more
|
||||
* secure alternative, users should prefer
|
||||
* {@link #stronger(CharSequence, CharSequence)}.
|
||||
* encrypted message will be unique. Requires Java 6.
|
||||
* @param password the password used to generate the encryptor's secret key; should
|
||||
* not be shared
|
||||
* @param salt a hex-encoded, random, site-global salt value to use to generate the
|
||||
* key
|
||||
*
|
||||
* @see Encryptors#stronger(CharSequence, CharSequence)
|
||||
* @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)}
|
||||
* instead.
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public static BytesEncryptor standard(CharSequence password, CharSequence salt) {
|
||||
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16));
|
||||
}
|
||||
@@ -74,8 +76,12 @@ public final class Encryptors {
|
||||
* text is hex-encoded.
|
||||
* @param password the password used to generate the encryptor's secret key; should
|
||||
* not be shared
|
||||
* @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)}
|
||||
* instead.
|
||||
* @see Encryptors#stronger(CharSequence, CharSequence)
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public static TextEncryptor delux(CharSequence password, CharSequence salt) {
|
||||
return new HexEncodingTextEncryptor(stronger(password, salt));
|
||||
}
|
||||
@@ -85,8 +91,12 @@ public final class Encryptors {
|
||||
* text is hex-encoded.
|
||||
* @param password the password used to generate the encryptor's secret key; should
|
||||
* not be shared
|
||||
* @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)}
|
||||
* instead.
|
||||
* @see Encryptors#standard(CharSequence, CharSequence)
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public static TextEncryptor text(CharSequence password, CharSequence salt) {
|
||||
return new HexEncodingTextEncryptor(standard(password, salt));
|
||||
}
|
||||
|
||||
+1
-11
@@ -16,8 +16,6 @@
|
||||
|
||||
package org.springframework.security.crypto.password;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import org.springframework.security.crypto.codec.Utf8;
|
||||
@@ -39,15 +37,7 @@ final class PasswordEncoderUtils {
|
||||
* @return
|
||||
*/
|
||||
static boolean equals(String expected, @Nullable String actual) {
|
||||
byte[] expectedBytes = bytesUtf8(expected);
|
||||
byte[] actualBytes = bytesUtf8(actual);
|
||||
return MessageDigest.isEqual(expectedBytes, actualBytes);
|
||||
}
|
||||
|
||||
private static byte @Nullable [] bytesUtf8(@Nullable String s) {
|
||||
// need to check if Utf8.encode() runs in constant time (probably not).
|
||||
// This may leak length of string.
|
||||
return (s != null) ? Utf8.encode(s) : null;
|
||||
return Utf8.isEqual(expected, actual);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
-1
@@ -24,6 +24,7 @@ import com.password4j.BalloonHashingFunction;
|
||||
import com.password4j.Hash;
|
||||
import com.password4j.Password;
|
||||
|
||||
import org.springframework.security.crypto.codec.Utf8;
|
||||
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
@@ -140,7 +141,7 @@ public class BalloonHashingPassword4jPasswordEncoder extends AbstractValidatingP
|
||||
String expectedHash = parts[1];
|
||||
|
||||
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
|
||||
return expectedHash.equals(hash.getResult());
|
||||
return Utf8.isEqual(expectedHash, hash.getResult());
|
||||
}
|
||||
catch (IllegalArgumentException ex) {
|
||||
// Invalid Base64 encoding
|
||||
|
||||
+2
-1
@@ -24,6 +24,7 @@ import com.password4j.Hash;
|
||||
import com.password4j.PBKDF2Function;
|
||||
import com.password4j.Password;
|
||||
|
||||
import org.springframework.security.crypto.codec.Utf8;
|
||||
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
@@ -138,7 +139,7 @@ public class Pbkdf2Password4jPasswordEncoder extends AbstractValidatingPasswordE
|
||||
String expectedHash = parts[1];
|
||||
|
||||
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
|
||||
return expectedHash.equals(hash.getResult());
|
||||
return Utf8.isEqual(expectedHash, hash.getResult());
|
||||
}
|
||||
catch (IllegalArgumentException ex) {
|
||||
// Invalid Base64 encoding
|
||||
|
||||
@@ -37,4 +37,48 @@ public class Utf8Tests {
|
||||
assertThat(decoded).isEqualTo("6048b75ed560785c");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenDifferentLengthThenFalse() {
|
||||
assertThat(Utf8.isEqual("abc", "a")).isFalse();
|
||||
assertThat(Utf8.isEqual("a", "abc")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenNullAndNotEmptyThenFalse() {
|
||||
assertThat(Utf8.isEqual(null, "a")).isFalse();
|
||||
assertThat(Utf8.isEqual("a", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenNullAndNullThenTrue() {
|
||||
assertThat(Utf8.isEqual(null, null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenNullAndEmptyThenFalse() {
|
||||
assertThat(Utf8.isEqual(null, "")).isFalse();
|
||||
assertThat(Utf8.isEqual("", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenNotEmptyAndEmptyThenFalse() {
|
||||
assertThat(Utf8.isEqual("abc", "")).isFalse();
|
||||
assertThat(Utf8.isEqual("", "abc")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenEmptyAndEmptyThenTrue() {
|
||||
assertThat(Utf8.isEqual("", "")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenDifferentCaseThenFalse() {
|
||||
assertThat(Utf8.isEqual("aBc", "abc")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenSameThenTrue() {
|
||||
assertThat(Utf8.isEqual("abcdef", "abcdef")).isTrue();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.crypto.encrypt;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.security.crypto.codec.Hex;
|
||||
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm;
|
||||
import org.springframework.security.crypto.util.EncodingUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Tests for {@link AesCbcBytesEncryptor}.
|
||||
*/
|
||||
class AesCbcBytesEncryptorTests {
|
||||
|
||||
private final String secret = "value";
|
||||
|
||||
private final String password = "password";
|
||||
|
||||
private final String hexSalt = "deadbeef";
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingPasswordAndSaltThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingSecretKeyThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey secretKey = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(secretKey).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptWhenUsingMockIvThenProducesKnownCiphertext() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
BytesKeyGenerator mockGenerator = mock(BytesKeyGenerator.class);
|
||||
given(mockGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
|
||||
given(mockGenerator.getKeyLength()).willReturn(16);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt)
|
||||
.ivGenerator(mockGenerator)
|
||||
.build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(Hex.encode(encrypted)).isEqualTo("4b0febebd439db7ca77153cb254520c3b7232ac29355d07869433f1ecf55fe94");
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptProducesUniqueOutputAndIvIsPrePended() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] first = encryptor.encrypt(this.secret.getBytes());
|
||||
byte[] second = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(first).isNotEqualTo(second);
|
||||
assertThat(first.length).isGreaterThan(16);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void migratesFromDeprecatedNullIvCbcToAesCbcBytesEncryptor() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt);
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
|
||||
AesCbcBytesEncryptor modern = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
BytesEncryptor migrating = new MigratingBytesEncryptor("{CBC}", modern, deprecated);
|
||||
|
||||
assertThat(new String(migrating.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
|
||||
byte[] migrated = migrating.encrypt(this.secret.getBytes());
|
||||
assertThat(migrated[0]).isEqualTo((byte) '{');
|
||||
assertThat(migrated[1]).isEqualTo((byte) 'C');
|
||||
assertThat(migrated[2]).isEqualTo((byte) 'B');
|
||||
assertThat(migrated[3]).isEqualTo((byte) 'C');
|
||||
assertThat(migrated[4]).isEqualTo((byte) '}');
|
||||
assertThat(new String(migrating.decrypt(migrated))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void withSecretWhenAesBytesEncryptorEncryptsThenDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
|
||||
AesBytesEncryptor.CipherAlgorithm.CBC);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(key).build();
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void aesBytesEncryptorWhenEncryptsThenAesCbcBytesEncryptorDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(key).build();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
|
||||
AesBytesEncryptor.CipherAlgorithm.CBC);
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(deprecated.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingCustomIvGeneratorThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
BytesKeyGenerator customIvGenerator = mock(BytesKeyGenerator.class);
|
||||
given(customIvGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
|
||||
given(customIvGenerator.getKeyLength()).willReturn(16);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt)
|
||||
.ivGenerator(customIvGenerator)
|
||||
.build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void withPasswordDerivesADifferentKeyThanAesBytesEncryptor() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt,
|
||||
KeyGenerators.secureRandom(16));
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
|
||||
}
|
||||
|
||||
private static final class MigratingBytesEncryptor implements BytesEncryptor {
|
||||
|
||||
private final byte[] prefix;
|
||||
|
||||
private final BytesEncryptor write;
|
||||
|
||||
private final BytesEncryptor deprecated;
|
||||
|
||||
MigratingBytesEncryptor(String prefix, BytesEncryptor write, BytesEncryptor deprecated) {
|
||||
this.prefix = prefix.getBytes(StandardCharsets.US_ASCII);
|
||||
this.write = write;
|
||||
this.deprecated = deprecated;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] encrypt(byte[] bytes) {
|
||||
return EncodingUtils.concatenate(this.prefix, this.write.encrypt(bytes));
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] decrypt(byte[] encryptedBytes) {
|
||||
if (startsWith(encryptedBytes, this.prefix)) {
|
||||
byte[] bytes = EncodingUtils.subArray(encryptedBytes, this.prefix.length, encryptedBytes.length);
|
||||
return this.write.decrypt(bytes);
|
||||
}
|
||||
return this.deprecated.decrypt(encryptedBytes);
|
||||
}
|
||||
|
||||
private static boolean startsWith(byte[] data, byte[] prefix) {
|
||||
if (data.length < prefix.length) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < prefix.length; i++) {
|
||||
if (data[i] != prefix[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.crypto.encrypt;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.security.crypto.codec.Hex;
|
||||
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Tests for {@link AesGcmBytesEncryptor}.
|
||||
*/
|
||||
class AesGcmBytesEncryptorTests {
|
||||
|
||||
private final String secret = "value";
|
||||
|
||||
private final String password = "password";
|
||||
|
||||
private final String hexSalt = "deadbeef";
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingPasswordAndSaltThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingSecretKeyThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey secretKey = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(secretKey).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptWhenUsingMockIvThenProducesKnownCiphertext() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
BytesKeyGenerator mockGenerator = mock(BytesKeyGenerator.class);
|
||||
given(mockGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
|
||||
given(mockGenerator.getKeyLength()).willReturn(16);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt)
|
||||
.ivGenerator(mockGenerator)
|
||||
.build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(Hex.encode(encrypted))
|
||||
.isEqualTo("4b0febebd439db7ca77153cb254520c3e4d61ae38207b4e42b820d311dc3d4e0e2f37ed5ee");
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptProducesUniqueOutputAndIvIsPrepended() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] first = encryptor.encrypt(this.secret.getBytes());
|
||||
byte[] second = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(first).isNotEqualTo(second);
|
||||
assertThat(first.length).isGreaterThan(32);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void withSecretWhenAesBytesEncryptorEncryptsThenDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
|
||||
AesBytesEncryptor.CipherAlgorithm.GCM);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(key).build();
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void aesBytesEncryptorWhenEncryptsThenAesGcmBytesEncryptorDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(key)
|
||||
.ivGenerator(KeyGenerators.secureRandom(12))
|
||||
.build();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(12),
|
||||
AesBytesEncryptor.CipherAlgorithm.GCM);
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(deprecated.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingCustomIvGeneratorLengthThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt)
|
||||
.ivGenerator(KeyGenerators.secureRandom(12))
|
||||
.build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void withPasswordDerivesADifferentKeyThanAesBytesEncryptor() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt,
|
||||
KeyGenerators.secureRandom(16), AesBytesEncryptor.CipherAlgorithm.GCM);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decryptDetectsAuthenticationTagTampering() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
encrypted[17] ^= 0xFF;
|
||||
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
|
||||
}
|
||||
|
||||
}
|
||||
+4
-6
@@ -24,25 +24,23 @@ import javax.crypto.NoSuchPaddingException;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.opentest4j.TestAbortedException;
|
||||
|
||||
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
|
||||
|
||||
public final class CryptoAssumptions {
|
||||
|
||||
private CryptoAssumptions() {
|
||||
}
|
||||
|
||||
public static void assumeGCMJCE() {
|
||||
assumeAes256(CipherAlgorithm.GCM);
|
||||
assumeAes256("GCM");
|
||||
}
|
||||
|
||||
public static void assumeCBCJCE() {
|
||||
assumeAes256(CipherAlgorithm.CBC);
|
||||
assumeAes256("CBC");
|
||||
}
|
||||
|
||||
private static void assumeAes256(CipherAlgorithm cipherAlgorithm) {
|
||||
private static void assumeAes256(String cipherAlgorithm) {
|
||||
boolean aes256Available = false;
|
||||
try {
|
||||
Cipher.getInstance(cipherAlgorithm.toString());
|
||||
Cipher.getInstance(cipherAlgorithm);
|
||||
aes256Available = Cipher.getMaxAllowedKeyLength("AES") >= 256;
|
||||
}
|
||||
catch (NoSuchAlgorithmException ex) {
|
||||
|
||||
+8
-1
@@ -11,13 +11,16 @@ dependencies {
|
||||
def springFrameworkVersion = project.property("springFrameworkVersion")
|
||||
def reactorVersion = project.property("reactorVersion")
|
||||
def springDataVersion = project.property("springDataVersion")
|
||||
def micrometerVersion = project.property("micrometerVersion")
|
||||
api platform("org.springframework:spring-framework-bom:$springFrameworkVersion")
|
||||
api platform("io.projectreactor:reactor-bom:$reactorVersion")
|
||||
api platform("org.springframework.data:spring-data-bom:$springDataVersion")
|
||||
api platform("io.micrometer:micrometer-bom:$micrometerVersion")
|
||||
} else {
|
||||
api platform(libs.org.springframework.spring.framework.bom)
|
||||
api platform(libs.io.projectreactor.reactor.bom)
|
||||
api platform(libs.org.springframework.data.spring.data.bom)
|
||||
api platform(libs.io.micrometer.micrometer.bom)
|
||||
}
|
||||
api platform(libs.io.rsocket.rsocket.bom)
|
||||
api platform(libs.org.junit.junit.bom)
|
||||
@@ -72,7 +75,11 @@ dependencies {
|
||||
api libs.org.skyscreamer.jsonassert
|
||||
api libs.org.slf4j.log4j.over.slf4j
|
||||
api libs.org.slf4j.slf4j.api
|
||||
api libs.org.springframework.ldap.spring.ldap.core
|
||||
if (project.hasProperty("isOverrideVersionCatalog") && project.hasProperty("springLdapVersion")) {
|
||||
api "org.springframework.ldap:spring-ldap-core:${springLdapVersion}"
|
||||
} else {
|
||||
api libs.org.springframework.ldap.spring.ldap.core
|
||||
}
|
||||
api libs.org.synchronoss.cloud.nio.multipart.parser
|
||||
api libs.org.apache.maven.resolver.maven.resolver.connector.basic
|
||||
api libs.org.apache.maven.resolver.maven.resolver.impl
|
||||
|
||||
@@ -23,10 +23,28 @@ When using a proxy server, it is important to ensure that you have configured yo
|
||||
For example, many applications have a load balancer that responds to request for `\https://example.com/` by forwarding the request to an application server at `\https://192.168.0.107`
|
||||
Without proper configuration, the application server can not know that the load balancer exists and treats the request as though `\https://192.168.0.107:8080` was requested by the client.
|
||||
|
||||
To fix this, you can use https://tools.ietf.org/html/rfc7239[RFC 7239] to specify that a load balancer is being used.
|
||||
To make the application aware of this, you need to configure your application server to be aware of the X-Forwarded headers.
|
||||
To fix this, the proxy needs to pass on the details of the original request, and the application needs to be configured to use them.
|
||||
Two kinds of headers are used for this, and it is important to know which of them applies in your deployment:
|
||||
|
||||
* The standard `Forwarded` header, defined by https://tools.ietf.org/html/rfc7239[RFC 7239], which carries the original host, protocol, and client in a single header.
|
||||
* The non-standard `X-Forwarded-*` headers, such as `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-For`, which predate RFC 7239.
|
||||
|
||||
Most proxies still send the `X-Forwarded-*` headers rather than the standard `Forwarded` header, while Spring Framework and servers such as Reactor Netty and Jetty understand both.
|
||||
Do not assume that only one of them is in use.
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
Both kinds of headers are supplied by the client unless a proxy overwrites them, so an application that trusts them without a trusted proxy in front of it can be made to believe a request arrived over a different host, protocol, or client address than it really did.
|
||||
====
|
||||
|
||||
For this reason, the proxy at the edge of your network must be configured to remove or overwrite any forwarded headers that arrive from the outside, for _both_ kinds of headers.
|
||||
Dropping only the `Forwarded` header while passing through `X-Forwarded-*` (or the reverse) leaves the application open to the same spoofing through the other set.
|
||||
Only headers added by a proxy you control should reach the application.
|
||||
|
||||
Once untrusted values are handled at the edge, the application server can be configured to apply the headers.
|
||||
For example, Tomcat uses https://tomcat.apache.org/tomcat-10.1-doc/api/org/apache/catalina/valves/RemoteIpValve.html[`RemoteIpValve`] and Jetty uses https://eclipse.dev/jetty/javadoc/jetty-11/org/eclipse/jetty/server/ForwardedRequestCustomizer.html[`ForwardedRequestCustomizer`].
|
||||
Alternatively, Spring users can use https://docs.spring.io/spring-framework/reference/web/webmvc/filters.html#filters-forwarded-headers[`ForwardedHeaderFilter`] with the Servlet stack or https://docs.spring.io/spring-framework/reference/web/webflux/reactive-spring.html#webflux-forwarded-headers[`ForwardedHeaderTransformer`] with the Reactive stack.
|
||||
Both handle the `Forwarded` header and the `X-Forwarded-*` headers, and both can be configured to remove the headers instead of applying them, which is useful when the application is not behind a proxy.
|
||||
|
||||
Spring Boot users can use the `server.forward-headers-strategy` property to configure the application.
|
||||
See the https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto.webserver.use-behind-a-proxy-server[Spring Boot documentation] for further details.
|
||||
|
||||
@@ -8,8 +8,8 @@ The code is distributed as part of the core module but has no dependencies on an
|
||||
|
||||
[[spring-security-crypto-encryption]]
|
||||
== Encryptors
|
||||
The javadoc:org.springframework.security.crypto.encrypt.Encryptors[] class provides factory methods for constructing symmetric encryptors.
|
||||
This class lets you create javadoc:org.springframework.security.crypto.encrypt.BytesEncryptor[] instances to encrypt data in raw `byte[]` form.
|
||||
Spring Security provides javadoc:org.springframework.security.crypto.encrypt.AesGcmBytesEncryptor[] and javadoc:org.springframework.security.crypto.encrypt.AesCbcBytesEncryptor[] for constructing symmetric encryptors.
|
||||
These can be used to encrypt data in raw `byte[]` form.
|
||||
You can also construct javadoc:org.springframework.security.crypto.encrypt.TextEncryptor[] instances to encrypt text strings.
|
||||
Encryptors are thread-safe.
|
||||
|
||||
@@ -20,7 +20,7 @@ Both `BytesEncryptor` and `TextEncryptor` are interfaces. `BytesEncryptor` has m
|
||||
|
||||
[[spring-security-crypto-encryption-bytes]]
|
||||
=== BytesEncryptor
|
||||
You can use the `Encryptors.stronger` factory method to construct a `BytesEncryptor`:
|
||||
Use `AesGcmBytesEncryptor` to construct a BytesEncryptor with authenticated encryption:
|
||||
|
||||
.BytesEncryptor
|
||||
[tabs]
|
||||
@@ -29,24 +29,22 @@ Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
Encryptors.stronger("password", "salt");
|
||||
AesGcmBytesEncryptor.withPassword("password", "salt").build();
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
Encryptors.stronger("password", "salt")
|
||||
AesGcmBytesEncryptor.withPassword("password", "salt").build()
|
||||
----
|
||||
======
|
||||
|
||||
The `stronger` encryption method creates an encryptor by using 256-bit AES encryption with
|
||||
Galois Counter Mode (GCM).
|
||||
It derives the secret key by using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2).
|
||||
This method requires Java 6.
|
||||
The password used to generate the `SecretKey` should be kept in a secure place and should not be shared.
|
||||
The salt is used to prevent dictionary attacks against the key in the event that your encrypted data is compromised.
|
||||
A 16-byte random initialization vector is also applied so that each encrypted message is unique.
|
||||
`AesGcmBytesEncryptor` uses 256-bit AES encryption with Galois Counter Mode (GCM), providing https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated encryption] (AEAD).
|
||||
It derives the secret key using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2).
|
||||
The password used to generate the SecretKey should be kept in a secure place and not be shared.
|
||||
The salt is used to prevent dictionary attacks against the key in the event your encrypted data is compromised.
|
||||
A 16-byte random initialization vector is also applied so each encrypted message is unique.
|
||||
|
||||
The provided salt should be in hex-encoded String form, be random, and be at least 8 bytes in length.
|
||||
You can generate such a salt by using a `KeyGenerator`:
|
||||
@@ -69,14 +67,33 @@ val salt = KeyGenerators.string().generateKey() // generates a random 8-byte sal
|
||||
----
|
||||
======
|
||||
|
||||
You can also use the `standard` encryption method, which is 256-bit AES in Cipher Block Chaining (CBC) Mode.
|
||||
Users who require CBC mode may use `AesCbcBytesEncryptor`:
|
||||
|
||||
.AesCbcBytesEncryptor
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
AesCbcBytesEncryptor.withPassword("password", "salt").build();
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
AesCbcBytesEncryptor.withPassword("password", "salt").build()
|
||||
----
|
||||
======
|
||||
|
||||
This mode is not https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated] and does not provide any
|
||||
guarantees about the authenticity of the data.
|
||||
For a more secure alternative, use `Encryptors.stronger`.
|
||||
For a more secure alternative, users should prefer `AesGcmBytesEncryptor`.
|
||||
|
||||
[[spring-security-crypto-encryption-text]]
|
||||
=== TextEncryptor
|
||||
You can use the `Encryptors.text` factory method to construct a standard TextEncryptor:
|
||||
Use `AesCbcBytesEncryptor` to encrypt text data:
|
||||
|
||||
.TextEncryptor
|
||||
[tabs]
|
||||
@@ -85,19 +102,22 @@ Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
Encryptors.text("password", "salt");
|
||||
AesCbcBytesEncryptor.withPassword("password", "salt").build();
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
Encryptors.text("password", "salt")
|
||||
AesCbcBytesEncryptor.withPassword("password", "salt").build()
|
||||
----
|
||||
======
|
||||
|
||||
A `TextEncryptor` uses a standard `BytesEncryptor` to encrypt text data.
|
||||
Encrypted results are returned as hex-encoded strings for easy storage on the filesystem or in a database.
|
||||
`AesCbcBytesEncryptor` encrypts data as raw bytes.
|
||||
You can hex-encode the result for easy storage on the filesystem or in the database.
|
||||
|
||||
NOTE: Queryable text encryption (encrypting such that the same plaintext always produces the same ciphertext) is no longer recommended, as it relies on a fixed initialization vector and does not provide adequate security.
|
||||
Instead, look to your data store for a mechanism to query encrypted data.
|
||||
|
||||
[[spring-security-crypto-keygenerators]]
|
||||
== Key Generators
|
||||
|
||||
@@ -132,4 +132,11 @@ authenticationConverter.setBearerTokenResolver(myBearerTokenResolver)
|
||||
authenticationConverter.setAuthenticationDetailsSource(myAuthenticationDetailsSource)
|
||||
val filter = BearerTokenAuthenticationFilter(authenticationManager, authenticationConverter)
|
||||
----
|
||||
|
||||
== `NimbusJwtDecoder`'s Default Connect and Read Timeouts Are Now 30 Seconds
|
||||
|
||||
`NimbusJwtDecoder`'s default `RestOperations`, used to fetch a JWK Set when no `RestOperations` is otherwise configured, previously used a 500 millisecond connect and read timeout.
|
||||
This value was too short for many deployments and is now 30 seconds, aligning with the connect and read timeouts already used elsewhere in the OAuth2 Client and Resource Server support, such as xref:servlet/oauth2/client/index.adoc[`ClientRegistrations`].
|
||||
|
||||
If your application relies on the previous, shorter timeout -- for example, expecting a fast failure when the authorization server is unreachable -- you can restore it either by setting the JDK's `sun.net.client.defaultConnectTimeout` and `sun.net.client.defaultReadTimeout` system properties (in milliseconds), or by providing your own `RestOperations`, as described in xref:servlet/oauth2/resource-server/jwt.adoc#oauth2resourceserver-jwt-timeouts[Configuring Timeouts].
|
||||
======
|
||||
|
||||
@@ -695,7 +695,7 @@ class MyAuthoritiesPopulator : LdapAuthoritiesPopulator {
|
||||
|
||||
You would then add a bean of this type to your application context and inject it into the `LdapAuthenticationProvider`. This is covered in the section on configuring LDAP by using explicit Spring beans in the LDAP chapter of the reference manual.
|
||||
Note that you cannot use the namespace for configuration in this case.
|
||||
You should also consult the {security-api-url}[Javadoc] for the relevant classes and interfaces.
|
||||
You should also consult the xref:attachment$api/java/index.html[Javadoc] for the relevant classes and interfaces.
|
||||
|
||||
|
||||
[[appendix-faq-namespace-post-processor]]
|
||||
|
||||
@@ -81,6 +81,12 @@ Xml::
|
||||
|
||||
and no authorization changes are necessary since it simply adjusts the `LogoutFilter`.
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
The URI passed to `logoutUrl` (and to `logoutSuccessUrl`) is matched and redirected to literally.
|
||||
If you want either URI to live under your application servlet's base path, include that prefix explicitly — for example, `logoutUrl("/api/logout")`.
|
||||
====
|
||||
|
||||
[[permit-logout-endpoints]]
|
||||
However, if you stand up your own logout success endpoint (or in a rare case, <<creating-custom-logout-endpoint, your own logout endpoint>>), say using {spring-framework-reference-url}web.html#spring-web[Spring MVC], you will need to permit it in Spring Security.
|
||||
This is because Spring MVC processes your request after Spring Security does.
|
||||
|
||||
@@ -42,7 +42,7 @@ In the following sections we will explore how to configure OTT Login for your ne
|
||||
[[default-pages]]
|
||||
== Default Login Page and Default One-Time Token Submit Page
|
||||
|
||||
When the `oneTimeTokenLogin()` DSL is used, by default the One-Time Token Login Page is auto-generated by the org.springframework.security.web.authentication.ui:DefaultLoginPageGeneratingFilter[].
|
||||
When the `oneTimeTokenLogin()` DSL is used, by default the One-Time Token Login Page is auto-generated by the javadoc:org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter[].
|
||||
The DSL will also set up the javadoc:org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter[] to generate a default One-Time Token submit page.
|
||||
|
||||
[[sending-token-to-user]]
|
||||
@@ -89,7 +89,7 @@ public class MagicLinkOneTimeTokenGenerationSuccessHandler implements OneTimeTok
|
||||
|
||||
@Override
|
||||
public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken) throws IOException, ServletException {
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(request.getRequestURL().toString())
|
||||
.replacePath(request.getContextPath())
|
||||
.replaceQuery(null)
|
||||
.fragment(null)
|
||||
@@ -147,7 +147,7 @@ class MagicLinkOneTimeTokenGenerationSuccessHandler(
|
||||
) : OneTimeTokenGenerationSuccessHandler {
|
||||
|
||||
override fun handle(request: HttpServletRequest, response: HttpServletResponse, oneTimeToken: OneTimeToken) {
|
||||
val builder = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
|
||||
val builder = UriComponentsBuilder.fromUriString(request.getRequestURL().toString())
|
||||
.replacePath(request.contextPath)
|
||||
.replaceQuery(null)
|
||||
.fragment(null)
|
||||
@@ -211,7 +211,7 @@ public class SecurityConfig {
|
||||
// ...
|
||||
.formLogin(Customizer.withDefaults())
|
||||
.oneTimeTokenLogin((ott) -> ott
|
||||
.generateTokenUrl("/ott/my-generate-url")
|
||||
.tokenGeneratingUrl("/ott/my-generate-url")
|
||||
);
|
||||
return http.build();
|
||||
}
|
||||
@@ -238,7 +238,7 @@ class SecurityConfig {
|
||||
//...
|
||||
formLogin { }
|
||||
oneTimeTokenLogin {
|
||||
generateTokenUrl = "/ott/my-generate-url"
|
||||
tokenGeneratingUrl = "/ott/my-generate-url"
|
||||
}
|
||||
}
|
||||
return http.build()
|
||||
@@ -252,6 +252,13 @@ class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSucc
|
||||
----
|
||||
======
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
The URI passed to `generateTokenUrl` is matched literally.
|
||||
If you want it to live under your application servlet's base path, include that prefix explicitly — for example, `generateTokenUrl("/api/ott/generate")`.
|
||||
The same applies to `loginProcessingUrl` and `tokenGeneratingUrl` elsewhere on this page.
|
||||
====
|
||||
|
||||
[[changing-submit-page-url]]
|
||||
== Changing the Default Submit Page URL
|
||||
|
||||
@@ -275,7 +282,7 @@ public class SecurityConfig {
|
||||
// ...
|
||||
.formLogin(Customizer.withDefaults())
|
||||
.oneTimeTokenLogin((ott) -> ott
|
||||
.submitPageUrl("/ott/submit")
|
||||
.defaultSubmitPageUrl("/ott/submit")
|
||||
);
|
||||
return http.build();
|
||||
}
|
||||
@@ -302,7 +309,7 @@ class SecurityConfig {
|
||||
//...
|
||||
formLogin { }
|
||||
oneTimeTokenLogin {
|
||||
submitPageUrl = "/ott/submit"
|
||||
defaultSubmitPageUrl = "/ott/submit"
|
||||
}
|
||||
}
|
||||
return http.build()
|
||||
|
||||
@@ -155,6 +155,15 @@ open fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
----
|
||||
======
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
The URI passed to `loginPage` (and to `loginProcessingUrl`, if set separately) is matched and redirected to literally.
|
||||
If your custom login page is served by an application servlet mapped under a base path, include that prefix in the URI you pass to the DSL.
|
||||
For example, if your dispatcher is at `/api/*`:
|
||||
|
||||
include-code::./FormLoginServletPathConfiguration[tag=loginPage,indent=0]
|
||||
====
|
||||
|
||||
[[servlet-authentication-form-custom-html]]
|
||||
When the login page is specified in the Spring Security configuration, you are responsible for rendering the page.
|
||||
// FIXME: default login page rendered by Spring Security
|
||||
|
||||
@@ -642,6 +642,8 @@ This is because Spring Security requires all URIs to be absolute (minus the cont
|
||||
There are several other components that create request matchers for you like {spring-boot-api-url}org/springframework/boot/security/autoconfigure/web/servlet/PathRequest.html[`PathRequest#toStaticResources#atCommonLocations`]
|
||||
=====
|
||||
|
||||
If most of your authorization rules sit under the same servlet path, you can xref:servlet/integrations/mvc.adoc#mvc-requestmatcher[publish a `PathPatternRequestMatcher.Builder` bean] with that base path; Spring Security then applies it to the string overloads of `authorizeHttpRequests((authorize) -> authorize.requestMatchers(...))`, `HttpSecurity#securityMatcher(...)` / `#securityMatchers(...)`, and `WebSecurityCustomizer#ignoring().requestMatchers(...)`.
|
||||
|
||||
[[match-by-custom]]
|
||||
=== Using a Custom Matcher
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ This means that we are running our `Runnable` with the same user that was used t
|
||||
|
||||
== Spring Security Concurrency Classes
|
||||
|
||||
See the {security-api-url}index.html[Javadoc] for additional integrations with both the Java concurrent APIs and the Spring Task abstractions.
|
||||
See the xref:attachment$api/java/index.html[Javadoc] for additional integrations with both the Java concurrent APIs and the Spring Task abstractions.
|
||||
They are self-explanatory once you understand the previous code.
|
||||
|
||||
* javadoc:org.springframework.security.concurrent.DelegatingSecurityContextCallable[]
|
||||
|
||||
+3
-3
@@ -144,13 +144,13 @@ An `OAuth2Authorization` is a representation of an OAuth2 authorization, which h
|
||||
[TIP]
|
||||
The corresponding authorization model in Spring Security's OAuth2 Client support is xref:servlet/oauth2/client/core.adoc#oauth2Client-authorized-client[OAuth2AuthorizedClient].
|
||||
|
||||
After the successful completion of an authorization grant flow, an `OAuth2Authorization` is created and associates an {security-api-url}/org/springframework/security/oauth2/core/OAuth2AccessToken.html[`OAuth2AccessToken`], an (optional) {security-api-url}/org/springframework/security/oauth2/core/OAuth2RefreshToken.html[`OAuth2RefreshToken`], and additional state specific to the executed authorization grant type.
|
||||
After the successful completion of an authorization grant flow, an `OAuth2Authorization` is created and associates an javadoc:org.springframework.security.oauth2.core.OAuth2AccessToken[], an (optional) javadoc:org.springframework.security.oauth2.core.OAuth2RefreshToken[], and additional state specific to the executed authorization grant type.
|
||||
|
||||
The {security-api-url}/org/springframework/security/oauth2/core/OAuth2Token.html[`OAuth2Token`] instances associated with an `OAuth2Authorization` vary, depending on the authorization grant type.
|
||||
The javadoc:org.springframework.security.oauth2.core.OAuth2Token[] instances associated with an `OAuth2Authorization` vary, depending on the authorization grant type.
|
||||
|
||||
For the OAuth2 https://datatracker.ietf.org/doc/html/rfc6749#section-4.1[authorization_code grant], an `OAuth2AuthorizationCode`, an `OAuth2AccessToken`, and an (optional) `OAuth2RefreshToken` are associated.
|
||||
|
||||
For the OpenID Connect 1.0 https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth[authorization_code grant], an `OAuth2AuthorizationCode`, an {security-api-url}/org/springframework/security/oauth2/core/oidc/OidcIdToken.html[`OidcIdToken`], an `OAuth2AccessToken`, and an (optional) `OAuth2RefreshToken` are associated.
|
||||
For the OpenID Connect 1.0 https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth[authorization_code grant], an `OAuth2AuthorizationCode`, an javadoc:org.springframework.security.oauth2.core.oidc.OidcIdToken[], an `OAuth2AccessToken`, and an (optional) `OAuth2RefreshToken` are associated.
|
||||
|
||||
For the OAuth2 https://datatracker.ietf.org/doc/html/rfc6749#section-4.4[client_credentials grant], only an `OAuth2AccessToken` is associated.
|
||||
|
||||
|
||||
@@ -277,9 +277,9 @@ This is a minimal configuration for getting started quickly. To understand what
|
||||
|
||||
<1> A Spring Security filter chain for the xref:servlet/oauth2/authorization-server/protocol-endpoints.adoc[Protocol Endpoints].
|
||||
<2> A Spring Security filter chain for xref:servlet/authentication/index.adoc#servlet-authentication[authentication].
|
||||
<3> An instance of {security-api-url}/org/springframework/security/core/userdetails/UserDetailsService.html[`UserDetailsService`] for retrieving users to authenticate.
|
||||
<3> An instance of javadoc:org.springframework.security.core.userdetails.UserDetailsService[] for retrieving users to authenticate.
|
||||
<4> An instance of xref:servlet/oauth2/authorization-server/core-model-components.adoc#oauth2AuthorizationServer-registered-client-repository[`RegisteredClientRepository`] for managing clients.
|
||||
<5> An instance of `com.nimbusds.jose.jwk.source.JWKSource` for signing access tokens.
|
||||
<6> An instance of `java.security.KeyPair` with keys generated on startup used to create the `JWKSource` above.
|
||||
<7> An instance of {security-api-url}/org/springframework/security/oauth2/jwt/JwtDecoder.html[`JwtDecoder`] for decoding signed access tokens.
|
||||
<7> An instance of javadoc:org.springframework.security.oauth2.jwt.JwtDecoder[] for decoding signed access tokens.
|
||||
<8> An instance of xref:servlet/oauth2/authorization-server/configuration-model.adoc#oauth2AuthorizationServer-configuring-authorization-server-settings[`AuthorizationServerSettings`] to configure Spring Security Authorization Server.
|
||||
|
||||
@@ -944,5 +944,52 @@ For MAC-based algorithms (such as `HS256`, `HS384`, or `HS512`), the `client-sec
|
||||
If more than one `ClientRegistration` is configured for OpenID Connect 1.0 Authentication, the JWS algorithm resolver may evaluate the provided `ClientRegistration` to determine which algorithm to return.
|
||||
====
|
||||
|
||||
[[oauth2login-advanced-idtoken-decoder-factory]]
|
||||
== Providing a Custom JwtDecoderFactory
|
||||
|
||||
`OidcIdTokenDecoderFactory` is deliberately narrow: it exists to make it easy to support multiple `ClientRegistration` instances, each potentially needing a different `JwtDecoder`, and its configuration surface is limited to what most applications need for that purpose, such as the JWS algorithm resolver shown above.
|
||||
|
||||
If your application needs deeper control over how the ID Token's `JwtDecoder` is constructed -- for example, providing your own `RestOperations` -- you can instead provide your own `JwtDecoderFactory<ClientRegistration>` `@Bean`.
|
||||
Because OAuth2 Login does not use a resource server `JwtDecoder` bean for this purpose, this is the supported way to reach the same level of control that `NimbusJwtDecoder`'s builder already offers, without introducing additional configuration properties on `OidcIdTokenDecoderFactory` itself.
|
||||
|
||||
For a single `ClientRegistration`, this can be as simple as:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
@Bean
|
||||
JwtDecoderFactory<ClientRegistration> idTokenDecoderFactory(RestOperations rest) {
|
||||
return (clientRegistration) -> {
|
||||
String issuerUri = clientRegistration.getProviderDetails().getIssuerUri();
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuerUri).restOperations(rest).build();
|
||||
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(new OidcIdTokenValidator(clientRegistration)));
|
||||
decoder.setClaimTypeConverter(OidcIdTokenDecoderFactory.createDefaultClaimTypeConverter());
|
||||
return decoder;
|
||||
};
|
||||
}
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
@Bean
|
||||
fun idTokenDecoderFactory(rest: RestOperations): JwtDecoderFactory<ClientRegistration> {
|
||||
return JwtDecoderFactory { clientRegistration ->
|
||||
val issuerUri = clientRegistration.providerDetails.issuerUri
|
||||
val decoder = NimbusJwtDecoder.withIssuerLocation(issuerUri).restOperations(rest).build()
|
||||
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(OidcIdTokenValidator(clientRegistration)))
|
||||
decoder.setClaimTypeConverter(OidcIdTokenDecoderFactory.createDefaultClaimTypeConverter())
|
||||
decoder
|
||||
}
|
||||
}
|
||||
----
|
||||
======
|
||||
|
||||
If you have multiple `ClientRegistration` instances and want to avoid rebuilding a `JwtDecoder` on every request, cache the decoder per registration, for example by annotating the factory method with `@Cacheable` or by keying a `Map` on `ClientRegistration#getRegistrationId()`.
|
||||
|
||||
[[oauth2login-advanced-oidc-logout]]
|
||||
Then, you can proceed to configure xref:servlet/oauth2/login/logout.adoc[logout]
|
||||
|
||||
@@ -1582,6 +1582,7 @@ fun jwtDecoder(): JwtDecoder {
|
||||
== Configuring Timeouts
|
||||
|
||||
By default, Resource Server uses connection and socket timeouts of 30 seconds each for coordinating with the authorization server.
|
||||
You can override these defaults without changing any code by setting the JDK's `sun.net.client.defaultConnectTimeout` and `sun.net.client.defaultReadTimeout` system properties (in milliseconds).
|
||||
|
||||
This may be too short in some scenarios.
|
||||
Further, it doesn't take into account more sophisticated patterns like back-off and discovery.
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"antora": "3.2.0-alpha.11",
|
||||
"antora": "3.2.0-rc.2",
|
||||
"@antora/atlas-extension": "1.0.0-alpha.5",
|
||||
"@antora/collector-extension": "1.0.3",
|
||||
"@asciidoctor/tabs": "1.0.0-beta.6",
|
||||
"@springio/antora-extensions": "1.14.11",
|
||||
"@springio/antora-extensions": "1.14.13",
|
||||
"@springio/asciidoctor-extensions": "1.0.0-alpha.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ plugins {
|
||||
apply plugin: 'io.spring.convention.docs'
|
||||
apply plugin: 'java'
|
||||
|
||||
node {
|
||||
version = "24.15.0"
|
||||
}
|
||||
|
||||
antora {
|
||||
options = [clean: true, fetch: !project.gradle.startParameter.offline, stacktrace: true]
|
||||
environment = [
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.docs.servlet.authentication.passwords.servletauthenticationform;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@EnableWebSecurity
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class FormLoginServletPathConfiguration {
|
||||
|
||||
// tag::loginPage[]
|
||||
@Bean
|
||||
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.formLogin((form) -> form
|
||||
.loginPage("/api/login")
|
||||
.loginProcessingUrl("/api/login")
|
||||
);
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
// end::loginPage[]
|
||||
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package org.springframework.security.kt.docs.servlet.authentication.passwords.servletauthenticationform
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.annotation.web.invoke
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
|
||||
@EnableWebSecurity
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
class FormLoginServletPathConfiguration {
|
||||
|
||||
// tag::loginPage[]
|
||||
@Bean
|
||||
fun springSecurity(http: HttpSecurity): SecurityFilterChain {
|
||||
// @formatter:off
|
||||
http {
|
||||
authorizeHttpRequests {
|
||||
authorize(anyRequest, authenticated)
|
||||
}
|
||||
formLogin {
|
||||
loginPage = "/api/login"
|
||||
loginProcessingUrl = "/api/login"
|
||||
}
|
||||
}
|
||||
return http.build()
|
||||
// @formatter:on
|
||||
}
|
||||
// end::loginPage[]
|
||||
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
# limitations under the License.
|
||||
#
|
||||
springBootVersion=4.0.0-SNAPSHOT
|
||||
version=7.0.5
|
||||
version=7.0.8-SNAPSHOT
|
||||
samplesBranch=main
|
||||
org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.parallel=true
|
||||
|
||||
+15
-13
@@ -1,22 +1,23 @@
|
||||
[versions]
|
||||
com-squareup-okhttp3 = "3.14.9"
|
||||
io-micrometer = "1.16.7"
|
||||
io-rsocket = "1.1.5"
|
||||
io-spring-javaformat = "0.0.47"
|
||||
io-spring-nohttp = "0.0.11"
|
||||
jakarta-websocket = "2.2.0"
|
||||
org-apache-maven-resolver = "1.9.27"
|
||||
org-aspectj = "1.9.25.1"
|
||||
org-bouncycastle = "1.80"
|
||||
org-bouncycastle = "1.80.2"
|
||||
org-eclipse-jetty = "11.0.26"
|
||||
org-jetbrains-kotlin = "2.2.21"
|
||||
org-jetbrains-kotlinx = "1.10.2"
|
||||
org-mockito = "5.17.0"
|
||||
org-opensaml5 = "5.1.6"
|
||||
org-springframework = "7.0.7"
|
||||
org-springframework = "7.0.9"
|
||||
com-password4j = "1.8.4"
|
||||
|
||||
[libraries]
|
||||
ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.32"
|
||||
ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.38"
|
||||
com-fasterxml-jackson-jackson-bom = "com.fasterxml.jackson:jackson-bom:2.20.2"
|
||||
com-google-inject-guice = "com.google.inject:guice:3.0"
|
||||
com-netflix-nebula-nebula-project-plugin = "com.netflix.nebula:nebula-project-plugin:8.2.0"
|
||||
@@ -24,13 +25,14 @@ com-nimbusds-nimbus-jose-jwt = "com.nimbusds:nimbus-jose-jwt:10.4"
|
||||
com-nimbusds-oauth2-oidc-sdk = "com.nimbusds:oauth2-oidc-sdk:11.26.1"
|
||||
com-squareup-okhttp3-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "com-squareup-okhttp3" }
|
||||
com-squareup-okhttp3-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "com-squareup-okhttp3" }
|
||||
com-unboundid-unboundid-ldapsdk = "com.unboundid:unboundid-ldapsdk:7.0.4"
|
||||
com-unboundid-unboundid-ldapsdk = "com.unboundid:unboundid-ldapsdk:7.0.5"
|
||||
com-jayway-jsonpath-json-path = "com.jayway.jsonpath:json-path:2.9.0"
|
||||
commons-collections = "commons-collections:commons-collections:3.2.2"
|
||||
io-micrometer-context-propagation = "io.micrometer:context-propagation:1.1.4"
|
||||
io-micrometer-micrometer-observation = "io.micrometer:micrometer-observation:1.14.14"
|
||||
io-mockk = "io.mockk:mockk:1.14.9"
|
||||
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2025.0.5"
|
||||
io-micrometer-micrometer-bom = { module = "io.micrometer:micrometer-bom", version.ref = "io-micrometer" }
|
||||
io-micrometer-micrometer-observation = { module = "io.micrometer:micrometer-observation", version.ref = "io-micrometer" }
|
||||
io-mockk = "io.mockk:mockk:1.14.11"
|
||||
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2025.0.7"
|
||||
io-rsocket-rsocket-bom = { module = "io.rsocket:rsocket-bom", version.ref = "io-rsocket" }
|
||||
io-spring-javaformat-spring-javaformat-checkstyle = { module = "io.spring.javaformat:spring-javaformat-checkstyle", version.ref = "io-spring-javaformat" }
|
||||
io-spring-javaformat-spring-javaformat-gradle-plugin = { module = "io.spring.javaformat:spring-javaformat-gradle-plugin", version.ref = "io-spring-javaformat" }
|
||||
@@ -50,8 +52,8 @@ ldapsdk = "ldapsdk:ldapsdk:4.1"
|
||||
net-sourceforge-htmlunit = "net.sourceforge.htmlunit:htmlunit:2.70.0"
|
||||
org-htmlunit-htmlunit = "org.htmlunit:htmlunit:4.11.1"
|
||||
org-apache-httpcomponents-httpclient = "org.apache.httpcomponents.client5:httpclient5:5.5.2"
|
||||
org-apache-kerby-simplekdc='org.apache.kerby:kerb-simplekdc:2.1.1'
|
||||
org-apache-maven-maven-resolver-provider = "org.apache.maven:maven-resolver-provider:3.9.15"
|
||||
org-apache-kerby-simplekdc='org.apache.kerby:kerb-simplekdc:2.1.2'
|
||||
org-apache-maven-maven-resolver-provider = "org.apache.maven:maven-resolver-provider:3.9.16"
|
||||
org-apache-maven-resolver-maven-resolver-connector-basic = { module = "org.apache.maven.resolver:maven-resolver-connector-basic", version.ref = "org-apache-maven-resolver" }
|
||||
org-apache-maven-resolver-maven-resolver-impl = { module = "org.apache.maven.resolver:maven-resolver-impl", version.ref = "org-apache-maven-resolver" }
|
||||
org-apache-maven-resolver-maven-resolver-transport-http = { module = "org.apache.maven.resolver:maven-resolver-transport-http", version.ref = "org-apache-maven-resolver" }
|
||||
@@ -80,9 +82,9 @@ org-seleniumhq-selenium-selenium-java = "org.seleniumhq.selenium:selenium-java:4
|
||||
org-seleniumhq-selenium-selenium-support = "org.seleniumhq.selenium:selenium-support:3.141.59"
|
||||
org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3"
|
||||
org-slf4j-log4j-over-slf4j = "org.slf4j:log4j-over-slf4j:1.7.36"
|
||||
org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.17"
|
||||
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2025.1.5"
|
||||
org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.0.3"
|
||||
org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.18"
|
||||
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2025.1.7"
|
||||
org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.0.5"
|
||||
org-springframework-spring-framework-bom = { module = "org.springframework:spring-framework-bom", version.ref = "org-springframework" }
|
||||
org-synchronoss-cloud-nio-multipart-parser = "org.synchronoss.cloud:nio-multipart-parser:1.1.0"
|
||||
tools-jackson-jackson-bom = "tools.jackson:jackson-bom:3.0.4"
|
||||
@@ -102,7 +104,7 @@ org-sonarsource-scanner-gradle-sonarqube-gradle-plugin = "org.sonarsource.scanne
|
||||
org-instancio-instancio-junit = "org.instancio:instancio-junit:3.7.1"
|
||||
|
||||
spring-nullability = 'io.spring.nullability:io.spring.nullability.gradle.plugin:0.0.6'
|
||||
webauthn4j-core = 'com.webauthn4j:webauthn4j-core:0.31.3.RELEASE'
|
||||
webauthn4j-core = 'com.webauthn4j:webauthn4j-core:0.31.6.RELEASE'
|
||||
com-password4j-password4j = { module = "com.password4j:password4j", version.ref = "com-password4j" }
|
||||
|
||||
[plugins]
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionSha256Sum=f1771298a70f6db5a29daf62378c4e18a17fc33c9ba6b14362e0cdf40610380d
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
|
||||
distributionSha256Sum=6f74b601422d6d6fc4e1f9a1ab6522f642c2fdcbc15ae33ebd30ba3d7198e854
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
+20
@@ -21,9 +21,11 @@ import java.net.ServerSocket;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -48,6 +50,24 @@ public class UnboundIdContainerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startLdapServerThenListenerBindsToLoopbackAddressOnly() throws Exception {
|
||||
UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null);
|
||||
server.setApplicationContext(new GenericApplicationContext());
|
||||
List<Integer> ports = getDefaultPorts(1);
|
||||
server.setPort(ports.get(0));
|
||||
|
||||
try {
|
||||
server.afterPropertiesSet();
|
||||
InMemoryDirectoryServer directoryServer = (InMemoryDirectoryServer) ReflectionTestUtils.getField(server,
|
||||
"directoryServer");
|
||||
assertThat(directoryServer.getListenAddress().isLoopbackAddress()).isTrue();
|
||||
}
|
||||
finally {
|
||||
server.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void afterPropertiesSetWhenPortIsZeroThenRandomPortIsSelected() throws Exception {
|
||||
UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package org.springframework.security.ldap.server;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
|
||||
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
|
||||
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
|
||||
@@ -96,7 +97,8 @@ public class UnboundIdContainer
|
||||
try {
|
||||
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(this.defaultPartitionSuffix);
|
||||
config.addAdditionalBindCredentials("uid=admin,ou=system", "secret");
|
||||
config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("LDAP", this.port));
|
||||
config.setListenerConfigs(
|
||||
InMemoryListenerConfig.createLDAPConfig("LDAP", InetAddress.getLoopbackAddress(), this.port, null));
|
||||
config.setEnforceSingleStructuralObjectClass(false);
|
||||
config.setEnforceAttributeSyntaxCompliance(true);
|
||||
DN dn = new DN(this.defaultPartitionSuffix);
|
||||
|
||||
+1
-6
@@ -20,7 +20,6 @@ import java.util.function.Supplier;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import org.springframework.expression.BeanResolver;
|
||||
import org.springframework.expression.EvaluationContext;
|
||||
import org.springframework.expression.spel.support.StandardEvaluationContext;
|
||||
import org.springframework.messaging.Message;
|
||||
@@ -47,11 +46,7 @@ public class DefaultMessageSecurityExpressionHandler<T> extends AbstractSecurity
|
||||
Message<T> message) {
|
||||
MessageSecurityExpressionRoot<T> root = createSecurityExpressionRoot(authentication, message);
|
||||
StandardEvaluationContext ctx = new StandardEvaluationContext(root);
|
||||
BeanResolver beanResolver = getBeanResolver();
|
||||
if (beanResolver != null) {
|
||||
// https://github.com/spring-projects/spring-framework/issues/35371
|
||||
ctx.setBeanResolver(beanResolver);
|
||||
}
|
||||
ctx.setBeanResolver(getBeanResolver());
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
||||
+1
-4
@@ -175,10 +175,7 @@ public class CurrentSecurityContextArgumentResolver implements HandlerMethodArgu
|
||||
StandardEvaluationContext context = new StandardEvaluationContext();
|
||||
context.setRootObject(securityContext);
|
||||
context.setVariable("this", securityContext);
|
||||
if (this.beanResolver != null) {
|
||||
// https://github.com/spring-projects/spring-framework/issues/35371
|
||||
context.setBeanResolver(this.beanResolver);
|
||||
}
|
||||
context.setBeanResolver(this.beanResolver);
|
||||
Expression expression = this.parser.parseExpression(expressionToParse);
|
||||
securityContext = expression.getValue(context);
|
||||
}
|
||||
|
||||
+8
-7
@@ -24,6 +24,7 @@ import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.crypto.codec.Utf8;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
||||
@@ -192,38 +193,38 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza
|
||||
}
|
||||
|
||||
private static boolean matchesState(OAuth2Authorization authorization, String token) {
|
||||
return token.equals(authorization.getAttribute(OAuth2ParameterNames.STATE));
|
||||
return Utf8.isEqual(authorization.getAttribute(OAuth2ParameterNames.STATE), token);
|
||||
}
|
||||
|
||||
private static boolean matchesAuthorizationCode(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = authorization
|
||||
.getToken(OAuth2AuthorizationCode.class);
|
||||
return authorizationCode != null && authorizationCode.getToken().getTokenValue().equals(token);
|
||||
return authorizationCode != null && Utf8.isEqual(authorizationCode.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesAccessToken(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2AccessToken> accessToken = authorization.getToken(OAuth2AccessToken.class);
|
||||
return accessToken != null && accessToken.getToken().getTokenValue().equals(token);
|
||||
return accessToken != null && Utf8.isEqual(accessToken.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesRefreshToken(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = authorization.getToken(OAuth2RefreshToken.class);
|
||||
return refreshToken != null && refreshToken.getToken().getTokenValue().equals(token);
|
||||
return refreshToken != null && Utf8.isEqual(refreshToken.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesIdToken(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OidcIdToken> idToken = authorization.getToken(OidcIdToken.class);
|
||||
return idToken != null && idToken.getToken().getTokenValue().equals(token);
|
||||
return idToken != null && Utf8.isEqual(idToken.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode = authorization.getToken(OAuth2DeviceCode.class);
|
||||
return deviceCode != null && deviceCode.getToken().getTokenValue().equals(token);
|
||||
return deviceCode != null && Utf8.isEqual(deviceCode.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesUserCode(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2UserCode> userCode = authorization.getToken(OAuth2UserCode.class);
|
||||
return userCode != null && userCode.getToken().getTokenValue().equals(token);
|
||||
return userCode != null && Utf8.isEqual(userCode.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
@SuppressWarnings("serial")
|
||||
|
||||
+2
-1
@@ -489,7 +489,8 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen
|
||||
registeredClient);
|
||||
if (error.getErrorCode().equals(OAuth2ErrorCodes.INVALID_REQUEST)
|
||||
&& (parameterName.equals(OAuth2ParameterNames.CLIENT_ID)
|
||||
|| parameterName.equals(OAuth2ParameterNames.STATE))) {
|
||||
|| parameterName.equals(OAuth2ParameterNames.STATE)
|
||||
|| parameterName.equals(OAuth2ParameterNames.REQUEST_URI))) {
|
||||
redirectUri = null; // Prevent redirects
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -293,8 +293,10 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationValidator
|
||||
String redirectUri = StringUtils.hasText(authorizationCodeRequestAuthentication.getRedirectUri())
|
||||
? authorizationCodeRequestAuthentication.getRedirectUri()
|
||||
: registeredClient.getRedirectUris().iterator().next();
|
||||
if (error.getErrorCode().equals(OAuth2ErrorCodes.INVALID_REQUEST)
|
||||
&& parameterName.equals(OAuth2ParameterNames.REDIRECT_URI)) {
|
||||
if ((error.getErrorCode().equals(OAuth2ErrorCodes.INVALID_REQUEST)
|
||||
|| error.getErrorCode().equals(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT))
|
||||
&& (parameterName.equals(OAuth2ParameterNames.CLIENT_ID)
|
||||
|| parameterName.equals(OAuth2ParameterNames.REDIRECT_URI))) {
|
||||
redirectUri = null; // Prevent redirects
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -27,6 +27,7 @@ import org.springframework.security.crypto.keygen.StringKeyGenerator;
|
||||
* Requests.
|
||||
*
|
||||
* @author Joe Grandja
|
||||
* @author Andrey Litvitski
|
||||
* @since 7.0
|
||||
*/
|
||||
final class OAuth2PushedAuthorizationRequestUri {
|
||||
@@ -60,7 +61,7 @@ final class OAuth2PushedAuthorizationRequestUri {
|
||||
|
||||
static OAuth2PushedAuthorizationRequestUri parse(String requestUri) {
|
||||
int stateStartIndex = REQUEST_URI_PREFIX.length();
|
||||
int expiresAtStartIndex = requestUri.indexOf(REQUEST_URI_DELIMITER) + REQUEST_URI_DELIMITER.length();
|
||||
int expiresAtStartIndex = requestUri.lastIndexOf(REQUEST_URI_DELIMITER) + REQUEST_URI_DELIMITER.length();
|
||||
OAuth2PushedAuthorizationRequestUri pushedAuthorizationRequestUri = new OAuth2PushedAuthorizationRequestUri();
|
||||
pushedAuthorizationRequestUri.requestUri = requestUri;
|
||||
pushedAuthorizationRequestUri.state = requestUri.substring(stateStartIndex);
|
||||
|
||||
+20
-10
@@ -29,6 +29,7 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
|
||||
/**
|
||||
* For internal use only.
|
||||
@@ -56,6 +57,12 @@ final class DefaultConsentPage {
|
||||
private static String generateConsentPage(HttpServletRequest request, String clientId, Authentication principal,
|
||||
Set<String> requestedScopes, Set<String> authorizedScopes, String state,
|
||||
Map<String, String> additionalParameters) {
|
||||
|
||||
String encodedClientId = HtmlUtils.htmlEscape(clientId);
|
||||
String encodedState = HtmlUtils.htmlEscape(state);
|
||||
String encodedPrincipalName = HtmlUtils.htmlEscape(principal.getName());
|
||||
String encodedRequestUri = HtmlUtils.htmlEscape(request.getRequestURI());
|
||||
|
||||
Set<String> scopesToAuthorize = new HashSet<>();
|
||||
Set<String> scopesPreviouslyAuthorized = new HashSet<>();
|
||||
for (String scope : requestedScopes) {
|
||||
@@ -74,6 +81,7 @@ final class DefaultConsentPage {
|
||||
// the "user_code" being displayed on the device to confirm they are
|
||||
// authorizing the correct device.
|
||||
String userCode = additionalParameters.get(OAuth2ParameterNames.USER_CODE);
|
||||
String encodedUserCode = (userCode != null) ? HtmlUtils.htmlEscape(userCode) : null;
|
||||
|
||||
// @formatter:off
|
||||
StringBuilder builder = new StringBuilder();
|
||||
@@ -98,13 +106,13 @@ final class DefaultConsentPage {
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"row\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <p><span class=\"font-weight-bold text-primary\">" + clientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + principal.getName() + "</span></p>");
|
||||
builder.append(" <p><span class=\"font-weight-bold text-primary\">" + encodedClientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + encodedPrincipalName + "</span></p>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
if (userCode != null) {
|
||||
builder.append(" <div class=\"row\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + userCode + "</span>. Verify that this code matches what is shown on your device.</p>");
|
||||
builder.append(" <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + encodedUserCode + "</span>. Verify that this code matches what is shown on your device.</p>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
}
|
||||
@@ -115,26 +123,28 @@ final class DefaultConsentPage {
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"row\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + request.getRequestURI() + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + clientId + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + state + "\">");
|
||||
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + encodedRequestUri + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + encodedClientId + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + encodedState + "\">");
|
||||
if (userCode != null) {
|
||||
builder.append(" <input type=\"hidden\" name=\"user_code\" value=\"" + userCode + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"user_code\" value=\"" + encodedUserCode + "\">");
|
||||
}
|
||||
|
||||
for (String scope : scopesToAuthorize) {
|
||||
String encodedScope = HtmlUtils.htmlEscape(scope);
|
||||
builder.append(" <div class=\"form-group form-check py-1\">");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + scope + "\" id=\"" + scope + "\">");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + encodedScope + "\" id=\"" + encodedScope + "\">");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + encodedScope + "\">" + encodedScope + "</label>");
|
||||
builder.append(" </div>");
|
||||
}
|
||||
|
||||
if (!scopesPreviouslyAuthorized.isEmpty()) {
|
||||
builder.append(" <p>You have already granted the following permissions to the above app:</p>");
|
||||
for (String scope : scopesPreviouslyAuthorized) {
|
||||
String encodedScope = HtmlUtils.htmlEscape(scope);
|
||||
builder.append(" <div class=\"form-group form-check py-1\">");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + scope + "\" checked disabled>");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + encodedScope + "\" checked disabled>");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + encodedScope + "\">" + encodedScope + "</label>");
|
||||
builder.append(" </div>");
|
||||
}
|
||||
}
|
||||
|
||||
+43
-29
@@ -123,46 +123,60 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationConverter impleme
|
||||
principal = ANONYMOUS_AUTHENTICATION;
|
||||
}
|
||||
|
||||
// redirect_uri (OPTIONAL)
|
||||
String redirectUri = parameters.getFirst(OAuth2ParameterNames.REDIRECT_URI);
|
||||
if (StringUtils.hasText(redirectUri) && parameters.get(OAuth2ParameterNames.REDIRECT_URI).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI);
|
||||
String redirectUri = null;
|
||||
if (!StringUtils.hasText(requestUri)) {
|
||||
// redirect_uri (OPTIONAL)
|
||||
redirectUri = parameters.getFirst(OAuth2ParameterNames.REDIRECT_URI);
|
||||
if (StringUtils.hasText(redirectUri) && parameters.get(OAuth2ParameterNames.REDIRECT_URI).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI);
|
||||
}
|
||||
}
|
||||
|
||||
// scope (OPTIONAL)
|
||||
Set<String> scopes = null;
|
||||
String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE);
|
||||
if (StringUtils.hasText(scope) && parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.SCOPE);
|
||||
}
|
||||
if (StringUtils.hasText(scope)) {
|
||||
scopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));
|
||||
if (!StringUtils.hasText(requestUri)) {
|
||||
// scope (OPTIONAL)
|
||||
String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE);
|
||||
if (StringUtils.hasText(scope) && parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.SCOPE);
|
||||
}
|
||||
if (StringUtils.hasText(scope)) {
|
||||
scopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));
|
||||
}
|
||||
}
|
||||
|
||||
// state (RECOMMENDED)
|
||||
String state = parameters.getFirst(OAuth2ParameterNames.STATE);
|
||||
if (StringUtils.hasText(state) && parameters.get(OAuth2ParameterNames.STATE).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE);
|
||||
String state = null;
|
||||
if (!StringUtils.hasText(requestUri)) {
|
||||
// state (RECOMMENDED)
|
||||
state = parameters.getFirst(OAuth2ParameterNames.STATE);
|
||||
if (StringUtils.hasText(state) && parameters.get(OAuth2ParameterNames.STATE).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE);
|
||||
}
|
||||
}
|
||||
|
||||
// code_challenge (REQUIRED for public clients) - RFC 7636 (PKCE)
|
||||
String codeChallenge = parameters.getFirst(PkceParameterNames.CODE_CHALLENGE);
|
||||
if (StringUtils.hasText(codeChallenge) && parameters.get(PkceParameterNames.CODE_CHALLENGE).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE, PKCE_ERROR_URI);
|
||||
if (!StringUtils.hasText(requestUri)) {
|
||||
// code_challenge (REQUIRED for public clients) - RFC 7636 (PKCE)
|
||||
String codeChallenge = parameters.getFirst(PkceParameterNames.CODE_CHALLENGE);
|
||||
if (StringUtils.hasText(codeChallenge) && parameters.get(PkceParameterNames.CODE_CHALLENGE).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE, PKCE_ERROR_URI);
|
||||
}
|
||||
}
|
||||
|
||||
// code_challenge_method (OPTIONAL for public clients) - RFC 7636 (PKCE)
|
||||
String codeChallengeMethod = parameters.getFirst(PkceParameterNames.CODE_CHALLENGE_METHOD);
|
||||
if (StringUtils.hasText(codeChallengeMethod)
|
||||
&& parameters.get(PkceParameterNames.CODE_CHALLENGE_METHOD).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD, PKCE_ERROR_URI);
|
||||
if (!StringUtils.hasText(requestUri)) {
|
||||
// code_challenge_method (OPTIONAL for public clients) - RFC 7636 (PKCE)
|
||||
String codeChallengeMethod = parameters.getFirst(PkceParameterNames.CODE_CHALLENGE_METHOD);
|
||||
if (StringUtils.hasText(codeChallengeMethod)
|
||||
&& parameters.get(PkceParameterNames.CODE_CHALLENGE_METHOD).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD, PKCE_ERROR_URI);
|
||||
}
|
||||
}
|
||||
|
||||
// prompt (OPTIONAL for OpenID Connect 1.0 Authentication Request)
|
||||
if (!CollectionUtils.isEmpty(scopes) && scopes.contains(OidcScopes.OPENID)) {
|
||||
String prompt = parameters.getFirst("prompt");
|
||||
if (StringUtils.hasText(prompt) && parameters.get("prompt").size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, "prompt");
|
||||
if (!StringUtils.hasText(requestUri)) {
|
||||
// prompt (OPTIONAL for OpenID Connect 1.0 Authentication Request)
|
||||
if (!CollectionUtils.isEmpty(scopes) && scopes.contains(OidcScopes.OPENID)) {
|
||||
String prompt = parameters.getFirst("prompt");
|
||||
if (StringUtils.hasText(prompt) && parameters.get("prompt").size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, "prompt");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+44
@@ -25,7 +25,9 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2UserCode;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
|
||||
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
|
||||
@@ -60,6 +62,10 @@ public class InMemoryOAuth2AuthorizationServiceTests {
|
||||
|
||||
private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN);
|
||||
|
||||
private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE);
|
||||
|
||||
private static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE);
|
||||
|
||||
private InMemoryOAuth2AuthorizationService authorizationService;
|
||||
|
||||
@BeforeEach
|
||||
@@ -313,6 +319,44 @@ public class InMemoryOAuth2AuthorizationServiceTests {
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findByTokenWhenDeviceCodeExistsThenFound() {
|
||||
OAuth2DeviceCode deviceCode = new OAuth2DeviceCode("device-code", Instant.now(),
|
||||
Instant.now().plus(5, ChronoUnit.MINUTES));
|
||||
OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
|
||||
.id(ID)
|
||||
.principalName(PRINCIPAL_NAME)
|
||||
.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
|
||||
.token(deviceCode)
|
||||
.build();
|
||||
this.authorizationService.save(authorization);
|
||||
|
||||
OAuth2Authorization result = this.authorizationService.findByToken(deviceCode.getTokenValue(),
|
||||
DEVICE_CODE_TOKEN_TYPE);
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
result = this.authorizationService.findByToken(deviceCode.getTokenValue(), null);
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findByTokenWhenUserCodeExistsThenFound() {
|
||||
OAuth2UserCode userCode = new OAuth2UserCode("user-code", Instant.now(),
|
||||
Instant.now().plus(5, ChronoUnit.MINUTES));
|
||||
OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
|
||||
.id(ID)
|
||||
.principalName(PRINCIPAL_NAME)
|
||||
.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
|
||||
.token(userCode)
|
||||
.build();
|
||||
this.authorizationService.save(authorization);
|
||||
|
||||
OAuth2Authorization result = this.authorizationService.findByToken(userCode.getTokenValue(),
|
||||
USER_CODE_TOKEN_TYPE);
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
result = this.authorizationService.findByToken(userCode.getTokenValue(), null);
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findByTokenWhenWrongTokenTypeThenNotFound() {
|
||||
OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now());
|
||||
|
||||
+1
-1
@@ -311,7 +311,7 @@ public class OAuth2AuthorizationCodeRequestAuthenticationProviderTests {
|
||||
assertThatExceptionOfType(OAuth2AuthorizationCodeRequestAuthenticationException.class)
|
||||
.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
|
||||
.satisfies((ex) -> assertAuthenticationException(ex, OAuth2ErrorCodes.UNAUTHORIZED_CLIENT,
|
||||
OAuth2ParameterNames.CLIENT_ID, authentication.getRedirectUri()));
|
||||
OAuth2ParameterNames.CLIENT_ID, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ public class OAuth2PushedAuthorizationRequestAuthenticationProviderTests {
|
||||
assertThatExceptionOfType(OAuth2AuthorizationCodeRequestAuthenticationException.class)
|
||||
.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
|
||||
.satisfies((ex) -> assertAuthenticationException(ex, OAuth2ErrorCodes.UNAUTHORIZED_CLIENT,
|
||||
OAuth2ParameterNames.CLIENT_ID, authentication.getRedirectUri()));
|
||||
OAuth2ParameterNames.CLIENT_ID, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.server.authorization.authentication;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link OAuth2PushedAuthorizationRequestUri}.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @author Andrey Litvitski
|
||||
*/
|
||||
public class OAuth2PushedAuthorizationRequestUriTests {
|
||||
|
||||
@Test
|
||||
public void parseWhenValidRequestUriThenReturnsExpectedValues() {
|
||||
String state = "abcXYZ123-abcXYZ123";
|
||||
long epochMilli = 1700000000000L;
|
||||
String requestUri = "urn:ietf:params:oauth:request_uri:" + state + "___" + epochMilli;
|
||||
|
||||
OAuth2PushedAuthorizationRequestUri parsed = OAuth2PushedAuthorizationRequestUri.parse(requestUri);
|
||||
|
||||
assertThat(parsed.getRequestUri()).isEqualTo(requestUri);
|
||||
assertThat(parsed.getState()).isEqualTo(state + "___" + epochMilli);
|
||||
assertThat(parsed.getExpiresAt()).isEqualTo(Instant.ofEpochMilli(epochMilli));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createWhenParsedThenReturnsEquivalentValues() {
|
||||
Instant expiresAt = Instant.ofEpochMilli(1700000000000L);
|
||||
|
||||
OAuth2PushedAuthorizationRequestUri created = OAuth2PushedAuthorizationRequestUri.create(expiresAt);
|
||||
OAuth2PushedAuthorizationRequestUri parsed = OAuth2PushedAuthorizationRequestUri.parse(created.getRequestUri());
|
||||
|
||||
assertThat(parsed.getRequestUri()).isEqualTo(created.getRequestUri());
|
||||
assertThat(parsed.getState()).isEqualTo(created.getState());
|
||||
assertThat(parsed.getExpiresAt()).isEqualTo(created.getExpiresAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseWhenStateContainsDelimiterThenParsesSuccessfully() {
|
||||
String state = "xXMGJTZwzXIFL8i_DFu_EM8IeWC___frCWjpiF2q-xs=";
|
||||
long epochMillis = 1781670640281L;
|
||||
String requestUri = "urn:ietf:params:oauth:request_uri:" + state + "___" + epochMillis;
|
||||
|
||||
OAuth2PushedAuthorizationRequestUri parsedUri = OAuth2PushedAuthorizationRequestUri.parse(requestUri);
|
||||
|
||||
assertThat(parsedUri.getRequestUri()).isEqualTo(requestUri);
|
||||
assertThat(parsedUri.getState()).isEqualTo(state + "___" + epochMillis);
|
||||
assertThat(parsedUri.getExpiresAt()).isEqualTo(Instant.ofEpochMilli(epochMillis));
|
||||
}
|
||||
|
||||
}
|
||||
+34
@@ -64,6 +64,7 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHand
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetails;
|
||||
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -581,6 +582,39 @@ public class OAuth2AuthorizationEndpointFilterTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void doFilterWhenAuthorizationRequestConsentRequiredThenConsentResponseHtmlEscaped() throws Exception {
|
||||
String unencodedScope = "<scope2>";
|
||||
String unencodedState = "<state>";
|
||||
Set<String> requestedScopes = new HashSet<>(Arrays.asList("scope1", unencodedScope));
|
||||
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes((scopes) -> {
|
||||
scopes.clear();
|
||||
scopes.addAll(requestedScopes);
|
||||
}).build();
|
||||
// No scopes previously approved
|
||||
OAuth2AuthorizationConsentAuthenticationToken authorizationConsentAuthenticationResult = new OAuth2AuthorizationConsentAuthenticationToken(
|
||||
AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, unencodedState, new HashSet<>(),
|
||||
null);
|
||||
authorizationConsentAuthenticationResult.setAuthenticated(true);
|
||||
given(this.authenticationManager.authenticate(any())).willReturn(authorizationConsentAuthenticationResult);
|
||||
|
||||
MockHttpServletRequest request = createAuthorizationRequest(registeredClient);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain filterChain = mock(FilterChain.class);
|
||||
|
||||
this.filter.doFilter(request, response, filterChain);
|
||||
|
||||
verify(this.authenticationManager).authenticate(any());
|
||||
verifyNoInteractions(filterChain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
|
||||
String html = response.getContentAsString();
|
||||
assertThat(html).doesNotContain(unencodedScope);
|
||||
assertThat(html).doesNotContain(unencodedState);
|
||||
assertThat(html).contains(HtmlUtils.htmlEscape(unencodedScope));
|
||||
assertThat(html).contains("name=\"state\" value=\"" + HtmlUtils.htmlEscape(unencodedState) + "\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationResponse() throws Exception {
|
||||
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().redirectUris((redirectUris) -> {
|
||||
|
||||
+14
@@ -33,6 +33,8 @@ public final class DelegatingOAuth2TokenValidator<T extends OAuth2Token> impleme
|
||||
|
||||
private final Collection<OAuth2TokenValidator<T>> tokenValidators;
|
||||
|
||||
private boolean failOnError;
|
||||
|
||||
/**
|
||||
* Constructs a {@code DelegatingOAuth2TokenValidator} using the provided validators.
|
||||
* @param tokenValidators the {@link Collection} of {@link OAuth2TokenValidator}s to
|
||||
@@ -57,8 +59,20 @@ public final class DelegatingOAuth2TokenValidator<T extends OAuth2Token> impleme
|
||||
Collection<OAuth2Error> errors = new ArrayList<>();
|
||||
for (OAuth2TokenValidator<T> validator : this.tokenValidators) {
|
||||
errors.addAll(validator.validate(token).getErrors());
|
||||
if (!errors.isEmpty() && this.failOnError) {
|
||||
return OAuth2TokenValidatorResult.failure(errors);
|
||||
}
|
||||
}
|
||||
return OAuth2TokenValidatorResult.failure(errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-fast when a delegate errors, defaults to {@code false}.
|
||||
* @param failOnError fail-fast when a delegate errors
|
||||
* @since 6.5.12
|
||||
*/
|
||||
public void setFailOnError(boolean failOnError) {
|
||||
this.failOnError = failOnError;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+143
-35
@@ -18,11 +18,11 @@ package org.springframework.security.oauth2.jwt;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
@@ -39,12 +39,15 @@ import com.nimbusds.jose.proc.SecurityContext;
|
||||
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
|
||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||||
|
||||
import org.springframework.security.oauth2.core.ClaimAccessor;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2Token;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
@@ -63,10 +66,11 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
||||
|
||||
/**
|
||||
* The default {@code OAuth2TokenValidator<Jwt>} factory that validates the
|
||||
* {@code htm}, {@code htu}, {@code jti} and {@code iat} claims of the DPoP Proof
|
||||
* {@link Jwt}.
|
||||
* {@code htm}, {@code htu}, {@code iat}, {@code jkt}, {@code ath} and {@code jti}
|
||||
* claims of the DPoP Proof {@link Jwt}.
|
||||
*/
|
||||
public static final Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = defaultJwtValidatorFactory();
|
||||
public static final Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = createDefaultJwtValidatorFactory(
|
||||
Collections.emptyList());
|
||||
|
||||
private static final JOSEObjectTypeVerifier<SecurityContext> DPOP_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>(
|
||||
new JOSEObjectType("dpop+jwt"));
|
||||
@@ -94,6 +98,70 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
||||
this.jwtValidatorFactory = jwtValidatorFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a factory that provides an {@link OAuth2TokenValidator} for the specified
|
||||
* {@link DPoPProofContext} and is used by the {@link JwtDecoder}. The returned
|
||||
* factory provides a validator that validates the {@code htm}, {@code htu},
|
||||
* {@code iat}, {@code jkt}, {@code ath} and {@code jti} claims, along with any custom
|
||||
* validators provided.
|
||||
* @param validators the custom validators to add
|
||||
* @return a factory that provides an {@link OAuth2TokenValidator} for the specified
|
||||
* {@link DPoPProofContext}
|
||||
* @since 6.5.12
|
||||
*/
|
||||
public static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> createDefaultJwtValidatorFactory(
|
||||
List<OAuth2TokenValidator<Jwt>> validators) {
|
||||
Assert.notNull(validators, "validators cannot be null");
|
||||
List<OAuth2TokenValidator<Jwt>> customValidators = new ArrayList<>();
|
||||
if (!CollectionUtils.isEmpty(validators)) {
|
||||
customValidators.addAll(validators);
|
||||
}
|
||||
final Duration clockSkew = Duration.ofSeconds(30);
|
||||
final JwtIssuedAtValidator jwtIssuedAtValidator;
|
||||
if (CollectionUtils.findValueOfType(customValidators, JwtIssuedAtValidator.class) != null) {
|
||||
jwtIssuedAtValidator = CollectionUtils.findValueOfType(customValidators, JwtIssuedAtValidator.class);
|
||||
customValidators.remove(jwtIssuedAtValidator);
|
||||
}
|
||||
else {
|
||||
jwtIssuedAtValidator = new JwtIssuedAtValidator(true);
|
||||
jwtIssuedAtValidator.setClockSkew(clockSkew);
|
||||
}
|
||||
final DPoPProofReplayValidator dPoPProofReplayValidator;
|
||||
if (CollectionUtils.findValueOfType(customValidators, DPoPProofReplayValidator.class) != null) {
|
||||
dPoPProofReplayValidator = CollectionUtils.findValueOfType(customValidators,
|
||||
DPoPProofReplayValidator.class);
|
||||
customValidators.remove(dPoPProofReplayValidator);
|
||||
}
|
||||
else {
|
||||
dPoPProofReplayValidator = new DPoPProofReplayValidator(new DPoPProofReplayValidator.InMemoryCache());
|
||||
dPoPProofReplayValidator.setClockSkew(clockSkew);
|
||||
}
|
||||
return (context) -> createDefaultJwtValidatorFactory(context, jwtIssuedAtValidator, dPoPProofReplayValidator,
|
||||
customValidators);
|
||||
}
|
||||
|
||||
private static OAuth2TokenValidator<Jwt> createDefaultJwtValidatorFactory(DPoPProofContext context,
|
||||
JwtIssuedAtValidator jwtIssuedAtValidator, DPoPProofReplayValidator dPoPProofReplayValidator,
|
||||
List<OAuth2TokenValidator<Jwt>> customValidators) {
|
||||
// Add custom validators first then default validators in a specific order
|
||||
List<OAuth2TokenValidator<Jwt>> tokenValidators = new ArrayList<>();
|
||||
if (!CollectionUtils.isEmpty(customValidators)) {
|
||||
tokenValidators.addAll(customValidators);
|
||||
}
|
||||
tokenValidators.add(new JwtClaimValidator<>("htm", context.getMethod()::equalsIgnoreCase));
|
||||
tokenValidators.add(new JwtClaimValidator<>("htu", context.getTargetUri()::equals));
|
||||
tokenValidators.add(jwtIssuedAtValidator);
|
||||
if (context.getAccessToken() != null) {
|
||||
tokenValidators.add(new JwkThumbprintValidator(context.getAccessToken()));
|
||||
tokenValidators.add(new AthClaimValidator(context.getAccessToken()));
|
||||
}
|
||||
tokenValidators.add(dPoPProofReplayValidator);
|
||||
DelegatingOAuth2TokenValidator<Jwt> delegatingTokenValidator = new DelegatingOAuth2TokenValidator<>(
|
||||
tokenValidators);
|
||||
delegatingTokenValidator.setFailOnError(true);
|
||||
return delegatingTokenValidator;
|
||||
}
|
||||
|
||||
private static NimbusJwtDecoder buildDecoder() {
|
||||
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
|
||||
jwtProcessor.setJWSTypeVerifier(DPOP_TYPE_VERIFIER);
|
||||
@@ -137,39 +205,34 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
||||
};
|
||||
}
|
||||
|
||||
private static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> defaultJwtValidatorFactory() {
|
||||
return (context) -> new DelegatingOAuth2TokenValidator<>(
|
||||
new JwtClaimValidator<>("htm", context.getMethod()::equals),
|
||||
new JwtClaimValidator<>("htu", context.getTargetUri()::equals), new JtiClaimValidator(),
|
||||
new JwtIssuedAtValidator(true));
|
||||
}
|
||||
private static final class AthClaimValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private static final class JtiClaimValidator implements OAuth2TokenValidator<Jwt> {
|
||||
private final OAuth2Token accessToken;
|
||||
|
||||
private static final Map<String, Long> JTI_CACHE = Collections.synchronizedMap(new JtiCache());
|
||||
private AthClaimValidator(OAuth2Token accessToken) {
|
||||
Assert.notNull(accessToken, "accessToken cannot be null");
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
||||
String jti = jwt.getId();
|
||||
if (!StringUtils.hasText(jti)) {
|
||||
OAuth2Error error = createOAuth2Error("jti claim is required.");
|
||||
String accessTokenHashClaim = jwt.getClaimAsString("ath");
|
||||
if (!StringUtils.hasText(accessTokenHashClaim)) {
|
||||
OAuth2Error error = createOAuth2Error("ath claim is required.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
// Enforce single-use to protect against DPoP proof replay
|
||||
String jtiHash;
|
||||
String accessTokenHash;
|
||||
try {
|
||||
jtiHash = computeSHA256(jti);
|
||||
accessTokenHash = computeSHA256(this.accessToken.getTokenValue());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
|
||||
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
Instant expiry = Instant.now().plus(1, ChronoUnit.HOURS);
|
||||
if ((JTI_CACHE.putIfAbsent(jtiHash, expiry.toEpochMilli())) != null) {
|
||||
// Already used
|
||||
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
|
||||
if (!accessTokenHashClaim.equals(accessTokenHash)) {
|
||||
OAuth2Error error = createOAuth2Error("ath claim is invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
@@ -185,20 +248,65 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
}
|
||||
|
||||
@SuppressWarnings("serial")
|
||||
private static final class JtiCache extends LinkedHashMap<String, Long> {
|
||||
}
|
||||
|
||||
private static final int MAX_SIZE = 1000;
|
||||
private static final class JwkThumbprintValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, Long> eldest) {
|
||||
if (size() > MAX_SIZE) {
|
||||
return true;
|
||||
}
|
||||
Instant expiry = Instant.ofEpochMilli(eldest.getValue());
|
||||
return Instant.now().isAfter(expiry);
|
||||
private final OAuth2Token accessToken;
|
||||
|
||||
private final ClaimAccessor claims;
|
||||
|
||||
private JwkThumbprintValidator(OAuth2Token accessToken) {
|
||||
Assert.notNull(accessToken, "accessToken cannot be null");
|
||||
Assert.isInstanceOf(ClaimAccessor.class, accessToken, "accessToken must be instance of ClaimAccessor");
|
||||
this.accessToken = accessToken;
|
||||
this.claims = (ClaimAccessor) accessToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
||||
String jwkThumbprintClaim = null;
|
||||
Map<String, Object> confirmationMethodClaim = this.claims.getClaimAsMap("cnf");
|
||||
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) {
|
||||
jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt");
|
||||
}
|
||||
if (jwkThumbprintClaim == null) {
|
||||
OAuth2Error error = createOAuth2Error("jkt claim is required.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
JWK jwk = null;
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
|
||||
try {
|
||||
jwk = JWK.parse(jwkJson);
|
||||
}
|
||||
catch (Exception ignored) {
|
||||
}
|
||||
if (jwk == null) {
|
||||
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
String jwkThumbprint;
|
||||
try {
|
||||
jwkThumbprint = jwk.computeThumbprint().toString();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
if (!jwkThumbprintClaim.equals(jwkThumbprint)) {
|
||||
OAuth2Error error = createOAuth2Error("jkt claim is invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
|
||||
private static OAuth2Error createOAuth2Error(String reason) {
|
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.jwt;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import com.nimbusds.jose.jwk.JWK;
|
||||
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.support.SimpleValueWrapper;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* An {@link OAuth2TokenValidator} that mitigates DPoP Proof Replay.
|
||||
*
|
||||
* <p>
|
||||
* This validator mitigates DPoP Proof Replay by ensuring the DPoP Proof:
|
||||
* <ul>
|
||||
* <li>contains the {@code iat} (issued at) claim, and it's within an acceptable time
|
||||
* window (configured via {@link #setClockSkew(Duration)})</li>
|
||||
* <li>contains the {@code jti} (JWT ID) claim, and it has not been used previously</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* This implementation uses a {@link Cache} to store the {@code jti} claim (along with
|
||||
* other information in {@link CacheValue CacheValue}) to enforce single-use. The
|
||||
* {@code jti} is retained in the cache until the DPoP Proof expires, which is calculated
|
||||
* as {@code iat + clockSkew}.
|
||||
*
|
||||
* @author Joe Grandja
|
||||
* @since 6.5.12
|
||||
* @see OAuth2TokenValidator
|
||||
* @see DPoPProofJwtDecoderFactory
|
||||
* @see <a target="_blank" href=
|
||||
* "https://datatracker.ietf.org/doc/html/rfc9449#section-11.1">Section 11.1. DPoP Proof
|
||||
* Replay</a>
|
||||
*/
|
||||
public final class DPoPProofReplayValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private final Cache cache;
|
||||
|
||||
private Duration clockSkew = Duration.ofSeconds(30);
|
||||
|
||||
private Clock clock = Clock.systemUTC();
|
||||
|
||||
/**
|
||||
* Constructs a {@code DPoPProofReplayValidator} using the provided parameters.
|
||||
* @param cache the {@link Cache} used to store {@link CacheValue} which contains
|
||||
* information of the used DPoP Proof {@link Jwt}'s
|
||||
*/
|
||||
public DPoPProofReplayValidator(Cache cache) {
|
||||
Assert.notNull(cache, "cache cannot be null");
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
||||
String jti = jwt.getId();
|
||||
if (!StringUtils.hasText(jti)) {
|
||||
OAuth2Error error = createOAuth2Error("jti claim is required.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
Instant issuedAt = jwt.getIssuedAt();
|
||||
if (issuedAt == null) {
|
||||
OAuth2Error error = createOAuth2Error("iat claim is required.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
// Ensure acceptable time window
|
||||
Instant now = Instant.now(this.clock);
|
||||
Instant notBefore = now.minus(this.clockSkew);
|
||||
Instant notAfter = now.plus(this.clockSkew);
|
||||
if (issuedAt.isBefore(notBefore) || issuedAt.isAfter(notAfter)) {
|
||||
OAuth2Error error = createOAuth2Error("iat claim is invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
String jwkThumbprint;
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
|
||||
JWK jwk = JWK.parse(jwkJson);
|
||||
jwkThumbprint = jwk.computeThumbprint().toString();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
String jtiHash;
|
||||
try {
|
||||
jtiHash = computeSHA256(jti);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
Instant expiresAt = issuedAt.plus(this.clockSkew);
|
||||
CacheValue cacheValue = new CacheValue(issuedAt, expiresAt, jwkThumbprint);
|
||||
|
||||
// Enforce single-use to protect against DPoP proof replay
|
||||
if (this.cache.putIfAbsent(jtiHash, cacheValue) != null) {
|
||||
// Already used or cache full or key limit reached
|
||||
OAuth2Error error = createOAuth2Error("jti claim is invalid or unable to cache.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the clock skew. The default is 30 seconds.
|
||||
* @param clockSkew the clock skew
|
||||
*/
|
||||
public void setClockSkew(Duration clockSkew) {
|
||||
Assert.notNull(clockSkew, "clockSkew cannot be null");
|
||||
Assert.isTrue(clockSkew.getSeconds() >= 0, "clockSkew must be >= 0");
|
||||
this.clockSkew = clockSkew;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link Clock} used in {@link Instant#now(Clock)}.
|
||||
* @param clock the clock
|
||||
*/
|
||||
public void setClock(Clock clock) {
|
||||
Assert.notNull(clock, "clock cannot be null");
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
private static OAuth2Error createOAuth2Error(String reason) {
|
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
|
||||
}
|
||||
|
||||
private static String computeSHA256(String value) throws Exception {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-memory {@link Cache} implementation backed by a {@link ConcurrentHashMap}.
|
||||
*
|
||||
* <p>
|
||||
* <b>NOTE:</b> This implementation has limitations as it only works in a single-node
|
||||
* setup. For production (and clustered) environments, it is recommended to use a
|
||||
* distributed {@link Cache} implementation (e.g. Redis, Hazelcast, etc.).
|
||||
*
|
||||
* <p>
|
||||
* This implementation can be fine-tuned based on the following configuration
|
||||
* settings:
|
||||
* <ul>
|
||||
* <li>{@link #setMaxSize(int)} - Sets the maximum number of entries the cache can
|
||||
* hold. The default is 100,000.</li>
|
||||
* <li>{@link #setMaxRequestsPerKey(int)} - Sets the maximum number of requests
|
||||
* allowed per {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is
|
||||
* 1000.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public static final class InMemoryCache implements Cache {
|
||||
|
||||
private static final String DEFAULT_NAME = InMemoryCache.class.getName().concat(".DPOP-PROOF-CACHE");
|
||||
|
||||
private static final int DEFAULT_MAX_SIZE = 100_000;
|
||||
|
||||
private static final int DEFAULT_MAX_REQUESTS_PER_KEY = 1000;
|
||||
|
||||
private static final int CLEANUP_INTERVAL_SECS = 10;
|
||||
|
||||
private final ConcurrentMap<String, CacheValue> cache = new ConcurrentHashMap<>();
|
||||
|
||||
private final ConcurrentMap<String, Integer> requestsPerKey = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicBoolean cleaning = new AtomicBoolean(false);
|
||||
|
||||
private long lastCleanup = System.currentTimeMillis();
|
||||
|
||||
private int maxSize = DEFAULT_MAX_SIZE;
|
||||
|
||||
private int maxRequestsPerKey = DEFAULT_MAX_REQUESTS_PER_KEY;
|
||||
|
||||
/**
|
||||
* Returns the maximum number of entries the cache can hold.
|
||||
* @return the maximum number of entries the cache can hold
|
||||
*/
|
||||
public int getMaxSize() {
|
||||
return this.maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum number of entries the cache can hold. The default is 100,000.
|
||||
* @param maxSize the maximum number of entries the cache can hold
|
||||
*/
|
||||
public void setMaxSize(int maxSize) {
|
||||
Assert.isTrue(maxSize > 0, "maxSize must be > 0");
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum number of requests allowed per
|
||||
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}.
|
||||
* @return the maximum number of requests allowed per
|
||||
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}
|
||||
*/
|
||||
public int getMaxRequestsPerKey() {
|
||||
return this.maxRequestsPerKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum number of requests allowed per
|
||||
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is 1000.
|
||||
* @param maxRequestsPerKey the maximum number of requests allowed per
|
||||
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}
|
||||
*/
|
||||
public void setMaxRequestsPerKey(int maxRequestsPerKey) {
|
||||
Assert.isTrue(maxRequestsPerKey > 0, "maxRequestsPerKey must be > 0");
|
||||
this.maxRequestsPerKey = maxRequestsPerKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return DEFAULT_NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getNativeCache() {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ValueWrapper get(Object key) {
|
||||
Object value = this.cache.get(key);
|
||||
return (value != null) ? new SimpleValueWrapper(value) : null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public <T> @Nullable T get(Object key, @Nullable Class<T> type) {
|
||||
Object value = this.cache.get(key);
|
||||
if (value != null && type != null && !type.isInstance(value)) {
|
||||
throw new IllegalStateException(
|
||||
"Cached value is not of required type [" + type.getName() + "]: " + value);
|
||||
}
|
||||
return (T) value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> @Nullable T get(Object key, Callable<T> valueLoader) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(Object key, Object value) {
|
||||
putIfAbsent(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ValueWrapper putIfAbsent(Object key, Object value) {
|
||||
String jti = (String) key;
|
||||
CacheValue cacheValue = (CacheValue) value;
|
||||
|
||||
cleanupIfNecessary();
|
||||
if (this.cache.size() >= this.maxSize) {
|
||||
// Force an immediate cleanup when we hit the limit before the cleanup
|
||||
// interval
|
||||
cleanup();
|
||||
if (this.cache.size() >= this.maxSize) {
|
||||
// Cache full - return non-null value
|
||||
return new SimpleValueWrapper(cacheValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Limit the number of requests per key
|
||||
AtomicBoolean limitExceeded = new AtomicBoolean(false);
|
||||
this.requestsPerKey.compute(cacheValue.jwkThumbprint, (k, v) -> {
|
||||
if (v != null && v >= this.maxRequestsPerKey) {
|
||||
limitExceeded.set(true);
|
||||
return v;
|
||||
}
|
||||
// Increment
|
||||
return (v != null) ? v + 1 : 1;
|
||||
});
|
||||
if (limitExceeded.get()) {
|
||||
// Key limit reached - return non-null value
|
||||
return new SimpleValueWrapper(cacheValue);
|
||||
}
|
||||
|
||||
if (this.cache.putIfAbsent(jti, cacheValue) != null) {
|
||||
// jti exists - revert the increment and return non-null value
|
||||
this.requestsPerKey.computeIfPresent(cacheValue.jwkThumbprint, (k, v) -> (v > 1) ? v - 1 : null);
|
||||
return new SimpleValueWrapper(cacheValue);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void evict(Object key) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear() {
|
||||
}
|
||||
|
||||
private void cleanupIfNecessary() {
|
||||
long now = System.currentTimeMillis();
|
||||
long last = this.lastCleanup;
|
||||
if ((now - last) > (CLEANUP_INTERVAL_SECS * 1000)) {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanup() {
|
||||
if (this.cleaning.compareAndSet(false, true)) {
|
||||
try {
|
||||
Instant now = Instant.now();
|
||||
for (Map.Entry<String, CacheValue> entry : this.cache.entrySet()) {
|
||||
if (now.isAfter(entry.getValue().expiresAt)) {
|
||||
this.cache.remove(entry.getKey());
|
||||
this.requestsPerKey.computeIfPresent(entry.getValue().jwkThumbprint,
|
||||
(k, v) -> (v > 1) ? v - 1 : null);
|
||||
}
|
||||
}
|
||||
this.lastCleanup = System.currentTimeMillis();
|
||||
}
|
||||
finally {
|
||||
this.cleaning.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A representation of the value to which the {@link Cache} maps a (hashed)
|
||||
* {@code (jti)} claim as the key.
|
||||
*/
|
||||
public static final class CacheValue {
|
||||
|
||||
private final Instant issuedAt;
|
||||
|
||||
private final Instant expiresAt;
|
||||
|
||||
private final String jwkThumbprint;
|
||||
|
||||
/**
|
||||
* Constructs a {@code CacheValue} using the provided parameters.
|
||||
* @param issuedAt the issued at claim which identifies the time at which the DPoP
|
||||
* Proof {@link Jwt} was issued
|
||||
* @param expiresAt the expiration time when this {@code CacheValue} will be
|
||||
* evicted from the cache
|
||||
* @param jwkThumbprint the SHA-256 thumbprint of the public key of the JSON Web
|
||||
* Key (JWK) corresponding to the key used to digitally sign the DPoP Proof
|
||||
* {@link Jwt}
|
||||
*/
|
||||
public CacheValue(Instant issuedAt, Instant expiresAt, String jwkThumbprint) {
|
||||
Assert.notNull(issuedAt, "issuedAt cannot be null");
|
||||
Assert.notNull(expiresAt, "expiresAt cannot be null");
|
||||
Assert.hasText(jwkThumbprint, "jwkThumbprint cannot be empty");
|
||||
this.issuedAt = issuedAt;
|
||||
this.expiresAt = expiresAt;
|
||||
this.jwkThumbprint = jwkThumbprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the issued at {@code (iat)} claim which identifies the time at which
|
||||
* the DPoP Proof {@link Jwt} was issued.
|
||||
* @return the issued at claim which identifies the time at which the DPoP Proof
|
||||
* {@link Jwt} was issued
|
||||
*/
|
||||
public Instant getIssuedAt() {
|
||||
return this.issuedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the expiration time when this {@code CacheValue} will be evicted from
|
||||
* the cache.
|
||||
* @return the expiration time when this {@code CacheValue} will be evicted from
|
||||
* the cache
|
||||
*/
|
||||
public Instant getExpiresAt() {
|
||||
return this.expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SHA-256 thumbprint of the public key of the JSON Web Key (JWK)
|
||||
* corresponding to the key used to digitally sign the DPoP Proof {@link Jwt}.
|
||||
* @return the SHA-256 thumbprint of the public key of the JSON Web Key (JWK)
|
||||
*/
|
||||
public String getJwkThumbprint() {
|
||||
return this.jwkThumbprint;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (obj == this) {
|
||||
return true;
|
||||
}
|
||||
if (obj == null || obj.getClass() != this.getClass()) {
|
||||
return false;
|
||||
}
|
||||
CacheValue that = (CacheValue) obj;
|
||||
return Objects.equals(this.issuedAt, that.issuedAt) && Objects.equals(this.expiresAt, that.expiresAt)
|
||||
&& Objects.equals(this.jwkThumbprint, that.jwkThumbprint);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(this.issuedAt, this.expiresAt, this.jwkThumbprint);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+23
-4
@@ -66,11 +66,9 @@ final class JwtDecoderProviderConfigurationUtils {
|
||||
private static final RestTemplate rest = new RestTemplate();
|
||||
|
||||
static {
|
||||
int connectTimeout = Integer.parseInt(System.getProperty("sun.net.client.defaultConnectTimeout", "30000"));
|
||||
int readTimeout = Integer.parseInt(System.getProperty("sun.net.client.defaultReadTimeout", "30000"));
|
||||
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||
requestFactory.setConnectTimeout(connectTimeout);
|
||||
requestFactory.setReadTimeout(readTimeout);
|
||||
requestFactory.setConnectTimeout(getConnectTimeout());
|
||||
requestFactory.setReadTimeout(getReadTimeout());
|
||||
rest.setRequestFactory(requestFactory);
|
||||
}
|
||||
|
||||
@@ -80,6 +78,27 @@ final class JwtDecoderProviderConfigurationUtils {
|
||||
private JwtDecoderProviderConfigurationUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default HTTP connect timeout, in milliseconds, for fetching
|
||||
* provider/JWK Set metadata. Honors the JDK's
|
||||
* {@code sun.net.client.defaultConnectTimeout} system property when set, otherwise
|
||||
* defaults to 30 seconds.
|
||||
* @return the default HTTP connect timeout, in milliseconds
|
||||
*/
|
||||
static int getConnectTimeout() {
|
||||
return Integer.parseInt(System.getProperty("sun.net.client.defaultConnectTimeout", "30000"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default HTTP read timeout, in milliseconds, for fetching provider/JWK
|
||||
* Set metadata. Honors the JDK's {@code sun.net.client.defaultReadTimeout} system
|
||||
* property when set, otherwise defaults to 30 seconds.
|
||||
* @return the default HTTP read timeout, in milliseconds
|
||||
*/
|
||||
static int getReadTimeout() {
|
||||
return Integer.parseInt(System.getProperty("sun.net.client.defaultReadTimeout", "30000"));
|
||||
}
|
||||
|
||||
static Map<String, Object> getConfigurationForOidcIssuerLocation(String oidcIssuerLocation) {
|
||||
return getConfiguration(oidcIssuerLocation, rest, oidc(oidcIssuerLocation));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user