Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b512ea6352 | |||
| 076ef2cc74 | |||
| 3cedc1a2e4 | |||
| d32b23e497 | |||
| b58eef0d5d | |||
| 1202f08f3e | |||
| 8e315eae11 | |||
| 03a504b4ff | |||
| 3c458bc661 | |||
| 1bb9ed906e | |||
| fc9a3f95ec | |||
| 7f1700d043 | |||
| 56456601ab | |||
| 249ea55494 | |||
| 43f42d8e32 | |||
| 734d989c32 | |||
| 2f572278d4 | |||
| 816066ab60 | |||
| fb3f864d27 | |||
| ab29661a68 | |||
| 35abf87e2e | |||
| 4e3811cb29 |
@@ -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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
workflow:
|
||||||
|
generator:
|
||||||
|
project:
|
||||||
|
java:
|
||||||
|
versions:
|
||||||
|
primary: 25
|
||||||
|
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,17 +0,0 @@
|
|||||||
name: Merge Dependabot PR
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- '*.x'
|
|
||||||
- 'docs-build'
|
|
||||||
|
|
||||||
run-name: Merge Dependabot PR ${{ github.ref_name }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
merge-dependabot-pr:
|
|
||||||
permissions: write-all
|
|
||||||
uses: spring-io/spring-github-workflows/.github/workflows/spring-merge-dependabot-pr.yml@0d3f15bb384839966a1ff5c4383731a2b747f24b # v7
|
|
||||||
with:
|
|
||||||
mergeArguments: --auto --rebase
|
|
||||||
@@ -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@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- java-version: 25
|
|
||||||
toolchain: 25
|
|
||||||
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.+ -PspringDataVersion=2025.+ -PmicrometerVersion=1.+ --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@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
|
|
||||||
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,45 +8,42 @@ on:
|
|||||||
- cron: '0 10 * * *' # Once per day at 10am UTC
|
- cron: '0 10 * * *' # Once per day at 10am UTC
|
||||||
workflow_dispatch: # Manual trigger
|
workflow_dispatch: # Manual trigger
|
||||||
|
|
||||||
env:
|
|
||||||
DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
uses: spring-io/spring-security-release-tools/.github/workflows/build.yml@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
|
uses: spring-projects/spring-security-commercial/.github/workflows/build.yml@workflows/v1
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: [ ubuntu-latest, windows-latest ]
|
|
||||||
jdk: [ 25 ]
|
|
||||||
with:
|
with:
|
||||||
runs-on: ${{ matrix.os }}
|
java-version: '25'
|
||||||
java-version: ${{ matrix.jdk }}
|
|
||||||
distribution: temurin
|
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
test:
|
||||||
|
name: Test Against Snapshots
|
||||||
|
uses: spring-projects/spring-security-commercial/.github/workflows/test.yml@workflows/v1
|
||||||
|
with:
|
||||||
|
java-version: '25'
|
||||||
|
test-args: --refresh-dependencies -PforceMavenRepositories=snapshot -PisOverrideVersionCatalog -PtestToolchain=25 -PspringFrameworkVersion=7.0.+ -PreactorVersion=2025.0.+ -PspringDataVersion=2026.0.+ -PmicrometerVersion=1.17.+ --stacktrace
|
||||||
|
secrets: inherit
|
||||||
|
compute-version:
|
||||||
|
name: Compute Version
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
snapshot: ${{ steps.project-version.outputs.snapshot }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 7.0.0
|
||||||
|
- id: project-version
|
||||||
|
name: Extract Project Version
|
||||||
|
uses: spring-io/spring-release-actions/compute-version@a1f321783a0769dd2aea4fad6c2ae2f95a52b885 # 0.0.5
|
||||||
deploy-artifacts:
|
deploy-artifacts:
|
||||||
name: Deploy Artifacts
|
name: Deploy Artifacts
|
||||||
needs: [ build ]
|
needs: [ build, test, compute-version ]
|
||||||
uses: spring-io/spring-security-release-tools/.github/workflows/deploy-artifacts.yml@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
|
if: needs.compute-version.outputs.snapshot == 'true'
|
||||||
with:
|
uses: spring-projects/spring-security-commercial/.github/workflows/deploy-artifacts.yml@workflows/v1
|
||||||
should-deploy-artifacts: ${{ needs.build.outputs.should-deploy-artifacts }}
|
|
||||||
default-publish-milestones-central: true
|
|
||||||
java-version: 25
|
|
||||||
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 }}
|
|
||||||
java-version: 25
|
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
send-notification:
|
send-notification:
|
||||||
name: Send Notification
|
name: Send Notification
|
||||||
needs: [ deploy-artifacts, deploy-schema ]
|
needs: [ deploy-artifacts ]
|
||||||
if: ${{ !success() }}
|
if: ${{ !success() }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -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,34 +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
|
|
||||||
if: ${{ github.repository == 'spring-projects/spring-security' }}
|
|
||||||
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 25
|
|
||||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
|
||||||
with:
|
|
||||||
java-version: '25'
|
|
||||||
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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
runs-on: ubuntu-latest
|
uses: spring-projects/spring-security-commercial/.github/workflows/build-pull-request.yml@workflows/v1
|
||||||
if: ${{ github.repository == 'spring-projects/spring-security' }}
|
if: ${{ github.actor != 'dependabot[bot]' }}
|
||||||
steps:
|
secrets: inherit
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
- name: Set up gradle
|
|
||||||
uses: spring-io/spring-gradle-build-action@c8668747d7c264864c8c7f7026d0d277d14a78dc # v2.0.6
|
|
||||||
with:
|
|
||||||
java-version: '25'
|
|
||||||
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: '25'
|
|
||||||
distribution: 'temurin'
|
|
||||||
- name: Run Antora
|
|
||||||
run: ./gradlew -PbuildSrc.skipTests=true :spring-security-docs:antora
|
|
||||||
- name: Upload Docs
|
|
||||||
id: upload
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: docs
|
|
||||||
path: docs/build/site
|
|
||||||
overwrite: true
|
|
||||||
send-notification:
|
|
||||||
name: Send Notification
|
|
||||||
needs: [ build, generate-docs ]
|
|
||||||
if: ${{ failure() && github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'spring-projects/spring-security' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Send Notification
|
|
||||||
uses: spring-io/spring-security-release-tools/.github/actions/send-notification@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
|
|
||||||
with:
|
|
||||||
webhook-url: ${{ secrets.SPRING_SECURITY_CI_GCHAT_WEBHOOK_URL }}
|
|
||||||
|
|||||||
@@ -0,0 +1,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: "25"
|
||||||
|
- 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: "25"
|
||||||
|
- 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,37 +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:
|
|
||||||
name: Update on Supported Branches
|
|
||||||
if: ${{ github.repository == 'spring-projects/spring-security' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
branch: [ '6.5.x', '7.0.x', '7.1.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:
|
|
||||||
name: Update on docs-build
|
|
||||||
if: ${{ github.repository == 'spring-projects/spring-security' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
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
@@ -27,6 +27,7 @@ apply plugin: 'org.jetbrains.kotlin.jvm'
|
|||||||
apply plugin: 'org.springframework.security.versions.verify-dependencies-versions'
|
apply plugin: 'org.springframework.security.versions.verify-dependencies-versions'
|
||||||
apply plugin: 'org.springframework.security.check-expected-branch-version'
|
apply plugin: 'org.springframework.security.check-expected-branch-version'
|
||||||
apply plugin: 'io.spring.security.release'
|
apply plugin: 'io.spring.security.release'
|
||||||
|
apply from: 'commercial-settings.gradle'
|
||||||
|
|
||||||
group = 'org.springframework.security'
|
group = 'org.springframework.security'
|
||||||
description = 'Spring Security'
|
description = 'Spring Security'
|
||||||
@@ -41,10 +42,12 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
springRelease {
|
springRelease {
|
||||||
|
repositoryOwner = "spring-projects"
|
||||||
|
repositoryName = "spring-security-commercial"
|
||||||
weekOfMonth = 3
|
weekOfMonth = 3
|
||||||
dayOfWeek = 1
|
dayOfWeek = 1
|
||||||
referenceDocUrl = "https://docs.spring.io/spring-security/reference/{version}/index.html"
|
referenceDocUrl = "https://docs.spring.vmware.com/spring-security/reference/{version}/index.html"
|
||||||
apiDocUrl = "https://docs.spring.io/spring-security/reference/{version}/api/java/index.html"
|
apiDocUrl = "https://docs.spring.vmware.com/spring-security/reference/{version}/api/java/index.html"
|
||||||
replaceSnapshotVersionInReferenceDocUrl = true
|
replaceSnapshotVersionInReferenceDocUrl = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,12 +75,6 @@ allprojects {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
develocity {
|
|
||||||
buildScan {
|
|
||||||
termsOfUseUrl = 'https://gradle.com/help/legal-terms-of-use'
|
|
||||||
termsOfUseAgree = 'yes'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nohttp {
|
nohttp {
|
||||||
source.exclude "buildSrc/build/**", "**/build/**", "**/target/**", "javascript/.gradle/**", "javascript/package-lock.json", "javascript/node_modules/**", "javascript/build/**", "javascript/dist/**"
|
source.exclude "buildSrc/build/**", "**/build/**", "**/target/**", "javascript/.gradle/**", "javascript/package-lock.json", "javascript/node_modules/**", "javascript/build/**", "javascript/dist/**"
|
||||||
|
|||||||
+10
-1
@@ -13,7 +13,16 @@ java {
|
|||||||
repositories {
|
repositories {
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven { url = 'https://repo.spring.io/snapshot' }
|
if (project.hasProperty("artifactoryUsername") && project.hasProperty("artifactoryPassword")) {
|
||||||
|
maven {
|
||||||
|
name "spring-commercial-release"
|
||||||
|
url "https://usw1.packages.broadcom.com/spring-enterprise-maven-prod-local"
|
||||||
|
credentials {
|
||||||
|
username project.artifactoryUsername
|
||||||
|
password project.artifactoryPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_URL") != null) {
|
if (System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_URL") != null) {
|
||||||
maven {
|
maven {
|
||||||
name = "Release Train"
|
name = "Release Train"
|
||||||
|
|||||||
@@ -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;
|
package org.springframework.security.config.annotation.authentication.configurers.ldap;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.InetAddress;
|
||||||
import java.net.ServerSocket;
|
import java.net.ServerSocket;
|
||||||
|
|
||||||
import org.springframework.ldap.core.support.BaseLdapPathContextSource;
|
import org.springframework.ldap.core.support.BaseLdapPathContextSource;
|
||||||
@@ -590,7 +591,7 @@ public class LdapAuthenticationProviderConfigurer<B extends ProviderManagerBuild
|
|||||||
}
|
}
|
||||||
|
|
||||||
private int getDefaultPort() {
|
private int getDefaultPort() {
|
||||||
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT)) {
|
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT, 50, InetAddress.getLoopbackAddress())) {
|
||||||
return serverSocket.getLocalPort();
|
return serverSocket.getLocalPort();
|
||||||
}
|
}
|
||||||
catch (IOException ex) {
|
catch (IOException ex) {
|
||||||
|
|||||||
+3
-1
@@ -706,8 +706,10 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void configure(H http) {
|
private void configure(H http) {
|
||||||
|
DPoPAuthenticationProvider authenticationProvider = new DPoPAuthenticationProvider(
|
||||||
|
getTokenAuthenticationManager(http));
|
||||||
|
http.authenticationProvider(postProcess(authenticationProvider));
|
||||||
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
|
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
|
||||||
http.authenticationProvider(new DPoPAuthenticationProvider(getTokenAuthenticationManager(http)));
|
|
||||||
AuthenticationFilter authenticationFilter = new AuthenticationFilter(authenticationManager,
|
AuthenticationFilter authenticationFilter = new AuthenticationFilter(authenticationManager,
|
||||||
getAuthenticationConverter());
|
getAuthenticationConverter());
|
||||||
authenticationFilter.setRequestMatcher(getRequestMatcher());
|
authenticationFilter.setRequestMatcher(getRequestMatcher());
|
||||||
|
|||||||
+2
-1
@@ -17,6 +17,7 @@
|
|||||||
package org.springframework.security.config.ldap;
|
package org.springframework.security.config.ldap;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.InetAddress;
|
||||||
import java.net.ServerSocket;
|
import java.net.ServerSocket;
|
||||||
|
|
||||||
import org.w3c.dom.Element;
|
import org.w3c.dom.Element;
|
||||||
@@ -188,7 +189,7 @@ public class LdapServerBeanDefinitionParser implements BeanDefinitionParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String getDefaultPort() {
|
private String getDefaultPort() {
|
||||||
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT)) {
|
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT, 50, InetAddress.getLoopbackAddress())) {
|
||||||
return String.valueOf(serverSocket.getLocalPort());
|
return String.valueOf(serverSocket.getLocalPort());
|
||||||
}
|
}
|
||||||
catch (IOException ex) {
|
catch (IOException ex) {
|
||||||
|
|||||||
+29
-2
@@ -22,6 +22,7 @@ import java.security.interfaces.ECPrivateKey;
|
|||||||
import java.security.interfaces.ECPublicKey;
|
import java.security.interfaces.ECPublicKey;
|
||||||
import java.security.interfaces.RSAPrivateKey;
|
import java.security.interfaces.RSAPrivateKey;
|
||||||
import java.security.interfaces.RSAPublicKey;
|
import java.security.interfaces.RSAPublicKey;
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
@@ -30,6 +31,7 @@ import java.util.HashMap;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
import com.nimbusds.jose.jwk.ECKey;
|
import com.nimbusds.jose.jwk.ECKey;
|
||||||
import com.nimbusds.jose.jwk.JWK;
|
import com.nimbusds.jose.jwk.JWK;
|
||||||
@@ -47,20 +49,26 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.config.Customizer;
|
import org.springframework.security.config.Customizer;
|
||||||
|
import org.springframework.security.config.ObjectPostProcessor;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.test.SpringTestContext;
|
import org.springframework.security.config.test.SpringTestContext;
|
||||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||||
import org.springframework.security.oauth2.jose.TestJwks;
|
import org.springframework.security.oauth2.jose.TestJwks;
|
||||||
import org.springframework.security.oauth2.jose.TestKeys;
|
import org.springframework.security.oauth2.jose.TestKeys;
|
||||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||||
|
import org.springframework.security.oauth2.jwt.DPoPProofContext;
|
||||||
|
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
|
||||||
|
import org.springframework.security.oauth2.jwt.DPoPProofReplayValidator;
|
||||||
import org.springframework.security.oauth2.jwt.JwsHeader;
|
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationProvider;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -247,8 +255,8 @@ public class DPoPAuthenticationTests {
|
|||||||
)
|
)
|
||||||
.oauth2ResourceServer((oauth2) -> oauth2
|
.oauth2ResourceServer((oauth2) -> oauth2
|
||||||
.jwt(Customizer.withDefaults())
|
.jwt(Customizer.withDefaults())
|
||||||
.dPoP(Customizer.withDefaults()));
|
.dPoP(Customizer.withDefaults())
|
||||||
|
.withObjectPostProcessor(dPoPProofVerifierFactoryCustomizer()));
|
||||||
// @formatter:on
|
// @formatter:on
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
@@ -258,6 +266,25 @@ public class DPoPAuthenticationTests {
|
|||||||
return NimbusJwtDecoder.withPublicKey(PROVIDER_RSA_PUBLIC_KEY).build();
|
return NimbusJwtDecoder.withPublicKey(PROVIDER_RSA_PUBLIC_KEY).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ObjectPostProcessor<DPoPAuthenticationProvider> dPoPProofVerifierFactoryCustomizer() {
|
||||||
|
return new ObjectPostProcessor<>() {
|
||||||
|
@Override
|
||||||
|
public <O extends DPoPAuthenticationProvider> O postProcess(O authenticationProvider) {
|
||||||
|
DPoPProofReplayValidator.InMemoryCache inMemoryCache = new DPoPProofReplayValidator.InMemoryCache();
|
||||||
|
inMemoryCache.setMaxSize(50_000);
|
||||||
|
inMemoryCache.setMaxRequestsPerKey(500);
|
||||||
|
DPoPProofReplayValidator dPoPProofReplayValidator = new DPoPProofReplayValidator(inMemoryCache);
|
||||||
|
dPoPProofReplayValidator.setClockSkew(Duration.ofSeconds(60));
|
||||||
|
Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = DPoPProofJwtDecoderFactory
|
||||||
|
.createDefaultJwtValidatorFactory(Collections.singletonList(dPoPProofReplayValidator));
|
||||||
|
DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory();
|
||||||
|
dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory);
|
||||||
|
authenticationProvider.setDPoPProofVerifierFactory(dPoPProofJwtDecoderFactory);
|
||||||
|
return authenticationProvider;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
|
|||||||
+1
-1
@@ -132,7 +132,7 @@ public class KeyBasedPersistenceTokenService implements TokenService, Initializi
|
|||||||
// Verification
|
// Verification
|
||||||
String content = creationTime + ":" + pseudoRandomNumber + ":" + extendedInfo.toString();
|
String content = creationTime + ":" + pseudoRandomNumber + ":" + extendedInfo.toString();
|
||||||
String expectedSha512Hex = Sha512DigestUtils.shaHex(content + ":" + serverSecret);
|
String expectedSha512Hex = Sha512DigestUtils.shaHex(content + ":" + serverSecret);
|
||||||
Assert.isTrue(expectedSha512Hex.equals(sha1Hex), "Key verification failure");
|
Assert.isTrue(Utf8.isEqual(expectedSha512Hex, sha1Hex), "Key verification failure");
|
||||||
return new DefaultToken(key, creationTime, extendedInfo.toString());
|
return new DefaultToken(key, creationTime, extendedInfo.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -335,6 +335,9 @@ public final class InetAddressMatchers {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean matches(@Nullable InetAddress address) {
|
public boolean matches(@Nullable InetAddress address) {
|
||||||
|
if (address == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return !this.internalMatcher.matches(address);
|
return !this.internalMatcher.matches(address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
@@ -415,6 +415,12 @@ class InetAddressMatchersTests {
|
|||||||
@Nested
|
@Nested
|
||||||
class ExternalInetAddressMatcherTests {
|
class ExternalInetAddressMatcherTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void matchesWhenInetAddressNullThenReturnsFalse() {
|
||||||
|
InetAddressMatcher matcher = InetAddressMatchers.matchExternal().build();
|
||||||
|
assertThat(matcher.matches((InetAddress) null)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(strings = { "8.8.8.8", "1.1.1.1" })
|
@ValueSource(strings = { "8.8.8.8", "1.1.1.1" })
|
||||||
void matchesWhenIpv4PublicThenReturnsTrue(String address) throws Exception {
|
void matchesWhenIpv4PublicThenReturnsTrue(String address) throws Exception {
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import java.nio.CharBuffer;
|
|||||||
import java.nio.charset.CharacterCodingException;
|
import java.nio.charset.CharacterCodingException;
|
||||||
import java.nio.charset.Charset;
|
import java.nio.charset.Charset;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
|
||||||
|
import org.jspecify.annotations.Nullable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UTF-8 Charset encoder/decoder.
|
* UTF-8 Charset encoder/decoder.
|
||||||
@@ -66,4 +69,21 @@ public final class Utf8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant time comparison to prevent against timing attacks.
|
||||||
|
* @param expected the expected {@link CharSequence}
|
||||||
|
* @param actual the actual {@link CharSequence}
|
||||||
|
* @return true if {@code expected} and {@code actual} are equal, false otherwise
|
||||||
|
* @since 5.7.26
|
||||||
|
*/
|
||||||
|
public static boolean isEqual(@Nullable CharSequence expected, @Nullable CharSequence actual) {
|
||||||
|
byte[] expectedBytes = bytesUtf8(expected);
|
||||||
|
byte[] actualBytes = bytesUtf8(actual);
|
||||||
|
return MessageDigest.isEqual(expectedBytes, actualBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte @Nullable [] bytesUtf8(@Nullable CharSequence s) {
|
||||||
|
return (s != null) ? Utf8.encode(s) : null;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -37,7 +37,9 @@ import org.springframework.security.crypto.util.EncodingUtils;
|
|||||||
*
|
*
|
||||||
* @author Keith Donald
|
* @author Keith Donald
|
||||||
* @author Dave Syer
|
* @author Dave Syer
|
||||||
|
* @deprecated Use {@link AesCbcBytesEncryptor} or {@link AesGcmBytesEncryptor} instead.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated
|
||||||
public final class AesBytesEncryptor implements BytesEncryptor {
|
public final class AesBytesEncryptor implements BytesEncryptor {
|
||||||
|
|
||||||
private final SecretKey secretKey;
|
private final SecretKey secretKey;
|
||||||
|
|||||||
+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;
|
package org.springframework.security.crypto.encrypt;
|
||||||
|
|
||||||
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
|
|
||||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,9 +41,14 @@ public final class Encryptors {
|
|||||||
* not be shared
|
* not be shared
|
||||||
* @param salt a hex-encoded, random, site-global salt value to use to generate the
|
* @param salt a hex-encoded, random, site-global salt value to use to generate the
|
||||||
* key
|
* key
|
||||||
|
* @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)}
|
||||||
|
* instead.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
public static BytesEncryptor stronger(CharSequence password, CharSequence salt) {
|
public static BytesEncryptor stronger(CharSequence password, CharSequence salt) {
|
||||||
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16), CipherAlgorithm.GCM);
|
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16),
|
||||||
|
AesBytesEncryptor.CipherAlgorithm.GCM);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,18 +57,16 @@ public final class Encryptors {
|
|||||||
* Function #2). Salts the password to prevent dictionary attacks against the key. The
|
* Function #2). Salts the password to prevent dictionary attacks against the key. The
|
||||||
* provided salt is expected to be hex-encoded; it should be random and at least 8
|
* provided salt is expected to be hex-encoded; it should be random and at least 8
|
||||||
* bytes in length. Also applies a random 16-byte initialization vector to ensure each
|
* bytes in length. Also applies a random 16-byte initialization vector to ensure each
|
||||||
* encrypted message will be unique. Requires Java 6. NOTE: This mode is not
|
* encrypted message will be unique. Requires Java 6.
|
||||||
* <a href="https://en.wikipedia.org/wiki/Authenticated_encryption">authenticated</a>
|
|
||||||
* and does not provide any guarantees about the authenticity of the data. For a more
|
|
||||||
* secure alternative, users should prefer
|
|
||||||
* {@link #stronger(CharSequence, CharSequence)}.
|
|
||||||
* @param password the password used to generate the encryptor's secret key; should
|
* @param password the password used to generate the encryptor's secret key; should
|
||||||
* not be shared
|
* not be shared
|
||||||
* @param salt a hex-encoded, random, site-global salt value to use to generate the
|
* @param salt a hex-encoded, random, site-global salt value to use to generate the
|
||||||
* key
|
* key
|
||||||
*
|
* @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)}
|
||||||
* @see Encryptors#stronger(CharSequence, CharSequence)
|
* instead.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
public static BytesEncryptor standard(CharSequence password, CharSequence salt) {
|
public static BytesEncryptor standard(CharSequence password, CharSequence salt) {
|
||||||
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16));
|
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16));
|
||||||
}
|
}
|
||||||
@@ -74,8 +76,12 @@ public final class Encryptors {
|
|||||||
* text is hex-encoded.
|
* text is hex-encoded.
|
||||||
* @param password the password used to generate the encryptor's secret key; should
|
* @param password the password used to generate the encryptor's secret key; should
|
||||||
* not be shared
|
* not be shared
|
||||||
|
* @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)}
|
||||||
|
* instead.
|
||||||
* @see Encryptors#stronger(CharSequence, CharSequence)
|
* @see Encryptors#stronger(CharSequence, CharSequence)
|
||||||
*/
|
*/
|
||||||
|
@Deprecated
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
public static TextEncryptor delux(CharSequence password, CharSequence salt) {
|
public static TextEncryptor delux(CharSequence password, CharSequence salt) {
|
||||||
return new HexEncodingTextEncryptor(stronger(password, salt));
|
return new HexEncodingTextEncryptor(stronger(password, salt));
|
||||||
}
|
}
|
||||||
@@ -85,8 +91,12 @@ public final class Encryptors {
|
|||||||
* text is hex-encoded.
|
* text is hex-encoded.
|
||||||
* @param password the password used to generate the encryptor's secret key; should
|
* @param password the password used to generate the encryptor's secret key; should
|
||||||
* not be shared
|
* not be shared
|
||||||
|
* @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)}
|
||||||
|
* instead.
|
||||||
* @see Encryptors#standard(CharSequence, CharSequence)
|
* @see Encryptors#standard(CharSequence, CharSequence)
|
||||||
*/
|
*/
|
||||||
|
@Deprecated
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
public static TextEncryptor text(CharSequence password, CharSequence salt) {
|
public static TextEncryptor text(CharSequence password, CharSequence salt) {
|
||||||
return new HexEncodingTextEncryptor(standard(password, salt));
|
return new HexEncodingTextEncryptor(standard(password, salt));
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-11
@@ -16,8 +16,6 @@
|
|||||||
|
|
||||||
package org.springframework.security.crypto.password;
|
package org.springframework.security.crypto.password;
|
||||||
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
|
|
||||||
import org.jspecify.annotations.Nullable;
|
import org.jspecify.annotations.Nullable;
|
||||||
|
|
||||||
import org.springframework.security.crypto.codec.Utf8;
|
import org.springframework.security.crypto.codec.Utf8;
|
||||||
@@ -39,15 +37,7 @@ final class PasswordEncoderUtils {
|
|||||||
* @return
|
* @return
|
||||||
*/
|
*/
|
||||||
static boolean equals(String expected, @Nullable String actual) {
|
static boolean equals(String expected, @Nullable String actual) {
|
||||||
byte[] expectedBytes = bytesUtf8(expected);
|
return Utf8.isEqual(expected, actual);
|
||||||
byte[] actualBytes = bytesUtf8(actual);
|
|
||||||
return MessageDigest.isEqual(expectedBytes, actualBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte @Nullable [] bytesUtf8(@Nullable String s) {
|
|
||||||
// need to check if Utf8.encode() runs in constant time (probably not).
|
|
||||||
// This may leak length of string.
|
|
||||||
return (s != null) ? Utf8.encode(s) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -24,6 +24,7 @@ import com.password4j.BalloonHashingFunction;
|
|||||||
import com.password4j.Hash;
|
import com.password4j.Hash;
|
||||||
import com.password4j.Password;
|
import com.password4j.Password;
|
||||||
|
|
||||||
|
import org.springframework.security.crypto.codec.Utf8;
|
||||||
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
|
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
|
||||||
import org.springframework.util.Assert;
|
import org.springframework.util.Assert;
|
||||||
|
|
||||||
@@ -140,7 +141,7 @@ public class BalloonHashingPassword4jPasswordEncoder extends AbstractValidatingP
|
|||||||
String expectedHash = parts[1];
|
String expectedHash = parts[1];
|
||||||
|
|
||||||
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
|
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
|
||||||
return expectedHash.equals(hash.getResult());
|
return Utf8.isEqual(expectedHash, hash.getResult());
|
||||||
}
|
}
|
||||||
catch (IllegalArgumentException ex) {
|
catch (IllegalArgumentException ex) {
|
||||||
// Invalid Base64 encoding
|
// Invalid Base64 encoding
|
||||||
|
|||||||
+2
-1
@@ -24,6 +24,7 @@ import com.password4j.Hash;
|
|||||||
import com.password4j.PBKDF2Function;
|
import com.password4j.PBKDF2Function;
|
||||||
import com.password4j.Password;
|
import com.password4j.Password;
|
||||||
|
|
||||||
|
import org.springframework.security.crypto.codec.Utf8;
|
||||||
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
|
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
|
||||||
import org.springframework.util.Assert;
|
import org.springframework.util.Assert;
|
||||||
|
|
||||||
@@ -138,7 +139,7 @@ public class Pbkdf2Password4jPasswordEncoder extends AbstractValidatingPasswordE
|
|||||||
String expectedHash = parts[1];
|
String expectedHash = parts[1];
|
||||||
|
|
||||||
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
|
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
|
||||||
return expectedHash.equals(hash.getResult());
|
return Utf8.isEqual(expectedHash, hash.getResult());
|
||||||
}
|
}
|
||||||
catch (IllegalArgumentException ex) {
|
catch (IllegalArgumentException ex) {
|
||||||
// Invalid Base64 encoding
|
// Invalid Base64 encoding
|
||||||
|
|||||||
@@ -37,4 +37,48 @@ public class Utf8Tests {
|
|||||||
assertThat(decoded).isEqualTo("6048b75ed560785c");
|
assertThat(decoded).isEqualTo("6048b75ed560785c");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void isEqualWhenDifferentLengthThenFalse() {
|
||||||
|
assertThat(Utf8.isEqual("abc", "a")).isFalse();
|
||||||
|
assertThat(Utf8.isEqual("a", "abc")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void isEqualWhenNullAndNotEmptyThenFalse() {
|
||||||
|
assertThat(Utf8.isEqual(null, "a")).isFalse();
|
||||||
|
assertThat(Utf8.isEqual("a", null)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void isEqualWhenNullAndNullThenTrue() {
|
||||||
|
assertThat(Utf8.isEqual(null, null)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void isEqualWhenNullAndEmptyThenFalse() {
|
||||||
|
assertThat(Utf8.isEqual(null, "")).isFalse();
|
||||||
|
assertThat(Utf8.isEqual("", null)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void isEqualWhenNotEmptyAndEmptyThenFalse() {
|
||||||
|
assertThat(Utf8.isEqual("abc", "")).isFalse();
|
||||||
|
assertThat(Utf8.isEqual("", "abc")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void isEqualWhenEmptyAndEmptyThenTrue() {
|
||||||
|
assertThat(Utf8.isEqual("", "")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void isEqualWhenDifferentCaseThenFalse() {
|
||||||
|
assertThat(Utf8.isEqual("aBc", "abc")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void isEqualWhenSameThenTrue() {
|
||||||
|
assertThat(Utf8.isEqual("abcdef", "abcdef")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+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.junit.jupiter.api.Assumptions;
|
||||||
import org.opentest4j.TestAbortedException;
|
import org.opentest4j.TestAbortedException;
|
||||||
|
|
||||||
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
|
|
||||||
|
|
||||||
public final class CryptoAssumptions {
|
public final class CryptoAssumptions {
|
||||||
|
|
||||||
private CryptoAssumptions() {
|
private CryptoAssumptions() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void assumeGCMJCE() {
|
public static void assumeGCMJCE() {
|
||||||
assumeAes256(CipherAlgorithm.GCM);
|
assumeAes256("GCM");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void assumeCBCJCE() {
|
public static void assumeCBCJCE() {
|
||||||
assumeAes256(CipherAlgorithm.CBC);
|
assumeAes256("CBC");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assumeAes256(CipherAlgorithm cipherAlgorithm) {
|
private static void assumeAes256(String cipherAlgorithm) {
|
||||||
boolean aes256Available = false;
|
boolean aes256Available = false;
|
||||||
try {
|
try {
|
||||||
Cipher.getInstance(cipherAlgorithm.toString());
|
Cipher.getInstance(cipherAlgorithm);
|
||||||
aes256Available = Cipher.getMaxAllowedKeyLength("AES") >= 256;
|
aes256Available = Cipher.getMaxAllowedKeyLength("AES") >= 256;
|
||||||
}
|
}
|
||||||
catch (NoSuchAlgorithmException ex) {
|
catch (NoSuchAlgorithmException ex) {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ The code is distributed as part of the core module but has no dependencies on an
|
|||||||
|
|
||||||
[[spring-security-crypto-encryption]]
|
[[spring-security-crypto-encryption]]
|
||||||
== Encryptors
|
== Encryptors
|
||||||
The javadoc:org.springframework.security.crypto.encrypt.Encryptors[] class provides factory methods for constructing symmetric encryptors.
|
Spring Security provides javadoc:org.springframework.security.crypto.encrypt.AesGcmBytesEncryptor[] and javadoc:org.springframework.security.crypto.encrypt.AesCbcBytesEncryptor[] for constructing symmetric encryptors.
|
||||||
This class lets you create javadoc:org.springframework.security.crypto.encrypt.BytesEncryptor[] instances to encrypt data in raw `byte[]` form.
|
These can be used to encrypt data in raw `byte[]` form.
|
||||||
You can also construct javadoc:org.springframework.security.crypto.encrypt.TextEncryptor[] instances to encrypt text strings.
|
You can also construct javadoc:org.springframework.security.crypto.encrypt.TextEncryptor[] instances to encrypt text strings.
|
||||||
Encryptors are thread-safe.
|
Encryptors are thread-safe.
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ Both `BytesEncryptor` and `TextEncryptor` are interfaces. `BytesEncryptor` has m
|
|||||||
|
|
||||||
[[spring-security-crypto-encryption-bytes]]
|
[[spring-security-crypto-encryption-bytes]]
|
||||||
=== BytesEncryptor
|
=== BytesEncryptor
|
||||||
You can use the `Encryptors.stronger` factory method to construct a `BytesEncryptor`:
|
Use `AesGcmBytesEncryptor` to construct a BytesEncryptor with authenticated encryption:
|
||||||
|
|
||||||
.BytesEncryptor
|
.BytesEncryptor
|
||||||
[tabs]
|
[tabs]
|
||||||
@@ -29,24 +29,22 @@ Java::
|
|||||||
+
|
+
|
||||||
[source,java,role="primary"]
|
[source,java,role="primary"]
|
||||||
----
|
----
|
||||||
Encryptors.stronger("password", "salt");
|
AesGcmBytesEncryptor.withPassword("password", "salt").build();
|
||||||
----
|
----
|
||||||
|
|
||||||
Kotlin::
|
Kotlin::
|
||||||
+
|
+
|
||||||
[source,kotlin,role="secondary"]
|
[source,kotlin,role="secondary"]
|
||||||
----
|
----
|
||||||
Encryptors.stronger("password", "salt")
|
AesGcmBytesEncryptor.withPassword("password", "salt").build()
|
||||||
----
|
----
|
||||||
======
|
======
|
||||||
|
|
||||||
The `stronger` encryption method creates an encryptor by using 256-bit AES encryption with
|
`AesGcmBytesEncryptor` uses 256-bit AES encryption with Galois Counter Mode (GCM), providing https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated encryption] (AEAD).
|
||||||
Galois Counter Mode (GCM).
|
It derives the secret key using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2).
|
||||||
It derives the secret key by using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2).
|
The password used to generate the SecretKey should be kept in a secure place and not be shared.
|
||||||
This method requires Java 6.
|
The salt is used to prevent dictionary attacks against the key in the event your encrypted data is compromised.
|
||||||
The password used to generate the `SecretKey` should be kept in a secure place and should not be shared.
|
A 16-byte random initialization vector is also applied so each encrypted message is unique.
|
||||||
The salt is used to prevent dictionary attacks against the key in the event that your encrypted data is compromised.
|
|
||||||
A 16-byte random initialization vector is also applied so that each encrypted message is unique.
|
|
||||||
|
|
||||||
The provided salt should be in hex-encoded String form, be random, and be at least 8 bytes in length.
|
The provided salt should be in hex-encoded String form, be random, and be at least 8 bytes in length.
|
||||||
You can generate such a salt by using a `KeyGenerator`:
|
You can generate such a salt by using a `KeyGenerator`:
|
||||||
@@ -69,14 +67,33 @@ val salt = KeyGenerators.string().generateKey() // generates a random 8-byte sal
|
|||||||
----
|
----
|
||||||
======
|
======
|
||||||
|
|
||||||
You can also use the `standard` encryption method, which is 256-bit AES in Cipher Block Chaining (CBC) Mode.
|
Users who require CBC mode may use `AesCbcBytesEncryptor`:
|
||||||
|
|
||||||
|
.AesCbcBytesEncryptor
|
||||||
|
[tabs]
|
||||||
|
======
|
||||||
|
Java::
|
||||||
|
+
|
||||||
|
[source,java,role="primary"]
|
||||||
|
----
|
||||||
|
AesCbcBytesEncryptor.withPassword("password", "salt").build();
|
||||||
|
----
|
||||||
|
|
||||||
|
Kotlin::
|
||||||
|
+
|
||||||
|
[source,kotlin,role="secondary"]
|
||||||
|
----
|
||||||
|
AesCbcBytesEncryptor.withPassword("password", "salt").build()
|
||||||
|
----
|
||||||
|
======
|
||||||
|
|
||||||
This mode is not https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated] and does not provide any
|
This mode is not https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated] and does not provide any
|
||||||
guarantees about the authenticity of the data.
|
guarantees about the authenticity of the data.
|
||||||
For a more secure alternative, use `Encryptors.stronger`.
|
For a more secure alternative, users should prefer `AesGcmBytesEncryptor`.
|
||||||
|
|
||||||
[[spring-security-crypto-encryption-text]]
|
[[spring-security-crypto-encryption-text]]
|
||||||
=== TextEncryptor
|
=== TextEncryptor
|
||||||
You can use the `Encryptors.text` factory method to construct a standard TextEncryptor:
|
Use `AesCbcBytesEncryptor` to encrypt text data:
|
||||||
|
|
||||||
.TextEncryptor
|
.TextEncryptor
|
||||||
[tabs]
|
[tabs]
|
||||||
@@ -85,19 +102,22 @@ Java::
|
|||||||
+
|
+
|
||||||
[source,java,role="primary"]
|
[source,java,role="primary"]
|
||||||
----
|
----
|
||||||
Encryptors.text("password", "salt");
|
AesCbcBytesEncryptor.withPassword("password", "salt").build();
|
||||||
----
|
----
|
||||||
|
|
||||||
Kotlin::
|
Kotlin::
|
||||||
+
|
+
|
||||||
[source,kotlin,role="secondary"]
|
[source,kotlin,role="secondary"]
|
||||||
----
|
----
|
||||||
Encryptors.text("password", "salt")
|
AesCbcBytesEncryptor.withPassword("password", "salt").build()
|
||||||
----
|
----
|
||||||
======
|
======
|
||||||
|
|
||||||
A `TextEncryptor` uses a standard `BytesEncryptor` to encrypt text data.
|
`AesCbcBytesEncryptor` encrypts data as raw bytes.
|
||||||
Encrypted results are returned as hex-encoded strings for easy storage on the filesystem or in a database.
|
You can hex-encode the result for easy storage on the filesystem or in the database.
|
||||||
|
|
||||||
|
NOTE: Queryable text encryption (encrypting such that the same plaintext always produces the same ciphertext) is no longer recommended, as it relies on a fixed initialization vector and does not provide adequate security.
|
||||||
|
Instead, look to your data store for a mechanism to query encrypted data.
|
||||||
|
|
||||||
[[spring-security-crypto-keygenerators]]
|
[[spring-security-crypto-keygenerators]]
|
||||||
== Key Generators
|
== Key Generators
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
#
|
#
|
||||||
springBootVersion=4.2.0-SNAPSHOT
|
springBootVersion=4.2.0-SNAPSHOT
|
||||||
version=7.2.0-SNAPSHOT
|
version=7.2.0-M1
|
||||||
samplesBranch=main
|
samplesBranch=main
|
||||||
org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError
|
||||||
org.gradle.parallel=true
|
org.gradle.parallel=true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[versions]
|
[versions]
|
||||||
com-squareup-okhttp3 = "3.14.9"
|
com-squareup-okhttp3 = "3.14.9"
|
||||||
io-micrometer = "1.17.0"
|
io-micrometer = "1.18.0-M1"
|
||||||
io-rsocket = "1.1.5"
|
io-rsocket = "1.1.5"
|
||||||
io-spring-javaformat = "0.0.47"
|
io-spring-javaformat = "0.0.47"
|
||||||
io-spring-nohttp = "0.0.11"
|
io-spring-nohttp = "0.0.11"
|
||||||
@@ -14,7 +14,7 @@ org-jetbrains-kotlin = "2.4.10"
|
|||||||
org-jetbrains-kotlinx = "1.11.0"
|
org-jetbrains-kotlinx = "1.11.0"
|
||||||
org-mockito = "5.23.0"
|
org-mockito = "5.23.0"
|
||||||
org-opensaml5 = "5.2.3"
|
org-opensaml5 = "5.2.3"
|
||||||
org-springframework = "7.0.8"
|
org-springframework = "7.1.0-M1"
|
||||||
com-password4j = "1.8.4"
|
com-password4j = "1.8.4"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
@@ -33,7 +33,7 @@ io-micrometer-context-propagation = "io.micrometer:context-propagation:1.2.1"
|
|||||||
io-micrometer-micrometer-bom = { module = "io.micrometer:micrometer-bom", version.ref = "io-micrometer" }
|
io-micrometer-micrometer-bom = { module = "io.micrometer:micrometer-bom", version.ref = "io-micrometer" }
|
||||||
io-micrometer-micrometer-observation = { module = "io.micrometer:micrometer-observation", version.ref = "io-micrometer" }
|
io-micrometer-micrometer-observation = { module = "io.micrometer:micrometer-observation", version.ref = "io-micrometer" }
|
||||||
io-mockk = "io.mockk:mockk:1.14.11"
|
io-mockk = "io.mockk:mockk:1.14.11"
|
||||||
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2025.0.6"
|
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2026.0.0-M1"
|
||||||
io-rsocket-rsocket-bom = { module = "io.rsocket:rsocket-bom", version.ref = "io-rsocket" }
|
io-rsocket-rsocket-bom = { module = "io.rsocket:rsocket-bom", version.ref = "io-rsocket" }
|
||||||
io-spring-javaformat-spring-javaformat-checkstyle = { module = "io.spring.javaformat:spring-javaformat-checkstyle", version.ref = "io-spring-javaformat" }
|
io-spring-javaformat-spring-javaformat-checkstyle = { module = "io.spring.javaformat:spring-javaformat-checkstyle", version.ref = "io-spring-javaformat" }
|
||||||
io-spring-javaformat-spring-javaformat-gradle-plugin = { module = "io.spring.javaformat:spring-javaformat-gradle-plugin", version.ref = "io-spring-javaformat" }
|
io-spring-javaformat-spring-javaformat-gradle-plugin = { module = "io.spring.javaformat:spring-javaformat-gradle-plugin", version.ref = "io-spring-javaformat" }
|
||||||
@@ -85,7 +85,7 @@ org-seleniumhq-selenium-selenium-support = "org.seleniumhq.selenium:selenium-sup
|
|||||||
org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3"
|
org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3"
|
||||||
org-slf4j-log4j-over-slf4j = "org.slf4j:log4j-over-slf4j:1.7.36"
|
org-slf4j-log4j-over-slf4j = "org.slf4j:log4j-over-slf4j:1.7.36"
|
||||||
org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.18"
|
org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.18"
|
||||||
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2026.0.0"
|
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2026.1.0-M1"
|
||||||
org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.1.0"
|
org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.1.0"
|
||||||
org-springframework-spring-framework-bom = { module = "org.springframework:spring-framework-bom", version.ref = "org-springframework" }
|
org-springframework-spring-framework-bom = { module = "org.springframework:spring-framework-bom", version.ref = "org-springframework" }
|
||||||
org-synchronoss-cloud-nio-multipart-parser = "org.synchronoss.cloud:nio-multipart-parser:1.1.0"
|
org-synchronoss-cloud-nio-multipart-parser = "org.synchronoss.cloud:nio-multipart-parser:1.1.0"
|
||||||
|
|||||||
+20
@@ -21,9 +21,11 @@ import java.net.ServerSocket;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import org.springframework.context.support.GenericApplicationContext;
|
import org.springframework.context.support.GenericApplicationContext;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@@ -48,6 +50,24 @@ public class UnboundIdContainerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void startLdapServerThenListenerBindsToLoopbackAddressOnly() throws Exception {
|
||||||
|
UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null);
|
||||||
|
server.setApplicationContext(new GenericApplicationContext());
|
||||||
|
List<Integer> ports = getDefaultPorts(1);
|
||||||
|
server.setPort(ports.get(0));
|
||||||
|
|
||||||
|
try {
|
||||||
|
server.afterPropertiesSet();
|
||||||
|
InMemoryDirectoryServer directoryServer = (InMemoryDirectoryServer) ReflectionTestUtils.getField(server,
|
||||||
|
"directoryServer");
|
||||||
|
assertThat(directoryServer.getListenAddress().isLoopbackAddress()).isTrue();
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
server.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void afterPropertiesSetWhenPortIsZeroThenRandomPortIsSelected() throws Exception {
|
public void afterPropertiesSetWhenPortIsZeroThenRandomPortIsSelected() throws Exception {
|
||||||
UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null);
|
UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
package org.springframework.security.ldap.server;
|
package org.springframework.security.ldap.server;
|
||||||
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
|
||||||
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
|
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
|
||||||
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
|
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
|
||||||
@@ -98,7 +99,8 @@ public class UnboundIdContainer
|
|||||||
try {
|
try {
|
||||||
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(this.defaultPartitionSuffix);
|
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(this.defaultPartitionSuffix);
|
||||||
config.addAdditionalBindCredentials("uid=admin,ou=system", "secret");
|
config.addAdditionalBindCredentials("uid=admin,ou=system", "secret");
|
||||||
config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("LDAP", this.port));
|
config.setListenerConfigs(
|
||||||
|
InMemoryListenerConfig.createLDAPConfig("LDAP", InetAddress.getLoopbackAddress(), this.port, null));
|
||||||
config.setEnforceSingleStructuralObjectClass(false);
|
config.setEnforceSingleStructuralObjectClass(false);
|
||||||
config.setEnforceAttributeSyntaxCompliance(true);
|
config.setEnforceAttributeSyntaxCompliance(true);
|
||||||
DN dn = new DN(this.defaultPartitionSuffix);
|
DN dn = new DN(this.defaultPartitionSuffix);
|
||||||
|
|||||||
+8
-7
@@ -25,6 +25,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
|
|
||||||
import org.jspecify.annotations.Nullable;
|
import org.jspecify.annotations.Nullable;
|
||||||
|
|
||||||
|
import org.springframework.security.crypto.codec.Utf8;
|
||||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||||
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
||||||
@@ -191,38 +192,38 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean matchesState(OAuth2Authorization authorization, String token) {
|
private static boolean matchesState(OAuth2Authorization authorization, String token) {
|
||||||
return token.equals(authorization.getAttribute(OAuth2ParameterNames.STATE));
|
return Utf8.isEqual(authorization.getAttribute(OAuth2ParameterNames.STATE), token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean matchesAuthorizationCode(OAuth2Authorization authorization, String token) {
|
private static boolean matchesAuthorizationCode(OAuth2Authorization authorization, String token) {
|
||||||
OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = authorization
|
OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = authorization
|
||||||
.getToken(OAuth2AuthorizationCode.class);
|
.getToken(OAuth2AuthorizationCode.class);
|
||||||
return authorizationCode != null && authorizationCode.getToken().getTokenValue().equals(token);
|
return authorizationCode != null && Utf8.isEqual(authorizationCode.getToken().getTokenValue(), token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean matchesAccessToken(OAuth2Authorization authorization, String token) {
|
private static boolean matchesAccessToken(OAuth2Authorization authorization, String token) {
|
||||||
OAuth2Authorization.Token<OAuth2AccessToken> accessToken = authorization.getToken(OAuth2AccessToken.class);
|
OAuth2Authorization.Token<OAuth2AccessToken> accessToken = authorization.getToken(OAuth2AccessToken.class);
|
||||||
return accessToken != null && accessToken.getToken().getTokenValue().equals(token);
|
return accessToken != null && Utf8.isEqual(accessToken.getToken().getTokenValue(), token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean matchesRefreshToken(OAuth2Authorization authorization, String token) {
|
private static boolean matchesRefreshToken(OAuth2Authorization authorization, String token) {
|
||||||
OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = authorization.getToken(OAuth2RefreshToken.class);
|
OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = authorization.getToken(OAuth2RefreshToken.class);
|
||||||
return refreshToken != null && refreshToken.getToken().getTokenValue().equals(token);
|
return refreshToken != null && Utf8.isEqual(refreshToken.getToken().getTokenValue(), token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean matchesIdToken(OAuth2Authorization authorization, String token) {
|
private static boolean matchesIdToken(OAuth2Authorization authorization, String token) {
|
||||||
OAuth2Authorization.Token<OidcIdToken> idToken = authorization.getToken(OidcIdToken.class);
|
OAuth2Authorization.Token<OidcIdToken> idToken = authorization.getToken(OidcIdToken.class);
|
||||||
return idToken != null && idToken.getToken().getTokenValue().equals(token);
|
return idToken != null && Utf8.isEqual(idToken.getToken().getTokenValue(), token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) {
|
private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) {
|
||||||
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode = authorization.getToken(OAuth2DeviceCode.class);
|
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode = authorization.getToken(OAuth2DeviceCode.class);
|
||||||
return deviceCode != null && deviceCode.getToken().getTokenValue().equals(token);
|
return deviceCode != null && Utf8.isEqual(deviceCode.getToken().getTokenValue(), token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean matchesUserCode(OAuth2Authorization authorization, String token) {
|
private static boolean matchesUserCode(OAuth2Authorization authorization, String token) {
|
||||||
OAuth2Authorization.Token<OAuth2UserCode> userCode = authorization.getToken(OAuth2UserCode.class);
|
OAuth2Authorization.Token<OAuth2UserCode> userCode = authorization.getToken(OAuth2UserCode.class);
|
||||||
return userCode != null && userCode.getToken().getTokenValue().equals(token);
|
return userCode != null && Utf8.isEqual(userCode.getToken().getTokenValue(), token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("serial")
|
@SuppressWarnings("serial")
|
||||||
|
|||||||
+20
-10
@@ -29,6 +29,7 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||||
|
import org.springframework.web.util.HtmlUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For internal use only.
|
* For internal use only.
|
||||||
@@ -56,6 +57,12 @@ final class DefaultConsentPage {
|
|||||||
private static String generateConsentPage(HttpServletRequest request, String clientId, Authentication principal,
|
private static String generateConsentPage(HttpServletRequest request, String clientId, Authentication principal,
|
||||||
Set<String> requestedScopes, Set<String> authorizedScopes, String state,
|
Set<String> requestedScopes, Set<String> authorizedScopes, String state,
|
||||||
Map<String, String> additionalParameters) {
|
Map<String, String> additionalParameters) {
|
||||||
|
|
||||||
|
String encodedClientId = HtmlUtils.htmlEscape(clientId);
|
||||||
|
String encodedState = HtmlUtils.htmlEscape(state);
|
||||||
|
String encodedPrincipalName = HtmlUtils.htmlEscape(principal.getName());
|
||||||
|
String encodedRequestUri = HtmlUtils.htmlEscape(request.getRequestURI());
|
||||||
|
|
||||||
Set<String> scopesToAuthorize = new HashSet<>();
|
Set<String> scopesToAuthorize = new HashSet<>();
|
||||||
Set<String> scopesPreviouslyAuthorized = new HashSet<>();
|
Set<String> scopesPreviouslyAuthorized = new HashSet<>();
|
||||||
for (String scope : requestedScopes) {
|
for (String scope : requestedScopes) {
|
||||||
@@ -74,6 +81,7 @@ final class DefaultConsentPage {
|
|||||||
// the "user_code" being displayed on the device to confirm they are
|
// the "user_code" being displayed on the device to confirm they are
|
||||||
// authorizing the correct device.
|
// authorizing the correct device.
|
||||||
String userCode = additionalParameters.get(OAuth2ParameterNames.USER_CODE);
|
String userCode = additionalParameters.get(OAuth2ParameterNames.USER_CODE);
|
||||||
|
String encodedUserCode = (userCode != null) ? HtmlUtils.htmlEscape(userCode) : null;
|
||||||
|
|
||||||
// @formatter:off
|
// @formatter:off
|
||||||
StringBuilder builder = new StringBuilder();
|
StringBuilder builder = new StringBuilder();
|
||||||
@@ -98,13 +106,13 @@ final class DefaultConsentPage {
|
|||||||
builder.append(" </div>");
|
builder.append(" </div>");
|
||||||
builder.append(" <div class=\"row\">");
|
builder.append(" <div class=\"row\">");
|
||||||
builder.append(" <div class=\"col text-center\">");
|
builder.append(" <div class=\"col text-center\">");
|
||||||
builder.append(" <p><span class=\"font-weight-bold text-primary\">" + clientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + principal.getName() + "</span></p>");
|
builder.append(" <p><span class=\"font-weight-bold text-primary\">" + encodedClientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + encodedPrincipalName + "</span></p>");
|
||||||
builder.append(" </div>");
|
builder.append(" </div>");
|
||||||
builder.append(" </div>");
|
builder.append(" </div>");
|
||||||
if (userCode != null) {
|
if (userCode != null) {
|
||||||
builder.append(" <div class=\"row\">");
|
builder.append(" <div class=\"row\">");
|
||||||
builder.append(" <div class=\"col text-center\">");
|
builder.append(" <div class=\"col text-center\">");
|
||||||
builder.append(" <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + userCode + "</span>. Verify that this code matches what is shown on your device.</p>");
|
builder.append(" <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + encodedUserCode + "</span>. Verify that this code matches what is shown on your device.</p>");
|
||||||
builder.append(" </div>");
|
builder.append(" </div>");
|
||||||
builder.append(" </div>");
|
builder.append(" </div>");
|
||||||
}
|
}
|
||||||
@@ -115,26 +123,28 @@ final class DefaultConsentPage {
|
|||||||
builder.append(" </div>");
|
builder.append(" </div>");
|
||||||
builder.append(" <div class=\"row\">");
|
builder.append(" <div class=\"row\">");
|
||||||
builder.append(" <div class=\"col text-center\">");
|
builder.append(" <div class=\"col text-center\">");
|
||||||
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + request.getRequestURI() + "\">");
|
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + encodedRequestUri + "\">");
|
||||||
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + clientId + "\">");
|
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + encodedClientId + "\">");
|
||||||
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + state + "\">");
|
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + encodedState + "\">");
|
||||||
if (userCode != null) {
|
if (userCode != null) {
|
||||||
builder.append(" <input type=\"hidden\" name=\"user_code\" value=\"" + userCode + "\">");
|
builder.append(" <input type=\"hidden\" name=\"user_code\" value=\"" + encodedUserCode + "\">");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (String scope : scopesToAuthorize) {
|
for (String scope : scopesToAuthorize) {
|
||||||
|
String encodedScope = HtmlUtils.htmlEscape(scope);
|
||||||
builder.append(" <div class=\"form-group form-check py-1\">");
|
builder.append(" <div class=\"form-group form-check py-1\">");
|
||||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + scope + "\" id=\"" + scope + "\">");
|
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + encodedScope + "\" id=\"" + encodedScope + "\">");
|
||||||
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
|
builder.append(" <label class=\"form-check-label\" for=\"" + encodedScope + "\">" + encodedScope + "</label>");
|
||||||
builder.append(" </div>");
|
builder.append(" </div>");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!scopesPreviouslyAuthorized.isEmpty()) {
|
if (!scopesPreviouslyAuthorized.isEmpty()) {
|
||||||
builder.append(" <p>You have already granted the following permissions to the above app:</p>");
|
builder.append(" <p>You have already granted the following permissions to the above app:</p>");
|
||||||
for (String scope : scopesPreviouslyAuthorized) {
|
for (String scope : scopesPreviouslyAuthorized) {
|
||||||
|
String encodedScope = HtmlUtils.htmlEscape(scope);
|
||||||
builder.append(" <div class=\"form-group form-check py-1\">");
|
builder.append(" <div class=\"form-group form-check py-1\">");
|
||||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + scope + "\" checked disabled>");
|
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + encodedScope + "\" checked disabled>");
|
||||||
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
|
builder.append(" <label class=\"form-check-label\" for=\"" + encodedScope + "\">" + encodedScope + "</label>");
|
||||||
builder.append(" </div>");
|
builder.append(" </div>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+44
@@ -25,7 +25,9 @@ import org.junit.jupiter.api.Test;
|
|||||||
|
|
||||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||||
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2UserCode;
|
||||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
|
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
|
||||||
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
|
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
|
||||||
@@ -60,6 +62,10 @@ public class InMemoryOAuth2AuthorizationServiceTests {
|
|||||||
|
|
||||||
private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN);
|
private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN);
|
||||||
|
|
||||||
|
private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE);
|
||||||
|
|
||||||
|
private static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE);
|
||||||
|
|
||||||
private InMemoryOAuth2AuthorizationService authorizationService;
|
private InMemoryOAuth2AuthorizationService authorizationService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -313,6 +319,44 @@ public class InMemoryOAuth2AuthorizationServiceTests {
|
|||||||
assertThat(authorization).isEqualTo(result);
|
assertThat(authorization).isEqualTo(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void findByTokenWhenDeviceCodeExistsThenFound() {
|
||||||
|
OAuth2DeviceCode deviceCode = new OAuth2DeviceCode("device-code", Instant.now(),
|
||||||
|
Instant.now().plus(5, ChronoUnit.MINUTES));
|
||||||
|
OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
|
||||||
|
.id(ID)
|
||||||
|
.principalName(PRINCIPAL_NAME)
|
||||||
|
.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
|
||||||
|
.token(deviceCode)
|
||||||
|
.build();
|
||||||
|
this.authorizationService.save(authorization);
|
||||||
|
|
||||||
|
OAuth2Authorization result = this.authorizationService.findByToken(deviceCode.getTokenValue(),
|
||||||
|
DEVICE_CODE_TOKEN_TYPE);
|
||||||
|
assertThat(authorization).isEqualTo(result);
|
||||||
|
result = this.authorizationService.findByToken(deviceCode.getTokenValue(), null);
|
||||||
|
assertThat(authorization).isEqualTo(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void findByTokenWhenUserCodeExistsThenFound() {
|
||||||
|
OAuth2UserCode userCode = new OAuth2UserCode("user-code", Instant.now(),
|
||||||
|
Instant.now().plus(5, ChronoUnit.MINUTES));
|
||||||
|
OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
|
||||||
|
.id(ID)
|
||||||
|
.principalName(PRINCIPAL_NAME)
|
||||||
|
.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
|
||||||
|
.token(userCode)
|
||||||
|
.build();
|
||||||
|
this.authorizationService.save(authorization);
|
||||||
|
|
||||||
|
OAuth2Authorization result = this.authorizationService.findByToken(userCode.getTokenValue(),
|
||||||
|
USER_CODE_TOKEN_TYPE);
|
||||||
|
assertThat(authorization).isEqualTo(result);
|
||||||
|
result = this.authorizationService.findByToken(userCode.getTokenValue(), null);
|
||||||
|
assertThat(authorization).isEqualTo(result);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void findByTokenWhenWrongTokenTypeThenNotFound() {
|
public void findByTokenWhenWrongTokenTypeThenNotFound() {
|
||||||
OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now());
|
OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now());
|
||||||
|
|||||||
+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.WebAuthenticationDetails;
|
||||||
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.util.HtmlUtils;
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -581,6 +582,39 @@ public class OAuth2AuthorizationEndpointFilterTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void doFilterWhenAuthorizationRequestConsentRequiredThenConsentResponseHtmlEscaped() throws Exception {
|
||||||
|
String unencodedScope = "<scope2>";
|
||||||
|
String unencodedState = "<state>";
|
||||||
|
Set<String> requestedScopes = new HashSet<>(Arrays.asList("scope1", unencodedScope));
|
||||||
|
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes((scopes) -> {
|
||||||
|
scopes.clear();
|
||||||
|
scopes.addAll(requestedScopes);
|
||||||
|
}).build();
|
||||||
|
// No scopes previously approved
|
||||||
|
OAuth2AuthorizationConsentAuthenticationToken authorizationConsentAuthenticationResult = new OAuth2AuthorizationConsentAuthenticationToken(
|
||||||
|
AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, unencodedState, new HashSet<>(),
|
||||||
|
null);
|
||||||
|
authorizationConsentAuthenticationResult.setAuthenticated(true);
|
||||||
|
given(this.authenticationManager.authenticate(any())).willReturn(authorizationConsentAuthenticationResult);
|
||||||
|
|
||||||
|
MockHttpServletRequest request = createAuthorizationRequest(registeredClient);
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
FilterChain filterChain = mock(FilterChain.class);
|
||||||
|
|
||||||
|
this.filter.doFilter(request, response, filterChain);
|
||||||
|
|
||||||
|
verify(this.authenticationManager).authenticate(any());
|
||||||
|
verifyNoInteractions(filterChain);
|
||||||
|
|
||||||
|
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
|
||||||
|
String html = response.getContentAsString();
|
||||||
|
assertThat(html).doesNotContain(unencodedScope);
|
||||||
|
assertThat(html).doesNotContain(unencodedState);
|
||||||
|
assertThat(html).contains(HtmlUtils.htmlEscape(unencodedScope));
|
||||||
|
assertThat(html).contains("name=\"state\" value=\"" + HtmlUtils.htmlEscape(unencodedState) + "\"");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationResponse() throws Exception {
|
public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationResponse() throws Exception {
|
||||||
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().redirectUris((redirectUris) -> {
|
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().redirectUris((redirectUris) -> {
|
||||||
|
|||||||
+14
@@ -33,6 +33,8 @@ public final class DelegatingOAuth2TokenValidator<T extends OAuth2Token> impleme
|
|||||||
|
|
||||||
private final Collection<OAuth2TokenValidator<T>> tokenValidators;
|
private final Collection<OAuth2TokenValidator<T>> tokenValidators;
|
||||||
|
|
||||||
|
private boolean failOnError;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a {@code DelegatingOAuth2TokenValidator} using the provided validators.
|
* Constructs a {@code DelegatingOAuth2TokenValidator} using the provided validators.
|
||||||
* @param tokenValidators the {@link Collection} of {@link OAuth2TokenValidator}s to
|
* @param tokenValidators the {@link Collection} of {@link OAuth2TokenValidator}s to
|
||||||
@@ -57,8 +59,20 @@ public final class DelegatingOAuth2TokenValidator<T extends OAuth2Token> impleme
|
|||||||
Collection<OAuth2Error> errors = new ArrayList<>();
|
Collection<OAuth2Error> errors = new ArrayList<>();
|
||||||
for (OAuth2TokenValidator<T> validator : this.tokenValidators) {
|
for (OAuth2TokenValidator<T> validator : this.tokenValidators) {
|
||||||
errors.addAll(validator.validate(token).getErrors());
|
errors.addAll(validator.validate(token).getErrors());
|
||||||
|
if (!errors.isEmpty() && this.failOnError) {
|
||||||
|
return OAuth2TokenValidatorResult.failure(errors);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return OAuth2TokenValidatorResult.failure(errors);
|
return OAuth2TokenValidatorResult.failure(errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail-fast when a delegate errors, defaults to {@code false}.
|
||||||
|
* @param failOnError fail-fast when a delegate errors
|
||||||
|
* @since 6.5.12
|
||||||
|
*/
|
||||||
|
public void setFailOnError(boolean failOnError) {
|
||||||
|
this.failOnError = failOnError;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+146
-35
@@ -18,11 +18,11 @@ package org.springframework.security.oauth2.jwt;
|
|||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.time.Instant;
|
import java.time.Duration;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
|
||||||
@@ -39,12 +39,15 @@ import com.nimbusds.jose.proc.SecurityContext;
|
|||||||
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
|
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
|
||||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||||||
|
|
||||||
|
import org.springframework.security.oauth2.core.ClaimAccessor;
|
||||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2Token;
|
||||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||||
import org.springframework.util.Assert;
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,10 +66,11 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The default {@code OAuth2TokenValidator<Jwt>} factory that validates the
|
* The default {@code OAuth2TokenValidator<Jwt>} factory that validates the
|
||||||
* {@code htm}, {@code htu}, {@code jti} and {@code iat} claims of the DPoP Proof
|
* {@code htm}, {@code htu}, {@code iat}, {@code jkt}, {@code ath} and {@code jti}
|
||||||
* {@link Jwt}.
|
* claims of the DPoP Proof {@link Jwt}.
|
||||||
*/
|
*/
|
||||||
public static final Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = defaultJwtValidatorFactory();
|
public static final Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = createDefaultJwtValidatorFactory(
|
||||||
|
Collections.emptyList());
|
||||||
|
|
||||||
private static final JOSEObjectTypeVerifier<SecurityContext> DPOP_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>(
|
private static final JOSEObjectTypeVerifier<SecurityContext> DPOP_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>(
|
||||||
new JOSEObjectType("dpop+jwt"));
|
new JOSEObjectType("dpop+jwt"));
|
||||||
@@ -94,6 +98,73 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
|||||||
this.jwtValidatorFactory = jwtValidatorFactory;
|
this.jwtValidatorFactory = jwtValidatorFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a factory that provides an {@link OAuth2TokenValidator} for the specified
|
||||||
|
* {@link DPoPProofContext} and is used by the {@link JwtDecoder}. The returned
|
||||||
|
* factory provides a validator that validates the {@code htm}, {@code htu},
|
||||||
|
* {@code iat}, {@code jkt}, {@code ath} and {@code jti} claims, along with any custom
|
||||||
|
* validators provided.
|
||||||
|
* @param validators the custom validators to add
|
||||||
|
* @return a factory that provides an {@link OAuth2TokenValidator} for the specified
|
||||||
|
* {@link DPoPProofContext}
|
||||||
|
* @since 6.5.12
|
||||||
|
*/
|
||||||
|
public static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> createDefaultJwtValidatorFactory(
|
||||||
|
List<OAuth2TokenValidator<Jwt>> validators) {
|
||||||
|
Assert.notNull(validators, "validators cannot be null");
|
||||||
|
List<OAuth2TokenValidator<Jwt>> customValidators = new ArrayList<>();
|
||||||
|
if (!CollectionUtils.isEmpty(validators)) {
|
||||||
|
customValidators.addAll(validators);
|
||||||
|
}
|
||||||
|
final Duration clockSkew = Duration.ofSeconds(30);
|
||||||
|
final JwtIssuedAtValidator jwtIssuedAtValidator;
|
||||||
|
JwtIssuedAtValidator existingJwtIssuedAtValidator = CollectionUtils.findValueOfType(customValidators,
|
||||||
|
JwtIssuedAtValidator.class);
|
||||||
|
if (existingJwtIssuedAtValidator != null) {
|
||||||
|
jwtIssuedAtValidator = existingJwtIssuedAtValidator;
|
||||||
|
customValidators.remove(jwtIssuedAtValidator);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
jwtIssuedAtValidator = new JwtIssuedAtValidator(true);
|
||||||
|
jwtIssuedAtValidator.setClockSkew(clockSkew);
|
||||||
|
}
|
||||||
|
final DPoPProofReplayValidator dPoPProofReplayValidator;
|
||||||
|
DPoPProofReplayValidator existingDPoPProofReplayValidator = CollectionUtils.findValueOfType(customValidators,
|
||||||
|
DPoPProofReplayValidator.class);
|
||||||
|
if (existingDPoPProofReplayValidator != null) {
|
||||||
|
dPoPProofReplayValidator = existingDPoPProofReplayValidator;
|
||||||
|
customValidators.remove(dPoPProofReplayValidator);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
dPoPProofReplayValidator = new DPoPProofReplayValidator(new DPoPProofReplayValidator.InMemoryCache());
|
||||||
|
dPoPProofReplayValidator.setClockSkew(clockSkew);
|
||||||
|
}
|
||||||
|
return (context) -> createDefaultJwtValidatorFactory(context, jwtIssuedAtValidator, dPoPProofReplayValidator,
|
||||||
|
customValidators);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OAuth2TokenValidator<Jwt> createDefaultJwtValidatorFactory(DPoPProofContext context,
|
||||||
|
JwtIssuedAtValidator jwtIssuedAtValidator, DPoPProofReplayValidator dPoPProofReplayValidator,
|
||||||
|
List<OAuth2TokenValidator<Jwt>> customValidators) {
|
||||||
|
// Add custom validators first then default validators in a specific order
|
||||||
|
List<OAuth2TokenValidator<Jwt>> tokenValidators = new ArrayList<>();
|
||||||
|
if (!CollectionUtils.isEmpty(customValidators)) {
|
||||||
|
tokenValidators.addAll(customValidators);
|
||||||
|
}
|
||||||
|
tokenValidators.add(new JwtClaimValidator<>("htm", context.getMethod()::equalsIgnoreCase));
|
||||||
|
tokenValidators.add(new JwtClaimValidator<>("htu", context.getTargetUri()::equals));
|
||||||
|
tokenValidators.add(jwtIssuedAtValidator);
|
||||||
|
if (context.getAccessToken() != null) {
|
||||||
|
tokenValidators.add(new JwkThumbprintValidator(context.getAccessToken()));
|
||||||
|
tokenValidators.add(new AthClaimValidator(context.getAccessToken()));
|
||||||
|
}
|
||||||
|
tokenValidators.add(dPoPProofReplayValidator);
|
||||||
|
DelegatingOAuth2TokenValidator<Jwt> delegatingTokenValidator = new DelegatingOAuth2TokenValidator<>(
|
||||||
|
tokenValidators);
|
||||||
|
delegatingTokenValidator.setFailOnError(true);
|
||||||
|
return delegatingTokenValidator;
|
||||||
|
}
|
||||||
|
|
||||||
private static NimbusJwtDecoder buildDecoder() {
|
private static NimbusJwtDecoder buildDecoder() {
|
||||||
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
|
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
|
||||||
jwtProcessor.setJWSTypeVerifier(DPOP_TYPE_VERIFIER);
|
jwtProcessor.setJWSTypeVerifier(DPOP_TYPE_VERIFIER);
|
||||||
@@ -137,39 +208,34 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> defaultJwtValidatorFactory() {
|
private static final class AthClaimValidator implements OAuth2TokenValidator<Jwt> {
|
||||||
return (context) -> new DelegatingOAuth2TokenValidator<>(
|
|
||||||
new JwtClaimValidator<>("htm", context.getMethod()::equals),
|
|
||||||
new JwtClaimValidator<>("htu", context.getTargetUri()::equals), new JtiClaimValidator(),
|
|
||||||
new JwtIssuedAtValidator(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class JtiClaimValidator implements OAuth2TokenValidator<Jwt> {
|
private final OAuth2Token accessToken;
|
||||||
|
|
||||||
private static final Map<String, Long> JTI_CACHE = Collections.synchronizedMap(new JtiCache());
|
private AthClaimValidator(OAuth2Token accessToken) {
|
||||||
|
Assert.notNull(accessToken, "accessToken cannot be null");
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
||||||
String jti = jwt.getId();
|
String accessTokenHashClaim = jwt.getClaimAsString("ath");
|
||||||
if (!StringUtils.hasText(jti)) {
|
if (!StringUtils.hasText(accessTokenHashClaim)) {
|
||||||
OAuth2Error error = createOAuth2Error("jti claim is required.");
|
OAuth2Error error = createOAuth2Error("ath claim is required.");
|
||||||
return OAuth2TokenValidatorResult.failure(error);
|
return OAuth2TokenValidatorResult.failure(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce single-use to protect against DPoP proof replay
|
String accessTokenHash;
|
||||||
String jtiHash;
|
|
||||||
try {
|
try {
|
||||||
jtiHash = computeSHA256(jti);
|
accessTokenHash = computeSHA256(this.accessToken.getTokenValue());
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
|
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token.");
|
||||||
return OAuth2TokenValidatorResult.failure(error);
|
return OAuth2TokenValidatorResult.failure(error);
|
||||||
}
|
}
|
||||||
Instant expiry = Instant.now().plus(1, ChronoUnit.HOURS);
|
if (!accessTokenHashClaim.equals(accessTokenHash)) {
|
||||||
if ((JTI_CACHE.putIfAbsent(jtiHash, expiry.toEpochMilli())) != null) {
|
OAuth2Error error = createOAuth2Error("ath claim is invalid.");
|
||||||
// Already used
|
|
||||||
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
|
|
||||||
return OAuth2TokenValidatorResult.failure(error);
|
return OAuth2TokenValidatorResult.failure(error);
|
||||||
}
|
}
|
||||||
return OAuth2TokenValidatorResult.success();
|
return OAuth2TokenValidatorResult.success();
|
||||||
@@ -185,20 +251,65 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
|||||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("serial")
|
}
|
||||||
private static final class JtiCache extends LinkedHashMap<String, Long> {
|
|
||||||
|
|
||||||
private static final int MAX_SIZE = 1000;
|
private static final class JwkThumbprintValidator implements OAuth2TokenValidator<Jwt> {
|
||||||
|
|
||||||
@Override
|
private final OAuth2Token accessToken;
|
||||||
protected boolean removeEldestEntry(Map.Entry<String, Long> eldest) {
|
|
||||||
if (size() > MAX_SIZE) {
|
private final ClaimAccessor claims;
|
||||||
return true;
|
|
||||||
}
|
private JwkThumbprintValidator(OAuth2Token accessToken) {
|
||||||
Instant expiry = Instant.ofEpochMilli(eldest.getValue());
|
Assert.notNull(accessToken, "accessToken cannot be null");
|
||||||
return Instant.now().isAfter(expiry);
|
Assert.isInstanceOf(ClaimAccessor.class, accessToken, "accessToken must be instance of ClaimAccessor");
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.claims = (ClaimAccessor) accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||||
|
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
||||||
|
String jwkThumbprintClaim = null;
|
||||||
|
Map<String, Object> confirmationMethodClaim = this.claims.getClaimAsMap("cnf");
|
||||||
|
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) {
|
||||||
|
jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt");
|
||||||
|
}
|
||||||
|
if (jwkThumbprintClaim == null) {
|
||||||
|
OAuth2Error error = createOAuth2Error("jkt claim is required.");
|
||||||
|
return OAuth2TokenValidatorResult.failure(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JWK jwk = null;
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
|
||||||
|
try {
|
||||||
|
jwk = JWK.parse(jwkJson);
|
||||||
|
}
|
||||||
|
catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
if (jwk == null) {
|
||||||
|
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
|
||||||
|
return OAuth2TokenValidatorResult.failure(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
String jwkThumbprint;
|
||||||
|
try {
|
||||||
|
jwkThumbprint = jwk.computeThumbprint().toString();
|
||||||
|
}
|
||||||
|
catch (Exception ex) {
|
||||||
|
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk.");
|
||||||
|
return OAuth2TokenValidatorResult.failure(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jwkThumbprintClaim.equals(jwkThumbprint)) {
|
||||||
|
OAuth2Error error = createOAuth2Error("jkt claim is invalid.");
|
||||||
|
return OAuth2TokenValidatorResult.failure(error);
|
||||||
|
}
|
||||||
|
return OAuth2TokenValidatorResult.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OAuth2Error createOAuth2Error(String reason) {
|
||||||
|
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+447
@@ -0,0 +1,447 @@
|
|||||||
|
/*
|
||||||
|
* 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.jspecify.annotations.Nullable;
|
||||||
|
|
||||||
|
import org.springframework.cache.Cache;
|
||||||
|
import org.springframework.cache.support.SimpleValueWrapper;
|
||||||
|
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, @Nullable Object value) {
|
||||||
|
putIfAbsent(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable ValueWrapper putIfAbsent(Object key, @Nullable Object value) {
|
||||||
|
Assert.notNull(value, "value cannot be null");
|
||||||
|
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
-126
@@ -16,14 +16,9 @@
|
|||||||
|
|
||||||
package org.springframework.security.oauth2.server.resource.authentication;
|
package org.springframework.security.oauth2.server.resource.authentication;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Function;
|
|
||||||
|
|
||||||
import com.nimbusds.jose.jwk.JWK;
|
|
||||||
import org.jspecify.annotations.Nullable;
|
import org.jspecify.annotations.Nullable;
|
||||||
|
|
||||||
import org.springframework.security.authentication.AuthenticationManager;
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
@@ -31,22 +26,16 @@ import org.springframework.security.authentication.AuthenticationProvider;
|
|||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.AuthenticationException;
|
import org.springframework.security.core.AuthenticationException;
|
||||||
import org.springframework.security.oauth2.core.ClaimAccessor;
|
import org.springframework.security.oauth2.core.ClaimAccessor;
|
||||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
|
||||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||||
import org.springframework.security.oauth2.core.OAuth2Token;
|
import org.springframework.security.oauth2.core.OAuth2Token;
|
||||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
|
||||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
|
||||||
import org.springframework.security.oauth2.jwt.DPoPProofContext;
|
import org.springframework.security.oauth2.jwt.DPoPProofContext;
|
||||||
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
|
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
|
import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
|
||||||
import org.springframework.security.oauth2.jwt.JwtException;
|
|
||||||
import org.springframework.util.Assert;
|
import org.springframework.util.Assert;
|
||||||
import org.springframework.util.CollectionUtils;
|
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An {@link AuthenticationProvider} implementation that is responsible for authenticating
|
* An {@link AuthenticationProvider} implementation that is responsible for authenticating
|
||||||
@@ -73,18 +62,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
|
|||||||
public DPoPAuthenticationProvider(AuthenticationManager tokenAuthenticationManager) {
|
public DPoPAuthenticationProvider(AuthenticationManager tokenAuthenticationManager) {
|
||||||
Assert.notNull(tokenAuthenticationManager, "tokenAuthenticationManager cannot be null");
|
Assert.notNull(tokenAuthenticationManager, "tokenAuthenticationManager cannot be null");
|
||||||
this.tokenAuthenticationManager = tokenAuthenticationManager;
|
this.tokenAuthenticationManager = tokenAuthenticationManager;
|
||||||
Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = (context) -> {
|
this.dPoPProofVerifierFactory = new DPoPProofJwtDecoderFactory();
|
||||||
OAuth2AccessTokenClaims accessToken = context.getAccessToken();
|
|
||||||
Assert.notNull(accessToken, "accessToken cannot be null");
|
|
||||||
return new DelegatingOAuth2TokenValidator<>(
|
|
||||||
// Use default validators
|
|
||||||
DPoPProofJwtDecoderFactory.DEFAULT_JWT_VALIDATOR_FACTORY.apply(context),
|
|
||||||
// Add custom validators
|
|
||||||
new AthClaimValidator(accessToken), new JwkThumbprintValidator(accessToken));
|
|
||||||
};
|
|
||||||
DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory();
|
|
||||||
dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory);
|
|
||||||
this.dPoPProofVerifierFactory = dPoPProofJwtDecoderFactory;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -119,7 +97,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
|
|||||||
try {
|
try {
|
||||||
dPoPProofVerifier.decode(dPoPProofContext.getDPoPProof());
|
dPoPProofVerifier.decode(dPoPProofContext.getDPoPProof());
|
||||||
}
|
}
|
||||||
catch (JwtException ex) {
|
catch (Exception ex) {
|
||||||
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF);
|
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF);
|
||||||
throw new OAuth2AuthenticationException(error, ex);
|
throw new OAuth2AuthenticationException(error, ex);
|
||||||
}
|
}
|
||||||
@@ -144,108 +122,6 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
|
|||||||
this.dPoPProofVerifierFactory = dPoPProofVerifierFactory;
|
this.dPoPProofVerifierFactory = dPoPProofVerifierFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class AthClaimValidator implements OAuth2TokenValidator<Jwt> {
|
|
||||||
|
|
||||||
private final OAuth2AccessTokenClaims accessToken;
|
|
||||||
|
|
||||||
private AthClaimValidator(OAuth2AccessTokenClaims accessToken) {
|
|
||||||
Assert.notNull(accessToken, "accessToken cannot be null");
|
|
||||||
this.accessToken = accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
|
||||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
|
||||||
String accessTokenHashClaim = jwt.getClaimAsString("ath");
|
|
||||||
if (!StringUtils.hasText(accessTokenHashClaim)) {
|
|
||||||
OAuth2Error error = createOAuth2Error("ath claim is required.");
|
|
||||||
return OAuth2TokenValidatorResult.failure(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
String accessTokenHash;
|
|
||||||
try {
|
|
||||||
accessTokenHash = computeSHA256(this.accessToken.getTokenValue());
|
|
||||||
}
|
|
||||||
catch (Exception ex) {
|
|
||||||
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token.");
|
|
||||||
return OAuth2TokenValidatorResult.failure(error);
|
|
||||||
}
|
|
||||||
if (!accessTokenHashClaim.equals(accessTokenHash)) {
|
|
||||||
OAuth2Error error = createOAuth2Error("ath claim is invalid.");
|
|
||||||
return OAuth2TokenValidatorResult.failure(error);
|
|
||||||
}
|
|
||||||
return OAuth2TokenValidatorResult.success();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static OAuth2Error createOAuth2Error(String reason) {
|
|
||||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String computeSHA256(String value) throws Exception {
|
|
||||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
|
||||||
byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8));
|
|
||||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class JwkThumbprintValidator implements OAuth2TokenValidator<Jwt> {
|
|
||||||
|
|
||||||
private final OAuth2AccessTokenClaims accessToken;
|
|
||||||
|
|
||||||
private JwkThumbprintValidator(OAuth2AccessTokenClaims accessToken) {
|
|
||||||
Assert.notNull(accessToken, "accessToken cannot be null");
|
|
||||||
this.accessToken = accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
|
||||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
|
||||||
String jwkThumbprintClaim = null;
|
|
||||||
Map<String, Object> confirmationMethodClaim = this.accessToken.getClaimAsMap("cnf");
|
|
||||||
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) {
|
|
||||||
jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt");
|
|
||||||
}
|
|
||||||
if (jwkThumbprintClaim == null) {
|
|
||||||
OAuth2Error error = createOAuth2Error("jkt claim is required.");
|
|
||||||
return OAuth2TokenValidatorResult.failure(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
JWK jwk = null;
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
|
|
||||||
try {
|
|
||||||
jwk = JWK.parse(jwkJson);
|
|
||||||
}
|
|
||||||
catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
if (jwk == null) {
|
|
||||||
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
|
|
||||||
return OAuth2TokenValidatorResult.failure(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
String jwkThumbprint;
|
|
||||||
try {
|
|
||||||
jwkThumbprint = jwk.computeThumbprint().toString();
|
|
||||||
}
|
|
||||||
catch (Exception ex) {
|
|
||||||
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk.");
|
|
||||||
return OAuth2TokenValidatorResult.failure(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!jwkThumbprintClaim.equals(jwkThumbprint)) {
|
|
||||||
OAuth2Error error = createOAuth2Error("jkt claim is invalid.");
|
|
||||||
return OAuth2TokenValidatorResult.failure(error);
|
|
||||||
}
|
|
||||||
return OAuth2TokenValidatorResult.success();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static OAuth2Error createOAuth2Error(String reason) {
|
|
||||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class OAuth2AccessTokenClaims implements OAuth2Token, ClaimAccessor {
|
private static final class OAuth2AccessTokenClaims implements OAuth2Token, ClaimAccessor {
|
||||||
|
|
||||||
private final OAuth2Token accessToken;
|
private final OAuth2Token accessToken;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
changelog:
|
changelog:
|
||||||
repository: spring-projects/spring-security
|
repository: spring-projects/spring-security-commercial
|
||||||
sections:
|
sections:
|
||||||
- title: ":rewind: Breaking Changes"
|
- title: ":rewind: Breaking Changes"
|
||||||
labels: ["type: breaks-passivity"]
|
labels: ["type: breaks-passivity"]
|
||||||
@@ -17,5 +17,6 @@ changelog:
|
|||||||
labels: ["type: task", "in: build"]
|
labels: ["type: task", "in: build"]
|
||||||
sort: "title"
|
sort: "title"
|
||||||
issues:
|
issues:
|
||||||
|
generate_links: false
|
||||||
exclude:
|
exclude:
|
||||||
labels: ["status: duplicate"]
|
labels: ["status: duplicate"]
|
||||||
|
|||||||
+2
-2
@@ -15,12 +15,12 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = 'spring-security'
|
rootProject.name = 'spring-security-commercial'
|
||||||
|
|
||||||
FileTree buildFiles = fileTree(rootDir) {
|
FileTree buildFiles = fileTree(rootDir) {
|
||||||
List excludes = gradle.startParameter.projectProperties.get("excludeProjects")?.split(",")
|
List excludes = gradle.startParameter.projectProperties.get("excludeProjects")?.split(",")
|
||||||
include '**/*.gradle', '**/*.gradle.kts'
|
include '**/*.gradle', '**/*.gradle.kts'
|
||||||
exclude 'build', '**/gradle', 'settings.gradle', 'buildSrc', '/build.gradle', '.*', 'out'
|
exclude 'build', '**/gradle', 'settings.gradle', 'commercial-settings.gradle', 'buildSrc', '/build.gradle', '.*', 'out'
|
||||||
exclude '**/grails3'
|
exclude '**/grails3'
|
||||||
if(excludes) {
|
if(excludes) {
|
||||||
exclude excludes
|
exclude excludes
|
||||||
|
|||||||
+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.UserDetailsService;
|
||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
import org.springframework.security.core.userdetails.cache.NullUserCache;
|
import org.springframework.security.core.userdetails.cache.NullUserCache;
|
||||||
|
import org.springframework.security.crypto.codec.Utf8;
|
||||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
|
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
|
||||||
import org.springframework.security.web.context.SecurityContextRepository;
|
import org.springframework.security.web.context.SecurityContextRepository;
|
||||||
@@ -167,7 +168,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
|
|||||||
}
|
}
|
||||||
serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod());
|
serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod());
|
||||||
// If digest is incorrect, try refreshing from backend and recomputing
|
// If digest is incorrect, try refreshing from backend and recomputing
|
||||||
if (!serverDigestMd5.equals(digestAuth.getResponse()) && cacheWasUsed) {
|
if (!Utf8.isEqual(serverDigestMd5, digestAuth.getResponse()) && cacheWasUsed) {
|
||||||
logger.debug("Digest comparison failure; trying to refresh user from DAO in case password had changed");
|
logger.debug("Digest comparison failure; trying to refresh user from DAO in case password had changed");
|
||||||
user = this.userDetailsService.loadUserByUsername(username);
|
user = this.userDetailsService.loadUserByUsername(username);
|
||||||
this.userCache.putUserInCache(user);
|
this.userCache.putUserInCache(user);
|
||||||
@@ -181,7 +182,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If digest is still incorrect, definitely reject authentication attempt
|
// If digest is still incorrect, definitely reject authentication attempt
|
||||||
if (!serverDigestMd5.equals(digestAuth.getResponse())) {
|
if (!Utf8.isEqual(serverDigestMd5, digestAuth.getResponse())) {
|
||||||
logger.debug(LogMessage.format(
|
logger.debug(LogMessage.format(
|
||||||
"Expected response: '%s' but received: '%s'; is AuthenticationDao returning clear text passwords?",
|
"Expected response: '%s' but received: '%s'; is AuthenticationDao returning clear text passwords?",
|
||||||
serverDigestMd5, digestAuth.getResponse()));
|
serverDigestMd5, digestAuth.getResponse()));
|
||||||
@@ -405,7 +406,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
|
|||||||
}
|
}
|
||||||
// Check signature of nonce matches this expiry time
|
// Check signature of nonce matches this expiry time
|
||||||
String expectedNonceSignature = DigestAuthUtils.md5Hex(this.nonceExpiryTime + ":" + entryPointKey);
|
String expectedNonceSignature = DigestAuthUtils.md5Hex(this.nonceExpiryTime + ":" + entryPointKey);
|
||||||
if (!expectedNonceSignature.equals(nonceTokens[1])) {
|
if (!Utf8.isEqual(expectedNonceSignature, nonceTokens[1])) {
|
||||||
throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
|
throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
|
||||||
"DigestAuthenticationFilter.nonceCompromised", new Object[] { nonceAsPlainText },
|
"DigestAuthenticationFilter.nonceCompromised", new Object[] { nonceAsPlainText },
|
||||||
"Nonce token compromised {0}"));
|
"Nonce token compromised {0}"));
|
||||||
|
|||||||
+22
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
package org.springframework.security.web.webauthn.api;
|
package org.springframework.security.web.webauthn.api;
|
||||||
|
|
||||||
|
import java.io.ObjectStreamException;
|
||||||
import java.io.Serial;
|
import java.io.Serial;
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
|
||||||
@@ -121,4 +122,25 @@ public final class AuthenticatorTransport implements Serializable {
|
|||||||
return new AuthenticatorTransport[] { USB, NFC, BLE, HYBRID, INTERNAL };
|
return new AuthenticatorTransport[] { USB, NFC, BLE, HYBRID, INTERNAL };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(obj instanceof AuthenticatorTransport other)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.value.equals(other.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return this.value.hashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private Object readResolve() throws ObjectStreamException {
|
||||||
|
return valueOf(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
package org.springframework.security.web.webauthn.api;
|
package org.springframework.security.web.webauthn.api;
|
||||||
|
|
||||||
|
import java.io.ObjectStreamException;
|
||||||
import java.io.Serial;
|
import java.io.Serial;
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
|
||||||
@@ -58,4 +59,25 @@ public final class PublicKeyCredentialType implements Serializable {
|
|||||||
return new PublicKeyCredentialType(value);
|
return new PublicKeyCredentialType(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(obj instanceof PublicKeyCredentialType other)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.value.equals(other.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return this.value.hashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private Object readResolve() throws ObjectStreamException {
|
||||||
|
return valueOf(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+33
@@ -16,9 +16,12 @@
|
|||||||
|
|
||||||
package org.springframework.security.web.webauthn.api;
|
package org.springframework.security.web.webauthn.api;
|
||||||
|
|
||||||
|
import java.io.ObjectStreamException;
|
||||||
import java.io.Serial;
|
import java.io.Serial;
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
import org.jspecify.annotations.Nullable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <a href=
|
* <a href=
|
||||||
* "https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement">UserVerificationRequirement</a>
|
* "https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement">UserVerificationRequirement</a>
|
||||||
@@ -72,4 +75,34 @@ public final class UserVerificationRequirement implements Serializable {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(@Nullable Object obj) {
|
||||||
|
if (this == obj) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(obj instanceof UserVerificationRequirement other)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.value.equals(other.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return this.value.hashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private Object readResolve() throws ObjectStreamException {
|
||||||
|
switch (this.value) {
|
||||||
|
case "required":
|
||||||
|
return REQUIRED;
|
||||||
|
case "preferred":
|
||||||
|
return PREFERRED;
|
||||||
|
case "discouraged":
|
||||||
|
return DISCOURAGED;
|
||||||
|
default:
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-6
@@ -257,8 +257,8 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
|
|||||||
byte[] clientDataJSON = response.getClientDataJSON().getBytes();
|
byte[] clientDataJSON = response.getClientDataJSON().getBytes();
|
||||||
Challenge challenge = new DefaultChallenge(base64Challenge);
|
Challenge challenge = new DefaultChallenge(base64Challenge);
|
||||||
ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge);
|
ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge);
|
||||||
boolean userVerificationRequired = creationOptions.getAuthenticatorSelection()
|
boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
|
||||||
.getUserVerification() == UserVerificationRequirement.REQUIRED;
|
.equals(creationOptions.getAuthenticatorSelection().getUserVerification());
|
||||||
// requireUserPresence The constant Boolean value true
|
// requireUserPresence The constant Boolean value true
|
||||||
// https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred
|
// https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred
|
||||||
boolean userPresenceRequired = true;
|
boolean userPresenceRequired = true;
|
||||||
@@ -317,8 +317,7 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
|
|||||||
|
|
||||||
private com.webauthn4j.data.PublicKeyCredentialParameters convertParamToWebauthn4j(
|
private com.webauthn4j.data.PublicKeyCredentialParameters convertParamToWebauthn4j(
|
||||||
PublicKeyCredentialParameters parameter) {
|
PublicKeyCredentialParameters parameter) {
|
||||||
PublicKeyCredentialType credentialType = PublicKeyCredentialType.valueOf(parameter.getType().getValue());
|
if (!PublicKeyCredentialType.PUBLIC_KEY.equals(parameter.getType())) {
|
||||||
if (credentialType != PublicKeyCredentialType.PUBLIC_KEY) {
|
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"Cannot convert unknown credential type " + parameter.getType() + " to webauthn4j");
|
"Cannot convert unknown credential type " + parameter.getType() + " to webauthn4j");
|
||||||
}
|
}
|
||||||
@@ -394,8 +393,8 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
|
|||||||
String rpId = requestOptions.getRpId();
|
String rpId = requestOptions.getRpId();
|
||||||
Assert.notNull(rpId, "rpId cannot be null");
|
Assert.notNull(rpId, "rpId cannot be null");
|
||||||
ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge);
|
ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge);
|
||||||
boolean userVerificationRequired = request.getRequestOptions()
|
boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
|
||||||
.getUserVerification() == UserVerificationRequirement.REQUIRED;
|
.equals(request.getRequestOptions().getUserVerification());
|
||||||
|
|
||||||
com.webauthn4j.data.AuthenticationRequest authenticationRequest = new com.webauthn4j.data.AuthenticationRequest(
|
com.webauthn4j.data.AuthenticationRequest authenticationRequest = new com.webauthn4j.data.AuthenticationRequest(
|
||||||
request.getPublicKey().getRawId().getBytes(), assertionResponse.getAuthenticatorData().getBytes(),
|
request.getPublicKey().getRawId().getBytes(), assertionResponse.getAuthenticatorData().getBytes(),
|
||||||
|
|||||||
+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