diff --git a/.github/dco.yml b/.github/dco.yml new file mode 100644 index 00000000000..0c4b142e9a7 --- /dev/null +++ b/.github/dco.yml @@ -0,0 +1,2 @@ +require: + members: false diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index f36d30d555b..b7baf20f2b5 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -79,7 +79,7 @@ jobs: env: STRUCTURE101_LICENSEID: ${{ secrets.STRUCTURE101_LICENSEID }} run: | - ./gradlew check s101 -Ps101.licenseId="$STRUCTURE101_LICENSEID" --stacktrace + ./gradlew assemble && ./gradlew s101 -Ps101.licenseId="$STRUCTURE101_LICENSEID" --stacktrace deploy-artifacts: name: Deploy Artifacts needs: [ build, test, check-samples, check-tangles ] @@ -116,7 +116,7 @@ jobs: send-notification: name: Send Notification needs: [ perform-release ] - if: ${{ failure() || cancelled() }} + if: ${{ !success() }} runs-on: ubuntu-latest steps: - name: Send Notification diff --git a/.github/workflows/release-scheduler.yml b/.github/workflows/release-scheduler.yml index b50ed7289a7..8b2f0f1eac1 100644 --- a/.github/workflows/release-scheduler.yml +++ b/.github/workflows/release-scheduler.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: # List of active maintenance branches. - branch: [ main, 6.4.x, 6.3.x, 6.2.x, 5.8.x ] + branch: [ main, 6.4.x, 6.3.x ] runs-on: ubuntu-latest steps: - name: Checkout diff --git a/CONTRIBUTING.adoc b/CONTRIBUTING.adoc index e254c3e0567..8ae6a0588d6 100644 --- a/CONTRIBUTING.adoc +++ b/CONTRIBUTING.adoc @@ -90,8 +90,8 @@ Please do your best to follow these steps. Don't worry if you don't get them all correct the first time, we will help you. [[sign-cla]] -1. If you have not previously done so, please sign the https://cla.spring.io/sign/spring[Contributor License Agreement]. -You will be reminded automatically when you submit the PR. +1. All commits must include a __Signed-off-by__ trailer at the end of each commit message to indicate that the contributor agrees to the Developer Certificate of Origin. +For additional details, please refer to the blog post https://spring.io/blog/2025/01/06/hello-dco-goodbye-cla-simplifying-contributions-to-spring[Hello DCO, Goodbye CLA: Simplifying Contributions to Spring]. [[create-an-issue]] 1. Must you https://github.com/spring-projects/spring-security/issues/new/choose[create an issue] first? No, but it is recommended for features and larger bug fixes. It's easier discuss with the team first to determine the right fix or enhancement. For typos and straightforward bug fixes, starting with a pull request is encouraged. diff --git a/build.gradle b/build.gradle index 60089e6734f..4fd368b54a9 100644 --- a/build.gradle +++ b/build.gradle @@ -110,6 +110,10 @@ nohttp { source.builtBy(project(':spring-security-config').tasks.withType(RncToXsd)) } +tasks.named('checkstyleNohttp') { + maxHeapSize = '1g' +} + tasks.register('cloneRepository', IncludeRepoTask) { repository = project.getProperties().get("repositoryName") ref = project.getProperties().get("ref") diff --git a/buildSrc/src/main/java/s101/S101Plugin.java b/buildSrc/src/main/java/s101/S101Plugin.java index 6d2e01abc0e..628b4ad52ab 100644 --- a/buildSrc/src/main/java/s101/S101Plugin.java +++ b/buildSrc/src/main/java/s101/S101Plugin.java @@ -50,7 +50,7 @@ private void configure(S101Configure configure) { private void configure(JavaExec exec) { exec.setDescription("Runs Structure101 headless analysis, installing and configuring if necessary"); - exec.dependsOn("check"); + exec.dependsOn("assemble"); Project project = exec.getProject(); S101PluginExtension extension = project.getExtensions().getByType(S101PluginExtension.class); exec diff --git a/cas/src/main/java/org/springframework/security/cas/jackson2/CasJackson2Module.java b/cas/src/main/java/org/springframework/security/cas/jackson2/CasJackson2Module.java index b6c7c6f8fad..fad74fdb7b6 100644 --- a/cas/src/main/java/org/springframework/security/cas/jackson2/CasJackson2Module.java +++ b/cas/src/main/java/org/springframework/security/cas/jackson2/CasJackson2Module.java @@ -41,6 +41,7 @@ * @since 4.2 * @see org.springframework.security.jackson2.SecurityJackson2Modules */ +@SuppressWarnings("serial") public class CasJackson2Module extends SimpleModule { public CasJackson2Module() { diff --git a/config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java b/config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java index 075856f3a4a..cc5d7a3501f 100644 --- a/config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java +++ b/config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java @@ -33,6 +33,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.openqa.selenium.By; +import org.openqa.selenium.WebDriverException; import org.openqa.selenium.WebElement; import org.openqa.selenium.chrome.ChromeDriverService; import org.openqa.selenium.chrome.ChromeOptions; @@ -273,12 +274,14 @@ private AbstractStringAssert assertHasAlertStartingWith(String alertType, Str /** * Await until the assertion passes. If the assertion fails, it will display the - * assertion error in stdout. + * assertion error in stdout. WebDriver-related exceptions are ignored, so that + * {@code assertion}s can interact with the page and be retried on error, e.g. + * {@code assertThat(this.driver.findElement(By.Id("some-id")).isNotNull()}. */ private void await(Supplier> assertion) { new FluentWait<>(this.driver).withTimeout(Duration.ofSeconds(2)) .pollingEvery(Duration.ofMillis(100)) - .ignoring(AssertionError.class) + .ignoring(AssertionError.class, WebDriverException.class) .until((d) -> { assertion.get(); return true; diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java b/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java index d94e9d9083e..4f849f86fb1 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java @@ -40,6 +40,7 @@ import org.springframework.http.HttpMethod; import org.springframework.lang.Nullable; import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.config.annotation.web.ServletRegistrationsSupport.RegistrationMapping; import org.springframework.security.config.annotation.web.configurers.AbstractConfigAttributeRequestMatcherRegistry; import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @@ -235,103 +236,31 @@ private boolean anyPathsDontStartWithLeadingSlash(String... patterns) { } private RequestMatcher resolve(AntPathRequestMatcher ant, MvcRequestMatcher mvc, ServletContext servletContext) { - Map registrations = mappableServletRegistrations(servletContext); - if (registrations.isEmpty()) { + ServletRegistrationsSupport registrations = new ServletRegistrationsSupport(servletContext); + Collection mappings = registrations.mappings(); + if (mappings.isEmpty()) { return new DispatcherServletDelegatingRequestMatcher(ant, mvc, new MockMvcRequestMatcher()); } - if (!hasDispatcherServlet(registrations)) { + Collection dispatcherServletMappings = registrations.dispatcherServletMappings(); + if (dispatcherServletMappings.isEmpty()) { return new DispatcherServletDelegatingRequestMatcher(ant, mvc, new MockMvcRequestMatcher()); } - ServletRegistration dispatcherServlet = requireOneRootDispatcherServlet(registrations); - if (dispatcherServlet != null) { - if (registrations.size() == 1) { - return mvc; - } - return new DispatcherServletDelegatingRequestMatcher(ant, mvc, servletContext); - } - dispatcherServlet = requireOnlyPathMappedDispatcherServlet(registrations); - if (dispatcherServlet != null) { - String mapping = dispatcherServlet.getMappings().iterator().next(); - mvc.setServletPath(mapping.substring(0, mapping.length() - 2)); - return mvc; + if (dispatcherServletMappings.size() > 1) { + String errorMessage = computeErrorMessage(servletContext.getServletRegistrations().values()); + throw new IllegalArgumentException(errorMessage); } - String errorMessage = computeErrorMessage(registrations.values()); - throw new IllegalArgumentException(errorMessage); - } - - private Map mappableServletRegistrations(ServletContext servletContext) { - Map mappable = new LinkedHashMap<>(); - for (Map.Entry entry : servletContext.getServletRegistrations() - .entrySet()) { - if (!entry.getValue().getMappings().isEmpty()) { - mappable.put(entry.getKey(), entry.getValue()); - } - } - return mappable; - } - - private boolean hasDispatcherServlet(Map registrations) { - if (registrations == null) { - return false; + RegistrationMapping dispatcherServlet = dispatcherServletMappings.iterator().next(); + if (mappings.size() > 1 && !dispatcherServlet.isDefault()) { + String errorMessage = computeErrorMessage(servletContext.getServletRegistrations().values()); + throw new IllegalArgumentException(errorMessage); } - for (ServletRegistration registration : registrations.values()) { - if (isDispatcherServlet(registration)) { - return true; - } - } - return false; - } - - private ServletRegistration requireOneRootDispatcherServlet( - Map registrations) { - ServletRegistration rootDispatcherServlet = null; - for (ServletRegistration registration : registrations.values()) { - if (!isDispatcherServlet(registration)) { - continue; - } - if (registration.getMappings().size() > 1) { - return null; - } - if (!"/".equals(registration.getMappings().iterator().next())) { - return null; - } - rootDispatcherServlet = registration; - } - return rootDispatcherServlet; - } - - private ServletRegistration requireOnlyPathMappedDispatcherServlet( - Map registrations) { - ServletRegistration pathDispatcherServlet = null; - for (ServletRegistration registration : registrations.values()) { - if (!isDispatcherServlet(registration)) { - return null; - } - if (registration.getMappings().size() > 1) { - return null; - } - String mapping = registration.getMappings().iterator().next(); - if (!mapping.startsWith("/") || !mapping.endsWith("/*")) { - return null; - } - if (pathDispatcherServlet != null) { - return null; + if (dispatcherServlet.isDefault()) { + if (mappings.size() == 1) { + return mvc; } - pathDispatcherServlet = registration; - } - return pathDispatcherServlet; - } - - private boolean isDispatcherServlet(ServletRegistration registration) { - Class dispatcherServlet = ClassUtils.resolveClassName("org.springframework.web.servlet.DispatcherServlet", - null); - try { - Class clazz = Class.forName(registration.getClassName()); - return dispatcherServlet.isAssignableFrom(clazz); - } - catch (ClassNotFoundException ex) { - return false; + return new DispatcherServletDelegatingRequestMatcher(ant, mvc); } + return mvc; } private static String computeErrorMessage(Collection registrations) { @@ -518,18 +447,12 @@ public boolean matches(HttpServletRequest request) { static class DispatcherServletRequestMatcher implements RequestMatcher { - private final ServletContext servletContext; - - DispatcherServletRequestMatcher(ServletContext servletContext) { - this.servletContext = servletContext; - } - @Override public boolean matches(HttpServletRequest request) { String name = request.getHttpServletMapping().getServletName(); - ServletRegistration registration = this.servletContext.getServletRegistration(name); + ServletRegistration registration = request.getServletContext().getServletRegistration(name); Assert.notNull(registration, - () -> computeErrorMessage(this.servletContext.getServletRegistrations().values())); + () -> computeErrorMessage(request.getServletContext().getServletRegistrations().values())); try { Class clazz = Class.forName(registration.getClassName()); return DispatcherServlet.class.isAssignableFrom(clazz); @@ -549,10 +472,8 @@ static class DispatcherServletDelegatingRequestMatcher implements RequestMatcher private final RequestMatcher dispatcherServlet; - DispatcherServletDelegatingRequestMatcher(AntPathRequestMatcher ant, MvcRequestMatcher mvc, - ServletContext servletContext) { - this(ant, mvc, new OrRequestMatcher(new MockMvcRequestMatcher(), - new DispatcherServletRequestMatcher(servletContext))); + DispatcherServletDelegatingRequestMatcher(AntPathRequestMatcher ant, MvcRequestMatcher mvc) { + this(ant, mvc, new OrRequestMatcher(new MockMvcRequestMatcher(), new DispatcherServletRequestMatcher())); } DispatcherServletDelegatingRequestMatcher(AntPathRequestMatcher ant, MvcRequestMatcher mvc, diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/ServletRegistrationsSupport.java b/config/src/main/java/org/springframework/security/config/annotation/web/ServletRegistrationsSupport.java new file mode 100644 index 00000000000..e84b8455f1c --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/ServletRegistrationsSupport.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-2025 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.web; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRegistration; + +import org.springframework.util.ClassUtils; + +class ServletRegistrationsSupport { + + private final Collection registrations; + + ServletRegistrationsSupport(ServletContext servletContext) { + Map registrations = servletContext.getServletRegistrations(); + Collection mappings = new ArrayList<>(); + for (Map.Entry entry : registrations.entrySet()) { + if (!entry.getValue().getMappings().isEmpty()) { + for (String mapping : entry.getValue().getMappings()) { + mappings.add(new RegistrationMapping(entry.getValue(), mapping)); + } + } + } + this.registrations = mappings; + } + + Collection dispatcherServletMappings() { + Collection mappings = new ArrayList<>(); + for (RegistrationMapping registration : this.registrations) { + if (registration.isDispatcherServlet()) { + mappings.add(registration); + } + } + return mappings; + } + + Collection mappings() { + return this.registrations; + } + + record RegistrationMapping(ServletRegistration registration, String mapping) { + boolean isDispatcherServlet() { + Class dispatcherServlet = ClassUtils + .resolveClassName("org.springframework.web.servlet.DispatcherServlet", null); + try { + Class clazz = Class.forName(this.registration.getClassName()); + return dispatcherServlet.isAssignableFrom(clazz); + } + catch (ClassNotFoundException ex) { + return false; + } + } + + boolean isDefault() { + return "/".equals(this.mapping); + } + } + +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/WebSecurityFilterChainValidator.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/WebSecurityFilterChainValidator.java index cc11cdef400..b0bf43fb3b7 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/WebSecurityFilterChainValidator.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/WebSecurityFilterChainValidator.java @@ -18,28 +18,45 @@ import java.util.List; +import jakarta.servlet.Filter; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.UnreachableFilterChainException; +import org.springframework.security.web.access.intercept.AuthorizationFilter; +import org.springframework.security.web.access.intercept.FilterSecurityInterceptor; import org.springframework.security.web.util.matcher.AnyRequestMatcher; /** * A filter chain validator for filter chains built by {@link WebSecurity} * + * @author Josh Cummings + * @author Max Batischev * @since 6.5 */ final class WebSecurityFilterChainValidator implements FilterChainProxy.FilterChainValidator { + private final Log logger = LogFactory.getLog(getClass()); + @Override public void validate(FilterChainProxy filterChainProxy) { List chains = filterChainProxy.getFilterChains(); + checkForAnyRequestRequestMatcher(chains); + checkForDuplicateMatchers(chains); + checkAuthorizationFilters(chains); + } + + private void checkForAnyRequestRequestMatcher(List chains) { DefaultSecurityFilterChain anyRequestFilterChain = null; for (SecurityFilterChain chain : chains) { if (anyRequestFilterChain != null) { String message = "A filter chain that matches any request [" + anyRequestFilterChain + "] has already been configured, which means that this filter chain [" + chain + "] will never get invoked. Please use `HttpSecurity#securityMatcher` to ensure that there is only one filter chain configured for 'any request' and that the 'any request' filter chain is published last."; - throw new IllegalArgumentException(message); + throw new UnreachableFilterChainException(message, anyRequestFilterChain, chain); } if (chain instanceof DefaultSecurityFilterChain defaultChain) { if (defaultChain.getRequestMatcher() instanceof AnyRequestMatcher) { @@ -49,4 +66,48 @@ public void validate(FilterChainProxy filterChainProxy) { } } + private void checkForDuplicateMatchers(List chains) { + DefaultSecurityFilterChain filterChain = null; + for (SecurityFilterChain chain : chains) { + if (filterChain != null) { + if (chain instanceof DefaultSecurityFilterChain defaultChain) { + if (defaultChain.getRequestMatcher().equals(filterChain.getRequestMatcher())) { + throw new UnreachableFilterChainException( + "The FilterChainProxy contains two filter chains using the" + " matcher " + + defaultChain.getRequestMatcher(), + filterChain, defaultChain); + } + } + } + if (chain instanceof DefaultSecurityFilterChain defaultChain) { + filterChain = defaultChain; + } + } + } + + private void checkAuthorizationFilters(List chains) { + Filter authorizationFilter = null; + Filter filterSecurityInterceptor = null; + for (SecurityFilterChain chain : chains) { + for (Filter filter : chain.getFilters()) { + if (filter instanceof AuthorizationFilter) { + authorizationFilter = filter; + } + if (filter instanceof FilterSecurityInterceptor) { + filterSecurityInterceptor = filter; + } + } + if (authorizationFilter != null && filterSecurityInterceptor != null) { + this.logger.warn( + "It is not recommended to use authorizeRequests in the configuration. Please only use authorizeHttpRequests"); + } + if (filterSecurityInterceptor != null) { + this.logger.warn( + "Usage of authorizeRequests is deprecated. Please use authorizeHttpRequests in the configuration"); + } + authorizationFilter = null; + filterSecurityInterceptor = null; + } + } + } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configuration/WebSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/web/configuration/WebSecurityConfiguration.java index d172a85d596..f58e9e55fc0 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configuration/WebSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configuration/WebSecurityConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 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. @@ -22,7 +22,7 @@ import jakarta.servlet.Filter; -import org.springframework.beans.factory.BeanClassLoaderAware; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.BeanFactoryPostProcessor; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; @@ -65,23 +65,16 @@ * @see WebSecurity */ @Configuration(proxyBeanMethods = false) -public class WebSecurityConfiguration implements ImportAware, BeanClassLoaderAware { +public class WebSecurityConfiguration implements ImportAware { private WebSecurity webSecurity; private Boolean debugEnabled; - private List> webSecurityConfigurers; - private List securityFilterChains = Collections.emptyList(); private List webSecurityCustomizers = Collections.emptyList(); - private ClassLoader beanClassLoader; - - @Autowired(required = false) - private HttpSecurity httpSecurity; - @Bean public static DelegatingApplicationListener delegatingApplicationListener() { return new DelegatingApplicationListener(); @@ -99,14 +92,15 @@ public SecurityExpressionHandler webSecurityExpressionHandler( * @throws Exception */ @Bean(name = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME) - public Filter springSecurityFilterChain() throws Exception { + public Filter springSecurityFilterChain(ObjectProvider provider) throws Exception { boolean hasFilterChain = !this.securityFilterChains.isEmpty(); if (!hasFilterChain) { this.webSecurity.addSecurityFilterChainBuilder(() -> { - this.httpSecurity.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()); - this.httpSecurity.formLogin(Customizer.withDefaults()); - this.httpSecurity.httpBasic(Customizer.withDefaults()); - return this.httpSecurity.build(); + HttpSecurity httpSecurity = provider.getObject(); + httpSecurity.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()); + httpSecurity.formLogin(Customizer.withDefaults()); + httpSecurity.httpBasic(Customizer.withDefaults()); + return httpSecurity.build(); }); } for (SecurityFilterChain securityFilterChain : this.securityFilterChains) { @@ -164,7 +158,6 @@ public void setFilterChainProxySecurityConfigurer(ObjectPostProcessor ob for (SecurityConfigurer webSecurityConfigurer : webSecurityConfigurers) { this.webSecurity.apply(webSecurityConfigurer); } - this.webSecurityConfigurers = webSecurityConfigurers; } @Autowired(required = false) @@ -193,11 +186,6 @@ public void setImportMetadata(AnnotationMetadata importMetadata) { } } - @Override - public void setBeanClassLoader(ClassLoader classLoader) { - this.beanClassLoader = classLoader; - } - /** * A custom version of the Spring provided AnnotationAwareOrderComparator that uses * {@link AnnotationUtils#findAnnotation(Class, Class)} to look on super class diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AbstractHttpConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AbstractHttpConfigurer.java index 841783c4f62..a7251514fda 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AbstractHttpConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/AbstractHttpConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -31,6 +31,7 @@ * {@link HttpSecurity}. * * @author Rob Winch + * @author Ding Hao */ public abstract class AbstractHttpConfigurer, B extends HttpSecurityBuilder> extends SecurityConfigurerAdapter { @@ -70,13 +71,8 @@ protected SecurityContextHolderStrategy getSecurityContextHolderStrategy() { return this.securityContextHolderStrategy; } ApplicationContext context = getBuilder().getSharedObject(ApplicationContext.class); - String[] names = context.getBeanNamesForType(SecurityContextHolderStrategy.class); - if (names.length == 1) { - this.securityContextHolderStrategy = context.getBean(SecurityContextHolderStrategy.class); - } - else { - this.securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy(); - } + this.securityContextHolderStrategy = context.getBeanProvider(SecurityContextHolderStrategy.class) + .getIfUnique(SecurityContextHolder::getContextHolderStrategy); return this.securityContextHolderStrategy; } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java index fc4a2a38804..fa601b94494 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java @@ -47,6 +47,7 @@ import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy; +import org.springframework.security.web.authentication.session.SessionLimit; import org.springframework.security.web.context.DelegatingSecurityContextRepository; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.NullSecurityContextRepository; @@ -123,7 +124,7 @@ public final class SessionManagementConfigurer> private SessionRegistry sessionRegistry; - private Integer maximumSessions; + private SessionLimit sessionLimit; private String expiredUrl; @@ -329,7 +330,7 @@ public SessionManagementConfigurer sessionFixation( * @return the {@link SessionManagementConfigurer} for further customizations */ public ConcurrencyControlConfigurer maximumSessions(int maximumSessions) { - this.maximumSessions = maximumSessions; + this.sessionLimit = SessionLimit.of(maximumSessions); this.propertiesThatRequireImplicitAuthentication.add("maximumSessions = " + maximumSessions); return new ConcurrencyControlConfigurer(); } @@ -570,7 +571,7 @@ private SessionAuthenticationStrategy getSessionAuthenticationStrategy(H http) { SessionRegistry sessionRegistry = getSessionRegistry(http); ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlStrategy = new ConcurrentSessionControlAuthenticationStrategy( sessionRegistry); - concurrentSessionControlStrategy.setMaximumSessions(this.maximumSessions); + concurrentSessionControlStrategy.setMaximumSessions(this.sessionLimit); concurrentSessionControlStrategy.setExceptionIfMaximumExceeded(this.maxSessionsPreventsLogin); concurrentSessionControlStrategy = postProcess(concurrentSessionControlStrategy); RegisterSessionAuthenticationStrategy registerSessionStrategy = new RegisterSessionAuthenticationStrategy( @@ -614,7 +615,7 @@ private void registerDelegateApplicationListener(H http, ApplicationListener * @return */ private boolean isConcurrentSessionControlEnabled() { - return this.maximumSessions != null; + return this.sessionLimit != null; } /** @@ -706,7 +707,19 @@ private ConcurrencyControlConfigurer() { * @return the {@link ConcurrencyControlConfigurer} for further customizations */ public ConcurrencyControlConfigurer maximumSessions(int maximumSessions) { - SessionManagementConfigurer.this.maximumSessions = maximumSessions; + SessionManagementConfigurer.this.sessionLimit = SessionLimit.of(maximumSessions); + return this; + } + + /** + * Determines the behaviour when a session limit is detected. + * @param sessionLimit the {@link SessionLimit} to check the maximum number of + * sessions for a user + * @return the {@link ConcurrencyControlConfigurer} for further customizations + * @since 6.5 + */ + public ConcurrencyControlConfigurer maximumSessions(SessionLimit sessionLimit) { + SessionManagementConfigurer.this.sessionLimit = sessionLimit; return this; } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java index 1a955e523da..104a0be328e 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -23,6 +23,7 @@ import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.context.ApplicationContext; +import org.springframework.http.converter.HttpMessageConverter; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.core.userdetails.UserDetailsService; @@ -43,6 +44,7 @@ import org.springframework.security.web.webauthn.management.Webauthn4JRelyingPartyOperations; import org.springframework.security.web.webauthn.registration.DefaultWebAuthnRegistrationPageGeneratingFilter; import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsFilter; +import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsRepository; import org.springframework.security.web.webauthn.registration.WebAuthnRegistrationFilter; /** @@ -63,6 +65,10 @@ public class WebAuthnConfigurer> private boolean disableDefaultRegistrationPage = false; + private PublicKeyCredentialCreationOptionsRepository creationOptionsRepository; + + private HttpMessageConverter converter; + /** * The Relying Party id. * @param rpId the relying party id @@ -116,6 +122,28 @@ public WebAuthnConfigurer disableDefaultRegistrationPage(boolean disable) { return this; } + /** + * Sets {@link HttpMessageConverter} used for WebAuthn to read/write to the HTTP + * request/response. + * @param converter the {@link HttpMessageConverter} + * @return the {@link WebAuthnConfigurer} for further customization + */ + public WebAuthnConfigurer messageConverter(HttpMessageConverter converter) { + this.converter = converter; + return this; + } + + /** + * Sets PublicKeyCredentialCreationOptionsRepository + * @param creationOptionsRepository the creationOptionsRepository + * @return the {@link WebAuthnConfigurer} for further customization + */ + public WebAuthnConfigurer creationOptionsRepository( + PublicKeyCredentialCreationOptionsRepository creationOptionsRepository) { + this.creationOptionsRepository = creationOptionsRepository; + return this; + } + @Override public void configure(H http) throws Exception { UserDetailsService userDetailsService = getSharedOrBean(http, UserDetailsService.class).orElseGet(() -> { @@ -127,12 +155,25 @@ public void configure(H http) throws Exception { UserCredentialRepository userCredentials = getSharedOrBean(http, UserCredentialRepository.class) .orElse(userCredentialRepository()); WebAuthnRelyingPartyOperations rpOperations = webAuthnRelyingPartyOperations(userEntities, userCredentials); + PublicKeyCredentialCreationOptionsRepository creationOptionsRepository = creationOptionsRepository(); WebAuthnAuthenticationFilter webAuthnAuthnFilter = new WebAuthnAuthenticationFilter(); webAuthnAuthnFilter.setAuthenticationManager( new ProviderManager(new WebAuthnAuthenticationProvider(rpOperations, userDetailsService))); + WebAuthnRegistrationFilter webAuthnRegistrationFilter = new WebAuthnRegistrationFilter(userCredentials, + rpOperations); + PublicKeyCredentialCreationOptionsFilter creationOptionsFilter = new PublicKeyCredentialCreationOptionsFilter( + rpOperations); + if (creationOptionsRepository != null) { + webAuthnRegistrationFilter.setCreationOptionsRepository(creationOptionsRepository); + creationOptionsFilter.setCreationOptionsRepository(creationOptionsRepository); + } + if (this.converter != null) { + webAuthnRegistrationFilter.setConverter(this.converter); + creationOptionsFilter.setConverter(this.converter); + } http.addFilterBefore(webAuthnAuthnFilter, BasicAuthenticationFilter.class); - http.addFilterAfter(new WebAuthnRegistrationFilter(userCredentials, rpOperations), AuthorizationFilter.class); - http.addFilterBefore(new PublicKeyCredentialCreationOptionsFilter(rpOperations), AuthorizationFilter.class); + http.addFilterAfter(webAuthnRegistrationFilter, AuthorizationFilter.class); + http.addFilterBefore(creationOptionsFilter, AuthorizationFilter.class); http.addFilterBefore(new PublicKeyCredentialRequestOptionsFilter(rpOperations), AuthorizationFilter.class); DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http @@ -159,6 +200,14 @@ public void configure(H http) throws Exception { } } + private PublicKeyCredentialCreationOptionsRepository creationOptionsRepository() { + if (this.creationOptionsRepository != null) { + return this.creationOptionsRepository; + } + ApplicationContext context = getBuilder().getSharedObject(ApplicationContext.class); + return context.getBeanProvider(PublicKeyCredentialCreationOptionsRepository.class).getIfUnique(); + } + private Optional getSharedOrBean(H http, Class type) { C shared = http.getSharedObject(type); return Optional.ofNullable(shared).or(() -> getBeanOrNull(type)); diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java index d191bb740be..1c6f9d1cb7a 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -68,6 +68,7 @@ import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; @@ -396,20 +397,8 @@ public void init(B http) throws Exception { @Override public void configure(B http) throws Exception { - OAuth2AuthorizationRequestRedirectFilter authorizationRequestFilter; - if (this.authorizationEndpointConfig.authorizationRequestResolver != null) { - authorizationRequestFilter = new OAuth2AuthorizationRequestRedirectFilter( - this.authorizationEndpointConfig.authorizationRequestResolver); - } - else { - String authorizationRequestBaseUri = this.authorizationEndpointConfig.authorizationRequestBaseUri; - if (authorizationRequestBaseUri == null) { - authorizationRequestBaseUri = OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI; - } - authorizationRequestFilter = new OAuth2AuthorizationRequestRedirectFilter( - OAuth2ClientConfigurerUtils.getClientRegistrationRepository(this.getBuilder()), - authorizationRequestBaseUri); - } + OAuth2AuthorizationRequestRedirectFilter authorizationRequestFilter = new OAuth2AuthorizationRequestRedirectFilter( + getAuthorizationRequestResolver()); if (this.authorizationEndpointConfig.authorizationRequestRepository != null) { authorizationRequestFilter .setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository); @@ -440,6 +429,24 @@ protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingU return new AntPathRequestMatcher(loginProcessingUrl); } + private OAuth2AuthorizationRequestResolver getAuthorizationRequestResolver() { + if (this.authorizationEndpointConfig.authorizationRequestResolver != null) { + return this.authorizationEndpointConfig.authorizationRequestResolver; + } + ClientRegistrationRepository clientRegistrationRepository = OAuth2ClientConfigurerUtils + .getClientRegistrationRepository(getBuilder()); + ResolvableType resolvableType = ResolvableType.forClass(OAuth2AuthorizationRequestResolver.class); + OAuth2AuthorizationRequestResolver bean = getBeanOrNull(resolvableType); + if (bean != null) { + return bean; + } + String authorizationRequestBaseUri = this.authorizationEndpointConfig.authorizationRequestBaseUri; + if (authorizationRequestBaseUri == null) { + authorizationRequestBaseUri = OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI; + } + return new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, authorizationRequestBaseUri); + } + @SuppressWarnings("unchecked") private JwtDecoderFactory getJwtDecoderFactoryBean() { ResolvableType type = ResolvableType.forClassWithGenerics(JwtDecoderFactory.class, ClientRegistration.class); diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java index 15718bf51b5..654c277e49c 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -18,6 +18,7 @@ import java.util.Collections; import java.util.Map; +import java.util.Objects; import jakarta.servlet.http.HttpServletRequest; @@ -25,6 +26,7 @@ import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; import org.springframework.security.authentication.ott.InMemoryOneTimeTokenService; import org.springframework.security.authentication.ott.OneTimeToken; import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationProvider; @@ -40,7 +42,9 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.security.web.authentication.ott.DefaultGenerateOneTimeTokenRequestResolver; import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenFilter; +import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenRequestResolver; import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationConverter; import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; @@ -79,6 +83,8 @@ public final class OneTimeTokenLoginConfigurer> private AuthenticationProvider authenticationProvider; + private GenerateOneTimeTokenRequestResolver requestResolver; + public OneTimeTokenLoginConfigurer(ApplicationContext context) { this.context = context; } @@ -135,6 +141,7 @@ private void configureOttGenerateFilter(H http) { GenerateOneTimeTokenFilter generateFilter = new GenerateOneTimeTokenFilter(getOneTimeTokenService(http), getOneTimeTokenGenerationSuccessHandler(http)); generateFilter.setRequestMatcher(antMatcher(HttpMethod.POST, this.tokenGeneratingUrl)); + generateFilter.setRequestResolver(getGenerateRequestResolver(http)); http.addFilter(postProcess(generateFilter)); http.addFilter(DefaultResourcesFilter.css()); } @@ -301,6 +308,28 @@ private AuthenticationFailureHandler getAuthenticationFailureHandler() { return this.authenticationFailureHandler; } + /** + * Use this {@link GenerateOneTimeTokenRequestResolver} when resolving + * {@link GenerateOneTimeTokenRequest} from {@link HttpServletRequest}. By default, + * the {@link DefaultGenerateOneTimeTokenRequestResolver} is used. + * @param requestResolver the {@link GenerateOneTimeTokenRequestResolver} + * @since 6.5 + */ + public OneTimeTokenLoginConfigurer generateRequestResolver(GenerateOneTimeTokenRequestResolver requestResolver) { + Assert.notNull(requestResolver, "requestResolver cannot be null"); + this.requestResolver = requestResolver; + return this; + } + + private GenerateOneTimeTokenRequestResolver getGenerateRequestResolver(H http) { + if (this.requestResolver != null) { + return this.requestResolver; + } + GenerateOneTimeTokenRequestResolver bean = getBeanOrNull(http, GenerateOneTimeTokenRequestResolver.class); + this.requestResolver = Objects.requireNonNullElseGet(bean, DefaultGenerateOneTimeTokenRequestResolver::new); + return this.requestResolver; + } + private OneTimeTokenService getOneTimeTokenService(H http) { if (this.oneTimeTokenService != null) { return this.oneTimeTokenService; diff --git a/config/src/main/java/org/springframework/security/config/http/DefaultFilterChainValidator.java b/config/src/main/java/org/springframework/security/config/http/DefaultFilterChainValidator.java index ce7c50be584..8f2baeb4c68 100644 --- a/config/src/main/java/org/springframework/security/config/http/DefaultFilterChainValidator.java +++ b/config/src/main/java/org/springframework/security/config/http/DefaultFilterChainValidator.java @@ -39,6 +39,7 @@ import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.FilterInvocation; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.UnreachableFilterChainException; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource; @@ -53,7 +54,6 @@ import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter; import org.springframework.security.web.session.SessionManagementFilter; import org.springframework.security.web.util.matcher.AnyRequestMatcher; -import org.springframework.security.web.util.matcher.RequestMatcher; public class DefaultFilterChainValidator implements FilterChainProxy.FilterChainValidator { @@ -69,31 +69,67 @@ public void validate(FilterChainProxy fcp) { } checkPathOrder(new ArrayList<>(fcp.getFilterChains())); checkForDuplicateMatchers(new ArrayList<>(fcp.getFilterChains())); + checkAuthorizationFilters(new ArrayList<>(fcp.getFilterChains())); } private void checkPathOrder(List filterChains) { // Check that the universal pattern is listed at the end, if at all Iterator chains = filterChains.iterator(); while (chains.hasNext()) { - RequestMatcher matcher = ((DefaultSecurityFilterChain) chains.next()).getRequestMatcher(); - if (AnyRequestMatcher.INSTANCE.equals(matcher) && chains.hasNext()) { - throw new IllegalArgumentException("A universal match pattern ('/**') is defined " - + " before other patterns in the filter chain, causing them to be ignored. Please check the " - + "ordering in your namespace or FilterChainProxy bean configuration"); + if (chains.next() instanceof DefaultSecurityFilterChain securityFilterChain) { + if (AnyRequestMatcher.INSTANCE.equals(securityFilterChain.getRequestMatcher()) && chains.hasNext()) { + throw new UnreachableFilterChainException("A universal match pattern ('/**') is defined " + + " before other patterns in the filter chain, causing them to be ignored. Please check the " + + "ordering in your namespace or FilterChainProxy bean configuration", + securityFilterChain, chains.next()); + } } } } private void checkForDuplicateMatchers(List chains) { - while (chains.size() > 1) { - DefaultSecurityFilterChain chain = (DefaultSecurityFilterChain) chains.remove(0); - for (SecurityFilterChain test : chains) { - if (chain.getRequestMatcher().equals(((DefaultSecurityFilterChain) test).getRequestMatcher())) { - throw new IllegalArgumentException("The FilterChainProxy contains two filter chains using the" - + " matcher " + chain.getRequestMatcher() + ". If you are using multiple namespace " - + "elements, you must use a 'pattern' attribute to define the request patterns to which they apply."); + DefaultSecurityFilterChain filterChain = null; + for (SecurityFilterChain chain : chains) { + if (filterChain != null) { + if (chain instanceof DefaultSecurityFilterChain defaultChain) { + if (defaultChain.getRequestMatcher().equals(filterChain.getRequestMatcher())) { + throw new UnreachableFilterChainException( + "The FilterChainProxy contains two filter chains using the" + " matcher " + + defaultChain.getRequestMatcher() + + ". If you are using multiple namespace " + + "elements, you must use a 'pattern' attribute to define the request patterns to which they apply.", + defaultChain, chain); + } } } + if (chain instanceof DefaultSecurityFilterChain defaultChain) { + filterChain = defaultChain; + } + } + } + + private void checkAuthorizationFilters(List chains) { + Filter authorizationFilter = null; + Filter filterSecurityInterceptor = null; + for (SecurityFilterChain chain : chains) { + for (Filter filter : chain.getFilters()) { + if (filter instanceof AuthorizationFilter) { + authorizationFilter = filter; + } + if (filter instanceof FilterSecurityInterceptor) { + filterSecurityInterceptor = filter; + } + } + if (authorizationFilter != null && filterSecurityInterceptor != null) { + this.logger.warn( + "It is not recommended to use authorizeRequests in the configuration. Please only use authorizeHttpRequests"); + } + if (filterSecurityInterceptor != null) { + this.logger.warn( + "Usage of authorizeRequests is deprecated. Please use authorizeHttpRequests in the configuration"); + } + authorizationFilter = null; + filterSecurityInterceptor = null; } } diff --git a/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java b/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java index 53635b5aa0b..db915da8678 100644 --- a/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java +++ b/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java @@ -122,6 +122,10 @@ class HttpConfigurationBuilder { private static final String ATT_SESSION_AUTH_STRATEGY_REF = "session-authentication-strategy-ref"; + private static final String ATT_MAX_SESSIONS_REF = "max-sessions-ref"; + + private static final String ATT_MAX_SESSIONS = "max-sessions"; + private static final String ATT_SESSION_AUTH_ERROR_URL = "session-authentication-error-url"; private static final String ATT_SECURITY_CONTEXT_HOLDER_STRATEGY = "security-context-holder-strategy-ref"; @@ -485,10 +489,16 @@ else if (StringUtils.hasText(sessionAuthStratRef)) { concurrentSessionStrategy.addConstructorArgValue(this.sessionRegistryRef); String maxSessions = this.pc.getReaderContext() .getEnvironment() - .resolvePlaceholders(sessionCtrlElt.getAttribute("max-sessions")); + .resolvePlaceholders(sessionCtrlElt.getAttribute(ATT_MAX_SESSIONS)); if (StringUtils.hasText(maxSessions)) { concurrentSessionStrategy.addPropertyValue("maximumSessions", maxSessions); } + String maxSessionsRef = this.pc.getReaderContext() + .getEnvironment() + .resolvePlaceholders(sessionCtrlElt.getAttribute(ATT_MAX_SESSIONS_REF)); + if (StringUtils.hasText(maxSessionsRef)) { + concurrentSessionStrategy.addPropertyReference("maximumSessions", maxSessionsRef); + } String exceptionIfMaximumExceeded = sessionCtrlElt.getAttribute("error-if-maximum-exceeded"); if (StringUtils.hasText(exceptionIfMaximumExceeded)) { concurrentSessionStrategy.addPropertyValue("exceptionIfMaximumExceeded", exceptionIfMaximumExceeded); @@ -591,6 +601,12 @@ private void createConcurrencyControlFilterAndSessionRegistry(Element element) { .error("Cannot use 'expired-url' attribute and 'expired-session-strategy-ref'" + " attribute together.", source); } + String maxSessions = element.getAttribute(ATT_MAX_SESSIONS); + String maxSessionsRef = element.getAttribute(ATT_MAX_SESSIONS_REF); + if (StringUtils.hasText(maxSessions) && StringUtils.hasText(maxSessionsRef)) { + this.pc.getReaderContext() + .error("Cannot use 'max-sessions' attribute and 'max-sessions-ref' attribute together.", source); + } if (StringUtils.hasText(expiryUrl)) { BeanDefinitionBuilder expiredSessionBldr = BeanDefinitionBuilder .rootBeanDefinition(SimpleRedirectSessionInformationExpiredStrategy.class); diff --git a/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParser.java index 860ed9fc551..24566458e11 100644 --- a/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParser.java +++ b/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParser.java @@ -146,6 +146,7 @@ public BeanDefinition parse(Element element, ParserContext pc) { BeanMetadataElement saml2LogoutRequestSuccessHandler = BeanDefinitionBuilder .rootBeanDefinition(Saml2RelyingPartyInitiatedLogoutSuccessHandler.class) .addConstructorArgValue(logoutRequestResolver) + .addPropertyValue("logoutRequestRepository", logoutRequestRepository) .getBeanDefinition(); this.logoutFilter = BeanDefinitionBuilder.rootBeanDefinition(LogoutFilter.class) .addConstructorArgValue(saml2LogoutRequestSuccessHandler) diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDsl.kt index 64249d7c80a..0133670a18f 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDsl.kt @@ -275,6 +275,13 @@ class AuthorizeHttpRequestsDsl : AbstractRequestMatcherDsl { val authenticated: AuthorizationManager = AuthenticatedAuthorizationManager.authenticated() + /** + * Specify that URLs are allowed by users who have authenticated and were not "remembered". + * @since 6.5 + */ + val fullyAuthenticated: AuthorizationManager = + AuthenticatedAuthorizationManager.fullyAuthenticated() + internal fun get(): (AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry) -> Unit { return { requests -> authorizationRules.forEach { rule -> diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt index 025e65e7410..2345bc5a679 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt @@ -23,6 +23,7 @@ import org.springframework.security.config.annotation.web.configurers.ott.OneTim import org.springframework.security.web.authentication.AuthenticationConverter import org.springframework.security.web.authentication.AuthenticationFailureHandler import org.springframework.security.web.authentication.AuthenticationSuccessHandler +import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenRequestResolver import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler /** @@ -34,6 +35,7 @@ import org.springframework.security.web.authentication.ott.OneTimeTokenGeneratio * @property authenticationConverter Use this [AuthenticationConverter] when converting incoming requests to an authentication * @property authenticationFailureHandler the [AuthenticationFailureHandler] to use when authentication * @property authenticationSuccessHandler the [AuthenticationSuccessHandler] to be used + * @property generateRequestResolver the [GenerateOneTimeTokenRequestResolver] to be used * @property defaultSubmitPageUrl sets the URL that the default submit page will be generated * @property showDefaultSubmitPage configures whether the default one-time token submit page should be shown * @property loginProcessingUrl the URL to process the login request @@ -47,6 +49,7 @@ class OneTimeTokenLoginDsl { var authenticationConverter: AuthenticationConverter? = null var authenticationFailureHandler: AuthenticationFailureHandler? = null var authenticationSuccessHandler: AuthenticationSuccessHandler? = null + var generateRequestResolver: GenerateOneTimeTokenRequestResolver? = null var defaultSubmitPageUrl: String? = null var loginProcessingUrl: String? = null var tokenGeneratingUrl: String? = null @@ -68,6 +71,11 @@ class OneTimeTokenLoginDsl { authenticationSuccessHandler ) } + generateRequestResolver?.also { + oneTimeTokenLoginConfigurer.generateRequestResolver( + generateRequestResolver + ) + } defaultSubmitPageUrl?.also { oneTimeTokenLoginConfigurer.defaultSubmitPageUrl(defaultSubmitPageUrl) } showDefaultSubmitPage?.also { oneTimeTokenLoginConfigurer.showDefaultSubmitPage(showDefaultSubmitPage!!) } loginProcessingUrl?.also { oneTimeTokenLoginConfigurer.loginProcessingUrl(loginProcessingUrl) } diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/WebAuthnDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/WebAuthnDsl.kt index 1624817431e..23447c1b6d7 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/WebAuthnDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/WebAuthnDsl.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2025 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. @@ -16,28 +16,38 @@ package org.springframework.security.config.annotation.web +import org.springframework.http.converter.HttpMessageConverter import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configurers.WebAuthnConfigurer +import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsRepository /** * A Kotlin DSL to configure [HttpSecurity] webauthn using idiomatic Kotlin code. * @property rpName the relying party name * @property rpId the relying party id - * @property the allowed origins + * @property allowedOrigins allowed origins + * @property disableDefaultRegistrationPage disable default webauthn registration page * @since 6.4 * @author Rob Winch + * @author Max Batischev */ @SecurityMarker class WebAuthnDsl { var rpName: String? = null var rpId: String? = null var allowedOrigins: Set? = null + var disableDefaultRegistrationPage: Boolean? = false + var creationOptionsRepository: PublicKeyCredentialCreationOptionsRepository? = null + var messageConverter: HttpMessageConverter? = null internal fun get(): (WebAuthnConfigurer) -> Unit { - return { webAuthn -> webAuthn - .rpId(rpId) - .rpName(rpName) - .allowedOrigins(allowedOrigins); + return { webAuthn -> + rpName?.also { webAuthn.rpName(rpName) } + rpId?.also { webAuthn.rpId(rpId) } + allowedOrigins?.also { webAuthn.allowedOrigins(allowedOrigins) } + disableDefaultRegistrationPage?.also { webAuthn.disableDefaultRegistrationPage(disableDefaultRegistrationPage!!) } + creationOptionsRepository?.also { webAuthn.creationOptionsRepository(creationOptionsRepository) } + messageConverter?.also { webAuthn.messageConverter(messageConverter) } } } } diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDsl.kt index 0d33c0702a5..ce4bc54ca5a 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDsl.kt @@ -19,7 +19,9 @@ package org.springframework.security.config.annotation.web.session import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer import org.springframework.security.core.session.SessionRegistry +import org.springframework.security.web.authentication.session.SessionLimit import org.springframework.security.web.session.SessionInformationExpiredStrategy +import org.springframework.util.Assert /** * A Kotlin DSL to configure the behaviour of multiple sessions using idiomatic @@ -44,12 +46,21 @@ class SessionConcurrencyDsl { var expiredSessionStrategy: SessionInformationExpiredStrategy? = null var maxSessionsPreventsLogin: Boolean? = null var sessionRegistry: SessionRegistry? = null + private var sessionLimit: SessionLimit? = null + + fun maximumSessions(max: SessionLimit) { + this.sessionLimit = max + } internal fun get(): (SessionManagementConfigurer.ConcurrencyControlConfigurer) -> Unit { + Assert.isTrue(maximumSessions == null || sessionLimit == null, "You cannot specify maximumSessions as both an Int and a SessionLimit. Please use only one.") return { sessionConcurrencyControl -> maximumSessions?.also { sessionConcurrencyControl.maximumSessions(maximumSessions!!) } + sessionLimit?.also { + sessionConcurrencyControl.maximumSessions(sessionLimit!!) + } expiredUrl?.also { sessionConcurrencyControl.expiredUrl(expiredUrl) } diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc index 9b2469aa879..9dcb7305714 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc +++ b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc @@ -934,6 +934,9 @@ concurrency-control = concurrency-control.attlist &= ## The maximum number of sessions a single authenticated user can have open at the same time. Defaults to "1". A negative value denotes unlimited sessions. attribute max-sessions {xsd:token}? +concurrency-control.attlist &= + ## Allows injection of the SessionLimit instance used by the ConcurrentSessionControlAuthenticationStrategy + attribute max-sessions-ref {xsd:token}? concurrency-control.attlist &= ## The URL a user will be redirected to if they attempt to use a session which has been "expired" because they have logged in again. attribute expired-url {xsd:token}? diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd index e46438d80dd..03a00f36657 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd +++ b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd @@ -2688,6 +2688,13 @@ + + + Allows injection of the SessionLimit instance used by the + ConcurrentSessionControlAuthenticationStrategy + + + The URL a user will be redirected to if they attempt to use a session which has been diff --git a/config/src/test/java/org/springframework/security/SpringSecurityCoreVersionSerializableTests.java b/config/src/test/java/org/springframework/security/SpringSecurityCoreVersionSerializableTests.java index cff442fffe8..4807eda046d 100644 --- a/config/src/test/java/org/springframework/security/SpringSecurityCoreVersionSerializableTests.java +++ b/config/src/test/java/org/springframework/security/SpringSecurityCoreVersionSerializableTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,8 @@ package org.springframework.security; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; @@ -35,12 +37,14 @@ import java.util.Collection; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.apache.commons.lang3.ObjectUtils; import org.apereo.cas.client.validation.AssertionImpl; import org.instancio.Instancio; import org.instancio.InstancioApi; @@ -54,25 +58,66 @@ import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; import org.springframework.core.type.filter.AssignableTypeFilter; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.AuthorizationServiceException; +import org.springframework.security.access.SecurityConfig; import org.springframework.security.access.intercept.RunAsUserToken; import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AccountExpiredException; import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.CredentialsExpiredException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.authentication.ProviderNotFoundException; import org.springframework.security.authentication.RememberMeAuthenticationToken; import org.springframework.security.authentication.TestAuthentication; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent; +import org.springframework.security.authentication.event.AuthenticationFailureCredentialsExpiredEvent; +import org.springframework.security.authentication.event.AuthenticationFailureDisabledEvent; +import org.springframework.security.authentication.event.AuthenticationFailureExpiredEvent; +import org.springframework.security.authentication.event.AuthenticationFailureLockedEvent; +import org.springframework.security.authentication.event.AuthenticationFailureProviderNotFoundEvent; +import org.springframework.security.authentication.event.AuthenticationFailureProxyUntrustedEvent; +import org.springframework.security.authentication.event.AuthenticationFailureServiceExceptionEvent; +import org.springframework.security.authentication.event.AuthenticationSuccessEvent; +import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent; +import org.springframework.security.authentication.event.LogoutSuccessEvent; import org.springframework.security.authentication.jaas.JaasAuthenticationToken; +import org.springframework.security.authentication.jaas.event.JaasAuthenticationFailedEvent; +import org.springframework.security.authentication.jaas.event.JaasAuthenticationSuccessEvent; +import org.springframework.security.authentication.ott.InvalidOneTimeTokenException; import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken; +import org.springframework.security.authentication.password.CompromisedPasswordException; import org.springframework.security.cas.authentication.CasAssertionAuthenticationToken; import org.springframework.security.cas.authentication.CasAuthenticationToken; import org.springframework.security.cas.authentication.CasServiceTicketAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.SpringSecurityCoreVersion; import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.core.context.TransientSecurityContext; +import org.springframework.security.core.session.AbstractSessionEvent; import org.springframework.security.core.session.ReactiveSessionInformation; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.ldap.ppolicy.PasswordPolicyControl; +import org.springframework.security.ldap.ppolicy.PasswordPolicyErrorStatus; +import org.springframework.security.ldap.ppolicy.PasswordPolicyException; +import org.springframework.security.ldap.ppolicy.PasswordPolicyResponseControl; import org.springframework.security.ldap.userdetails.LdapAuthority; +import org.springframework.security.oauth2.client.ClientAuthorizationException; +import org.springframework.security.oauth2.client.ClientAuthorizationRequiredException; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.client.authentication.OAuth2AuthorizationCodeAuthenticationToken; @@ -84,11 +129,15 @@ import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; import org.springframework.security.oauth2.client.oidc.session.TestOidcSessionInformations; import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistration.ClientSettings; import org.springframework.security.oauth2.client.registration.TestClientRegistrations; import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.OAuth2UserCode; import org.springframework.security.oauth2.core.TestOAuth2AccessTokens; @@ -108,16 +157,27 @@ import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; import org.springframework.security.oauth2.core.user.TestOAuth2Users; +import org.springframework.security.oauth2.jwt.BadJwtException; import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoderInitializationException; +import org.springframework.security.oauth2.jwt.JwtEncodingException; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.jwt.JwtValidationException; import org.springframework.security.oauth2.jwt.TestJwts; import org.springframework.security.oauth2.server.resource.BearerTokenError; import org.springframework.security.oauth2.server.resource.BearerTokenErrors; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication; import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.oauth2.server.resource.introspection.BadOpaqueTokenException; import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionAuthenticatedPrincipal; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException; +import org.springframework.security.saml2.Saml2Exception; +import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.provider.service.authentication.DefaultSaml2AuthenticatedPrincipal; import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException; import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest; import org.springframework.security.saml2.provider.service.authentication.Saml2RedirectAuthenticationRequest; import org.springframework.security.saml2.provider.service.authentication.TestSaml2Authentications; @@ -125,6 +185,22 @@ import org.springframework.security.saml2.provider.service.authentication.TestSaml2RedirectAuthenticationRequests; import org.springframework.security.web.authentication.WebAuthenticationDetails; import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedCredentialsNotFoundException; +import org.springframework.security.web.authentication.rememberme.CookieTheftException; +import org.springframework.security.web.authentication.rememberme.InvalidCookieException; +import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException; +import org.springframework.security.web.authentication.session.SessionAuthenticationException; +import org.springframework.security.web.authentication.session.SessionFixationProtectionEvent; +import org.springframework.security.web.authentication.switchuser.AuthenticationSwitchUserEvent; +import org.springframework.security.web.authentication.www.NonceExpiredException; +import org.springframework.security.web.csrf.CsrfException; +import org.springframework.security.web.csrf.DefaultCsrfToken; +import org.springframework.security.web.csrf.InvalidCsrfTokenException; +import org.springframework.security.web.csrf.MissingCsrfTokenException; +import org.springframework.security.web.firewall.RequestRejectedException; +import org.springframework.security.web.server.firewall.ServerExchangeRejectedException; +import org.springframework.security.web.session.HttpSessionCreatedEvent; +import org.springframework.util.ReflectionUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; @@ -155,6 +231,8 @@ class SpringSecurityCoreVersionSerializableTests { static { UserDetails user = TestAuthentication.user(); + Authentication authentication = TestAuthentication.authenticated(user); + SecurityContext securityContext = new SecurityContextImpl(authentication); // oauth2-core generatorByClassName.put(DefaultOAuth2User.class, (r) -> TestOAuth2Users.create()); @@ -171,15 +249,22 @@ class SpringSecurityCoreVersionSerializableTests { (r) -> new ReactiveSessionInformation(user, r.alphanumeric(4), Instant.ofEpochMilli(1704378933936L))); generatorByClassName.put(OAuth2AccessToken.class, (r) -> TestOAuth2AccessTokens.scopes("scope")); generatorByClassName.put(OAuth2DeviceCode.class, - (r) -> new OAuth2DeviceCode("token", Instant.now(), Instant.now())); + (r) -> new OAuth2DeviceCode("token", Instant.now(), Instant.now().plusSeconds(1))); generatorByClassName.put(OAuth2RefreshToken.class, - (r) -> new OAuth2RefreshToken("refreshToken", Instant.now(), Instant.now())); + (r) -> new OAuth2RefreshToken("refreshToken", Instant.now(), Instant.now().plusSeconds(1))); generatorByClassName.put(OAuth2UserCode.class, - (r) -> new OAuth2UserCode("token", Instant.now(), Instant.now())); + (r) -> new OAuth2UserCode("token", Instant.now(), Instant.now().plusSeconds(1))); + generatorByClassName.put(ClientSettings.class, (r) -> ClientSettings.builder().build()); generatorByClassName.put(DefaultOidcUser.class, (r) -> TestOidcUsers.create()); generatorByClassName.put(OidcUserAuthority.class, (r) -> new OidcUserAuthority(TestOidcIdTokens.idToken().build(), new OidcUserInfo(Map.of("claim", "value")), "claim")); + generatorByClassName.put(OAuth2AuthenticationException.class, + (r) -> new OAuth2AuthenticationException(new OAuth2Error("error", "description", "uri"), "message", + new RuntimeException())); + generatorByClassName.put(OAuth2AuthorizationException.class, + (r) -> new OAuth2AuthorizationException(new OAuth2Error("error", "description", "uri"), "message", + new RuntimeException())); // oauth2-client ClientRegistration.Builder clientRegistrationBuilder = TestClientRegistrations.clientRegistration(); @@ -218,6 +303,21 @@ class SpringSecurityCoreVersionSerializableTests { return new DefaultOAuth2AuthenticatedPrincipal(principal.getName(), principal.getAttributes(), (Collection) principal.getAuthorities()); }); + generatorByClassName.put(ClientAuthorizationException.class, + (r) -> new ClientAuthorizationException(new OAuth2Error("error", "description", "uri"), "id", "message", + new RuntimeException())); + generatorByClassName.put(ClientAuthorizationRequiredException.class, + (r) -> new ClientAuthorizationRequiredException("id")); + + // oauth2-jose + generatorByClassName.put(BadJwtException.class, (r) -> new BadJwtException("token", new RuntimeException())); + generatorByClassName.put(JwtDecoderInitializationException.class, + (r) -> new JwtDecoderInitializationException("message", new RuntimeException())); + generatorByClassName.put(JwtEncodingException.class, + (r) -> new JwtEncodingException("message", new RuntimeException())); + generatorByClassName.put(JwtException.class, (r) -> new JwtException("message", new RuntimeException())); + generatorByClassName.put(JwtValidationException.class, + (r) -> new JwtValidationException("message", List.of(new OAuth2Error("error", "description", "uri")))); // oauth2-jwt generatorByClassName.put(Jwt.class, (r) -> TestJwts.user()); @@ -249,6 +349,12 @@ class SpringSecurityCoreVersionSerializableTests { generatorByClassName.put(BearerTokenError.class, (r) -> BearerTokenErrors.invalidToken("invalid token")); generatorByClassName.put(OAuth2IntrospectionAuthenticatedPrincipal.class, (r) -> TestOAuth2AuthenticatedPrincipals.active()); + generatorByClassName.put(InvalidBearerTokenException.class, + (r) -> new InvalidBearerTokenException("description", new RuntimeException())); + generatorByClassName.put(BadOpaqueTokenException.class, + (r) -> new BadOpaqueTokenException("message", new RuntimeException())); + generatorByClassName.put(OAuth2IntrospectionException.class, + (r) -> new OAuth2IntrospectionException("message", new RuntimeException())); // core generatorByClassName.put(RunAsUserToken.class, (r) -> { @@ -274,9 +380,68 @@ class SpringSecurityCoreVersionSerializableTests { }); generatorByClassName.put(OneTimeTokenAuthenticationToken.class, (r) -> applyDetails(new OneTimeTokenAuthenticationToken("username", "token"))); - + generatorByClassName.put(AccessDeniedException.class, + (r) -> new AccessDeniedException("access denied", new RuntimeException())); + generatorByClassName.put(AuthorizationServiceException.class, + (r) -> new AuthorizationServiceException("access denied", new RuntimeException())); + generatorByClassName.put(AccountExpiredException.class, + (r) -> new AccountExpiredException("error", new RuntimeException())); + generatorByClassName.put(AuthenticationCredentialsNotFoundException.class, + (r) -> new AuthenticationCredentialsNotFoundException("error", new RuntimeException())); + generatorByClassName.put(AuthenticationServiceException.class, + (r) -> new AuthenticationServiceException("error", new RuntimeException())); + generatorByClassName.put(BadCredentialsException.class, + (r) -> new BadCredentialsException("error", new RuntimeException())); + generatorByClassName.put(CredentialsExpiredException.class, + (r) -> new CredentialsExpiredException("error", new RuntimeException())); + generatorByClassName.put(DisabledException.class, + (r) -> new DisabledException("error", new RuntimeException())); + generatorByClassName.put(InsufficientAuthenticationException.class, + (r) -> new InsufficientAuthenticationException("error", new RuntimeException())); + generatorByClassName.put(InternalAuthenticationServiceException.class, + (r) -> new InternalAuthenticationServiceException("error", new RuntimeException())); + generatorByClassName.put(LockedException.class, (r) -> new LockedException("error", new RuntimeException())); + generatorByClassName.put(ProviderNotFoundException.class, (r) -> new ProviderNotFoundException("error")); + generatorByClassName.put(InvalidOneTimeTokenException.class, (r) -> new InvalidOneTimeTokenException("error")); + generatorByClassName.put(CompromisedPasswordException.class, + (r) -> new CompromisedPasswordException("error", new RuntimeException())); + generatorByClassName.put(UsernameNotFoundException.class, + (r) -> new UsernameNotFoundException("error", new RuntimeException())); generatorByClassName.put(TestingAuthenticationToken.class, (r) -> applyDetails(new TestingAuthenticationToken("username", "password"))); + generatorByClassName.put(AuthenticationFailureBadCredentialsEvent.class, + (r) -> new AuthenticationFailureBadCredentialsEvent(authentication, + new BadCredentialsException("message"))); + generatorByClassName.put(AuthenticationFailureCredentialsExpiredEvent.class, + (r) -> new AuthenticationFailureCredentialsExpiredEvent(authentication, + new CredentialsExpiredException("message"))); + generatorByClassName.put(AuthenticationFailureDisabledEvent.class, + (r) -> new AuthenticationFailureDisabledEvent(authentication, new DisabledException("message"))); + generatorByClassName.put(AuthenticationFailureExpiredEvent.class, + (r) -> new AuthenticationFailureExpiredEvent(authentication, new AccountExpiredException("message"))); + generatorByClassName.put(AuthenticationFailureLockedEvent.class, + (r) -> new AuthenticationFailureLockedEvent(authentication, new LockedException("message"))); + generatorByClassName.put(AuthenticationFailureProviderNotFoundEvent.class, + (r) -> new AuthenticationFailureProviderNotFoundEvent(authentication, + new ProviderNotFoundException("message"))); + generatorByClassName.put(AuthenticationFailureProxyUntrustedEvent.class, + (r) -> new AuthenticationFailureProxyUntrustedEvent(authentication, + new AuthenticationServiceException("message"))); + generatorByClassName.put(AuthenticationFailureServiceExceptionEvent.class, + (r) -> new AuthenticationFailureServiceExceptionEvent(authentication, + new AuthenticationServiceException("message"))); + generatorByClassName.put(AuthenticationSuccessEvent.class, + (r) -> new AuthenticationSuccessEvent(authentication)); + generatorByClassName.put(InteractiveAuthenticationSuccessEvent.class, + (r) -> new InteractiveAuthenticationSuccessEvent(authentication, Authentication.class)); + generatorByClassName.put(LogoutSuccessEvent.class, (r) -> new LogoutSuccessEvent(authentication)); + generatorByClassName.put(JaasAuthenticationFailedEvent.class, + (r) -> new JaasAuthenticationFailedEvent(authentication, new RuntimeException("message"))); + generatorByClassName.put(JaasAuthenticationSuccessEvent.class, + (r) -> new JaasAuthenticationSuccessEvent(authentication)); + generatorByClassName.put(AbstractSessionEvent.class, (r) -> new AbstractSessionEvent(securityContext)); + generatorByClassName.put(SecurityConfig.class, (r) -> new SecurityConfig("value")); + generatorByClassName.put(TransientSecurityContext.class, (r) -> new TransientSecurityContext(authentication)); // cas generatorByClassName.put(CasServiceTicketAuthenticationToken.class, (r) -> { @@ -299,8 +464,19 @@ class SpringSecurityCoreVersionSerializableTests { // ldap generatorByClassName.put(LdapAuthority.class, (r) -> new LdapAuthority("USER", "username", Map.of("attribute", List.of("value1", "value2")))); + generatorByClassName.put(PasswordPolicyException.class, + (r) -> new PasswordPolicyException(PasswordPolicyErrorStatus.INSUFFICIENT_PASSWORD_QUALITY)); + generatorByClassName.put(PasswordPolicyControl.class, (r) -> new PasswordPolicyControl(true)); + generatorByClassName.put(PasswordPolicyResponseControl.class, (r) -> { + byte[] encodedResponse = { 0x30, 0x05, (byte) 0xA0, 0x03, (byte) 0xA0, 0x1, 0x21 }; + return new PasswordPolicyResponseControl(encodedResponse); + }); // saml2-service-provider + generatorByClassName.put(Saml2AuthenticationException.class, + (r) -> new Saml2AuthenticationException(new Saml2Error("code", "descirption"), "message", + new IOException("fail"))); + generatorByClassName.put(Saml2Exception.class, (r) -> new Saml2Exception("message", new IOException("fail"))); generatorByClassName.put(DefaultSaml2AuthenticatedPrincipal.class, (r) -> TestSaml2Authentications.authentication().getPrincipal()); generatorByClassName.put(Saml2Authentication.class, @@ -321,6 +497,76 @@ class SpringSecurityCoreVersionSerializableTests { token.setDetails(details); return token; }); + generatorByClassName.put(PreAuthenticatedCredentialsNotFoundException.class, + (r) -> new PreAuthenticatedCredentialsNotFoundException("message", new IOException("fail"))); + generatorByClassName.put(CookieTheftException.class, (r) -> new CookieTheftException("message")); + generatorByClassName.put(InvalidCookieException.class, (r) -> new InvalidCookieException("message")); + generatorByClassName.put(RememberMeAuthenticationException.class, + (r) -> new RememberMeAuthenticationException("message", new IOException("fail"))); + generatorByClassName.put(SessionAuthenticationException.class, + (r) -> new SessionAuthenticationException("message")); + generatorByClassName.put(NonceExpiredException.class, + (r) -> new NonceExpiredException("message", new IOException("fail"))); + generatorByClassName.put(CsrfException.class, (r) -> new CsrfException("message")); + generatorByClassName.put(org.springframework.security.web.server.csrf.CsrfException.class, + (r) -> new org.springframework.security.web.server.csrf.CsrfException("message")); + generatorByClassName.put(InvalidCsrfTokenException.class, + (r) -> new InvalidCsrfTokenException(new DefaultCsrfToken("header", "parameter", "token"), "token")); + generatorByClassName.put(MissingCsrfTokenException.class, (r) -> new MissingCsrfTokenException("token")); + generatorByClassName.put(DefaultCsrfToken.class, (r) -> new DefaultCsrfToken("header", "parameter", "token")); + generatorByClassName.put(org.springframework.security.web.server.csrf.DefaultCsrfToken.class, + (r) -> new org.springframework.security.web.server.csrf.DefaultCsrfToken("header", "parameter", + "token")); + generatorByClassName.put(RequestRejectedException.class, (r) -> new RequestRejectedException("message")); + generatorByClassName.put(ServerExchangeRejectedException.class, + (r) -> new ServerExchangeRejectedException("message")); + generatorByClassName.put(SessionFixationProtectionEvent.class, + (r) -> new SessionFixationProtectionEvent(authentication, "old", "new")); + generatorByClassName.put(AuthenticationSwitchUserEvent.class, + (r) -> new AuthenticationSwitchUserEvent(authentication, user)); + generatorByClassName.put(HttpSessionCreatedEvent.class, + (r) -> new HttpSessionCreatedEvent(new MockHttpSession())); + } + + @ParameterizedTest + @MethodSource("getClassesToSerialize") + void serializeAndDeserializeAreEqual(Class clazz) throws Exception { + Object expected = instancioWithDefaults(clazz).create(); + assertThat(expected).isInstanceOf(clazz); + try (ByteArrayOutputStream out = new ByteArrayOutputStream(); + ObjectOutputStream objectOutputStream = new ObjectOutputStream(out)) { + objectOutputStream.writeObject(expected); + objectOutputStream.flush(); + + try (ByteArrayInputStream in = new ByteArrayInputStream(out.toByteArray()); + ObjectInputStream objectInputStream = new ObjectInputStream(in)) { + Object deserialized = objectInputStream.readObject(); + // Ignore transient fields Event classes extend from EventObject which has + // transient source property + Set transientFieldNames = new HashSet(); + Set> visitedClasses = new HashSet(); + collectTransientFieldNames(transientFieldNames, visitedClasses, clazz); + assertThat(deserialized).usingRecursiveComparison() + .ignoringFields(transientFieldNames.toArray(new String[0])) + // RuntimeExceptions do not fully work but ensure the message does + .withComparatorForType((lhs, rhs) -> ObjectUtils.compare(lhs.getMessage(), rhs.getMessage()), + RuntimeException.class) + .isEqualTo(expected); + } + } + } + + private static void collectTransientFieldNames(Set transientFieldNames, Set> visitedClasses, + Class clazz) { + if (!visitedClasses.add(clazz) || clazz.isPrimitive()) { + return; + } + ReflectionUtils.doWithFields(clazz, (field) -> { + if (Modifier.isTransient(field.getModifiers())) { + transientFieldNames.add(field.getName()); + } + collectTransientFieldNames(transientFieldNames, visitedClasses, field.getType()); + }); } @ParameterizedTest diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java index 8561390515e..70f383c203a 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistryTests.java @@ -31,12 +31,12 @@ import org.springframework.core.ResolvableType; import org.springframework.http.HttpMethod; import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.security.config.MockServletContext; import org.springframework.security.config.ObjectPostProcessor; -import org.springframework.security.config.TestMockHttpServletMappings; import org.springframework.security.config.annotation.web.AbstractRequestMatcherRegistry.DispatcherServletDelegatingRequestMatcher; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.web.servlet.MockServletContext; +import org.springframework.security.web.servlet.TestMockHttpServletMappings; import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.DispatcherTypeRequestMatcher; @@ -318,7 +318,7 @@ public void requestMatchersWhenPathBasedNonDispatcherServletThenAllows() { List requestMatchers = this.matcherRegistry.requestMatchers("/services/*"); assertThat(requestMatchers).hasSize(1); assertThat(requestMatchers.get(0)).isInstanceOf(DispatcherServletDelegatingRequestMatcher.class); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/services/endpoint"); + MockHttpServletRequest request = new MockHttpServletRequest(servletContext, "GET", "/services/endpoint"); request.setHttpServletMapping(TestMockHttpServletMappings.defaultMapping()); assertThat(requestMatchers.get(0).matcher(request).isMatch()).isTrue(); request.setHttpServletMapping(TestMockHttpServletMappings.path(request, "/services")); @@ -334,9 +334,8 @@ public void matchesWhenDispatcherServletThenMvc() { servletContext.addServlet("path", Servlet.class).addMapping("/services/*"); MvcRequestMatcher mvc = mock(MvcRequestMatcher.class); AntPathRequestMatcher ant = mock(AntPathRequestMatcher.class); - DispatcherServletDelegatingRequestMatcher requestMatcher = new DispatcherServletDelegatingRequestMatcher(ant, - mvc, servletContext); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/services/endpoint"); + RequestMatcher requestMatcher = new DispatcherServletDelegatingRequestMatcher(ant, mvc); + MockHttpServletRequest request = new MockHttpServletRequest(servletContext, "GET", "/services/endpoint"); request.setHttpServletMapping(TestMockHttpServletMappings.defaultMapping()); assertThat(requestMatcher.matches(request)).isFalse(); verify(mvc).matches(request); @@ -354,9 +353,8 @@ public void matchesWhenNoMappingThenException() { servletContext.addServlet("path", Servlet.class).addMapping("/services/*"); MvcRequestMatcher mvc = mock(MvcRequestMatcher.class); AntPathRequestMatcher ant = mock(AntPathRequestMatcher.class); - DispatcherServletDelegatingRequestMatcher requestMatcher = new DispatcherServletDelegatingRequestMatcher(ant, - mvc, servletContext); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/services/endpoint"); + RequestMatcher requestMatcher = new DispatcherServletDelegatingRequestMatcher(ant, mvc); + MockHttpServletRequest request = new MockHttpServletRequest(servletContext, "GET", "/services/endpoint"); assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> requestMatcher.matcher(request)); } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/builders/WebSecurityFilterChainValidatorTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/builders/WebSecurityFilterChainValidatorTests.java new file mode 100644 index 00000000000..450a3dfdc17 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/builders/WebSecurityFilterChainValidatorTests.java @@ -0,0 +1,119 @@ +/* + * Copyright 2002-2024 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.web.builders; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.FilterChainProxy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.UnreachableFilterChainException; +import org.springframework.security.web.access.ExceptionTranslationFilter; +import org.springframework.security.web.access.intercept.FilterSecurityInterceptor; +import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatchers; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Tests for {@link WebSecurityFilterChainValidator} + * + * @author Max Batischev + */ +@ExtendWith(MockitoExtension.class) +public class WebSecurityFilterChainValidatorTests { + + private final WebSecurityFilterChainValidator validator = new WebSecurityFilterChainValidator(); + + @Mock + private AnonymousAuthenticationFilter authenticationFilter; + + @Mock + private ExceptionTranslationFilter exceptionTranslationFilter; + + @Mock + private FilterSecurityInterceptor authorizationInterceptor; + + @Test + void validateWhenFilterSecurityInterceptorConfiguredThenValidates() { + SecurityFilterChain chain = new DefaultSecurityFilterChain(AntPathRequestMatcher.antMatcher("/api"), + this.authenticationFilter, this.exceptionTranslationFilter, this.authorizationInterceptor); + FilterChainProxy proxy = new FilterChainProxy(List.of(chain)); + + assertThatNoException().isThrownBy(() -> this.validator.validate(proxy)); + } + + @Test + void validateWhenAnyRequestMatcherIsPresentThenUnreachableFilterChainException() { + SecurityFilterChain chain1 = new DefaultSecurityFilterChain(AntPathRequestMatcher.antMatcher("/api"), + this.authenticationFilter, this.exceptionTranslationFilter, this.authorizationInterceptor); + SecurityFilterChain chain2 = new DefaultSecurityFilterChain(AnyRequestMatcher.INSTANCE, + this.authenticationFilter, this.exceptionTranslationFilter, this.authorizationInterceptor); + List chains = new ArrayList<>(); + chains.add(chain2); + chains.add(chain1); + FilterChainProxy proxy = new FilterChainProxy(chains); + + assertThatExceptionOfType(UnreachableFilterChainException.class) + .isThrownBy(() -> this.validator.validate(proxy)); + } + + @Test + void validateWhenSameRequestMatchersArePresentThenUnreachableFilterChainException() { + SecurityFilterChain chain1 = new DefaultSecurityFilterChain(AntPathRequestMatcher.antMatcher("/api"), + this.authenticationFilter, this.exceptionTranslationFilter, this.authorizationInterceptor); + SecurityFilterChain chain2 = new DefaultSecurityFilterChain(AntPathRequestMatcher.antMatcher("/api"), + this.authenticationFilter, this.exceptionTranslationFilter, this.authorizationInterceptor); + List chains = new ArrayList<>(); + chains.add(chain2); + chains.add(chain1); + FilterChainProxy proxy = new FilterChainProxy(chains); + + assertThatExceptionOfType(UnreachableFilterChainException.class) + .isThrownBy(() -> this.validator.validate(proxy)); + } + + @Test + void validateWhenSameComposedRequestMatchersArePresentThenUnreachableFilterChainException() { + RequestMatcher matcher1 = RequestMatchers.anyOf(RequestMatchers.allOf(AntPathRequestMatcher.antMatcher("/api"), + AntPathRequestMatcher.antMatcher("*.do")), AntPathRequestMatcher.antMatcher("/admin")); + RequestMatcher matcher2 = RequestMatchers.anyOf(RequestMatchers.allOf(AntPathRequestMatcher.antMatcher("/api"), + AntPathRequestMatcher.antMatcher("*.do")), AntPathRequestMatcher.antMatcher("/admin")); + SecurityFilterChain chain1 = new DefaultSecurityFilterChain(matcher1, this.authenticationFilter, + this.exceptionTranslationFilter, this.authorizationInterceptor); + SecurityFilterChain chain2 = new DefaultSecurityFilterChain(matcher2, this.authenticationFilter, + this.exceptionTranslationFilter, this.authorizationInterceptor); + List chains = new ArrayList<>(); + chains.add(chain2); + chains.add(chain1); + FilterChainProxy proxy = new FilterChainProxy(chains); + + assertThatExceptionOfType(UnreachableFilterChainException.class) + .isThrownBy(() -> this.validator.validate(proxy)); + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configuration/WebSecurityConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configuration/WebSecurityConfigurationTests.java index 326e2bda108..e546ffb6a13 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configuration/WebSecurityConfigurationTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configuration/WebSecurityConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -27,8 +27,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanCreationException; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @@ -323,7 +325,14 @@ public void loadConfigWhenTwoSecurityFilterChainsPresentAndSecondWithAnyRequestT assertThatExceptionOfType(BeanCreationException.class) .isThrownBy(() -> this.spring.register(MultipleAnyRequestSecurityFilterChainConfig.class).autowire()) .havingRootCause() - .isExactlyInstanceOf(IllegalArgumentException.class); + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void avoidUnnecessaryHttpSecurityInstantiationWhenProvideOneSecurityFilterChain() { + this.spring.register(SecurityFilterChainConfig.class).autowire(); + assertThat(this.spring.getContext().getBean(CountHttpSecurityBeanPostProcessor.class).instantiationCount) + .isEqualTo(1); } private void assertAnotherUserPermission(WebInvocationPrivilegeEvaluator privilegeEvaluator) { @@ -347,6 +356,32 @@ private void assertUserPermissions(WebInvocationPrivilegeEvaluator privilegeEval assertThat(privilegeEvaluator.isAllowed("/another", user)).isTrue(); } + @Configuration + @EnableWebSecurity + @Import(CountHttpSecurityBeanPostProcessor.class) + static class SecurityFilterChainConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()).build(); + } + + } + + static class CountHttpSecurityBeanPostProcessor implements BeanPostProcessor { + + int instantiationCount = 0; + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof HttpSecurity) { + this.instantiationCount++; + } + return bean; + } + + } + @Configuration @EnableWebSecurity @Import(AuthenticationTestConfiguration.class) diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeRequestsTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeRequestsTests.java index 00b67e17f9d..65d7c13beae 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeRequestsTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeRequestsTests.java @@ -31,7 +31,6 @@ import org.springframework.security.access.hierarchicalroles.RoleHierarchy; import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.config.MockServletContext; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.authority.AuthorityUtils; @@ -42,6 +41,7 @@ import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.servlet.MockServletContext; import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurerTests.java index 6d683c4899a..f4646fe6f53 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2023 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. diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpSecuritySecurityMatchersTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpSecuritySecurityMatchersTests.java index 272f7ed26ad..e5c080aefe3 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpSecuritySecurityMatchersTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpSecuritySecurityMatchersTests.java @@ -32,7 +32,6 @@ import org.springframework.mock.web.MockFilterChain; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.config.MockServletContext; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.userdetails.User; @@ -42,6 +41,7 @@ import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.servlet.MockServletContext; import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.test.util.ReflectionTestUtils; diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurerTests.java index fbe52459a45..bca300ec521 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 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. @@ -59,6 +59,7 @@ import org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.ConcurrentSessionControlAuthenticationStrategy; import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; +import org.springframework.security.web.authentication.session.SessionLimit; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.security.web.savedrequest.RequestCache; @@ -249,6 +250,82 @@ public void loginWhenUserLoggedInAndMaxSessionsOneInLambdaThenLoginPrevented() t // @formatter:on } + @Test + public void loginWhenAdminUserLoggedInAndSessionLimitIsConfiguredThenLoginSuccessfully() throws Exception { + this.spring.register(ConcurrencyControlWithSessionLimitConfig.class).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + HttpSession secondSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(secondSession).isNotNull(); + // @formatter:on + assertThat(firstSession.getId()).isNotEqualTo(secondSession.getId()); + } + + @Test + public void loginWhenAdminUserLoggedInAndSessionLimitIsConfiguredThenLoginPrevented() throws Exception { + this.spring.register(ConcurrencyControlWithSessionLimitConfig.class).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + HttpSession secondSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(secondSession).isNotNull(); + assertThat(firstSession.getId()).isNotEqualTo(secondSession.getId()); + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + + @Test + public void loginWhenUserLoggedInAndSessionLimitIsConfiguredThenLoginPrevented() throws Exception { + this.spring.register(ConcurrencyControlWithSessionLimitConfig.class).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + @Test public void requestWhenSessionCreationPolicyStateLessInLambdaThenNoSessionCreated() throws Exception { this.spring.register(SessionCreationPolicyStateLessInLambdaConfig.class).autowire(); @@ -625,6 +702,42 @@ UserDetailsService userDetailsService() { } + @Configuration + @EnableWebSecurity + static class ConcurrencyControlWithSessionLimitConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http, SessionLimit sessionLimit) throws Exception { + // @formatter:off + http + .formLogin(withDefaults()) + .sessionManagement((sessionManagement) -> sessionManagement + .sessionConcurrency((sessionConcurrency) -> sessionConcurrency + .maximumSessions(sessionLimit) + .maxSessionsPreventsLogin(true) + ) + ); + // @formatter:on + return http.build(); + } + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(PasswordEncodedUser.admin(), PasswordEncodedUser.user()); + } + + @Bean + SessionLimit SessionLimit() { + return (authentication) -> { + if ("admin".equals(authentication.getName())) { + return 2; + } + return 1; + }; + } + + } + @Configuration @EnableWebSecurity static class SessionCreationPolicyStateLessInLambdaConfig { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/UrlAuthorizationConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/UrlAuthorizationConfigurerTests.java index 83bac3b026c..f98c86bbf27 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/UrlAuthorizationConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/UrlAuthorizationConfigurerTests.java @@ -31,7 +31,6 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.config.Customizer; -import org.springframework.security.config.MockServletContext; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.userdetails.PasswordEncodedUser; @@ -41,6 +40,7 @@ import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.servlet.MockServletContext; import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurerTests.java index a90c43f3122..201fbc4553c 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,7 @@ package org.springframework.security.config.annotation.web.configurers; +import java.nio.charset.StandardCharsets; import java.util.List; import org.junit.jupiter.api.Test; @@ -24,23 +25,38 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.config.Customizer; 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.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.ui.DefaultResourcesFilter; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions; +import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialCreationOptions; +import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations; +import org.springframework.security.web.webauthn.registration.HttpSessionPublicKeyCredentialCreationOptionsRepository; import org.springframework.test.web.servlet.MockMvc; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willAnswer; +import static org.mockito.Mockito.mock; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** @@ -126,6 +142,153 @@ public void webauthnWhenConfiguredAndNoDefaultRegistrationPageThenDoesNotServeJa this.mvc.perform(get("/login/webauthn.js")).andExpect(status().isNotFound()); } + @Test + public void webauthnWhenConfiguredPublicKeyCredentialCreationOptionsRepository() throws Exception { + TestingAuthenticationToken user = new TestingAuthenticationToken("user", "password", "ROLE_USER"); + SecurityContextHolder.setContext(new SecurityContextImpl(user)); + PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions + .createPublicKeyCredentialCreationOptions() + .build(); + WebAuthnRelyingPartyOperations rpOperations = mock(WebAuthnRelyingPartyOperations.class); + ConfigCredentialCreationOptionsRepository.rpOperations = rpOperations; + given(rpOperations.createPublicKeyCredentialCreationOptions(any())).willReturn(options); + String attrName = "attrName"; + HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository = new HttpSessionPublicKeyCredentialCreationOptionsRepository(); + creationOptionsRepository.setAttrName(attrName); + ConfigCredentialCreationOptionsRepository.creationOptionsRepository = creationOptionsRepository; + this.spring.register(ConfigCredentialCreationOptionsRepository.class).autowire(); + this.mvc.perform(post("/webauthn/register/options")) + .andExpect(status().isOk()) + .andExpect(request().sessionAttribute(attrName, options)); + } + + @Test + public void webauthnWhenConfiguredPublicKeyCredentialCreationOptionsRepositoryBeanPresent() throws Exception { + TestingAuthenticationToken user = new TestingAuthenticationToken("user", "password", "ROLE_USER"); + SecurityContextHolder.setContext(new SecurityContextImpl(user)); + PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions + .createPublicKeyCredentialCreationOptions() + .build(); + WebAuthnRelyingPartyOperations rpOperations = mock(WebAuthnRelyingPartyOperations.class); + ConfigCredentialCreationOptionsRepositoryFromBean.rpOperations = rpOperations; + given(rpOperations.createPublicKeyCredentialCreationOptions(any())).willReturn(options); + String attrName = "attrName"; + HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository = new HttpSessionPublicKeyCredentialCreationOptionsRepository(); + creationOptionsRepository.setAttrName(attrName); + ConfigCredentialCreationOptionsRepositoryFromBean.creationOptionsRepository = creationOptionsRepository; + this.spring.register(ConfigCredentialCreationOptionsRepositoryFromBean.class).autowire(); + this.mvc.perform(post("/webauthn/register/options")) + .andExpect(status().isOk()) + .andExpect(request().sessionAttribute(attrName, options)); + } + + @Test + public void webauthnWhenConfiguredMessageConverter() throws Exception { + TestingAuthenticationToken user = new TestingAuthenticationToken("user", "password", "ROLE_USER"); + SecurityContextHolder.setContext(new SecurityContextImpl(user)); + PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions + .createPublicKeyCredentialCreationOptions() + .build(); + WebAuthnRelyingPartyOperations rpOperations = mock(WebAuthnRelyingPartyOperations.class); + ConfigMessageConverter.rpOperations = rpOperations; + given(rpOperations.createPublicKeyCredentialCreationOptions(any())).willReturn(options); + HttpMessageConverter converter = mock(HttpMessageConverter.class); + given(converter.canWrite(any(), any())).willReturn(true); + String expectedBody = "123"; + willAnswer((args) -> { + HttpOutputMessage out = (HttpOutputMessage) args.getArguments()[2]; + out.getBody().write(expectedBody.getBytes(StandardCharsets.UTF_8)); + return null; + }).given(converter).write(any(), any(), any()); + ConfigMessageConverter.converter = converter; + this.spring.register(ConfigMessageConverter.class).autowire(); + this.mvc.perform(post("/webauthn/register/options")) + .andExpect(status().isOk()) + .andExpect(content().string(expectedBody)); + } + + @Configuration + @EnableWebSecurity + static class ConfigCredentialCreationOptionsRepository { + + private static HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository; + + private static WebAuthnRelyingPartyOperations rpOperations; + + @Bean + WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations() { + return ConfigCredentialCreationOptionsRepository.rpOperations; + } + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(); + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.csrf(AbstractHttpConfigurer::disable) + .webAuthn((c) -> c.creationOptionsRepository(creationOptionsRepository)) + .build(); + } + + } + + @Configuration + @EnableWebSecurity + static class ConfigCredentialCreationOptionsRepositoryFromBean { + + private static HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository; + + private static WebAuthnRelyingPartyOperations rpOperations; + + @Bean + WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations() { + return ConfigCredentialCreationOptionsRepositoryFromBean.rpOperations; + } + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(); + } + + @Bean + HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository() { + return ConfigCredentialCreationOptionsRepositoryFromBean.creationOptionsRepository; + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.csrf(AbstractHttpConfigurer::disable).webAuthn(Customizer.withDefaults()).build(); + } + + } + + @Configuration + @EnableWebSecurity + static class ConfigMessageConverter { + + private static HttpMessageConverter converter; + + private static WebAuthnRelyingPartyOperations rpOperations; + + @Bean + WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations() { + return ConfigMessageConverter.rpOperations; + } + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(); + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.csrf(AbstractHttpConfigurer::disable).webAuthn((c) -> c.messageConverter(converter)).build(); + } + + } + @Configuration @EnableWebSecurity static class DefaultWebauthnConfiguration { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurerTests.java index b56d047a5f7..770a8a17be5 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -379,6 +379,19 @@ public void oauth2LoginWithCustomAuthorizationRequestParameters() throws Excepti "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=clientId&scope=openid+profile+email&state=state&redirect_uri=http%3A%2F%2Flocalhost%2Flogin%2Foauth2%2Fcode%2Fgoogle&custom-param1=custom-value1"); } + @Test + public void oauth2LoginWithCustomAuthorizationRequestParametersAndResolverAsBean() throws Exception { + loadConfig(OAuth2LoginConfigCustomAuthorizationRequestResolverBean.class); + // @formatter:off + // @formatter:on + String requestUri = "/oauth2/authorization/google"; + this.request = new MockHttpServletRequest("GET", requestUri); + this.request.setServletPath(requestUri); + this.springSecurityFilterChain.doFilter(this.request, this.response, this.filterChain); + assertThat(this.response.getRedirectedUrl()).isEqualTo( + "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=clientId&scope=openid+profile+email&state=state&redirect_uri=http%3A%2F%2Flocalhost%2Flogin%2Foauth2%2Fcode%2Fgoogle&custom-param1=custom-value1"); + } + @Test public void requestWhenOauth2LoginWithCustomAuthorizationRequestParametersThenParametersInRedirectedUrl() throws Exception { @@ -940,6 +953,42 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { } + @Configuration + @EnableWebSecurity + static class OAuth2LoginConfigCustomAuthorizationRequestResolverBean extends CommonSecurityFilterChainConfig { + + private ClientRegistrationRepository clientRegistrationRepository = new InMemoryClientRegistrationRepository( + GOOGLE_CLIENT_REGISTRATION); + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .oauth2Login() + .clientRegistrationRepository(this.clientRegistrationRepository) + .authorizationEndpoint(); + // @formatter:on + return super.configureFilterChain(http); + } + + @Bean + OAuth2AuthorizationRequestResolver resolver() { + OAuth2AuthorizationRequestResolver resolver = mock(OAuth2AuthorizationRequestResolver.class); + // @formatter:off + OAuth2AuthorizationRequest result = OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri("https://accounts.google.com/authorize") + .clientId("client-id") + .state("adsfa") + .authorizationRequestUri( + "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=clientId&scope=openid+profile+email&state=state&redirect_uri=http%3A%2F%2Flocalhost%2Flogin%2Foauth2%2Fcode%2Fgoogle&custom-param1=custom-value1") + .build(); + given(resolver.resolve(any())).willReturn(result); + // @formatter:on + return resolver; + } + + } + @Configuration @EnableWebSecurity static class OAuth2LoginConfigCustomAuthorizationRequestResolverInLambda diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java index f89a37ae40f..b3c97b92015 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -17,6 +17,9 @@ package org.springframework.security.config.annotation.web.configurers.ott; import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -29,6 +32,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; import org.springframework.security.authentication.ott.OneTimeToken; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -40,6 +44,8 @@ import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.security.web.authentication.ott.DefaultGenerateOneTimeTokenRequestResolver; +import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenRequestResolver; import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler; import org.springframework.security.web.csrf.CsrfToken; @@ -194,6 +200,55 @@ Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL. """); } + @Test + void oneTimeTokenWhenCustomTokenExpirationTimeSetThenAuthenticate() throws Exception { + this.spring.register(OneTimeTokenConfigWithCustomTokenExpirationTime.class).autowire(); + this.mvc.perform(post("/ott/generate").param("username", "user").with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/login/ott")); + + OneTimeToken token = TestOneTimeTokenGenerationSuccessHandler.lastToken; + + this.mvc.perform(post("/login/ott").param("token", token.getTokenValue()).with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/"), authenticated()); + assertThat(getCurrentMinutes(token.getExpiresAt())).isEqualTo(10); + } + + private int getCurrentMinutes(Instant expiresAt) { + int expiresMinutes = expiresAt.atZone(ZoneOffset.UTC).getMinute(); + int currentMinutes = Instant.now().atZone(ZoneOffset.UTC).getMinute(); + return expiresMinutes - currentMinutes; + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + @Import(UserDetailsServiceConfig.class) + static class OneTimeTokenConfigWithCustomTokenExpirationTime { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authz) -> authz + .anyRequest().authenticated() + ) + .oneTimeTokenLogin((ott) -> ott + .tokenGenerationSuccessHandler(new TestOneTimeTokenGenerationSuccessHandler()) + ); + // @formatter:on + return http.build(); + } + + @Bean + GenerateOneTimeTokenRequestResolver generateOneTimeTokenRequestResolver() { + DefaultGenerateOneTimeTokenRequestResolver delegate = new DefaultGenerateOneTimeTokenRequestResolver(); + return (request) -> { + GenerateOneTimeTokenRequest generate = delegate.resolve(request); + return new GenerateOneTimeTokenRequest(generate.getUsername(), Duration.ofSeconds(600)); + }; + } + + } + @Configuration(proxyBeanMethods = false) @EnableWebSecurity @Import(UserDetailsServiceConfig.class) diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java index 3957d416dae..e13bddf7073 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java @@ -484,6 +484,7 @@ public void saml2LogoutResponseWhenCustomLogoutResponseHandlerThenUses() throws verify(getBean(Saml2LogoutResponseValidator.class)).validate(any()); } + // gh-11363 @Test public void saml2LogoutWhenCustomLogoutRequestRepositoryThenUses() throws Exception { this.spring.register(Saml2LogoutComponentsConfig.class).autowire(); diff --git a/config/src/test/java/org/springframework/security/config/http/DefaultFilterChainValidatorTests.java b/config/src/test/java/org/springframework/security/config/http/DefaultFilterChainValidatorTests.java index a5b899db48c..d75ce815d58 100644 --- a/config/src/test/java/org/springframework/security/config/http/DefaultFilterChainValidatorTests.java +++ b/config/src/test/java/org/springframework/security/config/http/DefaultFilterChainValidatorTests.java @@ -16,7 +16,9 @@ package org.springframework.security.config.http; +import java.util.ArrayList; import java.util.Collection; +import java.util.List; import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.logging.Log; @@ -33,6 +35,8 @@ import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.FilterChainProxy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.UnreachableFilterChainException; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.access.intercept.DefaultFilterInvocationSecurityMetadataSource; @@ -40,9 +44,12 @@ import org.springframework.security.web.access.intercept.FilterSecurityInterceptor; import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.AnyRequestMatcher; import org.springframework.test.util.ReflectionTestUtils; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; @@ -97,6 +104,11 @@ public void setUp() { ReflectionTestUtils.setField(this.validator, "logger", this.logger); } + @Test + void validateWhenFilterSecurityInterceptorConfiguredThenValidates() { + assertThatNoException().isThrownBy(() -> this.validator.validate(this.chain)); + } + // SEC-1878 @SuppressWarnings("unchecked") @Test @@ -130,4 +142,21 @@ public void validateCustomMetadataSource() { verify(customMetaDataSource, atLeastOnce()).getAttributes(any()); } + @Test + void validateWhenSameRequestMatchersArePresentThenUnreachableFilterChainException() { + AnonymousAuthenticationFilter authenticationFilter = mock(AnonymousAuthenticationFilter.class); + ExceptionTranslationFilter exceptionTranslationFilter = mock(ExceptionTranslationFilter.class); + SecurityFilterChain chain1 = new DefaultSecurityFilterChain(AntPathRequestMatcher.antMatcher("/api"), + authenticationFilter, exceptionTranslationFilter, this.authorizationInterceptor); + SecurityFilterChain chain2 = new DefaultSecurityFilterChain(AntPathRequestMatcher.antMatcher("/api"), + authenticationFilter, exceptionTranslationFilter, this.authorizationInterceptor); + List chains = new ArrayList<>(); + chains.add(chain2); + chains.add(chain1); + FilterChainProxy proxy = new FilterChainProxy(chains); + + assertThatExceptionOfType(UnreachableFilterChainException.class) + .isThrownBy(() -> this.validator.validate(proxy)); + } + } diff --git a/config/src/test/java/org/springframework/security/config/http/HttpConfigTests.java b/config/src/test/java/org/springframework/security/config/http/HttpConfigTests.java index 9a4e3b041e1..c7f0590bc1b 100644 --- a/config/src/test/java/org/springframework/security/config/http/HttpConfigTests.java +++ b/config/src/test/java/org/springframework/security/config/http/HttpConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2022 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. diff --git a/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java b/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java index c66933de16c..2c41d1a3688 100644 --- a/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java +++ b/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 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. @@ -24,6 +24,7 @@ import java.util.Set; import com.google.common.collect.ImmutableMap; +import jakarta.servlet.http.HttpSession; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -33,14 +34,21 @@ import org.springframework.beans.factory.xml.XmlBeanDefinitionStoreException; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.session.SessionLimit; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** @@ -49,6 +57,7 @@ * @author Josh Cummings * @author Rafiullah Hamedy * @author Marcus Da Coregio + * @author Claudenir Freitas */ @ExtendWith(SpringTestContextExtension.class) public class HttpHeadersConfigTests { @@ -782,6 +791,120 @@ public void requestWhenCrossOriginPoliciesRespondsCrossOriginPolicies() throws E // @formatter:on } + @Test + public void requestWhenSessionManagementConcurrencyControlMaxSessionIsOne() throws Exception { + System.setProperty("security.session-management.concurrency-control.max-sessions", "1"); + this.spring.configLocations(this.xml("DefaultsSessionManagementConcurrencyControlMaxSessions")).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + // @formatter:on + assertThat(firstSession).isNotNull(); + // @formatter:off + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + + @Test + public void requestWhenSessionManagementConcurrencyControlMaxSessionIsUnlimited() throws Exception { + System.setProperty("security.session-management.concurrency-control.max-sessions", "-1"); + this.spring.configLocations(this.xml("DefaultsSessionManagementConcurrencyControlMaxSessions")).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + HttpSession secondSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(secondSession).isNotNull(); + // @formatter:on + assertThat(firstSession.getId()).isNotEqualTo(secondSession.getId()); + } + + @Test + public void requestWhenSessionManagementConcurrencyControlMaxSessionRefIsOneForNonAdminUsers() throws Exception { + this.spring.configLocations(this.xml("DefaultsSessionManagementConcurrencyControlMaxSessionsRef")).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + // @formatter:on + assertThat(firstSession).isNotNull(); + // @formatter:off + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + + @Test + public void requestWhenSessionManagementConcurrencyControlMaxSessionRefIsTwoForAdminUsers() throws Exception { + this.spring.configLocations(this.xml("DefaultsSessionManagementConcurrencyControlMaxSessionsRef")).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + HttpSession secondSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(secondSession).isNotNull(); + // @formatter:on + assertThat(firstSession.getId()).isNotEqualTo(secondSession.getId()); + // @formatter:off + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + + @Test + public void requestWhenSessionManagementConcurrencyControlWithInvalidMaxSessionConfig() { + assertThatExceptionOfType(BeanDefinitionParsingException.class) + .isThrownBy(() -> this.spring + .configLocations(this.xml("DefaultsSessionManagementConcurrencyControlWithInvalidMaxSessionsConfig")) + .autowire()) + .withMessageContaining("Cannot use 'max-sessions' attribute and 'max-sessions-ref' attribute together."); + } + private static ResultMatcher includesDefaults() { return includes(defaultHeaders); } @@ -832,4 +955,16 @@ public String ok() { } + public static class CustomSessionLimit implements SessionLimit { + + @Override + public Integer apply(Authentication authentication) { + if ("admin".equals(authentication.getName())) { + return 2; + } + return 1; + } + + } + } diff --git a/config/src/test/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserTests.java b/config/src/test/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserTests.java index 152525d4a20..d51349440a5 100644 --- a/config/src/test/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserTests.java +++ b/config/src/test/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserTests.java @@ -63,6 +63,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.verify; @@ -380,6 +381,22 @@ public void saml2LogoutResponseWhenCustomLogoutResponseHandlerThenUses() throws verify(getBean(Saml2LogoutResponseValidator.class)).validate(any()); } + // gh-11363 + @Test + public void saml2LogoutWhenCustomLogoutRequestRepositoryThenUses() throws Exception { + this.spring.configLocations(this.xml("CustomComponents")).autowire(); + RelyingPartyRegistration registration = this.repository.findByRegistrationId("get"); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .samlRequest(this.rpLogoutRequest) + .id(this.rpLogoutRequestId) + .relayState(this.rpLogoutRequestRelayState) + .parameters((params) -> params.put("Signature", this.rpLogoutRequestSignature)) + .build(); + given(getBean(Saml2LogoutRequestResolver.class).resolve(any(), any())).willReturn(logoutRequest); + this.mvc.perform(post("/logout").with(authentication(this.saml2User)).with(csrf())); + verify(getBean(Saml2LogoutRequestRepository.class)).saveLogoutRequest(eq(logoutRequest), any(), any()); + } + private T getBean(Class clazz) { return this.spring.getContext().getBean(clazz); } diff --git a/config/src/test/java/org/springframework/security/config/test/SpringTestContext.java b/config/src/test/java/org/springframework/security/config/test/SpringTestContext.java index b165c20b609..800a3f45aeb 100644 --- a/config/src/test/java/org/springframework/security/config/test/SpringTestContext.java +++ b/config/src/test/java/org/springframework/security/config/test/SpringTestContext.java @@ -29,8 +29,8 @@ import org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor; import org.springframework.mock.web.MockServletConfig; import org.springframework.security.config.BeanIds; -import org.springframework.security.config.MockServletContext; import org.springframework.security.config.util.InMemoryXmlWebApplicationContext; +import org.springframework.security.web.servlet.MockServletContext; import org.springframework.test.context.web.GenericXmlWebContextLoader; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.RequestPostProcessor; diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDslTests.kt index 5a124b2f973..dfede958e73 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDslTests.kt @@ -27,6 +27,8 @@ import org.springframework.context.annotation.Configuration import org.springframework.http.HttpMethod import org.springframework.security.access.hierarchicalroles.RoleHierarchy import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl +import org.springframework.security.authentication.RememberMeAuthenticationToken +import org.springframework.security.authentication.TestAuthentication import org.springframework.security.authorization.AuthorizationDecision import org.springframework.security.authorization.AuthorizationManager import org.springframework.security.config.annotation.web.builders.HttpSecurity @@ -35,11 +37,11 @@ import org.springframework.security.config.core.GrantedAuthorityDefaults import org.springframework.security.config.test.SpringTestContext import org.springframework.security.config.test.SpringTestContextExtension import org.springframework.security.core.Authentication +import org.springframework.security.core.authority.AuthorityUtils import org.springframework.security.core.userdetails.User import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.provisioning.InMemoryUserDetailsManager -import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf -import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.* import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.access.intercept.RequestAuthorizationContext import org.springframework.security.web.util.matcher.RegexRequestMatcher @@ -961,4 +963,61 @@ class AuthorizeHttpRequestsDslTests { } } + + @Test + fun `request when fully authenticated configured then responds ok`() { + this.spring.register(FullyAuthenticatedConfig::class.java).autowire() + + this.mockMvc.get("/path") { + with(user("user").roles("USER")) + }.andExpect { + status { + isOk() + } + } + } + + @Test + fun `request when fully authenticated configured and remember-me token then responds unauthorized`() { + this.spring.register(FullyAuthenticatedConfig::class.java).autowire() + val rememberMe = RememberMeAuthenticationToken("key", "user", + AuthorityUtils.createAuthorityList("ROLE_USER")) + + this.mockMvc.get("/path") { + with(user("user").roles("USER")) + with(authentication(rememberMe)) + }.andExpect { + status { + isUnauthorized() + } + } + } + + @Configuration + @EnableWebSecurity + @EnableWebMvc + open class FullyAuthenticatedConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize("/path", fullyAuthenticated) + } + httpBasic { } + rememberMe { } + } + return http.build() + } + + @Bean + open fun userDetailsService(): UserDetailsService = InMemoryUserDetailsManager(TestAuthentication.user()) + + @RestController + internal class PathController { + @GetMapping("/path") + fun path(): String { + return "ok" + } + } + } } diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDslTests.kt index 07833e283f9..a8b52c51371 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDslTests.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -18,6 +18,7 @@ package org.springframework.security.config.annotation.web import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired @@ -36,11 +37,15 @@ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequ import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler +import org.springframework.security.web.authentication.ott.DefaultGenerateOneTimeTokenRequestResolver import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import java.time.Duration +import java.time.Instant +import java.time.ZoneOffset /** * Tests for [OneTimeTokenLoginDsl] @@ -104,6 +109,32 @@ class OneTimeTokenLoginDslTests { ) } + @Test + fun `oneTimeToken when custom resolver set then use custom token`() { + spring.register(OneTimeTokenConfigWithCustomTokenResolver::class.java).autowire() + + this.mockMvc.perform( + MockMvcRequestBuilders.post("/ott/generate").param("username", "user") + .with(SecurityMockMvcRequestPostProcessors.csrf()) + ).andExpectAll( + MockMvcResultMatchers + .status() + .isFound(), + MockMvcResultMatchers + .redirectedUrl("/login/ott") + ) + + val token = TestOneTimeTokenGenerationSuccessHandler.lastToken + + assertThat(getCurrentMinutes(token!!.expiresAt)).isEqualTo(10) + } + + private fun getCurrentMinutes(expiresAt: Instant): Int { + val expiresMinutes = expiresAt.atZone(ZoneOffset.UTC).minute + val currentMinutes = Instant.now().atZone(ZoneOffset.UTC).minute + return expiresMinutes - currentMinutes + } + @Configuration @EnableWebSecurity @Import(UserDetailsServiceConfig::class) @@ -125,6 +156,32 @@ class OneTimeTokenLoginDslTests { } } + @Configuration + @EnableWebSecurity + @Import(UserDetailsServiceConfig::class) + open class OneTimeTokenConfigWithCustomTokenResolver { + + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + // @formatter:off + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + oneTimeTokenLogin { + oneTimeTokenGenerationSuccessHandler = TestOneTimeTokenGenerationSuccessHandler() + generateRequestResolver = DefaultGenerateOneTimeTokenRequestResolver().apply { + this.setExpiresIn(Duration.ofMinutes(10)) + } + } + } + // @formatter:on + return http.build() + } + + + } + @EnableWebSecurity @Configuration(proxyBeanMethods = false) @Import(UserDetailsServiceConfig::class) diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/WebAuthnDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/WebAuthnDslTests.kt index c0705e50bc2..00e02f58212 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/WebAuthnDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/WebAuthnDslTests.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 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. @@ -16,11 +16,13 @@ package org.springframework.security.config.annotation.web +import org.hamcrest.Matchers import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter 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 @@ -29,8 +31,11 @@ import org.springframework.security.core.userdetails.User import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.provisioning.InMemoryUserDetailsManager import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.webauthn.registration.HttpSessionPublicKeyCredentialCreationOptionsRepository import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers /** * Tests for [WebAuthnDsl] @@ -55,6 +60,150 @@ class WebAuthnDslTests { } } + @Test + fun `explicit PublicKeyCredentialCreationOptionsRepository`() { + this.spring.register(ExplicitPublicKeyCredentialCreationOptionsRepositoryConfig::class.java).autowire() + + this.mockMvc.post("/test1") + .andExpect { + status { isForbidden() } + } + } + + @Test + fun `explicit HttpMessageConverter`() { + this.spring.register(ExplicitHttpMessageConverterConfig::class.java).autowire() + + this.mockMvc.post("/test1") + .andExpect { + status { isForbidden() } + } + } + + @Test + fun `webauthn and formLogin configured with default registration page`() { + spring.register(DefaultWebauthnConfig::class.java).autowire() + + this.mockMvc.get("/login/webauthn.js") + .andExpect { + MockMvcResultMatchers.status().isOk + header { + string("content-type", "text/javascript;charset=UTF-8") + } + content { + string(Matchers.containsString("async function authenticate(")) + } + } + } + + @Test + fun `webauthn and formLogin configured with disabled default registration page`() { + spring.register(FormLoginAndNoDefaultRegistrationPageConfiguration::class.java).autowire() + + this.mockMvc.get("/login/webauthn.js") + .andExpect { + MockMvcResultMatchers.status().isOk + header { + string("content-type", "text/javascript;charset=UTF-8") + } + content { + string(Matchers.containsString("async function authenticate(")) + } + } + } + + @Configuration + @EnableWebSecurity + open class FormLoginAndNoDefaultRegistrationPageConfiguration { + @Bean + open fun userDetailsService(): UserDetailsService = + InMemoryUserDetailsManager() + + + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http{ + formLogin { } + webAuthn { + disableDefaultRegistrationPage = true + } + } + return http.build() + } + } + + @Configuration + @EnableWebSecurity + open class DefaultWebauthnConfig { + @Bean + open fun userDetailsService(): UserDetailsService = + InMemoryUserDetailsManager() + + + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http{ + formLogin { } + webAuthn { } + } + return http.build() + } + } + + @Configuration + @EnableWebSecurity + open class ExplicitPublicKeyCredentialCreationOptionsRepositoryConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + webAuthn { + rpName = "Spring Security Relying Party" + rpId = "example.com" + allowedOrigins = setOf("https://example.com") + creationOptionsRepository = HttpSessionPublicKeyCredentialCreationOptionsRepository() + } + } + return http.build() + } + + @Bean + open fun userDetailsService(): UserDetailsService { + val userDetails = User.withDefaultPasswordEncoder() + .username("rod") + .password("password") + .roles("USER") + .build() + return InMemoryUserDetailsManager(userDetails) + } + } + + @Configuration + @EnableWebSecurity + open class ExplicitHttpMessageConverterConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + webAuthn { + rpName = "Spring Security Relying Party" + rpId = "example.com" + allowedOrigins = setOf("https://example.com") + messageConverter = MappingJackson2HttpMessageConverter() + } + } + return http.build() + } + + @Bean + open fun userDetailsService(): UserDetailsService { + val userDetails = User.withDefaultPasswordEncoder() + .username("rod") + .password("password") + .roles("USER") + .build() + return InMemoryUserDetailsManager(userDetails) + } + } + @Configuration @EnableWebSecurity open class WebauthnConfig { diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDslTests.kt index 6437c54326d..9117ae757a7 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDslTests.kt @@ -18,18 +18,19 @@ package org.springframework.security.config.annotation.web.session import io.mockk.every import io.mockk.mockkObject -import java.util.Date import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.mock.web.MockHttpSession +import org.springframework.security.authorization.AuthorityAuthorizationManager +import org.springframework.security.authorization.AuthorizationManager import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke import org.springframework.security.config.test.SpringTestContext import org.springframework.security.config.test.SpringTestContextExtension -import org.springframework.security.config.annotation.web.invoke import org.springframework.security.core.session.SessionInformation import org.springframework.security.core.session.SessionRegistry import org.springframework.security.core.session.SessionRegistryImpl @@ -44,6 +45,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.* /** * Tests for [SessionConcurrencyDsl] @@ -173,16 +175,75 @@ class SessionConcurrencyDslTests { open fun sessionRegistry(): SessionRegistry = SESSION_REGISTRY } + @Test + fun `session concurrency when session limit then no more sessions allowed`() { + this.spring.register(MaximumSessionsFunctionConfig::class.java, UserDetailsConfig::class.java).autowire() + + this.mockMvc.perform(post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password")) + + this.mockMvc.perform(post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password")) + .andExpect(status().isFound) + .andExpect(redirectedUrl("/login?error")) + + this.mockMvc.perform(post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password")) + .andExpect(status().isFound) + .andExpect(redirectedUrl("/")) + + this.mockMvc.perform(post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password")) + .andExpect(status().isFound) + .andExpect(redirectedUrl("/")) + } + + @Configuration + @EnableWebSecurity + open class MaximumSessionsFunctionConfig { + + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + val isAdmin: AuthorizationManager = AuthorityAuthorizationManager.hasRole("ADMIN") + http { + sessionManagement { + sessionConcurrency { + maximumSessions { + authentication -> if (isAdmin.authorize({ authentication }, null)!!.isGranted) -1 else 1 + } + maxSessionsPreventsLogin = true + } + } + formLogin { } + } + return http.build() + } + + } + @Configuration open class UserDetailsConfig { @Bean open fun userDetailsService(): UserDetailsService { - val userDetails = User.withDefaultPasswordEncoder() + val user = User.withDefaultPasswordEncoder() .username("user") .password("password") .roles("USER") .build() - return InMemoryUserDetailsManager(userDetails) + val admin = User.withDefaultPasswordEncoder() + .username("admin") + .password("password") + .roles("ADMIN") + .build() + return InMemoryUserDetailsManager(user, admin) } } } diff --git a/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessions.xml b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessions.xml new file mode 100644 index 00000000000..7e8c3d12a34 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessions.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessionsRef.xml b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessionsRef.xml new file mode 100644 index 00000000000..98215ca86cd --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessionsRef.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlWithInvalidMaxSessionsConfig.xml b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlWithInvalidMaxSessionsConfig.xml new file mode 100644 index 00000000000..7bf56c9a3a4 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlWithInvalidMaxSessionsConfig.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.access.AccessDeniedException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.access.AccessDeniedException.serialized new file mode 100644 index 00000000000..61dae86206e Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.access.AccessDeniedException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.access.AuthorizationServiceException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.access.AuthorizationServiceException.serialized new file mode 100644 index 00000000000..222e625eb63 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.access.AuthorizationServiceException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.access.SecurityConfig.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.access.SecurityConfig.serialized new file mode 100644 index 00000000000..ae659612d73 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.access.SecurityConfig.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.AccountExpiredException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.AccountExpiredException.serialized new file mode 100644 index 00000000000..004b8f22ea7 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.AccountExpiredException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.AuthenticationCredentialsNotFoundException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.AuthenticationCredentialsNotFoundException.serialized new file mode 100644 index 00000000000..4e99aa03653 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.AuthenticationCredentialsNotFoundException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.AuthenticationServiceException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.AuthenticationServiceException.serialized new file mode 100644 index 00000000000..c12cd3a7c52 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.AuthenticationServiceException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.BadCredentialsException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.BadCredentialsException.serialized new file mode 100644 index 00000000000..36c9802e720 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.BadCredentialsException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.CredentialsExpiredException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.CredentialsExpiredException.serialized new file mode 100644 index 00000000000..0ec7355f62c Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.CredentialsExpiredException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.DisabledException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.DisabledException.serialized new file mode 100644 index 00000000000..71d58fa87c7 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.DisabledException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.InsufficientAuthenticationException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.InsufficientAuthenticationException.serialized new file mode 100644 index 00000000000..24e5a933fa9 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.InsufficientAuthenticationException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.InternalAuthenticationServiceException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.InternalAuthenticationServiceException.serialized new file mode 100644 index 00000000000..3ce3a576f5a Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.InternalAuthenticationServiceException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.LockedException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.LockedException.serialized new file mode 100644 index 00000000000..30e52eafc8c Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.LockedException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.ProviderNotFoundException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.ProviderNotFoundException.serialized new file mode 100644 index 00000000000..1a7ade4e8d1 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.ProviderNotFoundException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent.serialized new file mode 100644 index 00000000000..979b2e937ad Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureCredentialsExpiredEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureCredentialsExpiredEvent.serialized new file mode 100644 index 00000000000..e4afece24aa Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureCredentialsExpiredEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureDisabledEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureDisabledEvent.serialized new file mode 100644 index 00000000000..c067d46e436 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureDisabledEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureExpiredEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureExpiredEvent.serialized new file mode 100644 index 00000000000..927df004815 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureExpiredEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureLockedEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureLockedEvent.serialized new file mode 100644 index 00000000000..46609358d9b Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureLockedEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureProviderNotFoundEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureProviderNotFoundEvent.serialized new file mode 100644 index 00000000000..18de70b6051 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureProviderNotFoundEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureProxyUntrustedEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureProxyUntrustedEvent.serialized new file mode 100644 index 00000000000..f348e60c844 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureProxyUntrustedEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureServiceExceptionEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureServiceExceptionEvent.serialized new file mode 100644 index 00000000000..15790690a40 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationFailureServiceExceptionEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationSuccessEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationSuccessEvent.serialized new file mode 100644 index 00000000000..d04eb51778b Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.AuthenticationSuccessEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent.serialized new file mode 100644 index 00000000000..49143cf8188 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.LogoutSuccessEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.LogoutSuccessEvent.serialized new file mode 100644 index 00000000000..646896dde48 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.event.LogoutSuccessEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.jaas.event.JaasAuthenticationFailedEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.jaas.event.JaasAuthenticationFailedEvent.serialized new file mode 100644 index 00000000000..d371ae6ae4b Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.jaas.event.JaasAuthenticationFailedEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.jaas.event.JaasAuthenticationSuccessEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.jaas.event.JaasAuthenticationSuccessEvent.serialized new file mode 100644 index 00000000000..6532dac81f9 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.jaas.event.JaasAuthenticationSuccessEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.ott.InvalidOneTimeTokenException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.ott.InvalidOneTimeTokenException.serialized new file mode 100644 index 00000000000..72c49585259 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.ott.InvalidOneTimeTokenException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.password.CompromisedPasswordException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.password.CompromisedPasswordException.serialized new file mode 100644 index 00000000000..112bcf688ce Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.authentication.password.CompromisedPasswordException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.core.context.TransientSecurityContext.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.core.context.TransientSecurityContext.serialized new file mode 100644 index 00000000000..5a4ccd07b4d Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.core.context.TransientSecurityContext.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.core.session.AbstractSessionEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.core.session.AbstractSessionEvent.serialized new file mode 100644 index 00000000000..a22f7a0f9b3 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.core.session.AbstractSessionEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.core.userdetails.UsernameNotFoundException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.core.userdetails.UsernameNotFoundException.serialized new file mode 100644 index 00000000000..0272398b25f Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.core.userdetails.UsernameNotFoundException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.ldap.ppolicy.PasswordPolicyControl.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.ldap.ppolicy.PasswordPolicyControl.serialized new file mode 100644 index 00000000000..51e783d58cf Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.ldap.ppolicy.PasswordPolicyControl.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.ldap.ppolicy.PasswordPolicyException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.ldap.ppolicy.PasswordPolicyException.serialized new file mode 100644 index 00000000000..148433692c0 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.ldap.ppolicy.PasswordPolicyException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.ldap.ppolicy.PasswordPolicyResponseControl.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.ldap.ppolicy.PasswordPolicyResponseControl.serialized new file mode 100644 index 00000000000..911742c9818 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.ldap.ppolicy.PasswordPolicyResponseControl.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.client.ClientAuthorizationException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.client.ClientAuthorizationException.serialized new file mode 100644 index 00000000000..7566a0979b5 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.client.ClientAuthorizationException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.client.ClientAuthorizationRequiredException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.client.ClientAuthorizationRequiredException.serialized new file mode 100644 index 00000000000..836566955ab Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.client.ClientAuthorizationRequiredException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.core.OAuth2AuthenticationException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.core.OAuth2AuthenticationException.serialized new file mode 100644 index 00000000000..de67c73ec22 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.core.OAuth2AuthenticationException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.core.OAuth2AuthorizationException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.core.OAuth2AuthorizationException.serialized new file mode 100644 index 00000000000..b082c12d282 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.core.OAuth2AuthorizationException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.BadJwtException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.BadJwtException.serialized new file mode 100644 index 00000000000..275216a9f2d Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.BadJwtException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.JwtDecoderInitializationException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.JwtDecoderInitializationException.serialized new file mode 100644 index 00000000000..39a7ada3a10 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.JwtDecoderInitializationException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.JwtEncodingException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.JwtEncodingException.serialized new file mode 100644 index 00000000000..e0026470c33 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.JwtEncodingException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.JwtException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.JwtException.serialized new file mode 100644 index 00000000000..ac27bf9f67a Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.JwtException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.JwtValidationException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.JwtValidationException.serialized new file mode 100644 index 00000000000..539b3ea50e3 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.jwt.JwtValidationException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.server.resource.InvalidBearerTokenException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.server.resource.InvalidBearerTokenException.serialized new file mode 100644 index 00000000000..e2cd7fbb997 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.server.resource.InvalidBearerTokenException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.server.resource.introspection.BadOpaqueTokenException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.server.resource.introspection.BadOpaqueTokenException.serialized new file mode 100644 index 00000000000..098c85e9bd6 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.server.resource.introspection.BadOpaqueTokenException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException.serialized new file mode 100644 index 00000000000..4c8b96b31ed Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.saml2.Saml2Exception.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.saml2.Saml2Exception.serialized new file mode 100644 index 00000000000..4fd752b76ff Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.saml2.Saml2Exception.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException.serialized new file mode 100644 index 00000000000..f771882b3de Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.preauth.PreAuthenticatedCredentialsNotFoundException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.preauth.PreAuthenticatedCredentialsNotFoundException.serialized new file mode 100644 index 00000000000..6d7a94c2950 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.preauth.PreAuthenticatedCredentialsNotFoundException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.CookieTheftException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.CookieTheftException.serialized new file mode 100644 index 00000000000..e983ebc0136 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.CookieTheftException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.InvalidCookieException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.InvalidCookieException.serialized new file mode 100644 index 00000000000..b4f3a5f6acc Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.InvalidCookieException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException.serialized new file mode 100644 index 00000000000..fe88d36cd4b Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.session.SessionAuthenticationException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.session.SessionAuthenticationException.serialized new file mode 100644 index 00000000000..5b627fb9c7a Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.session.SessionAuthenticationException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.session.SessionFixationProtectionEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.session.SessionFixationProtectionEvent.serialized new file mode 100644 index 00000000000..4fc1f92cb28 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.session.SessionFixationProtectionEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.switchuser.AuthenticationSwitchUserEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.switchuser.AuthenticationSwitchUserEvent.serialized new file mode 100644 index 00000000000..17b756520d3 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.switchuser.AuthenticationSwitchUserEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.www.NonceExpiredException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.www.NonceExpiredException.serialized new file mode 100644 index 00000000000..2d1621125f3 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.www.NonceExpiredException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.csrf.CsrfException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.csrf.CsrfException.serialized new file mode 100644 index 00000000000..55eddf9e9f0 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.csrf.CsrfException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.csrf.DefaultCsrfToken.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.csrf.DefaultCsrfToken.serialized new file mode 100644 index 00000000000..693e898c313 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.csrf.DefaultCsrfToken.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.csrf.InvalidCsrfTokenException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.csrf.InvalidCsrfTokenException.serialized new file mode 100644 index 00000000000..18f8a50a348 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.csrf.InvalidCsrfTokenException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.csrf.MissingCsrfTokenException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.csrf.MissingCsrfTokenException.serialized new file mode 100644 index 00000000000..dd210a46128 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.csrf.MissingCsrfTokenException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.firewall.RequestRejectedException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.firewall.RequestRejectedException.serialized new file mode 100644 index 00000000000..52e1faf545b Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.firewall.RequestRejectedException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.server.csrf.CsrfException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.server.csrf.CsrfException.serialized new file mode 100644 index 00000000000..6556a08dde7 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.server.csrf.CsrfException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.server.csrf.DefaultCsrfToken.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.server.csrf.DefaultCsrfToken.serialized new file mode 100644 index 00000000000..9cff958c490 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.server.csrf.DefaultCsrfToken.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.server.firewall.ServerExchangeRejectedException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.server.firewall.ServerExchangeRejectedException.serialized new file mode 100644 index 00000000000..33fb178f627 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.server.firewall.ServerExchangeRejectedException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.session.HttpSessionCreatedEvent.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.session.HttpSessionCreatedEvent.serialized new file mode 100644 index 00000000000..95888e6e1cc Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.session.HttpSessionCreatedEvent.serialized differ diff --git a/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.OAuth2AuthorizedClient.serialized b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.OAuth2AuthorizedClient.serialized new file mode 100644 index 00000000000..9c6c667a11a Binary files /dev/null and b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.OAuth2AuthorizedClient.serialized differ diff --git a/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.authentication.OAuth2AuthorizationCodeAuthenticationToken.serialized b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.authentication.OAuth2AuthorizationCodeAuthenticationToken.serialized new file mode 100644 index 00000000000..2fc9de7065d Binary files /dev/null and b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.authentication.OAuth2AuthorizationCodeAuthenticationToken.serialized differ diff --git a/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken.serialized b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken.serialized new file mode 100644 index 00000000000..5d1c909a791 Binary files /dev/null and b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken.serialized differ diff --git a/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.registration.ClientRegistration$Builder.serialized b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.registration.ClientRegistration$Builder.serialized new file mode 100644 index 00000000000..5db839c1aba Binary files /dev/null and b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.registration.ClientRegistration$Builder.serialized differ diff --git a/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.registration.ClientRegistration$ClientSettings.serialized b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.registration.ClientRegistration$ClientSettings.serialized new file mode 100644 index 00000000000..14e74db3b46 Binary files /dev/null and b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.registration.ClientRegistration$ClientSettings.serialized differ diff --git a/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.registration.ClientRegistration.serialized b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.registration.ClientRegistration.serialized new file mode 100644 index 00000000000..9eb1e5f751c Binary files /dev/null and b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.client.registration.ClientRegistration.serialized differ diff --git a/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.core.OAuth2DeviceCode.serialized b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.core.OAuth2DeviceCode.serialized new file mode 100644 index 00000000000..8a382b26102 Binary files /dev/null and b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.core.OAuth2DeviceCode.serialized differ diff --git a/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.core.OAuth2RefreshToken.serialized b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.core.OAuth2RefreshToken.serialized new file mode 100644 index 00000000000..aad2554caee Binary files /dev/null and b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.core.OAuth2RefreshToken.serialized differ diff --git a/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.core.OAuth2UserCode.serialized b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.core.OAuth2UserCode.serialized new file mode 100644 index 00000000000..a3fd001c786 Binary files /dev/null and b/config/src/test/resources/serialized/6.5.x/org.springframework.security.oauth2.core.OAuth2UserCode.serialized differ diff --git a/config/src/test/resources/serialized/6.5.x/org.springframework.security.web.UnreachableFilterChainException.serialized b/config/src/test/resources/serialized/6.5.x/org.springframework.security.web.UnreachableFilterChainException.serialized new file mode 100644 index 00000000000..418c3b8ece1 Binary files /dev/null and b/config/src/test/resources/serialized/6.5.x/org.springframework.security.web.UnreachableFilterChainException.serialized differ diff --git a/core/src/main/java/org/springframework/security/access/AccessDeniedException.java b/core/src/main/java/org/springframework/security/access/AccessDeniedException.java index 3bf6ceac5a0..49efd9f689d 100644 --- a/core/src/main/java/org/springframework/security/access/AccessDeniedException.java +++ b/core/src/main/java/org/springframework/security/access/AccessDeniedException.java @@ -16,6 +16,8 @@ package org.springframework.security.access; +import java.io.Serial; + /** * Thrown if an {@link org.springframework.security.core.Authentication Authentication} * object does not hold a required authority. @@ -24,6 +26,9 @@ */ public class AccessDeniedException extends RuntimeException { + @Serial + private static final long serialVersionUID = 6395817500121599533L; + /** * Constructs an AccessDeniedException with the specified message. * @param msg the detail message diff --git a/core/src/main/java/org/springframework/security/access/AuthorizationServiceException.java b/core/src/main/java/org/springframework/security/access/AuthorizationServiceException.java index 6952be563a6..4320b0075ff 100644 --- a/core/src/main/java/org/springframework/security/access/AuthorizationServiceException.java +++ b/core/src/main/java/org/springframework/security/access/AuthorizationServiceException.java @@ -16,6 +16,8 @@ package org.springframework.security.access; +import java.io.Serial; + /** * Thrown if an authorization request could not be processed due to a system problem. *

@@ -26,6 +28,9 @@ */ public class AuthorizationServiceException extends AccessDeniedException { + @Serial + private static final long serialVersionUID = 4817857292041606900L; + /** * Constructs an AuthorizationServiceException with the specified * message. diff --git a/core/src/main/java/org/springframework/security/access/SecurityConfig.java b/core/src/main/java/org/springframework/security/access/SecurityConfig.java index 3079174e529..2cbc640b3ad 100644 --- a/core/src/main/java/org/springframework/security/access/SecurityConfig.java +++ b/core/src/main/java/org/springframework/security/access/SecurityConfig.java @@ -16,6 +16,7 @@ package org.springframework.security.access; +import java.io.Serial; import java.util.ArrayList; import java.util.List; @@ -29,6 +30,9 @@ */ public class SecurityConfig implements ConfigAttribute { + @Serial + private static final long serialVersionUID = -7138084564199804304L; + private final String attrib; public SecurityConfig(String config) { diff --git a/core/src/main/java/org/springframework/security/access/annotation/Jsr250SecurityConfig.java b/core/src/main/java/org/springframework/security/access/annotation/Jsr250SecurityConfig.java index 3a3ccdf91e7..f129fdbe176 100644 --- a/core/src/main/java/org/springframework/security/access/annotation/Jsr250SecurityConfig.java +++ b/core/src/main/java/org/springframework/security/access/annotation/Jsr250SecurityConfig.java @@ -30,6 +30,7 @@ * @deprecated Use {@link AuthorizationManagerBeforeMethodInterceptor#jsr250()} instead */ @Deprecated +@SuppressWarnings("serial") public class Jsr250SecurityConfig extends SecurityConfig { public static final Jsr250SecurityConfig PERMIT_ALL_ATTRIBUTE = new Jsr250SecurityConfig(PermitAll.class.getName()); diff --git a/core/src/main/java/org/springframework/security/access/event/AuthenticationCredentialsNotFoundEvent.java b/core/src/main/java/org/springframework/security/access/event/AuthenticationCredentialsNotFoundEvent.java index daae07eec98..8d7107ed5bb 100644 --- a/core/src/main/java/org/springframework/security/access/event/AuthenticationCredentialsNotFoundEvent.java +++ b/core/src/main/java/org/springframework/security/access/event/AuthenticationCredentialsNotFoundEvent.java @@ -32,6 +32,7 @@ * instead. */ @Deprecated +@SuppressWarnings("serial") public class AuthenticationCredentialsNotFoundEvent extends AbstractAuthorizationEvent { private final AuthenticationCredentialsNotFoundException credentialsNotFoundException; diff --git a/core/src/main/java/org/springframework/security/access/event/AuthorizationFailureEvent.java b/core/src/main/java/org/springframework/security/access/event/AuthorizationFailureEvent.java index eac534ba6dd..fba28adf0bb 100644 --- a/core/src/main/java/org/springframework/security/access/event/AuthorizationFailureEvent.java +++ b/core/src/main/java/org/springframework/security/access/event/AuthorizationFailureEvent.java @@ -39,6 +39,7 @@ * instead */ @Deprecated +@SuppressWarnings("serial") public class AuthorizationFailureEvent extends AbstractAuthorizationEvent { private final AccessDeniedException accessDeniedException; diff --git a/core/src/main/java/org/springframework/security/access/event/AuthorizedEvent.java b/core/src/main/java/org/springframework/security/access/event/AuthorizedEvent.java index 7697dea90df..3ec29ce6a2c 100644 --- a/core/src/main/java/org/springframework/security/access/event/AuthorizedEvent.java +++ b/core/src/main/java/org/springframework/security/access/event/AuthorizedEvent.java @@ -34,6 +34,7 @@ * instead */ @Deprecated +@SuppressWarnings("serial") public class AuthorizedEvent extends AbstractAuthorizationEvent { private final Authentication authentication; diff --git a/core/src/main/java/org/springframework/security/access/event/PublicInvocationEvent.java b/core/src/main/java/org/springframework/security/access/event/PublicInvocationEvent.java index 2aab5dba91e..7289d8a1edd 100644 --- a/core/src/main/java/org/springframework/security/access/event/PublicInvocationEvent.java +++ b/core/src/main/java/org/springframework/security/access/event/PublicInvocationEvent.java @@ -34,6 +34,7 @@ * {@link AuthorizationGrantedEvent#getSource()} to deduce public invocations. */ @Deprecated +@SuppressWarnings("serial") public class PublicInvocationEvent extends AbstractAuthorizationEvent { /** diff --git a/core/src/main/java/org/springframework/security/access/expression/method/PostInvocationExpressionAttribute.java b/core/src/main/java/org/springframework/security/access/expression/method/PostInvocationExpressionAttribute.java index 3dc86cc5a11..8642484a418 100644 --- a/core/src/main/java/org/springframework/security/access/expression/method/PostInvocationExpressionAttribute.java +++ b/core/src/main/java/org/springframework/security/access/expression/method/PostInvocationExpressionAttribute.java @@ -28,6 +28,7 @@ * instead */ @Deprecated +@SuppressWarnings("serial") class PostInvocationExpressionAttribute extends AbstractExpressionBasedMethodConfigAttribute implements PostInvocationAttribute { diff --git a/core/src/main/java/org/springframework/security/access/expression/method/PreInvocationExpressionAttribute.java b/core/src/main/java/org/springframework/security/access/expression/method/PreInvocationExpressionAttribute.java index 26af51a6f1e..41ec280bc77 100644 --- a/core/src/main/java/org/springframework/security/access/expression/method/PreInvocationExpressionAttribute.java +++ b/core/src/main/java/org/springframework/security/access/expression/method/PreInvocationExpressionAttribute.java @@ -28,6 +28,7 @@ * instead */ @Deprecated +@SuppressWarnings("serial") class PreInvocationExpressionAttribute extends AbstractExpressionBasedMethodConfigAttribute implements PreInvocationAttribute { diff --git a/core/src/main/java/org/springframework/security/access/intercept/aopalliance/MethodSecurityMetadataSourceAdvisor.java b/core/src/main/java/org/springframework/security/access/intercept/aopalliance/MethodSecurityMetadataSourceAdvisor.java index 4bc3d19b5bf..58174d9d1ae 100644 --- a/core/src/main/java/org/springframework/security/access/intercept/aopalliance/MethodSecurityMetadataSourceAdvisor.java +++ b/core/src/main/java/org/springframework/security/access/intercept/aopalliance/MethodSecurityMetadataSourceAdvisor.java @@ -54,6 +54,7 @@ * @deprecated Use {@link EnableMethodSecurity} or publish interceptors directly */ @Deprecated +@SuppressWarnings("serial") public class MethodSecurityMetadataSourceAdvisor extends AbstractPointcutAdvisor implements BeanFactoryAware { private transient MethodSecurityMetadataSource attributeSource; diff --git a/core/src/main/java/org/springframework/security/authentication/AccountExpiredException.java b/core/src/main/java/org/springframework/security/authentication/AccountExpiredException.java index e8ef659882e..1193bf52364 100644 --- a/core/src/main/java/org/springframework/security/authentication/AccountExpiredException.java +++ b/core/src/main/java/org/springframework/security/authentication/AccountExpiredException.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication; +import java.io.Serial; + /** * Thrown if an authentication request is rejected because the account has expired. Makes * no assertion as to whether or not the credentials were valid. @@ -24,6 +26,9 @@ */ public class AccountExpiredException extends AccountStatusException { + @Serial + private static final long serialVersionUID = 3732869526329993353L; + /** * Constructs a AccountExpiredException with the specified message. * @param msg the detail message diff --git a/core/src/main/java/org/springframework/security/authentication/AuthenticationCredentialsNotFoundException.java b/core/src/main/java/org/springframework/security/authentication/AuthenticationCredentialsNotFoundException.java index 91b5d616d88..0ed92018e69 100644 --- a/core/src/main/java/org/springframework/security/authentication/AuthenticationCredentialsNotFoundException.java +++ b/core/src/main/java/org/springframework/security/authentication/AuthenticationCredentialsNotFoundException.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication; +import java.io.Serial; + import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -28,6 +30,9 @@ */ public class AuthenticationCredentialsNotFoundException extends AuthenticationException { + @Serial + private static final long serialVersionUID = 4153580041526791384L; + /** * Constructs an AuthenticationCredentialsNotFoundException with the * specified message. diff --git a/core/src/main/java/org/springframework/security/authentication/AuthenticationServiceException.java b/core/src/main/java/org/springframework/security/authentication/AuthenticationServiceException.java index 69d7233bdf9..3bd076dfd86 100644 --- a/core/src/main/java/org/springframework/security/authentication/AuthenticationServiceException.java +++ b/core/src/main/java/org/springframework/security/authentication/AuthenticationServiceException.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; /** @@ -29,6 +31,9 @@ */ public class AuthenticationServiceException extends AuthenticationException { + @Serial + private static final long serialVersionUID = -1591626195291329340L; + /** * Constructs an AuthenticationServiceException with the specified * message. diff --git a/core/src/main/java/org/springframework/security/authentication/BadCredentialsException.java b/core/src/main/java/org/springframework/security/authentication/BadCredentialsException.java index e202ef7b5a1..bc759f5f7a3 100644 --- a/core/src/main/java/org/springframework/security/authentication/BadCredentialsException.java +++ b/core/src/main/java/org/springframework/security/authentication/BadCredentialsException.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; /** @@ -26,6 +28,9 @@ */ public class BadCredentialsException extends AuthenticationException { + @Serial + private static final long serialVersionUID = 2742216069043066973L; + /** * Constructs a BadCredentialsException with the specified message. * @param msg the detail message diff --git a/core/src/main/java/org/springframework/security/authentication/CredentialsExpiredException.java b/core/src/main/java/org/springframework/security/authentication/CredentialsExpiredException.java index 8e532169aed..04194177633 100644 --- a/core/src/main/java/org/springframework/security/authentication/CredentialsExpiredException.java +++ b/core/src/main/java/org/springframework/security/authentication/CredentialsExpiredException.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication; +import java.io.Serial; + /** * Thrown if an authentication request is rejected because the account's credentials have * expired. Makes no assertion as to whether or not the credentials were valid. @@ -24,6 +26,9 @@ */ public class CredentialsExpiredException extends AccountStatusException { + @Serial + private static final long serialVersionUID = -3306615738048904753L; + /** * Constructs a CredentialsExpiredException with the specified message. * @param msg the detail message diff --git a/core/src/main/java/org/springframework/security/authentication/DisabledException.java b/core/src/main/java/org/springframework/security/authentication/DisabledException.java index 31a75ce0cc8..fba17185901 100644 --- a/core/src/main/java/org/springframework/security/authentication/DisabledException.java +++ b/core/src/main/java/org/springframework/security/authentication/DisabledException.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication; +import java.io.Serial; + /** * Thrown if an authentication request is rejected because the account is disabled. Makes * no assertion as to whether or not the credentials were valid. @@ -24,6 +26,9 @@ */ public class DisabledException extends AccountStatusException { + @Serial + private static final long serialVersionUID = 2295984593872502361L; + /** * Constructs a DisabledException with the specified message. * @param msg the detail message diff --git a/core/src/main/java/org/springframework/security/authentication/InsufficientAuthenticationException.java b/core/src/main/java/org/springframework/security/authentication/InsufficientAuthenticationException.java index 0e072b527a1..f4759349277 100644 --- a/core/src/main/java/org/springframework/security/authentication/InsufficientAuthenticationException.java +++ b/core/src/main/java/org/springframework/security/authentication/InsufficientAuthenticationException.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; /** @@ -33,6 +35,9 @@ */ public class InsufficientAuthenticationException extends AuthenticationException { + @Serial + private static final long serialVersionUID = -5514084346181236128L; + /** * Constructs an InsufficientAuthenticationException with the specified * message. diff --git a/core/src/main/java/org/springframework/security/authentication/InternalAuthenticationServiceException.java b/core/src/main/java/org/springframework/security/authentication/InternalAuthenticationServiceException.java index 3037ebaaf08..de59b2d5efa 100644 --- a/core/src/main/java/org/springframework/security/authentication/InternalAuthenticationServiceException.java +++ b/core/src/main/java/org/springframework/security/authentication/InternalAuthenticationServiceException.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication; +import java.io.Serial; + /** *

* Thrown if an authentication request could not be processed due to a system problem that @@ -37,6 +39,9 @@ */ public class InternalAuthenticationServiceException extends AuthenticationServiceException { + @Serial + private static final long serialVersionUID = -6029644854192497840L; + public InternalAuthenticationServiceException(String message, Throwable cause) { super(message, cause); } diff --git a/core/src/main/java/org/springframework/security/authentication/LockedException.java b/core/src/main/java/org/springframework/security/authentication/LockedException.java index 9b2272b08fd..5262fdb52e4 100644 --- a/core/src/main/java/org/springframework/security/authentication/LockedException.java +++ b/core/src/main/java/org/springframework/security/authentication/LockedException.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication; +import java.io.Serial; + /** * Thrown if an authentication request is rejected because the account is locked. Makes no * assertion as to whether or not the credentials were valid. @@ -24,6 +26,9 @@ */ public class LockedException extends AccountStatusException { + @Serial + private static final long serialVersionUID = 548864198455046567L; + /** * Constructs a LockedException with the specified message. * @param msg the detail message. diff --git a/core/src/main/java/org/springframework/security/authentication/ProviderNotFoundException.java b/core/src/main/java/org/springframework/security/authentication/ProviderNotFoundException.java index 629a28e8c8c..870a6ea1f83 100644 --- a/core/src/main/java/org/springframework/security/authentication/ProviderNotFoundException.java +++ b/core/src/main/java/org/springframework/security/authentication/ProviderNotFoundException.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; /** @@ -27,6 +29,9 @@ */ public class ProviderNotFoundException extends AuthenticationException { + @Serial + private static final long serialVersionUID = 8107665253214447614L; + /** * Constructs a ProviderNotFoundException with the specified message. * @param msg the detail message diff --git a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureBadCredentialsEvent.java b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureBadCredentialsEvent.java index 796690b0e61..6c80a3e883b 100644 --- a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureBadCredentialsEvent.java +++ b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureBadCredentialsEvent.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.event; +import java.io.Serial; + import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -27,6 +29,9 @@ */ public class AuthenticationFailureBadCredentialsEvent extends AbstractAuthenticationFailureEvent { + @Serial + private static final long serialVersionUID = -5245144711561130379L; + public AuthenticationFailureBadCredentialsEvent(Authentication authentication, AuthenticationException exception) { super(authentication, exception); } diff --git a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureCredentialsExpiredEvent.java b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureCredentialsExpiredEvent.java index 57f218a239e..2849ba03714 100644 --- a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureCredentialsExpiredEvent.java +++ b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureCredentialsExpiredEvent.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.event; +import java.io.Serial; + import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -27,6 +29,9 @@ */ public class AuthenticationFailureCredentialsExpiredEvent extends AbstractAuthenticationFailureEvent { + @Serial + private static final long serialVersionUID = -7595086332769705203L; + public AuthenticationFailureCredentialsExpiredEvent(Authentication authentication, AuthenticationException exception) { super(authentication, exception); diff --git a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureDisabledEvent.java b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureDisabledEvent.java index 3a4604354f4..79c0fd479fc 100644 --- a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureDisabledEvent.java +++ b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureDisabledEvent.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.event; +import java.io.Serial; + import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -27,6 +29,9 @@ */ public class AuthenticationFailureDisabledEvent extends AbstractAuthenticationFailureEvent { + @Serial + private static final long serialVersionUID = 8037552364666766279L; + public AuthenticationFailureDisabledEvent(Authentication authentication, AuthenticationException exception) { super(authentication, exception); } diff --git a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureExpiredEvent.java b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureExpiredEvent.java index 086e16cb378..a1f680dc5d9 100644 --- a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureExpiredEvent.java +++ b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureExpiredEvent.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.event; +import java.io.Serial; + import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -27,6 +29,9 @@ */ public class AuthenticationFailureExpiredEvent extends AbstractAuthenticationFailureEvent { + @Serial + private static final long serialVersionUID = -8437264795214121718L; + public AuthenticationFailureExpiredEvent(Authentication authentication, AuthenticationException exception) { super(authentication, exception); } diff --git a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureLockedEvent.java b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureLockedEvent.java index 544964cdec4..5cc0702909a 100644 --- a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureLockedEvent.java +++ b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureLockedEvent.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.event; +import java.io.Serial; + import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -27,6 +29,9 @@ */ public class AuthenticationFailureLockedEvent extends AbstractAuthenticationFailureEvent { + @Serial + private static final long serialVersionUID = -5126110096093568463L; + public AuthenticationFailureLockedEvent(Authentication authentication, AuthenticationException exception) { super(authentication, exception); } diff --git a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureProviderNotFoundEvent.java b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureProviderNotFoundEvent.java index 1a1cf7c87ee..ee4f5538e26 100644 --- a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureProviderNotFoundEvent.java +++ b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureProviderNotFoundEvent.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.event; +import java.io.Serial; + import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -27,6 +29,9 @@ */ public class AuthenticationFailureProviderNotFoundEvent extends AbstractAuthenticationFailureEvent { + @Serial + private static final long serialVersionUID = 9122219669183263487L; + public AuthenticationFailureProviderNotFoundEvent(Authentication authentication, AuthenticationException exception) { super(authentication, exception); diff --git a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureProxyUntrustedEvent.java b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureProxyUntrustedEvent.java index 772774d3f18..31617e6caa0 100644 --- a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureProxyUntrustedEvent.java +++ b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureProxyUntrustedEvent.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.event; +import java.io.Serial; + import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -27,6 +29,9 @@ */ public class AuthenticationFailureProxyUntrustedEvent extends AbstractAuthenticationFailureEvent { + @Serial + private static final long serialVersionUID = 1801476426012753252L; + public AuthenticationFailureProxyUntrustedEvent(Authentication authentication, AuthenticationException exception) { super(authentication, exception); } diff --git a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureServiceExceptionEvent.java b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureServiceExceptionEvent.java index 167d5fae3b3..d84f38625e3 100644 --- a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureServiceExceptionEvent.java +++ b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationFailureServiceExceptionEvent.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.event; +import java.io.Serial; + import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -27,6 +29,9 @@ */ public class AuthenticationFailureServiceExceptionEvent extends AbstractAuthenticationFailureEvent { + @Serial + private static final long serialVersionUID = 5580062757249390756L; + public AuthenticationFailureServiceExceptionEvent(Authentication authentication, AuthenticationException exception) { super(authentication, exception); diff --git a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationSuccessEvent.java b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationSuccessEvent.java index 5b3b9bcd24b..5b18199a6c9 100644 --- a/core/src/main/java/org/springframework/security/authentication/event/AuthenticationSuccessEvent.java +++ b/core/src/main/java/org/springframework/security/authentication/event/AuthenticationSuccessEvent.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.event; +import java.io.Serial; + import org.springframework.security.core.Authentication; /** @@ -25,6 +27,9 @@ */ public class AuthenticationSuccessEvent extends AbstractAuthenticationEvent { + @Serial + private static final long serialVersionUID = 2537206344128673963L; + public AuthenticationSuccessEvent(Authentication authentication) { super(authentication); } diff --git a/core/src/main/java/org/springframework/security/authentication/event/InteractiveAuthenticationSuccessEvent.java b/core/src/main/java/org/springframework/security/authentication/event/InteractiveAuthenticationSuccessEvent.java index c93d2a9165d..eac89b4eafd 100644 --- a/core/src/main/java/org/springframework/security/authentication/event/InteractiveAuthenticationSuccessEvent.java +++ b/core/src/main/java/org/springframework/security/authentication/event/InteractiveAuthenticationSuccessEvent.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.event; +import java.io.Serial; + import org.springframework.security.core.Authentication; import org.springframework.util.Assert; @@ -34,6 +36,9 @@ */ public class InteractiveAuthenticationSuccessEvent extends AbstractAuthenticationEvent { + @Serial + private static final long serialVersionUID = -1990271553478571709L; + private final Class generatedBy; public InteractiveAuthenticationSuccessEvent(Authentication authentication, Class generatedBy) { diff --git a/core/src/main/java/org/springframework/security/authentication/event/LogoutSuccessEvent.java b/core/src/main/java/org/springframework/security/authentication/event/LogoutSuccessEvent.java index 094d0a332d7..1ea77c2a21d 100644 --- a/core/src/main/java/org/springframework/security/authentication/event/LogoutSuccessEvent.java +++ b/core/src/main/java/org/springframework/security/authentication/event/LogoutSuccessEvent.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,8 @@ package org.springframework.security.authentication.event; +import java.io.Serial; + import org.springframework.security.core.Authentication; /** @@ -26,6 +28,9 @@ */ public class LogoutSuccessEvent extends AbstractAuthenticationEvent { + @Serial + private static final long serialVersionUID = 5112491795571632311L; + public LogoutSuccessEvent(Authentication authentication) { super(authentication); } diff --git a/core/src/main/java/org/springframework/security/authentication/jaas/event/JaasAuthenticationFailedEvent.java b/core/src/main/java/org/springframework/security/authentication/jaas/event/JaasAuthenticationFailedEvent.java index 4b70d779509..c3b6d427bda 100644 --- a/core/src/main/java/org/springframework/security/authentication/jaas/event/JaasAuthenticationFailedEvent.java +++ b/core/src/main/java/org/springframework/security/authentication/jaas/event/JaasAuthenticationFailedEvent.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.jaas.event; +import java.io.Serial; + import org.springframework.security.core.Authentication; /** @@ -26,6 +28,9 @@ */ public class JaasAuthenticationFailedEvent extends JaasAuthenticationEvent { + @Serial + private static final long serialVersionUID = -240510538971925002L; + private final Exception exception; public JaasAuthenticationFailedEvent(Authentication auth, Exception exception) { diff --git a/core/src/main/java/org/springframework/security/authentication/jaas/event/JaasAuthenticationSuccessEvent.java b/core/src/main/java/org/springframework/security/authentication/jaas/event/JaasAuthenticationSuccessEvent.java index 0afa2b882b9..ec654a2a9fc 100644 --- a/core/src/main/java/org/springframework/security/authentication/jaas/event/JaasAuthenticationSuccessEvent.java +++ b/core/src/main/java/org/springframework/security/authentication/jaas/event/JaasAuthenticationSuccessEvent.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.jaas.event; +import java.io.Serial; + import org.springframework.security.core.Authentication; /** @@ -28,6 +30,9 @@ */ public class JaasAuthenticationSuccessEvent extends JaasAuthenticationEvent { + @Serial + private static final long serialVersionUID = 2236826715750256181L; + public JaasAuthenticationSuccessEvent(Authentication auth) { super(auth); } diff --git a/core/src/main/java/org/springframework/security/authentication/ott/GenerateOneTimeTokenRequest.java b/core/src/main/java/org/springframework/security/authentication/ott/GenerateOneTimeTokenRequest.java index c9a023ef832..b03e65dd188 100644 --- a/core/src/main/java/org/springframework/security/authentication/ott/GenerateOneTimeTokenRequest.java +++ b/core/src/main/java/org/springframework/security/authentication/ott/GenerateOneTimeTokenRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,8 @@ package org.springframework.security.authentication.ott; +import java.time.Duration; + import org.springframework.util.Assert; /** @@ -26,15 +28,29 @@ */ public class GenerateOneTimeTokenRequest { + private static final Duration DEFAULT_EXPIRES_IN = Duration.ofMinutes(5); + private final String username; + private final Duration expiresIn; + public GenerateOneTimeTokenRequest(String username) { + this(username, DEFAULT_EXPIRES_IN); + } + + public GenerateOneTimeTokenRequest(String username, Duration expiresIn) { Assert.hasText(username, "username cannot be empty"); + Assert.notNull(expiresIn, "expiresIn cannot be null"); this.username = username; + this.expiresIn = expiresIn; } public String getUsername() { return this.username; } + public Duration getExpiresIn() { + return this.expiresIn; + } + } diff --git a/core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.java b/core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.java index 6365bdb5f1d..0d679617946 100644 --- a/core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.java +++ b/core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -44,8 +44,8 @@ public final class InMemoryOneTimeTokenService implements OneTimeTokenService { @NonNull public OneTimeToken generate(GenerateOneTimeTokenRequest request) { String token = UUID.randomUUID().toString(); - Instant fiveMinutesFromNow = this.clock.instant().plusSeconds(300); - OneTimeToken ott = new DefaultOneTimeToken(token, request.getUsername(), fiveMinutesFromNow); + Instant expiresAt = this.clock.instant().plus(request.getExpiresIn()); + OneTimeToken ott = new DefaultOneTimeToken(token, request.getUsername(), expiresAt); this.oneTimeTokenByToken.put(token, ott); cleanExpiredTokensIfNeeded(); return ott; diff --git a/core/src/main/java/org/springframework/security/authentication/ott/InvalidOneTimeTokenException.java b/core/src/main/java/org/springframework/security/authentication/ott/InvalidOneTimeTokenException.java index 03289f12b78..8ee8199cd09 100644 --- a/core/src/main/java/org/springframework/security/authentication/ott/InvalidOneTimeTokenException.java +++ b/core/src/main/java/org/springframework/security/authentication/ott/InvalidOneTimeTokenException.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.ott; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; /** @@ -26,6 +28,9 @@ */ public class InvalidOneTimeTokenException extends AuthenticationException { + @Serial + private static final long serialVersionUID = -3651018515682919943L; + public InvalidOneTimeTokenException(String msg) { super(msg); } diff --git a/core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java b/core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java index 014541373ad..a58665bd1e5 100644 --- a/core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java +++ b/core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -21,7 +21,6 @@ import java.sql.Timestamp; import java.sql.Types; import java.time.Clock; -import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -132,8 +131,8 @@ public void setCleanupCron(String cleanupCron) { public OneTimeToken generate(GenerateOneTimeTokenRequest request) { Assert.notNull(request, "generateOneTimeTokenRequest cannot be null"); String token = UUID.randomUUID().toString(); - Instant fiveMinutesFromNow = this.clock.instant().plus(Duration.ofMinutes(5)); - OneTimeToken oneTimeToken = new DefaultOneTimeToken(token, request.getUsername(), fiveMinutesFromNow); + Instant expiresAt = this.clock.instant().plus(request.getExpiresIn()); + OneTimeToken oneTimeToken = new DefaultOneTimeToken(token, request.getUsername(), expiresAt); insertOneTimeToken(oneTimeToken); return oneTimeToken; } @@ -190,7 +189,8 @@ private ThreadPoolTaskScheduler createTaskScheduler(String cleanupCron) { } public void cleanupExpiredTokens() { - List parameters = List.of(new SqlParameterValue(Types.TIMESTAMP, Instant.now())); + List parameters = List + .of(new SqlParameterValue(Types.TIMESTAMP, Timestamp.from(Instant.now()))); PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray()); int deletedCount = this.jdbcOperations.update(DELETE_ONE_TIME_TOKENS_BY_EXPIRY_TIME_QUERY, pss); if (this.logger.isDebugEnabled()) { diff --git a/core/src/main/java/org/springframework/security/authentication/password/CompromisedPasswordException.java b/core/src/main/java/org/springframework/security/authentication/password/CompromisedPasswordException.java index 672876164fb..04d042b96af 100644 --- a/core/src/main/java/org/springframework/security/authentication/password/CompromisedPasswordException.java +++ b/core/src/main/java/org/springframework/security/authentication/password/CompromisedPasswordException.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.password; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; /** @@ -26,6 +28,9 @@ */ public class CompromisedPasswordException extends AuthenticationException { + @Serial + private static final long serialVersionUID = -885858958297842864L; + public CompromisedPasswordException(String message) { super(message); } diff --git a/core/src/main/java/org/springframework/security/authorization/AuthorizationDeniedException.java b/core/src/main/java/org/springframework/security/authorization/AuthorizationDeniedException.java index fdcb1e70aa6..63385e1cbd1 100644 --- a/core/src/main/java/org/springframework/security/authorization/AuthorizationDeniedException.java +++ b/core/src/main/java/org/springframework/security/authorization/AuthorizationDeniedException.java @@ -16,6 +16,8 @@ package org.springframework.security.authorization; +import java.io.Serial; + import org.springframework.security.access.AccessDeniedException; import org.springframework.util.Assert; @@ -27,6 +29,9 @@ */ public class AuthorizationDeniedException extends AccessDeniedException implements AuthorizationResult { + @Serial + private static final long serialVersionUID = 3227305845919610459L; + private final AuthorizationResult result; public AuthorizationDeniedException(String msg, AuthorizationResult authorizationResult) { diff --git a/core/src/main/java/org/springframework/security/authorization/event/AuthorizationDeniedEvent.java b/core/src/main/java/org/springframework/security/authorization/event/AuthorizationDeniedEvent.java index 94e7d6a2312..05d0fcdbc5d 100644 --- a/core/src/main/java/org/springframework/security/authorization/event/AuthorizationDeniedEvent.java +++ b/core/src/main/java/org/springframework/security/authorization/event/AuthorizationDeniedEvent.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 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. @@ -30,6 +30,7 @@ * @author Josh Cummings * @since 5.7 */ +@SuppressWarnings("serial") public class AuthorizationDeniedEvent extends AuthorizationEvent { /** diff --git a/core/src/main/java/org/springframework/security/authorization/event/AuthorizationEvent.java b/core/src/main/java/org/springframework/security/authorization/event/AuthorizationEvent.java index a848dff4917..d4bce6b586d 100644 --- a/core/src/main/java/org/springframework/security/authorization/event/AuthorizationEvent.java +++ b/core/src/main/java/org/springframework/security/authorization/event/AuthorizationEvent.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,7 @@ package org.springframework.security.authorization.event; +import java.io.Serial; import java.util.function.Supplier; import org.springframework.context.ApplicationEvent; @@ -31,8 +32,12 @@ * @author Josh Cummings * @since 5.8 */ +@SuppressWarnings("serial") public class AuthorizationEvent extends ApplicationEvent { + @Serial + private static final long serialVersionUID = -9053927371500241295L; + private final Supplier authentication; private final AuthorizationResult result; diff --git a/core/src/main/java/org/springframework/security/authorization/event/AuthorizationGrantedEvent.java b/core/src/main/java/org/springframework/security/authorization/event/AuthorizationGrantedEvent.java index 693bc7e4a76..9cde3519303 100644 --- a/core/src/main/java/org/springframework/security/authorization/event/AuthorizationGrantedEvent.java +++ b/core/src/main/java/org/springframework/security/authorization/event/AuthorizationGrantedEvent.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,7 @@ package org.springframework.security.authorization.event; +import java.io.Serial; import java.util.function.Supplier; import org.springframework.context.ApplicationEvent; @@ -30,8 +31,12 @@ * @author Josh Cummings * @since 5.7 */ +@SuppressWarnings("serial") public class AuthorizationGrantedEvent extends AuthorizationEvent { + @Serial + private static final long serialVersionUID = -8690818228055810339L; + /** * @deprecated please use a constructor that takes an * {@link org.springframework.security.authorization.AuthorizationResult} diff --git a/core/src/main/java/org/springframework/security/core/ComparableVersion.java b/core/src/main/java/org/springframework/security/core/ComparableVersion.java index 347644734c3..88708cecd42 100644 --- a/core/src/main/java/org/springframework/security/core/ComparableVersion.java +++ b/core/src/main/java/org/springframework/security/core/ComparableVersion.java @@ -405,6 +405,7 @@ public String toString() { * Represents a version list item. This class is used both for the global item list * and for sub-lists (which start with '-(number)' in the version specification). */ + @SuppressWarnings("serial") private static class ListItem extends ArrayList implements Item { @Override @@ -414,7 +415,7 @@ public int getType() { @Override public boolean isNull() { - return (size() == 0); + return isEmpty(); } void normalize() { @@ -434,7 +435,7 @@ else if (!(lastItem instanceof ListItem)) { @Override public int compareTo(Item item) { if (item == null) { - if (size() == 0) { + if (isEmpty()) { return 0; // 1-0 = 1- (normalize) = 1 } Item first = get(0); diff --git a/core/src/main/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScanner.java b/core/src/main/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScanner.java index 5093e1bd1d1..e3f5c9297c6 100644 --- a/core/src/main/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScanner.java +++ b/core/src/main/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScanner.java @@ -18,6 +18,7 @@ import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Executable; import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.ArrayList; @@ -83,6 +84,7 @@ * * @param the annotation to search for and synthesize * @author Josh Cummings + * @author DingHao * @since 6.4 */ final class UniqueSecurityAnnotationScanner extends AbstractSecurityAnnotationScanner { @@ -107,7 +109,7 @@ final class UniqueSecurityAnnotationScanner extends Abstra MergedAnnotation merge(AnnotatedElement element, Class targetClass) { if (element instanceof Parameter parameter) { return this.uniqueParameterAnnotationCache.computeIfAbsent(parameter, (p) -> { - List> annotations = findDirectAnnotations(p); + List> annotations = findParameterAnnotations(p); return requireUnique(p, annotations); }); } @@ -137,6 +139,56 @@ private MergedAnnotation requireUnique(AnnotatedElement element, List> findParameterAnnotations(Parameter current) { + List> directAnnotations = findDirectAnnotations(current); + if (!directAnnotations.isEmpty()) { + return directAnnotations; + } + Executable executable = current.getDeclaringExecutable(); + if (executable instanceof Method method) { + Class clazz = method.getDeclaringClass(); + Set> visited = new HashSet<>(); + while (clazz != null && clazz != Object.class) { + directAnnotations = findClosestParameterAnnotations(method, clazz, current, visited); + if (!directAnnotations.isEmpty()) { + return directAnnotations; + } + clazz = clazz.getSuperclass(); + } + } + return Collections.emptyList(); + } + + private List> findClosestParameterAnnotations(Method method, Class clazz, Parameter current, + Set> visited) { + if (!visited.add(clazz)) { + return Collections.emptyList(); + } + List> annotations = new ArrayList<>(findDirectParameterAnnotations(method, clazz, current)); + for (Class ifc : clazz.getInterfaces()) { + annotations.addAll(findClosestParameterAnnotations(method, ifc, current, visited)); + } + return annotations; + } + + private List> findDirectParameterAnnotations(Method method, Class clazz, Parameter current) { + try { + Method methodToUse = clazz.getDeclaredMethod(method.getName(), method.getParameterTypes()); + for (Parameter parameter : methodToUse.getParameters()) { + if (parameter.getName().equals(current.getName())) { + List> directAnnotations = findDirectAnnotations(parameter); + if (!directAnnotations.isEmpty()) { + return directAnnotations; + } + } + } + } + catch (NoSuchMethodException ex) { + // move on + } + return Collections.emptyList(); + } + private List> findMethodAnnotations(Method method, Class targetClass) { // The method may be on an interface, but we need attributes from the target // class. diff --git a/core/src/main/java/org/springframework/security/core/context/SecurityContextChangedEvent.java b/core/src/main/java/org/springframework/security/core/context/SecurityContextChangedEvent.java index c14125c475e..ac38804cff5 100644 --- a/core/src/main/java/org/springframework/security/core/context/SecurityContextChangedEvent.java +++ b/core/src/main/java/org/springframework/security/core/context/SecurityContextChangedEvent.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2025 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. @@ -26,6 +26,7 @@ * @author Josh Cummings * @since 5.6 */ +@SuppressWarnings("serial") public class SecurityContextChangedEvent extends ApplicationEvent { public static final Supplier NO_CONTEXT = () -> null; diff --git a/core/src/main/java/org/springframework/security/core/context/TransientSecurityContext.java b/core/src/main/java/org/springframework/security/core/context/TransientSecurityContext.java index 0089ae455d0..7a4b3d30fe4 100644 --- a/core/src/main/java/org/springframework/security/core/context/TransientSecurityContext.java +++ b/core/src/main/java/org/springframework/security/core/context/TransientSecurityContext.java @@ -16,6 +16,8 @@ package org.springframework.security.core.context; +import java.io.Serial; + import org.springframework.security.core.Authentication; import org.springframework.security.core.Transient; @@ -30,6 +32,9 @@ @Transient public class TransientSecurityContext extends SecurityContextImpl { + @Serial + private static final long serialVersionUID = -7925492364422193347L; + public TransientSecurityContext() { } diff --git a/core/src/main/java/org/springframework/security/core/session/AbstractSessionEvent.java b/core/src/main/java/org/springframework/security/core/session/AbstractSessionEvent.java index 4c8c20da5ce..a02ad09eb6f 100644 --- a/core/src/main/java/org/springframework/security/core/session/AbstractSessionEvent.java +++ b/core/src/main/java/org/springframework/security/core/session/AbstractSessionEvent.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,8 @@ package org.springframework.security.core.session; +import java.io.Serial; + import org.springframework.context.ApplicationEvent; /** @@ -26,6 +28,9 @@ */ public class AbstractSessionEvent extends ApplicationEvent { + @Serial + private static final long serialVersionUID = -6878881229287231479L; + public AbstractSessionEvent(Object source) { super(source); } diff --git a/core/src/main/java/org/springframework/security/core/userdetails/UsernameNotFoundException.java b/core/src/main/java/org/springframework/security/core/userdetails/UsernameNotFoundException.java index 22c3c1d8e5f..d1d969dc262 100644 --- a/core/src/main/java/org/springframework/security/core/userdetails/UsernameNotFoundException.java +++ b/core/src/main/java/org/springframework/security/core/userdetails/UsernameNotFoundException.java @@ -16,6 +16,8 @@ package org.springframework.security.core.userdetails; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; /** @@ -26,6 +28,9 @@ */ public class UsernameNotFoundException extends AuthenticationException { + @Serial + private static final long serialVersionUID = 1410688585992297006L; + /** * Constructs a UsernameNotFoundException with the specified message. * @param msg the detail message. diff --git a/core/src/main/java/org/springframework/security/jackson2/SecurityJackson2Modules.java b/core/src/main/java/org/springframework/security/jackson2/SecurityJackson2Modules.java index 974910bc912..5db1b2e5382 100644 --- a/core/src/main/java/org/springframework/security/jackson2/SecurityJackson2Modules.java +++ b/core/src/main/java/org/springframework/security/jackson2/SecurityJackson2Modules.java @@ -201,6 +201,7 @@ private static TypeResolverBuilder createAllowlis * * @author Rob Winch */ + @SuppressWarnings("serial") static class AllowlistTypeResolverBuilder extends ObjectMapper.DefaultTypeResolverBuilder { AllowlistTypeResolverBuilder(ObjectMapper.DefaultTyping defaultTyping) { diff --git a/core/src/test/java/org/springframework/security/access/annotation/BusinessServiceImpl.java b/core/src/test/java/org/springframework/security/access/annotation/BusinessServiceImpl.java index 0e732bf480c..587e795f5a4 100644 --- a/core/src/test/java/org/springframework/security/access/annotation/BusinessServiceImpl.java +++ b/core/src/test/java/org/springframework/security/access/annotation/BusinessServiceImpl.java @@ -16,6 +16,7 @@ package org.springframework.security.access.annotation; +import java.io.Serial; import java.util.ArrayList; import java.util.List; @@ -24,6 +25,9 @@ */ public class BusinessServiceImpl implements BusinessService { + @Serial + private static final long serialVersionUID = -4249394090237180795L; + @Override @Secured({ "ROLE_USER" }) public void someUserMethod1() { diff --git a/core/src/test/java/org/springframework/security/access/annotation/ExpressionProtectedBusinessServiceImpl.java b/core/src/test/java/org/springframework/security/access/annotation/ExpressionProtectedBusinessServiceImpl.java index 9d1b066d013..1ca226709b9 100644 --- a/core/src/test/java/org/springframework/security/access/annotation/ExpressionProtectedBusinessServiceImpl.java +++ b/core/src/test/java/org/springframework/security/access/annotation/ExpressionProtectedBusinessServiceImpl.java @@ -16,6 +16,7 @@ package org.springframework.security.access.annotation; +import java.io.Serial; import java.util.ArrayList; import java.util.List; @@ -25,6 +26,9 @@ public class ExpressionProtectedBusinessServiceImpl implements BusinessService { + @Serial + private static final long serialVersionUID = -3320014879907436606L; + @Override public void someAdminMethod() { } diff --git a/core/src/test/java/org/springframework/security/access/annotation/Jsr250BusinessServiceImpl.java b/core/src/test/java/org/springframework/security/access/annotation/Jsr250BusinessServiceImpl.java index b19b19bfcfa..6d9f34ac615 100644 --- a/core/src/test/java/org/springframework/security/access/annotation/Jsr250BusinessServiceImpl.java +++ b/core/src/test/java/org/springframework/security/access/annotation/Jsr250BusinessServiceImpl.java @@ -16,6 +16,7 @@ package org.springframework.security.access.annotation; +import java.io.Serial; import java.util.ArrayList; import java.util.List; @@ -28,6 +29,9 @@ @PermitAll public class Jsr250BusinessServiceImpl implements BusinessService { + @Serial + private static final long serialVersionUID = -7550211450382764339L; + @Override @RolesAllowed("ROLE_USER") public void someUserMethod1() { diff --git a/core/src/test/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScannerTests.java b/core/src/test/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScannerTests.java index b1a7a779aac..976e0879ab1 100644 --- a/core/src/test/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScannerTests.java +++ b/core/src/test/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScannerTests.java @@ -16,7 +16,13 @@ package org.springframework.security.core.annotation; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.List; import org.junit.jupiter.api.Test; @@ -34,6 +40,9 @@ public class UniqueSecurityAnnotationScannerTests { private UniqueSecurityAnnotationScanner scanner = new UniqueSecurityAnnotationScanner<>( PreAuthorize.class); + private UniqueSecurityAnnotationScanner parameterScanner = new UniqueSecurityAnnotationScanner<>( + CustomParameterAnnotation.class); + @Test void scanWhenAnnotationOnInterfaceThenResolves() throws Exception { Method method = AnnotationOnInterface.class.getDeclaredMethod("method"); @@ -251,6 +260,101 @@ void scanWhenClassInheritingAbstractClassNoAnnotationsThenNoAnnotation() throws assertThat(preAuthorize).isNull(); } + @Test + void scanParameterAnnotationWhenAnnotationOnInterface() throws Exception { + Parameter parameter = UserService.class.getDeclaredMethod("add", String.class).getParameters()[0]; + CustomParameterAnnotation customParameterAnnotation = this.parameterScanner.scan(parameter); + assertThat(customParameterAnnotation.value()).isEqualTo("one"); + } + + @Test + void scanParameterAnnotationWhenClassInheritingInterfaceAnnotation() throws Exception { + Parameter parameter = UserServiceImpl.class.getDeclaredMethod("add", String.class).getParameters()[0]; + CustomParameterAnnotation customParameterAnnotation = this.parameterScanner.scan(parameter); + assertThat(customParameterAnnotation.value()).isEqualTo("one"); + } + + @Test + void scanParameterAnnotationWhenClassOverridingMethodOverridingInterface() throws Exception { + Parameter parameter = UserServiceImpl.class.getDeclaredMethod("get", String.class).getParameters()[0]; + CustomParameterAnnotation customParameterAnnotation = this.parameterScanner.scan(parameter); + assertThat(customParameterAnnotation.value()).isEqualTo("five"); + } + + @Test + void scanParameterAnnotationWhenMultipleMethodInheritanceThenException() throws Exception { + Parameter parameter = UserServiceImpl.class.getDeclaredMethod("list", String.class).getParameters()[0]; + assertThatExceptionOfType(AnnotationConfigurationException.class) + .isThrownBy(() -> this.parameterScanner.scan(parameter)); + } + + @Test + void scanParameterAnnotationWhenInterfaceNoAnnotationsThenException() throws Exception { + Parameter parameter = UserServiceImpl.class.getDeclaredMethod("delete", String.class).getParameters()[0]; + assertThatExceptionOfType(AnnotationConfigurationException.class) + .isThrownBy(() -> this.parameterScanner.scan(parameter)); + } + + interface UserService { + + void add(@CustomParameterAnnotation("one") String user); + + List list(@CustomParameterAnnotation("two") String user); + + String get(@CustomParameterAnnotation("three") String user); + + void delete(@CustomParameterAnnotation("five") String user); + + } + + interface OtherUserService { + + List list(@CustomParameterAnnotation("four") String user); + + } + + interface ThirdPartyUserService { + + void delete(@CustomParameterAnnotation("five") String user); + + } + + interface RemoteUserService extends ThirdPartyUserService { + + } + + static class UserServiceImpl implements UserService, OtherUserService, RemoteUserService { + + @Override + public void add(String user) { + + } + + @Override + public List list(String user) { + return List.of(user); + } + + @Override + public String get(@CustomParameterAnnotation("five") String user) { + return user; + } + + @Override + public void delete(String user) { + + } + + } + + @Target({ ElementType.PARAMETER }) + @Retention(RetentionPolicy.RUNTIME) + @interface CustomParameterAnnotation { + + String value(); + + } + @PreAuthorize("one") private interface AnnotationOnInterface { diff --git a/crypto/src/main/java/org/springframework/security/crypto/codec/Base64.java b/crypto/src/main/java/org/springframework/security/crypto/codec/Base64.java index d4afce73c18..b696c0c4bf2 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/codec/Base64.java +++ b/crypto/src/main/java/org/springframework/security/crypto/codec/Base64.java @@ -617,6 +617,7 @@ else if (len < 4) { return out; } + @SuppressWarnings("serial") static class InvalidBase64CharacterException extends IllegalArgumentException { InvalidBase64CharacterException(String message) { diff --git a/docs/modules/ROOT/pages/migration-7/web.adoc b/docs/modules/ROOT/pages/migration-7/web.adoc new file mode 100644 index 00000000000..024d5604494 --- /dev/null +++ b/docs/modules/ROOT/pages/migration-7/web.adoc @@ -0,0 +1,104 @@ += Web Migrations + +== Favor Relative URIs + +When redirecting to a login endpoint, Spring Security has favored absolute URIs in the past. +For example, if you set your login page like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +http + // ... + .formLogin((form) -> form.loginPage("/my-login")) + // ... +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +http { + formLogin { + loginPage = "/my-login" + } +} +---- + +Xml:: ++ +[source,kotlin,role="secondary"] +---- + + + +---- +====== + +then when redirecting to `/my-login` Spring Security would use a `Location:` like the following: + +[source] +---- +302 Found +// ... +Location: https://myapp.example.org/my-login +---- + +However, this is no longer necessary given that the RFC is was based on is now obsolete. + +In Spring Security 7, this is changed to use a relative URI like so: + +[source] +---- +302 Found +// ... +Location: /my-login +---- + +Most applications will not notice a difference. +However, in the event that this change causes problems, you can switch back to the Spring Security 6 behavior by setting the `favorRelativeUrls` value: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +LoginUrlAuthenticationEntryPoint entryPoint = new LoginUrlAuthenticationEntryPoint("/my-login"); +entryPoint.setFavorRelativeUris(false); +http + // ... + .exceptionHandling((exceptions) -> exceptions.authenticaitonEntryPoint(entryPoint)) + // ... +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +LoginUrlAuthenticationEntryPoint entryPoint = LoginUrlAuthenticationEntryPoint("/my-login") +entryPoint.setFavorRelativeUris(false) + +http { + exceptionHandling { + authenticationEntryPoint = entryPoint + } +} +---- + +Xml:: ++ +[source,kotlin,role="secondary"] +---- + + + + + + + +---- +====== diff --git a/docs/modules/ROOT/pages/migration/authentication.adoc b/docs/modules/ROOT/pages/migration/authentication.adoc new file mode 100644 index 00000000000..9c5407ae00a --- /dev/null +++ b/docs/modules/ROOT/pages/migration/authentication.adoc @@ -0,0 +1,68 @@ += Authentication Changes + +== Opaque Token Credentials Will Be Encoded For You + +In order to comply more closely with the Introspection RFC, Spring Security's opaque token support will encode the client id and secret before creating the authorization header. +This change means you will no longer have to encode the client id and secret yourself. + +If your client id or secret contain URL-unsafe characters, then you can prepare yourself for this change by doing the following: + +=== Replace Usage of `introspectionClientCredentials` + +Since Spring Security can now do the encoding for you, replace xref:servlet/oauth2/resource-server/opaque-token.adoc#oauth2resourceserver-opaque-introspectionuri-dsl[using `introspectionClientCredentials`] with publishing the following `@Bean`: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +OpaqueTokenIntrospector introspector() { + return SpringOpaqueTokenIntrospector.withIntrospectionUri(introspectionUri) + .clientId(unencodedClientId).clientSecret(unencodedClientSecret).build(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun introspector(): OpaqueTokenIntrospector { + return SpringOpaqueTokenIntrospector.withIntrospectionUri(introspectionUri) + .clientId(unencodedClientId).clientSecret(unencodedClientSecret).build() +} +---- +====== + +The above will be the default in 7.0. + +If this setting gives you trouble or you cannot apply it for now, you can use the `RestOperations` constructor instead: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +OpaqueTokenIntrospector introspector() { + RestTemplate rest = new RestTemplate(); + rest.addInterceptor(new BasicAuthenticationInterceptor(encodedClientId, encodedClientSecret)); + return new SpringOpaqueTokenIntrospector(introspectionUri, rest); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun introspector(): OpaqueTokenIntrospector { + val rest = RestTemplate() + rest.addInterceptor(BasicAuthenticationInterceptor(encodedClientId, encodedClientSecret)) + return SpringOpaqueTokenIntrospector(introspectionUri, rest) +} +---- +====== diff --git a/docs/modules/ROOT/pages/reactive/oauth2/client/authorization-grants.adoc b/docs/modules/ROOT/pages/reactive/oauth2/client/authorization-grants.adoc index bd002f31e8b..59def321a46 100644 --- a/docs/modules/ROOT/pages/reactive/oauth2/client/authorization-grants.adoc +++ b/docs/modules/ROOT/pages/reactive/oauth2/client/authorization-grants.adoc @@ -79,6 +79,10 @@ If the client is running in an untrusted environment (eg. native application or . `client-secret` is omitted (or empty) . `client-authentication-method` is set to "none" (`ClientAuthenticationMethod.NONE`) +or + +. When `ClientRegistration.clientSettings.requireProofKey` is `true` (in this case `ClientRegistration.authorizationGrantType` must be `authorization_code`) + [TIP] ==== If the OAuth 2.0 Provider supports PKCE for https://tools.ietf.org/html/rfc6749#section-2.1[Confidential Clients], you may (optionally) configure it using `DefaultServerOAuth2AuthorizationRequestResolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce())`. diff --git a/docs/modules/ROOT/pages/reactive/oauth2/client/core.adoc b/docs/modules/ROOT/pages/reactive/oauth2/client/core.adoc index b6eee52585a..e1ca19df49f 100644 --- a/docs/modules/ROOT/pages/reactive/oauth2/client/core.adoc +++ b/docs/modules/ROOT/pages/reactive/oauth2/client/core.adoc @@ -39,6 +39,10 @@ public final class ClientRegistration { } } + + public static final class ClientSettings { + private boolean requireProofKey; // <17> + } } ---- <1> `registrationId`: The ID that uniquely identifies the `ClientRegistration`. @@ -64,6 +68,7 @@ The name may be used in certain scenarios, such as when displaying the name of t <15> `(userInfoEndpoint)authenticationMethod`: The authentication method used when sending the access token to the UserInfo Endpoint. The supported values are *header*, *form* and *query*. <16> `userNameAttributeName`: The name of the attribute returned in the UserInfo Response that references the Name or Identifier of the end-user. +<17> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default. A `ClientRegistration` can be initially configured using discovery of an OpenID Connect Provider's https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig[Configuration endpoint] or an Authorization Server's https://tools.ietf.org/html/rfc8414#section-3[Metadata endpoint]. diff --git a/docs/modules/ROOT/pages/reactive/oauth2/resource-server/opaque-token.adoc b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/opaque-token.adoc index ccdcbf5e9a6..3cbda22e309 100644 --- a/docs/modules/ROOT/pages/reactive/oauth2/resource-server/opaque-token.adoc +++ b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/opaque-token.adoc @@ -273,7 +273,8 @@ Java:: ---- @Bean public ReactiveOpaqueTokenIntrospector introspector() { - return new NimbusReactiveOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret); + return SpringReactiveOpaqueTokenIntrospector.withIntrospectionUri(introspectionUri) + .clientId(clientId).clientSecret(clientSecret).build(); } ---- @@ -283,7 +284,8 @@ Kotlin:: ---- @Bean fun introspector(): ReactiveOpaqueTokenIntrospector { - return NimbusReactiveOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret) + return SpringReactiveOpaqueTokenIntrospector.withIntrospectionUri(introspectionUri) + .clientId(clientId).clientSecret(clientSecret).build() } ---- ====== @@ -411,7 +413,8 @@ Java:: ---- @Bean public ReactiveOpaqueTokenIntrospector introspector() { - return new NimbusReactiveOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret); + return SpringReactiveOpaqueTokenIntrospector.withIntrospectionUri(introspectionUri) + .clientId(clientId).clientSecret(clientSecret).build() } ---- @@ -421,7 +424,8 @@ Kotlin:: ---- @Bean fun introspector(): ReactiveOpaqueTokenIntrospector { - return NimbusReactiveOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret) + return SpringReactiveOpaqueTokenIntrospector.withIntrospectionUri(introspectionUri) + .clientId(clientId).clientSecret(clientSecret).build() } ---- ====== @@ -534,8 +538,9 @@ Java:: [source,java,role="primary"] ---- public class CustomAuthoritiesOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { - private ReactiveOpaqueTokenIntrospector delegate = - new NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret"); + private ReactiveOpaqueTokenIntrospector delegate = SpringReactiveOpaqueTokenIntrospector + .withIntrospectionUri("https://idp.example.org/introspect") + .clientId("client").clientSecret("secret").build(); public Mono introspect(String token) { return this.delegate.introspect(token) @@ -557,7 +562,9 @@ Kotlin:: [source,kotlin,role="secondary"] ---- class CustomAuthoritiesOpaqueTokenIntrospector : ReactiveOpaqueTokenIntrospector { - private val delegate: ReactiveOpaqueTokenIntrospector = NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret") + private val delegate: ReactiveOpaqueTokenIntrospector = SpringReactiveOpaqueTokenIntrospector + .withIntrospectionUri("https://idp.example.org/introspect") + .clientId("client").clientSecret("secret").build() override fun introspect(token: String): Mono { return delegate.introspect(token) .map { principal: OAuth2AuthenticatedPrincipal -> @@ -637,8 +644,9 @@ Java:: [source,java,role="primary"] ---- public class JwtOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { - private ReactiveOpaqueTokenIntrospector delegate = - new NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret"); + private ReactiveOpaqueTokenIntrospector delegate = SpringReactiveOpaqueTokenIntrospector + .withIntrospectionUri("https://idp.example.org/introspect") + .clientId("client").clientSecret("secret").build(); private ReactiveJwtDecoder jwtDecoder = new NimbusReactiveJwtDecoder(new ParseOnlyJWTProcessor()); public Mono introspect(String token) { @@ -664,7 +672,9 @@ Kotlin:: [source,kotlin,role="secondary"] ---- class JwtOpaqueTokenIntrospector : ReactiveOpaqueTokenIntrospector { - private val delegate: ReactiveOpaqueTokenIntrospector = NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret") + private val delegate: ReactiveOpaqueTokenIntrospector = SpringReactiveOpaqueTokenIntrospector + .withIntrospectionUri("https://idp.example.org/introspect") + .clientId("client").clientSecret("secret").build() private val jwtDecoder: ReactiveJwtDecoder = NimbusReactiveJwtDecoder(ParseOnlyJWTProcessor()) override fun introspect(token: String): Mono { return delegate.introspect(token) @@ -731,8 +741,9 @@ Java:: [source,java,role="primary"] ---- public class UserInfoOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { - private final ReactiveOpaqueTokenIntrospector delegate = - new NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret"); + private final ReactiveOpaqueTokenIntrospector delegate = SpringReactiveOpaqueTokenIntrospector + .withIntrospectionUri("https://idp.example.org/introspect") + .clientId("client").clientSecret("secret").build(); private final ReactiveOAuth2UserService oauth2UserService = new DefaultReactiveOAuth2UserService(); @@ -761,7 +772,9 @@ Kotlin:: [source,kotlin,role="secondary"] ---- class UserInfoOpaqueTokenIntrospector : ReactiveOpaqueTokenIntrospector { - private val delegate: ReactiveOpaqueTokenIntrospector = NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret") + private val delegate: ReactiveOpaqueTokenIntrospector = SpringReactiveOpaqueTokenIntrospector + .withIntrospectionUri("https://idp.example.org/introspect") + .clientId("client").clientSecret("secret").build() private val oauth2UserService: ReactiveOAuth2UserService = DefaultReactiveOAuth2UserService() private val repository: ReactiveClientRegistrationRepository? = null @@ -792,8 +805,9 @@ Java:: [source,java,role="primary"] ---- public class UserInfoOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { - private final ReactiveOpaqueTokenIntrospector delegate = - new NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret"); + private final ReactiveOpaqueTokenIntrospector delegate = SpringReactiveOpaqueTokenIntrospector + .withIntrospectionUri("https://idp.example.org/introspect") + .clientId("client").clientSecret("secret").build(); private final WebClient rest = WebClient.create(); @Override @@ -809,7 +823,9 @@ Kotlin:: [source,kotlin,role="secondary"] ---- class UserInfoOpaqueTokenIntrospector : ReactiveOpaqueTokenIntrospector { - private val delegate: ReactiveOpaqueTokenIntrospector = NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret") + private val delegate: ReactiveOpaqueTokenIntrospector = SpringReactiveOpaqueTokenIntrospector + .withIntrospectionUri("https://idp.example.org/introspect") + .clientId("client").clientSecret("secret").build() private val rest: WebClient = WebClient.create() override fun introspect(token: String): Mono { diff --git a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc index 59e48e0986d..d49c2f12db3 100644 --- a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc +++ b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc @@ -2168,6 +2168,9 @@ Allows injection of the ExpiredSessionStrategy instance used by the ConcurrentSe Maps to the `maximumSessions` property of `ConcurrentSessionControlAuthenticationStrategy`. Specify `-1` as the value to support unlimited sessions. +[[nsa-concurrency-control-max-sessions-ref]] +* **max-sessions-ref** +Allows injection of the SessionLimit instance used by the ConcurrentSessionControlAuthenticationStrategy [[nsa-concurrency-control-session-registry-alias]] * **session-registry-alias** diff --git a/docs/modules/ROOT/pages/servlet/architecture.adoc b/docs/modules/ROOT/pages/servlet/architecture.adoc index 6548b679230..ad143353b8c 100644 --- a/docs/modules/ROOT/pages/servlet/architecture.adoc +++ b/docs/modules/ROOT/pages/servlet/architecture.adoc @@ -170,7 +170,7 @@ In fact, a `SecurityFilterChain` might have zero security `Filter` instances if The Security Filters are inserted into the <> with the <> API. Those filters can be used for a number of different purposes, like -xref:servlet/exploits/index.adoc[exploit protection],xref:servlet/authentication/index.adoc[authentication], xref:servlet/authorization/index.adoc[authorization], and more. +xref:servlet/exploits/index.adoc[exploit protection], xref:servlet/authentication/index.adoc[authentication], xref:servlet/authorization/index.adoc[authorization], and more. The filters are executed in a specific order to guarantee that they are invoked at the right time, for example, the `Filter` that performs authentication should be invoked before the `Filter` that performs authorization. It is typically not necessary to know the ordering of Spring Security's ``Filter``s. However, there are times that it is beneficial to know the ordering, if you want to know them, you can check the {gh-url}/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterOrderRegistration.java[`FilterOrderRegistration` code]. @@ -609,7 +609,7 @@ try { } ---- <1> As described in <>, invoking `FilterChain.doFilter(request, response)` is the equivalent of invoking the rest of the application. -This means that if another part of the application, (<> or method security) throws an `AuthenticationException` or `AccessDeniedException` it is caught and handled here. +This means that if another part of the application, (xref:servlet/authorization/authorize-http-requests.adoc[`AuthorizationFilter`] or method security) throws an `AuthenticationException` or `AccessDeniedException` it is caught and handled here. <2> If the user is not authenticated or it is an `AuthenticationException`, __Start Authentication__. <3> Otherwise, __Access Denied__ diff --git a/docs/modules/ROOT/pages/servlet/authentication/architecture.adoc b/docs/modules/ROOT/pages/servlet/authentication/architecture.adoc index b181fa96350..7d900f94767 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/architecture.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/architecture.adoc @@ -2,7 +2,7 @@ = Servlet Authentication Architecture :figures: servlet/authentication/architecture -This discussion expands on xref:servlet/architecture.adoc#servlet-architecture[Servlet Security: The Big Picture] to describe the main architectural components of Spring Security's used in Servlet authentication. +This discussion expands on xref:servlet/architecture.adoc#servlet-architecture[Servlet Security: The Big Picture] to describe the main architectural components that Spring Security uses in Servlet authentication. If you need concrete flows that explain how these pieces fit together, look at the xref:servlet/authentication/index.adoc#servlet-authentication-mechanisms[Authentication Mechanism] specific sections. * <> - The `SecurityContextHolder` is where Spring Security stores the details of who is xref:features/authentication/index.adoc#authentication[authenticated]. diff --git a/docs/modules/ROOT/pages/servlet/authentication/logout.adoc b/docs/modules/ROOT/pages/servlet/authentication/logout.adoc index d94b8a8d2ec..876deb3a267 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/logout.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/logout.adoc @@ -226,7 +226,7 @@ Kotlin:: ---- http { logout { - deleteCookies = "our-custom-cookie" + deleteCookies("our-custom-cookie") } } ---- diff --git a/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc index db67a98527e..b799f6637a3 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc @@ -545,3 +545,37 @@ class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSucc } ---- ====== + +[[customize-generate-token-request]] +== Customize GenerateOneTimeTokenRequest Instance +There are a number of reasons that you may want to adjust an GenerateOneTimeTokenRequest. For example, you may want expiresIn to be set to 10 mins, which Spring Security sets to 5 mins by default. + +You can customize elements of GenerateOneTimeTokenRequest by publishing an GenerateOneTimeTokenRequestResolver as a @Bean, like so: +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +GenerateOneTimeTokenRequestResolver generateOneTimeTokenRequestResolver() { + DefaultGenerateOneTimeTokenRequestResolver delegate = new DefaultGenerateOneTimeTokenRequestResolver(); + return (request) -> { + GenerateOneTimeTokenRequest generate = delegate.resolve(request); + return new GenerateOneTimeTokenRequest(generate.getUsername(), Duration.ofSeconds(600)); + }; +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun generateRequestResolver() : GenerateOneTimeTokenRequestResolver { + return DefaultGenerateOneTimeTokenRequestResolver().apply { + this.setExpiresIn(Duration.ofMinutes(10)) + } +} +---- +====== diff --git a/docs/modules/ROOT/pages/servlet/authentication/passkeys.adoc b/docs/modules/ROOT/pages/servlet/authentication/passkeys.adoc index 9b0cd52356d..0ccbf18cc15 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/passkeys.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/passkeys.adoc @@ -60,6 +60,7 @@ Java:: ---- @Bean SecurityFilterChain filterChain(HttpSecurity http) { + // ... http // ... .formLogin(withDefaults()) @@ -67,6 +68,9 @@ SecurityFilterChain filterChain(HttpSecurity http) { .rpName("Spring Security Relying Party") .rpId("example.com") .allowedOrigins("https://example.com") + // optional properties + .creationOptionsRepository(new CustomPublicKeyCredentialCreationOptionsRepository()) + .messageConverter(new CustomHttpMessageConverter()) ); return http.build(); } @@ -89,11 +93,15 @@ Kotlin:: ---- @Bean open fun filterChain(http: HttpSecurity): SecurityFilterChain { + // ... http { webAuthn { rpName = "Spring Security Relying Party" rpId = "example.com" allowedOrigins = setOf("https://example.com") + // optional properties + creationOptionsRepository = CustomPublicKeyCredentialCreationOptionsRepository() + messageConverter = CustomHttpMessageConverter() } } } @@ -110,6 +118,79 @@ open fun userDetailsService(): UserDetailsService { ---- ====== + +[[passkeys-configuration-persistence]] +=== JDBC & Custom Persistence + +WebAuthn performs persistence with javadoc:org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository[] and javadoc:org.springframework.security.web.webauthn.management.UserCredentialRepository[]. +The default is to use in memory persistence, but JDBC persistence is support with javadoc:org.springframework.security.web.webauthn.management.JdbcPublicKeyCredentialUserEntityRepository[] and javadoc:org.springframework.security.web.webauthn.management.JdbcUserCredentialRepository[]. +To configure JDBC based persistence, expose the repositories as a Bean: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +JdbcPublicKeyCredentialUserEntityRepository jdbcPublicKeyCredentialRepository(JdbcOperations jdbc) { + return new JdbcPublicKeyCredentialUserEntityRepository(jdbc); +} + +@Bean +JdbcUserCredentialRepository jdbcUserCredentialRepository(JdbcOperations jdbc) { + return new JdbcUserCredentialRepository(jdbc); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun jdbcPublicKeyCredentialRepository(jdbc: JdbcOperations): JdbcPublicKeyCredentialUserEntityRepository { + return JdbcPublicKeyCredentialUserEntityRepository(jdbc) +} + +@Bean +fun jdbcUserCredentialRepository(jdbc: JdbcOperations): JdbcUserCredentialRepository { + return JdbcUserCredentialRepository(jdbc) +} +---- +====== + +If JDBC does not meet your needs, you can create your own implementations of the interfaces and use them by exposing them as a Bean similar to the example above. + +[[passkeys-configuration-pkccor]] +=== Custom PublicKeyCredentialCreationOptionsRepository + +The `PublicKeyCredentialCreationOptionsRepository` is used to persist the `PublicKeyCredentialCreationOptions` between requests. +The default is to persist it the `HttpSession`, but at times users may need to customize this behavior. +This can be done by setting the optional property `creationOptionsRepository` demonstrated in xref:./passkeys.adoc#passkeys-configuration[Configuration] or by exposing a `PublicKeyCredentialCreationOptionsRepository` Bean: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +CustomPublicKeyCredentialCreationOptionsRepository creationOptionsRepository() { + return new CustomPublicKeyCredentialCreationOptionsRepository(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +open fun creationOptionsRepository(): CustomPublicKeyCredentialCreationOptionsRepository { + return CustomPublicKeyCredentialCreationOptionsRepository() +} +---- +====== + [[passkeys-register]] == Register a New Credential diff --git a/docs/modules/ROOT/pages/servlet/authentication/persistence.adoc b/docs/modules/ROOT/pages/servlet/authentication/persistence.adoc index e98615eb01d..ddf930762ff 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/persistence.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/persistence.adoc @@ -196,7 +196,7 @@ image::{figures}/securitycontextpersistencefilter.png[] image:{icondir}/number_1.png[] Before running the rest of the application, `SecurityContextPersistenceFilter` loads the `SecurityContext` from the `SecurityContextRepository` and sets it on the `SecurityContextHolder`. -image:{icondir}/number_2.png[] Next, the application is ran. +image:{icondir}/number_2.png[] Next, the application is run. image:{icondir}/number_3.png[] Finally, if the `SecurityContext` has changed, we save the `SecurityContext` using the `SecurityContextRepository`. This means that when using `SecurityContextPersistenceFilter`, just setting the `SecurityContextHolder` will ensure that the `SecurityContext` is persisted using `SecurityContextRepository`. @@ -219,7 +219,7 @@ image::{figures}/securitycontextholderfilter.png[] image:{icondir}/number_1.png[] Before running the rest of the application, `SecurityContextHolderFilter` loads the `SecurityContext` from the `SecurityContextRepository` and sets it on the `SecurityContextHolder`. -image:{icondir}/number_2.png[] Next, the application is ran. +image:{icondir}/number_2.png[] Next, the application is run. Unlike, xref:servlet/authentication/persistence.adoc#securitycontextpersistencefilter[`SecurityContextPersistenceFilter`], `SecurityContextHolderFilter` only loads the `SecurityContext` it does not save the `SecurityContext`. This means that when using `SecurityContextHolderFilter`, it is required that the `SecurityContext` is explicitly saved. diff --git a/docs/modules/ROOT/pages/servlet/authentication/session-management.adoc b/docs/modules/ROOT/pages/servlet/authentication/session-management.adoc index fe0821a31df..c6282e2351c 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/session-management.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/session-management.adoc @@ -399,7 +399,62 @@ XML:: This will prevent a user from logging in multiple times - a second login will cause the first to be invalidated. -Using Spring Boot, you can test the above configuration scenario the following way: +You can also adjust this based on who the user is. +For example, administrators may be able to have more than one session: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +public SecurityFilterChain filterChain(HttpSecurity http) { + AuthorizationManager isAdmin = AuthorityAuthorizationManager.hasRole("ADMIN"); + http + .sessionManagement(session -> session + .maximumSessions((authentication) -> isAdmin.authorize(() -> authentication, null).isGranted() ? -1 : 1) + ); + return http.build(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +open fun filterChain(http: HttpSecurity): SecurityFilterChain { + val isAdmin: AuthorizationManager<*> = AuthorityAuthorizationManager.hasRole("ADMIN") + http { + sessionManagement { + sessionConcurrency { + maximumSessions { + authentication -> if (isAdmin.authorize({ authentication }, null)!!.isGranted) -1 else 1 + } + } + } + } + return http.build() +} +---- + +XML:: ++ +[source,xml,role="secondary"] +---- + +... + + + + + + +---- +====== + +Using Spring Boot, you can test the above configurations in the following way: [tabs] ====== diff --git a/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc b/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc index 903fcb5ac1a..329270c65e4 100644 --- a/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc +++ b/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc @@ -108,7 +108,7 @@ Kotlin:: open class MyCustomerService { @PreAuthorize("hasAuthority('permission:read')") @PostAuthorize("returnObject.owner == authentication.name") - fun readCustomer(val id: String): Customer { ... } + fun readCustomer(id: String): Customer { ... } } ---- ====== @@ -216,13 +216,15 @@ Java:: ---- @PreAuthorize("hasAuthority('permission:read') || hasRole('ADMIN')") ---- -====== -.Kotlin -[source,kotlin,role="kotlin"] +Kotlin:: ++ +[source,kotlin,role="secondary"] ---- @PreAuthorize("hasAuthority('permission:read') || hasRole('ADMIN')") ---- +====== + However, you could instead grant `permission:read` to those with `ROLE_ADMIN`. One way to do this is with a `RoleHierarchy` like so: @@ -336,7 +338,7 @@ Kotlin:: @Component open class BankService { @PreAuthorize("hasRole('ADMIN')") - fun readAccount(val id: Long): Account { + fun readAccount(id: Long): Account { // ... is only invoked if the `Authentication` has the `ROLE_ADMIN` authority } } @@ -424,7 +426,7 @@ Kotlin:: @Component open class BankService { @PostAuthorize("returnObject.owner == authentication.name") - fun readAccount(val id: Long): Account { + fun readAccount(id: Long): Account { // ... is only returned if the `Account` belongs to the logged in user } } @@ -534,7 +536,7 @@ Kotlin:: @Component open class BankService { @RequireOwnership - fun readAccount(val id: Long): Account { + fun readAccount(id: Long): Account { // ... is only returned if the `Account` belongs to the logged in user } } @@ -991,7 +993,7 @@ Kotlin:: @Component open class BankService { @IsAdmin - fun readAccount(val id: Long): Account { + fun readAccount(id: Long): Account { // ... is only returned if the `Account` belongs to the logged in user } } @@ -1082,7 +1084,7 @@ Kotlin:: @Component open class BankService { @HasRole("ADMIN") - fun readAccount(val id: Long): Account { + fun readAccount(id: Long): Account { // ... is only returned if the `Account` belongs to the logged in user } } @@ -1142,7 +1144,7 @@ Kotlin:: @Component open class BankService { @HasAnyRole(roles = arrayOf("'USER'", "'ADMIN'")) - fun readAccount(val id: Long): Account { + fun readAccount(id: Long): Account { // ... is only returned if the `Account` belongs to the logged in user } } @@ -1269,7 +1271,7 @@ Kotlin:: ---- @Component("authz") open class AuthorizationLogic { - fun decide(val operations: MethodSecurityExpressionOperations): boolean { + fun decide(operations: MethodSecurityExpressionOperations): boolean { // ... authorization logic } } @@ -1340,7 +1342,7 @@ Kotlin:: ---- @Component("authz") open class AuthorizationLogic { - fun decide(val operations: MethodSecurityExpressionOperations): AuthorizationDecision { + fun decide(operations: MethodSecurityExpressionOperations): AuthorizationDecision { // ... authorization logic return MyAuthorizationDecision(false, details) } @@ -1433,13 +1435,13 @@ Kotlin:: class MethodSecurityConfig { @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - fun preAuthorize(val manager: MyAuthorizationManager) : Advisor { + fun preAuthorize(manager: MyAuthorizationManager) : Advisor { return AuthorizationManagerBeforeMethodInterceptor.preAuthorize(manager) } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - fun postAuthorize(val manager: MyAuthorizationManager) : Advisor { + fun postAuthorize(manager: MyAuthorizationManager) : Advisor { return AuthorizationManagerAfterMethodInterceptor.postAuthorize(manager) } } @@ -1499,7 +1501,7 @@ Kotlin:: ---- companion object { @Bean - fun methodSecurityExpressionHandler(val roleHierarchy: RoleHierarchy) : MethodSecurityExpressionHandler { + fun methodSecurityExpressionHandler(roleHierarchy: RoleHierarchy) : MethodSecurityExpressionHandler { val handler = DefaultMethodSecurityExpressionHandler() handler.setRoleHierarchy(roleHierarchy) return handler @@ -3234,7 +3236,7 @@ Kotlin:: [source,kotlin,role="secondary"] ---- class MyAuthorizer { - fun isAdmin(val root: MethodSecurityExpressionOperations): boolean { + fun isAdmin(root: MethodSecurityExpressionOperations): boolean { val decision = root.hasAuthority("ADMIN"); // custom work ... return decision; @@ -3293,7 +3295,7 @@ Kotlin:: ---- @Component class MyExpressionHandler: DefaultMethodSecurityExpressionHandler { - override fun createEvaluationContext(val authentication: Supplier, + override fun createEvaluationContext(authentication: Supplier, val mi: MethodInvocation): EvaluationContext { val context = super.createEvaluationContext(authentication, mi) as StandardEvaluationContext val delegate = context.getRootObject().getValue() as MethodSecurityExpressionOperations diff --git a/docs/modules/ROOT/pages/servlet/integrations/mvc.adoc b/docs/modules/ROOT/pages/servlet/integrations/mvc.adoc index 92223407e21..49314e8ba83 100644 --- a/docs/modules/ROOT/pages/servlet/integrations/mvc.adoc +++ b/docs/modules/ROOT/pages/servlet/integrations/mvc.adoc @@ -199,12 +199,10 @@ We could add additional rules for all the permutations of Spring MVC, but this w Fortunately, when using the `requestMatchers` DSL method, Spring Security automatically creates a `MvcRequestMatcher` if it detects that Spring MVC is available in the classpath. Therefore, it will protect the same URLs that Spring MVC will match on by using Spring MVC to match on the URL. -One common requirement when using Spring MVC is to specify the servlet path property, for that you can use the `MvcRequestMatcher.Builder` to create multiple `MvcRequestMatcher` instances that share the same servlet path: +One common requirement when using Spring MVC is to specify the servlet path property. + +For Java-based Configuration, you can use the `MvcRequestMatcher.Builder` to create multiple `MvcRequestMatcher` instances that share the same servlet path: -[tabs] -====== -Java:: -+ [source,java,role="primary"] ---- @Bean @@ -219,32 +217,36 @@ public SecurityFilterChain filterChain(HttpSecurity http, HandlerMappingIntrospe } ---- +For Kotlin and XML, this happens when you specify the servlet path for each path like so: + +[tabs] +====== Kotlin:: + [source,kotlin,role="secondary"] ---- @Bean -open fun filterChain(http: HttpSecurity, introspector: HandlerMappingIntrospector): SecurityFilterChain { - val mvcMatcherBuilder = MvcRequestMatcher.Builder(introspector) +open fun filterChain(http: HttpSecurity): SecurityFilterChain { http { authorizeHttpRequests { - authorize(mvcMatcherBuilder.pattern("/admin"), hasRole("ADMIN")) - authorize(mvcMatcherBuilder.pattern("/user"), hasRole("USER")) + authorize("/admin/**", "/mvc", hasRole("ADMIN")) + authorize("/user/**", "/mvc", hasRole("USER")) } } return http.build() } ---- -====== -The following XML has the same effect: - -[source,xml] +Xml:: ++ +[source,xml, role="secondary"] ---- - + + ---- +====== [[mvc-authentication-principal]] == @AuthenticationPrincipal diff --git a/docs/modules/ROOT/pages/servlet/oauth2/client/authorization-grants.adoc b/docs/modules/ROOT/pages/servlet/oauth2/client/authorization-grants.adoc index 4cab6a8472b..f2f14be45e9 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/client/authorization-grants.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/client/authorization-grants.adoc @@ -77,9 +77,14 @@ spring: Public Clients are supported by using https://tools.ietf.org/html/rfc7636[Proof Key for Code Exchange] (PKCE). If the client is running in an untrusted environment (such as a native application or web browser-based application) and is therefore incapable of maintaining the confidentiality of its credentials, PKCE is automatically used when the following conditions are true: -. `client-secret` is omitted (or empty) +. `client-secret` is omitted (or empty) and . `client-authentication-method` is set to `none` (`ClientAuthenticationMethod.NONE`) +or + +. When `ClientRegistration.clientSettings.requireProofKey` is `true` (in this case `ClientRegistration.authorizationGrantType` must be `authorization_code`) + + [TIP] ==== If the OAuth 2.0 Provider supports PKCE for https://tools.ietf.org/html/rfc6749#section-2.1[Confidential Clients], you may (optionally) configure it using `DefaultOAuth2AuthorizationRequestResolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce())`. diff --git a/docs/modules/ROOT/pages/servlet/oauth2/client/core.adoc b/docs/modules/ROOT/pages/servlet/oauth2/client/core.adoc index 375c8a12a89..04188773718 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/client/core.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/client/core.adoc @@ -40,6 +40,10 @@ public final class ClientRegistration { } } + + public static final class ClientSettings { + private boolean requireProofKey; // <17> + } } ---- <1> `registrationId`: The ID that uniquely identifies the `ClientRegistration`. @@ -65,6 +69,7 @@ This information is available only if the Spring Boot property `spring.security. <15> `(userInfoEndpoint)authenticationMethod`: The authentication method used when sending the access token to the UserInfo Endpoint. The supported values are *header*, *form*, and *query*. <16> `userNameAttributeName`: The name of the attribute returned in the UserInfo Response that references the Name or Identifier of the end-user. +<17> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default. You can initially configure a `ClientRegistration` by using discovery of an OpenID Connect Provider's https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig[Configuration endpoint] or an Authorization Server's https://tools.ietf.org/html/rfc8414#section-3[Metadata endpoint]. diff --git a/docs/modules/ROOT/pages/servlet/oauth2/client/index.adoc b/docs/modules/ROOT/pages/servlet/oauth2/client/index.adoc index e4195ea9dc7..bec08cf2eff 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/client/index.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/client/index.adoc @@ -1,5 +1,5 @@ [[oauth2-client]] -= [[oauth2client]]OAuth 2.0 Client += OAuth 2.0 Client :page-section-summary-toc: 1 The OAuth 2.0 Client features provide support for the Client role as defined in the https://tools.ietf.org/html/rfc6749#section-1.1[OAuth 2.0 Authorization Framework]. diff --git a/docs/modules/ROOT/pages/servlet/oauth2/resource-server/opaque-token.adoc b/docs/modules/ROOT/pages/servlet/oauth2/resource-server/opaque-token.adoc index dfc2ed47335..2798fd2206c 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/resource-server/opaque-token.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/resource-server/opaque-token.adoc @@ -307,7 +307,8 @@ Java:: ---- @Bean public OpaqueTokenIntrospector introspector() { - return new NimbusOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret); + return SpringOpaqueTokenIntrospector.withIntrospectionUri(introspectionUri) + .clientId(clientId).clientSecret(clientSecret).build(); } ---- @@ -317,7 +318,8 @@ Kotlin:: ---- @Bean fun introspector(): OpaqueTokenIntrospector { - return NimbusOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret) + return SpringOpaqueTokenIntrospector.withIntrospectionUri(introspectionUri) + .clientId(clientId).clientSecret(clientSecret).build() } ---- ====== @@ -532,7 +534,8 @@ Or, exposing a <> -* <> +* <> [[test-mockmvc-securitycontextholder-rpp]] == Running as a User in Spring MVC Test with RequestPostProcessor diff --git a/docs/modules/ROOT/pages/servlet/test/mockmvc/index.adoc b/docs/modules/ROOT/pages/servlet/test/mockmvc/index.adoc index 49d97c46a6f..997f9db7c5e 100644 --- a/docs/modules/ROOT/pages/servlet/test/mockmvc/index.adoc +++ b/docs/modules/ROOT/pages/servlet/test/mockmvc/index.adoc @@ -2,4 +2,4 @@ = Spring MVC Test Integration :page-section-summary-toc: 1 -Spring Security provides comprehensive integration with https://docs.spring.io/spring/docs/current/spring-framework-reference/html/testing.html#spring-mvc-test-framework[Spring MVC Test] +Spring Security provides comprehensive integration with https://docs.spring.io/spring-framework/reference/testing/mockmvc.html[Spring Testing MockMVC] diff --git a/docs/modules/ROOT/pages/servlet/test/mockmvc/setup.adoc b/docs/modules/ROOT/pages/servlet/test/mockmvc/setup.adoc index ed3bd1af17c..c77a95cf922 100644 --- a/docs/modules/ROOT/pages/servlet/test/mockmvc/setup.adoc +++ b/docs/modules/ROOT/pages/servlet/test/mockmvc/setup.adoc @@ -7,7 +7,7 @@ Spring Security's testing support requires spring-test-4.1.3.RELEASE or greater. ==== To use Spring Security with Spring MVC Test, add the Spring Security `FilterChainProxy` as a `Filter`. -You also need to add Spring Security's `TestSecurityContextHolderPostProcessor` to support xref:servlet/test/mockmvc/setup.adoc#test-mockmvc-withmockuser[Running as a User in Spring MVC Test with Annotations]. +You also need to add Spring Security's `TestSecurityContextHolderPostProcessor` to support xref:servlet/test/mockmvc/authentication.adoc#test-mockmvc-withmockuser[Running as a User in Spring MVC Test with Annotations]. To do so, use Spring Security's `SecurityMockMvcConfigurers.springSecurity()`: [tabs] diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index 773ad5f4f54..ce394207742 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -3,3 +3,24 @@ Spring Security 6.5 provides a number of new features. Below are the highlights of the release, or you can view https://github.com/spring-projects/spring-security/releases[the release notes] for a detailed listing of each feature and bug fix. + +== Breaking Changes + +=== Observability + +The `security.security.reached.filter.section` key name was corrected to `spring.security.reached.filter.section`. +Note that this may affect reports that operate on this key name. + +== OAuth + +* https://github.com/spring-projects/spring-security/pull/16386[gh-16386] - Enable PKCE for confidential clients using `ClientRegistration.clientSettings.requireProofKey=true` for xref:servlet/oauth2/client/core.adoc#oauth2Client-client-registration-requireProofKey[servlet] and xref:reactive/oauth2/client/core.adoc#oauth2Client-client-registration-requireProofKey[reactive] applications + +== WebAuthn + +* https://github.com/spring-projects/spring-security/pull/16282[gh-16282] - xref:servlet/authentication/passkeys.adoc#passkeys-configuration-persistence[JDBC Persistence] for WebAuthn/Passkeys +* https://github.com/spring-projects/spring-security/pull/16397[gh-16397] - Added the ability to configure a custom `HttpMessageConverter` for Passkeys using the optional xref:servlet/authentication/passkeys.adoc#passkeys-configuration[`messageConverter` property] on the `webAuthn` DSL. +* https://github.com/spring-projects/spring-security/pull/16396[gh-16396] - Added the ability to configure a custom xref:servlet/authentication/passkeys.adoc#passkeys-configuration-pkccor[`PublicKeyCredentialCreationOptionsRepository`] + +== One-Time Token Login + +* https://github.com/spring-projects/spring-security/issues/16291[gh-16291] - `oneTimeTokenLogin()` now supports customizing GenerateOneTimeTokenRequest xref:servlet/authentication/onetimetoken.adoc#customize-generate-token-request[via GenerateOneTimeTokenRequestResolver] diff --git a/git/hooks/forward-merge b/git/hooks/forward-merge index 322bdade8f3..37912fc8b33 100755 --- a/git/hooks/forward-merge +++ b/git/hooks/forward-merge @@ -26,7 +26,7 @@ def find_forward_merges(message_file) forward_merges = [] message.each_line do |line| $log.debug "Checking #{line} for message" - match = /^(?:Fixes|Closes) gh-(\d+) in (\d\.\d\.[\dx](?:[\.\-](?:M|RC)\d)?)$/.match(line) + match = /^(?:Fixes|Closes) gh-(\d+) in (\d\.\d\.[\dx](?:[\.\-](?:M|RC)\d)?)$/i.match(line) if match then issue = match[1] milestone = match[2] diff --git a/git/hooks/prepare-forward-merge b/git/hooks/prepare-forward-merge index f2d019008da..c0134f2c317 100755 --- a/git/hooks/prepare-forward-merge +++ b/git/hooks/prepare-forward-merge @@ -18,7 +18,7 @@ def get_fixed_issues() message = `git log -1 --pretty=%B #{rev}` message.each_line do |line| $log.debug "Checking #{line} for message" - fixed << line.strip if /^(?:Fixes|Closes) gh-(\d+)/.match(line) + fixed << line.strip if /^(?:Fixes|Closes) gh-(\d+)/i.match(line) end $log.debug "Found fixed issues #{fixed}" return fixed; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e79f42a258c..bbd150c53bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,17 +7,17 @@ jakarta-websocket = "2.2.0" org-apache-directory-server = "1.5.5" org-apache-maven-resolver = "1.9.22" org-aspectj = "1.9.22.1" -org-bouncycastle = "1.79" +org-bouncycastle = "1.80" org-eclipse-jetty = "11.0.24" org-jetbrains-kotlin = "1.9.25" -org-jetbrains-kotlinx = "1.9.0" -org-mockito = "5.14.2" +org-jetbrains-kotlinx = "1.10.1" +org-mockito = "5.15.2" org-opensaml = "4.3.2" org-opensaml5 = "5.1.2" -org-springframework = "6.2.1" +org-springframework = "6.2.2" [libraries] -ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.12" +ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.16" com-fasterxml-jackson-jackson-bom = "com.fasterxml.jackson:jackson-bom:2.18.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" @@ -28,9 +28,9 @@ com-squareup-okhttp3-okhttp = { module = "com.squareup.okhttp3:okhttp", version. com-unboundid-unboundid-ldapsdk = "com.unboundid:unboundid-ldapsdk:6.0.11" com-unboundid-unboundid-ldapsdk7 = "com.unboundid:unboundid-ldapsdk:7.0.1" commons-collections = "commons-collections:commons-collections:3.2.2" -io-micrometer-micrometer-observation = "io.micrometer:micrometer-observation:1.14.2" -io-mockk = "io.mockk:mockk:1.13.13" -io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2023.0.13" +io-micrometer-micrometer-observation = "io.micrometer:micrometer-observation:1.14.3" +io-mockk = "io.mockk:mockk:1.13.16" +io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2023.0.14" 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" } @@ -48,7 +48,7 @@ jakarta-websocket-jakarta-websocket-client-api = { module = "jakarta.websocket:j jakarta-xml-bind-jakarta-xml-bind-api = "jakarta.xml.bind:jakarta.xml.bind-api:4.0.2" ldapsdk = "ldapsdk:ldapsdk:4.1" net-sourceforge-htmlunit = "net.sourceforge.htmlunit:htmlunit:2.70.0" -org-htmlunit-htmlunit = "org.htmlunit:htmlunit:4.7.0" +org-htmlunit-htmlunit = "org.htmlunit:htmlunit:4.9.0" org-apache-directory-server-apacheds-core = { module = "org.apache.directory.server:apacheds-core", version.ref = "org-apache-directory-server" } org-apache-directory-server-apacheds-entry = { module = "org.apache.directory.server:apacheds-core-entry", version.ref = "org-apache-directory-server" } org-apache-directory-server-apacheds-protocol-ldap = { module = "org.apache.directory.server:apacheds-protocol-ldap", version.ref = "org-apache-directory-server" } @@ -61,16 +61,16 @@ org-apache-maven-resolver-maven-resolver-connector-basic = { module = "org.apach org-apache-maven-resolver-maven-resolver-impl = { module = "org.apache.maven.resolver:maven-resolver-impl", version.ref = "org-apache-maven-resolver" } org-apache-maven-resolver-maven-resolver-transport-http = { module = "org.apache.maven.resolver:maven-resolver-transport-http", version.ref = "org-apache-maven-resolver" } org-apereo-cas-client-cas-client-core = "org.apereo.cas.client:cas-client-core:4.0.4" -io-freefair-gradle-aspectj-plugin = "io.freefair.gradle:aspectj-plugin:8.11" +io-freefair-gradle-aspectj-plugin = "io.freefair.gradle:aspectj-plugin:8.12" org-aspectj-aspectjrt = { module = "org.aspectj:aspectjrt", version.ref = "org-aspectj" } org-aspectj-aspectjweaver = { module = "org.aspectj:aspectjweaver", version.ref = "org-aspectj" } -org-assertj-assertj-core = "org.assertj:assertj-core:3.26.3" +org-assertj-assertj-core = "org.assertj:assertj-core:3.27.3" org-bouncycastle-bcpkix-jdk15on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "org-bouncycastle" } org-bouncycastle-bcprov-jdk15on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "org-bouncycastle" } org-eclipse-jetty-jetty-server = { module = "org.eclipse.jetty:jetty-server", version.ref = "org-eclipse-jetty" } org-eclipse-jetty-jetty-servlet = { module = "org.eclipse.jetty:jetty-servlet", version.ref = "org-eclipse-jetty" } org-hamcrest = "org.hamcrest:hamcrest:2.2" -org-hibernate-orm-hibernate-core = "org.hibernate.orm:hibernate-core:6.6.3.Final" +org-hibernate-orm-hibernate-core = "org.hibernate.orm:hibernate-core:6.6.5.Final" org-hsqldb = "org.hsqldb:hsqldb:2.7.4" org-jetbrains-kotlin-kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "org-jetbrains-kotlin" } org-jetbrains-kotlin-kotlin-gradle-plugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25" @@ -83,12 +83,12 @@ org-opensaml-opensaml5-saml-api = { module = "org.opensaml:opensaml-saml-api", v org-opensaml-opensaml5-saml-impl = { module = "org.opensaml:opensaml-saml-impl", version.ref = "org-opensaml5" } org-python-jython = { module = "org.python:jython", version = "2.5.3" } org-seleniumhq-selenium-htmlunit-driver = "org.seleniumhq.selenium:htmlunit3-driver:4.27.0" -org-seleniumhq-selenium-selenium-java = "org.seleniumhq.selenium:selenium-java:4.27.0" +org-seleniumhq-selenium-selenium-java = "org.seleniumhq.selenium:selenium-java:4.28.1" org-seleniumhq-selenium-selenium-support = "org.seleniumhq.selenium:selenium-support:3.141.59" org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3" org-slf4j-log4j-over-slf4j = "org.slf4j:log4j-over-slf4j:1.7.36" org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.16" -org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2024.1.1" +org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2024.1.2" org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:3.2.10" 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" @@ -100,14 +100,14 @@ org-yaml-snakeyaml = "org.yaml:snakeyaml:1.33" org-apache-commons-commons-io = "org.apache.commons:commons-io:1.3.2" io-github-gradle-nexus-publish-plugin = "io.github.gradle-nexus:publish-plugin:1.3.0" org-gretty-gretty = "org.gretty:gretty:4.1.6" -com-github-ben-manes-gradle-versions-plugin = "com.github.ben-manes:gradle-versions-plugin:0.51.0" +com-github-ben-manes-gradle-versions-plugin = "com.github.ben-manes:gradle-versions-plugin:0.52.0" com-github-spullara-mustache-java-compiler = "com.github.spullara.mustache.java:compiler:0.9.14" org-hidetake-gradle-ssh-plugin = "org.hidetake:gradle-ssh-plugin:2.10.1" org-jfrog-buildinfo-build-info-extractor-gradle = "org.jfrog.buildinfo:build-info-extractor-gradle:4.33.23" org-sonarsource-scanner-gradle-sonarqube-gradle-plugin = "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.8.0.1969" org-instancio-instancio-junit = "org.instancio:instancio-junit:3.7.1" -webauthn4j-core = 'com.webauthn4j:webauthn4j-core:0.28.3.RELEASE' +webauthn4j-core = 'com.webauthn4j:webauthn4j-core:0.28.4.RELEASE' [plugins] diff --git a/javascript/lib/webauthn-core.js b/javascript/lib/webauthn-core.js index b4c26d08f0e..e2cdc0148d9 100644 --- a/javascript/lib/webauthn-core.js +++ b/javascript/lib/webauthn-core.js @@ -41,8 +41,16 @@ async function authenticate(headers, contextPath, useConditionalMediation) { } // FIXME: Use https://www.w3.org/TR/webauthn-3/#sctn-parseRequestOptionsFromJSON + const decodedAllowCredentials = !options.allowCredentials + ? [] + : options.allowCredentials.map((cred) => ({ + ...cred, + id: base64url.decode(cred.id), + })); + const decodedOptions = { ...options, + allowCredentials: decodedAllowCredentials, challenge: base64url.decode(options.challenge), }; diff --git a/javascript/test/webauthn-core.test.js b/javascript/test/webauthn-core.test.js index 2c6413a33e3..88dae0052e1 100644 --- a/javascript/test/webauthn-core.test.js +++ b/javascript/test/webauthn-core.test.js @@ -85,7 +85,13 @@ describe("webauthn-core", () => { challenge: "nRbOrtNKTfJ1JaxfUDKs8j3B-JFqyGQw8DO4u6eV3JA", timeout: 300000, rpId: "localhost", - allowCredentials: [], + allowCredentials: [ + { + id: "nOsjw8eaaqSwVdTBBYE1FqfGdHs", + type: "public-key", + transports: [], + }, + ], userVerification: "preferred", extensions: {}, }; @@ -172,7 +178,13 @@ describe("webauthn-core", () => { challenge: base64url.decode("nRbOrtNKTfJ1JaxfUDKs8j3B-JFqyGQw8DO4u6eV3JA"), timeout: 300000, rpId: "localhost", - allowCredentials: [], + allowCredentials: [ + { + id: base64url.decode("nOsjw8eaaqSwVdTBBYE1FqfGdHs"), + type: "public-key", + transports: [], + }, + ], userVerification: "preferred", extensions: {}, }, diff --git a/ldap/src/main/java/org/springframework/security/ldap/authentication/ad/ActiveDirectoryAuthenticationException.java b/ldap/src/main/java/org/springframework/security/ldap/authentication/ad/ActiveDirectoryAuthenticationException.java index 42b0403740e..124fce51bbb 100644 --- a/ldap/src/main/java/org/springframework/security/ldap/authentication/ad/ActiveDirectoryAuthenticationException.java +++ b/ldap/src/main/java/org/springframework/security/ldap/authentication/ad/ActiveDirectoryAuthenticationException.java @@ -40,6 +40,7 @@ * * @author Rob Winch */ +@SuppressWarnings("serial") public final class ActiveDirectoryAuthenticationException extends AuthenticationException { private final String dataCode; diff --git a/ldap/src/main/java/org/springframework/security/ldap/jackson2/LdapJackson2Module.java b/ldap/src/main/java/org/springframework/security/ldap/jackson2/LdapJackson2Module.java index f84e8df6205..aaa4164da5f 100644 --- a/ldap/src/main/java/org/springframework/security/ldap/jackson2/LdapJackson2Module.java +++ b/ldap/src/main/java/org/springframework/security/ldap/jackson2/LdapJackson2Module.java @@ -46,6 +46,7 @@ * @since 5.7 * @see SecurityJackson2Modules */ +@SuppressWarnings("serial") public class LdapJackson2Module extends SimpleModule { public LdapJackson2Module() { diff --git a/ldap/src/main/java/org/springframework/security/ldap/ppolicy/PasswordPolicyControl.java b/ldap/src/main/java/org/springframework/security/ldap/ppolicy/PasswordPolicyControl.java index 84eb48cdf98..629513cc8b3 100755 --- a/ldap/src/main/java/org/springframework/security/ldap/ppolicy/PasswordPolicyControl.java +++ b/ldap/src/main/java/org/springframework/security/ldap/ppolicy/PasswordPolicyControl.java @@ -16,6 +16,8 @@ package org.springframework.security.ldap.ppolicy; +import java.io.Serial; + import javax.naming.ldap.Control; /** @@ -37,6 +39,9 @@ public class PasswordPolicyControl implements Control { */ public static final String OID = "1.3.6.1.4.1.42.2.27.8.5.1"; + @Serial + private static final long serialVersionUID = 2843242715616817932L; + private final boolean critical; /** diff --git a/ldap/src/main/java/org/springframework/security/ldap/ppolicy/PasswordPolicyException.java b/ldap/src/main/java/org/springframework/security/ldap/ppolicy/PasswordPolicyException.java index 73ab1420525..f01222d4a2a 100644 --- a/ldap/src/main/java/org/springframework/security/ldap/ppolicy/PasswordPolicyException.java +++ b/ldap/src/main/java/org/springframework/security/ldap/ppolicy/PasswordPolicyException.java @@ -16,6 +16,8 @@ package org.springframework.security.ldap.ppolicy; +import java.io.Serial; + /** * Generic exception raised by the ppolicy package. *

@@ -27,6 +29,9 @@ */ public class PasswordPolicyException extends RuntimeException { + @Serial + private static final long serialVersionUID = 2586535034047453106L; + private final PasswordPolicyErrorStatus status; public PasswordPolicyException(PasswordPolicyErrorStatus status) { diff --git a/ldap/src/main/java/org/springframework/security/ldap/ppolicy/PasswordPolicyResponseControl.java b/ldap/src/main/java/org/springframework/security/ldap/ppolicy/PasswordPolicyResponseControl.java index 2aa2b330e06..a6ac94590dd 100755 --- a/ldap/src/main/java/org/springframework/security/ldap/ppolicy/PasswordPolicyResponseControl.java +++ b/ldap/src/main/java/org/springframework/security/ldap/ppolicy/PasswordPolicyResponseControl.java @@ -19,6 +19,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.Serial; import netscape.ldap.ber.stream.BERChoice; import netscape.ldap.ber.stream.BERElement; @@ -53,6 +54,9 @@ public class PasswordPolicyResponseControl extends PasswordPolicyControl { private static final Log logger = LogFactory.getLog(PasswordPolicyResponseControl.class); + @Serial + private static final long serialVersionUID = -4592657167939234499L; + private final byte[] encodedValue; private PasswordPolicyErrorStatus errorStatus; diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/ClientAuthorizationException.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/ClientAuthorizationException.java index 8050b74a03f..257f26f4f59 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/ClientAuthorizationException.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/ClientAuthorizationException.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.client; +import java.io.Serial; + import org.springframework.security.oauth2.core.OAuth2AuthorizationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.util.Assert; @@ -30,6 +32,9 @@ */ public class ClientAuthorizationException extends OAuth2AuthorizationException { + @Serial + private static final long serialVersionUID = 4710713969265443271L; + private final String clientRegistrationId; /** diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/ClientAuthorizationRequiredException.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/ClientAuthorizationRequiredException.java index ee4c0e47849..0bb5649ece9 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/ClientAuthorizationRequiredException.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/ClientAuthorizationRequiredException.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.client; +import java.io.Serial; + import org.springframework.security.oauth2.core.OAuth2Error; /** @@ -28,6 +30,9 @@ */ public class ClientAuthorizationRequiredException extends ClientAuthorizationException { + @Serial + private static final long serialVersionUID = -5738646355203953667L; + private static final String CLIENT_AUTHORIZATION_REQUIRED_ERROR_CODE = "client_authorization_required"; /** diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/ClientRegistrationDeserializer.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/ClientRegistrationDeserializer.java index 77b1fdd1217..d8fc1a099d6 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/ClientRegistrationDeserializer.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/ClientRegistrationDeserializer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2025 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. diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/OAuth2ClientJackson2Module.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/OAuth2ClientJackson2Module.java index ba1eaacd2c7..30f1185c9ba 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/OAuth2ClientJackson2Module.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/OAuth2ClientJackson2Module.java @@ -86,6 +86,7 @@ * @see OAuth2AuthenticationExceptionMixin * @see OAuth2ErrorMixin */ +@SuppressWarnings("serial") public class OAuth2ClientJackson2Module extends SimpleModule { public OAuth2ClientJackson2Module() { diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandler.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandler.java index 3cc5754cac1..9d35ddc69b2 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandler.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 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. @@ -23,6 +23,7 @@ import reactor.core.publisher.Mono; +import org.springframework.core.convert.converter.Converter; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; @@ -35,6 +36,7 @@ import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler; import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler; import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; @@ -57,6 +59,8 @@ public class OidcClientInitiatedServerLogoutSuccessHandler implements ServerLogo private String postLogoutRedirectUri; + private Converter> redirectUriResolver = new DefaultRedirectUriResolver(); + /** * Constructs an {@link OidcClientInitiatedServerLogoutSuccessHandler} with the * provided parameters @@ -79,15 +83,10 @@ public Mono onLogoutSuccess(WebFilterExchange exchange, Authentication aut .map(OAuth2AuthenticationToken.class::cast) .map(OAuth2AuthenticationToken::getAuthorizedClientRegistrationId) .flatMap(this.clientRegistrationRepository::findByRegistrationId) - .flatMap((clientRegistration) -> { - URI endSessionEndpoint = endSessionEndpoint(clientRegistration); - if (endSessionEndpoint == null) { - return Mono.empty(); - } - String idToken = idToken(authentication); - String postLogoutRedirectUri = postLogoutRedirectUri(exchange.getExchange().getRequest(), clientRegistration); - return Mono.just(endpointUri(endSessionEndpoint, idToken, postLogoutRedirectUri)); - }) + .flatMap((clientRegistration) -> + this.redirectUriResolver.convert( + new RedirectUriParameters(exchange.getExchange(), authentication, clientRegistration)) + ) .switchIfEmpty( this.serverLogoutSuccessHandler.onLogoutSuccess(exchange, authentication).then(Mono.empty()) ) @@ -189,4 +188,79 @@ public void setLogoutSuccessUrl(URI logoutSuccessUrl) { this.serverLogoutSuccessHandler.setLogoutSuccessUrl(logoutSuccessUrl); } + /** + * Set the {@link Converter} that converts {@link RedirectUriParameters} to redirect + * URI + * @param redirectUriResolver {@link Converter} + * @since 6.5 + */ + public void setRedirectUriResolver(Converter> redirectUriResolver) { + Assert.notNull(redirectUriResolver, "redirectUriResolver cannot be null"); + this.redirectUriResolver = redirectUriResolver; + } + + /** + * Parameters, required for redirect URI resolving. + * + * @author Max Batischev + * @since 6.5 + */ + public static final class RedirectUriParameters { + + private final ServerWebExchange serverWebExchange; + + private final Authentication authentication; + + private final ClientRegistration clientRegistration; + + public RedirectUriParameters(ServerWebExchange serverWebExchange, Authentication authentication, + ClientRegistration clientRegistration) { + Assert.notNull(clientRegistration, "clientRegistration cannot be null"); + Assert.notNull(serverWebExchange, "serverWebExchange cannot be null"); + Assert.notNull(authentication, "authentication cannot be null"); + this.serverWebExchange = serverWebExchange; + this.authentication = authentication; + this.clientRegistration = clientRegistration; + } + + public ServerWebExchange getServerWebExchange() { + return this.serverWebExchange; + } + + public Authentication getAuthentication() { + return this.authentication; + } + + public ClientRegistration getClientRegistration() { + return this.clientRegistration; + } + + } + + /** + * Default {@link Converter} for redirect uri resolving. + * + * @since 6.5 + */ + private final class DefaultRedirectUriResolver implements Converter> { + + @Override + public Mono convert(RedirectUriParameters redirectUriParameters) { + // @formatter:off + return Mono.just(redirectUriParameters.authentication) + .flatMap((authentication) -> { + URI endSessionEndpoint = endSessionEndpoint(redirectUriParameters.clientRegistration); + if (endSessionEndpoint == null) { + return Mono.empty(); + } + String idToken = idToken(authentication); + String postLogoutRedirectUri = postLogoutRedirectUri( + redirectUriParameters.serverWebExchange.getRequest(), redirectUriParameters.clientRegistration); + return Mono.just(endpointUri(endSessionEndpoint, idToken, postLogoutRedirectUri)); + }); + // @formatter:on + } + + } + } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistration.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistration.java index 0639a395f89..b492a6d8015 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistration.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,7 @@ package org.springframework.security.oauth2.client.registration; +import java.io.Serial; import java.io.Serializable; import java.util.Arrays; import java.util.Collection; @@ -26,6 +27,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import org.apache.commons.logging.Log; @@ -71,6 +73,8 @@ public final class ClientRegistration implements Serializable { private String clientName; + private ClientSettings clientSettings; + private ClientRegistration() { } @@ -162,6 +166,14 @@ public String getClientName() { return this.clientName; } + /** + * Returns the {@link ClientSettings client configuration settings}. + * @return the {@link ClientSettings} + */ + public ClientSettings getClientSettings() { + return this.clientSettings; + } + @Override public String toString() { // @formatter:off @@ -175,6 +187,7 @@ public String toString() { + '\'' + ", scopes=" + this.scopes + ", providerDetails=" + this.providerDetails + ", clientName='" + this.clientName + '\'' + + ", clientSettings='" + this.clientSettings + '\'' + '}'; // @formatter:on } @@ -367,6 +380,8 @@ public static final class Builder implements Serializable { private String clientName; + private ClientSettings clientSettings = ClientSettings.builder().build(); + private Builder(String registrationId) { this.registrationId = registrationId; } @@ -391,6 +406,7 @@ private Builder(ClientRegistration clientRegistration) { this.configurationMetadata = new HashMap<>(configurationMetadata); } this.clientName = clientRegistration.clientName; + this.clientSettings = clientRegistration.clientSettings; } /** @@ -594,6 +610,17 @@ public Builder clientName(String clientName) { return this; } + /** + * Sets the {@link ClientSettings client configuration settings}. + * @param clientSettings the client configuration settings + * @return the {@link Builder} + */ + public Builder clientSettings(ClientSettings clientSettings) { + Assert.notNull(clientSettings, "clientSettings cannot be null"); + this.clientSettings = clientSettings; + return this; + } + /** * Builds a new {@link ClientRegistration}. * @return a {@link ClientRegistration} @@ -627,12 +654,13 @@ private ClientRegistration create() { clientRegistration.providerDetails = createProviderDetails(clientRegistration); clientRegistration.clientName = StringUtils.hasText(this.clientName) ? this.clientName : this.registrationId; + clientRegistration.clientSettings = this.clientSettings; return clientRegistration; } private ClientAuthenticationMethod deduceClientAuthenticationMethod(ClientRegistration clientRegistration) { if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(this.authorizationGrantType) - && !StringUtils.hasText(this.clientSecret)) { + && (!StringUtils.hasText(this.clientSecret))) { return ClientAuthenticationMethod.NONE; } return ClientAuthenticationMethod.CLIENT_SECRET_BASIC; @@ -685,6 +713,12 @@ private void validateAuthorizationGrantTypes() { "AuthorizationGrantType: %s does not match the pre-defined constant %s and won't match a valid OAuth2AuthorizedClientProvider", this.authorizationGrantType, authorizationGrantType)); } + if (!AuthorizationGrantType.AUTHORIZATION_CODE.equals(this.authorizationGrantType) + && this.clientSettings.isRequireProofKey()) { + throw new IllegalStateException( + "clientSettings.isRequireProofKey=true is only valid with authorizationGrantType=AUTHORIZATION_CODE. Got authorizationGrantType=" + + this.authorizationGrantType); + } } } @@ -709,4 +743,79 @@ private static boolean withinTheRangeOf(int c, int min, int max) { } + /** + * A facility for client configuration settings. + * + * @author DingHao + * @since 6.5 + */ + public static final class ClientSettings implements Serializable { + + @Serial + private static final long serialVersionUID = 7495627155437124692L; + + private boolean requireProofKey; + + private ClientSettings() { + + } + + public boolean isRequireProofKey() { + return this.requireProofKey; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ClientSettings that)) { + return false; + } + return this.requireProofKey == that.requireProofKey; + } + + @Override + public int hashCode() { + return Objects.hashCode(this.requireProofKey); + } + + @Override + public String toString() { + return "ClientSettings{" + "requireProofKey=" + this.requireProofKey + '}'; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private boolean requireProofKey; + + private Builder() { + } + + /** + * Set to {@code true} if the client is required to provide a proof key + * challenge and verifier when performing the Authorization Code Grant flow. + * @param requireProofKey {@code true} if the client is required to provide a + * proof key challenge and verifier, {@code false} otherwise + * @return the {@link Builder} for further configuration + */ + public Builder requireProofKey(boolean requireProofKey) { + this.requireProofKey = requireProofKey; + return this; + } + + public ClientSettings build() { + ClientSettings clientSettings = new ClientSettings(); + clientSettings.requireProofKey = this.requireProofKey; + return clientSettings; + } + + } + + } + } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolver.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolver.java index c189317ec43..4909d0f730d 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolver.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 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. @@ -183,7 +183,8 @@ private OAuth2AuthorizationRequest.Builder getBuilder(ClientRegistration clientR // value. applyNonce(builder); } - if (ClientAuthenticationMethod.NONE.equals(clientRegistration.getClientAuthenticationMethod())) { + if (ClientAuthenticationMethod.NONE.equals(clientRegistration.getClientAuthenticationMethod()) + || clientRegistration.getClientSettings().isRequireProofKey()) { DEFAULT_PKCE_APPLIER.accept(builder); } return builder; diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/InvalidClientRegistrationIdException.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/InvalidClientRegistrationIdException.java index f42249284fe..e7e718949c9 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/InvalidClientRegistrationIdException.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/InvalidClientRegistrationIdException.java @@ -20,6 +20,7 @@ * @author Steve Riesenberg * @since 5.8 */ +@SuppressWarnings("serial") class InvalidClientRegistrationIdException extends IllegalArgumentException { /** diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/server/DefaultServerOAuth2AuthorizationRequestResolver.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/server/DefaultServerOAuth2AuthorizationRequestResolver.java index bb95dd20b73..0123a2aab7d 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/server/DefaultServerOAuth2AuthorizationRequestResolver.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/server/DefaultServerOAuth2AuthorizationRequestResolver.java @@ -196,7 +196,8 @@ private OAuth2AuthorizationRequest.Builder getBuilder(ClientRegistration clientR // value. applyNonce(builder); } - if (ClientAuthenticationMethod.NONE.equals(clientRegistration.getClientAuthenticationMethod())) { + if (ClientAuthenticationMethod.NONE.equals(clientRegistration.getClientAuthenticationMethod()) + || clientRegistration.getClientSettings().isRequireProofKey()) { DEFAULT_PKCE_APPLIER.accept(builder); } return builder; diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthorizedClientMixinTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthorizedClientMixinTests.java index 3f696c361c1..d6d0e819276 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthorizedClientMixinTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthorizedClientMixinTests.java @@ -214,6 +214,71 @@ public void deserializeWhenRequiredAttributesOnlyThenDeserializes() throws Excep assertThat(authorizedClient.getRefreshToken()).isNull(); } + @Test + void deserializeWhenClientSettingsPropertyDoesNotExistThenDefaulted() throws JsonProcessingException { + // ClientRegistration.clientSettings was added later, so old values will be + // serialized without that property + // this test checks for passivity + ClientRegistration clientRegistration = this.clientRegistrationBuilder.build(); + ClientRegistration.ProviderDetails providerDetails = clientRegistration.getProviderDetails(); + ClientRegistration.ProviderDetails.UserInfoEndpoint userInfoEndpoint = providerDetails.getUserInfoEndpoint(); + String scopes = ""; + if (!CollectionUtils.isEmpty(clientRegistration.getScopes())) { + scopes = StringUtils.collectionToDelimitedString(clientRegistration.getScopes(), ",", "\"", "\""); + } + String configurationMetadata = "\"@class\": \"java.util.Collections$UnmodifiableMap\""; + if (!CollectionUtils.isEmpty(providerDetails.getConfigurationMetadata())) { + configurationMetadata += "," + providerDetails.getConfigurationMetadata() + .keySet() + .stream() + .map((key) -> "\"" + key + "\": \"" + providerDetails.getConfigurationMetadata().get(key) + "\"") + .collect(Collectors.joining(",")); + } + // @formatter:off + String json = "{\n" + + " \"@class\": \"org.springframework.security.oauth2.client.registration.ClientRegistration\",\n" + + " \"registrationId\": \"" + clientRegistration.getRegistrationId() + "\",\n" + + " \"clientId\": \"" + clientRegistration.getClientId() + "\",\n" + + " \"clientSecret\": \"" + clientRegistration.getClientSecret() + "\",\n" + + " \"clientAuthenticationMethod\": {\n" + + " \"value\": \"" + clientRegistration.getClientAuthenticationMethod().getValue() + "\"\n" + + " },\n" + + " \"authorizationGrantType\": {\n" + + " \"value\": \"" + clientRegistration.getAuthorizationGrantType().getValue() + "\"\n" + + " },\n" + + " \"redirectUri\": \"" + clientRegistration.getRedirectUri() + "\",\n" + + " \"scopes\": [\n" + + " \"java.util.Collections$UnmodifiableSet\",\n" + + " [" + scopes + "]\n" + + " ],\n" + + " \"providerDetails\": {\n" + + " \"@class\": \"org.springframework.security.oauth2.client.registration.ClientRegistration$ProviderDetails\",\n" + + " \"authorizationUri\": \"" + providerDetails.getAuthorizationUri() + "\",\n" + + " \"tokenUri\": \"" + providerDetails.getTokenUri() + "\",\n" + + " \"userInfoEndpoint\": {\n" + + " \"@class\": \"org.springframework.security.oauth2.client.registration.ClientRegistration$ProviderDetails$UserInfoEndpoint\",\n" + + " \"uri\": " + ((userInfoEndpoint.getUri() != null) ? "\"" + userInfoEndpoint.getUri() + "\"" : null) + ",\n" + + " \"authenticationMethod\": {\n" + + " \"value\": \"" + userInfoEndpoint.getAuthenticationMethod().getValue() + "\"\n" + + " },\n" + + " \"userNameAttributeName\": " + ((userInfoEndpoint.getUserNameAttributeName() != null) ? "\"" + userInfoEndpoint.getUserNameAttributeName() + "\"" : null) + "\n" + + " },\n" + + " \"jwkSetUri\": " + ((providerDetails.getJwkSetUri() != null) ? "\"" + providerDetails.getJwkSetUri() + "\"" : null) + ",\n" + + " \"issuerUri\": " + ((providerDetails.getIssuerUri() != null) ? "\"" + providerDetails.getIssuerUri() + "\"" : null) + ",\n" + + " \"configurationMetadata\": {\n" + + " " + configurationMetadata + "\n" + + " }\n" + + " },\n" + + " \"clientName\": \"" + clientRegistration.getClientName() + "\"\n" + + "}"; + // @formatter:on + // validate the test input + assertThat(json).doesNotContain("clientSettings"); + ClientRegistration registration = this.mapper.readValue(json, ClientRegistration.class); + // the default value of requireProofKey is false + assertThat(registration.getClientSettings().isRequireProofKey()).isFalse(); + } + private static String asJson(OAuth2AuthorizedClient authorizedClient) { // @formatter:off return "{\n" + @@ -276,7 +341,10 @@ private static String asJson(ClientRegistration clientRegistration) { " " + configurationMetadata + "\n" + " }\n" + " },\n" + - " \"clientName\": \"" + clientRegistration.getClientName() + "\"\n" + + " \"clientName\": \"" + clientRegistration.getClientName() + "\",\n" + + " \"clientSettings\": {\n" + + " \"requireProofKey\": " + clientRegistration.getClientSettings().isRequireProofKey() + "\n" + + " }\n" + "}"; // @formatter:on } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandlerTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandlerTests.java index 300a815caf4..591ef091dae 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandlerTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/web/server/logout/OidcClientInitiatedServerLogoutSuccessHandlerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 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. @@ -19,6 +19,7 @@ import java.io.IOException; import java.net.URI; import java.util.Collections; +import java.util.Objects; import jakarta.servlet.ServletException; import org.junit.jupiter.api.BeforeEach; @@ -199,6 +200,25 @@ public void setPostLogoutRedirectUriTemplateWhenGivenNullThenThrowsException() { assertThatIllegalArgumentException().isThrownBy(() -> this.handler.setPostLogoutRedirectUri((String) null)); } + @Test + public void logoutWhenCustomRedirectUriResolverSetThenRedirects() { + OAuth2AuthenticationToken token = new OAuth2AuthenticationToken(TestOidcUsers.create(), + AuthorityUtils.NO_AUTHORITIES, this.registration.getRegistrationId()); + WebFilterExchange filterExchange = new WebFilterExchange(this.exchange, this.chain); + given(this.exchange.getRequest()) + .willReturn(MockServerHttpRequest.get("/").queryParam("location", "https://test.com").build()); + // @formatter:off + this.handler.setRedirectUriResolver((params) -> Mono.just( + Objects.requireNonNull(params.getServerWebExchange() + .getRequest() + .getQueryParams() + .getFirst("location")))); + // @formatter:on + this.handler.onLogoutSuccess(filterExchange, token).block(); + + assertThat(redirectedUrl(this.exchange)).isEqualTo("https://test.com"); + } + private String redirectedUrl(ServerWebExchange exchange) { return exchange.getResponse().getHeaders().getFirst("Location"); } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationTests.java index 070e2040bdb..9dbcbd5a5c8 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationTests.java @@ -16,14 +16,20 @@ package org.springframework.security.oauth2.client.registration; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.security.oauth2.core.AuthenticationMethod; import org.springframework.security.oauth2.core.AuthorizationGrantType; @@ -31,6 +37,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** * Tests for {@link ClientRegistration}. @@ -753,4 +760,86 @@ public void buildWhenCustomClientAuthenticationMethodProvidedThenSet() { assertThat(clientRegistration.getClientAuthenticationMethod()).isEqualTo(clientAuthenticationMethod); } + @Test + void clientSettingsWhenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ClientRegistration.withRegistrationId(REGISTRATION_ID).clientSettings(null)); + } + + // gh-16382 + @Test + void buildWhenDefaultClientSettingsThenDefaulted() { + ClientRegistration clientRegistration = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI) + .build(); + + // should not be null + assertThat(clientRegistration.getClientSettings()).isNotNull(); + // proof key should be false for passivity + assertThat(clientRegistration.getClientSettings().isRequireProofKey()).isFalse(); + } + + // gh-16382 + @Test + void buildWhenNewAuthorizationCodeAndPkceThenBuilds() { + ClientRegistration.ClientSettings pkceEnabled = ClientRegistration.ClientSettings.builder() + .requireProofKey(true) + .build(); + ClientRegistration clientRegistration = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .clientSettings(pkceEnabled) + .authorizationGrantType(new AuthorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI) + .build(); + + // proof key should be false for passivity + assertThat(clientRegistration.getClientSettings().isRequireProofKey()).isTrue(); + } + + @ParameterizedTest + @MethodSource("invalidPkceGrantTypes") + void buildWhenInvalidGrantTypeForPkceThenException(AuthorizationGrantType invalidGrantType) { + ClientRegistration.ClientSettings pkceEnabled = ClientRegistration.ClientSettings.builder() + .requireProofKey(true) + .build(); + ClientRegistration.Builder builder = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .clientSettings(pkceEnabled) + .authorizationGrantType(invalidGrantType) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI); + + assertThatIllegalStateException().describedAs( + "clientSettings.isRequireProofKey=true is only valid with authorizationGrantType=AUTHORIZATION_CODE. Got authorizationGrantType={}", + invalidGrantType) + .isThrownBy(builder::build); + } + + static List invalidPkceGrantTypes() { + return Arrays.stream(AuthorizationGrantType.class.getFields()) + .filter((field) -> Modifier.isFinal(field.getModifiers()) + && field.getType() == AuthorizationGrantType.class) + .map((field) -> getStaticValue(field, AuthorizationGrantType.class)) + .filter((grantType) -> grantType != AuthorizationGrantType.AUTHORIZATION_CODE) + // ensure works with .equals + .map((grantType) -> new AuthorizationGrantType(grantType.getValue())) + .collect(Collectors.toList()); + } + + private static T getStaticValue(Field field, Class clazz) { + try { + return (T) field.get(null); + } + catch (IllegalAccessException ex) { + throw new RuntimeException(ex); + } + } + } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolverTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolverTests.java index c10a3f82cfc..a0abf7132e4 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolverTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolverTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 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. @@ -56,6 +56,8 @@ public class DefaultOAuth2AuthorizationRequestResolverTests { private ClientRegistration registration2; + private ClientRegistration pkceClientRegistration; + private ClientRegistration fineRedirectUriTemplateRegistration; private ClientRegistration publicClientRegistration; @@ -72,6 +74,9 @@ public class DefaultOAuth2AuthorizationRequestResolverTests { public void setUp() { this.registration1 = TestClientRegistrations.clientRegistration().build(); this.registration2 = TestClientRegistrations.clientRegistration2().build(); + + this.pkceClientRegistration = pkceClientRegistration().build(); + this.fineRedirectUriTemplateRegistration = fineRedirectUriTemplateClientRegistration().build(); // @formatter:off this.publicClientRegistration = TestClientRegistrations.clientRegistration() @@ -86,8 +91,8 @@ public void setUp() { .build(); // @formatter:on this.clientRegistrationRepository = new InMemoryClientRegistrationRepository(this.registration1, - this.registration2, this.fineRedirectUriTemplateRegistration, this.publicClientRegistration, - this.oidcRegistration); + this.registration2, this.pkceClientRegistration, this.fineRedirectUriTemplateRegistration, + this.publicClientRegistration, this.oidcRegistration); this.resolver = new DefaultOAuth2AuthorizationRequestResolver(this.clientRegistrationRepository, this.authorizationRequestBaseUri); } @@ -563,6 +568,32 @@ public void resolveWhenAuthorizationRequestCustomizerOverridesParameterThenQuery + "nonce=([a-zA-Z0-9\\-\\.\\_\\~]){43}&" + "appid=client-id"); } + @Test + public void resolveWhenAuthorizationRequestProvideCodeChallengeMethod() { + ClientRegistration clientRegistration = this.pkceClientRegistration; + String requestUri = this.authorizationRequestBaseUri + "/" + clientRegistration.getRegistrationId(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); + request.setServletPath(requestUri); + OAuth2AuthorizationRequest authorizationRequest = this.resolver.resolve(request); + assertThat(authorizationRequest.getAdditionalParameters().containsKey(PkceParameterNames.CODE_CHALLENGE_METHOD)) + .isTrue(); + } + + private static ClientRegistration.Builder pkceClientRegistration() { + return ClientRegistration.withRegistrationId("pkce") + .redirectUri("{baseUrl}/{action}/oauth2/code/{registrationId}") + .clientSettings(ClientRegistration.ClientSettings.builder().requireProofKey(true).build()) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .scope("read:user") + .authorizationUri("https://example.com/login/oauth/authorize") + .tokenUri("https://example.com/login/oauth/access_token") + .userInfoUri("https://api.example.com/user") + .userNameAttributeName("id") + .clientName("Client Name") + .clientId("client-id-3") + .clientSecret("client-secret"); + } + private static ClientRegistration.Builder fineRedirectUriTemplateClientRegistration() { // @formatter:off return ClientRegistration.withRegistrationId("fine-redirect-uri-template-client-registration") diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/server/DefaultServerOAuth2AuthorizationRequestResolverTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/server/DefaultServerOAuth2AuthorizationRequestResolverTests.java index ec293997f5e..bf7ab096784 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/server/DefaultServerOAuth2AuthorizationRequestResolverTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/server/DefaultServerOAuth2AuthorizationRequestResolverTests.java @@ -169,6 +169,22 @@ public void resolveWhenAuthorizationRequestApplyPkceToSpecificConfidentialClient assertPkceNotApplied(request, registration2); } + @Test + void resolveWhenRequireProofKeyTrueThenPkceEnabled() { + ClientRegistration.ClientSettings pkceEnabled = ClientRegistration.ClientSettings.builder() + .requireProofKey(true) + .build(); + ClientRegistration clientWithPkceEnabled = TestClientRegistrations.clientRegistration() + .clientSettings(pkceEnabled) + .build(); + given(this.clientRegistrationRepository.findByRegistrationId(any())) + .willReturn(Mono.just(clientWithPkceEnabled)); + + OAuth2AuthorizationRequest request = resolve( + "/oauth2/authorization/" + clientWithPkceEnabled.getRegistrationId()); + assertPkceApplied(request, clientWithPkceEnabled); + } + private void assertPkceApplied(OAuth2AuthorizationRequest authorizationRequest, ClientRegistration clientRegistration) { assertThat(authorizationRequest.getAdditionalParameters()).containsKey(PkceParameterNames.CODE_CHALLENGE); diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AuthorizationGrantType.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AuthorizationGrantType.java index e1321bd7595..433811a781a 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AuthorizationGrantType.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AuthorizationGrantType.java @@ -111,4 +111,9 @@ public int hashCode() { return this.getValue().hashCode(); } + @Override + public String toString() { + return "AuthorizationGrantType{" + "value='" + this.value + '\'' + '}'; + } + } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AccessToken.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AccessToken.java index d288503f131..ea1124d0412 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AccessToken.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AccessToken.java @@ -107,9 +107,19 @@ public static final class TokenType implements Serializable { public static final TokenType BEARER = new TokenType("Bearer"); + /** + * @since 6.5 + */ + public static final TokenType DPOP = new TokenType("DPoP"); + private final String value; - private TokenType(String value) { + /** + * Constructs a {@code TokenType} using the provided value. + * @param value the value of the token type + * @since 6.5 + */ + public TokenType(String value) { Assert.hasText(value, "value cannot be empty"); this.value = value; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticationException.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticationException.java index a868f3180de..ac760c5dc43 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticationException.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticationException.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.core; +import java.io.Serial; + import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.util.Assert; @@ -41,6 +43,9 @@ */ public class OAuth2AuthenticationException extends AuthenticationException { + @Serial + private static final long serialVersionUID = -7832130893085581438L; + private final OAuth2Error error; /** diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthorizationException.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthorizationException.java index dbfdf98e5f0..af833d1dae4 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthorizationException.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthorizationException.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.core; +import java.io.Serial; + import org.springframework.util.Assert; /** @@ -26,6 +28,9 @@ */ public class OAuth2AuthorizationException extends RuntimeException { + @Serial + private static final long serialVersionUID = -5470222190376181102L; + private final OAuth2Error error; /** diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/BadJwtException.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/BadJwtException.java index 3a30545179d..2742d0c51ec 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/BadJwtException.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/BadJwtException.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.jwt; +import java.io.Serial; + /** * An exception similar to * {@link org.springframework.security.authentication.BadCredentialsException} that @@ -26,6 +28,9 @@ */ public class BadJwtException extends JwtException { + @Serial + private static final long serialVersionUID = 7748429527132280501L; + public BadJwtException(String message) { super(message); } diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoderInitializationException.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoderInitializationException.java index 775da4c9a92..cd1b90a14cb 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoderInitializationException.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoderInitializationException.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.jwt; +import java.io.Serial; + /** * An exception thrown when a {@link JwtDecoder} or {@link ReactiveJwtDecoder}'s lazy * initialization fails. @@ -25,6 +27,9 @@ */ public class JwtDecoderInitializationException extends RuntimeException { + @Serial + private static final long serialVersionUID = 2786360018315628982L; + public JwtDecoderInitializationException(String message, Throwable cause) { super(message, cause); } diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncodingException.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncodingException.java index 9b48f5c4a2d..365993c5edc 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncodingException.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncodingException.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.jwt; +import java.io.Serial; + /** * This exception is thrown when an error occurs while attempting to encode a JSON Web * Token (JWT). @@ -25,6 +27,9 @@ */ public class JwtEncodingException extends JwtException { + @Serial + private static final long serialVersionUID = 6581840872589902213L; + /** * Constructs a {@code JwtEncodingException} using the provided parameters. * @param message the detail message diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtException.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtException.java index b13f0dff265..2004727ffb7 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtException.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtException.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.jwt; +import java.io.Serial; + /** * Base exception for all JSON Web Token (JWT) related errors. * @@ -24,6 +26,9 @@ */ public class JwtException extends RuntimeException { + @Serial + private static final long serialVersionUID = -3070197880233583797L; + /** * Constructs a {@code JwtException} using the provided parameters. * @param message the detail message diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidationException.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidationException.java index 94568d2dc6b..ab3722e5fdc 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidationException.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidationException.java @@ -16,6 +16,7 @@ package org.springframework.security.oauth2.jwt; +import java.io.Serial; import java.util.ArrayList; import java.util.Collection; @@ -31,6 +32,9 @@ */ public class JwtValidationException extends BadJwtException { + @Serial + private static final long serialVersionUID = 134652048447295615L; + private final Collection errors; /** diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.java index 2713ee96b2d..732ecc2476a 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,12 @@ package org.springframework.security.oauth2.jwt; +import com.nimbusds.jose.KeySourceException; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKMatcher; +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.source.JWKSetParseException; +import com.nimbusds.jose.jwk.source.JWKSetRetrievalException; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; @@ -26,8 +32,10 @@ import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; import java.util.function.Function; @@ -35,17 +43,12 @@ import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; -import com.nimbusds.jose.RemoteKeySourceException; import com.nimbusds.jose.jwk.JWKSet; -import com.nimbusds.jose.jwk.source.JWKSetCache; import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.jwk.source.RemoteJWKSet; import com.nimbusds.jose.proc.JWSKeySelector; import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jose.proc.SingleKeyJWSKeySelector; -import com.nimbusds.jose.util.Resource; -import com.nimbusds.jose.util.ResourceRetriever; import com.nimbusds.jwt.JWT; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.JWTParser; @@ -57,6 +60,7 @@ import org.apache.commons.logging.LogFactory; import org.springframework.cache.Cache; +import org.springframework.cache.support.NoOpCache; import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -80,6 +84,7 @@ * @author Josh Cummings * @author Joe Grandja * @author Mykyta Bezverkhyi + * @author Daeho Kwon * @since 5.2 */ public final class NimbusJwtDecoder implements JwtDecoder { @@ -165,7 +170,7 @@ private Jwt createJwt(String token, JWT parsedJwt) { .build(); // @formatter:on } - catch (RemoteKeySourceException ex) { + catch (KeySourceException ex) { this.logger.trace("Failed to retrieve JWK set", ex); if (ex.getCause() instanceof ParseException) { throw new JwtException(String.format(DECODING_ERROR_MESSAGE_TEMPLATE, "Malformed Jwk set"), ex); @@ -273,7 +278,7 @@ public static final class JwkSetUriJwtDecoderBuilder { private RestOperations restOperations = new RestTemplate(); - private Cache cache; + private Cache cache = new NoOpCache("default"); private Consumer> jwtProcessorCustomizer; @@ -376,18 +381,13 @@ JWSKeySelector jwsKeySelector(JWKSource jwkSou return new JWSVerificationKeySelector<>(jwsAlgorithms, jwkSource); } - JWKSource jwkSource(ResourceRetriever jwkSetRetriever, String jwkSetUri) { - if (this.cache == null) { - return new RemoteJWKSet<>(toURL(jwkSetUri), jwkSetRetriever); - } - JWKSetCache jwkSetCache = new SpringJWKSetCache(jwkSetUri, this.cache); - return new RemoteJWKSet<>(toURL(jwkSetUri), jwkSetRetriever, jwkSetCache); + JWKSource jwkSource() { + String jwkSetUri = this.jwkSetUri.apply(this.restOperations); + return new SpringJWKSource<>(this.restOperations, this.cache, toURL(jwkSetUri), jwkSetUri); } JWTProcessor processor() { - ResourceRetriever jwkSetRetriever = new RestOperationsResourceRetriever(this.restOperations); - String jwkSetUri = this.jwkSetUri.apply(this.restOperations); - JWKSource jwkSource = jwkSource(jwkSetRetriever, jwkSetUri); + JWKSource jwkSource = jwkSource(); ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); jwtProcessor.setJWSKeySelector(jwsKeySelector(jwkSource)); // Spring Security validates the claim set independent from Nimbus @@ -414,84 +414,130 @@ private static URL toURL(String url) { } } - private static final class SpringJWKSetCache implements JWKSetCache { + private static final class SpringJWKSource implements JWKSource { - private final String jwkSetUri; + private static final MediaType APPLICATION_JWK_SET_JSON = new MediaType("application", "jwk-set+json"); + + private final ReentrantLock reentrantLock = new ReentrantLock(); + + private final RestOperations restOperations; private final Cache cache; - private JWKSet jwkSet; + private final URL url; - SpringJWKSetCache(String jwkSetUri, Cache cache) { - this.jwkSetUri = jwkSetUri; + private final String jwkSetUri; + + private SpringJWKSource(RestOperations restOperations, Cache cache, URL url, String jwkSetUri) { + Assert.notNull(restOperations, "restOperations cannot be null"); + this.restOperations = restOperations; this.cache = cache; - this.updateJwkSetFromCache(); + this.url = url; + this.jwkSetUri = jwkSetUri; } - private void updateJwkSetFromCache() { + + @Override + public List get(JWKSelector jwkSelector, SecurityContext context) throws KeySourceException { String cachedJwkSet = this.cache.get(this.jwkSetUri, String.class); + JWKSet jwkSet = null; if (cachedJwkSet != null) { - try { - this.jwkSet = JWKSet.parse(cachedJwkSet); - } - catch (ParseException ignored) { - // Ignore invalid cache value + jwkSet = parse(cachedJwkSet); + } + if (jwkSet == null) { + if(reentrantLock.tryLock()) { + try { + String cachedJwkSetAfterLock = this.cache.get(this.jwkSetUri, String.class); + if (cachedJwkSetAfterLock != null) { + jwkSet = parse(cachedJwkSetAfterLock); + } + if(jwkSet == null) { + try { + jwkSet = fetchJWKSet(); + } catch (IOException e) { + throw new JWKSetRetrievalException("Couldn't retrieve JWK set from URL: " + e.getMessage(), e); + } + } + } finally { + reentrantLock.unlock(); + } } } - } - - // Note: Only called from inside a synchronized block in RemoteJWKSet. - @Override - public void put(JWKSet jwkSet) { - this.jwkSet = jwkSet; - this.cache.put(this.jwkSetUri, jwkSet.toString(false)); - } - - @Override - public JWKSet get() { - return (!requiresRefresh()) ? this.jwkSet : null; - - } - - @Override - public boolean requiresRefresh() { - return this.cache.get(this.jwkSetUri) == null; - } - - } - - private static class RestOperationsResourceRetriever implements ResourceRetriever { - - private static final MediaType APPLICATION_JWK_SET_JSON = new MediaType("application", "jwk-set+json"); - - private final RestOperations restOperations; + List matches = jwkSelector.select(jwkSet); + if(!matches.isEmpty()) { + return matches; + } + String soughtKeyID = getFirstSpecifiedKeyID(jwkSelector.getMatcher()); + if (soughtKeyID == null) { + return Collections.emptyList(); + } + if (jwkSet.getKeyByKeyId(soughtKeyID) != null) { + return Collections.emptyList(); + } - RestOperationsResourceRetriever(RestOperations restOperations) { - Assert.notNull(restOperations, "restOperations cannot be null"); - this.restOperations = restOperations; + if(reentrantLock.tryLock()) { + try { + String jwkSetUri = this.cache.get(this.jwkSetUri, String.class); + JWKSet cacheJwkSet = parse(jwkSetUri); + if(jwkSetUri != null && cacheJwkSet.toString().equals(jwkSet.toString())) { + try { + jwkSet = fetchJWKSet(); + } catch (IOException e) { + throw new JWKSetRetrievalException("Couldn't retrieve JWK set from URL: " + e.getMessage(), e); + } + } else if (jwkSetUri != null) { + jwkSet = parse(jwkSetUri); + } + } finally { + reentrantLock.unlock(); + } + } + if(jwkSet == null) { + return Collections.emptyList(); + } + return jwkSelector.select(jwkSet); } - @Override - public Resource retrieveResource(URL url) throws IOException { + private JWKSet fetchJWKSet() throws IOException, KeySourceException { HttpHeaders headers = new HttpHeaders(); headers.setAccept(Arrays.asList(MediaType.APPLICATION_JSON, APPLICATION_JWK_SET_JSON)); - ResponseEntity response = getResponse(url, headers); + ResponseEntity response = getResponse(headers); if (response.getStatusCode().value() != 200) { throw new IOException(response.toString()); } - return new Resource(response.getBody(), "UTF-8"); + try { + String jwkSet = response.getBody(); + this.cache.put(this.jwkSetUri, jwkSet); + return JWKSet.parse(jwkSet); + } catch (ParseException e) { + throw new JWKSetParseException("Unable to parse JWK set", e); + } } - private ResponseEntity getResponse(URL url, HttpHeaders headers) throws IOException { + private ResponseEntity getResponse(HttpHeaders headers) throws IOException { try { - RequestEntity request = new RequestEntity<>(headers, HttpMethod.GET, url.toURI()); + RequestEntity request = new RequestEntity<>(headers, HttpMethod.GET, this.url.toURI()); return this.restOperations.exchange(request, String.class); - } - catch (Exception ex) { + } catch (Exception ex) { throw new IOException(ex); } } + private JWKSet parse(String cachedJwkSet) { + JWKSet jwkSet = null; + try { + jwkSet = JWKSet.parse(cachedJwkSet); + } catch (ParseException ignored) { + // Ignore invalid cache value + } + return jwkSet; + } + + private String getFirstSpecifiedKeyID(JWKMatcher jwkMatcher) { + Set keyIDs = jwkMatcher.getKeyIDs(); + return (keyIDs == null || keyIDs.isEmpty()) ? + null : keyIDs.stream().filter(id -> id != null).findFirst().orElse(null); + } } } diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtDecodersTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtDecodersTests.java index f343cd2b69a..378a6dbd416 100644 --- a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtDecodersTests.java +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtDecodersTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2025 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. @@ -308,6 +308,7 @@ private void prepareConfigurationResponse() { private void prepareConfigurationResponse(String body) { this.server.enqueue(response(body)); this.server.enqueue(response(JWK_SET)); + this.server.enqueue(response(JWK_SET)); // default NoOpCache } private void prepareConfigurationResponseOidc() { diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderTests.java index fb4535f240d..c45b4a958b7 100644 --- a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderTests.java +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 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. @@ -60,7 +60,6 @@ import org.springframework.cache.Cache; import org.springframework.cache.concurrent.ConcurrentMapCache; -import org.springframework.cache.support.SimpleValueWrapper; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpStatus; @@ -704,7 +703,6 @@ public void decodeWhenCacheThenRetrieveFromCache() throws Exception { RestOperations restOperations = mock(RestOperations.class); Cache cache = mock(Cache.class); given(cache.get(eq(JWK_SET_URI), eq(String.class))).willReturn(JWK_SET); - given(cache.get(eq(JWK_SET_URI))).willReturn(mock(Cache.ValueWrapper.class)); // @formatter:off NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_SET_URI) .cache(cache) @@ -713,7 +711,6 @@ public void decodeWhenCacheThenRetrieveFromCache() throws Exception { // @formatter:on jwtDecoder.decode(SIGNED_JWT); verify(cache).get(eq(JWK_SET_URI), eq(String.class)); - verify(cache, times(2)).get(eq(JWK_SET_URI)); verifyNoMoreInteractions(cache); verifyNoInteractions(restOperations); } @@ -724,7 +721,6 @@ public void decodeWhenCacheAndUnknownKidShouldTriggerFetchOfJwkSet() throws JOSE RestOperations restOperations = mock(RestOperations.class); Cache cache = mock(Cache.class); given(cache.get(eq(JWK_SET_URI), eq(String.class))).willReturn(JWK_SET); - given(cache.get(eq(JWK_SET_URI))).willReturn(new SimpleValueWrapper(JWK_SET)); given(restOperations.exchange(any(RequestEntity.class), eq(String.class))) .willReturn(new ResponseEntity<>(NEW_KID_JWK_SET, HttpStatus.OK)); @@ -796,7 +792,6 @@ public void decodeWhenCacheIsConfiguredAndParseFailsOnCachedValueThenExceptionIg RestOperations restOperations = mock(RestOperations.class); Cache cache = mock(Cache.class); given(cache.get(eq(JWK_SET_URI), eq(String.class))).willReturn(JWK_SET); - given(cache.get(eq(JWK_SET_URI))).willReturn(mock(Cache.ValueWrapper.class)); // @formatter:off NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_SET_URI) .cache(cache) @@ -805,7 +800,6 @@ public void decodeWhenCacheIsConfiguredAndParseFailsOnCachedValueThenExceptionIg // @formatter:on jwtDecoder.decode(SIGNED_JWT); verify(cache).get(eq(JWK_SET_URI), eq(String.class)); - verify(cache, times(2)).get(eq(JWK_SET_URI)); verifyNoMoreInteractions(cache); verifyNoInteractions(restOperations); diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/InvalidBearerTokenException.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/InvalidBearerTokenException.java index 0ba62813da7..c82b3bd5e49 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/InvalidBearerTokenException.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/InvalidBearerTokenException.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.server.resource; +import java.io.Serial; + import org.springframework.security.oauth2.core.OAuth2AuthenticationException; /** @@ -26,6 +28,9 @@ */ public class InvalidBearerTokenException extends OAuth2AuthenticationException { + @Serial + private static final long serialVersionUID = 6904689954809100280L; + /** * Construct an instance of {@link InvalidBearerTokenException} given the provided * description. diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/BadOpaqueTokenException.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/BadOpaqueTokenException.java index 5e155c8bce2..cddd32c3b0c 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/BadOpaqueTokenException.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/BadOpaqueTokenException.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.server.resource.introspection; +import java.io.Serial; + /** * An exception similar to * {@link org.springframework.security.authentication.BadCredentialsException} that @@ -26,6 +28,9 @@ */ public class BadOpaqueTokenException extends OAuth2IntrospectionException { + @Serial + private static final long serialVersionUID = -6937847463454551076L; + public BadOpaqueTokenException(String message) { super(message); } diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2IntrospectionException.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2IntrospectionException.java index e2649ba975c..6650d96e572 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2IntrospectionException.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/OAuth2IntrospectionException.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.server.resource.introspection; +import java.io.Serial; + /** * Base exception for all OAuth 2.0 Introspection related errors * @@ -24,6 +26,9 @@ */ public class OAuth2IntrospectionException extends RuntimeException { + @Serial + private static final long serialVersionUID = -7327790383594166793L; + public OAuth2IntrospectionException(String message) { super(message); } diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospector.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospector.java index 4674ab78868..ef9b4f3309d 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospector.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospector.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -18,6 +18,8 @@ import java.io.Serial; import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; @@ -77,9 +79,11 @@ public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector { /** * Creates a {@code OpaqueTokenAuthenticationProvider} with the provided parameters * @param introspectionUri The introspection endpoint uri - * @param clientId The client id authorized to introspect - * @param clientSecret The client's secret + * @param clientId The URL-encoded client id authorized to introspect + * @param clientSecret The URL-encoded client secret authorized to introspect + * @deprecated Please use {@link SpringOpaqueTokenIntrospector.Builder} */ + @Deprecated(since = "6.5", forRemoval = true) public SpringOpaqueTokenIntrospector(String introspectionUri, String clientId, String clientSecret) { Assert.notNull(introspectionUri, "introspectionUri cannot be null"); Assert.notNull(clientId, "clientId cannot be null"); @@ -269,6 +273,18 @@ private Collection authorities(List scopes) { return authorities; } + /** + * Creates a {@code SpringOpaqueTokenIntrospector.Builder} with the given + * introspection endpoint uri + * @param introspectionUri The introspection endpoint uri + * @return the {@link SpringOpaqueTokenIntrospector.Builder} + * @since 6.5 + */ + public static Builder withIntrospectionUri(String introspectionUri) { + Assert.notNull(introspectionUri, "introspectionUri cannot be null"); + return new Builder(introspectionUri); + } + // gh-7563 private static final class ArrayListFromString extends ArrayList { @@ -295,4 +311,61 @@ default List getScopes() { } + /** + * Used to build {@link SpringOpaqueTokenIntrospector}. + * + * @author Ngoc Nhan + * @since 6.5 + */ + public static final class Builder { + + private final String introspectionUri; + + private String clientId; + + private String clientSecret; + + private Builder(String introspectionUri) { + this.introspectionUri = introspectionUri; + } + + /** + * The builder will {@link URLEncoder encode} the client id that you provide, so + * please give the unencoded value. + * @param clientId The unencoded client id + * @return the {@link SpringOpaqueTokenIntrospector.Builder} + * @since 6.5 + */ + public Builder clientId(String clientId) { + Assert.notNull(clientId, "clientId cannot be null"); + this.clientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8); + return this; + } + + /** + * The builder will {@link URLEncoder encode} the client secret that you provide, + * so please give the unencoded value. + * @param clientSecret The unencoded client secret + * @return the {@link SpringOpaqueTokenIntrospector.Builder} + * @since 6.5 + */ + public Builder clientSecret(String clientSecret) { + Assert.notNull(clientSecret, "clientSecret cannot be null"); + this.clientSecret = URLEncoder.encode(clientSecret, StandardCharsets.UTF_8); + return this; + } + + /** + * Creates a {@code SpringOpaqueTokenIntrospector} + * @return the {@link SpringOpaqueTokenIntrospector} + * @since 6.5 + */ + public SpringOpaqueTokenIntrospector build() { + RestTemplate restTemplate = new RestTemplate(); + restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(this.clientId, this.clientSecret)); + return new SpringOpaqueTokenIntrospector(this.introspectionUri, restTemplate); + } + + } + } diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospector.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospector.java index 7c6bf8ecb05..283317f95ea 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospector.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospector.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -18,6 +18,8 @@ import java.io.Serial; import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; @@ -72,9 +74,11 @@ public class SpringReactiveOpaqueTokenIntrospector implements ReactiveOpaqueToke * Creates a {@code OpaqueTokenReactiveAuthenticationManager} with the provided * parameters * @param introspectionUri The introspection endpoint uri - * @param clientId The client id authorized to introspect - * @param clientSecret The client secret for the authorized client + * @param clientId The URL-encoded client id authorized to introspect + * @param clientSecret The URL-encoded client secret authorized to introspect + * @deprecated Please use {@link SpringReactiveOpaqueTokenIntrospector.Builder} */ + @Deprecated(since = "6.5", forRemoval = true) public SpringReactiveOpaqueTokenIntrospector(String introspectionUri, String clientId, String clientSecret) { Assert.hasText(introspectionUri, "introspectionUri cannot be empty"); Assert.hasText(clientId, "clientId cannot be empty"); @@ -223,6 +227,18 @@ private Collection authorities(List scopes) { return authorities; } + /** + * Creates a {@code SpringReactiveOpaqueTokenIntrospector.Builder} with the given + * introspection endpoint uri + * @param introspectionUri The introspection endpoint uri + * @return the {@link SpringReactiveOpaqueTokenIntrospector.Builder} + * @since 6.5 + */ + public static Builder withIntrospectionUri(String introspectionUri) { + + return new Builder(introspectionUri); + } + // gh-7563 private static final class ArrayListFromString extends ArrayList { @@ -249,4 +265,62 @@ default List getScopes() { } + /** + * Used to build {@link SpringReactiveOpaqueTokenIntrospector}. + * + * @author Ngoc Nhan + * @since 6.5 + */ + public static final class Builder { + + private final String introspectionUri; + + private String clientId; + + private String clientSecret; + + private Builder(String introspectionUri) { + this.introspectionUri = introspectionUri; + } + + /** + * The builder will {@link URLEncoder encode} the client id that you provide, so + * please give the unencoded value. + * @param clientId The unencoded client id + * @return the {@link SpringReactiveOpaqueTokenIntrospector.Builder} + * @since 6.5 + */ + public Builder clientId(String clientId) { + Assert.notNull(clientId, "clientId cannot be null"); + this.clientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8); + return this; + } + + /** + * The builder will {@link URLEncoder encode} the client secret that you provide, + * so please give the unencoded value. + * @param clientSecret The unencoded client secret + * @return the {@link SpringReactiveOpaqueTokenIntrospector.Builder} + * @since 6.5 + */ + public Builder clientSecret(String clientSecret) { + Assert.notNull(clientSecret, "clientSecret cannot be null"); + this.clientSecret = URLEncoder.encode(clientSecret, StandardCharsets.UTF_8); + return this; + } + + /** + * Creates a {@code SpringReactiveOpaqueTokenIntrospector} + * @return the {@link SpringReactiveOpaqueTokenIntrospector} + * @since 6.5 + */ + public SpringReactiveOpaqueTokenIntrospector build() { + WebClient webClient = WebClient.builder() + .defaultHeaders((h) -> h.setBasicAuth(this.clientId, this.clientSecret)) + .build(); + return new SpringReactiveOpaqueTokenIntrospector(this.introspectionUri, webClient); + } + + } + } diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospectorTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospectorTests.java index 01555f01fd4..32afbf67987 100644 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospectorTests.java +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospectorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -339,6 +339,50 @@ public void setAuthenticationConverterWhenNonNullConverterGivenThenConverterUsed verify(authenticationConverter).convert(any()); } + @Test + public void introspectWithoutEncodeClientCredentialsThenExceptionIsThrown() throws Exception { + try (MockWebServer server = new MockWebServer()) { + String response = """ + { + "active": true, + "username": "client%&1" + } + """; + server.setDispatcher(requiresAuth("client%25%261", "secret%40%242", response)); + String introspectUri = server.url("/introspect").toString(); + OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(introspectUri, "client%&1", + "secret@$2"); + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token")); + } + } + + @Test + public void introspectWithEncodeClientCredentialsThenOk() throws Exception { + try (MockWebServer server = new MockWebServer()) { + String response = """ + { + "active": true, + "username": "client&1" + } + """; + server.setDispatcher(requiresAuth("client%261", "secret%40%242", response)); + String introspectUri = server.url("/introspect").toString(); + OpaqueTokenIntrospector introspectionClient = SpringOpaqueTokenIntrospector + .withIntrospectionUri(introspectUri) + .clientId("client&1") + .clientSecret("secret@$2") + .build(); + OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token"); + // @formatter:off + assertThat(authority.getAttributes()) + .isNotNull() + .containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true) + .containsEntry(OAuth2TokenIntrospectionClaimNames.USERNAME, "client&1"); + // @formatter:on + } + } + private static ResponseEntity> response(String content) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospectorTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospectorTests.java index ae0f01afd7f..8fe12983607 100644 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospectorTests.java +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospectorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -261,6 +261,52 @@ public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() { .isThrownBy(() -> new SpringReactiveOpaqueTokenIntrospector(INTROSPECTION_URL, null)); } + @Test + public void introspectWithoutEncodeClientCredentialsThenExceptionIsThrown() throws Exception { + try (MockWebServer server = new MockWebServer()) { + String response = """ + { + "active": true, + "username": "client%&1" + } + """; + server.setDispatcher(requiresAuth("client%25%261", "secret%40%242", response)); + String introspectUri = server.url("/introspect").toString(); + ReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector( + introspectUri, "client%&1", "secret@$2"); + // @formatter:off + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token").block()); + // @formatter:on + } + } + + @Test + public void introspectWithEncodeClientCredentialsThenOk() throws Exception { + try (MockWebServer server = new MockWebServer()) { + String response = """ + { + "active": true, + "username": "client&1" + } + """; + server.setDispatcher(requiresAuth("client%261", "secret%40%242", response)); + String introspectUri = server.url("/introspect").toString(); + ReactiveOpaqueTokenIntrospector introspectionClient = SpringReactiveOpaqueTokenIntrospector + .withIntrospectionUri(introspectUri) + .clientId("client&1") + .clientSecret("secret@$2") + .build(); + OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token").block(); + // @formatter:off + assertThat(authority.getAttributes()) + .isNotNull() + .containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true) + .containsEntry(OAuth2TokenIntrospectionClaimNames.USERNAME, "client&1"); + // @formatter:on + } + } + private WebClient mockResponse(String response) { return mockResponse(toMap(response)); } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/Saml2Exception.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/Saml2Exception.java index dc4e6bb770d..3595dec00ae 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/Saml2Exception.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/Saml2Exception.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2024 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. @@ -16,11 +16,16 @@ package org.springframework.security.saml2; +import java.io.Serial; + /** * @since 5.2 */ public class Saml2Exception extends RuntimeException { + @Serial + private static final long serialVersionUID = 6076252564189633016L; + public Saml2Exception(String message) { super(message); } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/Saml2Jackson2Module.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/Saml2Jackson2Module.java index 025ffc6b36b..3d99fc2cfa7 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/Saml2Jackson2Module.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/Saml2Jackson2Module.java @@ -39,6 +39,7 @@ * @since 5.7 * @see SecurityJackson2Modules */ +@SuppressWarnings("serial") public class Saml2Jackson2Module extends SimpleModule { public Saml2Jackson2Module() { diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationException.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationException.java index 6ee38c6d60a..36075ba0df1 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationException.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 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. @@ -16,6 +16,8 @@ package org.springframework.security.saml2.provider.service.authentication; +import java.io.Serial; + import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.saml2.core.Saml2Error; @@ -40,6 +42,9 @@ */ public class Saml2AuthenticationException extends AuthenticationException { + @Serial + private static final long serialVersionUID = -2996886630890949105L; + private final Saml2Error error; /** diff --git a/web/src/main/java/org/springframework/security/web/ObservationFilterChainDecorator.java b/web/src/main/java/org/springframework/security/web/ObservationFilterChainDecorator.java index ed24f19f910..6578d8dcb5e 100644 --- a/web/src/main/java/org/springframework/security/web/ObservationFilterChainDecorator.java +++ b/web/src/main/java/org/springframework/security/web/ObservationFilterChainDecorator.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 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. @@ -554,7 +554,7 @@ static final class FilterChainObservationConvention private static final String CHAIN_SIZE_NAME = "spring.security.filterchain.size"; - private static final String FILTER_SECTION_NAME = "security.security.reached.filter.section"; + private static final String FILTER_SECTION_NAME = "spring.security.reached.filter.section"; private static final String FILTER_NAME = "spring.security.reached.filter.name"; diff --git a/web/src/main/java/org/springframework/security/web/UnreachableFilterChainException.java b/web/src/main/java/org/springframework/security/web/UnreachableFilterChainException.java new file mode 100644 index 00000000000..ff697bd07d9 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/UnreachableFilterChainException.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2024 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; + +import org.springframework.security.core.SpringSecurityCoreVersion; + +/** + * Thrown if {@link SecurityFilterChain securityFilterChain} is not valid. + * + * @author Max Batischev + * @since 6.5 + */ +public class UnreachableFilterChainException extends IllegalArgumentException { + + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + private final SecurityFilterChain filterChain; + + private final SecurityFilterChain unreachableFilterChain; + + /** + * Constructs an UnreachableFilterChainException with the specified + * message. + * @param message the detail message + */ + public UnreachableFilterChainException(String message, SecurityFilterChain filterChain, + SecurityFilterChain unreachableFilterChain) { + super(message); + this.filterChain = filterChain; + this.unreachableFilterChain = unreachableFilterChain; + } + + public SecurityFilterChain getFilterChain() { + return this.filterChain; + } + + public SecurityFilterChain getUnreachableFilterChain() { + return this.unreachableFilterChain; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHints.java b/web/src/main/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHints.java new file mode 100644 index 00000000000..c35cf5b81c6 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHints.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 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.aot.hint; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository; + +/** + * + * A JDBC implementation of an {@link PublicKeyCredentialUserEntityRepository} that uses a + * {@link JdbcOperations} for {@link PublicKeyCredentialUserEntity} persistence. + * + * @author Max Batischev + * @since 6.5 + */ +class PublicKeyCredentialUserEntityRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + hints.resources().registerPattern("org/springframework/security/user-entities-schema.sql"); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHints.java b/web/src/main/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHints.java new file mode 100644 index 00000000000..c3b4c95a148 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHints.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 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.aot.hint; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.security.web.webauthn.api.CredentialRecord; +import org.springframework.security.web.webauthn.management.UserCredentialRepository; + +/** + * + * A JDBC implementation of an {@link UserCredentialRepository} that uses a + * {@link JdbcOperations} for {@link CredentialRecord} persistence. + * + * @author Max Batischev + * @since 6.5 + */ +class UserCredentialRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + hints.resources().registerPattern("org/springframework/security/user-credentials-schema.sql"); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java b/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java index 3bf2c6f0dbf..5e62d2ebeb6 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java +++ b/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java @@ -61,6 +61,7 @@ * @author colin sampaleanu * @author Omri Spector * @author Luke Taylor + * @author Michal Okosy * @since 3.0 */ public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean { @@ -77,6 +78,8 @@ public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoin private boolean useForward = false; + private boolean favorRelativeUris = false; + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); /** @@ -146,27 +149,38 @@ protected String buildRedirectUrlToLoginPage(HttpServletRequest request, HttpSer if (UrlUtils.isAbsoluteUrl(loginForm)) { return loginForm; } + if (requiresRewrite(request)) { + return httpsUri(request, loginForm); + } + return this.favorRelativeUris ? loginForm : absoluteUri(request, loginForm).getUrl(); + } + + private boolean requiresRewrite(HttpServletRequest request) { + return this.forceHttps && "http".equals(request.getScheme()); + } + + private String httpsUri(HttpServletRequest request, String path) { int serverPort = this.portResolver.getServerPort(request); - String scheme = request.getScheme(); + Integer httpsPort = this.portMapper.lookupHttpsPort(serverPort); + if (httpsPort == null) { + logger.warn(LogMessage.format("Unable to redirect to HTTPS as no port mapping found for HTTP port %s", + serverPort)); + return this.favorRelativeUris ? path : absoluteUri(request, path).getUrl(); + } + RedirectUrlBuilder builder = absoluteUri(request, path); + builder.setScheme("https"); + builder.setPort(httpsPort); + return builder.getUrl(); + } + + private RedirectUrlBuilder absoluteUri(HttpServletRequest request, String path) { RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder(); - urlBuilder.setScheme(scheme); + urlBuilder.setScheme(request.getScheme()); urlBuilder.setServerName(request.getServerName()); - urlBuilder.setPort(serverPort); + urlBuilder.setPort(this.portResolver.getServerPort(request)); urlBuilder.setContextPath(request.getContextPath()); - urlBuilder.setPathInfo(loginForm); - if (this.forceHttps && "http".equals(scheme)) { - Integer httpsPort = this.portMapper.lookupHttpsPort(serverPort); - if (httpsPort != null) { - // Overwrite scheme and port in the redirect URL - urlBuilder.setScheme("https"); - urlBuilder.setPort(httpsPort); - } - else { - logger.warn(LogMessage.format("Unable to redirect to HTTPS as no port mapping found for HTTP port %s", - serverPort)); - } - } - return urlBuilder.getUrl(); + urlBuilder.setPathInfo(path); + return urlBuilder; } /** @@ -244,4 +258,18 @@ protected boolean isUseForward() { return this.useForward; } + /** + * Favor using relative URIs when formulating a redirect. + * + *

+ * Note that a relative redirect is not always possible. For example, when redirecting + * from {@code http} to {@code https}, the URL needs to be absolute. + *

+ * @param favorRelativeUris whether to favor relative URIs or not + * @since 6.5 + */ + public void setFavorRelativeUris(boolean favorRelativeUris) { + this.favorRelativeUris = favorRelativeUris; + } + } diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolver.java b/web/src/main/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolver.java new file mode 100644 index 00000000000..87c5034905c --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolver.java @@ -0,0 +1,58 @@ +/* + * Copyright 2002-2025 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.authentication.ott; + +import java.time.Duration; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Default implementation of {@link GenerateOneTimeTokenRequestResolver}. Resolves + * {@link GenerateOneTimeTokenRequest} from username parameter. + * + * @author Max Batischev + * @since 6.5 + */ +public final class DefaultGenerateOneTimeTokenRequestResolver implements GenerateOneTimeTokenRequestResolver { + + private static final Duration DEFAULT_EXPIRES_IN = Duration.ofMinutes(5); + + private Duration expiresIn = DEFAULT_EXPIRES_IN; + + @Override + public GenerateOneTimeTokenRequest resolve(HttpServletRequest request) { + String username = request.getParameter("username"); + if (!StringUtils.hasText(username)) { + return null; + } + return new GenerateOneTimeTokenRequest(username, this.expiresIn); + } + + /** + * Sets one-time token expiration time + * @param expiresIn one-time token expiration time + */ + public void setExpiresIn(Duration expiresIn) { + Assert.notNull(expiresIn, "expiresAt cannot be null"); + this.expiresIn = expiresIn; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilter.java index 8c9cbf65b6e..2ad462993e5 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -49,6 +49,8 @@ public final class GenerateOneTimeTokenFilter extends OncePerRequestFilter { private RequestMatcher requestMatcher = antMatcher(HttpMethod.POST, "/ott/generate"); + private GenerateOneTimeTokenRequestResolver requestResolver = new DefaultGenerateOneTimeTokenRequestResolver(); + public GenerateOneTimeTokenFilter(OneTimeTokenService tokenService, OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler) { Assert.notNull(tokenService, "tokenService cannot be null"); @@ -69,8 +71,12 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); return; } - GenerateOneTimeTokenRequest generateRequest = new GenerateOneTimeTokenRequest(username); + GenerateOneTimeTokenRequest generateRequest = this.requestResolver.resolve(request); OneTimeToken ott = this.tokenService.generate(generateRequest); + if (generateRequest == null) { + filterChain.doFilter(request, response); + return; + } this.tokenGenerationSuccessHandler.handle(request, response, ott); } @@ -83,4 +89,15 @@ public void setRequestMatcher(RequestMatcher requestMatcher) { this.requestMatcher = requestMatcher; } + /** + * Use the given {@link GenerateOneTimeTokenRequestResolver} to resolve + * {@link GenerateOneTimeTokenRequest}. + * @param requestResolver {@link GenerateOneTimeTokenRequestResolver} + * @since 6.5 + */ + public void setRequestResolver(GenerateOneTimeTokenRequestResolver requestResolver) { + Assert.notNull(requestResolver, "requestResolver cannot be null"); + this.requestResolver = requestResolver; + } + } diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenRequestResolver.java b/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenRequestResolver.java new file mode 100644 index 00000000000..9fa8873ed2c --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenRequestResolver.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2025 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.authentication.ott; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; + +/** + * A strategy for resolving a {@link GenerateOneTimeTokenRequest} from the + * {@link HttpServletRequest}. + * + * @author Max Batischev + * @since 6.5 + */ +public interface GenerateOneTimeTokenRequestResolver { + + /** + * Resolves {@link GenerateOneTimeTokenRequest} from {@link HttpServletRequest} + * @param request {@link HttpServletRequest} to resolve + * @return {@link GenerateOneTimeTokenRequest} + */ + @Nullable + GenerateOneTimeTokenRequest resolve(HttpServletRequest request); + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/preauth/PreAuthenticatedCredentialsNotFoundException.java b/web/src/main/java/org/springframework/security/web/authentication/preauth/PreAuthenticatedCredentialsNotFoundException.java index d18e7c0cc2b..57767d815c5 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/preauth/PreAuthenticatedCredentialsNotFoundException.java +++ b/web/src/main/java/org/springframework/security/web/authentication/preauth/PreAuthenticatedCredentialsNotFoundException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2024 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. @@ -16,10 +16,15 @@ package org.springframework.security.web.authentication.preauth; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; public class PreAuthenticatedCredentialsNotFoundException extends AuthenticationException { + @Serial + private static final long serialVersionUID = 2026209817833032728L; + public PreAuthenticatedCredentialsNotFoundException(String msg) { super(msg); } diff --git a/web/src/main/java/org/springframework/security/web/authentication/rememberme/CookieTheftException.java b/web/src/main/java/org/springframework/security/web/authentication/rememberme/CookieTheftException.java index dabaedd8954..3a477e0c504 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/rememberme/CookieTheftException.java +++ b/web/src/main/java/org/springframework/security/web/authentication/rememberme/CookieTheftException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2024 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. @@ -16,11 +16,16 @@ package org.springframework.security.web.authentication.rememberme; +import java.io.Serial; + /** * @author Luke Taylor */ public class CookieTheftException extends RememberMeAuthenticationException { + @Serial + private static final long serialVersionUID = -7215039140728554850L; + public CookieTheftException(String message) { super(message); } diff --git a/web/src/main/java/org/springframework/security/web/authentication/rememberme/InvalidCookieException.java b/web/src/main/java/org/springframework/security/web/authentication/rememberme/InvalidCookieException.java index 00668e06d88..d434bbc47b4 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/rememberme/InvalidCookieException.java +++ b/web/src/main/java/org/springframework/security/web/authentication/rememberme/InvalidCookieException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2024 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. @@ -16,6 +16,8 @@ package org.springframework.security.web.authentication.rememberme; +import java.io.Serial; + /** * Exception thrown by a RememberMeServices implementation to indicate that a submitted * cookie is of an invalid format or has expired. @@ -24,6 +26,9 @@ */ public class InvalidCookieException extends RememberMeAuthenticationException { + @Serial + private static final long serialVersionUID = -7952247791921087125L; + public InvalidCookieException(String message) { super(message); } diff --git a/web/src/main/java/org/springframework/security/web/authentication/rememberme/RememberMeAuthenticationException.java b/web/src/main/java/org/springframework/security/web/authentication/rememberme/RememberMeAuthenticationException.java index dc727efa921..a1fc8c4ee89 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/rememberme/RememberMeAuthenticationException.java +++ b/web/src/main/java/org/springframework/security/web/authentication/rememberme/RememberMeAuthenticationException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2024 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. @@ -16,6 +16,8 @@ package org.springframework.security.web.authentication.rememberme; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; /** @@ -27,6 +29,9 @@ */ public class RememberMeAuthenticationException extends AuthenticationException { + @Serial + private static final long serialVersionUID = 7028526952590057426L; + /** * Constructs a {@code RememberMeAuthenticationException} with the specified message * and root cause. diff --git a/web/src/main/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategy.java b/web/src/main/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategy.java index 8d528f56212..51be7bd0ab4 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategy.java +++ b/web/src/main/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategy.java @@ -76,7 +76,7 @@ public class ConcurrentSessionControlAuthenticationStrategy private boolean exceptionIfMaximumExceeded = false; - private int maximumSessions = 1; + private SessionLimit sessionLimit = SessionLimit.of(1); /** * @param sessionRegistry the session registry which should be updated when the @@ -130,7 +130,7 @@ public void onAuthentication(Authentication authentication, HttpServletRequest r * @return either -1 meaning unlimited, or a positive integer to limit (never zero) */ protected int getMaximumSessionsForThisUser(Authentication authentication) { - return this.maximumSessions; + return this.sessionLimit.apply(authentication); } /** @@ -172,15 +172,24 @@ public void setExceptionIfMaximumExceeded(boolean exceptionIfMaximumExceeded) { } /** - * Sets the maxSessions property. The default value is 1. Use -1 for + * Sets the sessionLimit property. The default value is 1. Use -1 for * unlimited sessions. * @param maximumSessions the maximum number of permitted sessions a user can have * open simultaneously. */ public void setMaximumSessions(int maximumSessions) { - Assert.isTrue(maximumSessions != 0, - "MaximumLogins must be either -1 to allow unlimited logins, or a positive integer to specify a maximum"); - this.maximumSessions = maximumSessions; + this.sessionLimit = SessionLimit.of(maximumSessions); + } + + /** + * Sets the sessionLimit property. The default value is 1. Use -1 for + * unlimited sessions. + * @param sessionLimit the session limit strategy + * @since 6.5 + */ + public void setMaximumSessions(SessionLimit sessionLimit) { + Assert.notNull(sessionLimit, "sessionLimit cannot be null"); + this.sessionLimit = sessionLimit; } /** diff --git a/web/src/main/java/org/springframework/security/web/authentication/session/SessionAuthenticationException.java b/web/src/main/java/org/springframework/security/web/authentication/session/SessionAuthenticationException.java index db1650b3a9e..6ec0835f758 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/session/SessionAuthenticationException.java +++ b/web/src/main/java/org/springframework/security/web/authentication/session/SessionAuthenticationException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 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. @@ -16,6 +16,8 @@ package org.springframework.security.web.authentication.session; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; /** @@ -31,6 +33,9 @@ */ public class SessionAuthenticationException extends AuthenticationException { + @Serial + private static final long serialVersionUID = -2359914603911936474L; + public SessionAuthenticationException(String msg) { super(msg); } diff --git a/web/src/main/java/org/springframework/security/web/authentication/session/SessionFixationProtectionEvent.java b/web/src/main/java/org/springframework/security/web/authentication/session/SessionFixationProtectionEvent.java index 1b6c36deb36..f06cec22e34 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/session/SessionFixationProtectionEvent.java +++ b/web/src/main/java/org/springframework/security/web/authentication/session/SessionFixationProtectionEvent.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2013 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,8 @@ package org.springframework.security.web.authentication.session; +import java.io.Serial; + import org.springframework.security.authentication.event.AbstractAuthenticationEvent; import org.springframework.security.core.Authentication; import org.springframework.util.Assert; @@ -29,6 +31,9 @@ */ public class SessionFixationProtectionEvent extends AbstractAuthenticationEvent { + @Serial + private static final long serialVersionUID = -2554621992006921150L; + private final String oldSessionId; private final String newSessionId; diff --git a/web/src/main/java/org/springframework/security/web/authentication/session/SessionLimit.java b/web/src/main/java/org/springframework/security/web/authentication/session/SessionLimit.java new file mode 100644 index 00000000000..362f3a7f7d7 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/session/SessionLimit.java @@ -0,0 +1,49 @@ +/* + * Copyright 2015-2024 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.authentication.session; + +import java.util.function.Function; + +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; + +/** + * Represents the maximum number of sessions allowed. Use {@link #UNLIMITED} to indicate + * that there is no limit. + * + * @author Claudenir Freitas + * @since 6.5 + */ +public interface SessionLimit extends Function { + + /** + * Represents unlimited sessions. + */ + SessionLimit UNLIMITED = (authentication) -> -1; + + /** + * Creates a {@link SessionLimit} that always returns the given value for any user + * @param maxSessions the maximum number of sessions allowed + * @return a {@link SessionLimit} instance that returns the given value. + */ + static SessionLimit of(int maxSessions) { + Assert.isTrue(maxSessions != 0, + "MaximumLogins must be either -1 to allow unlimited logins, or a positive integer to specify a maximum"); + return (authentication) -> maxSessions; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/switchuser/AuthenticationSwitchUserEvent.java b/web/src/main/java/org/springframework/security/web/authentication/switchuser/AuthenticationSwitchUserEvent.java index 70ba6108bb0..5b7af22bf31 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/switchuser/AuthenticationSwitchUserEvent.java +++ b/web/src/main/java/org/springframework/security/web/authentication/switchuser/AuthenticationSwitchUserEvent.java @@ -16,6 +16,8 @@ package org.springframework.security.web.authentication.switchuser; +import java.io.Serial; + import org.springframework.security.authentication.event.AbstractAuthenticationEvent; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; @@ -27,6 +29,9 @@ */ public class AuthenticationSwitchUserEvent extends AbstractAuthenticationEvent { + @Serial + private static final long serialVersionUID = 6265996480231793939L; + private final UserDetails targetUser; /** diff --git a/web/src/main/java/org/springframework/security/web/authentication/www/NonceExpiredException.java b/web/src/main/java/org/springframework/security/web/authentication/www/NonceExpiredException.java index 8ac38137d03..6628a9e27a2 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/www/NonceExpiredException.java +++ b/web/src/main/java/org/springframework/security/web/authentication/www/NonceExpiredException.java @@ -16,6 +16,8 @@ package org.springframework.security.web.authentication.www; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; /** @@ -25,6 +27,9 @@ */ public class NonceExpiredException extends AuthenticationException { + @Serial + private static final long serialVersionUID = -3487244679050681257L; + /** * Constructs a NonceExpiredException with the specified message. * @param msg the detail message diff --git a/web/src/main/java/org/springframework/security/web/csrf/CsrfException.java b/web/src/main/java/org/springframework/security/web/csrf/CsrfException.java index c53541ac545..e18dc3961b9 100644 --- a/web/src/main/java/org/springframework/security/web/csrf/CsrfException.java +++ b/web/src/main/java/org/springframework/security/web/csrf/CsrfException.java @@ -16,6 +16,8 @@ package org.springframework.security.web.csrf; +import java.io.Serial; + import org.springframework.security.access.AccessDeniedException; /** @@ -24,9 +26,11 @@ * @author Rob Winch * @since 3.2 */ -@SuppressWarnings("serial") public class CsrfException extends AccessDeniedException { + @Serial + private static final long serialVersionUID = 7802567627837252670L; + public CsrfException(String message) { super(message); } diff --git a/web/src/main/java/org/springframework/security/web/csrf/CsrfTokenRequestAttributeHandler.java b/web/src/main/java/org/springframework/security/web/csrf/CsrfTokenRequestAttributeHandler.java index 621391651f3..a0950fa44b3 100644 --- a/web/src/main/java/org/springframework/security/web/csrf/CsrfTokenRequestAttributeHandler.java +++ b/web/src/main/java/org/springframework/security/web/csrf/CsrfTokenRequestAttributeHandler.java @@ -62,6 +62,7 @@ public void handle(HttpServletRequest request, HttpServletResponse response, request.setAttribute(csrfAttrName, csrfToken); } + @SuppressWarnings("serial") private static final class SupplierCsrfToken implements CsrfToken { private final Supplier csrfTokenSupplier; diff --git a/web/src/main/java/org/springframework/security/web/csrf/DefaultCsrfToken.java b/web/src/main/java/org/springframework/security/web/csrf/DefaultCsrfToken.java index 682be4b1dd4..122d95d1ce9 100644 --- a/web/src/main/java/org/springframework/security/web/csrf/DefaultCsrfToken.java +++ b/web/src/main/java/org/springframework/security/web/csrf/DefaultCsrfToken.java @@ -16,6 +16,8 @@ package org.springframework.security.web.csrf; +import java.io.Serial; + import org.springframework.util.Assert; /** @@ -24,9 +26,11 @@ * @author Rob Winch * @since 3.2 */ -@SuppressWarnings("serial") public final class DefaultCsrfToken implements CsrfToken { + @Serial + private static final long serialVersionUID = 6552658053267913685L; + private final String token; private final String parameterName; diff --git a/web/src/main/java/org/springframework/security/web/csrf/InvalidCsrfTokenException.java b/web/src/main/java/org/springframework/security/web/csrf/InvalidCsrfTokenException.java index 0c57e5a604d..bb4afac31d8 100644 --- a/web/src/main/java/org/springframework/security/web/csrf/InvalidCsrfTokenException.java +++ b/web/src/main/java/org/springframework/security/web/csrf/InvalidCsrfTokenException.java @@ -16,6 +16,8 @@ package org.springframework.security.web.csrf; +import java.io.Serial; + import jakarta.servlet.http.HttpServletRequest; /** @@ -25,9 +27,11 @@ * @author Rob Winch * @since 3.2 */ -@SuppressWarnings("serial") public class InvalidCsrfTokenException extends CsrfException { + @Serial + private static final long serialVersionUID = -7745955098435417418L; + /** * @param expectedAccessToken * @param actualAccessToken diff --git a/web/src/main/java/org/springframework/security/web/csrf/LazyCsrfTokenRepository.java b/web/src/main/java/org/springframework/security/web/csrf/LazyCsrfTokenRepository.java index 5a6a63f4bb0..a8326fa2a7d 100644 --- a/web/src/main/java/org/springframework/security/web/csrf/LazyCsrfTokenRepository.java +++ b/web/src/main/java/org/springframework/security/web/csrf/LazyCsrfTokenRepository.java @@ -159,6 +159,7 @@ public String toString() { } + @SuppressWarnings("serial") private static final class SaveOnAccessCsrfToken implements CsrfToken { private transient CsrfTokenRepository tokenRepository; diff --git a/web/src/main/java/org/springframework/security/web/firewall/RequestRejectedException.java b/web/src/main/java/org/springframework/security/web/firewall/RequestRejectedException.java index b997031a476..ea91775b62f 100644 --- a/web/src/main/java/org/springframework/security/web/firewall/RequestRejectedException.java +++ b/web/src/main/java/org/springframework/security/web/firewall/RequestRejectedException.java @@ -16,11 +16,16 @@ package org.springframework.security.web.firewall; +import java.io.Serial; + /** * @author Luke Taylor */ public class RequestRejectedException extends RuntimeException { + @Serial + private static final long serialVersionUID = 7226768874760909859L; + public RequestRejectedException(String message) { super(message); } diff --git a/web/src/main/java/org/springframework/security/web/jackson2/WebJackson2Module.java b/web/src/main/java/org/springframework/security/web/jackson2/WebJackson2Module.java index a54a55a96de..87daedcc40d 100644 --- a/web/src/main/java/org/springframework/security/web/jackson2/WebJackson2Module.java +++ b/web/src/main/java/org/springframework/security/web/jackson2/WebJackson2Module.java @@ -40,6 +40,7 @@ * @since 4.2 * @see SecurityJackson2Modules */ +@SuppressWarnings("serial") public class WebJackson2Module extends SimpleModule { public WebJackson2Module() { diff --git a/web/src/main/java/org/springframework/security/web/jackson2/WebServletJackson2Module.java b/web/src/main/java/org/springframework/security/web/jackson2/WebServletJackson2Module.java index 70b098e4fed..b5fd4d0777c 100644 --- a/web/src/main/java/org/springframework/security/web/jackson2/WebServletJackson2Module.java +++ b/web/src/main/java/org/springframework/security/web/jackson2/WebServletJackson2Module.java @@ -44,6 +44,7 @@ * @since 5.1 * @see SecurityJackson2Modules */ +@SuppressWarnings("serial") public class WebServletJackson2Module extends SimpleModule { public WebServletJackson2Module() { diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/SwitchUserWebFilter.java b/web/src/main/java/org/springframework/security/web/server/authentication/SwitchUserWebFilter.java index 8eab25cf1f6..85686f5815a 100644 --- a/web/src/main/java/org/springframework/security/web/server/authentication/SwitchUserWebFilter.java +++ b/web/src/main/java/org/springframework/security/web/server/authentication/SwitchUserWebFilter.java @@ -353,6 +353,7 @@ public void setSwitchUserMatcher(ServerWebExchangeMatcher switchUserMatcher) { this.switchUserMatcher = switchUserMatcher; } + @SuppressWarnings("serial") private static class SwitchUserAuthenticationException extends RuntimeException { SwitchUserAuthenticationException(AuthenticationException exception) { diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilter.java b/web/src/main/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilter.java index 8301e17dcf2..170d1d0b680 100644 --- a/web/src/main/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilter.java +++ b/web/src/main/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilter.java @@ -58,7 +58,6 @@ public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { // @formatter:off return this.matcher.matches(exchange) .filter(ServerWebExchangeMatcher.MatchResult::isMatch) - .switchIfEmpty(chain.filter(exchange).then(Mono.empty())) .then(exchange.getFormData()) .mapNotNull((data) -> data.getFirst(USERNAME)) .switchIfEmpty(chain.filter(exchange).then(Mono.empty())) diff --git a/web/src/main/java/org/springframework/security/web/server/csrf/CsrfException.java b/web/src/main/java/org/springframework/security/web/server/csrf/CsrfException.java index 631c5b7fdc0..bdb693e95ca 100644 --- a/web/src/main/java/org/springframework/security/web/server/csrf/CsrfException.java +++ b/web/src/main/java/org/springframework/security/web/server/csrf/CsrfException.java @@ -16,6 +16,8 @@ package org.springframework.security.web.server.csrf; +import java.io.Serial; + import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.csrf.CsrfToken; @@ -25,9 +27,11 @@ * @author Rob Winch * @since 3.2 */ -@SuppressWarnings("serial") public class CsrfException extends AccessDeniedException { + @Serial + private static final long serialVersionUID = -8209680716517631141L; + public CsrfException(String message) { super(message); } diff --git a/web/src/main/java/org/springframework/security/web/server/csrf/DefaultCsrfToken.java b/web/src/main/java/org/springframework/security/web/server/csrf/DefaultCsrfToken.java index eb49369e6fe..2a32018a5cb 100644 --- a/web/src/main/java/org/springframework/security/web/server/csrf/DefaultCsrfToken.java +++ b/web/src/main/java/org/springframework/security/web/server/csrf/DefaultCsrfToken.java @@ -16,6 +16,8 @@ package org.springframework.security.web.server.csrf; +import java.io.Serial; + import org.springframework.util.Assert; /** @@ -24,9 +26,11 @@ * @author Rob Winch * @since 5.0 */ -@SuppressWarnings("serial") public final class DefaultCsrfToken implements CsrfToken { + @Serial + private static final long serialVersionUID = 308340117851874929L; + private final String token; private final String parameterName; diff --git a/web/src/main/java/org/springframework/security/web/server/firewall/ServerExchangeRejectedException.java b/web/src/main/java/org/springframework/security/web/server/firewall/ServerExchangeRejectedException.java index 5246838dcfb..f46140d3515 100644 --- a/web/src/main/java/org/springframework/security/web/server/firewall/ServerExchangeRejectedException.java +++ b/web/src/main/java/org/springframework/security/web/server/firewall/ServerExchangeRejectedException.java @@ -16,6 +16,8 @@ package org.springframework.security.web.server.firewall; +import java.io.Serial; + /** * Thrown when a {@link org.springframework.web.server.ServerWebExchange} is rejected. * @@ -24,6 +26,9 @@ */ public class ServerExchangeRejectedException extends RuntimeException { + @Serial + private static final long serialVersionUID = 904984955691607748L; + public ServerExchangeRejectedException(String message) { super(message); } diff --git a/web/src/main/java/org/springframework/security/web/server/jackson2/WebServerJackson2Module.java b/web/src/main/java/org/springframework/security/web/server/jackson2/WebServerJackson2Module.java index ceea54bdbc1..001a5accf4a 100644 --- a/web/src/main/java/org/springframework/security/web/server/jackson2/WebServerJackson2Module.java +++ b/web/src/main/java/org/springframework/security/web/server/jackson2/WebServerJackson2Module.java @@ -38,6 +38,7 @@ * @since 5.1 * @see SecurityJackson2Modules */ +@SuppressWarnings("serial") public class WebServerJackson2Module extends SimpleModule { private static final String NAME = WebServerJackson2Module.class.getName(); diff --git a/web/src/main/java/org/springframework/security/web/session/HttpSessionCreatedEvent.java b/web/src/main/java/org/springframework/security/web/session/HttpSessionCreatedEvent.java index 15dcfff296a..547bc7fcdba 100644 --- a/web/src/main/java/org/springframework/security/web/session/HttpSessionCreatedEvent.java +++ b/web/src/main/java/org/springframework/security/web/session/HttpSessionCreatedEvent.java @@ -27,6 +27,7 @@ * @author Ray Krueger * @author Luke Taylor */ +@SuppressWarnings("serial") public class HttpSessionCreatedEvent extends SessionCreationEvent { public HttpSessionCreatedEvent(HttpSession session) { diff --git a/web/src/main/java/org/springframework/security/web/session/HttpSessionDestroyedEvent.java b/web/src/main/java/org/springframework/security/web/session/HttpSessionDestroyedEvent.java index 944dd3c202d..d3ac900ad47 100644 --- a/web/src/main/java/org/springframework/security/web/session/HttpSessionDestroyedEvent.java +++ b/web/src/main/java/org/springframework/security/web/session/HttpSessionDestroyedEvent.java @@ -33,6 +33,7 @@ * @author Luke Taylor * @author Rob Winch */ +@SuppressWarnings("serial") public class HttpSessionDestroyedEvent extends SessionDestroyedEvent { public HttpSessionDestroyedEvent(HttpSession session) { diff --git a/web/src/main/java/org/springframework/security/web/session/HttpSessionIdChangedEvent.java b/web/src/main/java/org/springframework/security/web/session/HttpSessionIdChangedEvent.java index 1320c1bb50d..ec0b645d580 100644 --- a/web/src/main/java/org/springframework/security/web/session/HttpSessionIdChangedEvent.java +++ b/web/src/main/java/org/springframework/security/web/session/HttpSessionIdChangedEvent.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,8 @@ package org.springframework.security.web.session; +import java.io.Serial; + import jakarta.servlet.http.HttpSession; import org.springframework.security.core.session.SessionIdChangedEvent; @@ -26,8 +28,12 @@ * * @since 5.4 */ +@SuppressWarnings("serial") public class HttpSessionIdChangedEvent extends SessionIdChangedEvent { + @Serial + private static final long serialVersionUID = -5725731666499807941L; + private final String oldSessionId; private final String newSessionId; diff --git a/web/src/main/java/org/springframework/security/web/session/SessionInformationExpiredEvent.java b/web/src/main/java/org/springframework/security/web/session/SessionInformationExpiredEvent.java index 1fa8e1573c6..44c99a56b5d 100644 --- a/web/src/main/java/org/springframework/security/web/session/SessionInformationExpiredEvent.java +++ b/web/src/main/java/org/springframework/security/web/session/SessionInformationExpiredEvent.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -30,6 +30,7 @@ * @author Rob Winch * @since 4.2 */ +@SuppressWarnings("serial") public final class SessionInformationExpiredEvent extends ApplicationEvent { private final HttpServletRequest request; diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/AndRequestMatcher.java b/web/src/main/java/org/springframework/security/web/util/matcher/AndRequestMatcher.java index b28b69bbba2..0630629cf25 100644 --- a/web/src/main/java/org/springframework/security/web/util/matcher/AndRequestMatcher.java +++ b/web/src/main/java/org/springframework/security/web/util/matcher/AndRequestMatcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 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. @@ -20,10 +20,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import jakarta.servlet.http.HttpServletRequest; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.springframework.util.Assert; @@ -36,8 +35,6 @@ */ public final class AndRequestMatcher implements RequestMatcher { - private final Log logger = LogFactory.getLog(getClass()); - private final List requestMatchers; /** @@ -90,6 +87,23 @@ public MatchResult matcher(HttpServletRequest request) { return MatchResult.match(variables); } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AndRequestMatcher that = (AndRequestMatcher) o; + return Objects.equals(this.requestMatchers, that.requestMatchers); + } + + @Override + public int hashCode() { + return Objects.hash(this.requestMatchers); + } + @Override public String toString() { return "And " + this.requestMatchers; diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/NegatedRequestMatcher.java b/web/src/main/java/org/springframework/security/web/util/matcher/NegatedRequestMatcher.java index 61da5dca623..60b7c32647b 100644 --- a/web/src/main/java/org/springframework/security/web/util/matcher/NegatedRequestMatcher.java +++ b/web/src/main/java/org/springframework/security/web/util/matcher/NegatedRequestMatcher.java @@ -17,8 +17,6 @@ package org.springframework.security.web.util.matcher; import jakarta.servlet.http.HttpServletRequest; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.springframework.util.Assert; @@ -33,8 +31,6 @@ */ public class NegatedRequestMatcher implements RequestMatcher { - private final Log logger = LogFactory.getLog(getClass()); - private final RequestMatcher requestMatcher; /** diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/OrRequestMatcher.java b/web/src/main/java/org/springframework/security/web/util/matcher/OrRequestMatcher.java index e3add8edf3a..53c0af8d922 100644 --- a/web/src/main/java/org/springframework/security/web/util/matcher/OrRequestMatcher.java +++ b/web/src/main/java/org/springframework/security/web/util/matcher/OrRequestMatcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 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. @@ -18,6 +18,7 @@ import java.util.Arrays; import java.util.List; +import java.util.Objects; import jakarta.servlet.http.HttpServletRequest; @@ -81,6 +82,23 @@ public MatchResult matcher(HttpServletRequest request) { return MatchResult.notMatch(); } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + OrRequestMatcher that = (OrRequestMatcher) o; + return Objects.equals(this.requestMatchers, that.requestMatchers); + } + + @Override + public int hashCode() { + return Objects.hash(this.requestMatchers); + } + @Override public String toString() { return "Or " + this.requestMatchers; diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/AttestationConveyancePreferenceSerializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/AttestationConveyancePreferenceSerializer.java index af2c0a23f51..03624dbe22d 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/AttestationConveyancePreferenceSerializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/AttestationConveyancePreferenceSerializer.java @@ -30,6 +30,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class AttestationConveyancePreferenceSerializer extends StdSerializer { AttestationConveyancePreferenceSerializer() { diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputSerializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputSerializer.java index 4d7ca1e38df..2746a0928b3 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputSerializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputSerializer.java @@ -30,6 +30,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class AuthenticationExtensionsClientInputSerializer extends StdSerializer { /** diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputsSerializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputsSerializer.java index 8009f0f16f6..e6ad216c8c7 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputsSerializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputsSerializer.java @@ -31,6 +31,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class AuthenticationExtensionsClientInputsSerializer extends StdSerializer { /** diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientOutputsDeserializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientOutputsDeserializer.java index 0cfd084936c..dc0d588c7cd 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientOutputsDeserializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientOutputsDeserializer.java @@ -39,6 +39,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class AuthenticationExtensionsClientOutputsDeserializer extends StdDeserializer { private static final Log logger = LogFactory.getLog(AuthenticationExtensionsClientOutputsDeserializer.class); diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttachmentDeserializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttachmentDeserializer.java index 0c6b9c9e741..8263081ddc8 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttachmentDeserializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttachmentDeserializer.java @@ -31,6 +31,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class AuthenticatorAttachmentDeserializer extends StdDeserializer { AuthenticatorAttachmentDeserializer() { diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttachmentSerializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttachmentSerializer.java index 67c1a2b9b3c..a6ea540716e 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttachmentSerializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttachmentSerializer.java @@ -30,6 +30,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class AuthenticatorAttachmentSerializer extends StdSerializer { AuthenticatorAttachmentSerializer() { diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorTransportDeserializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorTransportDeserializer.java index 77085c43502..8cafd92aa96 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorTransportDeserializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorTransportDeserializer.java @@ -31,6 +31,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class AuthenticatorTransportDeserializer extends StdDeserializer { AuthenticatorTransportDeserializer() { diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/BytesSerializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/BytesSerializer.java index b02b33eecb8..894cab4ed5a 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/BytesSerializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/BytesSerializer.java @@ -30,6 +30,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class BytesSerializer extends StdSerializer { /** diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/COSEAlgorithmIdentifierDeserializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/COSEAlgorithmIdentifierDeserializer.java index 343b0bde1ce..ed1e6e48370 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/COSEAlgorithmIdentifierDeserializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/COSEAlgorithmIdentifierDeserializer.java @@ -31,6 +31,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class COSEAlgorithmIdentifierDeserializer extends StdDeserializer { COSEAlgorithmIdentifierDeserializer() { diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/COSEAlgorithmIdentifierSerializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/COSEAlgorithmIdentifierSerializer.java index eb408569fa5..6cc3d844135 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/COSEAlgorithmIdentifierSerializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/COSEAlgorithmIdentifierSerializer.java @@ -30,6 +30,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class COSEAlgorithmIdentifierSerializer extends StdSerializer { COSEAlgorithmIdentifierSerializer() { diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/CredProtectAuthenticationExtensionsClientInputSerializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/CredProtectAuthenticationExtensionsClientInputSerializer.java index b1cd17892d0..05619965668 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/CredProtectAuthenticationExtensionsClientInputSerializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/CredProtectAuthenticationExtensionsClientInputSerializer.java @@ -31,6 +31,7 @@ * * @author Rob Winch */ +@SuppressWarnings("serial") class CredProtectAuthenticationExtensionsClientInputSerializer extends StdSerializer { diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/DurationSerializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/DurationSerializer.java index 442acc5fd0d..f1a27e17b5e 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/DurationSerializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/DurationSerializer.java @@ -29,6 +29,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class DurationSerializer extends StdSerializer { /** diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/PublicKeyCredentialTypeDeserializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/PublicKeyCredentialTypeDeserializer.java index b7709d41f2b..7640d7a366c 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/PublicKeyCredentialTypeDeserializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/PublicKeyCredentialTypeDeserializer.java @@ -31,6 +31,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class PublicKeyCredentialTypeDeserializer extends StdDeserializer { /** diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/PublicKeyCredentialTypeSerializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/PublicKeyCredentialTypeSerializer.java index 06eb0bbbe6d..23319e366a3 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/PublicKeyCredentialTypeSerializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/PublicKeyCredentialTypeSerializer.java @@ -30,6 +30,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class PublicKeyCredentialTypeSerializer extends StdSerializer { /** diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/ResidentKeyRequirementSerializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/ResidentKeyRequirementSerializer.java index 158e8627cde..31b85366d44 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/ResidentKeyRequirementSerializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/ResidentKeyRequirementSerializer.java @@ -30,6 +30,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class ResidentKeyRequirementSerializer extends StdSerializer { /** diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/UserVerificationRequirementSerializer.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/UserVerificationRequirementSerializer.java index 1bb29904460..07a6184a96f 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/UserVerificationRequirementSerializer.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/UserVerificationRequirementSerializer.java @@ -30,6 +30,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") class UserVerificationRequirementSerializer extends StdSerializer { /** diff --git a/web/src/main/java/org/springframework/security/web/webauthn/jackson/WebauthnJackson2Module.java b/web/src/main/java/org/springframework/security/web/webauthn/jackson/WebauthnJackson2Module.java index 0fe386aecc4..97a1c8e1f46 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/jackson/WebauthnJackson2Module.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/jackson/WebauthnJackson2Module.java @@ -47,6 +47,7 @@ * @author Rob Winch * @since 6.4 */ +@SuppressWarnings("serial") public class WebauthnJackson2Module extends SimpleModule { /** diff --git a/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepository.java b/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepository.java new file mode 100644 index 00000000000..bfeaafb0e87 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepository.java @@ -0,0 +1,193 @@ +/* + * Copyright 2002-2024 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.management; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.ArgumentPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.PreparedStatementSetter; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.SqlParameterValue; +import org.springframework.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.util.Assert; + +/** + * A JDBC implementation of an {@link PublicKeyCredentialUserEntityRepository} that uses a + * {@link JdbcOperations} for {@link PublicKeyCredentialUserEntity} persistence. + * + * NOTE: This {@code PublicKeyCredentialUserEntityRepository} depends on the table + * definition described in + * "classpath:org/springframework/security/user-entities-schema.sql" and therefore MUST be + * defined in the database schema. + * + * @author Max Batischev + * @since 6.5 + * @see PublicKeyCredentialUserEntityRepository + * @see PublicKeyCredentialUserEntity + * @see JdbcOperations + * @see RowMapper + */ +public final class JdbcPublicKeyCredentialUserEntityRepository implements PublicKeyCredentialUserEntityRepository { + + private RowMapper userEntityRowMapper = new UserEntityRecordRowMapper(); + + private Function> userEntityParametersMapper = new UserEntityParametersMapper(); + + private final JdbcOperations jdbcOperations; + + private static final String TABLE_NAME = "user_entities"; + + // @formatter:off + private static final String COLUMN_NAMES = "id, " + + "name, " + + "display_name "; + // @formatter:on + + // @formatter:off + private static final String SAVE_USER_SQL = "INSERT INTO " + TABLE_NAME + + " (" + COLUMN_NAMES + ") VALUES (?, ?, ?)"; + // @formatter:on + + private static final String ID_FILTER = "id = ? "; + + private static final String USER_NAME_FILTER = "name = ? "; + + // @formatter:off + private static final String FIND_USER_BY_ID_SQL = "SELECT " + COLUMN_NAMES + + " FROM " + TABLE_NAME + + " WHERE " + ID_FILTER; + // @formatter:on + + // @formatter:off + private static final String FIND_USER_BY_NAME_SQL = "SELECT " + COLUMN_NAMES + + " FROM " + TABLE_NAME + + " WHERE " + USER_NAME_FILTER; + // @formatter:on + + private static final String DELETE_USER_SQL = "DELETE FROM " + TABLE_NAME + " WHERE " + ID_FILTER; + + // @formatter:off + private static final String UPDATE_USER_SQL = "UPDATE " + TABLE_NAME + + " SET name = ?, display_name = ? " + + " WHERE " + ID_FILTER; + // @formatter:on + + /** + * Constructs a {@code JdbcPublicKeyCredentialUserEntityRepository} using the provided + * parameters. + * @param jdbcOperations the JDBC operations + */ + public JdbcPublicKeyCredentialUserEntityRepository(JdbcOperations jdbcOperations) { + Assert.notNull(jdbcOperations, "jdbcOperations cannot be null"); + this.jdbcOperations = jdbcOperations; + } + + @Override + public PublicKeyCredentialUserEntity findById(Bytes id) { + Assert.notNull(id, "id cannot be null"); + List result = this.jdbcOperations.query(FIND_USER_BY_ID_SQL, + this.userEntityRowMapper, id.toBase64UrlString()); + return !result.isEmpty() ? result.get(0) : null; + } + + @Override + public PublicKeyCredentialUserEntity findByUsername(String username) { + Assert.hasText(username, "name cannot be null or empty"); + List result = this.jdbcOperations.query(FIND_USER_BY_NAME_SQL, + this.userEntityRowMapper, username); + return !result.isEmpty() ? result.get(0) : null; + } + + @Override + public void save(PublicKeyCredentialUserEntity userEntity) { + Assert.notNull(userEntity, "userEntity cannot be null"); + boolean existsUserEntity = null != this.findById(userEntity.getId()); + if (existsUserEntity) { + updateUserEntity(userEntity); + } + else { + try { + insertUserEntity(userEntity); + } + catch (DuplicateKeyException ex) { + updateUserEntity(userEntity); + } + } + } + + private void insertUserEntity(PublicKeyCredentialUserEntity userEntity) { + List parameters = this.userEntityParametersMapper.apply(userEntity); + PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray()); + this.jdbcOperations.update(SAVE_USER_SQL, pss); + } + + private void updateUserEntity(PublicKeyCredentialUserEntity userEntity) { + List parameters = this.userEntityParametersMapper.apply(userEntity); + SqlParameterValue userEntityId = parameters.remove(0); + parameters.add(userEntityId); + PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray()); + this.jdbcOperations.update(UPDATE_USER_SQL, pss); + } + + @Override + public void delete(Bytes id) { + Assert.notNull(id, "id cannot be null"); + SqlParameterValue[] parameters = new SqlParameterValue[] { + new SqlParameterValue(Types.VARCHAR, id.toBase64UrlString()), }; + PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters); + this.jdbcOperations.update(DELETE_USER_SQL, pss); + } + + private static class UserEntityParametersMapper + implements Function> { + + @Override + public List apply(PublicKeyCredentialUserEntity userEntity) { + List parameters = new ArrayList<>(); + + parameters.add(new SqlParameterValue(Types.VARCHAR, userEntity.getId().toBase64UrlString())); + parameters.add(new SqlParameterValue(Types.VARCHAR, userEntity.getName())); + parameters.add(new SqlParameterValue(Types.VARCHAR, userEntity.getDisplayName())); + + return parameters; + } + + } + + private static class UserEntityRecordRowMapper implements RowMapper { + + @Override + public PublicKeyCredentialUserEntity mapRow(ResultSet rs, int rowNum) throws SQLException { + Bytes id = Bytes.fromBase64(new String(rs.getString("id").getBytes())); + String name = rs.getString("name"); + String displayName = rs.getString("display_name"); + + return ImmutablePublicKeyCredentialUserEntity.builder().id(id).name(name).displayName(displayName).build(); + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepository.java b/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepository.java new file mode 100644 index 00000000000..aa012d6964b --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepository.java @@ -0,0 +1,305 @@ +/* + * Copyright 2002-2024 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.management; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +import org.springframework.jdbc.core.ArgumentPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.PreparedStatementSetter; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.SqlParameterValue; +import org.springframework.jdbc.support.lob.DefaultLobHandler; +import org.springframework.jdbc.support.lob.LobCreator; +import org.springframework.jdbc.support.lob.LobHandler; +import org.springframework.security.web.webauthn.api.AuthenticatorTransport; +import org.springframework.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.CredentialRecord; +import org.springframework.security.web.webauthn.api.ImmutableCredentialRecord; +import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCose; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialType; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * A JDBC implementation of an {@link UserCredentialRepository} that uses a + * {@link JdbcOperations} for {@link CredentialRecord} persistence. + * + * NOTE: This {@code UserCredentialRepository} depends on the table definition + * described in "classpath:org/springframework/security/user-credentials-schema.sql" and + * therefore MUST be defined in the database schema. + * + * @author Max Batischev + * @since 6.5 + * @see UserCredentialRepository + * @see CredentialRecord + * @see JdbcOperations + * @see RowMapper + */ +public final class JdbcUserCredentialRepository implements UserCredentialRepository { + + private RowMapper credentialRecordRowMapper = new CredentialRecordRowMapper(); + + private Function> credentialRecordParametersMapper = new CredentialRecordParametersMapper(); + + private LobHandler lobHandler = new DefaultLobHandler(); + + private final JdbcOperations jdbcOperations; + + private static final String TABLE_NAME = "user_credentials"; + + // @formatter:off + private static final String COLUMN_NAMES = "credential_id, " + + "user_entity_user_id, " + + "public_key, " + + "signature_count, " + + "uv_initialized, " + + "backup_eligible, " + + "authenticator_transports, " + + "public_key_credential_type, " + + "backup_state, " + + "attestation_object, " + + "attestation_client_data_json, " + + "created, " + + "last_used, " + + "label "; + // @formatter:on + + // @formatter:off + private static final String SAVE_CREDENTIAL_RECORD_SQL = "INSERT INTO " + TABLE_NAME + + " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + // @formatter:on + + private static final String ID_FILTER = "credential_id = ? "; + + private static final String USER_ID_FILTER = "user_entity_user_id = ? "; + + // @formatter:off + private static final String FIND_CREDENTIAL_RECORD_BY_ID_SQL = "SELECT " + COLUMN_NAMES + + " FROM " + TABLE_NAME + + " WHERE " + ID_FILTER; + // @formatter:on + + // @formatter:off + private static final String FIND_CREDENTIAL_RECORD_BY_USER_ID_SQL = "SELECT " + COLUMN_NAMES + + " FROM " + TABLE_NAME + + " WHERE " + USER_ID_FILTER; + // @formatter:on + + private static final String DELETE_CREDENTIAL_RECORD_SQL = "DELETE FROM " + TABLE_NAME + " WHERE " + ID_FILTER; + + /** + * Constructs a {@code JdbcUserCredentialRepository} using the provided parameters. + * @param jdbcOperations the JDBC operations + */ + public JdbcUserCredentialRepository(JdbcOperations jdbcOperations) { + Assert.notNull(jdbcOperations, "jdbcOperations cannot be null"); + this.jdbcOperations = jdbcOperations; + } + + @Override + public void delete(Bytes credentialId) { + Assert.notNull(credentialId, "credentialId cannot be null"); + SqlParameterValue[] parameters = new SqlParameterValue[] { + new SqlParameterValue(Types.VARCHAR, credentialId.toBase64UrlString()), }; + PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters); + this.jdbcOperations.update(DELETE_CREDENTIAL_RECORD_SQL, pss); + } + + @Override + public void save(CredentialRecord record) { + Assert.notNull(record, "record cannot be null"); + List parameters = this.credentialRecordParametersMapper.apply(record); + try (LobCreator lobCreator = this.lobHandler.getLobCreator()) { + PreparedStatementSetter pss = new LobCreatorArgumentPreparedStatementSetter(lobCreator, + parameters.toArray()); + this.jdbcOperations.update(SAVE_CREDENTIAL_RECORD_SQL, pss); + } + } + + @Override + public CredentialRecord findByCredentialId(Bytes credentialId) { + Assert.notNull(credentialId, "credentialId cannot be null"); + List result = this.jdbcOperations.query(FIND_CREDENTIAL_RECORD_BY_ID_SQL, + this.credentialRecordRowMapper, credentialId.toBase64UrlString()); + return !result.isEmpty() ? result.get(0) : null; + } + + @Override + public List findByUserId(Bytes userId) { + Assert.notNull(userId, "userId cannot be null"); + return this.jdbcOperations.query(FIND_CREDENTIAL_RECORD_BY_USER_ID_SQL, this.credentialRecordRowMapper, + userId.toBase64UrlString()); + } + + /** + * Sets a {@link LobHandler} for large binary fields and large text field parameters. + * @param lobHandler the lob handler + */ + public void setLobHandler(LobHandler lobHandler) { + Assert.notNull(lobHandler, "lobHandler cannot be null"); + this.lobHandler = lobHandler; + } + + private static class CredentialRecordParametersMapper + implements Function> { + + @Override + public List apply(CredentialRecord record) { + List parameters = new ArrayList<>(); + + List transports = new ArrayList<>(); + if (!CollectionUtils.isEmpty(record.getTransports())) { + for (AuthenticatorTransport transport : record.getTransports()) { + transports.add(transport.getValue()); + } + } + + parameters.add(new SqlParameterValue(Types.VARCHAR, record.getCredentialId().toBase64UrlString())); + parameters.add(new SqlParameterValue(Types.VARCHAR, record.getUserEntityUserId().toBase64UrlString())); + parameters.add(new SqlParameterValue(Types.BLOB, record.getPublicKey().getBytes())); + parameters.add(new SqlParameterValue(Types.BIGINT, record.getSignatureCount())); + parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isUvInitialized())); + parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isBackupEligible())); + parameters.add(new SqlParameterValue(Types.VARCHAR, + (!CollectionUtils.isEmpty(record.getTransports())) ? String.join(",", transports) : "")); + parameters.add(new SqlParameterValue(Types.VARCHAR, + (record.getCredentialType() != null) ? record.getCredentialType().getValue() : null)); + parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isBackupState())); + parameters.add(new SqlParameterValue(Types.BLOB, + (record.getAttestationObject() != null) ? record.getAttestationObject().getBytes() : null)); + parameters.add(new SqlParameterValue(Types.BLOB, (record.getAttestationClientDataJSON() != null) + ? record.getAttestationClientDataJSON().getBytes() : null)); + parameters.add(new SqlParameterValue(Types.TIMESTAMP, fromInstant(record.getCreated()))); + parameters.add(new SqlParameterValue(Types.TIMESTAMP, fromInstant(record.getLastUsed()))); + parameters.add(new SqlParameterValue(Types.VARCHAR, record.getLabel())); + + return parameters; + } + + private Timestamp fromInstant(Instant instant) { + if (instant == null) { + return null; + } + return Timestamp.from(instant); + } + + } + + private static final class LobCreatorArgumentPreparedStatementSetter extends ArgumentPreparedStatementSetter { + + private final LobCreator lobCreator; + + private LobCreatorArgumentPreparedStatementSetter(LobCreator lobCreator, Object[] args) { + super(args); + this.lobCreator = lobCreator; + } + + @Override + protected void doSetValue(PreparedStatement ps, int parameterPosition, Object argValue) throws SQLException { + if (argValue instanceof SqlParameterValue paramValue) { + if (paramValue.getSqlType() == Types.BLOB) { + if (paramValue.getValue() != null) { + Assert.isInstanceOf(byte[].class, paramValue.getValue(), + "Value of blob parameter must be byte[]"); + } + byte[] valueBytes = (byte[]) paramValue.getValue(); + this.lobCreator.setBlobAsBytes(ps, parameterPosition, valueBytes); + return; + } + } + super.doSetValue(ps, parameterPosition, argValue); + } + + } + + private static class CredentialRecordRowMapper implements RowMapper { + + private LobHandler lobHandler = new DefaultLobHandler(); + + @Override + public CredentialRecord mapRow(ResultSet rs, int rowNum) throws SQLException { + Bytes credentialId = Bytes.fromBase64(new String(rs.getString("credential_id").getBytes())); + Bytes userEntityUserId = Bytes.fromBase64(new String(rs.getString("user_entity_user_id").getBytes())); + ImmutablePublicKeyCose publicKey = new ImmutablePublicKeyCose( + this.lobHandler.getBlobAsBytes(rs, "public_key")); + long signatureCount = rs.getLong("signature_count"); + boolean uvInitialized = rs.getBoolean("uv_initialized"); + boolean backupEligible = rs.getBoolean("backup_eligible"); + PublicKeyCredentialType credentialType = PublicKeyCredentialType + .valueOf(rs.getString("public_key_credential_type")); + boolean backupState = rs.getBoolean("backup_state"); + + Bytes attestationObject = null; + byte[] rawAttestationObject = this.lobHandler.getBlobAsBytes(rs, "attestation_object"); + if (rawAttestationObject != null) { + attestationObject = new Bytes(rawAttestationObject); + } + + Bytes attestationClientDataJson = null; + byte[] rawAttestationClientDataJson = this.lobHandler.getBlobAsBytes(rs, "attestation_client_data_json"); + if (rawAttestationClientDataJson != null) { + attestationClientDataJson = new Bytes(rawAttestationClientDataJson); + } + + Instant created = fromTimestamp(rs.getTimestamp("created")); + Instant lastUsed = fromTimestamp(rs.getTimestamp("last_used")); + String label = rs.getString("label"); + String[] transports = rs.getString("authenticator_transports").split(","); + + Set authenticatorTransports = new HashSet<>(); + for (String transport : transports) { + authenticatorTransports.add(AuthenticatorTransport.valueOf(transport)); + } + return ImmutableCredentialRecord.builder() + .credentialId(credentialId) + .userEntityUserId(userEntityUserId) + .publicKey(publicKey) + .signatureCount(signatureCount) + .uvInitialized(uvInitialized) + .backupEligible(backupEligible) + .credentialType(credentialType) + .backupState(backupState) + .attestationObject(attestationObject) + .attestationClientDataJSON(attestationClientDataJson) + .created(created) + .label(label) + .lastUsed(lastUsed) + .transports(authenticatorTransports) + .build(); + } + + private Instant fromTimestamp(Timestamp timestamp) { + if (timestamp == null) { + return null; + } + return timestamp.toInstant(); + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/webauthn/registration/PublicKeyCredentialCreationOptionsFilter.java b/web/src/main/java/org/springframework/security/web/webauthn/registration/PublicKeyCredentialCreationOptionsFilter.java index 3f163b0cc2c..0863925c8c8 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/registration/PublicKeyCredentialCreationOptionsFilter.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/registration/PublicKeyCredentialCreationOptionsFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -53,6 +53,8 @@ * {@link PublicKeyCredentialCreationOptions} for creating * a new credential. + * + * @author DingHao */ public class PublicKeyCredentialCreationOptionsFilter extends OncePerRequestFilter { @@ -67,7 +69,7 @@ public class PublicKeyCredentialCreationOptionsFilter extends OncePerRequestFilt private final WebAuthnRelyingPartyOperations rpOperations; - private final HttpMessageConverter converter = new MappingJackson2HttpMessageConverter( + private HttpMessageConverter converter = new MappingJackson2HttpMessageConverter( Jackson2ObjectMapperBuilder.json().modules(new WebauthnJackson2Module()).build()); /** @@ -103,4 +105,26 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse this.converter.write(options, MediaType.APPLICATION_JSON, new ServletServerHttpResponse(response)); } + /** + * Sets the {@link PublicKeyCredentialCreationOptionsRepository} to use. The default + * is {@link HttpSessionPublicKeyCredentialCreationOptionsRepository}. + * @param creationOptionsRepository the + * {@link PublicKeyCredentialCreationOptionsRepository} to use. Cannot be null. + */ + public void setCreationOptionsRepository(PublicKeyCredentialCreationOptionsRepository creationOptionsRepository) { + Assert.notNull(creationOptionsRepository, "creationOptionsRepository cannot be null"); + this.repository = creationOptionsRepository; + } + + /** + * Set the {@link HttpMessageConverter} to read the + * {@link WebAuthnRegistrationFilter.WebAuthnRegistrationRequest} and write the + * response. The default is {@link MappingJackson2HttpMessageConverter}. + * @param converter the {@link HttpMessageConverter} to use. Cannot be null. + */ + public void setConverter(HttpMessageConverter converter) { + Assert.notNull(converter, "converter cannot be null"); + this.converter = converter; + } + } diff --git a/web/src/main/resources/META-INF/spring/aot.factories b/web/src/main/resources/META-INF/spring/aot.factories index dcc4be6a067..2a3c8ad7681 100644 --- a/web/src/main/resources/META-INF/spring/aot.factories +++ b/web/src/main/resources/META-INF/spring/aot.factories @@ -1,2 +1,4 @@ org.springframework.aot.hint.RuntimeHintsRegistrar=\ -org.springframework.security.web.aot.hint.WebMvcSecurityRuntimeHints +org.springframework.security.web.aot.hint.WebMvcSecurityRuntimeHints,\ +org.springframework.security.web.aot.hint.UserCredentialRuntimeHints,\ +org.springframework.security.web.aot.hint.PublicKeyCredentialUserEntityRuntimeHints diff --git a/web/src/main/resources/org/springframework/security/user-credentials-schema.sql b/web/src/main/resources/org/springframework/security/user-credentials-schema.sql new file mode 100644 index 00000000000..1be48f2fb1e --- /dev/null +++ b/web/src/main/resources/org/springframework/security/user-credentials-schema.sql @@ -0,0 +1,18 @@ +create table user_credentials +( + credential_id varchar(1000) not null, + user_entity_user_id varchar(1000) not null, + public_key blob not null, + signature_count bigint, + uv_initialized boolean, + backup_eligible boolean not null, + authenticator_transports varchar(1000), + public_key_credential_type varchar(100), + backup_state boolean not null, + attestation_object blob, + attestation_client_data_json blob, + created timestamp, + last_used timestamp, + label varchar(1000) not null, + primary key (credential_id) +); diff --git a/web/src/main/resources/org/springframework/security/user-entities-schema.sql b/web/src/main/resources/org/springframework/security/user-entities-schema.sql new file mode 100644 index 00000000000..ec66c66519b --- /dev/null +++ b/web/src/main/resources/org/springframework/security/user-entities-schema.sql @@ -0,0 +1,7 @@ +create table user_entities +( + id varchar(1000) not null, + name varchar(100) not null, + display_name varchar(200), + primary key (id) +); diff --git a/web/src/test/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHintsTests.java b/web/src/test/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHintsTests.java new file mode 100644 index 00000000000..4909a643030 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHintsTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2024 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.aot.hint; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PublicKeyCredentialUserEntityRuntimeHints} + * + * @author Max Batischev + */ +public class PublicKeyCredentialUserEntityRuntimeHintsTests { + + private final RuntimeHints hints = new RuntimeHints(); + + @BeforeEach + void setup() { + SpringFactoriesLoader.forResourceLocation("META-INF/spring/aot.factories") + .load(RuntimeHintsRegistrar.class) + .forEach((registrar) -> registrar.registerHints(this.hints, ClassUtils.getDefaultClassLoader())); + } + + @ParameterizedTest + @MethodSource("getUserEntitiesSqlFiles") + void userEntitiesSqlFilesHasHints(String schemaFile) { + assertThat(RuntimeHintsPredicates.resource().forResource(schemaFile)).accepts(this.hints); + } + + private static Stream getUserEntitiesSqlFiles() { + return Stream.of("org/springframework/security/user-entities-schema.sql"); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHintsTests.java b/web/src/test/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHintsTests.java new file mode 100644 index 00000000000..33799cc6f92 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHintsTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2024 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.aot.hint; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link UserCredentialRuntimeHints} + * + * @author Max Batischev + */ +public class UserCredentialRuntimeHintsTests { + + private final RuntimeHints hints = new RuntimeHints(); + + @BeforeEach + void setup() { + SpringFactoriesLoader.forResourceLocation("META-INF/spring/aot.factories") + .load(RuntimeHintsRegistrar.class) + .forEach((registrar) -> registrar.registerHints(this.hints, ClassUtils.getDefaultClassLoader())); + } + + @ParameterizedTest + @MethodSource("getClientRecordsSqlFiles") + void credentialRecordsSqlFilesHasHints(String schemaFile) { + assertThat(RuntimeHintsPredicates.resource().forResource(schemaFile)).accepts(this.hints); + } + + private static Stream getClientRecordsSqlFiles() { + return Stream.of("org/springframework/security/user-credentials-schema.sql"); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPointTests.java b/web/src/test/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPointTests.java index 77b49be1a16..91e2d93cdfa 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPointTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPointTests.java @@ -135,6 +135,12 @@ public void testHttpsOperationFromOriginalHttpsUrl() throws Exception { ep.setPortResolver(new MockPortResolver(8080, 8443)); ep.commence(request, response, null); assertThat(response.getRedirectedUrl()).isEqualTo("https://www.example.com:8443/bigWebApp/hello"); + // access to https via http port + request.setServerPort(8080); + response = new MockHttpServletResponse(); + ep.setPortResolver(new MockPortResolver(8080, 8443)); + ep.commence(request, response, null); + assertThat(response.getRedirectedUrl()).isEqualTo("https://www.example.com:8443/bigWebApp/hello"); } @Test @@ -231,4 +237,54 @@ public void absoluteLoginFormUrlCantBeUsedWithForwarding() throws Exception { assertThatIllegalArgumentException().isThrownBy(ep::afterPropertiesSet); } + @Test + public void commenceWhenFavorRelativeUrisThenHttpsSchemeNotIncluded() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/some_path"); + request.setScheme("https"); + request.setServerName("www.example.com"); + request.setContextPath("/bigWebApp"); + request.setServerPort(443); + MockHttpServletResponse response = new MockHttpServletResponse(); + LoginUrlAuthenticationEntryPoint ep = new LoginUrlAuthenticationEntryPoint("/hello"); + ep.setFavorRelativeUris(true); + ep.setPortMapper(new PortMapperImpl()); + ep.setForceHttps(true); + ep.setPortMapper(new PortMapperImpl()); + ep.setPortResolver(new MockPortResolver(80, 443)); + ep.afterPropertiesSet(); + ep.commence(request, response, null); + assertThat(response.getRedirectedUrl()).isEqualTo("/bigWebApp/hello"); + request.setServerPort(8443); + response = new MockHttpServletResponse(); + ep.setPortResolver(new MockPortResolver(8080, 8443)); + ep.commence(request, response, null); + assertThat(response.getRedirectedUrl()).isEqualTo("/bigWebApp/hello"); + // access to https via http port + request.setServerPort(8080); + response = new MockHttpServletResponse(); + ep.setPortResolver(new MockPortResolver(8080, 8443)); + ep.commence(request, response, null); + assertThat(response.getRedirectedUrl()).isEqualTo("/bigWebApp/hello"); + } + + @Test + public void commenceWhenFavorRelativeUrisThenHttpSchemeNotIncluded() throws Exception { + LoginUrlAuthenticationEntryPoint ep = new LoginUrlAuthenticationEntryPoint("/hello"); + ep.setFavorRelativeUris(true); + ep.setPortMapper(new PortMapperImpl()); + ep.setPortResolver(new MockPortResolver(80, 443)); + ep.afterPropertiesSet(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/some_path"); + request.setContextPath("/bigWebApp"); + request.setScheme("http"); + request.setServerName("localhost"); + request.setContextPath("/bigWebApp"); + request.setServerPort(80); + MockHttpServletResponse response = new MockHttpServletResponse(); + ep.commence(request, response, null); + assertThat(response.getRedirectedUrl()).isEqualTo("/bigWebApp/hello"); + } + } diff --git a/web/src/test/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolverTests.java b/web/src/test/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolverTests.java new file mode 100644 index 00000000000..12a491230ea --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolverTests.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-2025 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.authentication.ott; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link DefaultGenerateOneTimeTokenRequestResolver} + * + * @author Max Batischev + */ +public class DefaultGenerateOneTimeTokenRequestResolverTests { + + private final DefaultGenerateOneTimeTokenRequestResolver requestResolver = new DefaultGenerateOneTimeTokenRequestResolver(); + + @Test + void resolveWhenUsernameParameterIsPresentThenResolvesGenerateRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameter("username", "test"); + + GenerateOneTimeTokenRequest generateRequest = this.requestResolver.resolve(request); + + assertThat(generateRequest).isNotNull(); + assertThat(generateRequest.getUsername()).isEqualTo("test"); + assertThat(generateRequest.getExpiresIn()).isEqualTo(Duration.ofSeconds(300)); + } + + @Test + void resolveWhenUsernameParameterIsNotPresentThenNull() { + GenerateOneTimeTokenRequest generateRequest = this.requestResolver.resolve(new MockHttpServletRequest()); + + assertThat(generateRequest).isNull(); + } + + @Test + void resolveWhenExpiresInSetThenResolvesGenerateRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameter("username", "test"); + this.requestResolver.setExpiresIn(Duration.ofSeconds(600)); + + GenerateOneTimeTokenRequest generateRequest = this.requestResolver.resolve(request); + + assertThat(generateRequest.getExpiresIn()).isEqualTo(Duration.ofSeconds(600)); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilterTests.java b/web/src/test/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilterTests.java new file mode 100644 index 00000000000..f3cdb2fd51b --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilterTests.java @@ -0,0 +1,115 @@ +/* + * Copyright 2002-2024 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.authentication.ott; + +import java.io.IOException; +import java.time.Instant; + +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; + +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.ott.DefaultOneTimeToken; +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; +import org.springframework.security.authentication.ott.OneTimeTokenService; +import org.springframework.security.web.server.authentication.ott.GenerateOneTimeTokenWebFilter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link GenerateOneTimeTokenWebFilter} + * + * @author Max Batischev + */ +public class GenerateOneTimeTokenFilterTests { + + private final OneTimeTokenService oneTimeTokenService = mock(OneTimeTokenService.class); + + private final RedirectOneTimeTokenGenerationSuccessHandler successHandler = new RedirectOneTimeTokenGenerationSuccessHandler( + "/login/ott"); + + private static final String TOKEN = "token"; + + private static final String USERNAME = "user"; + + private final MockHttpServletRequest request = new MockHttpServletRequest(); + + private final MockHttpServletResponse response = new MockHttpServletResponse(); + + private final MockFilterChain filterChain = new MockFilterChain(); + + @BeforeEach + void setup() { + this.request.setMethod("POST"); + this.request.setServletPath("/ott/generate"); + } + + @Test + void filterWhenUsernameFormParamIsPresentThenSuccess() throws ServletException, IOException { + given(this.oneTimeTokenService.generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class))) + .willReturn(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())); + this.request.setParameter("username", USERNAME); + + GenerateOneTimeTokenFilter filter = new GenerateOneTimeTokenFilter(this.oneTimeTokenService, + this.successHandler); + + filter.doFilter(this.request, this.response, this.filterChain); + + verify(this.oneTimeTokenService).generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class)); + assertThat(this.response.getRedirectedUrl()).isEqualTo("/login/ott"); + } + + @Test + void filterWhenUsernameFormParamIsEmptyThenNull() throws ServletException, IOException { + given(this.oneTimeTokenService.generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class))) + .willReturn((new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now()))); + GenerateOneTimeTokenFilter filter = new GenerateOneTimeTokenFilter(this.oneTimeTokenService, + this.successHandler); + + filter.doFilter(this.request, this.response, this.filterChain); + + verify(this.oneTimeTokenService, never()).generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class)); + } + + @Test + public void constructorWhenOneTimeTokenServiceNullThenIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new GenerateOneTimeTokenFilter(null, this.successHandler)); + // @formatter:on + } + + @Test + public void setWhenRequestMatcherNullThenIllegalArgumentException() { + GenerateOneTimeTokenFilter filter = new GenerateOneTimeTokenFilter(this.oneTimeTokenService, + this.successHandler); + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> filter.setRequestMatcher(null)); + // @formatter:on + } + +} diff --git a/web/src/test/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategyTests.java b/web/src/test/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategyTests.java index ffe51cc2a0e..aa1bed6d8f8 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategyTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2024 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. @@ -41,9 +41,11 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verifyNoInteractions; /** * @author Rob Winch + * @author Claudenir Freitas * */ @ExtendWith(MockitoExtension.class) @@ -144,6 +146,86 @@ public void onAuthenticationWhenMaxSessionsExceededByTwoThenTwoSessionsExpired() assertThat(this.sessionInformation.isExpired()).isFalse(); } + @Test + public void setMaximumSessionsWithNullValue() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> this.strategy.setMaximumSessions(null)) + .withMessage("sessionLimit cannot be null"); + } + + @Test + public void noRegisteredSessionUsingSessionLimit() { + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())).willReturn(Collections.emptyList()); + this.strategy.setMaximumSessions(SessionLimit.of(1)); + this.strategy.setExceptionIfMaximumExceeded(true); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + // no exception + } + + @Test + public void maxSessionsSameSessionIdUsingSessionLimit() { + MockHttpSession session = new MockHttpSession(new MockServletContext(), this.sessionInformation.getSessionId()); + this.request.setSession(session); + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Collections.singletonList(this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(1)); + this.strategy.setExceptionIfMaximumExceeded(true); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + // no exception + } + + @Test + public void maxSessionsWithExceptionUsingSessionLimit() { + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Collections.singletonList(this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(1)); + this.strategy.setExceptionIfMaximumExceeded(true); + assertThatExceptionOfType(SessionAuthenticationException.class) + .isThrownBy(() -> this.strategy.onAuthentication(this.authentication, this.request, this.response)); + } + + @Test + public void maxSessionsExpireExistingUserUsingSessionLimit() { + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Collections.singletonList(this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(1)); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + assertThat(this.sessionInformation.isExpired()).isTrue(); + } + + @Test + public void maxSessionsExpireLeastRecentExistingUserUsingSessionLimit() { + SessionInformation moreRecentSessionInfo = new SessionInformation(this.authentication.getPrincipal(), "unique", + new Date(1374766999999L)); + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Arrays.asList(moreRecentSessionInfo, this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(2)); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + assertThat(this.sessionInformation.isExpired()).isTrue(); + } + + @Test + public void onAuthenticationWhenMaxSessionsExceededByTwoThenTwoSessionsExpiredUsingSessionLimit() { + SessionInformation oldestSessionInfo = new SessionInformation(this.authentication.getPrincipal(), "unique1", + new Date(1374766134214L)); + SessionInformation secondOldestSessionInfo = new SessionInformation(this.authentication.getPrincipal(), + "unique2", new Date(1374766134215L)); + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Arrays.asList(oldestSessionInfo, secondOldestSessionInfo, this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(2)); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + assertThat(oldestSessionInfo.isExpired()).isTrue(); + assertThat(secondOldestSessionInfo.isExpired()).isTrue(); + assertThat(this.sessionInformation.isExpired()).isFalse(); + } + + @Test + public void onAuthenticationWhenSessionLimitIsUnlimited() { + this.strategy.setMaximumSessions(SessionLimit.UNLIMITED); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + verifyNoInteractions(this.sessionRegistry); + } + @Test public void setMessageSourceNull() { assertThatIllegalArgumentException().isThrownBy(() -> this.strategy.setMessageSource(null)); diff --git a/web/src/test/java/org/springframework/security/web/authentication/session/SessionLimitTests.java b/web/src/test/java/org/springframework/security/web/authentication/session/SessionLimitTests.java new file mode 100644 index 00000000000..134f9f6e7ae --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/session/SessionLimitTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2024 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.authentication.session; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; + +import org.springframework.security.core.Authentication; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * @author Claudenir Freitas + * @since 6.5 + */ +class SessionLimitTests { + + private final Authentication authentication = Mockito.mock(Authentication.class); + + @Test + void testUnlimitedInstance() { + SessionLimit sessionLimit = SessionLimit.UNLIMITED; + int result = sessionLimit.apply(this.authentication); + assertThat(result).isEqualTo(-1); + } + + @ParameterizedTest + @ValueSource(ints = { -1, 1, 2, 3 }) + void testInstanceWithValidMaxSessions(int maxSessions) { + SessionLimit sessionLimit = SessionLimit.of(maxSessions); + int result = sessionLimit.apply(this.authentication); + assertThat(result).isEqualTo(maxSessions); + } + + @Test + void testInstanceWithInvalidMaxSessions() { + assertThatIllegalArgumentException().isThrownBy(() -> SessionLimit.of(0)) + .withMessage( + "MaximumLogins must be either -1 to allow unlimited logins, or a positive integer to specify a maximum"); + } + +} diff --git a/config/src/test/java/org/springframework/security/config/MockServletContext.java b/web/src/test/java/org/springframework/security/web/servlet/MockServletContext.java similarity index 98% rename from config/src/test/java/org/springframework/security/config/MockServletContext.java rename to web/src/test/java/org/springframework/security/web/servlet/MockServletContext.java index d819d4c7989..fff01a5f3b0 100644 --- a/config/src/test/java/org/springframework/security/config/MockServletContext.java +++ b/web/src/test/java/org/springframework/security/web/servlet/MockServletContext.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.security.config; +package org.springframework.security.web.servlet; import java.util.Arrays; import java.util.Collection; diff --git a/config/src/test/java/org/springframework/security/config/TestMockHttpServletMappings.java b/web/src/test/java/org/springframework/security/web/servlet/TestMockHttpServletMappings.java similarity index 96% rename from config/src/test/java/org/springframework/security/config/TestMockHttpServletMappings.java rename to web/src/test/java/org/springframework/security/web/servlet/TestMockHttpServletMappings.java index 3f1f7f797bb..16733d100b4 100644 --- a/config/src/test/java/org/springframework/security/config/TestMockHttpServletMappings.java +++ b/web/src/test/java/org/springframework/security/web/servlet/TestMockHttpServletMappings.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.security.config; +package org.springframework.security.web.servlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.MappingMatch; diff --git a/web/src/test/java/org/springframework/security/web/webauthn/api/TestBytes.java b/web/src/test/java/org/springframework/security/web/webauthn/api/TestBytes.java new file mode 100644 index 00000000000..b8850c12de8 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/webauthn/api/TestBytes.java @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2025 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; + +/** + * @author Rob Winch + */ +public final class TestBytes { + + public static Bytes get() { + return Bytes.fromBase64("OSCtNugR-n4YR4ozlHRa-CKXzY9v-yMKtQGcvui5xN8"); + } + + private TestBytes() { + } + +} diff --git a/web/src/test/java/org/springframework/security/web/webauthn/api/TestCredentialRecord.java b/web/src/test/java/org/springframework/security/web/webauthn/api/TestCredentialRecord.java index 917125ae670..1ed190c03d5 100644 --- a/web/src/test/java/org/springframework/security/web/webauthn/api/TestCredentialRecord.java +++ b/web/src/test/java/org/springframework/security/web/webauthn/api/TestCredentialRecord.java @@ -16,6 +16,9 @@ package org.springframework.security.web.webauthn.api; +import java.time.Instant; +import java.util.Set; + public final class TestCredentialRecord { public static ImmutableCredentialRecord.ImmutableCredentialRecordBuilder userCredential() { @@ -29,6 +32,24 @@ public static ImmutableCredentialRecord.ImmutableCredentialRecordBuilder userCre .backupState(true); } + public static ImmutableCredentialRecord.ImmutableCredentialRecordBuilder fullUserCredential() { + return ImmutableCredentialRecord.builder() + .label("label") + .credentialId(Bytes.fromBase64("NauGCN7bZ5jEBwThcde51g")) + .userEntityUserId(Bytes.fromBase64("vKBFhsWT3gQnn-gHdT4VXIvjDkVXVYg5w8CLGHPunMM")) + .publicKey(ImmutablePublicKeyCose.fromBase64( + "pQECAyYgASFYIC7DAiV_trHFPjieOxXbec7q2taBcgLnIi19zrUwVhCdIlggvN6riHORK_velHcTLFK_uJhyKK0oBkJqzNqR2E-2xf8=")) + .backupEligible(true) + .created(Instant.now()) + .transports(Set.of(AuthenticatorTransport.BLE, AuthenticatorTransport.HYBRID)) + .signatureCount(100) + .uvInitialized(false) + .credentialType(PublicKeyCredentialType.PUBLIC_KEY) + .attestationObject(new Bytes("test".getBytes())) + .attestationClientDataJSON(new Bytes(("test").getBytes())) + .backupState(true); + } + private TestCredentialRecord() { } diff --git a/web/src/test/java/org/springframework/security/web/webauthn/api/TestPublicKeyCredentialUserEntity.java b/web/src/test/java/org/springframework/security/web/webauthn/api/TestPublicKeyCredentialUserEntity.java index 704e6ce17fc..cc35752d15c 100644 --- a/web/src/test/java/org/springframework/security/web/webauthn/api/TestPublicKeyCredentialUserEntity.java +++ b/web/src/test/java/org/springframework/security/web/webauthn/api/TestPublicKeyCredentialUserEntity.java @@ -21,7 +21,7 @@ public final class TestPublicKeyCredentialUserEntity { public static PublicKeyCredentialUserEntityBuilder userEntity() { - return ImmutablePublicKeyCredentialUserEntity.builder().name("user").id(Bytes.random()).displayName("user"); + return ImmutablePublicKeyCredentialUserEntity.builder().name("user").id(TestBytes.get()).displayName("user"); } private TestPublicKeyCredentialUserEntity() { diff --git a/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepositoryTests.java b/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepositoryTests.java new file mode 100644 index 00000000000..503108ac4ea --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepositoryTests.java @@ -0,0 +1,182 @@ +/* + * Copyright 2002-2024 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.management; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialUserEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link JdbcPublicKeyCredentialUserEntityRepository} + * + * @author Max Batischev + */ +public class JdbcPublicKeyCredentialUserEntityRepositoryTests { + + private EmbeddedDatabase db; + + private JdbcPublicKeyCredentialUserEntityRepository repository; + + private static final String USER_ENTITIES_SQL_RESOURCE = "org/springframework/security/user-entities-schema.sql"; + + @BeforeEach + void setUp() { + this.db = createDb(); + JdbcOperations jdbcOperations = new JdbcTemplate(this.db); + this.repository = new JdbcPublicKeyCredentialUserEntityRepository(jdbcOperations); + } + + @AfterEach + void tearDown() { + this.db.shutdown(); + } + + private static EmbeddedDatabase createDb() { + // @formatter:off + return new EmbeddedDatabaseBuilder() + .generateUniqueName(true) + .setType(EmbeddedDatabaseType.HSQL) + .setScriptEncoding("UTF-8") + .addScript(USER_ENTITIES_SQL_RESOURCE) + .build(); + // @formatter:on + } + + @Test + void constructorWhenJdbcOperationsIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new JdbcPublicKeyCredentialUserEntityRepository(null)) + .withMessage("jdbcOperations cannot be null"); + // @formatter:on + } + + @Test + void saveWhenUserEntityIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.repository.save(null)) + .withMessage("userEntity cannot be null"); + // @formatter:on + } + + @Test + void findByUserEntityIdWheIdIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.repository.findById(null)) + .withMessage("id cannot be null"); + // @formatter:on + } + + @Test + void findByUserNameWheUserNameIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.repository.findByUsername(null)) + .withMessage("name cannot be null or empty"); + // @formatter:on + } + + @Test + void saveUserEntityWhenSaveThenReturnsSaved() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + + this.repository.save(userEntity); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId()); + assertThat(savedUserEntity).isNotNull(); + assertThat(savedUserEntity.getId()).isEqualTo(userEntity.getId()); + assertThat(savedUserEntity.getDisplayName()).isEqualTo(userEntity.getDisplayName()); + assertThat(savedUserEntity.getName()).isEqualTo(userEntity.getName()); + } + + @Test + void saveUserEntityWhenUserEntityExistsThenUpdates() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + this.repository.save(userEntity); + + this.repository.save(testUserEntity(userEntity.getId())); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId()); + assertThat(savedUserEntity).isNotNull(); + assertThat(savedUserEntity.getId()).isEqualTo(userEntity.getId()); + assertThat(savedUserEntity.getDisplayName()).isEqualTo("user2"); + assertThat(savedUserEntity.getName()).isEqualTo("user2"); + } + + @Test + void findUserEntityByUserNameWhenUserEntityExistsThenReturnsSaved() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + this.repository.save(userEntity); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findByUsername(userEntity.getName()); + + assertThat(savedUserEntity).isNotNull(); + } + + @Test + void deleteUserEntityWhenRecordExistThenSuccess() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + this.repository.save(userEntity); + + this.repository.delete(userEntity.getId()); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId()); + assertThat(savedUserEntity).isNull(); + } + + @Test + void findUserEntityByIdWhenUserEntityDoesNotExistThenReturnsNull() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId()); + assertThat(savedUserEntity).isNull(); + } + + @Test + void findUserEntityByUserNameWhenUserEntityDoesNotExistThenReturnsEmpty() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findByUsername(userEntity.getName()); + assertThat(savedUserEntity).isNull(); + } + + private PublicKeyCredentialUserEntity testUserEntity(Bytes id) { + // @formatter:off + return ImmutablePublicKeyCredentialUserEntity.builder() + .name("user2") + .id(id) + .displayName("user2") + .build(); + // @formatter:on + } + +} diff --git a/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepositoryTests.java b/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepositoryTests.java new file mode 100644 index 00000000000..4829b537f0a --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepositoryTests.java @@ -0,0 +1,180 @@ +/* + * Copyright 2002-2024 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.management; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.security.web.webauthn.api.AuthenticatorTransport; +import org.springframework.security.web.webauthn.api.CredentialRecord; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialType; +import org.springframework.security.web.webauthn.api.TestCredentialRecord; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link JdbcUserCredentialRepository} + * + * @author Max Batischev + */ +public class JdbcUserCredentialRepositoryTests { + + private EmbeddedDatabase db; + + private JdbcUserCredentialRepository jdbcUserCredentialRepository; + + private static final String USER_CREDENTIALS_SQL_RESOURCE = "org/springframework/security/user-credentials-schema.sql"; + + @BeforeEach + void setUp() { + this.db = createDb(); + JdbcOperations jdbcOperations = new JdbcTemplate(this.db); + this.jdbcUserCredentialRepository = new JdbcUserCredentialRepository(jdbcOperations); + } + + @AfterEach + void tearDown() { + this.db.shutdown(); + } + + private static EmbeddedDatabase createDb() { + // @formatter:off + return new EmbeddedDatabaseBuilder() + .generateUniqueName(true) + .setType(EmbeddedDatabaseType.HSQL) + .setScriptEncoding("UTF-8") + .addScript(USER_CREDENTIALS_SQL_RESOURCE) + .build(); + // @formatter:on + } + + @Test + void constructorWhenJdbcOperationsIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new JdbcUserCredentialRepository(null)) + .withMessage("jdbcOperations cannot be null"); + // @formatter:on + } + + @Test + void saveWhenCredentialRecordIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.jdbcUserCredentialRepository.save(null)) + .withMessage("record cannot be null"); + // @formatter:on + } + + @Test + void findByCredentialIdWheCredentialIdIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.jdbcUserCredentialRepository.findByCredentialId(null)) + .withMessage("credentialId cannot be null"); + // @formatter:on + } + + @Test + void findByCredentialIdWheUserIdIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.jdbcUserCredentialRepository.findByUserId(null)) + .withMessage("userId cannot be null"); + // @formatter:on + } + + @Test + void saveCredentialRecordWhenSaveThenReturnsSaved() { + CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build(); + this.jdbcUserCredentialRepository.save(userCredential); + + CredentialRecord savedUserCredential = this.jdbcUserCredentialRepository + .findByCredentialId(userCredential.getCredentialId()); + + assertThat(savedUserCredential).isNotNull(); + assertThat(savedUserCredential.getCredentialId()).isEqualTo(userCredential.getCredentialId()); + assertThat(savedUserCredential.getUserEntityUserId()).isEqualTo(userCredential.getUserEntityUserId()); + assertThat(savedUserCredential.getLabel()).isEqualTo(userCredential.getLabel()); + assertThat(savedUserCredential.getPublicKey().getBytes()).isEqualTo(userCredential.getPublicKey().getBytes()); + assertThat(savedUserCredential.isBackupEligible()).isEqualTo(userCredential.isBackupEligible()); + assertThat(savedUserCredential.isBackupState()).isEqualTo(userCredential.isBackupState()); + assertThat(savedUserCredential.getCreated()).isNotNull(); + assertThat(savedUserCredential.getLastUsed()).isNotNull(); + assertThat(savedUserCredential.isUvInitialized()).isFalse(); + assertThat(savedUserCredential.getSignatureCount()).isEqualTo(100); + assertThat(savedUserCredential.getCredentialType()).isEqualTo(PublicKeyCredentialType.PUBLIC_KEY); + assertThat(savedUserCredential.getTransports().contains(AuthenticatorTransport.HYBRID)).isTrue(); + assertThat(savedUserCredential.getTransports().contains(AuthenticatorTransport.BLE)).isTrue(); + assertThat(new String(savedUserCredential.getAttestationObject().getBytes())).isEqualTo("test"); + assertThat(new String(savedUserCredential.getAttestationClientDataJSON().getBytes())).isEqualTo("test"); + } + + @Test + void findCredentialRecordByUserIdWhenRecordExistsThenReturnsSaved() { + CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build(); + this.jdbcUserCredentialRepository.save(userCredential); + + List credentialRecords = this.jdbcUserCredentialRepository + .findByUserId(userCredential.getUserEntityUserId()); + + assertThat(credentialRecords).isNotNull(); + assertThat(credentialRecords.size()).isEqualTo(1); + } + + @Test + void findCredentialRecordByUserIdWhenRecordDoesNotExistThenReturnsEmpty() { + CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build(); + + List credentialRecords = this.jdbcUserCredentialRepository + .findByUserId(userCredential.getUserEntityUserId()); + + assertThat(credentialRecords.size()).isEqualTo(0); + } + + @Test + void findCredentialRecordByCredentialIdWhenRecordDoesNotExistThenReturnsNull() { + CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build(); + + CredentialRecord credentialRecord = this.jdbcUserCredentialRepository + .findByCredentialId(userCredential.getCredentialId()); + + assertThat(credentialRecord).isNull(); + } + + @Test + void deleteCredentialRecordWhenRecordExistThenSuccess() { + CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build(); + this.jdbcUserCredentialRepository.save(userCredential); + + this.jdbcUserCredentialRepository.delete(userCredential.getCredentialId()); + + CredentialRecord credentialRecord = this.jdbcUserCredentialRepository + .findByCredentialId(userCredential.getCredentialId()); + assertThat(credentialRecord).isNull(); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/webauthn/management/MapUserCredentialRepositoryTests.java b/web/src/test/java/org/springframework/security/web/webauthn/management/MapUserCredentialRepositoryTests.java index 36081973f88..d14e98df126 100644 --- a/web/src/test/java/org/springframework/security/web/webauthn/management/MapUserCredentialRepositoryTests.java +++ b/web/src/test/java/org/springframework/security/web/webauthn/management/MapUserCredentialRepositoryTests.java @@ -20,9 +20,9 @@ import org.junit.jupiter.api.Test; -import org.springframework.security.web.webauthn.api.Bytes; import org.springframework.security.web.webauthn.api.CredentialRecord; import org.springframework.security.web.webauthn.api.ImmutableCredentialRecord; +import org.springframework.security.web.webauthn.api.TestBytes; import org.springframework.security.web.webauthn.api.TestCredentialRecord; import static org.assertj.core.api.Assertions.assertThat; @@ -41,7 +41,7 @@ class MapUserCredentialRepositoryTests { @Test void findByUserIdWhenNotFoundThenEmpty() { - assertThat(this.userCredentials.findByUserId(Bytes.random())).isEmpty(); + assertThat(this.userCredentials.findByUserId(TestBytes.get())).isEmpty(); } @Test @@ -56,7 +56,7 @@ void findByCredentialIdWhenIdNullThenIllegalArgumentException() { @Test void findByCredentialIdWhenNotFoundThenIllegalArgumentException() { - assertThat(this.userCredentials.findByCredentialId(Bytes.random())).isNull(); + assertThat(this.userCredentials.findByCredentialId(TestBytes.get())).isNull(); } @Test @@ -114,7 +114,7 @@ void saveWhenSameUserThenUpdated() { ImmutableCredentialRecord credentialRecord = TestCredentialRecord.userCredential().build(); this.userCredentials.save(credentialRecord); CredentialRecord newCredentialRecord = ImmutableCredentialRecord.fromCredentialRecord(credentialRecord) - .credentialId(Bytes.random()) + .credentialId(TestBytes.get()) .build(); this.userCredentials.save(newCredentialRecord); assertThat(this.userCredentials.findByCredentialId(credentialRecord.getCredentialId())) @@ -130,8 +130,8 @@ void saveWhenDifferentUserThenNewEntryAdded() { ImmutableCredentialRecord credentialRecord = TestCredentialRecord.userCredential().build(); this.userCredentials.save(credentialRecord); CredentialRecord newCredentialRecord = ImmutableCredentialRecord.fromCredentialRecord(credentialRecord) - .userEntityUserId(Bytes.random()) - .credentialId(Bytes.random()) + .userEntityUserId(TestBytes.get()) + .credentialId(TestBytes.get()) .build(); this.userCredentials.save(newCredentialRecord); assertThat(this.userCredentials.findByCredentialId(credentialRecord.getCredentialId())) diff --git a/web/src/test/java/org/springframework/security/web/webauthn/registration/DefaultWebAuthnRegistrationPageGeneratingFilterTests.java b/web/src/test/java/org/springframework/security/web/webauthn/registration/DefaultWebAuthnRegistrationPageGeneratingFilterTests.java index 03fe8d0fece..7f681cc1dc9 100644 --- a/web/src/test/java/org/springframework/security/web/webauthn/registration/DefaultWebAuthnRegistrationPageGeneratingFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/webauthn/registration/DefaultWebAuthnRegistrationPageGeneratingFilterTests.java @@ -31,10 +31,10 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.csrf.DefaultCsrfToken; -import org.springframework.security.web.webauthn.api.Bytes; import org.springframework.security.web.webauthn.api.ImmutableCredentialRecord; import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity; import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.TestBytes; import org.springframework.security.web.webauthn.api.TestCredentialRecord; import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository; import org.springframework.security.web.webauthn.management.UserCredentialRepository; @@ -88,7 +88,7 @@ void doFilterWhenNotMatchThenNoInteractions() throws Exception { void doFilterThenCsrfDataAttrsPresent() throws Exception { PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder() .name("user") - .id(Bytes.random()) + .id(TestBytes.get()) .displayName("User") .build(); given(this.userEntities.findByUsername(any())).willReturn(userEntity); @@ -115,7 +115,7 @@ void doFilterWhenNullPublicKeyCredentialUserEntityThenNoResults() throws Excepti void doFilterWhenNoCredentialsThenNoResults() throws Exception { PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder() .name("user") - .id(Bytes.random()) + .id(TestBytes.get()) .displayName("User") .build(); given(this.userEntities.findByUsername(any())).willReturn(userEntity); @@ -129,7 +129,7 @@ void doFilterWhenNoCredentialsThenNoResults() throws Exception { void doFilterWhenResultsThenDisplayed() throws Exception { PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder() .name("user") - .id(Bytes.random()) + .id(TestBytes.get()) .displayName("User") .build(); @@ -225,7 +225,7 @@ void doFilterWhenResultsContainEntitiesThenEncoded() throws Exception { assertThat(label).isNotEqualTo(htmlEncodedLabel); PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder() .name("user") - .id(Bytes.random()) + .id(TestBytes.get()) .displayName("User") .build(); ImmutableCredentialRecord credential = TestCredentialRecord.userCredential().label(label).build(); @@ -240,7 +240,7 @@ void doFilterWhenResultsContainEntitiesThenEncoded() throws Exception { void doFilterWhenContextEmptyThenUrlsEmptyPrefix() throws Exception { PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder() .name("user") - .id(Bytes.random()) + .id(TestBytes.get()) .displayName("User") .build(); ImmutableCredentialRecord credential = TestCredentialRecord.userCredential().build(); @@ -256,7 +256,7 @@ void doFilterWhenContextEmptyThenUrlsEmptyPrefix() throws Exception { void doFilterWhenContextNotEmptyThenUrlsPrefixed() throws Exception { PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder() .name("user") - .id(Bytes.random()) + .id(TestBytes.get()) .displayName("User") .build(); ImmutableCredentialRecord credential = TestCredentialRecord.userCredential().build();