diff --git a/.github/dco.yml b/.github/dco.yml new file mode 100644 index 0000000000..0c4b142e9a --- /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 f36d30d555..b7baf20f2b 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 b50ed7289a..8b2f0f1eac 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 e254c3e056..8ae6a0588d 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 60089e6734..4fd368b54a 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 6d2e01abc0..628b4ad52a 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 b6c7c6f8fa..fad74fdb7b 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 075856f3a4..cc5d7a3501 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 d94e9d9083..4f849f86fb 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 0000000000..e84b8455f1 --- /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 cc11cdef40..b0bf43fb3b 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 d172a85d59..f58e9e55fc 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 841783c4f6..a7251514fd 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 fc4a2a3880..fa601b9449 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 1a955e523d..104a0be328 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 d191bb740b..1c6f9d1cb7 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 15718bf51b..654c277e49 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 ce7c50be58..8f2baeb4c6 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 53635b5aa0..db915da867 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 860ed9fc55..24566458e1 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 64249d7c80..0133670a18 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 025e65e741..2345bc5a67 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 1624817431..23447c1b6d 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 0d33c0702a..ce4bc54ca5 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 9b2469aa87..9dcb730571 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 e46438d80d..03a00f3665 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 cff442fffe..4807eda046 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 8561390515..70f383c203 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 0000000000..450a3dfdc1 --- /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 326e2bda10..e546ffb6a1 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 00b67e17f9..65d7c13bea 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 6d683c4899..f4646fe6f5 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 272f7ed26a..e5c080aefe 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 fbe52459a4..bca300ec52 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 83bac3b026..f98c86bbf2 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 a90c43f312..201fbc4553 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 b56d047a5f..770a8a17be 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 f89a37ae40..b3c97b9201 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 3957d416da..e13bddf707 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 a5b899db48..d75ce815d5 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 9a4e3b041e..c7f0590bc1 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 c66933de16..2c41d1a368 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 152525d4a2..d51349440a 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 b165c20b60..800a3f45ae 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 5a124b2f97..dfede958e7 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 07833e283f..a8b52c5137 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 c0705e50bc..00e02f5821 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 6437c54326..9117ae757a 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 0000000000..7e8c3d12a3 --- /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 0000000000..98215ca86c --- /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 0000000000..7bf56c9a3a --- /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 0000000000..61dae86206 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 0000000000..222e625eb6 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 0000000000..ae659612d7 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 0000000000..004b8f22ea 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 0000000000..4e99aa0365 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 0000000000..c12cd3a7c5 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 0000000000..36c9802e72 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 0000000000..0ec7355f62 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 0000000000..71d58fa87c 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 0000000000..24e5a933fa 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 0000000000..3ce3a576f5 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 0000000000..30e52eafc8 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 0000000000..1a7ade4e8d 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 0000000000..979b2e937a 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 0000000000..e4afece24a 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 0000000000..c067d46e43 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 0000000000..927df00481 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 0000000000..46609358d9 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 0000000000..18de70b605 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 0000000000..f348e60c84 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 0000000000..15790690a4 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 0000000000..d04eb51778 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 0000000000..49143cf818 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 0000000000..646896dde4 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 0000000000..d371ae6ae4 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 0000000000..6532dac81f 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 0000000000..72c4958525 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 0000000000..112bcf688c 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 0000000000..5a4ccd07b4 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 0000000000..a22f7a0f9b 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 0000000000..0272398b25 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 0000000000..51e783d58c 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 0000000000..148433692c 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 0000000000..911742c981 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 0000000000..7566a0979b 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 0000000000..836566955a 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 0000000000..de67c73ec2 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 0000000000..b082c12d28 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 0000000000..275216a9f2 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 0000000000..39a7ada3a1 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 0000000000..e0026470c3 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 0000000000..ac27bf9f67 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 0000000000..539b3ea50e 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 0000000000..e2cd7fbb99 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 0000000000..098c85e9bd 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 0000000000..4c8b96b31e 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 0000000000..4fd752b76f 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 0000000000..f771882b3d 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 0000000000..6d7a94c295 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 0000000000..e983ebc013 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 0000000000..b4f3a5f6ac 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 0000000000..fe88d36cd4 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 0000000000..5b627fb9c7 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 0000000000..4fc1f92cb2 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 0000000000..17b756520d 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 0000000000..2d1621125f 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 0000000000..55eddf9e9f 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 0000000000..693e898c31 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 0000000000..18f8a50a34 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 0000000000..dd210a4612 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 0000000000..52e1faf545 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 0000000000..6556a08dde 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 0000000000..9cff958c49 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 0000000000..33fb178f62 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 0000000000..95888e6e1c 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 0000000000..9c6c667a11 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 0000000000..2fc9de7065 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 0000000000..5d1c909a79 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 0000000000..5db839c1ab 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 0000000000..14e74db3b4 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 0000000000..9eb1e5f751 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 0000000000..8a382b2610 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 0000000000..aad2554cae 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 0000000000..a3fd001c78 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 0000000000..418c3b8ece 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 3bf6ceac5a..49efd9f689 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 6952be563a..4320b0075f 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 3079174e52..2cbc640b3a 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 3a3ccdf91e..f129fdbe17 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 daae07eec9..8d7107ed5b 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 eac534ba6d..fba28adf0b 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 7697dea90d..3ec29ce6a2 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 2aab5dba91..7289d8a1ed 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 3dc86cc5a1..8642484a41 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 26af51a6f1..41ec280bc7 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 4bc3d19b5b..58174d9d1a 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 e8ef659882..1193bf5236 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 91b5d616d8..0ed92018e6 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 69d7233bdf..3bd076dfd8 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 e202ef7b5a..bc759f5f7a 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 8e532169ae..0419417763 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 31a75ce0cc..fba1718590 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 0e072b527a..f475934927 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 3037ebaaf0..de59b2d5ef 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 9b2272b08f..5262fdb52e 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 629a28e8c8..870a6ea1f8 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 796690b0e6..6c80a3e883 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 57f218a239..2849ba0371 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 3a4604354f..79c0fd479f 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 086e16cb37..a1f680dc5d 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 544964cdec..5cc0702909 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 1a1cf7c87e..ee4f5538e2 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 772774d3f1..31617e6caa 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 167d5fae3b..d84f38625e 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 5b3b9bcd24..5b18199a6c 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 c93d2a9165..eac89b4eaf 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 094d0a332d..1ea77c2a21 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 4b70d77950..c3b6d427bd 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 0afa2b882b..ec654a2a9f 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 c9a023ef83..b03e65dd18 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 6365bdb5f1..0d67961794 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 03289f12b7..8ee8199cd0 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 014541373a..a58665bd1e 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 672876164f..04d042b96a 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 fdcb1e70aa..63385e1cbd 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 94e7d6a231..05d0fcdbc5 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 a848dff491..d4bce6b586 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 693bc7e4a7..9cde351930 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 347644734c..88708cecd4 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 5093e1bd1d..e3f5c9297c 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 c14125c475..ac38804cff 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 0089ae455d..7a4b3d30fe 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 4c8c20da5c..a02ad09eb6 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 22c3c1d8e5..d1d969dc26 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 974910bc91..5db1b2e538 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 0e732bf480..587e795f5a 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 9d1b066d01..1ca226709b 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 b19b19bfcf..6d9f34ac61 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 b1a7a779aa..976e0879ab 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 d4afce73c1..b696c0c4bf 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 0000000000..024d560449 --- /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 0000000000..9c5407ae00 --- /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 bd002f31e8..59def321a4 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 b6eee52585..e1ca19df49 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 ccdcbf5e9a..3cbda22e30 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 59e48e0986..d49c2f12db 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 6548b67923..ad143353b8 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 b181fa9635..7d900f9476 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 d94b8a8d2e..876deb3a26 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 db67a98527..b799f6637a 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 9b0cd52356..0ccbf18cc1 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 e98615eb01..ddf930762f 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 fe0821a31d..c6282e2351 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 903fcb5ac1..329270c65e 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 92223407e2..49314e8ba8 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 4cab6a8472..f2f14be45e 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 375c8a12a8..0418877371 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 e4195ea9dc..bec08cf2ef 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 dfc2ed4733..2798fd2206 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 49d97c46a6..997f9db7c5 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 ed3bd1af17..c77a95cf92 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 773ad5f4f5..ce39420774 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 322bdade8f..37912fc8b3 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 f2d019008d..c0134f2c31 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 e79f42a258..bbd150c53b 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 b4c26d08f0..e2cdc0148d 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 2c6413a33e..88dae0052e 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 42b0403740..124fce51bb 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 f84e8df620..aaa4164da5 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 84eb48cdf9..629513cc8b 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 73ab142052..f01222d4a2 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 2aa2b330e0..a6ac94590d 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 8050b74a03..257f26f4f5 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 ee4c0e4784..0bb5649ece 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 77b1fdd121..d8fc1a099d 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 ba1eaacd2c..30f1185c9b 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 3cc5754cac..9d35ddc69b 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 0639a395f8..b492a6d801 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 c189317ec4..4909d0f730 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 f42249284f..e7e718949c 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 bb95dd20b7..0123a2aab7 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 3f696c361c..d6d0e81927 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 300a815caf..591ef091da 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 070e2040bd..9dbcbd5a5c 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 c10a3f82cf..a0abf7132e 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 ec293997f5..bf7ab09678 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 e1321bd759..433811a781 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 d288503f13..ea1124d041 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 a868f3180d..ac760c5dc4 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 dbfdf98e5f..af833d1dae 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 3a30545179..2742d0c51e 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 775da4c9a9..cd1b90a14c 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 9b48f5c4a2..365993c5ed 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 b13f0dff26..2004727ffb 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 94568d2dc6..ab3722e5fd 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 2713ee96b2..732ecc2476 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 f343cd2b69..378a6dbd41 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 fb4535f240..c45b4a958b 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 0ba62813da..c82b3bd5e4 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 5e155c8bce..cddd32c3b0 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 e2649ba975..6650d96e57 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 4674ab7886..ef9b4f3309 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 7c6bf8ecb0..283317f95e 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 01555f01fd..32afbf6798 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 ae0f01afd7..8fe1298360 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 dc4e6bb770..3595dec00a 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 025ffc6b36..3d99fc2cfa 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 6ee38c6d60..36075ba0df 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 ed24f19f91..6578d8dcb5 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 0000000000..ff697bd07d --- /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 0000000000..c35cf5b81c --- /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 0000000000..c3b4c95a14 --- /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 3bf2c6f0db..5e62d2ebeb 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 0000000000..87c5034905 --- /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 8c9cbf65b6..2ad462993e 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 0000000000..9fa8873ed2 --- /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 d18e7c0cc2..57767d815c 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 dabaedd895..3a477e0c50 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 00668e06d8..d434bbc47b 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 dc727efa92..a1fc8c4ee8 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 8d528f5621..51be7bd0ab 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 db1650b3a9..6ec0835f75 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 1b6c36deb3..f06cec22e3 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 0000000000..362f3a7f7d --- /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 70ba6108bb..5b7af22bf3 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 8ac38137d0..6628a9e27a 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 c53541ac54..e18dc3961b 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 621391651f..a0950fa44b 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 682be4b1dd..122d95d1ce 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 0c57e5a604..bb4afac31d 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 5a6a63f4bb..a8326fa2a7 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 b997031a47..ea91775b62 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 a54a55a96d..87daedcc40 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 70b098e4fe..b5fd4d0777 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 8eab25cf1f..85686f5815 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 8301e17dcf..170d1d0b68 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 631c5b7fdc..bdb693e95c 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 eb49369e6f..2a32018a5c 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 5246838dcf..f46140d351 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 ceea54bdbc..001a5accf4 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 15dcfff296..547bc7fcdb 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 944dd3c202..d3ac900ad4 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 1320c1bb50..ec0b645d58 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 1fa8e1573c..44c99a56b5 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 b28b69bbba..0630629cf2 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 61da5dca62..60b7c32647 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 e3add8edf3..53c0af8d92 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 af2c0a23f5..03624dbe22 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 4d7ca1e38d..2746a0928b 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 8009f0f16f..e6ad216c8c 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 0cfd084936..dc0d588c7c 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 0c6b9c9e74..8263081ddc 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 67c1a2b9b3..a6ea540716 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 77085c4350..8cafd92aa9 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 b02b33eecb..894cab4ed5 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 343b0bde1c..ed1e6e4837 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 eb408569fa..6cc3d84413 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 b1cd17892d..0561996566 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 442acc5fd0..f1a27e17b5 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 b7709d41f2..7640d7a366 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 06eb0bbbe6..23319e366a 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 158e8627cd..31b85366d4 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 1bb2990446..07a6184a96 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 0fe386aecc..97a1c8e1f4 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 0000000000..bfeaafb0e8 --- /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 0000000000..aa012d6964 --- /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 3f163b0cc2..0863925c8c 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 dcc4be6a06..2a3c8ad768 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 0000000000..1be48f2fb1 --- /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 0000000000..ec66c66519 --- /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 0000000000..4909a64303 --- /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 0000000000..33799cc6f9 --- /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 77b49be1a1..91e2d93cdf 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 0000000000..12a491230e --- /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 0000000000..f3cdb2fd51 --- /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 ffe51cc2a0..aa1bed6d8f 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 0000000000..134f9f6e7a --- /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 d819d4c798..fff01a5f3b 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 3f1f7f797b..16733d100b 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 0000000000..b8850c12de --- /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 917125ae67..1ed190c03d 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 704e6ce17f..cc35752d15 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 0000000000..503108ac4e --- /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 0000000000..4829b537f0 --- /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 36081973f8..d14e98df12 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 03fe8d0fec..7f681cc1dc 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();