1
0
mirror of synced 2026-09-05 00:41:22 +00:00

Compare commits

...

22 Commits

Author SHA1 Message Date
Josh Cummings 93019f889e Release 7.0.7
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:30:20 -06:00
Josh Cummings dfd440ad64 Update to Spring Data 2025.1.7
Closes gh-19485

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:30:20 -06:00
Josh Cummings 25bb4a1cd4 Update to Spring LDAP 4.0.5
Closes gh-19486

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:30:20 -06:00
Josh Cummings 66a149066f Update to Micrometer 1.16.7
Closes gh-19484

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:30:20 -06:00
Josh Cummings ea365cab06 Update to Reactor 2025.0.7
Closes gh-19483

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:30:20 -06:00
Josh Cummings ae8ff1af43 Update to Spring Framework 7.0.9
Closes gh-19482

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:30:20 -06:00
Josh Cummings a298a2b8c9 Add Release Announcement Workflows
This commit adds a workflow for staging release
announcements. It is activated when a tag is
created on the repo, which will privately stage
any needed release announcements.

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:30:20 -06:00
Josh Cummings aac1e2e2fc Configure Build for Commercial Repositories
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:30:20 -06:00
Josh Cummings 556b139a02 Configure with Commercial Workflows
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:30:20 -06:00
Josh Cummings fbd2cce3ba Remove OSS CI configuration 2026-08-13 16:30:20 -06:00
Josh Cummings 40d0d19713 Deprecate AesBytesEncryptor
This commit separates AesBytesEncryptor into two separate
implememtations, allowing for a migration away from default
arrangements that used a null IV

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:29:49 -06:00
Josh Cummings 3fb2c68ff7 Improve Equivalence Tests
This commit adds equals and hashCode implementations as well
as a readResolve implementation to ensure that deserialization
mechanisms can correctly assess the equality of a constnat
and a corresponding deserialized instance. For defense-in-depth
reasons, this commit also favors .equals over == for these
constants.

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:29:49 -06:00
Joe Grandja 5900348174 Bind UnboundIdContainer to loopback address 2026-08-13 16:29:49 -06:00
Joe Grandja 307b460838 Apply html encoding in DefaultConsentPage 2026-08-13 16:29:49 -06:00
Joe Grandja 3b528e0c4c Provide ability to configure DPoP proof replay 2026-08-13 16:29:49 -06:00
Josh Cummings c7f01cb974 Use Constant-Time Comparison
This commit updates password encoders to use a
constant-time comparison method to defend against
timing attacks

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:29:49 -06:00
Josh Cummings e4a5e21486 Use Utf8#isEqual
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:29:49 -06:00
Josh Cummings b851af3e02 Use Constant-Time Equals
This commit updates the filter to use constant-time
equals for sensitive material

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:29:49 -06:00
Josh Cummings 100e0407ee Use Constant-Time Equals
This commit updates the filter to use constant-time
equals for sensitive material

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:29:49 -06:00
Josh Cummings 56835a04a1 Add Utf8#isEqual
This commit adds a constant-time equals method,
useful for comparing password hashes or other
sensitive material

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:29:49 -06:00
Josh Cummings 1cbb3fc10d Use MessageDigest#isEqual
This commit favors constant-time comparison
to mitigate timing attacks

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:29:49 -06:00
Josh Cummings 1f0c0fe204 Add Tests for User and Device Code
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-13 16:29:49 -06:00
71 changed files with 2609 additions and 691 deletions
@@ -0,0 +1,7 @@
name: Build Release
runs:
using: composite
steps:
- name: Build Release
shell: bash
run: ./gradlew -PdeploymentRepository=$(pwd)/deployment-repository publishAllPublicationsToDeploymentRepository
@@ -0,0 +1,7 @@
name: Test Release
runs:
using: composite
steps:
- name: Test Release
shell: bash
run: ./gradlew build
-2
View File
@@ -1,2 +0,0 @@
require:
members: false
-113
View File
@@ -1,113 +0,0 @@
version: 2
registries:
spring-milestones:
type: maven-repository
url: https://repo.spring.io/milestone
shibboleth:
type: maven-repository
url: https://build.shibboleth.net/maven/releases
updates:
- package-ecosystem: gradle
target-branch: 6.5.x
directory: /
schedule:
interval: daily
time: '03:00'
timezone: Etc/UTC
labels:
- 'type: dependency-upgrade'
registries:
- spring-milestones
- shibboleth
ignore:
- dependency-name: com.nimbusds:nimbus-jose-jwt
- dependency-name: org.python:jython
- dependency-name: org.apache.directory.server:*
- dependency-name: org.apache.directory.shared:*
- dependency-name: org.junit:junit-bom
update-types:
- version-update:semver-major
- dependency-name: org.mockito:mockito-bom
update-types:
- version-update:semver-major
- dependency-name: '*'
update-types:
- version-update:semver-major
- version-update:semver-minor
- package-ecosystem: gradle
target-branch: 6.4.x
directory: /
schedule:
interval: daily
time: '03:00'
timezone: Etc/UTC
labels:
- 'type: dependency-upgrade'
registries:
- spring-milestones
- shibboleth
ignore:
- dependency-name: com.nimbusds:nimbus-jose-jwt
- dependency-name: org.python:jython
- dependency-name: org.apache.directory.server:*
- dependency-name: org.apache.directory.shared:*
- dependency-name: org.junit:junit-bom
update-types:
- version-update:semver-major
- dependency-name: org.mockito:mockito-bom
update-types:
- version-update:semver-major
- dependency-name: '*'
update-types:
- version-update:semver-major
- version-update:semver-minor
- package-ecosystem: gradle
target-branch: main
directory: /
schedule:
interval: daily
time: '03:00'
timezone: Etc/UTC
labels:
- 'type: dependency-upgrade'
registries:
- spring-milestones
- shibboleth
ignore:
- dependency-name: com.nimbusds:nimbus-jose-jwt
- dependency-name: org.python:jython
- dependency-name: org.apache.directory.server:*
- dependency-name: org.apache.directory.shared:*
- dependency-name: org.junit:junit-bom
update-types:
- version-update:semver-major
- dependency-name: org.mockito:mockito-bom
update-types:
- version-update:semver-major
- dependency-name: com.gradle.enterprise
update-types:
- version-update:semver-major
- version-update:semver-minor
- dependency-name: '*'
update-types:
- version-update:semver-major
- version-update:semver-minor
- package-ecosystem: npm
target-branch: docs-build
directory: /
schedule:
interval: weekly
labels:
- 'type: task'
- 'in: build'
- package-ecosystem: npm
target-branch: main
directory: /docs
schedule:
interval: weekly
labels:
- 'type: task'
- 'in: build'
+17
View File
@@ -0,0 +1,17 @@
workflow:
generator:
project:
java:
versions:
primary: 17
workflows:
release-train:
build:
env:
COMMERCIAL_REPO_USERNAME: secrets.COMMERCIAL_ARTIFACTORY_USERNAME
COMMERCIAL_REPO_PASSWORD: secrets.COMMERCIAL_ARTIFACTORY_PASSWORD
test:
env:
COMMERCIAL_REPO_USERNAME: secrets.COMMERCIAL_ARTIFACTORY_USERNAME
COMMERCIAL_REPO_PASSWORD: secrets.COMMERCIAL_ARTIFACTORY_PASSWORD
-36
View File
@@ -1,36 +0,0 @@
name: CI
on:
schedule:
- cron: '0 10 * * *' # Once per day at 10am UTC
workflow_dispatch: # Manual trigger
env:
DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
permissions:
contents: read
jobs:
snapshot-test:
name: Test Against Snapshots
uses: spring-io/spring-security-release-tools/.github/workflows/test.yml@ed473b4dafba053c63a453d2d88a89df3b3e18b3 # v1
strategy:
matrix:
include:
- 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.0.+ -PreactorVersion=2025.0.+ -PspringDataVersion=2025.1.+ -PmicrometerVersion=1.16.+ --stacktrace
secrets: inherit
send-notification:
name: Send Notification
needs: [ snapshot-test ]
if: ${{ !success() }}
runs-on: ubuntu-latest
steps:
- name: Send Notification
uses: spring-io/spring-security-release-tools/.github/actions/send-notification@ed473b4dafba053c63a453d2d88a89df3b3e18b3 # v1
with:
webhook-url: ${{ secrets.SPRING_SECURITY_CI_GCHAT_WEBHOOK_URL }}
@@ -1,23 +0,0 @@
name: Clean build artifacts
on:
schedule:
- cron: '0 10 * * *' # Once per day at 10am UTC
permissions:
contents: read
jobs:
main:
runs-on: ubuntu-latest
if: ${{ github.repository == 'spring-projects/spring-security' }}
permissions:
contents: none
steps:
- name: Delete artifacts in cron job
env:
GH_ACTIONS_REPO_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}
run: |
echo "Running clean build artifacts logic"
output=$(curl -X GET -H "Authorization: token $GH_ACTIONS_REPO_TOKEN" https://api.github.com/repos/spring-projects/spring-security/actions/artifacts | grep '"id"' | cut -d : -f2 | sed 's/,*$//g')
echo Output is $output
for id in $output; do curl -X DELETE -H "Authorization: token $GH_ACTIONS_REPO_TOKEN" https://api.github.com/repos/spring-projects/spring-security/actions/artifacts/$id; done;
-17
View File
@@ -1,17 +0,0 @@
name: "CodeQL Advanced"
on:
push:
pull_request:
workflow_dispatch:
schedule:
# https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule
- cron: '0 5 * * *'
permissions: read-all
jobs:
codeql-analysis-call:
permissions:
actions: read
contents: read
security-events: write
uses: spring-io/github-actions/.github/workflows/codeql-analysis.yml@e415dadd0910c901e7a7fabd67bbb355b2324500 # 1
@@ -8,43 +8,40 @@ on:
- cron: '0 10 * * *' # Once per day at 10am UTC - cron: '0 10 * * *' # Once per day at 10am UTC
workflow_dispatch: # Manual trigger workflow_dispatch: # Manual trigger
env:
DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
permissions: permissions:
contents: read contents: read
jobs: jobs:
build: build:
name: Build name: Build
uses: spring-io/spring-security-release-tools/.github/workflows/build.yml@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15 uses: spring-projects/spring-security-commercial/.github/workflows/build.yml@workflows/v1
strategy:
matrix:
os: [ ubuntu-latest, windows-latest ]
jdk: [ 17 ]
with:
runs-on: ${{ matrix.os }}
java-version: ${{ matrix.jdk }}
distribution: temurin
secrets: inherit secrets: inherit
test:
name: Test Against Snapshots
uses: spring-projects/spring-security-commercial/.github/workflows/test.yml@workflows/v1
with:
java-version: '17'
test-args: --refresh-dependencies -PforceMavenRepositories=snapshot -PisOverrideVersionCatalog -PtestToolchain=17 -PspringFrameworkVersion=7.0.+ -PreactorVersion=2025.0.+ -PspringDataVersion=2025.1.+ -PmicrometerVersion=1.16.+ --stacktrace
secrets: inherit
compute-version:
name: Compute Version
runs-on: ubuntu-latest
outputs:
snapshot: ${{ steps.project-version.outputs.snapshot }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 7.0.0
- id: project-version
name: Extract Project Version
uses: spring-io/spring-release-actions/compute-version@a1f321783a0769dd2aea4fad6c2ae2f95a52b885 # 0.0.5
deploy-artifacts: deploy-artifacts:
name: Deploy Artifacts name: Deploy Artifacts
needs: [ build ] needs: [ build, test, compute-version ]
uses: spring-io/spring-security-release-tools/.github/workflows/deploy-artifacts.yml@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15 if: needs.compute-version.outputs.snapshot == 'true'
with: uses: spring-projects/spring-security-commercial/.github/workflows/deploy-artifacts.yml@workflows/v1
should-deploy-artifacts: ${{ needs.build.outputs.should-deploy-artifacts }}
default-publish-milestones-central: true
secrets: inherit
deploy-schema:
name: Deploy Schema
needs: [ build ]
uses: spring-io/spring-security-release-tools/.github/workflows/deploy-schema.yml@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
with:
should-deploy-schema: ${{ needs.build.outputs.should-deploy-artifacts }}
secrets: inherit secrets: inherit
send-notification: send-notification:
name: Send Notification name: Send Notification
needs: [ deploy-artifacts, deploy-schema ] needs: [ deploy-artifacts ]
if: ${{ !success() }} if: ${{ !success() }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
-76
View File
@@ -1,76 +0,0 @@
name: Defer Issues
on:
workflow_dispatch:
permissions:
contents: read
jobs:
defer-issues:
name: Defer Issues
runs-on: ubuntu-latest
if: github.repository_owner == 'spring-projects'
permissions:
issues: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Compute Version
id: compute-version
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@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@a1f321783a0769dd2aea4fad6c2ae2f95a52b885 # 0.0.5
with:
version: ${{ steps.todays-release.outputs.release-version }}
- name: Schedule Next Milestone
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 }}
repository: ${{ github.repository }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Move Open Issues to Next Milestone
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CURRENT_MILESTONE: ${{ steps.todays-release.outputs.release-version }}
NEXT_MILESTONE: ${{ steps.next-version.outputs.version }}
run: |
current_milestone_number=$(gh api repos/${{ github.repository }}/milestones \
--jq ".[] | select(.title == \"$CURRENT_MILESTONE\") | .number")
if [ -z "$current_milestone_number" ]; then
echo "No milestone found for $CURRENT_MILESTONE"
exit 0
fi
next_milestone_number=$(gh api repos/${{ github.repository }}/milestones \
--jq ".[] | select(.title == \"$NEXT_MILESTONE\") | .number")
if [ -z "$next_milestone_number" ]; then
echo "No milestone found for $NEXT_MILESTONE"
exit 1
fi
echo "Moving open issues from milestone '$CURRENT_MILESTONE' (#$current_milestone_number) to '$NEXT_MILESTONE' (#$next_milestone_number)"
page=1
while true; do
issues=$(gh api "repos/${{ github.repository }}/issues?milestone=$current_milestone_number&state=open&per_page=100&page=$page" \
--jq '.[].number')
if [ -z "$issues" ]; then
break
fi
for issue in $issues; do
echo "Moving issue/PR #$issue to milestone $NEXT_MILESTONE"
gh api repos/${{ github.repository }}/issues/$issue \
--method PATCH \
--field milestone=$next_milestone_number \
--silent
done
page=$((page + 1))
done
echo "Done."
@@ -0,0 +1,10 @@
name: Dependabot PR Build
on: pull_request_target
jobs:
build:
name: Build
uses: spring-projects/spring-security-commercial/.github/workflows/build-pull-request.yml@workflows/v1
if: ${{ github.actor == 'dependabot[bot]' }}
secrets: inherit
-33
View File
@@ -1,33 +0,0 @@
name: Deploy Docs
on:
push:
branches-ignore:
- "gh-pages"
- "dependabot/**"
tags: '**'
repository_dispatch:
types: request-build-reference # legacy
#schedule:
#- cron: '0 10 * * *' # Once per day at 10am UTC
workflow_dispatch:
permissions: read-all
jobs:
build:
runs-on: ubuntu-latest
if: github.repository_owner == 'spring-projects'
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: docs-build
fetch-depth: 1
- name: Dispatch (partial build)
if: github.ref_type == 'branch'
env:
GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}
run: gh workflow run deploy-docs.yml -r $(git rev-parse --abbrev-ref HEAD) -f build-refname=${{ github.ref_name }}
- name: Dispatch (full build)
if: github.ref_type == 'tag'
env:
GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}
run: gh workflow run deploy-docs.yml -r $(git rev-parse --abbrev-ref HEAD)
@@ -1,33 +0,0 @@
name: Execute Gradle Wrapper Upgrade
on:
schedule:
- cron: '0 2 * * *' # 2am UTC
workflow_dispatch:
permissions:
pull-requests: write
jobs:
upgrade_wrapper:
name: Execution
runs-on: ubuntu-latest
steps:
- name: Set up Git configuration
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config --global url."https://unused-username:${TOKEN}@github.com/".insteadOf "https://github.com/"
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up JDK 17
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: '17'
distribution: 'temurin'
- name: Set up Gradle
uses: gradle/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
- name: Upgrade Wrappers
run: ./gradlew clean upgradeGradleWrapperAll --continue -Porg.gradle.java.installations.auto-download=false
env:
WRAPPER_UPGRADE_GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+63
View File
@@ -0,0 +1,63 @@
name: Merge Dependabot PR
on: pull_request_target
run-name: Merge Dependabot PR ${{ github.ref_name }}
permissions: write-all
jobs:
merge-dependabot-pr:
name: Merge Dependabot PR
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'spring-projects/spring-security-commercial' }}
steps:
- uses: actions/checkout@v5
with:
show-progress: false
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Set Milestone to Dependabot Pull Request
id: set-milestone
run: |
if test -f pom.xml
then
CURRENT_VERSION=$(mvn help:evaluate -Dexpression="project.version" -q -DforceStdout)
else
CURRENT_VERSION=$(cat gradle.properties | sed -n '/^version=/ { s/^version=//;p }')
fi
export CANDIDATE_VERSION=${CURRENT_VERSION/-SNAPSHOT}
MILESTONE=$(gh api repos/$GITHUB_REPOSITORY/milestones --jq 'map(select(.due_on != null and (.title | startswith(env.CANDIDATE_VERSION)))) | .[0] | .title')
if [ -z $MILESTONE ]
then
gh run cancel ${{ github.run_id }}
echo "::warning title=Cannot merge::No scheduled milestone for $CURRENT_VERSION version"
else
gh pr edit ${{ github.event.pull_request.number }} --milestone $MILESTONE
echo mergeEnabled=true >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Merge Dependabot pull request
if: steps.set-milestone.outputs.mergeEnabled
run: gh pr merge ${{ github.event.pull_request.number }} --auto --rebase
env:
GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}
send-notification:
name: Send Notification
needs: [ merge-dependabot-pr ]
if: ${{ failure() || cancelled() }}
runs-on: ubuntu-latest
steps:
- name: Send Notification
uses: spring-io/spring-security-release-tools/.github/actions/send-notification@v1
with:
webhook-url: ${{ secrets.SPRING_SECURITY_CI_GCHAT_WEBHOOK_URL }}
+3 -41
View File
@@ -8,44 +8,6 @@ permissions:
jobs: jobs:
build: build:
name: Build name: Build
runs-on: ubuntu-latest uses: spring-projects/spring-security-commercial/.github/workflows/build-pull-request.yml@workflows/v1
if: ${{ github.repository == 'spring-projects/spring-security' }} if: ${{ github.actor != 'dependabot[bot]' }}
steps: secrets: inherit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up gradle
uses: spring-io/spring-gradle-build-action@c8668747d7c264864c8c7f7026d0d277d14a78dc # v2.0.6
with:
java-version: '17'
distribution: 'temurin'
- name: Build with Gradle
run: ./gradlew clean build -PskipCheckExpectedBranchVersion --continue --scan
generate-docs:
name: Generate Docs
runs-on: ubuntu-latest
if: ${{ github.repository == 'spring-projects/spring-security' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up gradle
uses: spring-io/spring-gradle-build-action@c8668747d7c264864c8c7f7026d0d277d14a78dc # v2.0.6
with:
java-version: '17'
distribution: 'temurin'
- name: Run Antora
run: ./gradlew -PbuildSrc.skipTests=true :spring-security-docs:antora
- name: Upload Docs
id: upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docs
path: docs/build/site
overwrite: true
send-notification:
name: Send Notification
needs: [ build, generate-docs ]
if: ${{ failure() && github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'spring-projects/spring-security' }}
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 }}
@@ -0,0 +1,24 @@
name: Release Announcements - Stage
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
- '[0-9]+.[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
uses: spring-projects/spring-security-commercial/.github/workflows/release-announcements-stage.yml@workflows/v1
with:
version: ${{ inputs.version || github.ref_name }}
secrets: inherit
+92
View File
@@ -0,0 +1,92 @@
# This file was auto-generated by github-actions-workflow-generator 0.0.6. Do not edit.
# To update it, modify .github/workflow-generator.yml as needed and re-run the generator.
name: "Release Train Build"
run-name: "${{ inputs.callback-ref }} Build"
"on":
workflow_dispatch:
inputs:
callback:
description: "Repository to which a callback should be made upon completion"
required: true
type: "string"
callback-ref:
description: "Ref in the callback repository to which a callback should be made upon completion"
required: true
type: "string"
release-train-maven-repository-url:
description: "URL of a Maven repository to be used to resolve artifacts of projects earlier in the train"
required: true
type: "string"
permissions:
contents: "read"
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
jobs:
build-release:
name: "Build Release"
runs-on: "ubuntu22-2-8"
steps:
- name: "Prevent Re-runs"
id: "prevent-re-runs"
run: |-
if [ "$GITHUB_RUN_ATTEMPT" -gt 1 ]; then
echo "Re-runs are prohibited. Use the 'Release Train  Retry' workflow to retry build failures"
exit 1
fi
- name: "Set up Java"
id: "set-up-java"
uses: "actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95" # v5.6.0
with:
distribution: "liberica"
java-version: "17"
- name: "Check Out Code"
id: "check-out-code"
uses: "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" # v7.0.0
- name: "Build Release"
id: "build-release"
uses: "./.github/actions/release-train-build"
env:
COMMERCIAL_REPO_PASSWORD: "${{ secrets.COMMERCIAL_ARTIFACTORY_PASSWORD }}"
COMMERCIAL_REPO_USERNAME: "${{ secrets.COMMERCIAL_ARTIFACTORY_USERNAME }}"
RELEASE_TRAIN_MAVEN_REPOSITORY_PASSWORD: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_MAVEN_REPOSITORY_PASSWORD }}"
RELEASE_TRAIN_MAVEN_REPOSITORY_URL: "${{ inputs.release-train-maven-repository-url }}"
RELEASE_TRAIN_MAVEN_REPOSITORY_USERNAME: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_MAVEN_REPOSITORY_USERNAME }}"
- name: "Upload Deployment Repository"
id: "upload-deployment-repository"
uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" # v7.0.1
with:
name: "deployment-repository"
path: "deployment-repository/**"
- name: "Upload Deployment Spec"
id: "upload-deployment-spec"
uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" # v7.0.1
with:
archive: "false"
if-no-files-found: "ignore"
name: "deployment-spec"
path: ".github/actions/release-train-build/deployment-spec.yml"
- name: "Save Build System Caches"
id: "save-build-system-caches"
uses: "actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9" # v6.1.0
with:
key: "release-train-${{ inputs.callback-ref }}-${{ github.ref_name }}"
path: |-
~/.gradle/caches
~/.gradle/wrapper
- name: "Send Callback"
id: "send-callback"
if: "${{ !cancelled() }}"
env:
GH_TOKEN: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_GITHUB_TOKEN }}"
run: |-
gh workflow run callback \
--repo ${{ inputs.callback }} \
--ref ${{ inputs.callback-ref }} \
--field commit-hash=${{ steps.check-out-code.outputs.commit }} \
--field deployment-repository-artifact-identifier=${{ steps.upload-deployment-repository.outputs.artifact-id }} \
--field deployment-spec-artifact-identifier=${{ steps.upload-deployment-spec.outputs.artifact-id }} \
--field release-branch=${{ github.ref_name }} \
--field release-repository=${{ github.repository }} \
--field result=${{ job.status == 'success' && 'built' || 'build-failed' }} \
--field workflow-run-url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+55
View File
@@ -0,0 +1,55 @@
# This file was auto-generated by github-actions-workflow-generator 0.0.6. Do not edit.
# To update it, modify .github/workflow-generator.yml as needed and re-run the generator.
name: "Release Train Join"
run-name: "${{ inputs.release-train }} Join"
"on":
workflow_dispatch:
inputs:
deployment-destination:
description: "Destination to which the release should be deployed"
options:
- "Maven Central"
- "Spring Enterprise"
required: true
type: "choice"
release-train:
description: "Release train"
required: true
type: "string"
release-train-repository:
default: "spring-io/release-train"
description: "Release train repository"
required: true
type: "string"
permissions:
contents: "none"
jobs:
join-release-train:
name: "Join Release Train"
runs-on: "ubuntu-latest"
steps:
- name: "Join Release Train"
id: "join-release-train"
env:
GH_TOKEN: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_GITHUB_TOKEN }}"
run: |-
run_url=$(
gh workflow run join \
--repo ${{ inputs.release-train-repository }} \
--ref ${{ inputs.release-train }} \
--field commit-hash=${{ github.sha }} \
--field deployment-destination=${{ inputs.deployment-destination == 'Maven Central' && 'maven-central' || 'spring-enterprise' }} \
--field release-branch=${{ github.ref_name }} \
--field release-repository=${{ github.repository }}
)
echo "Dispatched workflow run. Waiting for $run_url to complete."
run_id=${run_url##*/}
watch_exit_code=0
gh run watch $run_id --repo ${{ inputs.release-train-repository }} --exit-status --interval=3 > /dev/null 2>&1 || watch_exit_code=$?
if [[ $watch_exit_code -eq 0 ]]; then
echo "Workflow run succeeded."
else
echo "Workflow run failed."
fi
exit $watch_exit_code
+46
View File
@@ -0,0 +1,46 @@
# This file was auto-generated by github-actions-workflow-generator 0.0.6. Do not edit.
# To update it, modify .github/workflow-generator.yml as needed and re-run the generator.
name: "Release Train Leave"
run-name: "${{ inputs.release-train }} Leave"
"on":
workflow_dispatch:
inputs:
release-train:
description: "Release train"
required: true
type: "string"
release-train-repository:
default: "spring-io/release-train"
description: "Release train repository"
required: true
type: "string"
permissions:
contents: "none"
jobs:
leave:
name: "Leave"
runs-on: "ubuntu-latest"
steps:
- name: "Leave"
id: "leave"
env:
GH_TOKEN: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_GITHUB_TOKEN }}"
run: |-
run_url=$(
gh workflow run leave \
--repo ${{ inputs.release-train-repository }} \
--ref ${{ inputs.release-train }} \
--field release-branch=${{ github.ref_name }} \
--field release-repository=${{ github.repository }}
)
echo "Dispatched workflow run. Waiting for $run_url to complete."
run_id=${run_url##*/}
watch_exit_code=0
gh run watch $run_id --repo ${{ inputs.release-train-repository }} --exit-status --interval=3 > /dev/null 2>&1 || watch_exit_code=$?
if [[ $watch_exit_code -eq 0 ]]; then
echo "Workflow run succeeded."
else
echo "Workflow run failed."
fi
exit $watch_exit_code
+47
View File
@@ -0,0 +1,47 @@
# This file was auto-generated by github-actions-workflow-generator 0.0.6. Do not edit.
# To update it, modify .github/workflow-generator.yml as needed and re-run the generator.
name: "Release Train Ready"
run-name: "${{ inputs.release-train }} Ready"
"on":
workflow_dispatch:
inputs:
release-train:
description: "Release train"
required: true
type: "string"
release-train-repository:
default: "spring-io/release-train"
description: "Release train repository"
required: true
type: "string"
permissions:
contents: "none"
jobs:
ready:
name: "Ready"
runs-on: "ubuntu-latest"
steps:
- name: "Ready"
id: "ready"
env:
GH_TOKEN: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_GITHUB_TOKEN }}"
run: |-
run_url=$(
gh workflow run ready \
--repo ${{ inputs.release-train-repository }} \
--ref ${{ inputs.release-train }} \
--field commit-hash=${{ github.sha }} \
--field release-branch=${{ github.ref_name }} \
--field release-repository=${{ github.repository }}
)
echo "Dispatched workflow run. Waiting for $run_url to complete."
run_id=${run_url##*/}
watch_exit_code=0
gh run watch $run_id --repo ${{ inputs.release-train-repository }} --exit-status --interval=3 > /dev/null 2>&1 || watch_exit_code=$?
if [[ $watch_exit_code -eq 0 ]]; then
echo "Workflow run succeeded."
else
echo "Workflow run failed."
fi
exit $watch_exit_code
+34
View File
@@ -0,0 +1,34 @@
# This file was auto-generated by github-actions-workflow-generator 0.0.6. Do not edit.
# To update it, modify .github/workflow-generator.yml as needed and re-run the generator.
name: "Release Train Retry"
run-name: "${{ inputs.release-train }} Retry"
"on":
workflow_dispatch:
inputs:
release-train:
description: "Release train"
required: true
type: "string"
release-train-repository:
default: "spring-io/release-train"
description: "Release train repository"
required: true
type: "string"
permissions:
contents: "none"
jobs:
trigger-retry:
name: "Trigger Retry"
runs-on: "ubuntu-latest"
steps:
- name: "Trigger Retry"
id: "trigger-retry"
env:
GH_TOKEN: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_GITHUB_TOKEN }}"
run: |-
gh workflow run retry \
--repo ${{ inputs.release-train-repository }} \
--ref ${{ inputs.release-train }} \
--field release-branch=${{ github.ref_name }} \
--field release-repository=${{ github.repository }}
+83
View File
@@ -0,0 +1,83 @@
# This file was auto-generated by github-actions-workflow-generator 0.0.6. Do not edit.
# To update it, modify .github/workflow-generator.yml as needed and re-run the generator.
name: "Release Train Test"
run-name: "${{ inputs.callback-ref }} Test"
"on":
workflow_dispatch:
inputs:
callback:
description: "Repository to which a callback should be made upon completion"
required: true
type: "string"
callback-ref:
description: "Ref in the callback repository to which a callback should be made upon completion"
required: true
type: "string"
release-train-maven-repository-url:
description: "URL of a Maven repository to be used to resolve artifacts of projects earlier in the train"
required: true
type: "string"
permissions:
contents: "read"
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
jobs:
test-release:
name: "Test Release"
runs-on: "ubuntu22-2-8"
steps:
- name: "Prevent Re-runs"
id: "prevent-re-runs"
run: |-
if [ "$GITHUB_RUN_ATTEMPT" -gt 1 ]; then
echo "Re-runs are prohibited. Use the 'Release Train  Retry' workflow to retry test failures"
exit 1
fi
- name: "Set up Java"
id: "set-up-java"
uses: "actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95" # v5.6.0
with:
distribution: "liberica"
java-version: "17"
- name: "Check Out Code"
id: "check-out-code"
uses: "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" # v7.0.0
- name: "Restore Build System Caches"
id: "restore-build-system-caches"
uses: "actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9" # v6.1.0
with:
key: "release-train-${{ inputs.callback-ref }}-${{ github.ref_name }}"
path: |-
~/.gradle/caches
~/.gradle/wrapper
- name: "Test Release"
id: "test-release"
uses: "./.github/actions/release-train-test"
env:
COMMERCIAL_REPO_PASSWORD: "${{ secrets.COMMERCIAL_ARTIFACTORY_PASSWORD }}"
COMMERCIAL_REPO_USERNAME: "${{ secrets.COMMERCIAL_ARTIFACTORY_USERNAME }}"
RELEASE_TRAIN_MAVEN_REPOSITORY_PASSWORD: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_MAVEN_REPOSITORY_PASSWORD }}"
RELEASE_TRAIN_MAVEN_REPOSITORY_URL: "${{ inputs.release-train-maven-repository-url }}"
RELEASE_TRAIN_MAVEN_REPOSITORY_USERNAME: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_MAVEN_REPOSITORY_USERNAME }}"
- name: "Send Callback"
id: "send-callback"
if: "${{ !cancelled() }}"
env:
GH_TOKEN: "${{ secrets.RELEASE_TRAIN_PARTICIPANT_GITHUB_TOKEN }}"
run: |-
gh workflow run callback \
--repo ${{ inputs.callback }} \
--ref ${{ inputs.callback-ref }} \
--field commit-hash=${{ steps.check-out-code.outputs.commit }} \
--field release-branch=${{ github.ref_name }} \
--field release-repository=${{ github.repository }} \
--field result=${{ job.status == 'success' && 'tested' || 'test-failed' }} \
--field workflow-run-url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: "Upload Build System Reports"
id: "upload-build-system-reports"
if: "${{ failure() }}"
uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" # v7.0.1
with:
name: "build-system-reports"
path: "**/build/reports"
@@ -1,35 +0,0 @@
name: Update Antora UI Spring
on:
schedule:
- cron: '0 10 * * *' # Once per day at 10am UTC
workflow_dispatch:
permissions:
pull-requests: write
issues: write
contents: write
jobs:
update-antora-ui-spring:
runs-on: ubuntu-latest
name: Update on Supported Branches
strategy:
matrix:
branch: [ '6.4.x', '6.5.x', 'main' ]
steps:
- uses: spring-io/spring-doc-actions/update-antora-spring-ui@415e2b11a766ba64799fffb5c97a4f7e17f677cf # v0.0.22
name: Update
with:
docs-branch: ${{ matrix.branch }}
token: ${{ secrets.GITHUB_TOKEN }}
antora-file-path: 'docs/antora-playbook.yml'
update-antora-ui-spring-docs-build:
runs-on: ubuntu-latest
name: Update on docs-build
steps:
- uses: spring-io/spring-doc-actions/update-antora-spring-ui@415e2b11a766ba64799fffb5c97a4f7e17f677cf # v0.0.22
name: Update
with:
docs-branch: 'docs-build'
token: ${{ secrets.GITHUB_TOKEN }}
+5 -8
View File
@@ -25,6 +25,7 @@ apply plugin: 'org.jetbrains.kotlin.jvm'
apply plugin: 'org.springframework.security.versions.verify-dependencies-versions' apply plugin: 'org.springframework.security.versions.verify-dependencies-versions'
apply plugin: 'org.springframework.security.check-expected-branch-version' apply plugin: 'org.springframework.security.check-expected-branch-version'
apply plugin: 'io.spring.security.release' apply plugin: 'io.spring.security.release'
apply from: 'commercial-settings.gradle'
group = 'org.springframework.security' group = 'org.springframework.security'
description = 'Spring Security' description = 'Spring Security'
@@ -39,10 +40,12 @@ repositories {
} }
springRelease { springRelease {
repositoryOwner = "spring-projects"
repositoryName = "spring-security-commercial"
weekOfMonth = 3 weekOfMonth = 3
dayOfWeek = 1 dayOfWeek = 1
referenceDocUrl = "https://docs.spring.io/spring-security/reference/{version}/index.html" referenceDocUrl = "https://docs.spring.vmware.com/spring-security/reference/{version}/index.html"
apiDocUrl = "https://docs.spring.io/spring-security/reference/{version}/api/java/index.html" apiDocUrl = "https://docs.spring.vmware.com/spring-security/reference/{version}/api/java/index.html"
replaceSnapshotVersionInReferenceDocUrl = true replaceSnapshotVersionInReferenceDocUrl = true
} }
@@ -70,12 +73,6 @@ allprojects {
} }
} }
develocity {
buildScan {
termsOfUseUrl = 'https://gradle.com/help/legal-terms-of-use'
termsOfUseAgree = 'yes'
}
}
nohttp { nohttp {
source.exclude "buildSrc/build/**", "javascript/.gradle/**", "javascript/package-lock.json", "javascript/node_modules/**", "javascript/build/**", "javascript/dist/**" source.exclude "buildSrc/build/**", "javascript/.gradle/**", "javascript/package-lock.json", "javascript/node_modules/**", "javascript/build/**", "javascript/dist/**"
+10 -1
View File
@@ -12,7 +12,16 @@ java {
repositories { repositories {
gradlePluginPortal() gradlePluginPortal()
mavenCentral() mavenCentral()
maven { url = 'https://repo.spring.io/snapshot' } if (project.hasProperty("artifactoryUsername") && project.hasProperty("artifactoryPassword")) {
maven {
name "spring-commercial-release"
url "https://usw1.packages.broadcom.com/spring-enterprise-maven-prod-local"
credentials {
username project.artifactoryUsername
password project.artifactoryPassword
}
}
}
if (System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_URL") != null) { if (System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_URL") != null) {
maven { maven {
name = "Release Train" name = "Release Train"
+27
View File
@@ -0,0 +1,27 @@
subprojects {
repositories {
mavenCentral()
def repoUsername = project.findProperty("artifactoryUsername") ?: System.getenv("COMMERCIAL_REPO_USERNAME")
def repoPassword = project.findProperty("artifactoryPassword") ?: System.getenv("COMMERCIAL_REPO_PASSWORD")
if (repoUsername && repoPassword) {
maven {
name "spring-commercial-release"
url "https://usw1.packages.broadcom.com/spring-enterprise-maven-prod-local"
credentials {
username repoUsername
password repoPassword
}
}
if ("$version".endsWith("-SNAPSHOT")) {
maven {
name "spring-commercial-snapshot"
url "https://usw1.packages.broadcom.com/spring-enterprise-maven-dev-local"
credentials {
username repoUsername
password repoPassword
}
}
}
}
}
}
@@ -17,6 +17,7 @@
package org.springframework.security.config.annotation.authentication.configurers.ldap; package org.springframework.security.config.annotation.authentication.configurers.ldap;
import java.io.IOException; import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket; import java.net.ServerSocket;
import org.springframework.ldap.core.support.BaseLdapPathContextSource; import org.springframework.ldap.core.support.BaseLdapPathContextSource;
@@ -590,7 +591,7 @@ public class LdapAuthenticationProviderConfigurer<B extends ProviderManagerBuild
} }
private int getDefaultPort() { private int getDefaultPort() {
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT)) { try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT, 50, InetAddress.getLoopbackAddress())) {
return serverSocket.getLocalPort(); return serverSocket.getLocalPort();
} }
catch (IOException ex) { catch (IOException ex) {
@@ -79,8 +79,10 @@ final class DPoPAuthenticationConfigurer<B extends HttpSecurityBuilder<B>>
@Override @Override
public void configure(B http) { public void configure(B http) {
DPoPAuthenticationProvider authenticationProvider = new DPoPAuthenticationProvider(
getTokenAuthenticationManager(http));
http.authenticationProvider(postProcess(authenticationProvider));
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class); AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
http.authenticationProvider(new DPoPAuthenticationProvider(getTokenAuthenticationManager(http)));
AuthenticationFilter authenticationFilter = new AuthenticationFilter(authenticationManager, AuthenticationFilter authenticationFilter = new AuthenticationFilter(authenticationManager,
getAuthenticationConverter()); getAuthenticationConverter());
authenticationFilter.setRequestMatcher(getRequestMatcher()); authenticationFilter.setRequestMatcher(getRequestMatcher());
@@ -33,6 +33,7 @@ import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationManagerResolver; import org.springframework.security.authentication.AuthenticationManagerResolver;
import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.Customizer; 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.HttpSecurityBuilder;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
@@ -298,6 +299,8 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
if (dPoPAuthenticationAvailable) { if (dPoPAuthenticationAvailable) {
DPoPAuthenticationConfigurer<H> dPoPAuthenticationConfigurer = new DPoPAuthenticationConfigurer<>(); DPoPAuthenticationConfigurer<H> dPoPAuthenticationConfigurer = new DPoPAuthenticationConfigurer<>();
dPoPAuthenticationConfigurer.withObjectPostProcessor(
(ObjectPostProcessor<Object>) OAuth2ResourceServerConfigurer.this::postProcess);
dPoPAuthenticationConfigurer.configure(http); dPoPAuthenticationConfigurer.configure(http);
} }
@@ -17,6 +17,7 @@
package org.springframework.security.config.ldap; package org.springframework.security.config.ldap;
import java.io.IOException; import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket; import java.net.ServerSocket;
import org.w3c.dom.Element; import org.w3c.dom.Element;
@@ -188,7 +189,7 @@ public class LdapServerBeanDefinitionParser implements BeanDefinitionParser {
} }
private String getDefaultPort() { 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()); return String.valueOf(serverSocket.getLocalPort());
} }
catch (IOException ex) { catch (IOException ex) {
@@ -22,6 +22,7 @@ import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey; import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey; import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.Base64; import java.util.Base64;
@@ -30,6 +31,7 @@ import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.function.Function;
import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jose.jwk.JWK; 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.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer; 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.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension; 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.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.jose.TestJwks; import org.springframework.security.oauth2.jose.TestJwks;
import org.springframework.security.oauth2.jose.TestKeys; import org.springframework.security.oauth2.jose.TestKeys;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; 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.JwsHeader;
import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; 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.security.web.SecurityFilterChain;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -245,8 +253,10 @@ public class DPoPAuthenticationConfigurerTests {
.requestMatchers("/resource2").hasAnyAuthority("SCOPE_resource2.read", "SCOPE_resource2.write") .requestMatchers("/resource2").hasAnyAuthority("SCOPE_resource2.read", "SCOPE_resource2.write")
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.oauth2ResourceServer((oauth2) -> oauth2 .oauth2ResourceServer((oauth2ResourceServer) ->
.jwt(Customizer.withDefaults())); oauth2ResourceServer
.jwt(Customizer.withDefaults())
.withObjectPostProcessor(dPoPProofVerifierFactoryCustomizer()));
// @formatter:on // @formatter:on
return http.build(); return http.build();
} }
@@ -256,6 +266,25 @@ public class DPoPAuthenticationConfigurerTests {
return NimbusJwtDecoder.withPublicKey(PROVIDER_RSA_PUBLIC_KEY).build(); 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 @RestController
@@ -132,7 +132,7 @@ public class KeyBasedPersistenceTokenService implements TokenService, Initializi
// Verification // Verification
String content = creationTime + ":" + pseudoRandomNumber + ":" + extendedInfo.toString(); String content = creationTime + ":" + pseudoRandomNumber + ":" + extendedInfo.toString();
String expectedSha512Hex = Sha512DigestUtils.shaHex(content + ":" + serverSecret); 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()); return new DefaultToken(key, creationTime, extendedInfo.toString());
} }
@@ -21,6 +21,9 @@ import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException; import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import org.jspecify.annotations.Nullable;
/** /**
* UTF-8 Charset encoder/decoder. * 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;
}
} }
@@ -37,7 +37,9 @@ import org.springframework.security.crypto.util.EncodingUtils;
* *
* @author Keith Donald * @author Keith Donald
* @author Dave Syer * @author Dave Syer
* @deprecated Use {@link AesCbcBytesEncryptor} or {@link AesGcmBytesEncryptor} instead.
*/ */
@Deprecated
public final class AesBytesEncryptor implements BytesEncryptor { public final class AesBytesEncryptor implements BytesEncryptor {
private final SecretKey secretKey; private final SecretKey secretKey;
@@ -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);
}
}
}
@@ -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; package org.springframework.security.crypto.encrypt;
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
import org.springframework.security.crypto.keygen.KeyGenerators; import org.springframework.security.crypto.keygen.KeyGenerators;
/** /**
@@ -42,9 +41,14 @@ public final class Encryptors {
* not be shared * not be shared
* @param salt a hex-encoded, random, site-global salt value to use to generate the * @param salt a hex-encoded, random, site-global salt value to use to generate the
* key * key
* @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)}
* instead.
*/ */
@Deprecated
@SuppressWarnings("deprecation")
public static BytesEncryptor stronger(CharSequence password, CharSequence salt) { 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 * 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 * 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 * 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 * encrypted message will be unique. Requires Java 6.
* <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)}.
* @param password the password used to generate the encryptor's secret key; should * @param password the password used to generate the encryptor's secret key; should
* not be shared * not be shared
* @param salt a hex-encoded, random, site-global salt value to use to generate the * @param salt a hex-encoded, random, site-global salt value to use to generate the
* key * key
* * @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)}
* @see Encryptors#stronger(CharSequence, CharSequence) * instead.
*/ */
@Deprecated
@SuppressWarnings("deprecation")
public static BytesEncryptor standard(CharSequence password, CharSequence salt) { public static BytesEncryptor standard(CharSequence password, CharSequence salt) {
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16)); return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16));
} }
@@ -74,8 +76,12 @@ public final class Encryptors {
* text is hex-encoded. * text is hex-encoded.
* @param password the password used to generate the encryptor's secret key; should * @param password the password used to generate the encryptor's secret key; should
* not be shared * not be shared
* @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)}
* instead.
* @see Encryptors#stronger(CharSequence, CharSequence) * @see Encryptors#stronger(CharSequence, CharSequence)
*/ */
@Deprecated
@SuppressWarnings("deprecation")
public static TextEncryptor delux(CharSequence password, CharSequence salt) { public static TextEncryptor delux(CharSequence password, CharSequence salt) {
return new HexEncodingTextEncryptor(stronger(password, salt)); return new HexEncodingTextEncryptor(stronger(password, salt));
} }
@@ -85,8 +91,12 @@ public final class Encryptors {
* text is hex-encoded. * text is hex-encoded.
* @param password the password used to generate the encryptor's secret key; should * @param password the password used to generate the encryptor's secret key; should
* not be shared * not be shared
* @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)}
* instead.
* @see Encryptors#standard(CharSequence, CharSequence) * @see Encryptors#standard(CharSequence, CharSequence)
*/ */
@Deprecated
@SuppressWarnings("deprecation")
public static TextEncryptor text(CharSequence password, CharSequence salt) { public static TextEncryptor text(CharSequence password, CharSequence salt) {
return new HexEncodingTextEncryptor(standard(password, salt)); return new HexEncodingTextEncryptor(standard(password, salt));
} }
@@ -16,8 +16,6 @@
package org.springframework.security.crypto.password; package org.springframework.security.crypto.password;
import java.security.MessageDigest;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import org.springframework.security.crypto.codec.Utf8; import org.springframework.security.crypto.codec.Utf8;
@@ -39,15 +37,7 @@ final class PasswordEncoderUtils {
* @return * @return
*/ */
static boolean equals(String expected, @Nullable String actual) { static boolean equals(String expected, @Nullable String actual) {
byte[] expectedBytes = bytesUtf8(expected); return Utf8.isEqual(expected, actual);
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;
} }
} }
@@ -24,6 +24,7 @@ import com.password4j.BalloonHashingFunction;
import com.password4j.Hash; import com.password4j.Hash;
import com.password4j.Password; import com.password4j.Password;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder; import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
import org.springframework.util.Assert; import org.springframework.util.Assert;
@@ -140,7 +141,7 @@ public class BalloonHashingPassword4jPasswordEncoder extends AbstractValidatingP
String expectedHash = parts[1]; String expectedHash = parts[1];
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction); Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
return expectedHash.equals(hash.getResult()); return Utf8.isEqual(expectedHash, hash.getResult());
} }
catch (IllegalArgumentException ex) { catch (IllegalArgumentException ex) {
// Invalid Base64 encoding // Invalid Base64 encoding
@@ -24,6 +24,7 @@ import com.password4j.Hash;
import com.password4j.PBKDF2Function; import com.password4j.PBKDF2Function;
import com.password4j.Password; import com.password4j.Password;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder; import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
import org.springframework.util.Assert; import org.springframework.util.Assert;
@@ -138,7 +139,7 @@ public class Pbkdf2Password4jPasswordEncoder extends AbstractValidatingPasswordE
String expectedHash = parts[1]; String expectedHash = parts[1];
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function); Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
return expectedHash.equals(hash.getResult()); return Utf8.isEqual(expectedHash, hash.getResult());
} }
catch (IllegalArgumentException ex) { catch (IllegalArgumentException ex) {
// Invalid Base64 encoding // Invalid Base64 encoding
@@ -37,4 +37,48 @@ public class Utf8Tests {
assertThat(decoded).isEqualTo("6048b75ed560785c"); 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();
}
} }
@@ -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;
}
}
}
@@ -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));
}
}
@@ -24,25 +24,23 @@ import javax.crypto.NoSuchPaddingException;
import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Assumptions;
import org.opentest4j.TestAbortedException; import org.opentest4j.TestAbortedException;
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
public final class CryptoAssumptions { public final class CryptoAssumptions {
private CryptoAssumptions() { private CryptoAssumptions() {
} }
public static void assumeGCMJCE() { public static void assumeGCMJCE() {
assumeAes256(CipherAlgorithm.GCM); assumeAes256("GCM");
} }
public static void assumeCBCJCE() { public static void assumeCBCJCE() {
assumeAes256(CipherAlgorithm.CBC); assumeAes256("CBC");
} }
private static void assumeAes256(CipherAlgorithm cipherAlgorithm) { private static void assumeAes256(String cipherAlgorithm) {
boolean aes256Available = false; boolean aes256Available = false;
try { try {
Cipher.getInstance(cipherAlgorithm.toString()); Cipher.getInstance(cipherAlgorithm);
aes256Available = Cipher.getMaxAllowedKeyLength("AES") >= 256; aes256Available = Cipher.getMaxAllowedKeyLength("AES") >= 256;
} }
catch (NoSuchAlgorithmException ex) { catch (NoSuchAlgorithmException ex) {
@@ -8,8 +8,8 @@ The code is distributed as part of the core module but has no dependencies on an
[[spring-security-crypto-encryption]] [[spring-security-crypto-encryption]]
== Encryptors == Encryptors
The javadoc:org.springframework.security.crypto.encrypt.Encryptors[] class provides factory methods for constructing symmetric encryptors. Spring Security provides javadoc:org.springframework.security.crypto.encrypt.AesGcmBytesEncryptor[] and javadoc:org.springframework.security.crypto.encrypt.AesCbcBytesEncryptor[] for constructing symmetric encryptors.
This class lets you create javadoc:org.springframework.security.crypto.encrypt.BytesEncryptor[] instances to encrypt data in raw `byte[]` form. 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. You can also construct javadoc:org.springframework.security.crypto.encrypt.TextEncryptor[] instances to encrypt text strings.
Encryptors are thread-safe. Encryptors are thread-safe.
@@ -20,7 +20,7 @@ Both `BytesEncryptor` and `TextEncryptor` are interfaces. `BytesEncryptor` has m
[[spring-security-crypto-encryption-bytes]] [[spring-security-crypto-encryption-bytes]]
=== BytesEncryptor === BytesEncryptor
You can use the `Encryptors.stronger` factory method to construct a `BytesEncryptor`: Use `AesGcmBytesEncryptor` to construct a BytesEncryptor with authenticated encryption:
.BytesEncryptor .BytesEncryptor
[tabs] [tabs]
@@ -29,24 +29,22 @@ Java::
+ +
[source,java,role="primary"] [source,java,role="primary"]
---- ----
Encryptors.stronger("password", "salt"); AesGcmBytesEncryptor.withPassword("password", "salt").build();
---- ----
Kotlin:: Kotlin::
+ +
[source,kotlin,role="secondary"] [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 `AesGcmBytesEncryptor` uses 256-bit AES encryption with Galois Counter Mode (GCM), providing https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated encryption] (AEAD).
Galois Counter Mode (GCM). It derives the secret key using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2).
It derives the secret key by 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.
This method requires Java 6. The salt is used to prevent dictionary attacks against the key in the event your encrypted data is compromised.
The password used to generate the `SecretKey` should be kept in a secure place and should not be shared. A 16-byte random initialization vector is also applied so each encrypted message is unique.
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.
The provided salt should be in hex-encoded String form, be random, and be at least 8 bytes in length. 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`: 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 This mode is not https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated] and does not provide any
guarantees about the authenticity of the data. 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]] [[spring-security-crypto-encryption-text]]
=== TextEncryptor === TextEncryptor
You can use the `Encryptors.text` factory method to construct a standard TextEncryptor: Use `AesCbcBytesEncryptor` to encrypt text data:
.TextEncryptor .TextEncryptor
[tabs] [tabs]
@@ -85,19 +102,22 @@ Java::
+ +
[source,java,role="primary"] [source,java,role="primary"]
---- ----
Encryptors.text("password", "salt"); AesCbcBytesEncryptor.withPassword("password", "salt").build();
---- ----
Kotlin:: Kotlin::
+ +
[source,kotlin,role="secondary"] [source,kotlin,role="secondary"]
---- ----
Encryptors.text("password", "salt") AesCbcBytesEncryptor.withPassword("password", "salt").build()
---- ----
====== ======
A `TextEncryptor` uses a standard `BytesEncryptor` to encrypt text data. `AesCbcBytesEncryptor` encrypts data as raw bytes.
Encrypted results are returned as hex-encoded strings for easy storage on the filesystem or in a database. 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]] [[spring-security-crypto-keygenerators]]
== Key Generators == Key Generators
+1 -1
View File
@@ -14,7 +14,7 @@
# limitations under the License. # limitations under the License.
# #
springBootVersion=4.0.0-SNAPSHOT springBootVersion=4.0.0-SNAPSHOT
version=7.0.7-SNAPSHOT version=7.0.7
samplesBranch=main samplesBranch=main
org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError
org.gradle.parallel=true org.gradle.parallel=true
+5 -5
View File
@@ -1,6 +1,6 @@
[versions] [versions]
com-squareup-okhttp3 = "3.14.9" com-squareup-okhttp3 = "3.14.9"
io-micrometer = "1.16.6" io-micrometer = "1.16.7"
io-rsocket = "1.1.5" io-rsocket = "1.1.5"
io-spring-javaformat = "0.0.47" io-spring-javaformat = "0.0.47"
io-spring-nohttp = "0.0.11" io-spring-nohttp = "0.0.11"
@@ -13,7 +13,7 @@ org-jetbrains-kotlin = "2.2.21"
org-jetbrains-kotlinx = "1.10.2" org-jetbrains-kotlinx = "1.10.2"
org-mockito = "5.17.0" org-mockito = "5.17.0"
org-opensaml5 = "5.1.6" org-opensaml5 = "5.1.6"
org-springframework = "7.0.8" org-springframework = "7.0.9"
com-password4j = "1.8.4" com-password4j = "1.8.4"
[libraries] [libraries]
@@ -32,7 +32,7 @@ io-micrometer-context-propagation = "io.micrometer:context-propagation:1.1.4"
io-micrometer-micrometer-bom = { module = "io.micrometer:micrometer-bom", version.ref = "io-micrometer" } 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-micrometer-micrometer-observation = { module = "io.micrometer:micrometer-observation", version.ref = "io-micrometer" }
io-mockk = "io.mockk:mockk:1.14.11" io-mockk = "io.mockk:mockk:1.14.11"
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2025.0.6" 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-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-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" } io-spring-javaformat-spring-javaformat-gradle-plugin = { module = "io.spring.javaformat:spring-javaformat-gradle-plugin", version.ref = "io-spring-javaformat" }
@@ -83,8 +83,8 @@ org-seleniumhq-selenium-selenium-support = "org.seleniumhq.selenium:selenium-sup
org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3" org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3"
org-slf4j-log4j-over-slf4j = "org.slf4j:log4j-over-slf4j:1.7.36" org-slf4j-log4j-over-slf4j = "org.slf4j:log4j-over-slf4j:1.7.36"
org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.18" 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.6" 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.4" 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-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" 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" tools-jackson-jackson-bom = "tools.jackson:jackson-bom:3.0.4"
@@ -21,9 +21,11 @@ import java.net.ServerSocket;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.context.support.GenericApplicationContext; import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat; 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 @Test
public void afterPropertiesSetWhenPortIsZeroThenRandomPortIsSelected() throws Exception { public void afterPropertiesSetWhenPortIsZeroThenRandomPortIsSelected() throws Exception {
UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null); UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null);
@@ -17,6 +17,7 @@
package org.springframework.security.ldap.server; package org.springframework.security.ldap.server;
import java.io.InputStream; import java.io.InputStream;
import java.net.InetAddress;
import com.unboundid.ldap.listener.InMemoryDirectoryServer; import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
@@ -96,7 +97,8 @@ public class UnboundIdContainer
try { try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(this.defaultPartitionSuffix); InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(this.defaultPartitionSuffix);
config.addAdditionalBindCredentials("uid=admin,ou=system", "secret"); 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.setEnforceSingleStructuralObjectClass(false);
config.setEnforceAttributeSyntaxCompliance(true); config.setEnforceAttributeSyntaxCompliance(true);
DN dn = new DN(this.defaultPartitionSuffix); DN dn = new DN(this.defaultPartitionSuffix);
@@ -24,6 +24,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import org.springframework.lang.Nullable; 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.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2DeviceCode; import org.springframework.security.oauth2.core.OAuth2DeviceCode;
import org.springframework.security.oauth2.core.OAuth2RefreshToken; 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) { 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) { private static boolean matchesAuthorizationCode(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = authorization OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = authorization
.getToken(OAuth2AuthorizationCode.class); .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) { private static boolean matchesAccessToken(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2AccessToken> accessToken = authorization.getToken(OAuth2AccessToken.class); 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) { private static boolean matchesRefreshToken(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = authorization.getToken(OAuth2RefreshToken.class); 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) { private static boolean matchesIdToken(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OidcIdToken> idToken = authorization.getToken(OidcIdToken.class); 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) { private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode = authorization.getToken(OAuth2DeviceCode.class); 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) { private static boolean matchesUserCode(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2UserCode> userCode = authorization.getToken(OAuth2UserCode.class); 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") @SuppressWarnings("serial")
@@ -29,6 +29,7 @@ import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcScopes; import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.web.util.HtmlUtils;
/** /**
* For internal use only. * For internal use only.
@@ -56,6 +57,12 @@ final class DefaultConsentPage {
private static String generateConsentPage(HttpServletRequest request, String clientId, Authentication principal, private static String generateConsentPage(HttpServletRequest request, String clientId, Authentication principal,
Set<String> requestedScopes, Set<String> authorizedScopes, String state, Set<String> requestedScopes, Set<String> authorizedScopes, String state,
Map<String, String> additionalParameters) { 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> scopesToAuthorize = new HashSet<>();
Set<String> scopesPreviouslyAuthorized = new HashSet<>(); Set<String> scopesPreviouslyAuthorized = new HashSet<>();
for (String scope : requestedScopes) { for (String scope : requestedScopes) {
@@ -74,6 +81,7 @@ final class DefaultConsentPage {
// the "user_code" being displayed on the device to confirm they are // the "user_code" being displayed on the device to confirm they are
// authorizing the correct device. // authorizing the correct device.
String userCode = additionalParameters.get(OAuth2ParameterNames.USER_CODE); String userCode = additionalParameters.get(OAuth2ParameterNames.USER_CODE);
String encodedUserCode = (userCode != null) ? HtmlUtils.htmlEscape(userCode) : null;
// @formatter:off // @formatter:off
StringBuilder builder = new StringBuilder(); StringBuilder builder = new StringBuilder();
@@ -98,13 +106,13 @@ final class DefaultConsentPage {
builder.append(" </div>"); builder.append(" </div>");
builder.append(" <div class=\"row\">"); builder.append(" <div class=\"row\">");
builder.append(" <div class=\"col text-center\">"); 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>");
builder.append(" </div>"); builder.append(" </div>");
if (userCode != null) { if (userCode != null) {
builder.append(" <div class=\"row\">"); builder.append(" <div class=\"row\">");
builder.append(" <div class=\"col text-center\">"); 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>");
builder.append(" </div>"); builder.append(" </div>");
} }
@@ -115,26 +123,28 @@ final class DefaultConsentPage {
builder.append(" </div>"); builder.append(" </div>");
builder.append(" <div class=\"row\">"); builder.append(" <div class=\"row\">");
builder.append(" <div class=\"col text-center\">"); builder.append(" <div class=\"col text-center\">");
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + request.getRequestURI() + "\">"); builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + encodedRequestUri + "\">");
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + clientId + "\">"); builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + encodedClientId + "\">");
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + state + "\">"); builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + encodedState + "\">");
if (userCode != null) { 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) { for (String scope : scopesToAuthorize) {
String encodedScope = HtmlUtils.htmlEscape(scope);
builder.append(" <div class=\"form-group form-check py-1\">"); 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(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + encodedScope + "\" id=\"" + encodedScope + "\">");
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>"); builder.append(" <label class=\"form-check-label\" for=\"" + encodedScope + "\">" + encodedScope + "</label>");
builder.append(" </div>"); builder.append(" </div>");
} }
if (!scopesPreviouslyAuthorized.isEmpty()) { if (!scopesPreviouslyAuthorized.isEmpty()) {
builder.append(" <p>You have already granted the following permissions to the above app:</p>"); builder.append(" <p>You have already granted the following permissions to the above app:</p>");
for (String scope : scopesPreviouslyAuthorized) { for (String scope : scopesPreviouslyAuthorized) {
String encodedScope = HtmlUtils.htmlEscape(scope);
builder.append(" <div class=\"form-group form-check py-1\">"); 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(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + encodedScope + "\" checked disabled>");
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>"); builder.append(" <label class=\"form-check-label\" for=\"" + encodedScope + "\">" + encodedScope + "</label>");
builder.append(" </div>"); builder.append(" </div>");
} }
} }
@@ -25,7 +25,9 @@ import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken; 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.OAuth2RefreshToken;
import org.springframework.security.oauth2.core.OAuth2UserCode;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; 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 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; private InMemoryOAuth2AuthorizationService authorizationService;
@BeforeEach @BeforeEach
@@ -313,6 +319,44 @@ public class InMemoryOAuth2AuthorizationServiceTests {
assertThat(authorization).isEqualTo(result); 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 @Test
public void findByTokenWhenWrongTokenTypeThenNotFound() { public void findByTokenWhenWrongTokenTypeThenNotFound() {
OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now()); OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now());
@@ -64,6 +64,7 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHand
import org.springframework.security.web.authentication.WebAuthenticationDetails; import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.util.HtmlUtils;
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
import static org.assertj.core.api.Assertions.assertThat; 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 @Test
public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationResponse() throws Exception { public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationResponse() throws Exception {
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().redirectUris((redirectUris) -> { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().redirectUris((redirectUris) -> {
@@ -33,6 +33,8 @@ public final class DelegatingOAuth2TokenValidator<T extends OAuth2Token> impleme
private final Collection<OAuth2TokenValidator<T>> tokenValidators; private final Collection<OAuth2TokenValidator<T>> tokenValidators;
private boolean failOnError;
/** /**
* Constructs a {@code DelegatingOAuth2TokenValidator} using the provided validators. * Constructs a {@code DelegatingOAuth2TokenValidator} using the provided validators.
* @param tokenValidators the {@link Collection} of {@link OAuth2TokenValidator}s to * @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<>(); Collection<OAuth2Error> errors = new ArrayList<>();
for (OAuth2TokenValidator<T> validator : this.tokenValidators) { for (OAuth2TokenValidator<T> validator : this.tokenValidators) {
errors.addAll(validator.validate(token).getErrors()); errors.addAll(validator.validate(token).getErrors());
if (!errors.isEmpty() && this.failOnError) {
return OAuth2TokenValidatorResult.failure(errors);
}
} }
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;
}
} }
@@ -18,11 +18,11 @@ package org.springframework.security.oauth2.jwt;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.Instant; import java.time.Duration;
import java.time.temporal.ChronoUnit; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Function; 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.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTProcessor; 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.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes; 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.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils; 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 * The default {@code OAuth2TokenValidator<Jwt>} factory that validates the
* {@code htm}, {@code htu}, {@code jti} and {@code iat} claims of the DPoP Proof * {@code htm}, {@code htu}, {@code iat}, {@code jkt}, {@code ath} and {@code jti}
* {@link Jwt}. * 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<>( private static final JOSEObjectTypeVerifier<SecurityContext> DPOP_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>(
new JOSEObjectType("dpop+jwt")); new JOSEObjectType("dpop+jwt"));
@@ -94,6 +98,70 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
this.jwtValidatorFactory = jwtValidatorFactory; 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() { private static NimbusJwtDecoder buildDecoder() {
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>(); ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
jwtProcessor.setJWSTypeVerifier(DPOP_TYPE_VERIFIER); jwtProcessor.setJWSTypeVerifier(DPOP_TYPE_VERIFIER);
@@ -137,39 +205,34 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
}; };
} }
private static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> defaultJwtValidatorFactory() { private static final class AthClaimValidator implements OAuth2TokenValidator<Jwt> {
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 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 @Override
public OAuth2TokenValidatorResult validate(Jwt jwt) { public OAuth2TokenValidatorResult validate(Jwt jwt) {
Assert.notNull(jwt, "DPoP proof jwt cannot be null"); Assert.notNull(jwt, "DPoP proof jwt cannot be null");
String jti = jwt.getId(); String accessTokenHashClaim = jwt.getClaimAsString("ath");
if (!StringUtils.hasText(jti)) { if (!StringUtils.hasText(accessTokenHashClaim)) {
OAuth2Error error = createOAuth2Error("jti claim is required."); OAuth2Error error = createOAuth2Error("ath claim is required.");
return OAuth2TokenValidatorResult.failure(error); return OAuth2TokenValidatorResult.failure(error);
} }
// Enforce single-use to protect against DPoP proof replay String accessTokenHash;
String jtiHash;
try { try {
jtiHash = computeSHA256(jti); accessTokenHash = computeSHA256(this.accessToken.getTokenValue());
} }
catch (Exception ex) { 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); return OAuth2TokenValidatorResult.failure(error);
} }
Instant expiry = Instant.now().plus(1, ChronoUnit.HOURS); if (!accessTokenHashClaim.equals(accessTokenHash)) {
if ((JTI_CACHE.putIfAbsent(jtiHash, expiry.toEpochMilli())) != null) { OAuth2Error error = createOAuth2Error("ath claim is invalid.");
// Already used
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
return OAuth2TokenValidatorResult.failure(error); return OAuth2TokenValidatorResult.failure(error);
} }
return OAuth2TokenValidatorResult.success(); return OAuth2TokenValidatorResult.success();
@@ -185,20 +248,65 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); 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 private final OAuth2Token accessToken;
protected boolean removeEldestEntry(Map.Entry<String, Long> eldest) {
if (size() > MAX_SIZE) { private final ClaimAccessor claims;
return true;
} private JwkThumbprintValidator(OAuth2Token accessToken) {
Instant expiry = Instant.ofEpochMilli(eldest.getValue()); Assert.notNull(accessToken, "accessToken cannot be null");
return Instant.now().isAfter(expiry); 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);
} }
} }
@@ -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);
}
}
}
@@ -16,36 +16,24 @@
package org.springframework.security.oauth2.server.resource.authentication; package org.springframework.security.oauth2.server.resource.authentication;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant; import java.time.Instant;
import java.util.Base64;
import java.util.Map; import java.util.Map;
import java.util.function.Function;
import com.nimbusds.jose.jwk.JWK;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.core.ClaimAccessor; import org.springframework.security.oauth2.core.ClaimAccessor;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.OAuth2Token; import org.springframework.security.oauth2.core.OAuth2Token;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.DPoPProofContext; import org.springframework.security.oauth2.jwt.DPoPProofContext;
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory; import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoderFactory; import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
/** /**
* An {@link AuthenticationProvider} implementation that is responsible for authenticating * An {@link AuthenticationProvider} implementation that is responsible for authenticating
@@ -72,16 +60,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
public DPoPAuthenticationProvider(AuthenticationManager tokenAuthenticationManager) { public DPoPAuthenticationProvider(AuthenticationManager tokenAuthenticationManager) {
Assert.notNull(tokenAuthenticationManager, "tokenAuthenticationManager cannot be null"); Assert.notNull(tokenAuthenticationManager, "tokenAuthenticationManager cannot be null");
this.tokenAuthenticationManager = tokenAuthenticationManager; this.tokenAuthenticationManager = tokenAuthenticationManager;
Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = ( this.dPoPProofVerifierFactory = new DPoPProofJwtDecoderFactory();
context) -> new DelegatingOAuth2TokenValidator<>(
// Use default validators
DPoPProofJwtDecoderFactory.DEFAULT_JWT_VALIDATOR_FACTORY.apply(context),
// Add custom validators
new AthClaimValidator(context.getAccessToken()),
new JwkThumbprintValidator(context.getAccessToken()));
DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory();
dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory);
this.dPoPProofVerifierFactory = dPoPProofJwtDecoderFactory;
} }
@Override @Override
@@ -116,7 +95,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
try { try {
dPoPProofVerifier.decode(dPoPProofContext.getDPoPProof()); dPoPProofVerifier.decode(dPoPProofContext.getDPoPProof());
} }
catch (JwtException ex) { catch (Exception ex) {
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF); OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF);
throw new OAuth2AuthenticationException(error, ex); throw new OAuth2AuthenticationException(error, ex);
} }
@@ -141,108 +120,6 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
this.dPoPProofVerifierFactory = dPoPProofVerifierFactory; this.dPoPProofVerifierFactory = dPoPProofVerifierFactory;
} }
private static final class AthClaimValidator implements OAuth2TokenValidator<Jwt> {
private final OAuth2AccessTokenClaims accessToken;
private AthClaimValidator(OAuth2AccessTokenClaims 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 accessTokenHashClaim = jwt.getClaimAsString("ath");
if (!StringUtils.hasText(accessTokenHashClaim)) {
OAuth2Error error = createOAuth2Error("ath claim is required.");
return OAuth2TokenValidatorResult.failure(error);
}
String accessTokenHash;
try {
accessTokenHash = computeSHA256(this.accessToken.getTokenValue());
}
catch (Exception ex) {
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token.");
return OAuth2TokenValidatorResult.failure(error);
}
if (!accessTokenHashClaim.equals(accessTokenHash)) {
OAuth2Error error = createOAuth2Error("ath 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);
}
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);
}
}
private static final class JwkThumbprintValidator implements OAuth2TokenValidator<Jwt> {
private final OAuth2AccessTokenClaims accessToken;
private JwkThumbprintValidator(OAuth2AccessTokenClaims 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 jwkThumbprintClaim = null;
Map<String, Object> confirmationMethodClaim = this.accessToken.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);
}
}
private static final class OAuth2AccessTokenClaims implements OAuth2Token, ClaimAccessor { private static final class OAuth2AccessTokenClaims implements OAuth2Token, ClaimAccessor {
private final OAuth2Token accessToken; private final OAuth2Token accessToken;
+2 -1
View File
@@ -1,5 +1,5 @@
changelog: changelog:
repository: spring-projects/spring-security repository: spring-projects/spring-security-commercial
sections: sections:
- title: ":rewind: Breaking Changes" - title: ":rewind: Breaking Changes"
labels: ["type: breaks-passivity"] labels: ["type: breaks-passivity"]
@@ -17,5 +17,6 @@ changelog:
labels: ["type: task", "in: build"] labels: ["type: task", "in: build"]
sort: "title" sort: "title"
issues: issues:
generate_links: false
exclude: exclude:
labels: ["status: duplicate"] labels: ["status: duplicate"]
+2 -2
View File
@@ -15,12 +15,12 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = 'spring-security' rootProject.name = 'spring-security-commercial'
FileTree buildFiles = fileTree(rootDir) { FileTree buildFiles = fileTree(rootDir) {
List excludes = gradle.startParameter.projectProperties.get("excludeProjects")?.split(",") List excludes = gradle.startParameter.projectProperties.get("excludeProjects")?.split(",")
include '**/*.gradle', '**/*.gradle.kts' include '**/*.gradle', '**/*.gradle.kts'
exclude 'build', '**/gradle', 'settings.gradle', 'buildSrc', '/build.gradle', '.*', 'out' exclude 'build', '**/gradle', 'settings.gradle', 'commercial-settings.gradle', 'buildSrc', '/build.gradle', '.*', 'out'
exclude '**/grails3' exclude '**/grails3'
if(excludes) { if(excludes) {
exclude excludes exclude excludes
@@ -51,6 +51,7 @@ import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.core.userdetails.cache.NullUserCache; import org.springframework.security.core.userdetails.cache.NullUserCache;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository;
@@ -167,7 +168,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
} }
serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod()); serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod());
// If digest is incorrect, try refreshing from backend and recomputing // If digest is incorrect, try refreshing from backend and recomputing
if (!serverDigestMd5.equals(digestAuth.getResponse()) && cacheWasUsed) { if (!Utf8.isEqual(serverDigestMd5, digestAuth.getResponse()) && cacheWasUsed) {
logger.debug("Digest comparison failure; trying to refresh user from DAO in case password had changed"); logger.debug("Digest comparison failure; trying to refresh user from DAO in case password had changed");
user = this.userDetailsService.loadUserByUsername(username); user = this.userDetailsService.loadUserByUsername(username);
this.userCache.putUserInCache(user); this.userCache.putUserInCache(user);
@@ -181,7 +182,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
return; return;
} }
// If digest is still incorrect, definitely reject authentication attempt // If digest is still incorrect, definitely reject authentication attempt
if (!serverDigestMd5.equals(digestAuth.getResponse())) { if (!Utf8.isEqual(serverDigestMd5, digestAuth.getResponse())) {
logger.debug(LogMessage.format( logger.debug(LogMessage.format(
"Expected response: '%s' but received: '%s'; is AuthenticationDao returning clear text passwords?", "Expected response: '%s' but received: '%s'; is AuthenticationDao returning clear text passwords?",
serverDigestMd5, digestAuth.getResponse())); serverDigestMd5, digestAuth.getResponse()));
@@ -405,7 +406,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
} }
// Check signature of nonce matches this expiry time // Check signature of nonce matches this expiry time
String expectedNonceSignature = DigestAuthUtils.md5Hex(this.nonceExpiryTime + ":" + entryPointKey); String expectedNonceSignature = DigestAuthUtils.md5Hex(this.nonceExpiryTime + ":" + entryPointKey);
if (!expectedNonceSignature.equals(nonceTokens[1])) { if (!Utf8.isEqual(expectedNonceSignature, nonceTokens[1])) {
throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage( throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
"DigestAuthenticationFilter.nonceCompromised", new Object[] { nonceAsPlainText }, "DigestAuthenticationFilter.nonceCompromised", new Object[] { nonceAsPlainText },
"Nonce token compromised {0}")); "Nonce token compromised {0}"));
@@ -16,6 +16,7 @@
package org.springframework.security.web.webauthn.api; package org.springframework.security.web.webauthn.api;
import java.io.ObjectStreamException;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
@@ -121,4 +122,25 @@ public final class AuthenticatorTransport implements Serializable {
return new AuthenticatorTransport[] { USB, NFC, BLE, HYBRID, INTERNAL }; return new AuthenticatorTransport[] { USB, NFC, BLE, HYBRID, INTERNAL };
} }
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof AuthenticatorTransport other)) {
return false;
}
return this.value.equals(other.value);
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Serial
private Object readResolve() throws ObjectStreamException {
return valueOf(this.value);
}
} }
@@ -16,6 +16,7 @@
package org.springframework.security.web.webauthn.api; package org.springframework.security.web.webauthn.api;
import java.io.ObjectStreamException;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
@@ -58,4 +59,25 @@ public final class PublicKeyCredentialType implements Serializable {
return new PublicKeyCredentialType(value); return new PublicKeyCredentialType(value);
} }
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof PublicKeyCredentialType other)) {
return false;
}
return this.value.equals(other.value);
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Serial
private Object readResolve() throws ObjectStreamException {
return valueOf(this.value);
}
} }
@@ -16,9 +16,12 @@
package org.springframework.security.web.webauthn.api; package org.springframework.security.web.webauthn.api;
import java.io.ObjectStreamException;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import org.jspecify.annotations.Nullable;
/** /**
* <a href= * <a href=
* "https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement">UserVerificationRequirement</a> * "https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement">UserVerificationRequirement</a>
@@ -72,4 +75,34 @@ public final class UserVerificationRequirement implements Serializable {
return this.value; return this.value;
} }
@Override
public boolean equals(@Nullable Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof UserVerificationRequirement other)) {
return false;
}
return this.value.equals(other.value);
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Serial
private Object readResolve() throws ObjectStreamException {
switch (this.value) {
case "required":
return REQUIRED;
case "preferred":
return PREFERRED;
case "discouraged":
return DISCOURAGED;
default:
return this;
}
}
} }
@@ -258,8 +258,8 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
byte[] clientDataJSON = response.getClientDataJSON().getBytes(); byte[] clientDataJSON = response.getClientDataJSON().getBytes();
Challenge challenge = new DefaultChallenge(base64Challenge); Challenge challenge = new DefaultChallenge(base64Challenge);
ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge); ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge);
boolean userVerificationRequired = creationOptions.getAuthenticatorSelection() boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
.getUserVerification() == UserVerificationRequirement.REQUIRED; .equals(creationOptions.getAuthenticatorSelection().getUserVerification());
// requireUserPresence The constant Boolean value true // requireUserPresence The constant Boolean value true
// https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred // https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred
boolean userPresenceRequired = true; boolean userPresenceRequired = true;
@@ -318,7 +318,7 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
private com.webauthn4j.data.PublicKeyCredentialParameters convertParamToWebauthn4j( private com.webauthn4j.data.PublicKeyCredentialParameters convertParamToWebauthn4j(
PublicKeyCredentialParameters parameter) { PublicKeyCredentialParameters parameter) {
if (parameter.getType() != PublicKeyCredentialType.PUBLIC_KEY) { if (!PublicKeyCredentialType.PUBLIC_KEY.equals(parameter.getType())) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"Cannot convert unknown credential type " + parameter.getType() + " to webauthn4j"); "Cannot convert unknown credential type " + parameter.getType() + " to webauthn4j");
} }
@@ -395,8 +395,8 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
String rpId = requestOptions.getRpId(); String rpId = requestOptions.getRpId();
Assert.notNull(rpId, "rpId cannot be null"); Assert.notNull(rpId, "rpId cannot be null");
ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge); ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge);
boolean userVerificationRequired = request.getRequestOptions() boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
.getUserVerification() == UserVerificationRequirement.REQUIRED; .equals(request.getRequestOptions().getUserVerification());
com.webauthn4j.data.AuthenticationRequest authenticationRequest = new com.webauthn4j.data.AuthenticationRequest( com.webauthn4j.data.AuthenticationRequest authenticationRequest = new com.webauthn4j.data.AuthenticationRequest(
request.getPublicKey().getRawId().getBytes(), assertionResponse.getAuthenticatorData().getBytes(), request.getPublicKey().getRawId().getBytes(), assertionResponse.getAuthenticatorData().getBytes(),
@@ -0,0 +1,42 @@
/*
* 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.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that {@link AuthenticatorAttachment} correctly restores singleton identity
* after Java deserialization. This class already implements {@code readResolve()} and
* serves as the reference for the pattern required by the other pseudo-enum types.
*/
class AuthenticatorAttachmentTests {
@Test
void platformWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorAttachment.PLATFORM))
.isSameAs(AuthenticatorAttachment.PLATFORM);
}
@Test
void crossPlatformWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorAttachment.CROSS_PLATFORM))
.isSameAs(AuthenticatorAttachment.CROSS_PLATFORM);
}
}
@@ -0,0 +1,76 @@
/*
* 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.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class AuthenticatorTransportTests {
@Test
void usbWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.USB))
.isSameAs(AuthenticatorTransport.USB);
}
@Test
void nfcWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.NFC))
.isSameAs(AuthenticatorTransport.NFC);
}
@Test
void bleWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.BLE))
.isSameAs(AuthenticatorTransport.BLE);
}
@Test
void smartCardWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.SMART_CARD))
.isSameAs(AuthenticatorTransport.SMART_CARD);
}
@Test
void hybridWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.HYBRID))
.isSameAs(AuthenticatorTransport.HYBRID);
}
@Test
void internalWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.INTERNAL))
.isSameAs(AuthenticatorTransport.INTERNAL);
}
@Test
void usbWhenSameValueThenEquals() {
assertThat(new AuthenticatorTransport("usb")).isEqualTo(AuthenticatorTransport.USB);
}
@Test
void usbWhenSameValueThenHashCodeMatches() {
assertThat(new AuthenticatorTransport("usb")).hasSameHashCodeAs(AuthenticatorTransport.USB);
}
@Test
void usbWhenDifferentValueThenNotEquals() {
assertThat(AuthenticatorTransport.USB).isNotEqualTo(AuthenticatorTransport.NFC);
}
}
@@ -0,0 +1,49 @@
/*
* 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.web.webauthn.api;
import java.util.Set;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicKeyCredentialDescriptorTests {
@Test
void typeWhenDeserializedThenSameAsConstant() {
PublicKeyCredentialDescriptor descriptor = PublicKeyCredentialDescriptor.builder()
.id(TestBytes.get())
.type(PublicKeyCredentialType.PUBLIC_KEY)
.build();
PublicKeyCredentialDescriptor deserialized = SerializationTestUtils.serializeAndDeserialize(descriptor);
assertThat(deserialized.getType()).isSameAs(PublicKeyCredentialType.PUBLIC_KEY);
}
@Test
void transportsWhenDeserializedThenSameAsConstants() {
PublicKeyCredentialDescriptor descriptor = PublicKeyCredentialDescriptor.builder()
.id(TestBytes.get())
.type(PublicKeyCredentialType.PUBLIC_KEY)
.transports(Set.of(AuthenticatorTransport.USB, AuthenticatorTransport.HYBRID))
.build();
PublicKeyCredentialDescriptor deserialized = SerializationTestUtils.serializeAndDeserialize(descriptor);
assertThat(deserialized.getTransports()).containsExactlyInAnyOrder(AuthenticatorTransport.USB,
AuthenticatorTransport.HYBRID);
}
}
@@ -0,0 +1,43 @@
/*
* 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.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicKeyCredentialRequestOptionsTests {
@Test
void userVerificationRequiredWhenDeserializedThenSameAsConstant() {
PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create()
.userVerification(UserVerificationRequirement.REQUIRED)
.build();
PublicKeyCredentialRequestOptions deserialized = SerializationTestUtils.serializeAndDeserialize(options);
assertThat(deserialized.getUserVerification()).isSameAs(UserVerificationRequirement.REQUIRED);
}
@Test
void userVerificationPreferredWhenDeserializedThenSameAsConstant() {
PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create()
.userVerification(UserVerificationRequirement.PREFERRED)
.build();
PublicKeyCredentialRequestOptions deserialized = SerializationTestUtils.serializeAndDeserialize(options);
assertThat(deserialized.getUserVerification()).isSameAs(UserVerificationRequirement.PREFERRED);
}
}
@@ -0,0 +1,51 @@
/*
* 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.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicKeyCredentialTypeTests {
@Test
void publicKeyWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(PublicKeyCredentialType.PUBLIC_KEY))
.isSameAs(PublicKeyCredentialType.PUBLIC_KEY);
}
@Test
void publicKeyWhenSameValueThenEquals() {
PublicKeyCredentialType first = PublicKeyCredentialType.valueOf("custom");
PublicKeyCredentialType second = PublicKeyCredentialType.valueOf("custom");
assertThat(first).isEqualTo(second);
}
@Test
void publicKeyWhenSameValueThenHashCodeMatches() {
PublicKeyCredentialType first = PublicKeyCredentialType.valueOf("custom");
PublicKeyCredentialType second = PublicKeyCredentialType.valueOf("custom");
assertThat(first).hasSameHashCodeAs(second);
}
@Test
void publicKeyWhenDifferentValueThenNotEquals() {
assertThat(PublicKeyCredentialType.valueOf("custom-1"))
.isNotEqualTo(PublicKeyCredentialType.valueOf("custom-2"));
}
}
@@ -0,0 +1,47 @@
/*
* 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.web.webauthn.api;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
public final class SerializationTestUtils {
private SerializationTestUtils() {
}
@SuppressWarnings("unchecked")
public static <T extends Serializable> T serializeAndDeserialize(T object) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(object);
}
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
try (ObjectInputStream ois = new ObjectInputStream(bais)) {
return (T) ois.readObject();
}
}
catch (Exception ex) {
throw new RuntimeException("Serialization round-trip failed", ex);
}
}
}
@@ -0,0 +1,58 @@
/*
* 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.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class UserVerificationRequirementTests {
@Test
void requiredWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.REQUIRED))
.isSameAs(UserVerificationRequirement.REQUIRED);
}
@Test
void preferredWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.PREFERRED))
.isSameAs(UserVerificationRequirement.PREFERRED);
}
@Test
void discouragedWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.DISCOURAGED))
.isSameAs(UserVerificationRequirement.DISCOURAGED);
}
@Test
void requiredWhenSameValueThenEquals() {
assertThat(new UserVerificationRequirement("required")).isEqualTo(UserVerificationRequirement.REQUIRED);
}
@Test
void requiredWhenSameValueThenHashCodeMatches() {
assertThat(new UserVerificationRequirement("required")).hasSameHashCodeAs(UserVerificationRequirement.REQUIRED);
}
@Test
void requiredWhenDifferentValueThenNotEquals() {
assertThat(UserVerificationRequirement.REQUIRED).isNotEqualTo(UserVerificationRequirement.PREFERRED);
}
}