1
0
mirror of synced 2026-09-01 06:55:19 +00:00

Compare commits

..

55 Commits

Author SHA1 Message Date
dependabot[bot] 82aaa5f54e Bump actions/setup-java from 5.7.0 to 6.0.0
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5.7.0 to 6.0.0.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/b6effb05e454b25005698d916606bdc6ffcbf961...dd06d9cba3e5552c54d9f8ea23572deb30010f7c)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 00:35:14 +00:00
Josh Cummings 461ccd817d Next Development Version
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-21 09:14:09 -06:00
Josh Cummings 381c556f78 Add Release Announcement Workflows
Closes gh-19571

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 16:57:48 -06:00
Josh Cummings 3b276e6c0d Update to Spring Data 2025.1.7
Closes gh-19485

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:24:08 -06:00
Josh Cummings f2115fbef8 Update to Spring LDAP 4.0.5
Closes gh-19486

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:24:01 -06:00
Josh Cummings 298335a57e Update to Micrometer 1.16.7
Closes gh-19484

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:23:55 -06:00
Josh Cummings c6566ee772 Update to Reactor 2025.0.7
Closes gh-19483

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:23:50 -06:00
Josh Cummings 7442214225 Update to Spring Framework 7.0.9
Closes gh-19482

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:23:44 -06:00
Josh Cummings 0123ae0981 Deprecate AesBytesEncryptor
This commit separates AesBytesEncryptor into two separate
implememtations, allowing for a migration away from default
arrangements that used a null IV

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

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:23:29 -06:00
Joe Grandja 536e09756c Bind UnboundIdContainer to loopback address 2026-08-20 11:23:22 -06:00
Joe Grandja ac15cdbea3 Apply html encoding in DefaultConsentPage 2026-08-20 11:23:14 -06:00
Joe Grandja ad812aefc4 Provide ability to configure DPoP proof replay 2026-08-20 11:23:08 -06:00
Josh Cummings d8769fb183 Use Constant-Time Comparison
This commit updates password encoders to use a
constant-time comparison method to defend against
timing attacks

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:23:02 -06:00
Josh Cummings ae997a4f46 Use Utf8#isEqual
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:22:55 -06:00
Josh Cummings bbc6273abf Use Constant-Time Equals
This commit updates the filter to use constant-time
equals for sensitive material

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

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

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:22:36 -06:00
Josh Cummings 8830e39073 Use MessageDigest#isEqual
This commit favors constant-time comparison
to mitigate timing attacks

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:22:31 -06:00
Josh Cummings 304cc21523 Add Tests for User and Device Code
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:22:25 -06:00
Tadaya Tsuyukubo d111029078 Prevent sharing SecurityContext across threads
`SecurityContextHolderThreadLocalAccessor` currently propagates the
same `SecurityContext` to other threads when Micrometer
Context Propagation is used. This leads to unintended sharing of
mutable state and can cause authentication to leak between threads.

This change updates the accessor to create a new
`SecurityContext` for the target thread while reusing only the
`Authentication` value. Each thread now receives its own
`SecurityContext` instance, preventing cross-thread interference and
aligning with recommended `SecurityContext` usage.

Signed-off-by: Tadaya Tsuyukubo <tadaya@ttddyy.net>
2026-08-19 16:54:43 -06:00
dae won 5b923c78ea Validate Parameter in setPostAuthenticationChecks
The null check in setPostAuthenticationChecks of
AbstractUserDetailsReactiveAuthenticationManager asserted the current
field value, which is initialized to a default and can never be null,
instead of the method parameter. As a result, null was silently
accepted and the next authenticate call failed with a raw
NullPointerException instead of failing fast with a clear message.

Closes gh-19276

Signed-off-by: dae won <eodnjs01477@gmail.com>
2026-08-13 15:58:01 -06:00
Tran Ngoc Nhan 65a1099ccb Correct validation logic in CasAuthenticationToken
Closes gh-19368

Signed-off-by: Tran Ngoc Nhan <ngocnhan.tran1996@gmail.com>
2026-08-13 15:19:26 -06:00
junhyeong9812 9fdd2dc675 Lowercase username in changePassword lookup
InMemoryUserDetailsManager keys its user map on the lower-cased
username everywhere except changePassword, which looked the current
user up with the raw name. A user whose username contains uppercase
letters could therefore not change its password. Lower-case the
lookup key to match the rest of the class.

Closes gh-19336

Signed-off-by: junhyeong9812 <pickjog@gmail.com>
2026-08-13 11:35:59 -06:00
Josh Cummings 2aa3c1ab2e Remove NullAway
Issue gh-17816

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-11 19:40:18 -06:00
Kim Tae Eun 17c58ef088 Remove BeanResolver Null Checks
StandardEvaluationContext.setBeanResolver now accepts a nullable
BeanResolver, so the workarounds added for the original limitation
are no longer needed. This removes the explicit null guards (and
equivalent Optional.ofNullable idiom) as well as the
@SuppressWarnings("NullAway") annotations introduced to silence
the false positives.

In addition to the seven sites that explicitly referenced
spring-projects/spring-framework#35371, two sites in
spring-security-core followed the same workaround pattern without
the comment marker (AbstractSecurityExpressionHandler and
DefaultMethodSecurityExpressionHandler) and have also been
simplified.

Closes gh-17816

Signed-off-by: Kim Tae Eun <snowykte0426@naver.com>
2026-08-11 18:37:09 -07:00
Arz Meow 63306ad8ee Update One-Time Token Docs for Renamed APIs
Replace deprecated `UriComponentsBuilder` usage and renamed
`oneTimeTokenLogin` DSL method references in the docs with their
current equivalents.

Closes gh-18367

Signed-off-by: Arz Meow <arthur37231@gmail.com>
2026-08-11 14:47:25 -07:00
Tran Ngoc Nhan 24aec1d002 Fix broken Javadoc links
Closes gh-19194

Signed-off-by: Tran Ngoc Nhan <ngocnhan.tran1996@gmail.com>
2026-08-11 14:31:49 -07:00
Tran Ngoc Nhan 863753580c Fix Broken DefaultLoginPageGeneratingFilter Javadoc Link
Use the `javadoc:` macro so the reference resolves correctly.

Signed-off-by: Tran Ngoc Nhan <ngocnhan.tran1996@gmail.com>
2026-08-11 14:31:43 -07:00
Tran Ngoc Nhan fa92ce3758 Remove Unnecessary AuthorizationDecision Cast
Closes gh-19282
2026-08-11 14:30:52 -07:00
dependabot[bot] 561f1bba0f Bump com.unboundid:unboundid-ldapsdk from 7.0.4 to 7.0.5
Bumps [com.unboundid:unboundid-ldapsdk](https://github.com/pingidentity/ldapsdk) from 7.0.4 to 7.0.5.
- [Release notes](https://github.com/pingidentity/ldapsdk/releases)
- [Changelog](https://github.com/pingidentity/ldapsdk/blob/master/docs/release-notes.html)
- [Commits](https://github.com/pingidentity/ldapsdk/compare/7.0.4...7.0.5)

---
updated-dependencies:
- dependency-name: com.unboundid:unboundid-ldapsdk
  dependency-version: 7.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 12:00:52 -07:00
dependabot[bot] 268fce2616 Bump antora from 3.2.0-alpha.12 to 3.2.0-rc.2 in /docs
---
updated-dependencies:
- dependency-name: antora
  dependency-version: 3.2.0-rc.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 10:27:36 -07:00
dependabot[bot] c4c547b73b Bump org.apache.kerby:kerb-simplekdc from 2.1.1 to 2.1.2
Bumps org.apache.kerby:kerb-simplekdc from 2.1.1 to 2.1.2.

---
updated-dependencies:
- dependency-name: org.apache.kerby:kerb-simplekdc
  dependency-version: 2.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 10:27:26 -07:00
dependabot[bot] d487048224 Bump ch.qos.logback:logback-classic from 1.5.34 to 1.5.38
Bumps [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) from 1.5.34 to 1.5.38.
- [Release notes](https://github.com/qos-ch/logback/releases)
- [Commits](https://github.com/qos-ch/logback/compare/v_1.5.34...v_1.5.38)

---
updated-dependencies:
- dependency-name: ch.qos.logback:logback-classic
  dependency-version: 1.5.38
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 10:27:10 -07:00
dependabot[bot] 1d15210ae2 Bump actions/checkout from 6.0.3 to 7.0.1
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.3 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...3d3c42e5aac5ba805825da76410c181273ba90b1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 10:26:57 -07:00
dependabot[bot] 5f377e5ec2 Bump @springio/antora-extensions from 1.14.12 to 1.14.13 in /docs
Bumps [@springio/antora-extensions](https://github.com/spring-io/antora-extensions) from 1.14.12 to 1.14.13.
- [Changelog](https://github.com/spring-io/antora-extensions/blob/main/CHANGELOG.adoc)
- [Commits](https://github.com/spring-io/antora-extensions/compare/v1.14.12...v1.14.13)

---
updated-dependencies:
- dependency-name: "@springio/antora-extensions"
  dependency-version: 1.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 10:26:13 -07:00
dependabot[bot] e107329766 Bump actions/setup-java from 5.2.0 to 5.7.0
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5.2.0 to 5.7.0.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/be666c2fcd27ec809703dec50e508c2fdc7f6654...b6effb05e454b25005698d916606bdc6ffcbf961)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 5.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 10:25:54 -07:00
Josh Cummings 905b4786e8 Remove Milestone Date Validation
Closes gh-19526

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-11 11:08:05 -06:00
Josh Cummings 36e7420bd0 Add Release Train Integration
This commit adds the build-level configuration required for
release train participation.

See gh-19518
2026-08-10 15:48:02 -06:00
Josh Cummings f03e612a14 Remove Release Workflows
This commit removes workflows for scheduling and
releasing releases in favor of release train support

Closes gh-19518
2026-08-10 15:46:10 -06:00
Sumit Kumar Das 2c4db99229 Differentiate Forwarded and X-Forwarded headers in proxy docs
The proxy server section pointed at RFC 7239 and then told the reader to
configure the application server for the X-Forwarded headers, conflating
the standard Forwarded header with the non-standard X-Forwarded-* set.

Describe the two kinds of headers separately, note that most proxies send
X-Forwarded-* while Spring Framework and servers such as Reactor Netty and
Jetty understand both, and state that the edge proxy has to drop or
overwrite untrusted values for both kinds rather than only one.

Closes gh-19461

Signed-off-by: Sumit Kumar Das <skdas5405@gmail.com>
2026-08-03 17:52:32 -06:00
Josh Cummings 0f6f453ea0 Increase Default NimbusJwtDecoder Timeouts to 30 Seconds
NimbusJwtDecoder's default RestOperations now respects the JDK's
sun.net.client.defaultConnectTimeout/defaultReadTimeout system properties,
falling back to 30 seconds instead of the previous 500 milliseconds,
matching JwtDecoderProviderConfigurationUtils's existing behavior.

Also documents this default and the RestOperations override in the
reference guide and migration guide (the reference guide's existing
"Configuring Timeouts" section already claimed 30 seconds -- it's been
inaccurate since the 500ms default shipped and is now correct again), and
documents providing a custom JwtDecoderFactory<ClientRegistration> for
OAuth2 Login's ID Token decoding.

Issue gh-19474

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-03 15:51:41 -06:00
Josh Cummings 1eef373ca0 Improve Error Message for Invalid JSR-250 Usage
This commit adds an IllegalStateException guard when spring-security-access
is missing and the application is using @EnableGlobalMethodSecurity with
jsr250Enabled.

Issue gh-19441

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-07-31 16:01:21 -06:00
jyx-07 7a03cd5a55 Fail fast when spring-security-access is missing
The "Move Core Access API" refactoring (gh-17847) relocated
MethodSecurityMetadataSourceAdvisor and MethodSecurityInterceptor
from spring-security-core, a mandatory dependency of
spring-security-config, into the new spring-security-access module,
which spring-security-config only depends on optionally.

GlobalMethodSecuritySelector (backing the deprecated
@EnableGlobalMethodSecurity) and ReactiveMethodSecuritySelector
(backing @EnableReactiveMethodSecurity(useAuthorizationManager =
false)) still unconditionally import configuration that constructs
those classes: MethodSecurityMetadataSourceAdvisorRegistrar in proxy
mode, GlobalMethodSecurityConfiguration in both proxy and aspectj
mode, and ReactiveMethodSecurityConfiguration for the legacy reactive
path. Applications that use any of these deprecated configuration
paths without explicitly adding spring-security-access now fail at
startup with a confusing NoClassDefFoundError deep inside Spring's
configuration-processing machinery, instead of an actionable message.

@EnableMethodSecurity and @EnableReactiveMethodSecurity's default
(AuthorizationManager-based) mode, the non-deprecated replacements,
never reference these classes and are unaffected either way.

Add a ClassUtils.isPresent check to both selectors so that, whenever
a legacy configuration path that needs it is chosen (proxy mode,
aspectj mode, or the legacy reactive interceptor), a missing
spring-security-access dependency now fails fast with a clear
IllegalStateException that names the missing dependency and points
to the supported alternative, rather than a NoClassDefFoundError.

This preserves gh-17847's footprint-reduction intent: the check only
runs for the deprecated legacy annotations, so the majority of
applications using @EnableMethodSecurity see no change in behavior or
dependencies. @EnableGlobalMethodSecurity remains deprecated; this
change adds no new investment in it beyond giving existing users of
it a clear diagnostic instead of a confusing crash.

Closes gh-19441

Signed-off-by: jyx-07 <s25069@gsm.hs.kr>
2026-07-31 16:01:21 -06:00
Andrey Litvitski e4fafce066 Fix OAuth2PushedAuthorizationRequestUri parsing
OAuth2PushedAuthorizationRequestUri uses Base64URL encoding for the state, which can produce a value containing ___, the same sequence used as the delimiter. As a result, indexOf may locate a delimiter within the state instead of the delimiter preceding the expiration timestamp.

Use lastIndexOf to reliably locate the delimiter before the timestamp without changing the existing request URI format.

Closes gh-19444

Signed-off-by: Andrey Litvitski <andrey1010102008@gmail.com>
2026-07-31 13:19:21 -06:00
Josh Cummings 2104c49597 Add tests for OAuth2PushedAuthorizationRequestUri
Adds baseline test coverage for OAuth2PushedAuthorizationRequestUri,
which previously had none: a round-trip via create()/parse() and an
explicit parse() case on a hand-constructed request URI.

See gh-19444.

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-07-31 13:19:21 -06:00
Josh Cummings c5b1f78118 Support Testing Against Snapshot Spring LDAP Versions
Adds an opt-in override to spring-security-dependencies so it can be
built against a specific Spring LDAP version instead of whatever's
declared in the version catalog, matching the existing pattern for
Spring Framework/Reactor/Spring Data/Micrometer. Passing
-PisOverrideVersionCatalog=true -PspringLdapVersion=X takes precedence
over the catalog. Default behavior (no properties passed) is unchanged.

Closes gh-19481

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-07-31 12:31:17 -06:00
Josh Cummings d2366650ec Polish Serialization Test
This commit makes the version calculation work for
intermediate version numbers like 7.0.5.1
2026-07-09 11:01:06 -06:00
Josh Cummings 5338b7a05b Move Wildcards to Minor Versions
This commit targets the major.minor version pair that
matches Spring Security 7.0.x

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-07-09 08:43:57 -06:00
Josh Cummings bd86d0b233 Add Micrometer to Snapshot Test
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-07-09 08:32:11 -06:00
Josh Cummings f8442794a8 Check Only JDK 17
This commit removes the extra build for
JDK 21 to save time on builds.

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-07-09 08:32:11 -06:00
Joe Grandja 57c2af4900 Merge branch '6.5.x' into 7.0.x 2026-06-09 14:30:37 -04:00
github-actions[bot] 6da4ea0d0d Next development version 2026-06-09 16:06:39 +00:00
github-actions[bot] 8f69721f24 Next development version 2026-06-09 16:02:59 +00:00
github-actions[bot] 73b077790f Release 6.5.11 2026-06-09 15:25:02 +00:00
97 changed files with 2679 additions and 448 deletions
+1 -3
View File
@@ -18,13 +18,11 @@ jobs:
strategy:
matrix:
include:
- java-version: 21-ea
toolchain: 21
- java-version: 17
toolchain: 17
with:
java-version: ${{ matrix.java-version }}
test-args: --refresh-dependencies -PforceMavenRepositories=snapshot,https://oss.sonatype.org/content/repositories/snapshots -PisOverrideVersionCatalog -PtestToolchain=${{ matrix.toolchain }} -PspringFrameworkVersion=7.+ -PreactorVersion=2025.+ -PspringDataVersion=2025.+ --stacktrace
test-args: --refresh-dependencies -PforceMavenRepositories=snapshot,https://oss.sonatype.org/content/repositories/snapshots -PisOverrideVersionCatalog -PtestToolchain=${{ matrix.toolchain }} -PspringFrameworkVersion=7.0.+ -PreactorVersion=2025.0.+ -PspringDataVersion=2025.1.+ -PmicrometerVersion=1.16.+ --stacktrace
secrets: inherit
send-notification:
name: Send Notification
@@ -42,21 +42,9 @@ jobs:
with:
should-deploy-schema: ${{ needs.build.outputs.should-deploy-artifacts }}
secrets: inherit
perform-release:
name: Perform Release
needs: [ deploy-artifacts, deploy-schema ]
uses: spring-io/spring-security-release-tools/.github/workflows/perform-release.yml@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
with:
should-perform-release: ${{ needs.deploy-artifacts.outputs.artifacts-deployed }}
project-version: ${{ needs.deploy-artifacts.outputs.project-version }}
milestone-repo-url: https://repo1.maven.org/maven2
release-repo-url: https://repo1.maven.org/maven2
artifact-path: org/springframework/security/spring-security-core
slack-announcing-id: spring-security-announcing
secrets: inherit
send-notification:
name: Send Notification
needs: [ perform-release ]
needs: [ deploy-artifacts, deploy-schema ]
if: ${{ !success() }}
runs-on: ubuntu-latest
steps:
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
issues: write
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
if: github.repository_owner == 'spring-projects'
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: docs-build
fetch-depth: 1
-27
View File
@@ -1,27 +0,0 @@
name: Finalize Release
on:
workflow_dispatch: # Manual trigger
inputs:
version:
description: The Spring Security release to finalize (e.g. 7.0.0-RC2)
required: true
env:
DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
permissions:
contents: read
jobs:
perform-release:
name: Perform Release
uses: spring-io/spring-security-release-tools/.github/workflows/perform-release.yml@ed473b4dafba053c63a453d2d88a89df3b3e18b3 # v1
with:
should-perform-release: true
project-version: ${{ inputs.version }}
milestone-repo-url: https://repo1.maven.org/maven2
release-repo-url: https://repo1.maven.org/maven2
artifact-path: org/springframework/security/spring-security-core
slack-announcing-id: spring-security-announcing
secrets: inherit
@@ -19,9 +19,9 @@ jobs:
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up JDK 17
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
java-version: '17'
distribution: 'temurin'
@@ -1,35 +0,0 @@
name: Check Milestone
on:
milestone:
types: [created, opened, edited]
env:
DUE_ON: ${{ github.event.milestone.due_on }}
TITLE: ${{ github.event.milestone.title }}
permissions:
contents: read
jobs:
spring-releasetrain-checks:
name: Check DueOn is on a Release Date
runs-on: ubuntu-latest
if: ${{ github.repository == 'spring-projects/spring-security' }}
permissions:
contents: none
steps:
- name: Print Milestone Being Checked
run: echo "Validating DueOn '$DUE_ON' for milestone '$TITLE'"
- name: Validate DueOn
if: env.DUE_ON != ''
run: |
export TOOL_VERSION=0.1.1
wget "https://repo.maven.apache.org/maven2/io/spring/releasetrain/spring-release-train-tools/$TOOL_VERSION/spring-release-train-tools-$TOOL_VERSION.jar"
java -cp "spring-release-train-tools-$TOOL_VERSION.jar" io.spring.releasetrain.CheckMilestoneDueOnMain --dueOn "$DUE_ON" --expectedDayOfWeek MONDAY --expectedMondayCount 3
send-notification:
name: Send Notification
needs: [ spring-releasetrain-checks ]
if: ${{ failure() || cancelled() }}
runs-on: ubuntu-latest
steps:
- name: Send Notification
uses: spring-io/spring-security-release-tools/.github/actions/send-notification@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
with:
webhook-url: ${{ secrets.SPRING_SECURITY_CI_GCHAT_WEBHOOK_URL }}
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository == 'spring-projects/spring-security' }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up gradle
uses: spring-io/spring-gradle-build-action@c8668747d7c264864c8c7f7026d0d277d14a78dc # v2.0.6
with:
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository == 'spring-projects/spring-security' }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up gradle
uses: spring-io/spring-gradle-build-action@c8668747d7c264864c8c7f7026d0d277d14a78dc # v2.0.6
with:
@@ -0,0 +1,30 @@
name: Release Announcements - Publish
on:
workflow_dispatch:
inputs:
version:
description: The version to publish
required: true
type: string
permissions:
actions: write
contents: read
issues: write
jobs:
deploy-schema:
name: Deploy Schema
uses: spring-io/spring-security-release-tools/.github/workflows/deploy-schema.yml@d6c65d3013c0888e2c9cbae9f4beda610994776c # v1.0.16
with:
should-deploy-schema: true
secrets: inherit
publish-release-announcements:
name: Publish Release Announcements
needs: [ deploy-schema ]
if: ${{ !github.event.repository.fork }}
uses: spring-io/spring-security-release-tools/.github/workflows/release-announcements-publish.yml@d6c65d3013c0888e2c9cbae9f4beda610994776c # v1.0.16
with:
version: ${{ inputs.version }}
secrets: inherit
@@ -0,0 +1,24 @@
name: Release Announcements - Stage
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
workflow_dispatch:
inputs:
version:
description: The version to stage
required: true
type: string
permissions:
contents: read
jobs:
stage-release-announcements:
name: Stage Release Announcements
if: ${{ !github.event.repository.fork }}
uses: spring-io/spring-security-release-tools/.github/workflows/release-announcements-stage.yml@d6c65d3013c0888e2c9cbae9f4beda610994776c # v1.0.16
with:
version: ${{ inputs.version || github.ref_name }}
secrets: inherit
-24
View File
@@ -1,24 +0,0 @@
name: Release Scheduler
on:
schedule:
- cron: '15 15 * * MON' # Every Monday at 3:15pm UTC
workflow_dispatch:
permissions: read-all
jobs:
dispatch_scheduled_releases:
name: Dispatch scheduled releases
if: github.repository_owner == 'spring-projects'
strategy:
matrix:
# List of active maintenance branches.
branch: [ main, 6.5.x, 6.4.x, 6.3.x ]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 1
- name: Dispatch
env:
GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}
run: gh workflow run update-scheduled-release-version.yml -r ${{ matrix.branch }}
@@ -1,23 +0,0 @@
name: Update Scheduled Release Version
on:
workflow_dispatch: # Manual trigger only. Triggered by release-scheduler.yml on main.
permissions:
contents: read
jobs:
update-scheduled-release-version:
name: Update Scheduled Release Version
uses: spring-io/spring-security-release-tools/.github/workflows/update-scheduled-release-version.yml@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
secrets: inherit
send-notification:
name: Send Notification
needs: [ update-scheduled-release-version ]
if: ${{ failure() || cancelled() }}
runs-on: ubuntu-latest
steps:
- name: Send Notification
uses: spring-io/spring-security-release-tools/.github/actions/send-notification@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
with:
webhook-url: ${{ secrets.SPRING_SECURITY_CI_GCHAT_WEBHOOK_URL }}
+10
View File
@@ -13,6 +13,16 @@ repositories {
gradlePluginPortal()
mavenCentral()
maven { url = 'https://repo.spring.io/snapshot' }
if (System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_URL") != null) {
maven {
name = "Release Train"
url = System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_URL")
credentials {
username = System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_USERNAME")
password = System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_PASSWORD")
}
}
}
}
sourceSets {
@@ -43,6 +43,16 @@ class RepositoryConventionPlugin implements Plugin<Project> {
}
}
mavenCentral()
if (System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_URL") != null) {
maven {
name = "Release Train"
url = System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_URL")
credentials {
username = System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_USERNAME")
password = System.getenv("RELEASE_TRAIN_MAVEN_REPOSITORY_PASSWORD")
}
}
}
if (isSnapshot) {
maven {
name = 'artifactory-snapshot'
@@ -22,6 +22,12 @@ public class MavenPublishingConventionsPlugin implements Plugin<Project> {
@Override
public void execute(MavenPublishPlugin mavenPublish) {
PublishingExtension publishing = project.getExtensions().getByType(PublishingExtension.class);
if (project.hasProperty("deploymentRepository")) {
publishing.getRepositories().maven((mavenRepository) -> {
mavenRepository.setUrl(project.property("deploymentRepository"));
mavenRepository.setName("deployment");
});
}
publishing.getPublications().withType(MavenPublication.class)
.all((mavenPublication) -> MavenPublishingConventionsPlugin.this.customizePom(mavenPublication.getPom(), project));
MavenPublishingConventionsPlugin.this.customizeJavaPlugin(project);
@@ -108,7 +108,7 @@ public class CasAuthenticationToken extends AbstractAuthenticationToken implemen
protected CasAuthenticationToken(Builder<?> builder) {
super(builder);
Assert.isTrue(!"".equals(builder.principal), "principal cannot be null or empty");
Assert.notNull(!"".equals(builder.credentials), "credentials cannot be null or empty");
Assert.isTrue(!"".equals(builder.credentials), "credentials cannot be null or empty");
Assert.notNull(builder.userDetails, "userDetails cannot be null");
Assert.notNull(builder.assertion, "assertion cannot be null");
this.keyHash = builder.keyHash;
@@ -34,6 +34,7 @@ import org.springframework.security.core.userdetails.UserDetails;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatNoException;
/**
* Tests {@link CasAuthenticationToken}.
@@ -182,4 +183,38 @@ public class CasAuthenticationTokenTests {
assertThat(authorities).containsExactlyInAnyOrder("FACTOR_ONE", "FACTOR_TWO");
}
@Test
public void toBuilderWhenPrincipalIsEmpty() {
final Assertion assertion = new AssertionImpl("test");
CasAuthenticationToken token = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES,
makeUserDetails(), assertion);
assertThatIllegalArgumentException().isThrownBy(() -> token.toBuilder().principal(null).build());
assertThatIllegalArgumentException().isThrownBy(() -> token.toBuilder().principal("").build());
}
@Test
public void toBuilderWhenPrincipalIsNotEmpty() {
final Assertion assertion = new AssertionImpl("test");
CasAuthenticationToken token = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES,
makeUserDetails(), assertion);
assertThatNoException().isThrownBy(() -> token.toBuilder().principal("principal").build());
}
@Test
public void toBuilderWhenCredentialsIsEmpty() {
final Assertion assertion = new AssertionImpl("test");
CasAuthenticationToken token = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES,
makeUserDetails(), assertion);
assertThatIllegalArgumentException().isThrownBy(() -> token.toBuilder().credentials(null).build());
assertThatIllegalArgumentException().isThrownBy(() -> token.toBuilder().credentials("").build());
}
@Test
public void toBuilderWhenCredentialsIsNotEmpty() {
final Assertion assertion = new AssertionImpl("test");
CasAuthenticationToken token = new CasAuthenticationToken("key", makeUserDetails(), "Password", this.ROLES,
makeUserDetails(), assertion);
assertThatNoException().isThrownBy(() -> token.toBuilder().credentials("credentials").build());
}
}
@@ -17,6 +17,7 @@
package org.springframework.security.config.annotation.authentication.configurers.ldap;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import org.springframework.ldap.core.support.BaseLdapPathContextSource;
@@ -590,7 +591,7 @@ public class LdapAuthenticationProviderConfigurer<B extends ProviderManagerBuild
}
private int getDefaultPort() {
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT)) {
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT, 50, InetAddress.getLoopbackAddress())) {
return serverSocket.getLocalPort();
}
catch (IOException ex) {
@@ -39,6 +39,9 @@ import org.springframework.util.ClassUtils;
@Deprecated
final class GlobalMethodSecuritySelector implements ImportSelector {
private static final boolean isAccessPresent = ClassUtils.isPresent(
"org.springframework.security.access.intercept.aopalliance.MethodSecurityMetadataSourceAdvisor", null);
@Override
public String[] selectImports(AnnotationMetadata importingClassMetadata) {
Class<EnableGlobalMethodSecurity> annoType = EnableGlobalMethodSecurity.class;
@@ -59,16 +62,26 @@ final class GlobalMethodSecuritySelector implements ImportSelector {
boolean jsr250Enabled = attributes.getBoolean("jsr250Enabled");
List<String> classNames = new ArrayList<>(4);
if (isProxy) {
assertAccessModulePresent();
classNames.add(MethodSecurityMetadataSourceAdvisorRegistrar.class.getName());
}
classNames.add(autoProxyClassName);
if (!skipMethodSecurityConfiguration) {
assertAccessModulePresent();
classNames.add(GlobalMethodSecurityConfiguration.class.getName());
}
if (jsr250Enabled) {
assertAccessModulePresent();
classNames.add(Jsr250MetadataSourceConfiguration.class.getName());
}
return classNames.toArray(new String[0]);
}
private static void assertAccessModulePresent() {
Assert.state(isAccessPresent,
() -> "@EnableGlobalMethodSecurity requires the spring-security-access dependency on the "
+ "classpath. Please add spring-security-access, or migrate to @EnableMethodSecurity "
+ "which does not require it.");
}
}
@@ -26,6 +26,7 @@ import org.springframework.context.annotation.AutoProxyRegistrar;
import org.springframework.context.annotation.ImportSelector;
import org.springframework.core.type.AnnotationMetadata;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
/**
@@ -35,6 +36,8 @@ import org.springframework.util.ClassUtils;
*/
class ReactiveMethodSecuritySelector implements ImportSelector {
private static final String METHOD_SECURITY_METADATA_SOURCE_ADVISOR = "org.springframework.security.access.intercept.aopalliance.MethodSecurityMetadataSourceAdvisor";
private static final boolean isDataPresent = ClassUtils
.isPresent("org.springframework.security.data.aot.hint.AuthorizeReturnObjectDataHintsRegistrar", null);
@@ -56,6 +59,11 @@ class ReactiveMethodSecuritySelector implements ImportSelector {
imports.add(ReactiveAuthorizationManagerMethodSecurityConfiguration.class.getName());
}
else {
Assert.state(
ClassUtils.isPresent(METHOD_SECURITY_METADATA_SOURCE_ADVISOR, ClassUtils.getDefaultClassLoader()),
() -> "@EnableReactiveMethodSecurity(useAuthorizationManager = false) requires the "
+ "spring-security-access dependency on the classpath. Please add spring-security-access, "
+ "or use the default useAuthorizationManager = true which does not require it.");
imports.add(ReactiveMethodSecurityConfiguration.class.getName());
}
if (isDataPresent) {
@@ -379,9 +379,8 @@ public final class WebSecurity extends AbstractConfiguredSecurityBuilder<Filter,
}
if (filter instanceof AuthorizationFilter authorization) {
AuthorizationManager<HttpServletRequest> authorizationManager = authorization.getAuthorizationManager();
builder.add(securityFilterChain::matches,
(authentication, context) -> (AuthorizationDecision) authorizationManager
.authorize(authentication, context.getRequest()));
builder.add(securityFilterChain::matches, (authentication, context) -> authorizationManager
.authorize(authentication, context.getRequest()));
mappings = true;
}
}
@@ -79,8 +79,10 @@ final class DPoPAuthenticationConfigurer<B extends HttpSecurityBuilder<B>>
@Override
public void configure(B http) {
DPoPAuthenticationProvider authenticationProvider = new DPoPAuthenticationProvider(
getTokenAuthenticationManager(http));
http.authenticationProvider(postProcess(authenticationProvider));
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
http.authenticationProvider(new DPoPAuthenticationProvider(getTokenAuthenticationManager(http)));
AuthenticationFilter authenticationFilter = new AuthenticationFilter(authenticationManager,
getAuthenticationConverter());
authenticationFilter.setRequestMatcher(getRequestMatcher());
@@ -33,6 +33,7 @@ import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationManagerResolver;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.ObjectPostProcessor;
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
@@ -298,6 +299,8 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
if (dPoPAuthenticationAvailable) {
DPoPAuthenticationConfigurer<H> dPoPAuthenticationConfigurer = new DPoPAuthenticationConfigurer<>();
dPoPAuthenticationConfigurer.withObjectPostProcessor(
(ObjectPostProcessor<Object>) OAuth2ResourceServerConfigurer.this::postProcess);
dPoPAuthenticationConfigurer.configure(http);
}
@@ -17,6 +17,7 @@
package org.springframework.security.config.ldap;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import org.w3c.dom.Element;
@@ -188,7 +189,7 @@ public class LdapServerBeanDefinitionParser implements BeanDefinitionParser {
}
private String getDefaultPort() {
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT)) {
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT, 50, InetAddress.getLoopbackAddress())) {
return String.valueOf(serverSocket.getLocalPort());
}
catch (IOException ex) {
@@ -301,8 +301,7 @@ class SpringSecurityCoreVersionSerializableTests {
private static String getCurrentVersion() {
String version = System.getProperty("springSecurityVersion");
String[] parts = version.split("\\.");
parts[2] = "x";
return String.join(".", parts);
return parts[0] + "." + parts[1] + ".x";
}
private static String getPreviousVersion() {
@@ -315,8 +314,7 @@ class SpringSecurityCoreVersionSerializableTests {
parts[0] = String.valueOf(Integer.parseInt(parts[0]) - 1);
parts[1] = "5"; // FIXME: this should not be hard coded
}
parts[2] = "x";
return String.join(".", parts);
return parts[0] + "." + parts[1] + ".x";
}
}
@@ -0,0 +1,138 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.method.configuration;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AdviceMode;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.test.support.ClassPathExclusions;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Tests for gh-19441: {@code spring-security-access} moved
* {@link org.springframework.security.access.intercept.aopalliance.MethodSecurityMetadataSourceAdvisor}
* out of {@code spring-security-core} and into the optional
* {@code spring-security-access} module. {@link EnableMethodSecurity} and
* {@link EnableReactiveMethodSecurity}'s default (AuthorizationManager-based) mode never
* needed that class and continue to work without {@code spring-security-access} on the
* classpath, but the deprecated legacy method security annotations do need it and
* previously failed with a confusing {@link NoClassDefFoundError} instead of an
* actionable message.
*/
@ClassPathExclusions("spring-security-access-*.jar")
public class Gh19441Tests {
@Test
public void enableMethodSecurityWhenAccessModuleAbsentThenContextStartsCleanly() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.register(EnableMethodSecurityConfig.class);
context.refresh();
}
}
@Test
public void enableReactiveMethodSecurityWhenAccessModuleAbsentThenContextStartsCleanly() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.register(EnableReactiveMethodSecurityConfig.class);
context.refresh();
}
}
@Test
public void enableGlobalMethodSecurityWhenProxyModeAndAccessModuleAbsentThenClearException() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.register(EnableGlobalMethodSecurityProxyConfig.class);
assertThatExceptionOfType(Exception.class).isThrownBy(context::refresh)
.havingRootCause()
.isInstanceOf(IllegalStateException.class)
.withMessageContaining("spring-security-access");
}
}
@Test
public void enableGlobalMethodSecurityWhenAspectJModeAndAccessModuleAbsentThenClearException() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.register(EnableGlobalMethodSecurityAspectJConfig.class);
assertThatExceptionOfType(Exception.class).isThrownBy(context::refresh)
.havingRootCause()
.isInstanceOf(IllegalStateException.class)
.withMessageContaining("spring-security-access");
}
}
@Test
public void enableReactiveMethodSecurityWhenUseAuthorizationManagerFalseAndAccessModuleAbsentThenClearException() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.register(EnableReactiveMethodSecurityLegacyConfig.class);
assertThatExceptionOfType(Exception.class).isThrownBy(context::refresh)
.havingRootCause()
.isInstanceOf(IllegalStateException.class)
.withMessageContaining("spring-security-access");
}
}
@Test
public void enableGlobalMethodSecurityWhenAspectJModeAndJsr250EnabledAndConfigurationSubclassedAndAccessModuleAbsentThenClearException() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.register(EnableGlobalMethodSecurityAspectJJsr250SubclassedConfig.class);
assertThatExceptionOfType(Exception.class).isThrownBy(context::refresh)
.havingRootCause()
.isInstanceOf(IllegalStateException.class)
.withMessageContaining("spring-security-access");
}
}
@Configuration
@EnableMethodSecurity
static class EnableMethodSecurityConfig {
}
@Configuration
@EnableReactiveMethodSecurity
static class EnableReactiveMethodSecurityConfig {
}
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
static class EnableGlobalMethodSecurityProxyConfig {
}
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true, mode = AdviceMode.ASPECTJ)
static class EnableGlobalMethodSecurityAspectJConfig {
}
@Configuration
@EnableReactiveMethodSecurity(useAuthorizationManager = false)
static class EnableReactiveMethodSecurityLegacyConfig {
}
@Configuration
@EnableGlobalMethodSecurity(jsr250Enabled = true, mode = AdviceMode.ASPECTJ)
static class EnableGlobalMethodSecurityAspectJJsr250SubclassedConfig extends GlobalMethodSecurityConfiguration {
}
}
@@ -22,6 +22,7 @@ import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
@@ -30,6 +31,7 @@ import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jose.jwk.JWK;
@@ -47,20 +49,26 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.ObjectPostProcessor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.jose.TestJwks;
import org.springframework.security.oauth2.jose.TestKeys;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.DPoPProofContext;
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
import org.springframework.security.oauth2.jwt.DPoPProofReplayValidator;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationProvider;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -245,8 +253,10 @@ public class DPoPAuthenticationConfigurerTests {
.requestMatchers("/resource2").hasAnyAuthority("SCOPE_resource2.read", "SCOPE_resource2.write")
.anyRequest().authenticated()
)
.oauth2ResourceServer((oauth2) -> oauth2
.jwt(Customizer.withDefaults()));
.oauth2ResourceServer((oauth2ResourceServer) ->
oauth2ResourceServer
.jwt(Customizer.withDefaults())
.withObjectPostProcessor(dPoPProofVerifierFactoryCustomizer()));
// @formatter:on
return http.build();
}
@@ -256,6 +266,25 @@ public class DPoPAuthenticationConfigurerTests {
return NimbusJwtDecoder.withPublicKey(PROVIDER_RSA_PUBLIC_KEY).build();
}
private ObjectPostProcessor<DPoPAuthenticationProvider> dPoPProofVerifierFactoryCustomizer() {
return new ObjectPostProcessor<>() {
@Override
public <O extends DPoPAuthenticationProvider> O postProcess(O authenticationProvider) {
DPoPProofReplayValidator.InMemoryCache inMemoryCache = new DPoPProofReplayValidator.InMemoryCache();
inMemoryCache.setMaxSize(50_000);
inMemoryCache.setMaxRequestsPerKey(500);
DPoPProofReplayValidator dPoPProofReplayValidator = new DPoPProofReplayValidator(inMemoryCache);
dPoPProofReplayValidator.setClockSkew(Duration.ofSeconds(60));
Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = DPoPProofJwtDecoderFactory
.createDefaultJwtValidatorFactory(Collections.singletonList(dPoPProofReplayValidator));
DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory();
dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory);
authenticationProvider.setDPoPProofVerifierFactory(dPoPProofJwtDecoderFactory);
return authenticationProvider;
}
};
}
}
@RestController
@@ -78,9 +78,7 @@ public abstract class AbstractSecurityExpressionHandler<T>
public final EvaluationContext createEvaluationContext(@Nullable Authentication authentication, T invocation) {
SecurityExpressionOperations root = createSecurityExpressionRoot(authentication, invocation);
StandardEvaluationContext ctx = createEvaluationContextInternal(authentication, invocation);
if (this.beanResolver != null) {
ctx.setBeanResolver(this.beanResolver);
}
ctx.setBeanResolver(this.beanResolver);
ctx.setRootObject(root);
return ctx;
}
@@ -23,7 +23,6 @@ import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.stream.Stream;
@@ -89,13 +88,12 @@ public class DefaultMethodSecurityExpressionHandler extends AbstractSecurityExpr
}
@Override
@SuppressWarnings("NullAway") // FIXME: Dataflow analysis limitation
public EvaluationContext createEvaluationContext(Supplier<? extends @Nullable Authentication> authentication,
MethodInvocation mi) {
MethodSecurityExpressionOperations root = createSecurityExpressionRoot(authentication, mi);
MethodSecurityEvaluationContext ctx = new MethodSecurityEvaluationContext(root, mi,
getParameterNameDiscoverer());
Optional.ofNullable(getBeanResolver()).ifPresent(ctx::setBeanResolver);
ctx.setBeanResolver(getBeanResolver());
return ctx;
}
@@ -184,7 +184,7 @@ public abstract class AbstractUserDetailsReactiveAuthenticationManager
* @since 5.2
*/
public void setPostAuthenticationChecks(UserDetailsChecker postAuthenticationChecks) {
Assert.notNull(this.postAuthenticationChecks, "postAuthenticationChecks cannot be null");
Assert.notNull(postAuthenticationChecks, "postAuthenticationChecks cannot be null");
this.postAuthenticationChecks = postAuthenticationChecks;
}
@@ -32,6 +32,7 @@ import org.springframework.util.Assert;
* {@link java.util.ServiceLoader} mechanism when context-propagation is on the classpath.
*
* @author Steve Riesenberg
* @author Tadaya Tsuyukubo
* @since 6.5
* @see io.micrometer.context.ContextRegistry
*/
@@ -53,7 +54,9 @@ public final class SecurityContextHolderThreadLocalAccessor implements ThreadLoc
@Override
public void setValue(SecurityContext securityContext) {
Assert.notNull(securityContext, "securityContext cannot be null");
SecurityContextHolder.setContext(securityContext);
SecurityContext newContext = SecurityContextHolder.createEmptyContext();
newContext.setAuthentication(securityContext.getAuthentication());
SecurityContextHolder.setContext(newContext);
}
@Override
@@ -132,7 +132,7 @@ public class KeyBasedPersistenceTokenService implements TokenService, Initializi
// Verification
String content = creationTime + ":" + pseudoRandomNumber + ":" + extendedInfo.toString();
String expectedSha512Hex = Sha512DigestUtils.shaHex(content + ":" + serverSecret);
Assert.isTrue(expectedSha512Hex.equals(sha1Hex), "Key verification failure");
Assert.isTrue(Utf8.isEqual(expectedSha512Hex, sha1Hex), "Key verification failure");
return new DefaultToken(key, creationTime, extendedInfo.toString());
}
@@ -149,7 +149,7 @@ public class InMemoryUserDetailsManager implements UserDetailsManager, UserDetai
else {
this.logger.debug("No authentication manager set. Password won't be re-checked.");
}
MutableUserDetails user = this.users.get(username);
MutableUserDetails user = this.users.get(username.toLowerCase(Locale.ROOT));
Assert.state(user != null, "Current user doesn't exist in database.");
user.setPassword(newPassword);
}
@@ -90,6 +90,12 @@ public class UserDetailsRepositoryReactiveAuthenticationManagerTests {
assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> this.manager.setScheduler(null));
}
@Test
public void setPostAuthenticationChecksWhenNullThenIllegalArgumentException() {
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> this.manager.setPostAuthenticationChecks(null));
}
@Test
public void authenticateWhenCustomSchedulerThenUsed() {
given(this.scheduler.schedule(any())).willAnswer((a) -> {
@@ -16,11 +16,18 @@
package org.springframework.security.core.context;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.Authentication;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
@@ -29,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
* Tests for {@link SecurityContextHolderThreadLocalAccessor}.
*
* @author Steve Riesenberg
* @author Tadaya Tsuyukubo
*/
public class SecurityContextHolderThreadLocalAccessorTests {
@@ -65,9 +73,11 @@ public class SecurityContextHolderThreadLocalAccessorTests {
@Test
public void setValueWhenSecurityContextThenSetsSecurityContextHolder() {
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(new TestingAuthenticationToken("user", "password"));
Authentication authentication = new TestingAuthenticationToken("user", "password");
securityContext.setAuthentication(authentication);
this.threadLocalAccessor.setValue(securityContext);
assertThat(SecurityContextHolder.getContext()).isSameAs(securityContext);
assertThat(SecurityContextHolder.getContext()).isNotSameAs(securityContext);
assertThat(SecurityContextHolder.getContext().getAuthentication()).isSameAs(authentication);
}
@Test
@@ -90,4 +100,41 @@ public class SecurityContextHolderThreadLocalAccessorTests {
assertThat(SecurityContextHolder.getContext()).isEqualTo(emptyContext);
}
@Test
public void newSecurityContextInDifferentThread() throws Exception {
Authentication authA = new TestingAuthenticationToken("foo", "password");
Authentication authB = new TestingAuthenticationToken("bar", "password");
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(authA);
SecurityContextHolder.setContext(securityContext);
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<SecurityContext> contextHolder = new AtomicReference<>();
AtomicReference<Authentication> authHolder = new AtomicReference<>();
Runnable runnable = () -> {
SecurityContext context = SecurityContextHolder.getContext();
contextHolder.set(context);
authHolder.set(context.getAuthentication());
context.setAuthentication(authB);
latch.countDown();
};
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
executor.afterPropertiesSet();
executor.execute(runnable);
boolean finished = latch.await(10, TimeUnit.SECONDS);
assertThat(finished).isTrue();
assertThat(contextHolder.get()).isNotSameAs(securityContext);
assertThat(authHolder.get()).isSameAs(authA);
SecurityContext current = SecurityContextHolder.getContext();
assertThat(current).isSameAs(securityContext);
assertThat(current.getAuthentication()).isSameAs(authA);
}
}
@@ -123,6 +123,22 @@ public class InMemoryUserDetailsManagerTests {
verify(strategy).getContext();
}
@Test
public void changePasswordWhenCurrentUsernameIsNotInLowercaseThenChangesPassword() {
UserDetails userNotLowerCase = User.withUserDetails(PasswordEncodedUser.user()).username("User").build();
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(userNotLowerCase);
Authentication authentication = new UsernamePasswordAuthenticationToken("User", userNotLowerCase.getPassword(),
userNotLowerCase.getAuthorities());
SecurityContextHolderStrategy strategy = mock(SecurityContextHolderStrategy.class);
given(strategy.getContext()).willReturn(new SecurityContextImpl(authentication));
manager.setSecurityContextHolderStrategy(strategy);
String newPassword = "newPassword";
manager.changePassword(userNotLowerCase.getPassword(), newPassword);
assertThat(manager.loadUserByUsername("User").getPassword()).isEqualTo(newPassword);
}
@Test
public void createUserWhenUserAlreadyExistsThenException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.manager.createUser(this.user))
@@ -21,6 +21,9 @@ import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import org.jspecify.annotations.Nullable;
/**
* UTF-8 Charset encoder/decoder.
@@ -66,4 +69,21 @@ public final class Utf8 {
}
}
/**
* Constant time comparison to prevent against timing attacks.
* @param expected the expected {@link CharSequence}
* @param actual the actual {@link CharSequence}
* @return true if {@code expected} and {@code actual} are equal, false otherwise
* @since 5.7.26
*/
public static boolean isEqual(@Nullable CharSequence expected, @Nullable CharSequence actual) {
byte[] expectedBytes = bytesUtf8(expected);
byte[] actualBytes = bytesUtf8(actual);
return MessageDigest.isEqual(expectedBytes, actualBytes);
}
private static byte @Nullable [] bytesUtf8(@Nullable CharSequence s) {
return (s != null) ? Utf8.encode(s) : null;
}
}
@@ -37,7 +37,9 @@ import org.springframework.security.crypto.util.EncodingUtils;
*
* @author Keith Donald
* @author Dave Syer
* @deprecated Use {@link AesCbcBytesEncryptor} or {@link AesGcmBytesEncryptor} instead.
*/
@Deprecated
public final class AesBytesEncryptor implements BytesEncryptor {
private final SecretKey secretKey;
@@ -0,0 +1,165 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.crypto.encrypt;
import java.util.Objects;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
import org.springframework.security.crypto.keygen.KeyGenerators;
import org.springframework.security.crypto.util.EncodingUtils;
/**
* {@link BytesEncryptor} that uses 256-bit AES/CBC/PKCS5Padding with a random 16-byte
* initialization vector. The IV is prepended to the ciphertext on encrypt and stripped on
* decrypt.
*
* <p>
* Note that CBC mode provides confidentiality but <em>not</em> integrity or authenticity.
* Applications that require authenticated encryption should prefer
* {@link AesGcmBytesEncryptor}. See the <a href=
* "https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html">
* OWASP Cryptographic Storage Cheat Sheet</a> for guidance on choosing a cipher mode.
*
* <p>
* When key derivation is used via {@link #withPassword(String, CharSequence)}, the key is
* derived using PBKDF2WithHmacSHA256 with {@code DEFAULT_PBKDF2_ITERATIONS} iterations
* per the <a href=
* "https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">
* OWASP Password Storage Cheat Sheet</a>. Because derivation is intentionally expensive,
* the encryptor instance should be created once and reused rather than constructed
* per-operation.
*
* @author Josh Cummings
* @since 5.7.26
* @see AesGcmBytesEncryptor
* @see AesBytesEncryptor
*/
public final class AesCbcBytesEncryptor implements BytesEncryptor {
private static final String ALGORITHM = "AES/CBC/PKCS5Padding";
private static final int IV_LENGTH_BYTES = 16;
private static final int DEFAULT_PBKDF2_ITERATIONS = 600_000;
private final SecretKey secretKey;
private final Cipher encryptor;
private final Cipher decryptor;
private final BytesKeyGenerator ivGenerator;
private AesCbcBytesEncryptor(SecretKey secretKey, BytesKeyGenerator ivGenerator) {
this.secretKey = new SecretKeySpec(secretKey.getEncoded(), "AES");
this.encryptor = CipherUtils.newCipher(ALGORITHM);
this.decryptor = CipherUtils.newCipher(ALGORITHM);
this.ivGenerator = ivGenerator;
}
/**
* Creates an encryptor that derives its key from the given password and hex-encoded
* salt using PBKDF2WithHmacSHA1.
* @param password the password value
* @param salt the hex-encoded salt value
*/
public static Builder withPassword(String password, CharSequence salt) {
return new Builder(password, salt);
}
/**
* Creates an encryptor using the supplied {@link SecretKey}.
* @param secretKey the secret (symmetric) key
*/
public static Builder withSecretKey(SecretKey secretKey) {
return new Builder(secretKey);
}
@Override
public byte[] encrypt(byte[] bytes) {
synchronized (this.encryptor) {
byte[] iv = this.ivGenerator.generateKey();
CipherUtils.initCipher(this.encryptor, Cipher.ENCRYPT_MODE, this.secretKey, new IvParameterSpec(iv));
byte[] ciphertext = CipherUtils.doFinal(this.encryptor, bytes);
return EncodingUtils.concatenate(iv, ciphertext);
}
}
@Override
public byte[] decrypt(byte[] encryptedBytes) {
int ivLength = this.ivGenerator.getKeyLength();
byte[] iv = EncodingUtils.subArray(encryptedBytes, 0, ivLength);
byte[] ciphertext = EncodingUtils.subArray(encryptedBytes, ivLength, encryptedBytes.length);
synchronized (this.decryptor) {
CipherUtils.initCipher(this.decryptor, Cipher.DECRYPT_MODE, this.secretKey, new IvParameterSpec(iv));
return CipherUtils.doFinal(this.decryptor, ciphertext);
}
}
private static SecretKey deriveKey(String password, CharSequence salt) {
return CipherUtils.newSecretKey("PBKDF2WithHmacSHA256",
new PBEKeySpec(password.toCharArray(), Hex.decode(salt), DEFAULT_PBKDF2_ITERATIONS, 256));
}
/**
* A Builder for {@link AesCbcBytesEncryptor}.
*/
public static final class Builder {
private final SecretKey secretKey;
private BytesKeyGenerator ivGenerator = KeyGenerators.secureRandom(IV_LENGTH_BYTES);
private Builder(SecretKey secretKey) {
this.secretKey = secretKey;
}
private Builder(String password, CharSequence salt) {
this.secretKey = deriveKey(password, salt);
}
/**
* Sets the {@link BytesKeyGenerator} to use for generating the initialization
* vector.
* @param ivGenerator the {@link BytesKeyGenerator} to use for generating the
* initialization vector
* @return this builder
*/
public Builder ivGenerator(BytesKeyGenerator ivGenerator) {
Objects.requireNonNull(ivGenerator, "ivGenerator cannot be null");
this.ivGenerator = ivGenerator;
return this;
}
/**
* Builds the {@link AesCbcBytesEncryptor}.
* @return the {@link AesCbcBytesEncryptor}
*/
public AesCbcBytesEncryptor build() {
return new AesCbcBytesEncryptor(this.secretKey, this.ivGenerator);
}
}
}
@@ -0,0 +1,172 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.crypto.encrypt;
import java.util.Objects;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
import org.springframework.security.crypto.keygen.KeyGenerators;
import org.springframework.security.crypto.util.EncodingUtils;
/**
* {@link BytesEncryptor} that uses 256-bit AES/GCM/NoPadding with a random 16-byte
* initialization vector and a 128-bit authentication tag. The IV is prepended to the
* ciphertext on encrypt and stripped on decrypt. GCM provides authenticated encryption
* (AEAD): both confidentiality and integrity are protected, and decryption throws if the
* ciphertext has been tampered with.
*
* <p>
* This class uses a 16-byte (128-bit) IV rather than the 12-byte (96-bit) IV recommended
* by NIST SP 800-38D for GCM. Both lengths are cryptographically valid; the 16-byte
* choice maintains consistency with the rest of the Spring Security crypto module. For
* additional guidance, please see the <a href=
* "https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html">
* OWASP Cryptographic Storage Cheat Sheet</a>.
*
* <p>
* When key derivation is used via {@link #withPassword(String, CharSequence)}, the key is
* derived using PBKDF2WithHmacSHA256 with {@code DEFAULT_PBKDF2_ITERATIONS} iterations
* per the <a href=
* "https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">
* OWASP Password Storage Cheat Sheet</a>. Because derivation is intentionally expensive,
* the encryptor instance should be created once and reused rather than constructed
* per-operation.
*
* @author Josh Cummings
* @since 5.7.26
* @see AesCbcBytesEncryptor
* @see AesBytesEncryptor
*/
public final class AesGcmBytesEncryptor implements BytesEncryptor {
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int IV_LENGTH_BYTES = 16;
private static final int TAG_LENGTH_BITS = 128;
private static final int DEFAULT_PBKDF2_ITERATIONS = 600_000;
private final SecretKey secretKey;
private final Cipher encryptor;
private final Cipher decryptor;
private final BytesKeyGenerator ivGenerator;
private AesGcmBytesEncryptor(SecretKey secretKey, BytesKeyGenerator ivGenerator) {
this.secretKey = new SecretKeySpec(secretKey.getEncoded(), "AES");
this.encryptor = CipherUtils.newCipher(ALGORITHM);
this.decryptor = CipherUtils.newCipher(ALGORITHM);
this.ivGenerator = ivGenerator;
}
/**
* Creates an encryptor that derives its key from the given password and hex-encoded
* salt using PBKDF2WithHmacSHA1.
* @param password the password value
* @param salt the hex-encoded salt value
*/
public static Builder withPassword(String password, CharSequence salt) {
return new Builder(password, salt);
}
/**
* Creates an encryptor using the supplied {@link SecretKey}.
* @param secretKey the secret (symmetric) key
*/
public static Builder withSecretKey(SecretKey secretKey) {
return new Builder(secretKey);
}
@Override
public byte[] encrypt(byte[] bytes) {
synchronized (this.encryptor) {
byte[] iv = this.ivGenerator.generateKey();
CipherUtils.initCipher(this.encryptor, Cipher.ENCRYPT_MODE, this.secretKey,
new GCMParameterSpec(TAG_LENGTH_BITS, iv));
byte[] ciphertext = CipherUtils.doFinal(this.encryptor, bytes);
return EncodingUtils.concatenate(iv, ciphertext);
}
}
@Override
public byte[] decrypt(byte[] encryptedBytes) {
int ivLength = this.ivGenerator.getKeyLength();
byte[] iv = EncodingUtils.subArray(encryptedBytes, 0, ivLength);
byte[] ciphertext = EncodingUtils.subArray(encryptedBytes, ivLength, encryptedBytes.length);
synchronized (this.decryptor) {
CipherUtils.initCipher(this.decryptor, Cipher.DECRYPT_MODE, this.secretKey,
new GCMParameterSpec(TAG_LENGTH_BITS, iv));
return CipherUtils.doFinal(this.decryptor, ciphertext);
}
}
private static SecretKey deriveKey(String password, CharSequence salt) {
return CipherUtils.newSecretKey("PBKDF2WithHmacSHA256",
new PBEKeySpec(password.toCharArray(), Hex.decode(salt), DEFAULT_PBKDF2_ITERATIONS, 256));
}
/**
* A Builder for {@link AesGcmBytesEncryptor}.
*/
public static final class Builder {
private final SecretKey secretKey;
private BytesKeyGenerator ivGenerator = KeyGenerators.secureRandom(IV_LENGTH_BYTES);
private Builder(SecretKey secretKey) {
this.secretKey = secretKey;
}
private Builder(String password, CharSequence salt) {
this.secretKey = deriveKey(password, salt);
}
/**
* Sets the {@link BytesKeyGenerator} to use for generating the initialization
* vector.
* @param ivGenerator the {@link BytesKeyGenerator} to use for generating the
* initialization vector
* @return this builder
*/
public Builder ivGenerator(BytesKeyGenerator ivGenerator) {
Objects.requireNonNull(ivGenerator, "ivGenerator cannot be null");
this.ivGenerator = ivGenerator;
return this;
}
/**
* Builds the {@link AesGcmBytesEncryptor}.
* @return the {@link AesGcmBytesEncryptor}
*/
public AesGcmBytesEncryptor build() {
return new AesGcmBytesEncryptor(this.secretKey, this.ivGenerator);
}
}
}
@@ -16,7 +16,6 @@
package org.springframework.security.crypto.encrypt;
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
import org.springframework.security.crypto.keygen.KeyGenerators;
/**
@@ -42,9 +41,14 @@ public final class Encryptors {
* not be shared
* @param salt a hex-encoded, random, site-global salt value to use to generate the
* key
* @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)}
* instead.
*/
@Deprecated
@SuppressWarnings("deprecation")
public static BytesEncryptor stronger(CharSequence password, CharSequence salt) {
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16), CipherAlgorithm.GCM);
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16),
AesBytesEncryptor.CipherAlgorithm.GCM);
}
/**
@@ -53,18 +57,16 @@ public final class Encryptors {
* Function #2). Salts the password to prevent dictionary attacks against the key. The
* provided salt is expected to be hex-encoded; it should be random and at least 8
* bytes in length. Also applies a random 16-byte initialization vector to ensure each
* encrypted message will be unique. Requires Java 6. NOTE: This mode is not
* <a href="https://en.wikipedia.org/wiki/Authenticated_encryption">authenticated</a>
* and does not provide any guarantees about the authenticity of the data. For a more
* secure alternative, users should prefer
* {@link #stronger(CharSequence, CharSequence)}.
* encrypted message will be unique. Requires Java 6.
* @param password the password used to generate the encryptor's secret key; should
* not be shared
* @param salt a hex-encoded, random, site-global salt value to use to generate the
* key
*
* @see Encryptors#stronger(CharSequence, CharSequence)
* @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)}
* instead.
*/
@Deprecated
@SuppressWarnings("deprecation")
public static BytesEncryptor standard(CharSequence password, CharSequence salt) {
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16));
}
@@ -74,8 +76,12 @@ public final class Encryptors {
* text is hex-encoded.
* @param password the password used to generate the encryptor's secret key; should
* not be shared
* @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)}
* instead.
* @see Encryptors#stronger(CharSequence, CharSequence)
*/
@Deprecated
@SuppressWarnings("deprecation")
public static TextEncryptor delux(CharSequence password, CharSequence salt) {
return new HexEncodingTextEncryptor(stronger(password, salt));
}
@@ -85,8 +91,12 @@ public final class Encryptors {
* text is hex-encoded.
* @param password the password used to generate the encryptor's secret key; should
* not be shared
* @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)}
* instead.
* @see Encryptors#standard(CharSequence, CharSequence)
*/
@Deprecated
@SuppressWarnings("deprecation")
public static TextEncryptor text(CharSequence password, CharSequence salt) {
return new HexEncodingTextEncryptor(standard(password, salt));
}
@@ -16,8 +16,6 @@
package org.springframework.security.crypto.password;
import java.security.MessageDigest;
import org.jspecify.annotations.Nullable;
import org.springframework.security.crypto.codec.Utf8;
@@ -39,15 +37,7 @@ final class PasswordEncoderUtils {
* @return
*/
static boolean equals(String expected, @Nullable String actual) {
byte[] expectedBytes = bytesUtf8(expected);
byte[] actualBytes = bytesUtf8(actual);
return MessageDigest.isEqual(expectedBytes, actualBytes);
}
private static byte @Nullable [] bytesUtf8(@Nullable String s) {
// need to check if Utf8.encode() runs in constant time (probably not).
// This may leak length of string.
return (s != null) ? Utf8.encode(s) : null;
return Utf8.isEqual(expected, actual);
}
}
@@ -24,6 +24,7 @@ import com.password4j.BalloonHashingFunction;
import com.password4j.Hash;
import com.password4j.Password;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
import org.springframework.util.Assert;
@@ -140,7 +141,7 @@ public class BalloonHashingPassword4jPasswordEncoder extends AbstractValidatingP
String expectedHash = parts[1];
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
return expectedHash.equals(hash.getResult());
return Utf8.isEqual(expectedHash, hash.getResult());
}
catch (IllegalArgumentException ex) {
// Invalid Base64 encoding
@@ -24,6 +24,7 @@ import com.password4j.Hash;
import com.password4j.PBKDF2Function;
import com.password4j.Password;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
import org.springframework.util.Assert;
@@ -138,7 +139,7 @@ public class Pbkdf2Password4jPasswordEncoder extends AbstractValidatingPasswordE
String expectedHash = parts[1];
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
return expectedHash.equals(hash.getResult());
return Utf8.isEqual(expectedHash, hash.getResult());
}
catch (IllegalArgumentException ex) {
// Invalid Base64 encoding
@@ -37,4 +37,48 @@ public class Utf8Tests {
assertThat(decoded).isEqualTo("6048b75ed560785c");
}
@Test
public void isEqualWhenDifferentLengthThenFalse() {
assertThat(Utf8.isEqual("abc", "a")).isFalse();
assertThat(Utf8.isEqual("a", "abc")).isFalse();
}
@Test
public void isEqualWhenNullAndNotEmptyThenFalse() {
assertThat(Utf8.isEqual(null, "a")).isFalse();
assertThat(Utf8.isEqual("a", null)).isFalse();
}
@Test
public void isEqualWhenNullAndNullThenTrue() {
assertThat(Utf8.isEqual(null, null)).isTrue();
}
@Test
public void isEqualWhenNullAndEmptyThenFalse() {
assertThat(Utf8.isEqual(null, "")).isFalse();
assertThat(Utf8.isEqual("", null)).isFalse();
}
@Test
public void isEqualWhenNotEmptyAndEmptyThenFalse() {
assertThat(Utf8.isEqual("abc", "")).isFalse();
assertThat(Utf8.isEqual("", "abc")).isFalse();
}
@Test
public void isEqualWhenEmptyAndEmptyThenTrue() {
assertThat(Utf8.isEqual("", "")).isTrue();
}
@Test
public void isEqualWhenDifferentCaseThenFalse() {
assertThat(Utf8.isEqual("aBc", "abc")).isFalse();
}
@Test
public void isEqualWhenSameThenTrue() {
assertThat(Utf8.isEqual("abcdef", "abcdef")).isTrue();
}
}
@@ -0,0 +1,203 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.crypto.encrypt;
import java.nio.charset.StandardCharsets;
import javax.crypto.SecretKey;
import javax.crypto.spec.PBEKeySpec;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
import org.springframework.security.crypto.keygen.KeyGenerators;
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm;
import org.springframework.security.crypto.util.EncodingUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link AesCbcBytesEncryptor}.
*/
class AesCbcBytesEncryptorTests {
private final String secret = "value";
private final String password = "password";
private final String hexSalt = "deadbeef";
@Test
void roundtripWhenUsingPasswordAndSaltThenEncryptsAndDecrypts() {
CryptoAssumptions.assumeCBCJCE();
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void roundtripWhenUsingSecretKeyThenEncryptsAndDecrypts() {
CryptoAssumptions.assumeCBCJCE();
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
SecretKey secretKey = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(secretKey).build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void encryptWhenUsingMockIvThenProducesKnownCiphertext() {
CryptoAssumptions.assumeCBCJCE();
BytesKeyGenerator mockGenerator = mock(BytesKeyGenerator.class);
given(mockGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
given(mockGenerator.getKeyLength()).willReturn(16);
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt)
.ivGenerator(mockGenerator)
.build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(Hex.encode(encrypted)).isEqualTo("4b0febebd439db7ca77153cb254520c3b7232ac29355d07869433f1ecf55fe94");
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void encryptProducesUniqueOutputAndIvIsPrePended() {
CryptoAssumptions.assumeCBCJCE();
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
byte[] first = encryptor.encrypt(this.secret.getBytes());
byte[] second = encryptor.encrypt(this.secret.getBytes());
assertThat(first).isNotEqualTo(second);
assertThat(first.length).isGreaterThan(16);
}
@Test
@SuppressWarnings("deprecation")
void migratesFromDeprecatedNullIvCbcToAesCbcBytesEncryptor() {
CryptoAssumptions.assumeCBCJCE();
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt);
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
AesCbcBytesEncryptor modern = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
BytesEncryptor migrating = new MigratingBytesEncryptor("{CBC}", modern, deprecated);
assertThat(new String(migrating.decrypt(encrypted))).isEqualTo(this.secret);
byte[] migrated = migrating.encrypt(this.secret.getBytes());
assertThat(migrated[0]).isEqualTo((byte) '{');
assertThat(migrated[1]).isEqualTo((byte) 'C');
assertThat(migrated[2]).isEqualTo((byte) 'B');
assertThat(migrated[3]).isEqualTo((byte) 'C');
assertThat(migrated[4]).isEqualTo((byte) '}');
assertThat(new String(migrating.decrypt(migrated))).isEqualTo(this.secret);
}
@Test
@SuppressWarnings("deprecation")
void withSecretWhenAesBytesEncryptorEncryptsThenDecrypts() {
CryptoAssumptions.assumeCBCJCE();
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
AesBytesEncryptor.CipherAlgorithm.CBC);
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(key).build();
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
@SuppressWarnings("deprecation")
void aesBytesEncryptorWhenEncryptsThenAesCbcBytesEncryptorDecrypts() {
CryptoAssumptions.assumeCBCJCE();
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(key).build();
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
AesBytesEncryptor.CipherAlgorithm.CBC);
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(deprecated.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void roundtripWhenUsingCustomIvGeneratorThenEncryptsAndDecrypts() {
CryptoAssumptions.assumeCBCJCE();
BytesKeyGenerator customIvGenerator = mock(BytesKeyGenerator.class);
given(customIvGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
given(customIvGenerator.getKeyLength()).willReturn(16);
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt)
.ivGenerator(customIvGenerator)
.build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
@SuppressWarnings("deprecation")
void withPasswordDerivesADifferentKeyThanAesBytesEncryptor() {
CryptoAssumptions.assumeCBCJCE();
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt,
KeyGenerators.secureRandom(16));
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
}
private static final class MigratingBytesEncryptor implements BytesEncryptor {
private final byte[] prefix;
private final BytesEncryptor write;
private final BytesEncryptor deprecated;
MigratingBytesEncryptor(String prefix, BytesEncryptor write, BytesEncryptor deprecated) {
this.prefix = prefix.getBytes(StandardCharsets.US_ASCII);
this.write = write;
this.deprecated = deprecated;
}
@Override
public byte[] encrypt(byte[] bytes) {
return EncodingUtils.concatenate(this.prefix, this.write.encrypt(bytes));
}
@Override
public byte[] decrypt(byte[] encryptedBytes) {
if (startsWith(encryptedBytes, this.prefix)) {
byte[] bytes = EncodingUtils.subArray(encryptedBytes, this.prefix.length, encryptedBytes.length);
return this.write.decrypt(bytes);
}
return this.deprecated.decrypt(encryptedBytes);
}
private static boolean startsWith(byte[] data, byte[] prefix) {
if (data.length < prefix.length) {
return false;
}
for (int i = 0; i < prefix.length; i++) {
if (data[i] != prefix[i]) {
return false;
}
}
return true;
}
}
}
@@ -0,0 +1,146 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.crypto.encrypt;
import javax.crypto.SecretKey;
import javax.crypto.spec.PBEKeySpec;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
import org.springframework.security.crypto.keygen.KeyGenerators;
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link AesGcmBytesEncryptor}.
*/
class AesGcmBytesEncryptorTests {
private final String secret = "value";
private final String password = "password";
private final String hexSalt = "deadbeef";
@Test
void roundtripWhenUsingPasswordAndSaltThenEncryptsAndDecrypts() {
CryptoAssumptions.assumeGCMJCE();
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void roundtripWhenUsingSecretKeyThenEncryptsAndDecrypts() {
CryptoAssumptions.assumeGCMJCE();
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
SecretKey secretKey = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(secretKey).build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void encryptWhenUsingMockIvThenProducesKnownCiphertext() {
CryptoAssumptions.assumeGCMJCE();
BytesKeyGenerator mockGenerator = mock(BytesKeyGenerator.class);
given(mockGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
given(mockGenerator.getKeyLength()).willReturn(16);
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt)
.ivGenerator(mockGenerator)
.build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(Hex.encode(encrypted))
.isEqualTo("4b0febebd439db7ca77153cb254520c3e4d61ae38207b4e42b820d311dc3d4e0e2f37ed5ee");
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void encryptProducesUniqueOutputAndIvIsPrepended() {
CryptoAssumptions.assumeGCMJCE();
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
byte[] first = encryptor.encrypt(this.secret.getBytes());
byte[] second = encryptor.encrypt(this.secret.getBytes());
assertThat(first).isNotEqualTo(second);
assertThat(first.length).isGreaterThan(32);
}
@Test
@SuppressWarnings("deprecation")
void withSecretWhenAesBytesEncryptorEncryptsThenDecrypts() {
CryptoAssumptions.assumeGCMJCE();
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
AesBytesEncryptor.CipherAlgorithm.GCM);
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(key).build();
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
@SuppressWarnings("deprecation")
void aesBytesEncryptorWhenEncryptsThenAesGcmBytesEncryptorDecrypts() {
CryptoAssumptions.assumeGCMJCE();
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(key)
.ivGenerator(KeyGenerators.secureRandom(12))
.build();
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(12),
AesBytesEncryptor.CipherAlgorithm.GCM);
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(deprecated.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void roundtripWhenUsingCustomIvGeneratorLengthThenEncryptsAndDecrypts() {
CryptoAssumptions.assumeGCMJCE();
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt)
.ivGenerator(KeyGenerators.secureRandom(12))
.build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
@SuppressWarnings("deprecation")
void withPasswordDerivesADifferentKeyThanAesBytesEncryptor() {
CryptoAssumptions.assumeGCMJCE();
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt,
KeyGenerators.secureRandom(16), AesBytesEncryptor.CipherAlgorithm.GCM);
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
}
@Test
void decryptDetectsAuthenticationTagTampering() {
CryptoAssumptions.assumeGCMJCE();
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
encrypted[17] ^= 0xFF;
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
}
}
@@ -24,25 +24,23 @@ import javax.crypto.NoSuchPaddingException;
import org.junit.jupiter.api.Assumptions;
import org.opentest4j.TestAbortedException;
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
public final class CryptoAssumptions {
private CryptoAssumptions() {
}
public static void assumeGCMJCE() {
assumeAes256(CipherAlgorithm.GCM);
assumeAes256("GCM");
}
public static void assumeCBCJCE() {
assumeAes256(CipherAlgorithm.CBC);
assumeAes256("CBC");
}
private static void assumeAes256(CipherAlgorithm cipherAlgorithm) {
private static void assumeAes256(String cipherAlgorithm) {
boolean aes256Available = false;
try {
Cipher.getInstance(cipherAlgorithm.toString());
Cipher.getInstance(cipherAlgorithm);
aes256Available = Cipher.getMaxAllowedKeyLength("AES") >= 256;
}
catch (NoSuchAlgorithmException ex) {
+5 -1
View File
@@ -75,7 +75,11 @@ dependencies {
api libs.org.skyscreamer.jsonassert
api libs.org.slf4j.log4j.over.slf4j
api libs.org.slf4j.slf4j.api
api libs.org.springframework.ldap.spring.ldap.core
if (project.hasProperty("isOverrideVersionCatalog") && project.hasProperty("springLdapVersion")) {
api "org.springframework.ldap:spring-ldap-core:${springLdapVersion}"
} else {
api libs.org.springframework.ldap.spring.ldap.core
}
api libs.org.synchronoss.cloud.nio.multipart.parser
api libs.org.apache.maven.resolver.maven.resolver.connector.basic
api libs.org.apache.maven.resolver.maven.resolver.impl
@@ -23,10 +23,28 @@ When using a proxy server, it is important to ensure that you have configured yo
For example, many applications have a load balancer that responds to request for `\https://example.com/` by forwarding the request to an application server at `\https://192.168.0.107`
Without proper configuration, the application server can not know that the load balancer exists and treats the request as though `\https://192.168.0.107:8080` was requested by the client.
To fix this, you can use https://tools.ietf.org/html/rfc7239[RFC 7239] to specify that a load balancer is being used.
To make the application aware of this, you need to configure your application server to be aware of the X-Forwarded headers.
To fix this, the proxy needs to pass on the details of the original request, and the application needs to be configured to use them.
Two kinds of headers are used for this, and it is important to know which of them applies in your deployment:
* The standard `Forwarded` header, defined by https://tools.ietf.org/html/rfc7239[RFC 7239], which carries the original host, protocol, and client in a single header.
* The non-standard `X-Forwarded-*` headers, such as `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-For`, which predate RFC 7239.
Most proxies still send the `X-Forwarded-*` headers rather than the standard `Forwarded` header, while Spring Framework and servers such as Reactor Netty and Jetty understand both.
Do not assume that only one of them is in use.
[NOTE]
====
Both kinds of headers are supplied by the client unless a proxy overwrites them, so an application that trusts them without a trusted proxy in front of it can be made to believe a request arrived over a different host, protocol, or client address than it really did.
====
For this reason, the proxy at the edge of your network must be configured to remove or overwrite any forwarded headers that arrive from the outside, for _both_ kinds of headers.
Dropping only the `Forwarded` header while passing through `X-Forwarded-*` (or the reverse) leaves the application open to the same spoofing through the other set.
Only headers added by a proxy you control should reach the application.
Once untrusted values are handled at the edge, the application server can be configured to apply the headers.
For example, Tomcat uses https://tomcat.apache.org/tomcat-10.1-doc/api/org/apache/catalina/valves/RemoteIpValve.html[`RemoteIpValve`] and Jetty uses https://eclipse.dev/jetty/javadoc/jetty-11/org/eclipse/jetty/server/ForwardedRequestCustomizer.html[`ForwardedRequestCustomizer`].
Alternatively, Spring users can use https://docs.spring.io/spring-framework/reference/web/webmvc/filters.html#filters-forwarded-headers[`ForwardedHeaderFilter`] with the Servlet stack or https://docs.spring.io/spring-framework/reference/web/webflux/reactive-spring.html#webflux-forwarded-headers[`ForwardedHeaderTransformer`] with the Reactive stack.
Both handle the `Forwarded` header and the `X-Forwarded-*` headers, and both can be configured to remove the headers instead of applying them, which is useful when the application is not behind a proxy.
Spring Boot users can use the `server.forward-headers-strategy` property to configure the application.
See the https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto.webserver.use-behind-a-proxy-server[Spring Boot documentation] for further details.
@@ -8,8 +8,8 @@ The code is distributed as part of the core module but has no dependencies on an
[[spring-security-crypto-encryption]]
== Encryptors
The javadoc:org.springframework.security.crypto.encrypt.Encryptors[] class provides factory methods for constructing symmetric encryptors.
This class lets you create javadoc:org.springframework.security.crypto.encrypt.BytesEncryptor[] instances to encrypt data in raw `byte[]` form.
Spring Security provides javadoc:org.springframework.security.crypto.encrypt.AesGcmBytesEncryptor[] and javadoc:org.springframework.security.crypto.encrypt.AesCbcBytesEncryptor[] for constructing symmetric encryptors.
These can be used to encrypt data in raw `byte[]` form.
You can also construct javadoc:org.springframework.security.crypto.encrypt.TextEncryptor[] instances to encrypt text strings.
Encryptors are thread-safe.
@@ -20,7 +20,7 @@ Both `BytesEncryptor` and `TextEncryptor` are interfaces. `BytesEncryptor` has m
[[spring-security-crypto-encryption-bytes]]
=== BytesEncryptor
You can use the `Encryptors.stronger` factory method to construct a `BytesEncryptor`:
Use `AesGcmBytesEncryptor` to construct a BytesEncryptor with authenticated encryption:
.BytesEncryptor
[tabs]
@@ -29,24 +29,22 @@ Java::
+
[source,java,role="primary"]
----
Encryptors.stronger("password", "salt");
AesGcmBytesEncryptor.withPassword("password", "salt").build();
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
Encryptors.stronger("password", "salt")
AesGcmBytesEncryptor.withPassword("password", "salt").build()
----
======
The `stronger` encryption method creates an encryptor by using 256-bit AES encryption with
Galois Counter Mode (GCM).
It derives the secret key by using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2).
This method requires Java 6.
The password used to generate the `SecretKey` should be kept in a secure place and should not be shared.
The salt is used to prevent dictionary attacks against the key in the event that your encrypted data is compromised.
A 16-byte random initialization vector is also applied so that each encrypted message is unique.
`AesGcmBytesEncryptor` uses 256-bit AES encryption with Galois Counter Mode (GCM), providing https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated encryption] (AEAD).
It derives the secret key using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2).
The password used to generate the SecretKey should be kept in a secure place and not be shared.
The salt is used to prevent dictionary attacks against the key in the event your encrypted data is compromised.
A 16-byte random initialization vector is also applied so each encrypted message is unique.
The provided salt should be in hex-encoded String form, be random, and be at least 8 bytes in length.
You can generate such a salt by using a `KeyGenerator`:
@@ -69,14 +67,33 @@ val salt = KeyGenerators.string().generateKey() // generates a random 8-byte sal
----
======
You can also use the `standard` encryption method, which is 256-bit AES in Cipher Block Chaining (CBC) Mode.
Users who require CBC mode may use `AesCbcBytesEncryptor`:
.AesCbcBytesEncryptor
[tabs]
======
Java::
+
[source,java,role="primary"]
----
AesCbcBytesEncryptor.withPassword("password", "salt").build();
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
AesCbcBytesEncryptor.withPassword("password", "salt").build()
----
======
This mode is not https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated] and does not provide any
guarantees about the authenticity of the data.
For a more secure alternative, use `Encryptors.stronger`.
For a more secure alternative, users should prefer `AesGcmBytesEncryptor`.
[[spring-security-crypto-encryption-text]]
=== TextEncryptor
You can use the `Encryptors.text` factory method to construct a standard TextEncryptor:
Use `AesCbcBytesEncryptor` to encrypt text data:
.TextEncryptor
[tabs]
@@ -85,19 +102,22 @@ Java::
+
[source,java,role="primary"]
----
Encryptors.text("password", "salt");
AesCbcBytesEncryptor.withPassword("password", "salt").build();
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
Encryptors.text("password", "salt")
AesCbcBytesEncryptor.withPassword("password", "salt").build()
----
======
A `TextEncryptor` uses a standard `BytesEncryptor` to encrypt text data.
Encrypted results are returned as hex-encoded strings for easy storage on the filesystem or in a database.
`AesCbcBytesEncryptor` encrypts data as raw bytes.
You can hex-encode the result for easy storage on the filesystem or in the database.
NOTE: Queryable text encryption (encrypting such that the same plaintext always produces the same ciphertext) is no longer recommended, as it relies on a fixed initialization vector and does not provide adequate security.
Instead, look to your data store for a mechanism to query encrypted data.
[[spring-security-crypto-keygenerators]]
== Key Generators
@@ -132,4 +132,11 @@ authenticationConverter.setBearerTokenResolver(myBearerTokenResolver)
authenticationConverter.setAuthenticationDetailsSource(myAuthenticationDetailsSource)
val filter = BearerTokenAuthenticationFilter(authenticationManager, authenticationConverter)
----
== `NimbusJwtDecoder`'s Default Connect and Read Timeouts Are Now 30 Seconds
`NimbusJwtDecoder`'s default `RestOperations`, used to fetch a JWK Set when no `RestOperations` is otherwise configured, previously used a 500 millisecond connect and read timeout.
This value was too short for many deployments and is now 30 seconds, aligning with the connect and read timeouts already used elsewhere in the OAuth2 Client and Resource Server support, such as xref:servlet/oauth2/client/index.adoc[`ClientRegistrations`].
If your application relies on the previous, shorter timeout -- for example, expecting a fast failure when the authorization server is unreachable -- you can restore it either by setting the JDK's `sun.net.client.defaultConnectTimeout` and `sun.net.client.defaultReadTimeout` system properties (in milliseconds), or by providing your own `RestOperations`, as described in xref:servlet/oauth2/resource-server/jwt.adoc#oauth2resourceserver-jwt-timeouts[Configuring Timeouts].
======
@@ -695,7 +695,7 @@ class MyAuthoritiesPopulator : LdapAuthoritiesPopulator {
You would then add a bean of this type to your application context and inject it into the `LdapAuthenticationProvider`. This is covered in the section on configuring LDAP by using explicit Spring beans in the LDAP chapter of the reference manual.
Note that you cannot use the namespace for configuration in this case.
You should also consult the {security-api-url}[Javadoc] for the relevant classes and interfaces.
You should also consult the xref:attachment$api/java/index.html[Javadoc] for the relevant classes and interfaces.
[[appendix-faq-namespace-post-processor]]
@@ -42,7 +42,7 @@ In the following sections we will explore how to configure OTT Login for your ne
[[default-pages]]
== Default Login Page and Default One-Time Token Submit Page
When the `oneTimeTokenLogin()` DSL is used, by default the One-Time Token Login Page is auto-generated by the org.springframework.security.web.authentication.ui:DefaultLoginPageGeneratingFilter[].
When the `oneTimeTokenLogin()` DSL is used, by default the One-Time Token Login Page is auto-generated by the javadoc:org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter[].
The DSL will also set up the javadoc:org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter[] to generate a default One-Time Token submit page.
[[sending-token-to-user]]
@@ -89,7 +89,7 @@ public class MagicLinkOneTimeTokenGenerationSuccessHandler implements OneTimeTok
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken) throws IOException, ServletException {
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(request.getRequestURL().toString())
.replacePath(request.getContextPath())
.replaceQuery(null)
.fragment(null)
@@ -147,7 +147,7 @@ class MagicLinkOneTimeTokenGenerationSuccessHandler(
) : OneTimeTokenGenerationSuccessHandler {
override fun handle(request: HttpServletRequest, response: HttpServletResponse, oneTimeToken: OneTimeToken) {
val builder = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
val builder = UriComponentsBuilder.fromUriString(request.getRequestURL().toString())
.replacePath(request.contextPath)
.replaceQuery(null)
.fragment(null)
@@ -211,7 +211,7 @@ public class SecurityConfig {
// ...
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin((ott) -> ott
.generateTokenUrl("/ott/my-generate-url")
.tokenGeneratingUrl("/ott/my-generate-url")
);
return http.build();
}
@@ -238,7 +238,7 @@ class SecurityConfig {
//...
formLogin { }
oneTimeTokenLogin {
generateTokenUrl = "/ott/my-generate-url"
tokenGeneratingUrl = "/ott/my-generate-url"
}
}
return http.build()
@@ -282,7 +282,7 @@ public class SecurityConfig {
// ...
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin((ott) -> ott
.submitPageUrl("/ott/submit")
.defaultSubmitPageUrl("/ott/submit")
);
return http.build();
}
@@ -309,7 +309,7 @@ class SecurityConfig {
//...
formLogin { }
oneTimeTokenLogin {
submitPageUrl = "/ott/submit"
defaultSubmitPageUrl = "/ott/submit"
}
}
return http.build()
@@ -150,7 +150,7 @@ This means that we are running our `Runnable` with the same user that was used t
== Spring Security Concurrency Classes
See the {security-api-url}index.html[Javadoc] for additional integrations with both the Java concurrent APIs and the Spring Task abstractions.
See the xref:attachment$api/java/index.html[Javadoc] for additional integrations with both the Java concurrent APIs and the Spring Task abstractions.
They are self-explanatory once you understand the previous code.
* javadoc:org.springframework.security.concurrent.DelegatingSecurityContextCallable[]
@@ -144,13 +144,13 @@ An `OAuth2Authorization` is a representation of an OAuth2 authorization, which h
[TIP]
The corresponding authorization model in Spring Security's OAuth2 Client support is xref:servlet/oauth2/client/core.adoc#oauth2Client-authorized-client[OAuth2AuthorizedClient].
After the successful completion of an authorization grant flow, an `OAuth2Authorization` is created and associates an {security-api-url}/org/springframework/security/oauth2/core/OAuth2AccessToken.html[`OAuth2AccessToken`], an (optional) {security-api-url}/org/springframework/security/oauth2/core/OAuth2RefreshToken.html[`OAuth2RefreshToken`], and additional state specific to the executed authorization grant type.
After the successful completion of an authorization grant flow, an `OAuth2Authorization` is created and associates an javadoc:org.springframework.security.oauth2.core.OAuth2AccessToken[], an (optional) javadoc:org.springframework.security.oauth2.core.OAuth2RefreshToken[], and additional state specific to the executed authorization grant type.
The {security-api-url}/org/springframework/security/oauth2/core/OAuth2Token.html[`OAuth2Token`] instances associated with an `OAuth2Authorization` vary, depending on the authorization grant type.
The javadoc:org.springframework.security.oauth2.core.OAuth2Token[] instances associated with an `OAuth2Authorization` vary, depending on the authorization grant type.
For the OAuth2 https://datatracker.ietf.org/doc/html/rfc6749#section-4.1[authorization_code grant], an `OAuth2AuthorizationCode`, an `OAuth2AccessToken`, and an (optional) `OAuth2RefreshToken` are associated.
For the OpenID Connect 1.0 https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth[authorization_code grant], an `OAuth2AuthorizationCode`, an {security-api-url}/org/springframework/security/oauth2/core/oidc/OidcIdToken.html[`OidcIdToken`], an `OAuth2AccessToken`, and an (optional) `OAuth2RefreshToken` are associated.
For the OpenID Connect 1.0 https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth[authorization_code grant], an `OAuth2AuthorizationCode`, an javadoc:org.springframework.security.oauth2.core.oidc.OidcIdToken[], an `OAuth2AccessToken`, and an (optional) `OAuth2RefreshToken` are associated.
For the OAuth2 https://datatracker.ietf.org/doc/html/rfc6749#section-4.4[client_credentials grant], only an `OAuth2AccessToken` is associated.
@@ -277,9 +277,9 @@ This is a minimal configuration for getting started quickly. To understand what
<1> A Spring Security filter chain for the xref:servlet/oauth2/authorization-server/protocol-endpoints.adoc[Protocol Endpoints].
<2> A Spring Security filter chain for xref:servlet/authentication/index.adoc#servlet-authentication[authentication].
<3> An instance of {security-api-url}/org/springframework/security/core/userdetails/UserDetailsService.html[`UserDetailsService`] for retrieving users to authenticate.
<3> An instance of javadoc:org.springframework.security.core.userdetails.UserDetailsService[] for retrieving users to authenticate.
<4> An instance of xref:servlet/oauth2/authorization-server/core-model-components.adoc#oauth2AuthorizationServer-registered-client-repository[`RegisteredClientRepository`] for managing clients.
<5> An instance of `com.nimbusds.jose.jwk.source.JWKSource` for signing access tokens.
<6> An instance of `java.security.KeyPair` with keys generated on startup used to create the `JWKSource` above.
<7> An instance of {security-api-url}/org/springframework/security/oauth2/jwt/JwtDecoder.html[`JwtDecoder`] for decoding signed access tokens.
<7> An instance of javadoc:org.springframework.security.oauth2.jwt.JwtDecoder[] for decoding signed access tokens.
<8> An instance of xref:servlet/oauth2/authorization-server/configuration-model.adoc#oauth2AuthorizationServer-configuring-authorization-server-settings[`AuthorizationServerSettings`] to configure Spring Security Authorization Server.
@@ -944,5 +944,52 @@ For MAC-based algorithms (such as `HS256`, `HS384`, or `HS512`), the `client-sec
If more than one `ClientRegistration` is configured for OpenID Connect 1.0 Authentication, the JWS algorithm resolver may evaluate the provided `ClientRegistration` to determine which algorithm to return.
====
[[oauth2login-advanced-idtoken-decoder-factory]]
== Providing a Custom JwtDecoderFactory
`OidcIdTokenDecoderFactory` is deliberately narrow: it exists to make it easy to support multiple `ClientRegistration` instances, each potentially needing a different `JwtDecoder`, and its configuration surface is limited to what most applications need for that purpose, such as the JWS algorithm resolver shown above.
If your application needs deeper control over how the ID Token's `JwtDecoder` is constructed -- for example, providing your own `RestOperations` -- you can instead provide your own `JwtDecoderFactory<ClientRegistration>` `@Bean`.
Because OAuth2 Login does not use a resource server `JwtDecoder` bean for this purpose, this is the supported way to reach the same level of control that `NimbusJwtDecoder`'s builder already offers, without introducing additional configuration properties on `OidcIdTokenDecoderFactory` itself.
For a single `ClientRegistration`, this can be as simple as:
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Bean
JwtDecoderFactory<ClientRegistration> idTokenDecoderFactory(RestOperations rest) {
return (clientRegistration) -> {
String issuerUri = clientRegistration.getProviderDetails().getIssuerUri();
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuerUri).restOperations(rest).build();
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(new OidcIdTokenValidator(clientRegistration)));
decoder.setClaimTypeConverter(OidcIdTokenDecoderFactory.createDefaultClaimTypeConverter());
return decoder;
};
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Bean
fun idTokenDecoderFactory(rest: RestOperations): JwtDecoderFactory<ClientRegistration> {
return JwtDecoderFactory { clientRegistration ->
val issuerUri = clientRegistration.providerDetails.issuerUri
val decoder = NimbusJwtDecoder.withIssuerLocation(issuerUri).restOperations(rest).build()
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(OidcIdTokenValidator(clientRegistration)))
decoder.setClaimTypeConverter(OidcIdTokenDecoderFactory.createDefaultClaimTypeConverter())
decoder
}
}
----
======
If you have multiple `ClientRegistration` instances and want to avoid rebuilding a `JwtDecoder` on every request, cache the decoder per registration, for example by annotating the factory method with `@Cacheable` or by keying a `Map` on `ClientRegistration#getRegistrationId()`.
[[oauth2login-advanced-oidc-logout]]
Then, you can proceed to configure xref:servlet/oauth2/login/logout.adoc[logout]
@@ -1582,6 +1582,7 @@ fun jwtDecoder(): JwtDecoder {
== Configuring Timeouts
By default, Resource Server uses connection and socket timeouts of 30 seconds each for coordinating with the authorization server.
You can override these defaults without changing any code by setting the JDK's `sun.net.client.defaultConnectTimeout` and `sun.net.client.defaultReadTimeout` system properties (in milliseconds).
This may be too short in some scenarios.
Further, it doesn't take into account more sophisticated patterns like back-off and discovery.
+2 -2
View File
@@ -1,10 +1,10 @@
{
"dependencies": {
"antora": "3.2.0-alpha.12",
"antora": "3.2.0-rc.2",
"@antora/atlas-extension": "1.0.0-alpha.5",
"@antora/collector-extension": "1.0.3",
"@asciidoctor/tabs": "1.0.0-beta.6",
"@springio/antora-extensions": "1.14.12",
"@springio/antora-extensions": "1.14.13",
"@springio/asciidoctor-extensions": "1.0.0-alpha.18"
}
}
+1 -1
View File
@@ -14,7 +14,7 @@
# limitations under the License.
#
springBootVersion=4.0.0-SNAPSHOT
version=7.0.6
version=7.0.8-SNAPSHOT
samplesBranch=main
org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError
org.gradle.parallel=true
+8 -8
View File
@@ -1,6 +1,6 @@
[versions]
com-squareup-okhttp3 = "3.14.9"
io-micrometer = "1.16.6"
io-micrometer = "1.16.7"
io-rsocket = "1.1.5"
io-spring-javaformat = "0.0.47"
io-spring-nohttp = "0.0.11"
@@ -13,11 +13,11 @@ org-jetbrains-kotlin = "2.2.21"
org-jetbrains-kotlinx = "1.10.2"
org-mockito = "5.17.0"
org-opensaml5 = "5.1.6"
org-springframework = "7.0.8"
org-springframework = "7.0.9"
com-password4j = "1.8.4"
[libraries]
ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.34"
ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.38"
com-fasterxml-jackson-jackson-bom = "com.fasterxml.jackson:jackson-bom:2.20.2"
com-google-inject-guice = "com.google.inject:guice:3.0"
com-netflix-nebula-nebula-project-plugin = "com.netflix.nebula:nebula-project-plugin:8.2.0"
@@ -25,14 +25,14 @@ com-nimbusds-nimbus-jose-jwt = "com.nimbusds:nimbus-jose-jwt:10.4"
com-nimbusds-oauth2-oidc-sdk = "com.nimbusds:oauth2-oidc-sdk:11.26.1"
com-squareup-okhttp3-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "com-squareup-okhttp3" }
com-squareup-okhttp3-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "com-squareup-okhttp3" }
com-unboundid-unboundid-ldapsdk = "com.unboundid:unboundid-ldapsdk:7.0.4"
com-unboundid-unboundid-ldapsdk = "com.unboundid:unboundid-ldapsdk:7.0.5"
com-jayway-jsonpath-json-path = "com.jayway.jsonpath:json-path:2.9.0"
commons-collections = "commons-collections:commons-collections:3.2.2"
io-micrometer-context-propagation = "io.micrometer:context-propagation:1.1.4"
io-micrometer-micrometer-bom = { module = "io.micrometer:micrometer-bom", version.ref = "io-micrometer" }
io-micrometer-micrometer-observation = { module = "io.micrometer:micrometer-observation", version.ref = "io-micrometer" }
io-mockk = "io.mockk:mockk:1.14.11"
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2025.0.6"
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2025.0.7"
io-rsocket-rsocket-bom = { module = "io.rsocket:rsocket-bom", version.ref = "io-rsocket" }
io-spring-javaformat-spring-javaformat-checkstyle = { module = "io.spring.javaformat:spring-javaformat-checkstyle", version.ref = "io-spring-javaformat" }
io-spring-javaformat-spring-javaformat-gradle-plugin = { module = "io.spring.javaformat:spring-javaformat-gradle-plugin", version.ref = "io-spring-javaformat" }
@@ -52,7 +52,7 @@ ldapsdk = "ldapsdk:ldapsdk:4.1"
net-sourceforge-htmlunit = "net.sourceforge.htmlunit:htmlunit:2.70.0"
org-htmlunit-htmlunit = "org.htmlunit:htmlunit:4.11.1"
org-apache-httpcomponents-httpclient = "org.apache.httpcomponents.client5:httpclient5:5.5.2"
org-apache-kerby-simplekdc='org.apache.kerby:kerb-simplekdc:2.1.1'
org-apache-kerby-simplekdc='org.apache.kerby:kerb-simplekdc:2.1.2'
org-apache-maven-maven-resolver-provider = "org.apache.maven:maven-resolver-provider:3.9.16"
org-apache-maven-resolver-maven-resolver-connector-basic = { module = "org.apache.maven.resolver:maven-resolver-connector-basic", version.ref = "org-apache-maven-resolver" }
org-apache-maven-resolver-maven-resolver-impl = { module = "org.apache.maven.resolver:maven-resolver-impl", version.ref = "org-apache-maven-resolver" }
@@ -83,8 +83,8 @@ org-seleniumhq-selenium-selenium-support = "org.seleniumhq.selenium:selenium-sup
org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3"
org-slf4j-log4j-over-slf4j = "org.slf4j:log4j-over-slf4j:1.7.36"
org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.18"
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2025.1.6"
org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.0.4"
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2025.1.7"
org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.0.5"
org-springframework-spring-framework-bom = { module = "org.springframework:spring-framework-bom", version.ref = "org-springframework" }
org-synchronoss-cloud-nio-multipart-parser = "org.synchronoss.cloud:nio-multipart-parser:1.1.0"
tools-jackson-jackson-bom = "tools.jackson:jackson-bom:3.0.4"
@@ -21,9 +21,11 @@ import java.net.ServerSocket;
import java.util.ArrayList;
import java.util.List;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import org.junit.jupiter.api.Test;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
@@ -48,6 +50,24 @@ public class UnboundIdContainerTests {
}
}
@Test
public void startLdapServerThenListenerBindsToLoopbackAddressOnly() throws Exception {
UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null);
server.setApplicationContext(new GenericApplicationContext());
List<Integer> ports = getDefaultPorts(1);
server.setPort(ports.get(0));
try {
server.afterPropertiesSet();
InMemoryDirectoryServer directoryServer = (InMemoryDirectoryServer) ReflectionTestUtils.getField(server,
"directoryServer");
assertThat(directoryServer.getListenAddress().isLoopbackAddress()).isTrue();
}
finally {
server.destroy();
}
}
@Test
public void afterPropertiesSetWhenPortIsZeroThenRandomPortIsSelected() throws Exception {
UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null);
@@ -17,6 +17,7 @@
package org.springframework.security.ldap.server;
import java.io.InputStream;
import java.net.InetAddress;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
@@ -96,7 +97,8 @@ public class UnboundIdContainer
try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(this.defaultPartitionSuffix);
config.addAdditionalBindCredentials("uid=admin,ou=system", "secret");
config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("LDAP", this.port));
config.setListenerConfigs(
InMemoryListenerConfig.createLDAPConfig("LDAP", InetAddress.getLoopbackAddress(), this.port, null));
config.setEnforceSingleStructuralObjectClass(false);
config.setEnforceAttributeSyntaxCompliance(true);
DN dn = new DN(this.defaultPartitionSuffix);
@@ -20,7 +20,6 @@ import java.util.function.Supplier;
import org.jspecify.annotations.Nullable;
import org.springframework.expression.BeanResolver;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.messaging.Message;
@@ -47,11 +46,7 @@ public class DefaultMessageSecurityExpressionHandler<T> extends AbstractSecurity
Message<T> message) {
MessageSecurityExpressionRoot<T> root = createSecurityExpressionRoot(authentication, message);
StandardEvaluationContext ctx = new StandardEvaluationContext(root);
BeanResolver beanResolver = getBeanResolver();
if (beanResolver != null) {
// https://github.com/spring-projects/spring-framework/issues/35371
ctx.setBeanResolver(beanResolver);
}
ctx.setBeanResolver(getBeanResolver());
return ctx;
}
@@ -175,10 +175,7 @@ public class CurrentSecurityContextArgumentResolver implements HandlerMethodArgu
StandardEvaluationContext context = new StandardEvaluationContext();
context.setRootObject(securityContext);
context.setVariable("this", securityContext);
if (this.beanResolver != null) {
// https://github.com/spring-projects/spring-framework/issues/35371
context.setBeanResolver(this.beanResolver);
}
context.setBeanResolver(this.beanResolver);
Expression expression = this.parser.parseExpression(expressionToParse);
securityContext = expression.getValue(context);
}
@@ -24,6 +24,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.lang.Nullable;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
@@ -192,38 +193,38 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza
}
private static boolean matchesState(OAuth2Authorization authorization, String token) {
return token.equals(authorization.getAttribute(OAuth2ParameterNames.STATE));
return Utf8.isEqual(authorization.getAttribute(OAuth2ParameterNames.STATE), token);
}
private static boolean matchesAuthorizationCode(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = authorization
.getToken(OAuth2AuthorizationCode.class);
return authorizationCode != null && authorizationCode.getToken().getTokenValue().equals(token);
return authorizationCode != null && Utf8.isEqual(authorizationCode.getToken().getTokenValue(), token);
}
private static boolean matchesAccessToken(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2AccessToken> accessToken = authorization.getToken(OAuth2AccessToken.class);
return accessToken != null && accessToken.getToken().getTokenValue().equals(token);
return accessToken != null && Utf8.isEqual(accessToken.getToken().getTokenValue(), token);
}
private static boolean matchesRefreshToken(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = authorization.getToken(OAuth2RefreshToken.class);
return refreshToken != null && refreshToken.getToken().getTokenValue().equals(token);
return refreshToken != null && Utf8.isEqual(refreshToken.getToken().getTokenValue(), token);
}
private static boolean matchesIdToken(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OidcIdToken> idToken = authorization.getToken(OidcIdToken.class);
return idToken != null && idToken.getToken().getTokenValue().equals(token);
return idToken != null && Utf8.isEqual(idToken.getToken().getTokenValue(), token);
}
private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode = authorization.getToken(OAuth2DeviceCode.class);
return deviceCode != null && deviceCode.getToken().getTokenValue().equals(token);
return deviceCode != null && Utf8.isEqual(deviceCode.getToken().getTokenValue(), token);
}
private static boolean matchesUserCode(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2UserCode> userCode = authorization.getToken(OAuth2UserCode.class);
return userCode != null && userCode.getToken().getTokenValue().equals(token);
return userCode != null && Utf8.isEqual(userCode.getToken().getTokenValue(), token);
}
@SuppressWarnings("serial")
@@ -27,6 +27,7 @@ import org.springframework.security.crypto.keygen.StringKeyGenerator;
* Requests.
*
* @author Joe Grandja
* @author Andrey Litvitski
* @since 7.0
*/
final class OAuth2PushedAuthorizationRequestUri {
@@ -60,7 +61,7 @@ final class OAuth2PushedAuthorizationRequestUri {
static OAuth2PushedAuthorizationRequestUri parse(String requestUri) {
int stateStartIndex = REQUEST_URI_PREFIX.length();
int expiresAtStartIndex = requestUri.indexOf(REQUEST_URI_DELIMITER) + REQUEST_URI_DELIMITER.length();
int expiresAtStartIndex = requestUri.lastIndexOf(REQUEST_URI_DELIMITER) + REQUEST_URI_DELIMITER.length();
OAuth2PushedAuthorizationRequestUri pushedAuthorizationRequestUri = new OAuth2PushedAuthorizationRequestUri();
pushedAuthorizationRequestUri.requestUri = requestUri;
pushedAuthorizationRequestUri.state = requestUri.substring(stateStartIndex);
@@ -29,6 +29,7 @@ import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.web.util.HtmlUtils;
/**
* For internal use only.
@@ -56,6 +57,12 @@ final class DefaultConsentPage {
private static String generateConsentPage(HttpServletRequest request, String clientId, Authentication principal,
Set<String> requestedScopes, Set<String> authorizedScopes, String state,
Map<String, String> additionalParameters) {
String encodedClientId = HtmlUtils.htmlEscape(clientId);
String encodedState = HtmlUtils.htmlEscape(state);
String encodedPrincipalName = HtmlUtils.htmlEscape(principal.getName());
String encodedRequestUri = HtmlUtils.htmlEscape(request.getRequestURI());
Set<String> scopesToAuthorize = new HashSet<>();
Set<String> scopesPreviouslyAuthorized = new HashSet<>();
for (String scope : requestedScopes) {
@@ -74,6 +81,7 @@ final class DefaultConsentPage {
// the "user_code" being displayed on the device to confirm they are
// authorizing the correct device.
String userCode = additionalParameters.get(OAuth2ParameterNames.USER_CODE);
String encodedUserCode = (userCode != null) ? HtmlUtils.htmlEscape(userCode) : null;
// @formatter:off
StringBuilder builder = new StringBuilder();
@@ -98,13 +106,13 @@ final class DefaultConsentPage {
builder.append(" </div>");
builder.append(" <div class=\"row\">");
builder.append(" <div class=\"col text-center\">");
builder.append(" <p><span class=\"font-weight-bold text-primary\">" + clientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + principal.getName() + "</span></p>");
builder.append(" <p><span class=\"font-weight-bold text-primary\">" + encodedClientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + encodedPrincipalName + "</span></p>");
builder.append(" </div>");
builder.append(" </div>");
if (userCode != null) {
builder.append(" <div class=\"row\">");
builder.append(" <div class=\"col text-center\">");
builder.append(" <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + userCode + "</span>. Verify that this code matches what is shown on your device.</p>");
builder.append(" <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + encodedUserCode + "</span>. Verify that this code matches what is shown on your device.</p>");
builder.append(" </div>");
builder.append(" </div>");
}
@@ -115,26 +123,28 @@ final class DefaultConsentPage {
builder.append(" </div>");
builder.append(" <div class=\"row\">");
builder.append(" <div class=\"col text-center\">");
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + request.getRequestURI() + "\">");
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + clientId + "\">");
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + state + "\">");
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + encodedRequestUri + "\">");
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + encodedClientId + "\">");
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + encodedState + "\">");
if (userCode != null) {
builder.append(" <input type=\"hidden\" name=\"user_code\" value=\"" + userCode + "\">");
builder.append(" <input type=\"hidden\" name=\"user_code\" value=\"" + encodedUserCode + "\">");
}
for (String scope : scopesToAuthorize) {
String encodedScope = HtmlUtils.htmlEscape(scope);
builder.append(" <div class=\"form-group form-check py-1\">");
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + scope + "\" id=\"" + scope + "\">");
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + encodedScope + "\" id=\"" + encodedScope + "\">");
builder.append(" <label class=\"form-check-label\" for=\"" + encodedScope + "\">" + encodedScope + "</label>");
builder.append(" </div>");
}
if (!scopesPreviouslyAuthorized.isEmpty()) {
builder.append(" <p>You have already granted the following permissions to the above app:</p>");
for (String scope : scopesPreviouslyAuthorized) {
String encodedScope = HtmlUtils.htmlEscape(scope);
builder.append(" <div class=\"form-group form-check py-1\">");
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + scope + "\" checked disabled>");
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + encodedScope + "\" checked disabled>");
builder.append(" <label class=\"form-check-label\" for=\"" + encodedScope + "\">" + encodedScope + "</label>");
builder.append(" </div>");
}
}
@@ -25,7 +25,9 @@ import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.security.oauth2.core.OAuth2UserCode;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
@@ -60,6 +62,10 @@ public class InMemoryOAuth2AuthorizationServiceTests {
private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN);
private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE);
private static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE);
private InMemoryOAuth2AuthorizationService authorizationService;
@BeforeEach
@@ -313,6 +319,44 @@ public class InMemoryOAuth2AuthorizationServiceTests {
assertThat(authorization).isEqualTo(result);
}
@Test
public void findByTokenWhenDeviceCodeExistsThenFound() {
OAuth2DeviceCode deviceCode = new OAuth2DeviceCode("device-code", Instant.now(),
Instant.now().plus(5, ChronoUnit.MINUTES));
OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
.id(ID)
.principalName(PRINCIPAL_NAME)
.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
.token(deviceCode)
.build();
this.authorizationService.save(authorization);
OAuth2Authorization result = this.authorizationService.findByToken(deviceCode.getTokenValue(),
DEVICE_CODE_TOKEN_TYPE);
assertThat(authorization).isEqualTo(result);
result = this.authorizationService.findByToken(deviceCode.getTokenValue(), null);
assertThat(authorization).isEqualTo(result);
}
@Test
public void findByTokenWhenUserCodeExistsThenFound() {
OAuth2UserCode userCode = new OAuth2UserCode("user-code", Instant.now(),
Instant.now().plus(5, ChronoUnit.MINUTES));
OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
.id(ID)
.principalName(PRINCIPAL_NAME)
.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
.token(userCode)
.build();
this.authorizationService.save(authorization);
OAuth2Authorization result = this.authorizationService.findByToken(userCode.getTokenValue(),
USER_CODE_TOKEN_TYPE);
assertThat(authorization).isEqualTo(result);
result = this.authorizationService.findByToken(userCode.getTokenValue(), null);
assertThat(authorization).isEqualTo(result);
}
@Test
public void findByTokenWhenWrongTokenTypeThenNotFound() {
OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now());
@@ -0,0 +1,71 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.oauth2.server.authorization.authentication;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link OAuth2PushedAuthorizationRequestUri}.
*
* @author Josh Cummings
* @author Andrey Litvitski
*/
public class OAuth2PushedAuthorizationRequestUriTests {
@Test
public void parseWhenValidRequestUriThenReturnsExpectedValues() {
String state = "abcXYZ123-abcXYZ123";
long epochMilli = 1700000000000L;
String requestUri = "urn:ietf:params:oauth:request_uri:" + state + "___" + epochMilli;
OAuth2PushedAuthorizationRequestUri parsed = OAuth2PushedAuthorizationRequestUri.parse(requestUri);
assertThat(parsed.getRequestUri()).isEqualTo(requestUri);
assertThat(parsed.getState()).isEqualTo(state + "___" + epochMilli);
assertThat(parsed.getExpiresAt()).isEqualTo(Instant.ofEpochMilli(epochMilli));
}
@Test
public void createWhenParsedThenReturnsEquivalentValues() {
Instant expiresAt = Instant.ofEpochMilli(1700000000000L);
OAuth2PushedAuthorizationRequestUri created = OAuth2PushedAuthorizationRequestUri.create(expiresAt);
OAuth2PushedAuthorizationRequestUri parsed = OAuth2PushedAuthorizationRequestUri.parse(created.getRequestUri());
assertThat(parsed.getRequestUri()).isEqualTo(created.getRequestUri());
assertThat(parsed.getState()).isEqualTo(created.getState());
assertThat(parsed.getExpiresAt()).isEqualTo(created.getExpiresAt());
}
@Test
public void parseWhenStateContainsDelimiterThenParsesSuccessfully() {
String state = "xXMGJTZwzXIFL8i_DFu_EM8IeWC___frCWjpiF2q-xs=";
long epochMillis = 1781670640281L;
String requestUri = "urn:ietf:params:oauth:request_uri:" + state + "___" + epochMillis;
OAuth2PushedAuthorizationRequestUri parsedUri = OAuth2PushedAuthorizationRequestUri.parse(requestUri);
assertThat(parsedUri.getRequestUri()).isEqualTo(requestUri);
assertThat(parsedUri.getState()).isEqualTo(state + "___" + epochMillis);
assertThat(parsedUri.getExpiresAt()).isEqualTo(Instant.ofEpochMilli(epochMillis));
}
}
@@ -64,6 +64,7 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHand
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.util.StringUtils;
import org.springframework.web.util.HtmlUtils;
import org.springframework.web.util.UriComponentsBuilder;
import static org.assertj.core.api.Assertions.assertThat;
@@ -581,6 +582,39 @@ public class OAuth2AuthorizationEndpointFilterTests {
}
}
@Test
public void doFilterWhenAuthorizationRequestConsentRequiredThenConsentResponseHtmlEscaped() throws Exception {
String unencodedScope = "<scope2>";
String unencodedState = "<state>";
Set<String> requestedScopes = new HashSet<>(Arrays.asList("scope1", unencodedScope));
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes((scopes) -> {
scopes.clear();
scopes.addAll(requestedScopes);
}).build();
// No scopes previously approved
OAuth2AuthorizationConsentAuthenticationToken authorizationConsentAuthenticationResult = new OAuth2AuthorizationConsentAuthenticationToken(
AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, unencodedState, new HashSet<>(),
null);
authorizationConsentAuthenticationResult.setAuthenticated(true);
given(this.authenticationManager.authenticate(any())).willReturn(authorizationConsentAuthenticationResult);
MockHttpServletRequest request = createAuthorizationRequest(registeredClient);
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain filterChain = mock(FilterChain.class);
this.filter.doFilter(request, response, filterChain);
verify(this.authenticationManager).authenticate(any());
verifyNoInteractions(filterChain);
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
String html = response.getContentAsString();
assertThat(html).doesNotContain(unencodedScope);
assertThat(html).doesNotContain(unencodedState);
assertThat(html).contains(HtmlUtils.htmlEscape(unencodedScope));
assertThat(html).contains("name=\"state\" value=\"" + HtmlUtils.htmlEscape(unencodedState) + "\"");
}
@Test
public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationResponse() throws Exception {
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().redirectUris((redirectUris) -> {
@@ -33,6 +33,8 @@ public final class DelegatingOAuth2TokenValidator<T extends OAuth2Token> impleme
private final Collection<OAuth2TokenValidator<T>> tokenValidators;
private boolean failOnError;
/**
* Constructs a {@code DelegatingOAuth2TokenValidator} using the provided validators.
* @param tokenValidators the {@link Collection} of {@link OAuth2TokenValidator}s to
@@ -57,8 +59,20 @@ public final class DelegatingOAuth2TokenValidator<T extends OAuth2Token> impleme
Collection<OAuth2Error> errors = new ArrayList<>();
for (OAuth2TokenValidator<T> validator : this.tokenValidators) {
errors.addAll(validator.validate(token).getErrors());
if (!errors.isEmpty() && this.failOnError) {
return OAuth2TokenValidatorResult.failure(errors);
}
}
return OAuth2TokenValidatorResult.failure(errors);
}
/**
* Fail-fast when a delegate errors, defaults to {@code false}.
* @param failOnError fail-fast when a delegate errors
* @since 6.5.12
*/
public void setFailOnError(boolean failOnError) {
this.failOnError = failOnError;
}
}
@@ -18,11 +18,11 @@ package org.springframework.security.oauth2.jwt;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@@ -39,12 +39,15 @@ import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import org.springframework.security.oauth2.core.ClaimAccessor;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.OAuth2Token;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
/**
@@ -63,10 +66,11 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
/**
* The default {@code OAuth2TokenValidator<Jwt>} factory that validates the
* {@code htm}, {@code htu}, {@code jti} and {@code iat} claims of the DPoP Proof
* {@link Jwt}.
* {@code htm}, {@code htu}, {@code iat}, {@code jkt}, {@code ath} and {@code jti}
* claims of the DPoP Proof {@link Jwt}.
*/
public static final Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = defaultJwtValidatorFactory();
public static final Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = createDefaultJwtValidatorFactory(
Collections.emptyList());
private static final JOSEObjectTypeVerifier<SecurityContext> DPOP_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>(
new JOSEObjectType("dpop+jwt"));
@@ -94,6 +98,70 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
this.jwtValidatorFactory = jwtValidatorFactory;
}
/**
* Creates a factory that provides an {@link OAuth2TokenValidator} for the specified
* {@link DPoPProofContext} and is used by the {@link JwtDecoder}. The returned
* factory provides a validator that validates the {@code htm}, {@code htu},
* {@code iat}, {@code jkt}, {@code ath} and {@code jti} claims, along with any custom
* validators provided.
* @param validators the custom validators to add
* @return a factory that provides an {@link OAuth2TokenValidator} for the specified
* {@link DPoPProofContext}
* @since 6.5.12
*/
public static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> createDefaultJwtValidatorFactory(
List<OAuth2TokenValidator<Jwt>> validators) {
Assert.notNull(validators, "validators cannot be null");
List<OAuth2TokenValidator<Jwt>> customValidators = new ArrayList<>();
if (!CollectionUtils.isEmpty(validators)) {
customValidators.addAll(validators);
}
final Duration clockSkew = Duration.ofSeconds(30);
final JwtIssuedAtValidator jwtIssuedAtValidator;
if (CollectionUtils.findValueOfType(customValidators, JwtIssuedAtValidator.class) != null) {
jwtIssuedAtValidator = CollectionUtils.findValueOfType(customValidators, JwtIssuedAtValidator.class);
customValidators.remove(jwtIssuedAtValidator);
}
else {
jwtIssuedAtValidator = new JwtIssuedAtValidator(true);
jwtIssuedAtValidator.setClockSkew(clockSkew);
}
final DPoPProofReplayValidator dPoPProofReplayValidator;
if (CollectionUtils.findValueOfType(customValidators, DPoPProofReplayValidator.class) != null) {
dPoPProofReplayValidator = CollectionUtils.findValueOfType(customValidators,
DPoPProofReplayValidator.class);
customValidators.remove(dPoPProofReplayValidator);
}
else {
dPoPProofReplayValidator = new DPoPProofReplayValidator(new DPoPProofReplayValidator.InMemoryCache());
dPoPProofReplayValidator.setClockSkew(clockSkew);
}
return (context) -> createDefaultJwtValidatorFactory(context, jwtIssuedAtValidator, dPoPProofReplayValidator,
customValidators);
}
private static OAuth2TokenValidator<Jwt> createDefaultJwtValidatorFactory(DPoPProofContext context,
JwtIssuedAtValidator jwtIssuedAtValidator, DPoPProofReplayValidator dPoPProofReplayValidator,
List<OAuth2TokenValidator<Jwt>> customValidators) {
// Add custom validators first then default validators in a specific order
List<OAuth2TokenValidator<Jwt>> tokenValidators = new ArrayList<>();
if (!CollectionUtils.isEmpty(customValidators)) {
tokenValidators.addAll(customValidators);
}
tokenValidators.add(new JwtClaimValidator<>("htm", context.getMethod()::equalsIgnoreCase));
tokenValidators.add(new JwtClaimValidator<>("htu", context.getTargetUri()::equals));
tokenValidators.add(jwtIssuedAtValidator);
if (context.getAccessToken() != null) {
tokenValidators.add(new JwkThumbprintValidator(context.getAccessToken()));
tokenValidators.add(new AthClaimValidator(context.getAccessToken()));
}
tokenValidators.add(dPoPProofReplayValidator);
DelegatingOAuth2TokenValidator<Jwt> delegatingTokenValidator = new DelegatingOAuth2TokenValidator<>(
tokenValidators);
delegatingTokenValidator.setFailOnError(true);
return delegatingTokenValidator;
}
private static NimbusJwtDecoder buildDecoder() {
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
jwtProcessor.setJWSTypeVerifier(DPOP_TYPE_VERIFIER);
@@ -137,39 +205,34 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
};
}
private static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> defaultJwtValidatorFactory() {
return (context) -> new DelegatingOAuth2TokenValidator<>(
new JwtClaimValidator<>("htm", context.getMethod()::equals),
new JwtClaimValidator<>("htu", context.getTargetUri()::equals), new JtiClaimValidator(),
new JwtIssuedAtValidator(true));
}
private static final class AthClaimValidator implements OAuth2TokenValidator<Jwt> {
private static final class JtiClaimValidator implements OAuth2TokenValidator<Jwt> {
private final OAuth2Token accessToken;
private static final Map<String, Long> JTI_CACHE = Collections.synchronizedMap(new JtiCache());
private AthClaimValidator(OAuth2Token accessToken) {
Assert.notNull(accessToken, "accessToken cannot be null");
this.accessToken = accessToken;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
String jti = jwt.getId();
if (!StringUtils.hasText(jti)) {
OAuth2Error error = createOAuth2Error("jti claim is required.");
String accessTokenHashClaim = jwt.getClaimAsString("ath");
if (!StringUtils.hasText(accessTokenHashClaim)) {
OAuth2Error error = createOAuth2Error("ath claim is required.");
return OAuth2TokenValidatorResult.failure(error);
}
// Enforce single-use to protect against DPoP proof replay
String jtiHash;
String accessTokenHash;
try {
jtiHash = computeSHA256(jti);
accessTokenHash = computeSHA256(this.accessToken.getTokenValue());
}
catch (Exception ex) {
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token.");
return OAuth2TokenValidatorResult.failure(error);
}
Instant expiry = Instant.now().plus(1, ChronoUnit.HOURS);
if ((JTI_CACHE.putIfAbsent(jtiHash, expiry.toEpochMilli())) != null) {
// Already used
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
if (!accessTokenHashClaim.equals(accessTokenHash)) {
OAuth2Error error = createOAuth2Error("ath claim is invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
return OAuth2TokenValidatorResult.success();
@@ -185,20 +248,65 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
@SuppressWarnings("serial")
private static final class JtiCache extends LinkedHashMap<String, Long> {
}
private static final int MAX_SIZE = 1000;
private static final class JwkThumbprintValidator implements OAuth2TokenValidator<Jwt> {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Long> eldest) {
if (size() > MAX_SIZE) {
return true;
}
Instant expiry = Instant.ofEpochMilli(eldest.getValue());
return Instant.now().isAfter(expiry);
private final OAuth2Token accessToken;
private final ClaimAccessor claims;
private JwkThumbprintValidator(OAuth2Token accessToken) {
Assert.notNull(accessToken, "accessToken cannot be null");
Assert.isInstanceOf(ClaimAccessor.class, accessToken, "accessToken must be instance of ClaimAccessor");
this.accessToken = accessToken;
this.claims = (ClaimAccessor) accessToken;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
String jwkThumbprintClaim = null;
Map<String, Object> confirmationMethodClaim = this.claims.getClaimAsMap("cnf");
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) {
jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt");
}
if (jwkThumbprintClaim == null) {
OAuth2Error error = createOAuth2Error("jkt claim is required.");
return OAuth2TokenValidatorResult.failure(error);
}
JWK jwk = null;
@SuppressWarnings("unchecked")
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
try {
jwk = JWK.parse(jwkJson);
}
catch (Exception ignored) {
}
if (jwk == null) {
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
String jwkThumbprint;
try {
jwkThumbprint = jwk.computeThumbprint().toString();
}
catch (Exception ex) {
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk.");
return OAuth2TokenValidatorResult.failure(error);
}
if (!jwkThumbprintClaim.equals(jwkThumbprint)) {
OAuth2Error error = createOAuth2Error("jkt claim is invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
return OAuth2TokenValidatorResult.success();
}
private static OAuth2Error createOAuth2Error(String reason) {
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
}
}
@@ -0,0 +1,446 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.oauth2.jwt;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean;
import com.nimbusds.jose.jwk.JWK;
import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import org.springframework.lang.Nullable;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* An {@link OAuth2TokenValidator} that mitigates DPoP Proof Replay.
*
* <p>
* This validator mitigates DPoP Proof Replay by ensuring the DPoP Proof:
* <ul>
* <li>contains the {@code iat} (issued at) claim, and it's within an acceptable time
* window (configured via {@link #setClockSkew(Duration)})</li>
* <li>contains the {@code jti} (JWT ID) claim, and it has not been used previously</li>
* </ul>
*
* <p>
* This implementation uses a {@link Cache} to store the {@code jti} claim (along with
* other information in {@link CacheValue CacheValue}) to enforce single-use. The
* {@code jti} is retained in the cache until the DPoP Proof expires, which is calculated
* as {@code iat + clockSkew}.
*
* @author Joe Grandja
* @since 6.5.12
* @see OAuth2TokenValidator
* @see DPoPProofJwtDecoderFactory
* @see <a target="_blank" href=
* "https://datatracker.ietf.org/doc/html/rfc9449#section-11.1">Section 11.1. DPoP Proof
* Replay</a>
*/
public final class DPoPProofReplayValidator implements OAuth2TokenValidator<Jwt> {
private final Cache cache;
private Duration clockSkew = Duration.ofSeconds(30);
private Clock clock = Clock.systemUTC();
/**
* Constructs a {@code DPoPProofReplayValidator} using the provided parameters.
* @param cache the {@link Cache} used to store {@link CacheValue} which contains
* information of the used DPoP Proof {@link Jwt}'s
*/
public DPoPProofReplayValidator(Cache cache) {
Assert.notNull(cache, "cache cannot be null");
this.cache = cache;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
String jti = jwt.getId();
if (!StringUtils.hasText(jti)) {
OAuth2Error error = createOAuth2Error("jti claim is required.");
return OAuth2TokenValidatorResult.failure(error);
}
Instant issuedAt = jwt.getIssuedAt();
if (issuedAt == null) {
OAuth2Error error = createOAuth2Error("iat claim is required.");
return OAuth2TokenValidatorResult.failure(error);
}
// Ensure acceptable time window
Instant now = Instant.now(this.clock);
Instant notBefore = now.minus(this.clockSkew);
Instant notAfter = now.plus(this.clockSkew);
if (issuedAt.isBefore(notBefore) || issuedAt.isAfter(notAfter)) {
OAuth2Error error = createOAuth2Error("iat claim is invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
String jwkThumbprint;
try {
@SuppressWarnings("unchecked")
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
JWK jwk = JWK.parse(jwkJson);
jwkThumbprint = jwk.computeThumbprint().toString();
}
catch (Exception ex) {
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
String jtiHash;
try {
jtiHash = computeSHA256(jti);
}
catch (Exception ex) {
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
Instant expiresAt = issuedAt.plus(this.clockSkew);
CacheValue cacheValue = new CacheValue(issuedAt, expiresAt, jwkThumbprint);
// Enforce single-use to protect against DPoP proof replay
if (this.cache.putIfAbsent(jtiHash, cacheValue) != null) {
// Already used or cache full or key limit reached
OAuth2Error error = createOAuth2Error("jti claim is invalid or unable to cache.");
return OAuth2TokenValidatorResult.failure(error);
}
return OAuth2TokenValidatorResult.success();
}
/**
* Sets the clock skew. The default is 30 seconds.
* @param clockSkew the clock skew
*/
public void setClockSkew(Duration clockSkew) {
Assert.notNull(clockSkew, "clockSkew cannot be null");
Assert.isTrue(clockSkew.getSeconds() >= 0, "clockSkew must be >= 0");
this.clockSkew = clockSkew;
}
/**
* Sets the {@link Clock} used in {@link Instant#now(Clock)}.
* @param clock the clock
*/
public void setClock(Clock clock) {
Assert.notNull(clock, "clock cannot be null");
this.clock = clock;
}
private static OAuth2Error createOAuth2Error(String reason) {
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
}
private static String computeSHA256(String value) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
/**
* An in-memory {@link Cache} implementation backed by a {@link ConcurrentHashMap}.
*
* <p>
* <b>NOTE:</b> This implementation has limitations as it only works in a single-node
* setup. For production (and clustered) environments, it is recommended to use a
* distributed {@link Cache} implementation (e.g. Redis, Hazelcast, etc.).
*
* <p>
* This implementation can be fine-tuned based on the following configuration
* settings:
* <ul>
* <li>{@link #setMaxSize(int)} - Sets the maximum number of entries the cache can
* hold. The default is 100,000.</li>
* <li>{@link #setMaxRequestsPerKey(int)} - Sets the maximum number of requests
* allowed per {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is
* 1000.</li>
* </ul>
*/
public static final class InMemoryCache implements Cache {
private static final String DEFAULT_NAME = InMemoryCache.class.getName().concat(".DPOP-PROOF-CACHE");
private static final int DEFAULT_MAX_SIZE = 100_000;
private static final int DEFAULT_MAX_REQUESTS_PER_KEY = 1000;
private static final int CLEANUP_INTERVAL_SECS = 10;
private final ConcurrentMap<String, CacheValue> cache = new ConcurrentHashMap<>();
private final ConcurrentMap<String, Integer> requestsPerKey = new ConcurrentHashMap<>();
private final AtomicBoolean cleaning = new AtomicBoolean(false);
private long lastCleanup = System.currentTimeMillis();
private int maxSize = DEFAULT_MAX_SIZE;
private int maxRequestsPerKey = DEFAULT_MAX_REQUESTS_PER_KEY;
/**
* Returns the maximum number of entries the cache can hold.
* @return the maximum number of entries the cache can hold
*/
public int getMaxSize() {
return this.maxSize;
}
/**
* Sets the maximum number of entries the cache can hold. The default is 100,000.
* @param maxSize the maximum number of entries the cache can hold
*/
public void setMaxSize(int maxSize) {
Assert.isTrue(maxSize > 0, "maxSize must be > 0");
this.maxSize = maxSize;
}
/**
* Returns the maximum number of requests allowed per
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}.
* @return the maximum number of requests allowed per
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}
*/
public int getMaxRequestsPerKey() {
return this.maxRequestsPerKey;
}
/**
* Sets the maximum number of requests allowed per
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is 1000.
* @param maxRequestsPerKey the maximum number of requests allowed per
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}
*/
public void setMaxRequestsPerKey(int maxRequestsPerKey) {
Assert.isTrue(maxRequestsPerKey > 0, "maxRequestsPerKey must be > 0");
this.maxRequestsPerKey = maxRequestsPerKey;
}
@Override
public String getName() {
return DEFAULT_NAME;
}
@Override
public Object getNativeCache() {
return this.cache;
}
@Override
public @Nullable ValueWrapper get(Object key) {
Object value = this.cache.get(key);
return (value != null) ? new SimpleValueWrapper(value) : null;
}
@SuppressWarnings("unchecked")
@Override
public <T> @Nullable T get(Object key, @Nullable Class<T> type) {
Object value = this.cache.get(key);
if (value != null && type != null && !type.isInstance(value)) {
throw new IllegalStateException(
"Cached value is not of required type [" + type.getName() + "]: " + value);
}
return (T) value;
}
@Override
public <T> @Nullable T get(Object key, Callable<T> valueLoader) {
throw new UnsupportedOperationException();
}
@Override
public void put(Object key, Object value) {
putIfAbsent(key, value);
}
@Override
public @Nullable ValueWrapper putIfAbsent(Object key, Object value) {
String jti = (String) key;
CacheValue cacheValue = (CacheValue) value;
cleanupIfNecessary();
if (this.cache.size() >= this.maxSize) {
// Force an immediate cleanup when we hit the limit before the cleanup
// interval
cleanup();
if (this.cache.size() >= this.maxSize) {
// Cache full - return non-null value
return new SimpleValueWrapper(cacheValue);
}
}
// Limit the number of requests per key
AtomicBoolean limitExceeded = new AtomicBoolean(false);
this.requestsPerKey.compute(cacheValue.jwkThumbprint, (k, v) -> {
if (v != null && v >= this.maxRequestsPerKey) {
limitExceeded.set(true);
return v;
}
// Increment
return (v != null) ? v + 1 : 1;
});
if (limitExceeded.get()) {
// Key limit reached - return non-null value
return new SimpleValueWrapper(cacheValue);
}
if (this.cache.putIfAbsent(jti, cacheValue) != null) {
// jti exists - revert the increment and return non-null value
this.requestsPerKey.computeIfPresent(cacheValue.jwkThumbprint, (k, v) -> (v > 1) ? v - 1 : null);
return new SimpleValueWrapper(cacheValue);
}
return null;
}
@Override
public void evict(Object key) {
}
@Override
public void clear() {
}
private void cleanupIfNecessary() {
long now = System.currentTimeMillis();
long last = this.lastCleanup;
if ((now - last) > (CLEANUP_INTERVAL_SECS * 1000)) {
cleanup();
}
}
private void cleanup() {
if (this.cleaning.compareAndSet(false, true)) {
try {
Instant now = Instant.now();
for (Map.Entry<String, CacheValue> entry : this.cache.entrySet()) {
if (now.isAfter(entry.getValue().expiresAt)) {
this.cache.remove(entry.getKey());
this.requestsPerKey.computeIfPresent(entry.getValue().jwkThumbprint,
(k, v) -> (v > 1) ? v - 1 : null);
}
}
this.lastCleanup = System.currentTimeMillis();
}
finally {
this.cleaning.set(false);
}
}
}
}
/**
* A representation of the value to which the {@link Cache} maps a (hashed)
* {@code (jti)} claim as the key.
*/
public static final class CacheValue {
private final Instant issuedAt;
private final Instant expiresAt;
private final String jwkThumbprint;
/**
* Constructs a {@code CacheValue} using the provided parameters.
* @param issuedAt the issued at claim which identifies the time at which the DPoP
* Proof {@link Jwt} was issued
* @param expiresAt the expiration time when this {@code CacheValue} will be
* evicted from the cache
* @param jwkThumbprint the SHA-256 thumbprint of the public key of the JSON Web
* Key (JWK) corresponding to the key used to digitally sign the DPoP Proof
* {@link Jwt}
*/
public CacheValue(Instant issuedAt, Instant expiresAt, String jwkThumbprint) {
Assert.notNull(issuedAt, "issuedAt cannot be null");
Assert.notNull(expiresAt, "expiresAt cannot be null");
Assert.hasText(jwkThumbprint, "jwkThumbprint cannot be empty");
this.issuedAt = issuedAt;
this.expiresAt = expiresAt;
this.jwkThumbprint = jwkThumbprint;
}
/**
* Returns the issued at {@code (iat)} claim which identifies the time at which
* the DPoP Proof {@link Jwt} was issued.
* @return the issued at claim which identifies the time at which the DPoP Proof
* {@link Jwt} was issued
*/
public Instant getIssuedAt() {
return this.issuedAt;
}
/**
* Returns the expiration time when this {@code CacheValue} will be evicted from
* the cache.
* @return the expiration time when this {@code CacheValue} will be evicted from
* the cache
*/
public Instant getExpiresAt() {
return this.expiresAt;
}
/**
* Returns the SHA-256 thumbprint of the public key of the JSON Web Key (JWK)
* corresponding to the key used to digitally sign the DPoP Proof {@link Jwt}.
* @return the SHA-256 thumbprint of the public key of the JSON Web Key (JWK)
*/
public String getJwkThumbprint() {
return this.jwkThumbprint;
}
@Override
public boolean equals(Object obj) {
if (obj == this) {
return true;
}
if (obj == null || obj.getClass() != this.getClass()) {
return false;
}
CacheValue that = (CacheValue) obj;
return Objects.equals(this.issuedAt, that.issuedAt) && Objects.equals(this.expiresAt, that.expiresAt)
&& Objects.equals(this.jwkThumbprint, that.jwkThumbprint);
}
@Override
public int hashCode() {
return Objects.hash(this.issuedAt, this.expiresAt, this.jwkThumbprint);
}
}
}
@@ -66,11 +66,9 @@ final class JwtDecoderProviderConfigurationUtils {
private static final RestTemplate rest = new RestTemplate();
static {
int connectTimeout = Integer.parseInt(System.getProperty("sun.net.client.defaultConnectTimeout", "30000"));
int readTimeout = Integer.parseInt(System.getProperty("sun.net.client.defaultReadTimeout", "30000"));
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(connectTimeout);
requestFactory.setReadTimeout(readTimeout);
requestFactory.setConnectTimeout(getConnectTimeout());
requestFactory.setReadTimeout(getReadTimeout());
rest.setRequestFactory(requestFactory);
}
@@ -80,6 +78,27 @@ final class JwtDecoderProviderConfigurationUtils {
private JwtDecoderProviderConfigurationUtils() {
}
/**
* Returns the default HTTP connect timeout, in milliseconds, for fetching
* provider/JWK Set metadata. Honors the JDK's
* {@code sun.net.client.defaultConnectTimeout} system property when set, otherwise
* defaults to 30 seconds.
* @return the default HTTP connect timeout, in milliseconds
*/
static int getConnectTimeout() {
return Integer.parseInt(System.getProperty("sun.net.client.defaultConnectTimeout", "30000"));
}
/**
* Returns the default HTTP read timeout, in milliseconds, for fetching provider/JWK
* Set metadata. Honors the JDK's {@code sun.net.client.defaultReadTimeout} system
* property when set, otherwise defaults to 30 seconds.
* @return the default HTTP read timeout, in milliseconds
*/
static int getReadTimeout() {
return Integer.parseInt(System.getProperty("sun.net.client.defaultReadTimeout", "30000"));
}
static Map<String, Object> getConfigurationForOidcIssuerLocation(String oidcIssuerLocation) {
return getConfiguration(oidcIssuerLocation, rest, oidc(oidcIssuerLocation));
}
@@ -296,7 +296,7 @@ public final class NimbusJwtDecoder implements JwtDecoder {
private final Set<SignatureAlgorithm> signatureAlgorithms = new HashSet<>();
private RestOperations restOperations = new RestTemplateWithNimbusDefaultTimeouts();
private RestOperations restOperations = new RestTemplateWithDefaultTimeouts();
private Cache cache = new NoOpCache("default");
@@ -568,12 +568,12 @@ public final class NimbusJwtDecoder implements JwtDecoder {
* A RestTemplate with timeouts configured to avoid blocking indefinitely when
* fetching JWK Sets while holding the reentrantLock.
*/
private static final class RestTemplateWithNimbusDefaultTimeouts extends RestTemplate {
private static final class RestTemplateWithDefaultTimeouts extends RestTemplate {
private RestTemplateWithNimbusDefaultTimeouts() {
private RestTemplateWithDefaultTimeouts() {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(JWKSourceBuilder.DEFAULT_HTTP_CONNECT_TIMEOUT);
requestFactory.setReadTimeout(JWKSourceBuilder.DEFAULT_HTTP_READ_TIMEOUT);
requestFactory.setConnectTimeout(JwtDecoderProviderConfigurationUtils.getConnectTimeout());
requestFactory.setReadTimeout(JwtDecoderProviderConfigurationUtils.getReadTimeout());
setRequestFactory(requestFactory);
}
@@ -30,6 +30,7 @@ import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.Base64URL;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jose.TestKeys;
@@ -46,6 +47,40 @@ import static org.mockito.BDDMockito.mock;
public class JwtDecoderProviderConfigurationUtilsTests {
@AfterEach
public void cleanup() {
System.clearProperty("sun.net.client.defaultConnectTimeout");
System.clearProperty("sun.net.client.defaultReadTimeout");
}
// gh-19474
@Test
public void getConnectTimeoutWhenPropertyNotSetThenDefaultsToThirtySeconds() {
System.clearProperty("sun.net.client.defaultConnectTimeout");
assertThat(JwtDecoderProviderConfigurationUtils.getConnectTimeout()).isEqualTo(30000);
}
// gh-19474
@Test
public void getConnectTimeoutWhenPropertySetThenUses() {
System.setProperty("sun.net.client.defaultConnectTimeout", "5000");
assertThat(JwtDecoderProviderConfigurationUtils.getConnectTimeout()).isEqualTo(5000);
}
// gh-19474
@Test
public void getReadTimeoutWhenPropertyNotSetThenDefaultsToThirtySeconds() {
System.clearProperty("sun.net.client.defaultReadTimeout");
assertThat(JwtDecoderProviderConfigurationUtils.getReadTimeout()).isEqualTo(30000);
}
// gh-19474
@Test
public void getReadTimeoutWhenPropertySetThenUses() {
System.setProperty("sun.net.client.defaultReadTimeout", "5000");
assertThat(JwtDecoderProviderConfigurationUtils.getReadTimeout()).isEqualTo(5000);
}
@Test
public void getSignatureAlgorithmsWhenJwkSetSpecifiesAlgorithmThenUses() throws Exception {
JWKSource<SecurityContext> jwkSource = mock(JWKSource.class);
@@ -67,14 +67,17 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jose.TestKeys;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -391,6 +394,24 @@ public class NimbusJwtDecoderTests {
// @formatter:on
}
// gh-19474
@Test
public void withJwkSetUriWhenDefaultRestOperationsThenUsesConfiguredTimeouts() {
try {
System.setProperty("sun.net.client.defaultConnectTimeout", "12345");
System.setProperty("sun.net.client.defaultReadTimeout", "23456");
NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder builder = NimbusJwtDecoder.withJwkSetUri(JWK_SET_URI);
RestOperations restOperations = (RestOperations) ReflectionTestUtils.getField(builder, "restOperations");
ClientHttpRequestFactory requestFactory = ((RestTemplate) restOperations).getRequestFactory();
assertThat(ReflectionTestUtils.getField(requestFactory, "connectTimeout")).isEqualTo(12345);
assertThat(ReflectionTestUtils.getField(requestFactory, "readTimeout")).isEqualTo(23456);
}
finally {
System.clearProperty("sun.net.client.defaultConnectTimeout");
System.clearProperty("sun.net.client.defaultReadTimeout");
}
}
@Test
public void cacheWhenNullThenThrowsException() {
NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder builder = NimbusJwtDecoder.withJwkSetUri(JWK_SET_URI);
@@ -16,36 +16,24 @@
package org.springframework.security.oauth2.server.resource.authentication;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.function.Function;
import com.nimbusds.jose.jwk.JWK;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.core.ClaimAccessor;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.OAuth2Token;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.DPoPProofContext;
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
/**
* An {@link AuthenticationProvider} implementation that is responsible for authenticating
@@ -72,16 +60,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
public DPoPAuthenticationProvider(AuthenticationManager tokenAuthenticationManager) {
Assert.notNull(tokenAuthenticationManager, "tokenAuthenticationManager cannot be null");
this.tokenAuthenticationManager = tokenAuthenticationManager;
Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = (
context) -> new DelegatingOAuth2TokenValidator<>(
// Use default validators
DPoPProofJwtDecoderFactory.DEFAULT_JWT_VALIDATOR_FACTORY.apply(context),
// Add custom validators
new AthClaimValidator(context.getAccessToken()),
new JwkThumbprintValidator(context.getAccessToken()));
DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory();
dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory);
this.dPoPProofVerifierFactory = dPoPProofJwtDecoderFactory;
this.dPoPProofVerifierFactory = new DPoPProofJwtDecoderFactory();
}
@Override
@@ -116,7 +95,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
try {
dPoPProofVerifier.decode(dPoPProofContext.getDPoPProof());
}
catch (JwtException ex) {
catch (Exception ex) {
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF);
throw new OAuth2AuthenticationException(error, ex);
}
@@ -141,108 +120,6 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
this.dPoPProofVerifierFactory = dPoPProofVerifierFactory;
}
private static final class AthClaimValidator implements OAuth2TokenValidator<Jwt> {
private final OAuth2AccessTokenClaims accessToken;
private AthClaimValidator(OAuth2AccessTokenClaims accessToken) {
Assert.notNull(accessToken, "accessToken cannot be null");
this.accessToken = accessToken;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
String accessTokenHashClaim = jwt.getClaimAsString("ath");
if (!StringUtils.hasText(accessTokenHashClaim)) {
OAuth2Error error = createOAuth2Error("ath claim is required.");
return OAuth2TokenValidatorResult.failure(error);
}
String accessTokenHash;
try {
accessTokenHash = computeSHA256(this.accessToken.getTokenValue());
}
catch (Exception ex) {
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token.");
return OAuth2TokenValidatorResult.failure(error);
}
if (!accessTokenHashClaim.equals(accessTokenHash)) {
OAuth2Error error = createOAuth2Error("ath claim is invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
return OAuth2TokenValidatorResult.success();
}
private static OAuth2Error createOAuth2Error(String reason) {
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
}
private static String computeSHA256(String value) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
}
private static final class JwkThumbprintValidator implements OAuth2TokenValidator<Jwt> {
private final OAuth2AccessTokenClaims accessToken;
private JwkThumbprintValidator(OAuth2AccessTokenClaims accessToken) {
Assert.notNull(accessToken, "accessToken cannot be null");
this.accessToken = accessToken;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
String jwkThumbprintClaim = null;
Map<String, Object> confirmationMethodClaim = this.accessToken.getClaimAsMap("cnf");
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) {
jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt");
}
if (jwkThumbprintClaim == null) {
OAuth2Error error = createOAuth2Error("jkt claim is required.");
return OAuth2TokenValidatorResult.failure(error);
}
JWK jwk = null;
@SuppressWarnings("unchecked")
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
try {
jwk = JWK.parse(jwkJson);
}
catch (Exception ignored) {
}
if (jwk == null) {
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
String jwkThumbprint;
try {
jwkThumbprint = jwk.computeThumbprint().toString();
}
catch (Exception ex) {
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk.");
return OAuth2TokenValidatorResult.failure(error);
}
if (!jwkThumbprintClaim.equals(jwkThumbprint)) {
OAuth2Error error = createOAuth2Error("jkt claim is invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
return OAuth2TokenValidatorResult.success();
}
private static OAuth2Error createOAuth2Error(String reason) {
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
}
}
private static final class OAuth2AccessTokenClaims implements OAuth2Token, ClaimAccessor {
private final OAuth2Token accessToken;
@@ -48,7 +48,6 @@ public class DefaultHttpSecurityExpressionHandler extends AbstractSecurityExpres
private String defaultRolePrefix = DEFAULT_ROLE_PREFIX;
@Override
@SuppressWarnings("NullAway") // https://github.com/spring-projects/spring-framework/issues/35371
public EvaluationContext createEvaluationContext(Supplier<? extends @Nullable Authentication> authentication,
RequestAuthorizationContext context) {
WebSecurityExpressionRoot root = createSecurityExpressionRoot(authentication, context);
@@ -51,6 +51,7 @@ import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.core.userdetails.cache.NullUserCache;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository;
@@ -167,7 +168,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
}
serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod());
// If digest is incorrect, try refreshing from backend and recomputing
if (!serverDigestMd5.equals(digestAuth.getResponse()) && cacheWasUsed) {
if (!Utf8.isEqual(serverDigestMd5, digestAuth.getResponse()) && cacheWasUsed) {
logger.debug("Digest comparison failure; trying to refresh user from DAO in case password had changed");
user = this.userDetailsService.loadUserByUsername(username);
this.userCache.putUserInCache(user);
@@ -181,7 +182,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
return;
}
// If digest is still incorrect, definitely reject authentication attempt
if (!serverDigestMd5.equals(digestAuth.getResponse())) {
if (!Utf8.isEqual(serverDigestMd5, digestAuth.getResponse())) {
logger.debug(LogMessage.format(
"Expected response: '%s' but received: '%s'; is AuthenticationDao returning clear text passwords?",
serverDigestMd5, digestAuth.getResponse()));
@@ -405,7 +406,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
}
// Check signature of nonce matches this expiry time
String expectedNonceSignature = DigestAuthUtils.md5Hex(this.nonceExpiryTime + ":" + entryPointKey);
if (!expectedNonceSignature.equals(nonceTokens[1])) {
if (!Utf8.isEqual(expectedNonceSignature, nonceTokens[1])) {
throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
"DigestAuthenticationFilter.nonceCompromised", new Object[] { nonceAsPlainText },
"Nonce token compromised {0}"));
@@ -126,10 +126,7 @@ public final class AuthenticationPrincipalArgumentResolver implements HandlerMet
StandardEvaluationContext context = new StandardEvaluationContext();
context.setRootObject(principal);
context.setVariable("this", principal);
// https://github.com/spring-projects/spring-framework/issues/35371
if (this.beanResolver != null) {
context.setBeanResolver(this.beanResolver);
}
context.setBeanResolver(this.beanResolver);
Expression expression = this.parser.parseExpression(expressionToParse);
principal = expression.getValue(context);
}
@@ -160,10 +160,7 @@ public final class CurrentSecurityContextArgumentResolver implements HandlerMeth
StandardEvaluationContext context = new StandardEvaluationContext();
context.setRootObject(securityContext);
context.setVariable("this", securityContext);
// https://github.com/spring-projects/spring-framework/issues/35371
if (this.beanResolver != null) {
context.setBeanResolver(this.beanResolver);
}
context.setBeanResolver(this.beanResolver);
Expression expression = this.parser.parseExpression(expressionToParse);
securityContextResult = expression.getValue(context);
}
@@ -95,7 +95,6 @@ public class AuthenticationPrincipalArgumentResolver extends HandlerMethodArgume
});
}
@SuppressWarnings("NullAway") // https://github.com/spring-projects/spring-framework/issues/35371
private @Nullable Object resolvePrincipal(MethodParameter parameter, @Nullable Object principal) {
AuthenticationPrincipal annotation = findMethodAnnotation(parameter);
if (annotation == null) {
@@ -141,7 +141,6 @@ public class CurrentSecurityContextArgumentResolver extends HandlerMethodArgumen
return securityContext;
}
@SuppressWarnings("NullAway") // https://github.com/spring-projects/spring-framework/issues/35371
private @Nullable Object resolveSecurityContextFromAnnotation(CurrentSecurityContext annotation,
MethodParameter parameter, Object securityContext) {
Object securityContextResult = securityContext;
@@ -16,6 +16,7 @@
package org.springframework.security.web.webauthn.api;
import java.io.ObjectStreamException;
import java.io.Serial;
import java.io.Serializable;
@@ -121,4 +122,25 @@ public final class AuthenticatorTransport implements Serializable {
return new AuthenticatorTransport[] { USB, NFC, BLE, HYBRID, INTERNAL };
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof AuthenticatorTransport other)) {
return false;
}
return this.value.equals(other.value);
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Serial
private Object readResolve() throws ObjectStreamException {
return valueOf(this.value);
}
}
@@ -16,6 +16,7 @@
package org.springframework.security.web.webauthn.api;
import java.io.ObjectStreamException;
import java.io.Serial;
import java.io.Serializable;
@@ -58,4 +59,25 @@ public final class PublicKeyCredentialType implements Serializable {
return new PublicKeyCredentialType(value);
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof PublicKeyCredentialType other)) {
return false;
}
return this.value.equals(other.value);
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Serial
private Object readResolve() throws ObjectStreamException {
return valueOf(this.value);
}
}
@@ -16,9 +16,12 @@
package org.springframework.security.web.webauthn.api;
import java.io.ObjectStreamException;
import java.io.Serial;
import java.io.Serializable;
import org.jspecify.annotations.Nullable;
/**
* <a href=
* "https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement">UserVerificationRequirement</a>
@@ -72,4 +75,34 @@ public final class UserVerificationRequirement implements Serializable {
return this.value;
}
@Override
public boolean equals(@Nullable Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof UserVerificationRequirement other)) {
return false;
}
return this.value.equals(other.value);
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Serial
private Object readResolve() throws ObjectStreamException {
switch (this.value) {
case "required":
return REQUIRED;
case "preferred":
return PREFERRED;
case "discouraged":
return DISCOURAGED;
default:
return this;
}
}
}
@@ -258,8 +258,8 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
byte[] clientDataJSON = response.getClientDataJSON().getBytes();
Challenge challenge = new DefaultChallenge(base64Challenge);
ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge);
boolean userVerificationRequired = creationOptions.getAuthenticatorSelection()
.getUserVerification() == UserVerificationRequirement.REQUIRED;
boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
.equals(creationOptions.getAuthenticatorSelection().getUserVerification());
// requireUserPresence The constant Boolean value true
// https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred
boolean userPresenceRequired = true;
@@ -318,7 +318,7 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
private com.webauthn4j.data.PublicKeyCredentialParameters convertParamToWebauthn4j(
PublicKeyCredentialParameters parameter) {
if (parameter.getType() != PublicKeyCredentialType.PUBLIC_KEY) {
if (!PublicKeyCredentialType.PUBLIC_KEY.equals(parameter.getType())) {
throw new IllegalArgumentException(
"Cannot convert unknown credential type " + parameter.getType() + " to webauthn4j");
}
@@ -395,8 +395,8 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
String rpId = requestOptions.getRpId();
Assert.notNull(rpId, "rpId cannot be null");
ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge);
boolean userVerificationRequired = request.getRequestOptions()
.getUserVerification() == UserVerificationRequirement.REQUIRED;
boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
.equals(request.getRequestOptions().getUserVerification());
com.webauthn4j.data.AuthenticationRequest authenticationRequest = new com.webauthn4j.data.AuthenticationRequest(
request.getPublicKey().getRawId().getBytes(), assertionResponse.getAuthenticatorData().getBytes(),
@@ -0,0 +1,42 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that {@link AuthenticatorAttachment} correctly restores singleton identity
* after Java deserialization. This class already implements {@code readResolve()} and
* serves as the reference for the pattern required by the other pseudo-enum types.
*/
class AuthenticatorAttachmentTests {
@Test
void platformWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorAttachment.PLATFORM))
.isSameAs(AuthenticatorAttachment.PLATFORM);
}
@Test
void crossPlatformWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorAttachment.CROSS_PLATFORM))
.isSameAs(AuthenticatorAttachment.CROSS_PLATFORM);
}
}
@@ -0,0 +1,76 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class AuthenticatorTransportTests {
@Test
void usbWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.USB))
.isSameAs(AuthenticatorTransport.USB);
}
@Test
void nfcWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.NFC))
.isSameAs(AuthenticatorTransport.NFC);
}
@Test
void bleWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.BLE))
.isSameAs(AuthenticatorTransport.BLE);
}
@Test
void smartCardWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.SMART_CARD))
.isSameAs(AuthenticatorTransport.SMART_CARD);
}
@Test
void hybridWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.HYBRID))
.isSameAs(AuthenticatorTransport.HYBRID);
}
@Test
void internalWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.INTERNAL))
.isSameAs(AuthenticatorTransport.INTERNAL);
}
@Test
void usbWhenSameValueThenEquals() {
assertThat(new AuthenticatorTransport("usb")).isEqualTo(AuthenticatorTransport.USB);
}
@Test
void usbWhenSameValueThenHashCodeMatches() {
assertThat(new AuthenticatorTransport("usb")).hasSameHashCodeAs(AuthenticatorTransport.USB);
}
@Test
void usbWhenDifferentValueThenNotEquals() {
assertThat(AuthenticatorTransport.USB).isNotEqualTo(AuthenticatorTransport.NFC);
}
}
@@ -0,0 +1,49 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.webauthn.api;
import java.util.Set;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicKeyCredentialDescriptorTests {
@Test
void typeWhenDeserializedThenSameAsConstant() {
PublicKeyCredentialDescriptor descriptor = PublicKeyCredentialDescriptor.builder()
.id(TestBytes.get())
.type(PublicKeyCredentialType.PUBLIC_KEY)
.build();
PublicKeyCredentialDescriptor deserialized = SerializationTestUtils.serializeAndDeserialize(descriptor);
assertThat(deserialized.getType()).isSameAs(PublicKeyCredentialType.PUBLIC_KEY);
}
@Test
void transportsWhenDeserializedThenSameAsConstants() {
PublicKeyCredentialDescriptor descriptor = PublicKeyCredentialDescriptor.builder()
.id(TestBytes.get())
.type(PublicKeyCredentialType.PUBLIC_KEY)
.transports(Set.of(AuthenticatorTransport.USB, AuthenticatorTransport.HYBRID))
.build();
PublicKeyCredentialDescriptor deserialized = SerializationTestUtils.serializeAndDeserialize(descriptor);
assertThat(deserialized.getTransports()).containsExactlyInAnyOrder(AuthenticatorTransport.USB,
AuthenticatorTransport.HYBRID);
}
}
@@ -0,0 +1,43 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicKeyCredentialRequestOptionsTests {
@Test
void userVerificationRequiredWhenDeserializedThenSameAsConstant() {
PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create()
.userVerification(UserVerificationRequirement.REQUIRED)
.build();
PublicKeyCredentialRequestOptions deserialized = SerializationTestUtils.serializeAndDeserialize(options);
assertThat(deserialized.getUserVerification()).isSameAs(UserVerificationRequirement.REQUIRED);
}
@Test
void userVerificationPreferredWhenDeserializedThenSameAsConstant() {
PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create()
.userVerification(UserVerificationRequirement.PREFERRED)
.build();
PublicKeyCredentialRequestOptions deserialized = SerializationTestUtils.serializeAndDeserialize(options);
assertThat(deserialized.getUserVerification()).isSameAs(UserVerificationRequirement.PREFERRED);
}
}
@@ -0,0 +1,51 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicKeyCredentialTypeTests {
@Test
void publicKeyWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(PublicKeyCredentialType.PUBLIC_KEY))
.isSameAs(PublicKeyCredentialType.PUBLIC_KEY);
}
@Test
void publicKeyWhenSameValueThenEquals() {
PublicKeyCredentialType first = PublicKeyCredentialType.valueOf("custom");
PublicKeyCredentialType second = PublicKeyCredentialType.valueOf("custom");
assertThat(first).isEqualTo(second);
}
@Test
void publicKeyWhenSameValueThenHashCodeMatches() {
PublicKeyCredentialType first = PublicKeyCredentialType.valueOf("custom");
PublicKeyCredentialType second = PublicKeyCredentialType.valueOf("custom");
assertThat(first).hasSameHashCodeAs(second);
}
@Test
void publicKeyWhenDifferentValueThenNotEquals() {
assertThat(PublicKeyCredentialType.valueOf("custom-1"))
.isNotEqualTo(PublicKeyCredentialType.valueOf("custom-2"));
}
}
@@ -0,0 +1,47 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.webauthn.api;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
public final class SerializationTestUtils {
private SerializationTestUtils() {
}
@SuppressWarnings("unchecked")
public static <T extends Serializable> T serializeAndDeserialize(T object) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(object);
}
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
try (ObjectInputStream ois = new ObjectInputStream(bais)) {
return (T) ois.readObject();
}
}
catch (Exception ex) {
throw new RuntimeException("Serialization round-trip failed", ex);
}
}
}
@@ -0,0 +1,58 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class UserVerificationRequirementTests {
@Test
void requiredWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.REQUIRED))
.isSameAs(UserVerificationRequirement.REQUIRED);
}
@Test
void preferredWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.PREFERRED))
.isSameAs(UserVerificationRequirement.PREFERRED);
}
@Test
void discouragedWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.DISCOURAGED))
.isSameAs(UserVerificationRequirement.DISCOURAGED);
}
@Test
void requiredWhenSameValueThenEquals() {
assertThat(new UserVerificationRequirement("required")).isEqualTo(UserVerificationRequirement.REQUIRED);
}
@Test
void requiredWhenSameValueThenHashCodeMatches() {
assertThat(new UserVerificationRequirement("required")).hasSameHashCodeAs(UserVerificationRequirement.REQUIRED);
}
@Test
void requiredWhenDifferentValueThenNotEquals() {
assertThat(UserVerificationRequirement.REQUIRED).isNotEqualTo(UserVerificationRequirement.PREFERRED);
}
}