Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93019f889e | |||
| dfd440ad64 | |||
| 25bb4a1cd4 | |||
| 66a149066f | |||
| ea365cab06 | |||
| ae8ff1af43 | |||
| a298a2b8c9 | |||
| aac1e2e2fc | |||
| 556b139a02 | |||
| fbd2cce3ba | |||
| 40d0d19713 | |||
| 3fb2c68ff7 | |||
| 5900348174 | |||
| 307b460838 | |||
| 3b528e0c4c | |||
| c7f01cb974 | |||
| e4a5e21486 | |||
| b851af3e02 | |||
| 100e0407ee | |||
| 56835a04a1 | |||
| 1cbb3fc10d | |||
| 1f0c0fe204 |
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
require:
|
||||
members: false
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
workflow_dispatch: # Manual trigger
|
||||
|
||||
env:
|
||||
DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
uses: spring-io/spring-security-release-tools/.github/workflows/build.yml@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ ubuntu-latest, windows-latest ]
|
||||
jdk: [ 17 ]
|
||||
with:
|
||||
runs-on: ${{ matrix.os }}
|
||||
java-version: ${{ matrix.jdk }}
|
||||
distribution: temurin
|
||||
uses: spring-projects/spring-security-commercial/.github/workflows/build.yml@workflows/v1
|
||||
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:
|
||||
name: Deploy Artifacts
|
||||
needs: [ build ]
|
||||
uses: spring-io/spring-security-release-tools/.github/workflows/deploy-artifacts.yml@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
|
||||
with:
|
||||
should-deploy-artifacts: ${{ needs.build.outputs.should-deploy-artifacts }}
|
||||
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 }}
|
||||
needs: [ build, test, compute-version ]
|
||||
if: needs.compute-version.outputs.snapshot == 'true'
|
||||
uses: spring-projects/spring-security-commercial/.github/workflows/deploy-artifacts.yml@workflows/v1
|
||||
secrets: inherit
|
||||
send-notification:
|
||||
name: Send Notification
|
||||
needs: [ deploy-artifacts, deploy-schema ]
|
||||
needs: [ deploy-artifacts ]
|
||||
if: ${{ !success() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -8,44 +8,6 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
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: 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 }}
|
||||
uses: spring-projects/spring-security-commercial/.github/workflows/build-pull-request.yml@workflows/v1
|
||||
if: ${{ github.actor != 'dependabot[bot]' }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -25,6 +25,7 @@ apply plugin: 'org.jetbrains.kotlin.jvm'
|
||||
apply plugin: 'org.springframework.security.versions.verify-dependencies-versions'
|
||||
apply plugin: 'org.springframework.security.check-expected-branch-version'
|
||||
apply plugin: 'io.spring.security.release'
|
||||
apply from: 'commercial-settings.gradle'
|
||||
|
||||
group = 'org.springframework.security'
|
||||
description = 'Spring Security'
|
||||
@@ -39,10 +40,12 @@ repositories {
|
||||
}
|
||||
|
||||
springRelease {
|
||||
repositoryOwner = "spring-projects"
|
||||
repositoryName = "spring-security-commercial"
|
||||
weekOfMonth = 3
|
||||
dayOfWeek = 1
|
||||
referenceDocUrl = "https://docs.spring.io/spring-security/reference/{version}/index.html"
|
||||
apiDocUrl = "https://docs.spring.io/spring-security/reference/{version}/api/java/index.html"
|
||||
referenceDocUrl = "https://docs.spring.vmware.com/spring-security/reference/{version}/index.html"
|
||||
apiDocUrl = "https://docs.spring.vmware.com/spring-security/reference/{version}/api/java/index.html"
|
||||
replaceSnapshotVersionInReferenceDocUrl = true
|
||||
}
|
||||
|
||||
@@ -70,12 +73,6 @@ allprojects {
|
||||
}
|
||||
}
|
||||
|
||||
develocity {
|
||||
buildScan {
|
||||
termsOfUseUrl = 'https://gradle.com/help/legal-terms-of-use'
|
||||
termsOfUseAgree = 'yes'
|
||||
}
|
||||
}
|
||||
|
||||
nohttp {
|
||||
source.exclude "buildSrc/build/**", "javascript/.gradle/**", "javascript/package-lock.json", "javascript/node_modules/**", "javascript/build/**", "javascript/dist/**"
|
||||
|
||||
+10
-1
@@ -12,7 +12,16 @@ java {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
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) {
|
||||
maven {
|
||||
name = "Release Train"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -17,6 +17,7 @@
|
||||
package org.springframework.security.config.annotation.authentication.configurers.ldap;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
|
||||
import org.springframework.ldap.core.support.BaseLdapPathContextSource;
|
||||
@@ -590,7 +591,7 @@ public class LdapAuthenticationProviderConfigurer<B extends ProviderManagerBuild
|
||||
}
|
||||
|
||||
private int getDefaultPort() {
|
||||
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT)) {
|
||||
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT, 50, InetAddress.getLoopbackAddress())) {
|
||||
return serverSocket.getLocalPort();
|
||||
}
|
||||
catch (IOException ex) {
|
||||
|
||||
+3
-1
@@ -79,8 +79,10 @@ final class DPoPAuthenticationConfigurer<B extends HttpSecurityBuilder<B>>
|
||||
|
||||
@Override
|
||||
public void configure(B http) {
|
||||
DPoPAuthenticationProvider authenticationProvider = new DPoPAuthenticationProvider(
|
||||
getTokenAuthenticationManager(http));
|
||||
http.authenticationProvider(postProcess(authenticationProvider));
|
||||
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
|
||||
http.authenticationProvider(new DPoPAuthenticationProvider(getTokenAuthenticationManager(http)));
|
||||
AuthenticationFilter authenticationFilter = new AuthenticationFilter(authenticationManager,
|
||||
getAuthenticationConverter());
|
||||
authenticationFilter.setRequestMatcher(getRequestMatcher());
|
||||
|
||||
+3
@@ -33,6 +33,7 @@ import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationManagerResolver;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.ObjectPostProcessor;
|
||||
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
|
||||
@@ -298,6 +299,8 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
|
||||
|
||||
if (dPoPAuthenticationAvailable) {
|
||||
DPoPAuthenticationConfigurer<H> dPoPAuthenticationConfigurer = new DPoPAuthenticationConfigurer<>();
|
||||
dPoPAuthenticationConfigurer.withObjectPostProcessor(
|
||||
(ObjectPostProcessor<Object>) OAuth2ResourceServerConfigurer.this::postProcess);
|
||||
dPoPAuthenticationConfigurer.configure(http);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -17,6 +17,7 @@
|
||||
package org.springframework.security.config.ldap;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
|
||||
import org.w3c.dom.Element;
|
||||
@@ -188,7 +189,7 @@ public class LdapServerBeanDefinitionParser implements BeanDefinitionParser {
|
||||
}
|
||||
|
||||
private String getDefaultPort() {
|
||||
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT)) {
|
||||
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT, 50, InetAddress.getLoopbackAddress())) {
|
||||
return String.valueOf(serverSocket.getLocalPort());
|
||||
}
|
||||
catch (IOException ex) {
|
||||
|
||||
+31
-2
@@ -22,6 +22,7 @@ import java.security.interfaces.ECPrivateKey;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
@@ -30,6 +31,7 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.nimbusds.jose.jwk.ECKey;
|
||||
import com.nimbusds.jose.jwk.JWK;
|
||||
@@ -47,20 +49,26 @@ import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.ObjectPostProcessor;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.jose.TestJwks;
|
||||
import org.springframework.security.oauth2.jose.TestKeys;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.DPoPProofContext;
|
||||
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
|
||||
import org.springframework.security.oauth2.jwt.DPoPProofReplayValidator;
|
||||
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationProvider;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -245,8 +253,10 @@ public class DPoPAuthenticationConfigurerTests {
|
||||
.requestMatchers("/resource2").hasAnyAuthority("SCOPE_resource2.read", "SCOPE_resource2.write")
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2ResourceServer((oauth2) -> oauth2
|
||||
.jwt(Customizer.withDefaults()));
|
||||
.oauth2ResourceServer((oauth2ResourceServer) ->
|
||||
oauth2ResourceServer
|
||||
.jwt(Customizer.withDefaults())
|
||||
.withObjectPostProcessor(dPoPProofVerifierFactoryCustomizer()));
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
@@ -256,6 +266,25 @@ public class DPoPAuthenticationConfigurerTests {
|
||||
return NimbusJwtDecoder.withPublicKey(PROVIDER_RSA_PUBLIC_KEY).build();
|
||||
}
|
||||
|
||||
private ObjectPostProcessor<DPoPAuthenticationProvider> dPoPProofVerifierFactoryCustomizer() {
|
||||
return new ObjectPostProcessor<>() {
|
||||
@Override
|
||||
public <O extends DPoPAuthenticationProvider> O postProcess(O authenticationProvider) {
|
||||
DPoPProofReplayValidator.InMemoryCache inMemoryCache = new DPoPProofReplayValidator.InMemoryCache();
|
||||
inMemoryCache.setMaxSize(50_000);
|
||||
inMemoryCache.setMaxRequestsPerKey(500);
|
||||
DPoPProofReplayValidator dPoPProofReplayValidator = new DPoPProofReplayValidator(inMemoryCache);
|
||||
dPoPProofReplayValidator.setClockSkew(Duration.ofSeconds(60));
|
||||
Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = DPoPProofJwtDecoderFactory
|
||||
.createDefaultJwtValidatorFactory(Collections.singletonList(dPoPProofReplayValidator));
|
||||
DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory();
|
||||
dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory);
|
||||
authenticationProvider.setDPoPProofVerifierFactory(dPoPProofJwtDecoderFactory);
|
||||
return authenticationProvider;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@RestController
|
||||
|
||||
+1
-1
@@ -132,7 +132,7 @@ public class KeyBasedPersistenceTokenService implements TokenService, Initializi
|
||||
// Verification
|
||||
String content = creationTime + ":" + pseudoRandomNumber + ":" + extendedInfo.toString();
|
||||
String expectedSha512Hex = Sha512DigestUtils.shaHex(content + ":" + serverSecret);
|
||||
Assert.isTrue(expectedSha512Hex.equals(sha1Hex), "Key verification failure");
|
||||
Assert.isTrue(Utf8.isEqual(expectedSha512Hex, sha1Hex), "Key verification failure");
|
||||
return new DefaultToken(key, creationTime, extendedInfo.toString());
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ import java.nio.CharBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* UTF-8 Charset encoder/decoder.
|
||||
@@ -66,4 +69,21 @@ public final class Utf8 {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant time comparison to prevent against timing attacks.
|
||||
* @param expected the expected {@link CharSequence}
|
||||
* @param actual the actual {@link CharSequence}
|
||||
* @return true if {@code expected} and {@code actual} are equal, false otherwise
|
||||
* @since 5.7.26
|
||||
*/
|
||||
public static boolean isEqual(@Nullable CharSequence expected, @Nullable CharSequence actual) {
|
||||
byte[] expectedBytes = bytesUtf8(expected);
|
||||
byte[] actualBytes = bytesUtf8(actual);
|
||||
return MessageDigest.isEqual(expectedBytes, actualBytes);
|
||||
}
|
||||
|
||||
private static byte @Nullable [] bytesUtf8(@Nullable CharSequence s) {
|
||||
return (s != null) ? Utf8.encode(s) : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
@@ -37,7 +37,9 @@ import org.springframework.security.crypto.util.EncodingUtils;
|
||||
*
|
||||
* @author Keith Donald
|
||||
* @author Dave Syer
|
||||
* @deprecated Use {@link AesCbcBytesEncryptor} or {@link AesGcmBytesEncryptor} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public final class AesBytesEncryptor implements BytesEncryptor {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.crypto.encrypt;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.security.crypto.codec.Hex;
|
||||
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
import org.springframework.security.crypto.util.EncodingUtils;
|
||||
|
||||
/**
|
||||
* {@link BytesEncryptor} that uses 256-bit AES/CBC/PKCS5Padding with a random 16-byte
|
||||
* initialization vector. The IV is prepended to the ciphertext on encrypt and stripped on
|
||||
* decrypt.
|
||||
*
|
||||
* <p>
|
||||
* Note that CBC mode provides confidentiality but <em>not</em> integrity or authenticity.
|
||||
* Applications that require authenticated encryption should prefer
|
||||
* {@link AesGcmBytesEncryptor}. See the <a href=
|
||||
* "https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html">
|
||||
* OWASP Cryptographic Storage Cheat Sheet</a> for guidance on choosing a cipher mode.
|
||||
*
|
||||
* <p>
|
||||
* When key derivation is used via {@link #withPassword(String, CharSequence)}, the key is
|
||||
* derived using PBKDF2WithHmacSHA256 with {@code DEFAULT_PBKDF2_ITERATIONS} iterations
|
||||
* per the <a href=
|
||||
* "https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">
|
||||
* OWASP Password Storage Cheat Sheet</a>. Because derivation is intentionally expensive,
|
||||
* the encryptor instance should be created once and reused rather than constructed
|
||||
* per-operation.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 5.7.26
|
||||
* @see AesGcmBytesEncryptor
|
||||
* @see AesBytesEncryptor
|
||||
*/
|
||||
public final class AesCbcBytesEncryptor implements BytesEncryptor {
|
||||
|
||||
private static final String ALGORITHM = "AES/CBC/PKCS5Padding";
|
||||
|
||||
private static final int IV_LENGTH_BYTES = 16;
|
||||
|
||||
private static final int DEFAULT_PBKDF2_ITERATIONS = 600_000;
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
private final Cipher encryptor;
|
||||
|
||||
private final Cipher decryptor;
|
||||
|
||||
private final BytesKeyGenerator ivGenerator;
|
||||
|
||||
private AesCbcBytesEncryptor(SecretKey secretKey, BytesKeyGenerator ivGenerator) {
|
||||
this.secretKey = new SecretKeySpec(secretKey.getEncoded(), "AES");
|
||||
this.encryptor = CipherUtils.newCipher(ALGORITHM);
|
||||
this.decryptor = CipherUtils.newCipher(ALGORITHM);
|
||||
this.ivGenerator = ivGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encryptor that derives its key from the given password and hex-encoded
|
||||
* salt using PBKDF2WithHmacSHA1.
|
||||
* @param password the password value
|
||||
* @param salt the hex-encoded salt value
|
||||
*/
|
||||
public static Builder withPassword(String password, CharSequence salt) {
|
||||
return new Builder(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encryptor using the supplied {@link SecretKey}.
|
||||
* @param secretKey the secret (symmetric) key
|
||||
*/
|
||||
public static Builder withSecretKey(SecretKey secretKey) {
|
||||
return new Builder(secretKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] encrypt(byte[] bytes) {
|
||||
synchronized (this.encryptor) {
|
||||
byte[] iv = this.ivGenerator.generateKey();
|
||||
CipherUtils.initCipher(this.encryptor, Cipher.ENCRYPT_MODE, this.secretKey, new IvParameterSpec(iv));
|
||||
byte[] ciphertext = CipherUtils.doFinal(this.encryptor, bytes);
|
||||
return EncodingUtils.concatenate(iv, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] decrypt(byte[] encryptedBytes) {
|
||||
int ivLength = this.ivGenerator.getKeyLength();
|
||||
byte[] iv = EncodingUtils.subArray(encryptedBytes, 0, ivLength);
|
||||
byte[] ciphertext = EncodingUtils.subArray(encryptedBytes, ivLength, encryptedBytes.length);
|
||||
synchronized (this.decryptor) {
|
||||
CipherUtils.initCipher(this.decryptor, Cipher.DECRYPT_MODE, this.secretKey, new IvParameterSpec(iv));
|
||||
return CipherUtils.doFinal(this.decryptor, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
private static SecretKey deriveKey(String password, CharSequence salt) {
|
||||
return CipherUtils.newSecretKey("PBKDF2WithHmacSHA256",
|
||||
new PBEKeySpec(password.toCharArray(), Hex.decode(salt), DEFAULT_PBKDF2_ITERATIONS, 256));
|
||||
}
|
||||
|
||||
/**
|
||||
* A Builder for {@link AesCbcBytesEncryptor}.
|
||||
*/
|
||||
public static final class Builder {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
private BytesKeyGenerator ivGenerator = KeyGenerators.secureRandom(IV_LENGTH_BYTES);
|
||||
|
||||
private Builder(SecretKey secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
private Builder(String password, CharSequence salt) {
|
||||
this.secretKey = deriveKey(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link BytesKeyGenerator} to use for generating the initialization
|
||||
* vector.
|
||||
* @param ivGenerator the {@link BytesKeyGenerator} to use for generating the
|
||||
* initialization vector
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder ivGenerator(BytesKeyGenerator ivGenerator) {
|
||||
Objects.requireNonNull(ivGenerator, "ivGenerator cannot be null");
|
||||
this.ivGenerator = ivGenerator;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@link AesCbcBytesEncryptor}.
|
||||
* @return the {@link AesCbcBytesEncryptor}
|
||||
*/
|
||||
public AesCbcBytesEncryptor build() {
|
||||
return new AesCbcBytesEncryptor(this.secretKey, this.ivGenerator);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.crypto.encrypt;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.security.crypto.codec.Hex;
|
||||
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
import org.springframework.security.crypto.util.EncodingUtils;
|
||||
|
||||
/**
|
||||
* {@link BytesEncryptor} that uses 256-bit AES/GCM/NoPadding with a random 16-byte
|
||||
* initialization vector and a 128-bit authentication tag. The IV is prepended to the
|
||||
* ciphertext on encrypt and stripped on decrypt. GCM provides authenticated encryption
|
||||
* (AEAD): both confidentiality and integrity are protected, and decryption throws if the
|
||||
* ciphertext has been tampered with.
|
||||
*
|
||||
* <p>
|
||||
* This class uses a 16-byte (128-bit) IV rather than the 12-byte (96-bit) IV recommended
|
||||
* by NIST SP 800-38D for GCM. Both lengths are cryptographically valid; the 16-byte
|
||||
* choice maintains consistency with the rest of the Spring Security crypto module. For
|
||||
* additional guidance, please see the <a href=
|
||||
* "https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html">
|
||||
* OWASP Cryptographic Storage Cheat Sheet</a>.
|
||||
*
|
||||
* <p>
|
||||
* When key derivation is used via {@link #withPassword(String, CharSequence)}, the key is
|
||||
* derived using PBKDF2WithHmacSHA256 with {@code DEFAULT_PBKDF2_ITERATIONS} iterations
|
||||
* per the <a href=
|
||||
* "https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">
|
||||
* OWASP Password Storage Cheat Sheet</a>. Because derivation is intentionally expensive,
|
||||
* the encryptor instance should be created once and reused rather than constructed
|
||||
* per-operation.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 5.7.26
|
||||
* @see AesCbcBytesEncryptor
|
||||
* @see AesBytesEncryptor
|
||||
*/
|
||||
public final class AesGcmBytesEncryptor implements BytesEncryptor {
|
||||
|
||||
private static final String ALGORITHM = "AES/GCM/NoPadding";
|
||||
|
||||
private static final int IV_LENGTH_BYTES = 16;
|
||||
|
||||
private static final int TAG_LENGTH_BITS = 128;
|
||||
|
||||
private static final int DEFAULT_PBKDF2_ITERATIONS = 600_000;
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
private final Cipher encryptor;
|
||||
|
||||
private final Cipher decryptor;
|
||||
|
||||
private final BytesKeyGenerator ivGenerator;
|
||||
|
||||
private AesGcmBytesEncryptor(SecretKey secretKey, BytesKeyGenerator ivGenerator) {
|
||||
this.secretKey = new SecretKeySpec(secretKey.getEncoded(), "AES");
|
||||
this.encryptor = CipherUtils.newCipher(ALGORITHM);
|
||||
this.decryptor = CipherUtils.newCipher(ALGORITHM);
|
||||
this.ivGenerator = ivGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encryptor that derives its key from the given password and hex-encoded
|
||||
* salt using PBKDF2WithHmacSHA1.
|
||||
* @param password the password value
|
||||
* @param salt the hex-encoded salt value
|
||||
*/
|
||||
public static Builder withPassword(String password, CharSequence salt) {
|
||||
return new Builder(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encryptor using the supplied {@link SecretKey}.
|
||||
* @param secretKey the secret (symmetric) key
|
||||
*/
|
||||
public static Builder withSecretKey(SecretKey secretKey) {
|
||||
return new Builder(secretKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] encrypt(byte[] bytes) {
|
||||
synchronized (this.encryptor) {
|
||||
byte[] iv = this.ivGenerator.generateKey();
|
||||
CipherUtils.initCipher(this.encryptor, Cipher.ENCRYPT_MODE, this.secretKey,
|
||||
new GCMParameterSpec(TAG_LENGTH_BITS, iv));
|
||||
byte[] ciphertext = CipherUtils.doFinal(this.encryptor, bytes);
|
||||
return EncodingUtils.concatenate(iv, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] decrypt(byte[] encryptedBytes) {
|
||||
int ivLength = this.ivGenerator.getKeyLength();
|
||||
byte[] iv = EncodingUtils.subArray(encryptedBytes, 0, ivLength);
|
||||
byte[] ciphertext = EncodingUtils.subArray(encryptedBytes, ivLength, encryptedBytes.length);
|
||||
synchronized (this.decryptor) {
|
||||
CipherUtils.initCipher(this.decryptor, Cipher.DECRYPT_MODE, this.secretKey,
|
||||
new GCMParameterSpec(TAG_LENGTH_BITS, iv));
|
||||
return CipherUtils.doFinal(this.decryptor, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
private static SecretKey deriveKey(String password, CharSequence salt) {
|
||||
return CipherUtils.newSecretKey("PBKDF2WithHmacSHA256",
|
||||
new PBEKeySpec(password.toCharArray(), Hex.decode(salt), DEFAULT_PBKDF2_ITERATIONS, 256));
|
||||
}
|
||||
|
||||
/**
|
||||
* A Builder for {@link AesGcmBytesEncryptor}.
|
||||
*/
|
||||
public static final class Builder {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
private BytesKeyGenerator ivGenerator = KeyGenerators.secureRandom(IV_LENGTH_BYTES);
|
||||
|
||||
private Builder(SecretKey secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
private Builder(String password, CharSequence salt) {
|
||||
this.secretKey = deriveKey(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link BytesKeyGenerator} to use for generating the initialization
|
||||
* vector.
|
||||
* @param ivGenerator the {@link BytesKeyGenerator} to use for generating the
|
||||
* initialization vector
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder ivGenerator(BytesKeyGenerator ivGenerator) {
|
||||
Objects.requireNonNull(ivGenerator, "ivGenerator cannot be null");
|
||||
this.ivGenerator = ivGenerator;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@link AesGcmBytesEncryptor}.
|
||||
* @return the {@link AesGcmBytesEncryptor}
|
||||
*/
|
||||
public AesGcmBytesEncryptor build() {
|
||||
return new AesGcmBytesEncryptor(this.secretKey, this.ivGenerator);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
package org.springframework.security.crypto.encrypt;
|
||||
|
||||
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
|
||||
/**
|
||||
@@ -42,9 +41,14 @@ public final class Encryptors {
|
||||
* not be shared
|
||||
* @param salt a hex-encoded, random, site-global salt value to use to generate the
|
||||
* key
|
||||
* @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)}
|
||||
* instead.
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public static BytesEncryptor stronger(CharSequence password, CharSequence salt) {
|
||||
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16), CipherAlgorithm.GCM);
|
||||
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16),
|
||||
AesBytesEncryptor.CipherAlgorithm.GCM);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,18 +57,16 @@ public final class Encryptors {
|
||||
* Function #2). Salts the password to prevent dictionary attacks against the key. The
|
||||
* provided salt is expected to be hex-encoded; it should be random and at least 8
|
||||
* bytes in length. Also applies a random 16-byte initialization vector to ensure each
|
||||
* encrypted message will be unique. Requires Java 6. NOTE: This mode is not
|
||||
* <a href="https://en.wikipedia.org/wiki/Authenticated_encryption">authenticated</a>
|
||||
* and does not provide any guarantees about the authenticity of the data. For a more
|
||||
* secure alternative, users should prefer
|
||||
* {@link #stronger(CharSequence, CharSequence)}.
|
||||
* encrypted message will be unique. Requires Java 6.
|
||||
* @param password the password used to generate the encryptor's secret key; should
|
||||
* not be shared
|
||||
* @param salt a hex-encoded, random, site-global salt value to use to generate the
|
||||
* key
|
||||
*
|
||||
* @see Encryptors#stronger(CharSequence, CharSequence)
|
||||
* @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)}
|
||||
* instead.
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public static BytesEncryptor standard(CharSequence password, CharSequence salt) {
|
||||
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16));
|
||||
}
|
||||
@@ -74,8 +76,12 @@ public final class Encryptors {
|
||||
* text is hex-encoded.
|
||||
* @param password the password used to generate the encryptor's secret key; should
|
||||
* not be shared
|
||||
* @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)}
|
||||
* instead.
|
||||
* @see Encryptors#stronger(CharSequence, CharSequence)
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public static TextEncryptor delux(CharSequence password, CharSequence salt) {
|
||||
return new HexEncodingTextEncryptor(stronger(password, salt));
|
||||
}
|
||||
@@ -85,8 +91,12 @@ public final class Encryptors {
|
||||
* text is hex-encoded.
|
||||
* @param password the password used to generate the encryptor's secret key; should
|
||||
* not be shared
|
||||
* @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)}
|
||||
* instead.
|
||||
* @see Encryptors#standard(CharSequence, CharSequence)
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public static TextEncryptor text(CharSequence password, CharSequence salt) {
|
||||
return new HexEncodingTextEncryptor(standard(password, salt));
|
||||
}
|
||||
|
||||
+1
-11
@@ -16,8 +16,6 @@
|
||||
|
||||
package org.springframework.security.crypto.password;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import org.springframework.security.crypto.codec.Utf8;
|
||||
@@ -39,15 +37,7 @@ final class PasswordEncoderUtils {
|
||||
* @return
|
||||
*/
|
||||
static boolean equals(String expected, @Nullable String actual) {
|
||||
byte[] expectedBytes = bytesUtf8(expected);
|
||||
byte[] actualBytes = bytesUtf8(actual);
|
||||
return MessageDigest.isEqual(expectedBytes, actualBytes);
|
||||
}
|
||||
|
||||
private static byte @Nullable [] bytesUtf8(@Nullable String s) {
|
||||
// need to check if Utf8.encode() runs in constant time (probably not).
|
||||
// This may leak length of string.
|
||||
return (s != null) ? Utf8.encode(s) : null;
|
||||
return Utf8.isEqual(expected, actual);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
-1
@@ -24,6 +24,7 @@ import com.password4j.BalloonHashingFunction;
|
||||
import com.password4j.Hash;
|
||||
import com.password4j.Password;
|
||||
|
||||
import org.springframework.security.crypto.codec.Utf8;
|
||||
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
@@ -140,7 +141,7 @@ public class BalloonHashingPassword4jPasswordEncoder extends AbstractValidatingP
|
||||
String expectedHash = parts[1];
|
||||
|
||||
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
|
||||
return expectedHash.equals(hash.getResult());
|
||||
return Utf8.isEqual(expectedHash, hash.getResult());
|
||||
}
|
||||
catch (IllegalArgumentException ex) {
|
||||
// Invalid Base64 encoding
|
||||
|
||||
+2
-1
@@ -24,6 +24,7 @@ import com.password4j.Hash;
|
||||
import com.password4j.PBKDF2Function;
|
||||
import com.password4j.Password;
|
||||
|
||||
import org.springframework.security.crypto.codec.Utf8;
|
||||
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
@@ -138,7 +139,7 @@ public class Pbkdf2Password4jPasswordEncoder extends AbstractValidatingPasswordE
|
||||
String expectedHash = parts[1];
|
||||
|
||||
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
|
||||
return expectedHash.equals(hash.getResult());
|
||||
return Utf8.isEqual(expectedHash, hash.getResult());
|
||||
}
|
||||
catch (IllegalArgumentException ex) {
|
||||
// Invalid Base64 encoding
|
||||
|
||||
@@ -37,4 +37,48 @@ public class Utf8Tests {
|
||||
assertThat(decoded).isEqualTo("6048b75ed560785c");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenDifferentLengthThenFalse() {
|
||||
assertThat(Utf8.isEqual("abc", "a")).isFalse();
|
||||
assertThat(Utf8.isEqual("a", "abc")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenNullAndNotEmptyThenFalse() {
|
||||
assertThat(Utf8.isEqual(null, "a")).isFalse();
|
||||
assertThat(Utf8.isEqual("a", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenNullAndNullThenTrue() {
|
||||
assertThat(Utf8.isEqual(null, null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenNullAndEmptyThenFalse() {
|
||||
assertThat(Utf8.isEqual(null, "")).isFalse();
|
||||
assertThat(Utf8.isEqual("", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenNotEmptyAndEmptyThenFalse() {
|
||||
assertThat(Utf8.isEqual("abc", "")).isFalse();
|
||||
assertThat(Utf8.isEqual("", "abc")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenEmptyAndEmptyThenTrue() {
|
||||
assertThat(Utf8.isEqual("", "")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenDifferentCaseThenFalse() {
|
||||
assertThat(Utf8.isEqual("aBc", "abc")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenSameThenTrue() {
|
||||
assertThat(Utf8.isEqual("abcdef", "abcdef")).isTrue();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.crypto.encrypt;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.security.crypto.codec.Hex;
|
||||
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm;
|
||||
import org.springframework.security.crypto.util.EncodingUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Tests for {@link AesCbcBytesEncryptor}.
|
||||
*/
|
||||
class AesCbcBytesEncryptorTests {
|
||||
|
||||
private final String secret = "value";
|
||||
|
||||
private final String password = "password";
|
||||
|
||||
private final String hexSalt = "deadbeef";
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingPasswordAndSaltThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingSecretKeyThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey secretKey = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(secretKey).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptWhenUsingMockIvThenProducesKnownCiphertext() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
BytesKeyGenerator mockGenerator = mock(BytesKeyGenerator.class);
|
||||
given(mockGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
|
||||
given(mockGenerator.getKeyLength()).willReturn(16);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt)
|
||||
.ivGenerator(mockGenerator)
|
||||
.build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(Hex.encode(encrypted)).isEqualTo("4b0febebd439db7ca77153cb254520c3b7232ac29355d07869433f1ecf55fe94");
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptProducesUniqueOutputAndIvIsPrePended() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] first = encryptor.encrypt(this.secret.getBytes());
|
||||
byte[] second = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(first).isNotEqualTo(second);
|
||||
assertThat(first.length).isGreaterThan(16);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void migratesFromDeprecatedNullIvCbcToAesCbcBytesEncryptor() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt);
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
|
||||
AesCbcBytesEncryptor modern = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
BytesEncryptor migrating = new MigratingBytesEncryptor("{CBC}", modern, deprecated);
|
||||
|
||||
assertThat(new String(migrating.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
|
||||
byte[] migrated = migrating.encrypt(this.secret.getBytes());
|
||||
assertThat(migrated[0]).isEqualTo((byte) '{');
|
||||
assertThat(migrated[1]).isEqualTo((byte) 'C');
|
||||
assertThat(migrated[2]).isEqualTo((byte) 'B');
|
||||
assertThat(migrated[3]).isEqualTo((byte) 'C');
|
||||
assertThat(migrated[4]).isEqualTo((byte) '}');
|
||||
assertThat(new String(migrating.decrypt(migrated))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void withSecretWhenAesBytesEncryptorEncryptsThenDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
|
||||
AesBytesEncryptor.CipherAlgorithm.CBC);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(key).build();
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void aesBytesEncryptorWhenEncryptsThenAesCbcBytesEncryptorDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(key).build();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
|
||||
AesBytesEncryptor.CipherAlgorithm.CBC);
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(deprecated.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingCustomIvGeneratorThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
BytesKeyGenerator customIvGenerator = mock(BytesKeyGenerator.class);
|
||||
given(customIvGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
|
||||
given(customIvGenerator.getKeyLength()).willReturn(16);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt)
|
||||
.ivGenerator(customIvGenerator)
|
||||
.build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void withPasswordDerivesADifferentKeyThanAesBytesEncryptor() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt,
|
||||
KeyGenerators.secureRandom(16));
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
|
||||
}
|
||||
|
||||
private static final class MigratingBytesEncryptor implements BytesEncryptor {
|
||||
|
||||
private final byte[] prefix;
|
||||
|
||||
private final BytesEncryptor write;
|
||||
|
||||
private final BytesEncryptor deprecated;
|
||||
|
||||
MigratingBytesEncryptor(String prefix, BytesEncryptor write, BytesEncryptor deprecated) {
|
||||
this.prefix = prefix.getBytes(StandardCharsets.US_ASCII);
|
||||
this.write = write;
|
||||
this.deprecated = deprecated;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] encrypt(byte[] bytes) {
|
||||
return EncodingUtils.concatenate(this.prefix, this.write.encrypt(bytes));
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] decrypt(byte[] encryptedBytes) {
|
||||
if (startsWith(encryptedBytes, this.prefix)) {
|
||||
byte[] bytes = EncodingUtils.subArray(encryptedBytes, this.prefix.length, encryptedBytes.length);
|
||||
return this.write.decrypt(bytes);
|
||||
}
|
||||
return this.deprecated.decrypt(encryptedBytes);
|
||||
}
|
||||
|
||||
private static boolean startsWith(byte[] data, byte[] prefix) {
|
||||
if (data.length < prefix.length) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < prefix.length; i++) {
|
||||
if (data[i] != prefix[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.crypto.encrypt;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.security.crypto.codec.Hex;
|
||||
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Tests for {@link AesGcmBytesEncryptor}.
|
||||
*/
|
||||
class AesGcmBytesEncryptorTests {
|
||||
|
||||
private final String secret = "value";
|
||||
|
||||
private final String password = "password";
|
||||
|
||||
private final String hexSalt = "deadbeef";
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingPasswordAndSaltThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingSecretKeyThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey secretKey = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(secretKey).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptWhenUsingMockIvThenProducesKnownCiphertext() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
BytesKeyGenerator mockGenerator = mock(BytesKeyGenerator.class);
|
||||
given(mockGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
|
||||
given(mockGenerator.getKeyLength()).willReturn(16);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt)
|
||||
.ivGenerator(mockGenerator)
|
||||
.build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(Hex.encode(encrypted))
|
||||
.isEqualTo("4b0febebd439db7ca77153cb254520c3e4d61ae38207b4e42b820d311dc3d4e0e2f37ed5ee");
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptProducesUniqueOutputAndIvIsPrepended() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] first = encryptor.encrypt(this.secret.getBytes());
|
||||
byte[] second = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(first).isNotEqualTo(second);
|
||||
assertThat(first.length).isGreaterThan(32);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void withSecretWhenAesBytesEncryptorEncryptsThenDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
|
||||
AesBytesEncryptor.CipherAlgorithm.GCM);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(key).build();
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void aesBytesEncryptorWhenEncryptsThenAesGcmBytesEncryptorDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(key)
|
||||
.ivGenerator(KeyGenerators.secureRandom(12))
|
||||
.build();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(12),
|
||||
AesBytesEncryptor.CipherAlgorithm.GCM);
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(deprecated.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingCustomIvGeneratorLengthThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt)
|
||||
.ivGenerator(KeyGenerators.secureRandom(12))
|
||||
.build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void withPasswordDerivesADifferentKeyThanAesBytesEncryptor() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt,
|
||||
KeyGenerators.secureRandom(16), AesBytesEncryptor.CipherAlgorithm.GCM);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decryptDetectsAuthenticationTagTampering() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
encrypted[17] ^= 0xFF;
|
||||
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
|
||||
}
|
||||
|
||||
}
|
||||
+4
-6
@@ -24,25 +24,23 @@ import javax.crypto.NoSuchPaddingException;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.opentest4j.TestAbortedException;
|
||||
|
||||
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
|
||||
|
||||
public final class CryptoAssumptions {
|
||||
|
||||
private CryptoAssumptions() {
|
||||
}
|
||||
|
||||
public static void assumeGCMJCE() {
|
||||
assumeAes256(CipherAlgorithm.GCM);
|
||||
assumeAes256("GCM");
|
||||
}
|
||||
|
||||
public static void assumeCBCJCE() {
|
||||
assumeAes256(CipherAlgorithm.CBC);
|
||||
assumeAes256("CBC");
|
||||
}
|
||||
|
||||
private static void assumeAes256(CipherAlgorithm cipherAlgorithm) {
|
||||
private static void assumeAes256(String cipherAlgorithm) {
|
||||
boolean aes256Available = false;
|
||||
try {
|
||||
Cipher.getInstance(cipherAlgorithm.toString());
|
||||
Cipher.getInstance(cipherAlgorithm);
|
||||
aes256Available = Cipher.getMaxAllowedKeyLength("AES") >= 256;
|
||||
}
|
||||
catch (NoSuchAlgorithmException ex) {
|
||||
|
||||
@@ -8,8 +8,8 @@ The code is distributed as part of the core module but has no dependencies on an
|
||||
|
||||
[[spring-security-crypto-encryption]]
|
||||
== Encryptors
|
||||
The javadoc:org.springframework.security.crypto.encrypt.Encryptors[] class provides factory methods for constructing symmetric encryptors.
|
||||
This class lets you create javadoc:org.springframework.security.crypto.encrypt.BytesEncryptor[] instances to encrypt data in raw `byte[]` form.
|
||||
Spring Security provides javadoc:org.springframework.security.crypto.encrypt.AesGcmBytesEncryptor[] and javadoc:org.springframework.security.crypto.encrypt.AesCbcBytesEncryptor[] for constructing symmetric encryptors.
|
||||
These can be used to encrypt data in raw `byte[]` form.
|
||||
You can also construct javadoc:org.springframework.security.crypto.encrypt.TextEncryptor[] instances to encrypt text strings.
|
||||
Encryptors are thread-safe.
|
||||
|
||||
@@ -20,7 +20,7 @@ Both `BytesEncryptor` and `TextEncryptor` are interfaces. `BytesEncryptor` has m
|
||||
|
||||
[[spring-security-crypto-encryption-bytes]]
|
||||
=== BytesEncryptor
|
||||
You can use the `Encryptors.stronger` factory method to construct a `BytesEncryptor`:
|
||||
Use `AesGcmBytesEncryptor` to construct a BytesEncryptor with authenticated encryption:
|
||||
|
||||
.BytesEncryptor
|
||||
[tabs]
|
||||
@@ -29,24 +29,22 @@ Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
Encryptors.stronger("password", "salt");
|
||||
AesGcmBytesEncryptor.withPassword("password", "salt").build();
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
Encryptors.stronger("password", "salt")
|
||||
AesGcmBytesEncryptor.withPassword("password", "salt").build()
|
||||
----
|
||||
======
|
||||
|
||||
The `stronger` encryption method creates an encryptor by using 256-bit AES encryption with
|
||||
Galois Counter Mode (GCM).
|
||||
It derives the secret key by using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2).
|
||||
This method requires Java 6.
|
||||
The password used to generate the `SecretKey` should be kept in a secure place and should not be shared.
|
||||
The salt is used to prevent dictionary attacks against the key in the event that your encrypted data is compromised.
|
||||
A 16-byte random initialization vector is also applied so that each encrypted message is unique.
|
||||
`AesGcmBytesEncryptor` uses 256-bit AES encryption with Galois Counter Mode (GCM), providing https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated encryption] (AEAD).
|
||||
It derives the secret key using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2).
|
||||
The password used to generate the SecretKey should be kept in a secure place and not be shared.
|
||||
The salt is used to prevent dictionary attacks against the key in the event your encrypted data is compromised.
|
||||
A 16-byte random initialization vector is also applied so each encrypted message is unique.
|
||||
|
||||
The provided salt should be in hex-encoded String form, be random, and be at least 8 bytes in length.
|
||||
You can generate such a salt by using a `KeyGenerator`:
|
||||
@@ -69,14 +67,33 @@ val salt = KeyGenerators.string().generateKey() // generates a random 8-byte sal
|
||||
----
|
||||
======
|
||||
|
||||
You can also use the `standard` encryption method, which is 256-bit AES in Cipher Block Chaining (CBC) Mode.
|
||||
Users who require CBC mode may use `AesCbcBytesEncryptor`:
|
||||
|
||||
.AesCbcBytesEncryptor
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
AesCbcBytesEncryptor.withPassword("password", "salt").build();
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
AesCbcBytesEncryptor.withPassword("password", "salt").build()
|
||||
----
|
||||
======
|
||||
|
||||
This mode is not https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated] and does not provide any
|
||||
guarantees about the authenticity of the data.
|
||||
For a more secure alternative, use `Encryptors.stronger`.
|
||||
For a more secure alternative, users should prefer `AesGcmBytesEncryptor`.
|
||||
|
||||
[[spring-security-crypto-encryption-text]]
|
||||
=== TextEncryptor
|
||||
You can use the `Encryptors.text` factory method to construct a standard TextEncryptor:
|
||||
Use `AesCbcBytesEncryptor` to encrypt text data:
|
||||
|
||||
.TextEncryptor
|
||||
[tabs]
|
||||
@@ -85,19 +102,22 @@ Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
Encryptors.text("password", "salt");
|
||||
AesCbcBytesEncryptor.withPassword("password", "salt").build();
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
Encryptors.text("password", "salt")
|
||||
AesCbcBytesEncryptor.withPassword("password", "salt").build()
|
||||
----
|
||||
======
|
||||
|
||||
A `TextEncryptor` uses a standard `BytesEncryptor` to encrypt text data.
|
||||
Encrypted results are returned as hex-encoded strings for easy storage on the filesystem or in a database.
|
||||
`AesCbcBytesEncryptor` encrypts data as raw bytes.
|
||||
You can hex-encode the result for easy storage on the filesystem or in the database.
|
||||
|
||||
NOTE: Queryable text encryption (encrypting such that the same plaintext always produces the same ciphertext) is no longer recommended, as it relies on a fixed initialization vector and does not provide adequate security.
|
||||
Instead, look to your data store for a mechanism to query encrypted data.
|
||||
|
||||
[[spring-security-crypto-keygenerators]]
|
||||
== Key Generators
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
# limitations under the License.
|
||||
#
|
||||
springBootVersion=4.0.0-SNAPSHOT
|
||||
version=7.0.7-SNAPSHOT
|
||||
version=7.0.7
|
||||
samplesBranch=main
|
||||
org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.parallel=true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
com-squareup-okhttp3 = "3.14.9"
|
||||
io-micrometer = "1.16.6"
|
||||
io-micrometer = "1.16.7"
|
||||
io-rsocket = "1.1.5"
|
||||
io-spring-javaformat = "0.0.47"
|
||||
io-spring-nohttp = "0.0.11"
|
||||
@@ -13,7 +13,7 @@ org-jetbrains-kotlin = "2.2.21"
|
||||
org-jetbrains-kotlinx = "1.10.2"
|
||||
org-mockito = "5.17.0"
|
||||
org-opensaml5 = "5.1.6"
|
||||
org-springframework = "7.0.8"
|
||||
org-springframework = "7.0.9"
|
||||
com-password4j = "1.8.4"
|
||||
|
||||
[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-observation = { module = "io.micrometer:micrometer-observation", version.ref = "io-micrometer" }
|
||||
io-mockk = "io.mockk:mockk:1.14.11"
|
||||
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2025.0.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-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" }
|
||||
@@ -83,8 +83,8 @@ org-seleniumhq-selenium-selenium-support = "org.seleniumhq.selenium:selenium-sup
|
||||
org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3"
|
||||
org-slf4j-log4j-over-slf4j = "org.slf4j:log4j-over-slf4j:1.7.36"
|
||||
org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.18"
|
||||
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2025.1.6"
|
||||
org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.0.4"
|
||||
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2025.1.7"
|
||||
org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.0.5"
|
||||
org-springframework-spring-framework-bom = { module = "org.springframework:spring-framework-bom", version.ref = "org-springframework" }
|
||||
org-synchronoss-cloud-nio-multipart-parser = "org.synchronoss.cloud:nio-multipart-parser:1.1.0"
|
||||
tools-jackson-jackson-bom = "tools.jackson:jackson-bom:3.0.4"
|
||||
|
||||
+20
@@ -21,9 +21,11 @@ import java.net.ServerSocket;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -48,6 +50,24 @@ public class UnboundIdContainerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startLdapServerThenListenerBindsToLoopbackAddressOnly() throws Exception {
|
||||
UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null);
|
||||
server.setApplicationContext(new GenericApplicationContext());
|
||||
List<Integer> ports = getDefaultPorts(1);
|
||||
server.setPort(ports.get(0));
|
||||
|
||||
try {
|
||||
server.afterPropertiesSet();
|
||||
InMemoryDirectoryServer directoryServer = (InMemoryDirectoryServer) ReflectionTestUtils.getField(server,
|
||||
"directoryServer");
|
||||
assertThat(directoryServer.getListenAddress().isLoopbackAddress()).isTrue();
|
||||
}
|
||||
finally {
|
||||
server.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void afterPropertiesSetWhenPortIsZeroThenRandomPortIsSelected() throws Exception {
|
||||
UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package org.springframework.security.ldap.server;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
|
||||
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
|
||||
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
|
||||
@@ -96,7 +97,8 @@ public class UnboundIdContainer
|
||||
try {
|
||||
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(this.defaultPartitionSuffix);
|
||||
config.addAdditionalBindCredentials("uid=admin,ou=system", "secret");
|
||||
config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("LDAP", this.port));
|
||||
config.setListenerConfigs(
|
||||
InMemoryListenerConfig.createLDAPConfig("LDAP", InetAddress.getLoopbackAddress(), this.port, null));
|
||||
config.setEnforceSingleStructuralObjectClass(false);
|
||||
config.setEnforceAttributeSyntaxCompliance(true);
|
||||
DN dn = new DN(this.defaultPartitionSuffix);
|
||||
|
||||
+8
-7
@@ -24,6 +24,7 @@ import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.crypto.codec.Utf8;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
||||
@@ -192,38 +193,38 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza
|
||||
}
|
||||
|
||||
private static boolean matchesState(OAuth2Authorization authorization, String token) {
|
||||
return token.equals(authorization.getAttribute(OAuth2ParameterNames.STATE));
|
||||
return Utf8.isEqual(authorization.getAttribute(OAuth2ParameterNames.STATE), token);
|
||||
}
|
||||
|
||||
private static boolean matchesAuthorizationCode(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = authorization
|
||||
.getToken(OAuth2AuthorizationCode.class);
|
||||
return authorizationCode != null && authorizationCode.getToken().getTokenValue().equals(token);
|
||||
return authorizationCode != null && Utf8.isEqual(authorizationCode.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesAccessToken(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2AccessToken> accessToken = authorization.getToken(OAuth2AccessToken.class);
|
||||
return accessToken != null && accessToken.getToken().getTokenValue().equals(token);
|
||||
return accessToken != null && Utf8.isEqual(accessToken.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesRefreshToken(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = authorization.getToken(OAuth2RefreshToken.class);
|
||||
return refreshToken != null && refreshToken.getToken().getTokenValue().equals(token);
|
||||
return refreshToken != null && Utf8.isEqual(refreshToken.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesIdToken(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OidcIdToken> idToken = authorization.getToken(OidcIdToken.class);
|
||||
return idToken != null && idToken.getToken().getTokenValue().equals(token);
|
||||
return idToken != null && Utf8.isEqual(idToken.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode = authorization.getToken(OAuth2DeviceCode.class);
|
||||
return deviceCode != null && deviceCode.getToken().getTokenValue().equals(token);
|
||||
return deviceCode != null && Utf8.isEqual(deviceCode.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesUserCode(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2UserCode> userCode = authorization.getToken(OAuth2UserCode.class);
|
||||
return userCode != null && userCode.getToken().getTokenValue().equals(token);
|
||||
return userCode != null && Utf8.isEqual(userCode.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
@SuppressWarnings("serial")
|
||||
|
||||
+20
-10
@@ -29,6 +29,7 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
|
||||
/**
|
||||
* For internal use only.
|
||||
@@ -56,6 +57,12 @@ final class DefaultConsentPage {
|
||||
private static String generateConsentPage(HttpServletRequest request, String clientId, Authentication principal,
|
||||
Set<String> requestedScopes, Set<String> authorizedScopes, String state,
|
||||
Map<String, String> additionalParameters) {
|
||||
|
||||
String encodedClientId = HtmlUtils.htmlEscape(clientId);
|
||||
String encodedState = HtmlUtils.htmlEscape(state);
|
||||
String encodedPrincipalName = HtmlUtils.htmlEscape(principal.getName());
|
||||
String encodedRequestUri = HtmlUtils.htmlEscape(request.getRequestURI());
|
||||
|
||||
Set<String> scopesToAuthorize = new HashSet<>();
|
||||
Set<String> scopesPreviouslyAuthorized = new HashSet<>();
|
||||
for (String scope : requestedScopes) {
|
||||
@@ -74,6 +81,7 @@ final class DefaultConsentPage {
|
||||
// the "user_code" being displayed on the device to confirm they are
|
||||
// authorizing the correct device.
|
||||
String userCode = additionalParameters.get(OAuth2ParameterNames.USER_CODE);
|
||||
String encodedUserCode = (userCode != null) ? HtmlUtils.htmlEscape(userCode) : null;
|
||||
|
||||
// @formatter:off
|
||||
StringBuilder builder = new StringBuilder();
|
||||
@@ -98,13 +106,13 @@ final class DefaultConsentPage {
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"row\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <p><span class=\"font-weight-bold text-primary\">" + clientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + principal.getName() + "</span></p>");
|
||||
builder.append(" <p><span class=\"font-weight-bold text-primary\">" + encodedClientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + encodedPrincipalName + "</span></p>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
if (userCode != null) {
|
||||
builder.append(" <div class=\"row\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + userCode + "</span>. Verify that this code matches what is shown on your device.</p>");
|
||||
builder.append(" <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + encodedUserCode + "</span>. Verify that this code matches what is shown on your device.</p>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
}
|
||||
@@ -115,26 +123,28 @@ final class DefaultConsentPage {
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"row\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + request.getRequestURI() + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + clientId + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + state + "\">");
|
||||
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + encodedRequestUri + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + encodedClientId + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + encodedState + "\">");
|
||||
if (userCode != null) {
|
||||
builder.append(" <input type=\"hidden\" name=\"user_code\" value=\"" + userCode + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"user_code\" value=\"" + encodedUserCode + "\">");
|
||||
}
|
||||
|
||||
for (String scope : scopesToAuthorize) {
|
||||
String encodedScope = HtmlUtils.htmlEscape(scope);
|
||||
builder.append(" <div class=\"form-group form-check py-1\">");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + scope + "\" id=\"" + scope + "\">");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + encodedScope + "\" id=\"" + encodedScope + "\">");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + encodedScope + "\">" + encodedScope + "</label>");
|
||||
builder.append(" </div>");
|
||||
}
|
||||
|
||||
if (!scopesPreviouslyAuthorized.isEmpty()) {
|
||||
builder.append(" <p>You have already granted the following permissions to the above app:</p>");
|
||||
for (String scope : scopesPreviouslyAuthorized) {
|
||||
String encodedScope = HtmlUtils.htmlEscape(scope);
|
||||
builder.append(" <div class=\"form-group form-check py-1\">");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + scope + "\" checked disabled>");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + encodedScope + "\" checked disabled>");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + encodedScope + "\">" + encodedScope + "</label>");
|
||||
builder.append(" </div>");
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -25,7 +25,9 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2UserCode;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
|
||||
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
|
||||
@@ -60,6 +62,10 @@ public class InMemoryOAuth2AuthorizationServiceTests {
|
||||
|
||||
private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN);
|
||||
|
||||
private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE);
|
||||
|
||||
private static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE);
|
||||
|
||||
private InMemoryOAuth2AuthorizationService authorizationService;
|
||||
|
||||
@BeforeEach
|
||||
@@ -313,6 +319,44 @@ public class InMemoryOAuth2AuthorizationServiceTests {
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findByTokenWhenDeviceCodeExistsThenFound() {
|
||||
OAuth2DeviceCode deviceCode = new OAuth2DeviceCode("device-code", Instant.now(),
|
||||
Instant.now().plus(5, ChronoUnit.MINUTES));
|
||||
OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
|
||||
.id(ID)
|
||||
.principalName(PRINCIPAL_NAME)
|
||||
.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
|
||||
.token(deviceCode)
|
||||
.build();
|
||||
this.authorizationService.save(authorization);
|
||||
|
||||
OAuth2Authorization result = this.authorizationService.findByToken(deviceCode.getTokenValue(),
|
||||
DEVICE_CODE_TOKEN_TYPE);
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
result = this.authorizationService.findByToken(deviceCode.getTokenValue(), null);
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findByTokenWhenUserCodeExistsThenFound() {
|
||||
OAuth2UserCode userCode = new OAuth2UserCode("user-code", Instant.now(),
|
||||
Instant.now().plus(5, ChronoUnit.MINUTES));
|
||||
OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
|
||||
.id(ID)
|
||||
.principalName(PRINCIPAL_NAME)
|
||||
.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
|
||||
.token(userCode)
|
||||
.build();
|
||||
this.authorizationService.save(authorization);
|
||||
|
||||
OAuth2Authorization result = this.authorizationService.findByToken(userCode.getTokenValue(),
|
||||
USER_CODE_TOKEN_TYPE);
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
result = this.authorizationService.findByToken(userCode.getTokenValue(), null);
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findByTokenWhenWrongTokenTypeThenNotFound() {
|
||||
OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now());
|
||||
|
||||
+34
@@ -64,6 +64,7 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHand
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetails;
|
||||
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -581,6 +582,39 @@ public class OAuth2AuthorizationEndpointFilterTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void doFilterWhenAuthorizationRequestConsentRequiredThenConsentResponseHtmlEscaped() throws Exception {
|
||||
String unencodedScope = "<scope2>";
|
||||
String unencodedState = "<state>";
|
||||
Set<String> requestedScopes = new HashSet<>(Arrays.asList("scope1", unencodedScope));
|
||||
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes((scopes) -> {
|
||||
scopes.clear();
|
||||
scopes.addAll(requestedScopes);
|
||||
}).build();
|
||||
// No scopes previously approved
|
||||
OAuth2AuthorizationConsentAuthenticationToken authorizationConsentAuthenticationResult = new OAuth2AuthorizationConsentAuthenticationToken(
|
||||
AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, unencodedState, new HashSet<>(),
|
||||
null);
|
||||
authorizationConsentAuthenticationResult.setAuthenticated(true);
|
||||
given(this.authenticationManager.authenticate(any())).willReturn(authorizationConsentAuthenticationResult);
|
||||
|
||||
MockHttpServletRequest request = createAuthorizationRequest(registeredClient);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain filterChain = mock(FilterChain.class);
|
||||
|
||||
this.filter.doFilter(request, response, filterChain);
|
||||
|
||||
verify(this.authenticationManager).authenticate(any());
|
||||
verifyNoInteractions(filterChain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
|
||||
String html = response.getContentAsString();
|
||||
assertThat(html).doesNotContain(unencodedScope);
|
||||
assertThat(html).doesNotContain(unencodedState);
|
||||
assertThat(html).contains(HtmlUtils.htmlEscape(unencodedScope));
|
||||
assertThat(html).contains("name=\"state\" value=\"" + HtmlUtils.htmlEscape(unencodedState) + "\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationResponse() throws Exception {
|
||||
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().redirectUris((redirectUris) -> {
|
||||
|
||||
+14
@@ -33,6 +33,8 @@ public final class DelegatingOAuth2TokenValidator<T extends OAuth2Token> impleme
|
||||
|
||||
private final Collection<OAuth2TokenValidator<T>> tokenValidators;
|
||||
|
||||
private boolean failOnError;
|
||||
|
||||
/**
|
||||
* Constructs a {@code DelegatingOAuth2TokenValidator} using the provided validators.
|
||||
* @param tokenValidators the {@link Collection} of {@link OAuth2TokenValidator}s to
|
||||
@@ -57,8 +59,20 @@ public final class DelegatingOAuth2TokenValidator<T extends OAuth2Token> impleme
|
||||
Collection<OAuth2Error> errors = new ArrayList<>();
|
||||
for (OAuth2TokenValidator<T> validator : this.tokenValidators) {
|
||||
errors.addAll(validator.validate(token).getErrors());
|
||||
if (!errors.isEmpty() && this.failOnError) {
|
||||
return OAuth2TokenValidatorResult.failure(errors);
|
||||
}
|
||||
}
|
||||
return OAuth2TokenValidatorResult.failure(errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-fast when a delegate errors, defaults to {@code false}.
|
||||
* @param failOnError fail-fast when a delegate errors
|
||||
* @since 6.5.12
|
||||
*/
|
||||
public void setFailOnError(boolean failOnError) {
|
||||
this.failOnError = failOnError;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+143
-35
@@ -18,11 +18,11 @@ package org.springframework.security.oauth2.jwt;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
@@ -39,12 +39,15 @@ import com.nimbusds.jose.proc.SecurityContext;
|
||||
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
|
||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||||
|
||||
import org.springframework.security.oauth2.core.ClaimAccessor;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2Token;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
@@ -63,10 +66,11 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
||||
|
||||
/**
|
||||
* The default {@code OAuth2TokenValidator<Jwt>} factory that validates the
|
||||
* {@code htm}, {@code htu}, {@code jti} and {@code iat} claims of the DPoP Proof
|
||||
* {@link Jwt}.
|
||||
* {@code htm}, {@code htu}, {@code iat}, {@code jkt}, {@code ath} and {@code jti}
|
||||
* claims of the DPoP Proof {@link Jwt}.
|
||||
*/
|
||||
public static final Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = defaultJwtValidatorFactory();
|
||||
public static final Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = createDefaultJwtValidatorFactory(
|
||||
Collections.emptyList());
|
||||
|
||||
private static final JOSEObjectTypeVerifier<SecurityContext> DPOP_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>(
|
||||
new JOSEObjectType("dpop+jwt"));
|
||||
@@ -94,6 +98,70 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
||||
this.jwtValidatorFactory = jwtValidatorFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a factory that provides an {@link OAuth2TokenValidator} for the specified
|
||||
* {@link DPoPProofContext} and is used by the {@link JwtDecoder}. The returned
|
||||
* factory provides a validator that validates the {@code htm}, {@code htu},
|
||||
* {@code iat}, {@code jkt}, {@code ath} and {@code jti} claims, along with any custom
|
||||
* validators provided.
|
||||
* @param validators the custom validators to add
|
||||
* @return a factory that provides an {@link OAuth2TokenValidator} for the specified
|
||||
* {@link DPoPProofContext}
|
||||
* @since 6.5.12
|
||||
*/
|
||||
public static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> createDefaultJwtValidatorFactory(
|
||||
List<OAuth2TokenValidator<Jwt>> validators) {
|
||||
Assert.notNull(validators, "validators cannot be null");
|
||||
List<OAuth2TokenValidator<Jwt>> customValidators = new ArrayList<>();
|
||||
if (!CollectionUtils.isEmpty(validators)) {
|
||||
customValidators.addAll(validators);
|
||||
}
|
||||
final Duration clockSkew = Duration.ofSeconds(30);
|
||||
final JwtIssuedAtValidator jwtIssuedAtValidator;
|
||||
if (CollectionUtils.findValueOfType(customValidators, JwtIssuedAtValidator.class) != null) {
|
||||
jwtIssuedAtValidator = CollectionUtils.findValueOfType(customValidators, JwtIssuedAtValidator.class);
|
||||
customValidators.remove(jwtIssuedAtValidator);
|
||||
}
|
||||
else {
|
||||
jwtIssuedAtValidator = new JwtIssuedAtValidator(true);
|
||||
jwtIssuedAtValidator.setClockSkew(clockSkew);
|
||||
}
|
||||
final DPoPProofReplayValidator dPoPProofReplayValidator;
|
||||
if (CollectionUtils.findValueOfType(customValidators, DPoPProofReplayValidator.class) != null) {
|
||||
dPoPProofReplayValidator = CollectionUtils.findValueOfType(customValidators,
|
||||
DPoPProofReplayValidator.class);
|
||||
customValidators.remove(dPoPProofReplayValidator);
|
||||
}
|
||||
else {
|
||||
dPoPProofReplayValidator = new DPoPProofReplayValidator(new DPoPProofReplayValidator.InMemoryCache());
|
||||
dPoPProofReplayValidator.setClockSkew(clockSkew);
|
||||
}
|
||||
return (context) -> createDefaultJwtValidatorFactory(context, jwtIssuedAtValidator, dPoPProofReplayValidator,
|
||||
customValidators);
|
||||
}
|
||||
|
||||
private static OAuth2TokenValidator<Jwt> createDefaultJwtValidatorFactory(DPoPProofContext context,
|
||||
JwtIssuedAtValidator jwtIssuedAtValidator, DPoPProofReplayValidator dPoPProofReplayValidator,
|
||||
List<OAuth2TokenValidator<Jwt>> customValidators) {
|
||||
// Add custom validators first then default validators in a specific order
|
||||
List<OAuth2TokenValidator<Jwt>> tokenValidators = new ArrayList<>();
|
||||
if (!CollectionUtils.isEmpty(customValidators)) {
|
||||
tokenValidators.addAll(customValidators);
|
||||
}
|
||||
tokenValidators.add(new JwtClaimValidator<>("htm", context.getMethod()::equalsIgnoreCase));
|
||||
tokenValidators.add(new JwtClaimValidator<>("htu", context.getTargetUri()::equals));
|
||||
tokenValidators.add(jwtIssuedAtValidator);
|
||||
if (context.getAccessToken() != null) {
|
||||
tokenValidators.add(new JwkThumbprintValidator(context.getAccessToken()));
|
||||
tokenValidators.add(new AthClaimValidator(context.getAccessToken()));
|
||||
}
|
||||
tokenValidators.add(dPoPProofReplayValidator);
|
||||
DelegatingOAuth2TokenValidator<Jwt> delegatingTokenValidator = new DelegatingOAuth2TokenValidator<>(
|
||||
tokenValidators);
|
||||
delegatingTokenValidator.setFailOnError(true);
|
||||
return delegatingTokenValidator;
|
||||
}
|
||||
|
||||
private static NimbusJwtDecoder buildDecoder() {
|
||||
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
|
||||
jwtProcessor.setJWSTypeVerifier(DPOP_TYPE_VERIFIER);
|
||||
@@ -137,39 +205,34 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
||||
};
|
||||
}
|
||||
|
||||
private static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> defaultJwtValidatorFactory() {
|
||||
return (context) -> new DelegatingOAuth2TokenValidator<>(
|
||||
new JwtClaimValidator<>("htm", context.getMethod()::equals),
|
||||
new JwtClaimValidator<>("htu", context.getTargetUri()::equals), new JtiClaimValidator(),
|
||||
new JwtIssuedAtValidator(true));
|
||||
}
|
||||
private static final class AthClaimValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private static final class JtiClaimValidator implements OAuth2TokenValidator<Jwt> {
|
||||
private final OAuth2Token accessToken;
|
||||
|
||||
private static final Map<String, Long> JTI_CACHE = Collections.synchronizedMap(new JtiCache());
|
||||
private AthClaimValidator(OAuth2Token accessToken) {
|
||||
Assert.notNull(accessToken, "accessToken cannot be null");
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
||||
String jti = jwt.getId();
|
||||
if (!StringUtils.hasText(jti)) {
|
||||
OAuth2Error error = createOAuth2Error("jti claim is required.");
|
||||
String accessTokenHashClaim = jwt.getClaimAsString("ath");
|
||||
if (!StringUtils.hasText(accessTokenHashClaim)) {
|
||||
OAuth2Error error = createOAuth2Error("ath claim is required.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
// Enforce single-use to protect against DPoP proof replay
|
||||
String jtiHash;
|
||||
String accessTokenHash;
|
||||
try {
|
||||
jtiHash = computeSHA256(jti);
|
||||
accessTokenHash = computeSHA256(this.accessToken.getTokenValue());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
|
||||
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
Instant expiry = Instant.now().plus(1, ChronoUnit.HOURS);
|
||||
if ((JTI_CACHE.putIfAbsent(jtiHash, expiry.toEpochMilli())) != null) {
|
||||
// Already used
|
||||
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
|
||||
if (!accessTokenHashClaim.equals(accessTokenHash)) {
|
||||
OAuth2Error error = createOAuth2Error("ath claim is invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
@@ -185,20 +248,65 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
}
|
||||
|
||||
@SuppressWarnings("serial")
|
||||
private static final class JtiCache extends LinkedHashMap<String, Long> {
|
||||
}
|
||||
|
||||
private static final int MAX_SIZE = 1000;
|
||||
private static final class JwkThumbprintValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, Long> eldest) {
|
||||
if (size() > MAX_SIZE) {
|
||||
return true;
|
||||
}
|
||||
Instant expiry = Instant.ofEpochMilli(eldest.getValue());
|
||||
return Instant.now().isAfter(expiry);
|
||||
private final OAuth2Token accessToken;
|
||||
|
||||
private final ClaimAccessor claims;
|
||||
|
||||
private JwkThumbprintValidator(OAuth2Token accessToken) {
|
||||
Assert.notNull(accessToken, "accessToken cannot be null");
|
||||
Assert.isInstanceOf(ClaimAccessor.class, accessToken, "accessToken must be instance of ClaimAccessor");
|
||||
this.accessToken = accessToken;
|
||||
this.claims = (ClaimAccessor) accessToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
||||
String jwkThumbprintClaim = null;
|
||||
Map<String, Object> confirmationMethodClaim = this.claims.getClaimAsMap("cnf");
|
||||
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) {
|
||||
jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt");
|
||||
}
|
||||
if (jwkThumbprintClaim == null) {
|
||||
OAuth2Error error = createOAuth2Error("jkt claim is required.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
JWK jwk = null;
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
|
||||
try {
|
||||
jwk = JWK.parse(jwkJson);
|
||||
}
|
||||
catch (Exception ignored) {
|
||||
}
|
||||
if (jwk == null) {
|
||||
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
String jwkThumbprint;
|
||||
try {
|
||||
jwkThumbprint = jwk.computeThumbprint().toString();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
if (!jwkThumbprintClaim.equals(jwkThumbprint)) {
|
||||
OAuth2Error error = createOAuth2Error("jkt claim is invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
|
||||
private static OAuth2Error createOAuth2Error(String reason) {
|
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.jwt;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import com.nimbusds.jose.jwk.JWK;
|
||||
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.support.SimpleValueWrapper;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* An {@link OAuth2TokenValidator} that mitigates DPoP Proof Replay.
|
||||
*
|
||||
* <p>
|
||||
* This validator mitigates DPoP Proof Replay by ensuring the DPoP Proof:
|
||||
* <ul>
|
||||
* <li>contains the {@code iat} (issued at) claim, and it's within an acceptable time
|
||||
* window (configured via {@link #setClockSkew(Duration)})</li>
|
||||
* <li>contains the {@code jti} (JWT ID) claim, and it has not been used previously</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* This implementation uses a {@link Cache} to store the {@code jti} claim (along with
|
||||
* other information in {@link CacheValue CacheValue}) to enforce single-use. The
|
||||
* {@code jti} is retained in the cache until the DPoP Proof expires, which is calculated
|
||||
* as {@code iat + clockSkew}.
|
||||
*
|
||||
* @author Joe Grandja
|
||||
* @since 6.5.12
|
||||
* @see OAuth2TokenValidator
|
||||
* @see DPoPProofJwtDecoderFactory
|
||||
* @see <a target="_blank" href=
|
||||
* "https://datatracker.ietf.org/doc/html/rfc9449#section-11.1">Section 11.1. DPoP Proof
|
||||
* Replay</a>
|
||||
*/
|
||||
public final class DPoPProofReplayValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private final Cache cache;
|
||||
|
||||
private Duration clockSkew = Duration.ofSeconds(30);
|
||||
|
||||
private Clock clock = Clock.systemUTC();
|
||||
|
||||
/**
|
||||
* Constructs a {@code DPoPProofReplayValidator} using the provided parameters.
|
||||
* @param cache the {@link Cache} used to store {@link CacheValue} which contains
|
||||
* information of the used DPoP Proof {@link Jwt}'s
|
||||
*/
|
||||
public DPoPProofReplayValidator(Cache cache) {
|
||||
Assert.notNull(cache, "cache cannot be null");
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
||||
String jti = jwt.getId();
|
||||
if (!StringUtils.hasText(jti)) {
|
||||
OAuth2Error error = createOAuth2Error("jti claim is required.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
Instant issuedAt = jwt.getIssuedAt();
|
||||
if (issuedAt == null) {
|
||||
OAuth2Error error = createOAuth2Error("iat claim is required.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
// Ensure acceptable time window
|
||||
Instant now = Instant.now(this.clock);
|
||||
Instant notBefore = now.minus(this.clockSkew);
|
||||
Instant notAfter = now.plus(this.clockSkew);
|
||||
if (issuedAt.isBefore(notBefore) || issuedAt.isAfter(notAfter)) {
|
||||
OAuth2Error error = createOAuth2Error("iat claim is invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
String jwkThumbprint;
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
|
||||
JWK jwk = JWK.parse(jwkJson);
|
||||
jwkThumbprint = jwk.computeThumbprint().toString();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
String jtiHash;
|
||||
try {
|
||||
jtiHash = computeSHA256(jti);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
Instant expiresAt = issuedAt.plus(this.clockSkew);
|
||||
CacheValue cacheValue = new CacheValue(issuedAt, expiresAt, jwkThumbprint);
|
||||
|
||||
// Enforce single-use to protect against DPoP proof replay
|
||||
if (this.cache.putIfAbsent(jtiHash, cacheValue) != null) {
|
||||
// Already used or cache full or key limit reached
|
||||
OAuth2Error error = createOAuth2Error("jti claim is invalid or unable to cache.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the clock skew. The default is 30 seconds.
|
||||
* @param clockSkew the clock skew
|
||||
*/
|
||||
public void setClockSkew(Duration clockSkew) {
|
||||
Assert.notNull(clockSkew, "clockSkew cannot be null");
|
||||
Assert.isTrue(clockSkew.getSeconds() >= 0, "clockSkew must be >= 0");
|
||||
this.clockSkew = clockSkew;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link Clock} used in {@link Instant#now(Clock)}.
|
||||
* @param clock the clock
|
||||
*/
|
||||
public void setClock(Clock clock) {
|
||||
Assert.notNull(clock, "clock cannot be null");
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
private static OAuth2Error createOAuth2Error(String reason) {
|
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
|
||||
}
|
||||
|
||||
private static String computeSHA256(String value) throws Exception {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-memory {@link Cache} implementation backed by a {@link ConcurrentHashMap}.
|
||||
*
|
||||
* <p>
|
||||
* <b>NOTE:</b> This implementation has limitations as it only works in a single-node
|
||||
* setup. For production (and clustered) environments, it is recommended to use a
|
||||
* distributed {@link Cache} implementation (e.g. Redis, Hazelcast, etc.).
|
||||
*
|
||||
* <p>
|
||||
* This implementation can be fine-tuned based on the following configuration
|
||||
* settings:
|
||||
* <ul>
|
||||
* <li>{@link #setMaxSize(int)} - Sets the maximum number of entries the cache can
|
||||
* hold. The default is 100,000.</li>
|
||||
* <li>{@link #setMaxRequestsPerKey(int)} - Sets the maximum number of requests
|
||||
* allowed per {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is
|
||||
* 1000.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public static final class InMemoryCache implements Cache {
|
||||
|
||||
private static final String DEFAULT_NAME = InMemoryCache.class.getName().concat(".DPOP-PROOF-CACHE");
|
||||
|
||||
private static final int DEFAULT_MAX_SIZE = 100_000;
|
||||
|
||||
private static final int DEFAULT_MAX_REQUESTS_PER_KEY = 1000;
|
||||
|
||||
private static final int CLEANUP_INTERVAL_SECS = 10;
|
||||
|
||||
private final ConcurrentMap<String, CacheValue> cache = new ConcurrentHashMap<>();
|
||||
|
||||
private final ConcurrentMap<String, Integer> requestsPerKey = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicBoolean cleaning = new AtomicBoolean(false);
|
||||
|
||||
private long lastCleanup = System.currentTimeMillis();
|
||||
|
||||
private int maxSize = DEFAULT_MAX_SIZE;
|
||||
|
||||
private int maxRequestsPerKey = DEFAULT_MAX_REQUESTS_PER_KEY;
|
||||
|
||||
/**
|
||||
* Returns the maximum number of entries the cache can hold.
|
||||
* @return the maximum number of entries the cache can hold
|
||||
*/
|
||||
public int getMaxSize() {
|
||||
return this.maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum number of entries the cache can hold. The default is 100,000.
|
||||
* @param maxSize the maximum number of entries the cache can hold
|
||||
*/
|
||||
public void setMaxSize(int maxSize) {
|
||||
Assert.isTrue(maxSize > 0, "maxSize must be > 0");
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum number of requests allowed per
|
||||
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}.
|
||||
* @return the maximum number of requests allowed per
|
||||
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}
|
||||
*/
|
||||
public int getMaxRequestsPerKey() {
|
||||
return this.maxRequestsPerKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum number of requests allowed per
|
||||
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is 1000.
|
||||
* @param maxRequestsPerKey the maximum number of requests allowed per
|
||||
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}
|
||||
*/
|
||||
public void setMaxRequestsPerKey(int maxRequestsPerKey) {
|
||||
Assert.isTrue(maxRequestsPerKey > 0, "maxRequestsPerKey must be > 0");
|
||||
this.maxRequestsPerKey = maxRequestsPerKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return DEFAULT_NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getNativeCache() {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ValueWrapper get(Object key) {
|
||||
Object value = this.cache.get(key);
|
||||
return (value != null) ? new SimpleValueWrapper(value) : null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public <T> @Nullable T get(Object key, @Nullable Class<T> type) {
|
||||
Object value = this.cache.get(key);
|
||||
if (value != null && type != null && !type.isInstance(value)) {
|
||||
throw new IllegalStateException(
|
||||
"Cached value is not of required type [" + type.getName() + "]: " + value);
|
||||
}
|
||||
return (T) value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> @Nullable T get(Object key, Callable<T> valueLoader) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(Object key, Object value) {
|
||||
putIfAbsent(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ValueWrapper putIfAbsent(Object key, Object value) {
|
||||
String jti = (String) key;
|
||||
CacheValue cacheValue = (CacheValue) value;
|
||||
|
||||
cleanupIfNecessary();
|
||||
if (this.cache.size() >= this.maxSize) {
|
||||
// Force an immediate cleanup when we hit the limit before the cleanup
|
||||
// interval
|
||||
cleanup();
|
||||
if (this.cache.size() >= this.maxSize) {
|
||||
// Cache full - return non-null value
|
||||
return new SimpleValueWrapper(cacheValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Limit the number of requests per key
|
||||
AtomicBoolean limitExceeded = new AtomicBoolean(false);
|
||||
this.requestsPerKey.compute(cacheValue.jwkThumbprint, (k, v) -> {
|
||||
if (v != null && v >= this.maxRequestsPerKey) {
|
||||
limitExceeded.set(true);
|
||||
return v;
|
||||
}
|
||||
// Increment
|
||||
return (v != null) ? v + 1 : 1;
|
||||
});
|
||||
if (limitExceeded.get()) {
|
||||
// Key limit reached - return non-null value
|
||||
return new SimpleValueWrapper(cacheValue);
|
||||
}
|
||||
|
||||
if (this.cache.putIfAbsent(jti, cacheValue) != null) {
|
||||
// jti exists - revert the increment and return non-null value
|
||||
this.requestsPerKey.computeIfPresent(cacheValue.jwkThumbprint, (k, v) -> (v > 1) ? v - 1 : null);
|
||||
return new SimpleValueWrapper(cacheValue);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void evict(Object key) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear() {
|
||||
}
|
||||
|
||||
private void cleanupIfNecessary() {
|
||||
long now = System.currentTimeMillis();
|
||||
long last = this.lastCleanup;
|
||||
if ((now - last) > (CLEANUP_INTERVAL_SECS * 1000)) {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanup() {
|
||||
if (this.cleaning.compareAndSet(false, true)) {
|
||||
try {
|
||||
Instant now = Instant.now();
|
||||
for (Map.Entry<String, CacheValue> entry : this.cache.entrySet()) {
|
||||
if (now.isAfter(entry.getValue().expiresAt)) {
|
||||
this.cache.remove(entry.getKey());
|
||||
this.requestsPerKey.computeIfPresent(entry.getValue().jwkThumbprint,
|
||||
(k, v) -> (v > 1) ? v - 1 : null);
|
||||
}
|
||||
}
|
||||
this.lastCleanup = System.currentTimeMillis();
|
||||
}
|
||||
finally {
|
||||
this.cleaning.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A representation of the value to which the {@link Cache} maps a (hashed)
|
||||
* {@code (jti)} claim as the key.
|
||||
*/
|
||||
public static final class CacheValue {
|
||||
|
||||
private final Instant issuedAt;
|
||||
|
||||
private final Instant expiresAt;
|
||||
|
||||
private final String jwkThumbprint;
|
||||
|
||||
/**
|
||||
* Constructs a {@code CacheValue} using the provided parameters.
|
||||
* @param issuedAt the issued at claim which identifies the time at which the DPoP
|
||||
* Proof {@link Jwt} was issued
|
||||
* @param expiresAt the expiration time when this {@code CacheValue} will be
|
||||
* evicted from the cache
|
||||
* @param jwkThumbprint the SHA-256 thumbprint of the public key of the JSON Web
|
||||
* Key (JWK) corresponding to the key used to digitally sign the DPoP Proof
|
||||
* {@link Jwt}
|
||||
*/
|
||||
public CacheValue(Instant issuedAt, Instant expiresAt, String jwkThumbprint) {
|
||||
Assert.notNull(issuedAt, "issuedAt cannot be null");
|
||||
Assert.notNull(expiresAt, "expiresAt cannot be null");
|
||||
Assert.hasText(jwkThumbprint, "jwkThumbprint cannot be empty");
|
||||
this.issuedAt = issuedAt;
|
||||
this.expiresAt = expiresAt;
|
||||
this.jwkThumbprint = jwkThumbprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the issued at {@code (iat)} claim which identifies the time at which
|
||||
* the DPoP Proof {@link Jwt} was issued.
|
||||
* @return the issued at claim which identifies the time at which the DPoP Proof
|
||||
* {@link Jwt} was issued
|
||||
*/
|
||||
public Instant getIssuedAt() {
|
||||
return this.issuedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the expiration time when this {@code CacheValue} will be evicted from
|
||||
* the cache.
|
||||
* @return the expiration time when this {@code CacheValue} will be evicted from
|
||||
* the cache
|
||||
*/
|
||||
public Instant getExpiresAt() {
|
||||
return this.expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SHA-256 thumbprint of the public key of the JSON Web Key (JWK)
|
||||
* corresponding to the key used to digitally sign the DPoP Proof {@link Jwt}.
|
||||
* @return the SHA-256 thumbprint of the public key of the JSON Web Key (JWK)
|
||||
*/
|
||||
public String getJwkThumbprint() {
|
||||
return this.jwkThumbprint;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (obj == this) {
|
||||
return true;
|
||||
}
|
||||
if (obj == null || obj.getClass() != this.getClass()) {
|
||||
return false;
|
||||
}
|
||||
CacheValue that = (CacheValue) obj;
|
||||
return Objects.equals(this.issuedAt, that.issuedAt) && Objects.equals(this.expiresAt, that.expiresAt)
|
||||
&& Objects.equals(this.jwkThumbprint, that.jwkThumbprint);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(this.issuedAt, this.expiresAt, this.jwkThumbprint);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+2
-125
@@ -16,36 +16,24 @@
|
||||
|
||||
package org.springframework.security.oauth2.server.resource.authentication;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
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.AuthenticationProvider;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
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.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2Token;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.DPoPProofContext;
|
||||
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* An {@link AuthenticationProvider} implementation that is responsible for authenticating
|
||||
@@ -72,16 +60,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
|
||||
public DPoPAuthenticationProvider(AuthenticationManager tokenAuthenticationManager) {
|
||||
Assert.notNull(tokenAuthenticationManager, "tokenAuthenticationManager cannot be null");
|
||||
this.tokenAuthenticationManager = tokenAuthenticationManager;
|
||||
Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = (
|
||||
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;
|
||||
this.dPoPProofVerifierFactory = new DPoPProofJwtDecoderFactory();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -116,7 +95,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
|
||||
try {
|
||||
dPoPProofVerifier.decode(dPoPProofContext.getDPoPProof());
|
||||
}
|
||||
catch (JwtException ex) {
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF);
|
||||
throw new OAuth2AuthenticationException(error, ex);
|
||||
}
|
||||
@@ -141,108 +120,6 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
|
||||
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 final OAuth2Token accessToken;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
changelog:
|
||||
repository: spring-projects/spring-security
|
||||
repository: spring-projects/spring-security-commercial
|
||||
sections:
|
||||
- title: ":rewind: Breaking Changes"
|
||||
labels: ["type: breaks-passivity"]
|
||||
@@ -17,5 +17,6 @@ changelog:
|
||||
labels: ["type: task", "in: build"]
|
||||
sort: "title"
|
||||
issues:
|
||||
generate_links: false
|
||||
exclude:
|
||||
labels: ["status: duplicate"]
|
||||
|
||||
+2
-2
@@ -15,12 +15,12 @@ dependencyResolutionManagement {
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = 'spring-security'
|
||||
rootProject.name = 'spring-security-commercial'
|
||||
|
||||
FileTree buildFiles = fileTree(rootDir) {
|
||||
List excludes = gradle.startParameter.projectProperties.get("excludeProjects")?.split(",")
|
||||
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'
|
||||
if(excludes) {
|
||||
exclude excludes
|
||||
|
||||
+4
-3
@@ -51,6 +51,7 @@ import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
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.context.RequestAttributeSecurityContextRepository;
|
||||
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());
|
||||
// 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");
|
||||
user = this.userDetailsService.loadUserByUsername(username);
|
||||
this.userCache.putUserInCache(user);
|
||||
@@ -181,7 +182,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
|
||||
return;
|
||||
}
|
||||
// If digest is still incorrect, definitely reject authentication attempt
|
||||
if (!serverDigestMd5.equals(digestAuth.getResponse())) {
|
||||
if (!Utf8.isEqual(serverDigestMd5, digestAuth.getResponse())) {
|
||||
logger.debug(LogMessage.format(
|
||||
"Expected response: '%s' but received: '%s'; is AuthenticationDao returning clear text passwords?",
|
||||
serverDigestMd5, digestAuth.getResponse()));
|
||||
@@ -405,7 +406,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
|
||||
}
|
||||
// Check signature of nonce matches this expiry time
|
||||
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(
|
||||
"DigestAuthenticationFilter.nonceCompromised", new Object[] { nonceAsPlainText },
|
||||
"Nonce token compromised {0}"));
|
||||
|
||||
+22
@@ -16,6 +16,7 @@
|
||||
|
||||
package org.springframework.security.web.webauthn.api;
|
||||
|
||||
import java.io.ObjectStreamException;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
@@ -121,4 +122,25 @@ public final class AuthenticatorTransport implements Serializable {
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+22
@@ -16,6 +16,7 @@
|
||||
|
||||
package org.springframework.security.web.webauthn.api;
|
||||
|
||||
import java.io.ObjectStreamException;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
@@ -58,4 +59,25 @@ public final class PublicKeyCredentialType implements Serializable {
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+33
@@ -16,9 +16,12 @@
|
||||
|
||||
package org.springframework.security.web.webauthn.api;
|
||||
|
||||
import java.io.ObjectStreamException;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* <a href=
|
||||
* "https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement">UserVerificationRequirement</a>
|
||||
@@ -72,4 +75,34 @@ public final class UserVerificationRequirement implements Serializable {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+5
-5
@@ -258,8 +258,8 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
|
||||
byte[] clientDataJSON = response.getClientDataJSON().getBytes();
|
||||
Challenge challenge = new DefaultChallenge(base64Challenge);
|
||||
ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge);
|
||||
boolean userVerificationRequired = creationOptions.getAuthenticatorSelection()
|
||||
.getUserVerification() == UserVerificationRequirement.REQUIRED;
|
||||
boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
|
||||
.equals(creationOptions.getAuthenticatorSelection().getUserVerification());
|
||||
// requireUserPresence The constant Boolean value true
|
||||
// https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred
|
||||
boolean userPresenceRequired = true;
|
||||
@@ -318,7 +318,7 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
|
||||
|
||||
private com.webauthn4j.data.PublicKeyCredentialParameters convertParamToWebauthn4j(
|
||||
PublicKeyCredentialParameters parameter) {
|
||||
if (parameter.getType() != PublicKeyCredentialType.PUBLIC_KEY) {
|
||||
if (!PublicKeyCredentialType.PUBLIC_KEY.equals(parameter.getType())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Cannot convert unknown credential type " + parameter.getType() + " to webauthn4j");
|
||||
}
|
||||
@@ -395,8 +395,8 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
|
||||
String rpId = requestOptions.getRpId();
|
||||
Assert.notNull(rpId, "rpId cannot be null");
|
||||
ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge);
|
||||
boolean userVerificationRequired = request.getRequestOptions()
|
||||
.getUserVerification() == UserVerificationRequirement.REQUIRED;
|
||||
boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
|
||||
.equals(request.getRequestOptions().getUserVerification());
|
||||
|
||||
com.webauthn4j.data.AuthenticationRequest authenticationRequest = new com.webauthn4j.data.AuthenticationRequest(
|
||||
request.getPublicKey().getRawId().getBytes(), assertionResponse.getAuthenticatorData().getBytes(),
|
||||
|
||||
+42
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+76
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+51
@@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user