diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 23736cf2c83..4082d7441c8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,6 +19,7 @@ updates: - dependency-name: com.nimbusds:nimbus-jose-jwt - dependency-name: org.python:jython - dependency-name: org.apache.directory.server:* + - dependency-name: org.apache.directory.shared:* - dependency-name: org.junit:junit-bom update-types: - version-update:semver-major @@ -44,6 +45,7 @@ updates: - dependency-name: com.nimbusds:nimbus-jose-jwt - dependency-name: org.python:jython - dependency-name: org.apache.directory.server:* + - dependency-name: org.apache.directory.shared:* - dependency-name: org.junit:junit-bom update-types: - version-update:semver-major @@ -69,6 +71,7 @@ updates: - dependency-name: com.nimbusds:nimbus-jose-jwt - dependency-name: org.python:jython - dependency-name: org.apache.directory.server:* + - dependency-name: org.apache.directory.shared:* - dependency-name: org.junit:junit-bom update-types: - version-update:semver-major diff --git a/config/src/main/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurer.java index 7320840d9b3..b23810c48e9 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurer.java @@ -95,14 +95,10 @@ else if (beanNames.length > 1) { PasswordEncoder passwordEncoder = getBeanOrNull(PasswordEncoder.class); UserDetailsPasswordService passwordManager = getBeanOrNull(UserDetailsPasswordService.class); CompromisedPasswordChecker passwordChecker = getBeanOrNull(CompromisedPasswordChecker.class); - DaoAuthenticationProvider provider; + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); if (passwordEncoder != null) { - provider = new DaoAuthenticationProvider(passwordEncoder); + provider.setPasswordEncoder(passwordEncoder); } - else { - provider = new DaoAuthenticationProvider(); - } - provider.setUserDetailsService(userDetailsService); if (passwordManager != null) { provider.setUserDetailsPasswordService(passwordManager); } diff --git a/config/src/main/java/org/springframework/security/config/annotation/authentication/configurers/userdetails/AbstractDaoAuthenticationConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/authentication/configurers/userdetails/AbstractDaoAuthenticationConfigurer.java index 519c2bd53f1..35adfe2f351 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/authentication/configurers/userdetails/AbstractDaoAuthenticationConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/authentication/configurers/userdetails/AbstractDaoAuthenticationConfigurer.java @@ -36,7 +36,7 @@ public abstract class AbstractDaoAuthenticationConfigurer, C extends AbstractDaoAuthenticationConfigurer, U extends UserDetailsService> extends UserDetailsAwareConfigurer { - private DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + private DaoAuthenticationProvider provider; private final U userDetailsService; @@ -46,7 +46,7 @@ public abstract class AbstractDaoAuthenticationConfigurer 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/configurers/SessionManagementConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java index fc4a2a38804..fa601b94494 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java @@ -47,6 +47,7 @@ import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy; +import org.springframework.security.web.authentication.session.SessionLimit; import org.springframework.security.web.context.DelegatingSecurityContextRepository; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.NullSecurityContextRepository; @@ -123,7 +124,7 @@ public final class SessionManagementConfigurer> private SessionRegistry sessionRegistry; - private Integer maximumSessions; + private SessionLimit sessionLimit; private String expiredUrl; @@ -329,7 +330,7 @@ public SessionManagementConfigurer sessionFixation( * @return the {@link SessionManagementConfigurer} for further customizations */ public ConcurrencyControlConfigurer maximumSessions(int maximumSessions) { - this.maximumSessions = maximumSessions; + this.sessionLimit = SessionLimit.of(maximumSessions); this.propertiesThatRequireImplicitAuthentication.add("maximumSessions = " + maximumSessions); return new ConcurrencyControlConfigurer(); } @@ -570,7 +571,7 @@ private SessionAuthenticationStrategy getSessionAuthenticationStrategy(H http) { SessionRegistry sessionRegistry = getSessionRegistry(http); ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlStrategy = new ConcurrentSessionControlAuthenticationStrategy( sessionRegistry); - concurrentSessionControlStrategy.setMaximumSessions(this.maximumSessions); + concurrentSessionControlStrategy.setMaximumSessions(this.sessionLimit); concurrentSessionControlStrategy.setExceptionIfMaximumExceeded(this.maxSessionsPreventsLogin); concurrentSessionControlStrategy = postProcess(concurrentSessionControlStrategy); RegisterSessionAuthenticationStrategy registerSessionStrategy = new RegisterSessionAuthenticationStrategy( @@ -614,7 +615,7 @@ private void registerDelegateApplicationListener(H http, ApplicationListener * @return */ private boolean isConcurrentSessionControlEnabled() { - return this.maximumSessions != null; + return this.sessionLimit != null; } /** @@ -706,7 +707,19 @@ private ConcurrencyControlConfigurer() { * @return the {@link ConcurrencyControlConfigurer} for further customizations */ public ConcurrencyControlConfigurer maximumSessions(int maximumSessions) { - SessionManagementConfigurer.this.maximumSessions = maximumSessions; + SessionManagementConfigurer.this.sessionLimit = SessionLimit.of(maximumSessions); + return this; + } + + /** + * Determines the behaviour when a session limit is detected. + * @param sessionLimit the {@link SessionLimit} to check the maximum number of + * sessions for a user + * @return the {@link ConcurrencyControlConfigurer} for further customizations + * @since 6.5 + */ + public ConcurrencyControlConfigurer maximumSessions(SessionLimit sessionLimit) { + SessionManagementConfigurer.this.sessionLimit = sessionLimit; return this; } diff --git a/config/src/main/java/org/springframework/security/config/authentication/AuthenticationManagerFactoryBean.java b/config/src/main/java/org/springframework/security/config/authentication/AuthenticationManagerFactoryBean.java index 337fc081f79..1f6ced54dc4 100644 --- a/config/src/main/java/org/springframework/security/config/authentication/AuthenticationManagerFactoryBean.java +++ b/config/src/main/java/org/springframework/security/config/authentication/AuthenticationManagerFactoryBean.java @@ -65,8 +65,7 @@ public AuthenticationManager getObject() throws Exception { if (uds == null) { throw new NoSuchBeanDefinitionException(BeanIds.AUTHENTICATION_MANAGER, MISSING_BEAN_ERROR_MESSAGE); } - DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); - provider.setUserDetailsService(uds); + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(uds); PasswordEncoder passwordEncoder = this.bf.getBeanProvider(PasswordEncoder.class).getIfUnique(); if (passwordEncoder != null) { provider.setPasswordEncoder(passwordEncoder); diff --git a/config/src/main/java/org/springframework/security/config/http/DefaultFilterChainValidator.java b/config/src/main/java/org/springframework/security/config/http/DefaultFilterChainValidator.java index ce7c50be584..8f2baeb4c68 100644 --- a/config/src/main/java/org/springframework/security/config/http/DefaultFilterChainValidator.java +++ b/config/src/main/java/org/springframework/security/config/http/DefaultFilterChainValidator.java @@ -39,6 +39,7 @@ import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.FilterInvocation; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.UnreachableFilterChainException; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource; @@ -53,7 +54,6 @@ import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter; import org.springframework.security.web.session.SessionManagementFilter; import org.springframework.security.web.util.matcher.AnyRequestMatcher; -import org.springframework.security.web.util.matcher.RequestMatcher; public class DefaultFilterChainValidator implements FilterChainProxy.FilterChainValidator { @@ -69,31 +69,67 @@ public void validate(FilterChainProxy fcp) { } checkPathOrder(new ArrayList<>(fcp.getFilterChains())); checkForDuplicateMatchers(new ArrayList<>(fcp.getFilterChains())); + checkAuthorizationFilters(new ArrayList<>(fcp.getFilterChains())); } private void checkPathOrder(List filterChains) { // Check that the universal pattern is listed at the end, if at all Iterator chains = filterChains.iterator(); while (chains.hasNext()) { - RequestMatcher matcher = ((DefaultSecurityFilterChain) chains.next()).getRequestMatcher(); - if (AnyRequestMatcher.INSTANCE.equals(matcher) && chains.hasNext()) { - throw new IllegalArgumentException("A universal match pattern ('/**') is defined " - + " before other patterns in the filter chain, causing them to be ignored. Please check the " - + "ordering in your namespace or FilterChainProxy bean configuration"); + if (chains.next() instanceof DefaultSecurityFilterChain securityFilterChain) { + if (AnyRequestMatcher.INSTANCE.equals(securityFilterChain.getRequestMatcher()) && chains.hasNext()) { + throw new UnreachableFilterChainException("A universal match pattern ('/**') is defined " + + " before other patterns in the filter chain, causing them to be ignored. Please check the " + + "ordering in your namespace or FilterChainProxy bean configuration", + securityFilterChain, chains.next()); + } } } } private void checkForDuplicateMatchers(List chains) { - while (chains.size() > 1) { - DefaultSecurityFilterChain chain = (DefaultSecurityFilterChain) chains.remove(0); - for (SecurityFilterChain test : chains) { - if (chain.getRequestMatcher().equals(((DefaultSecurityFilterChain) test).getRequestMatcher())) { - throw new IllegalArgumentException("The FilterChainProxy contains two filter chains using the" - + " matcher " + chain.getRequestMatcher() + ". If you are using multiple namespace " - + "elements, you must use a 'pattern' attribute to define the request patterns to which they apply."); + DefaultSecurityFilterChain filterChain = null; + for (SecurityFilterChain chain : chains) { + if (filterChain != null) { + if (chain instanceof DefaultSecurityFilterChain defaultChain) { + if (defaultChain.getRequestMatcher().equals(filterChain.getRequestMatcher())) { + throw new UnreachableFilterChainException( + "The FilterChainProxy contains two filter chains using the" + " matcher " + + defaultChain.getRequestMatcher() + + ". If you are using multiple namespace " + + "elements, you must use a 'pattern' attribute to define the request patterns to which they apply.", + defaultChain, chain); + } } } + if (chain instanceof DefaultSecurityFilterChain defaultChain) { + filterChain = defaultChain; + } + } + } + + private void checkAuthorizationFilters(List chains) { + Filter authorizationFilter = null; + Filter filterSecurityInterceptor = null; + for (SecurityFilterChain chain : chains) { + for (Filter filter : chain.getFilters()) { + if (filter instanceof AuthorizationFilter) { + authorizationFilter = filter; + } + if (filter instanceof FilterSecurityInterceptor) { + filterSecurityInterceptor = filter; + } + } + if (authorizationFilter != null && filterSecurityInterceptor != null) { + this.logger.warn( + "It is not recommended to use authorizeRequests in the configuration. Please only use authorizeHttpRequests"); + } + if (filterSecurityInterceptor != null) { + this.logger.warn( + "Usage of authorizeRequests is deprecated. Please use authorizeHttpRequests in the configuration"); + } + authorizationFilter = null; + filterSecurityInterceptor = null; } } diff --git a/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java b/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java index 53635b5aa0b..db915da8678 100644 --- a/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java +++ b/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java @@ -122,6 +122,10 @@ class HttpConfigurationBuilder { private static final String ATT_SESSION_AUTH_STRATEGY_REF = "session-authentication-strategy-ref"; + private static final String ATT_MAX_SESSIONS_REF = "max-sessions-ref"; + + private static final String ATT_MAX_SESSIONS = "max-sessions"; + private static final String ATT_SESSION_AUTH_ERROR_URL = "session-authentication-error-url"; private static final String ATT_SECURITY_CONTEXT_HOLDER_STRATEGY = "security-context-holder-strategy-ref"; @@ -485,10 +489,16 @@ else if (StringUtils.hasText(sessionAuthStratRef)) { concurrentSessionStrategy.addConstructorArgValue(this.sessionRegistryRef); String maxSessions = this.pc.getReaderContext() .getEnvironment() - .resolvePlaceholders(sessionCtrlElt.getAttribute("max-sessions")); + .resolvePlaceholders(sessionCtrlElt.getAttribute(ATT_MAX_SESSIONS)); if (StringUtils.hasText(maxSessions)) { concurrentSessionStrategy.addPropertyValue("maximumSessions", maxSessions); } + String maxSessionsRef = this.pc.getReaderContext() + .getEnvironment() + .resolvePlaceholders(sessionCtrlElt.getAttribute(ATT_MAX_SESSIONS_REF)); + if (StringUtils.hasText(maxSessionsRef)) { + concurrentSessionStrategy.addPropertyReference("maximumSessions", maxSessionsRef); + } String exceptionIfMaximumExceeded = sessionCtrlElt.getAttribute("error-if-maximum-exceeded"); if (StringUtils.hasText(exceptionIfMaximumExceeded)) { concurrentSessionStrategy.addPropertyValue("exceptionIfMaximumExceeded", exceptionIfMaximumExceeded); @@ -591,6 +601,12 @@ private void createConcurrencyControlFilterAndSessionRegistry(Element element) { .error("Cannot use 'expired-url' attribute and 'expired-session-strategy-ref'" + " attribute together.", source); } + String maxSessions = element.getAttribute(ATT_MAX_SESSIONS); + String maxSessionsRef = element.getAttribute(ATT_MAX_SESSIONS_REF); + if (StringUtils.hasText(maxSessions) && StringUtils.hasText(maxSessionsRef)) { + this.pc.getReaderContext() + .error("Cannot use 'max-sessions' attribute and 'max-sessions-ref' attribute together.", source); + } if (StringUtils.hasText(expiryUrl)) { BeanDefinitionBuilder expiredSessionBldr = BeanDefinitionBuilder .rootBeanDefinition(SimpleRedirectSessionInformationExpiredStrategy.class); diff --git a/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParser.java index 860ed9fc551..24566458e11 100644 --- a/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParser.java +++ b/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParser.java @@ -146,6 +146,7 @@ public BeanDefinition parse(Element element, ParserContext pc) { BeanMetadataElement saml2LogoutRequestSuccessHandler = BeanDefinitionBuilder .rootBeanDefinition(Saml2RelyingPartyInitiatedLogoutSuccessHandler.class) .addConstructorArgValue(logoutRequestResolver) + .addPropertyValue("logoutRequestRepository", logoutRequestRepository) .getBeanDefinition(); this.logoutFilter = BeanDefinitionBuilder.rootBeanDefinition(LogoutFilter.class) .addConstructorArgValue(saml2LogoutRequestSuccessHandler) diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDsl.kt index 0d33c0702a5..ce4bc54ca5a 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDsl.kt @@ -19,7 +19,9 @@ package org.springframework.security.config.annotation.web.session import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer import org.springframework.security.core.session.SessionRegistry +import org.springframework.security.web.authentication.session.SessionLimit import org.springframework.security.web.session.SessionInformationExpiredStrategy +import org.springframework.util.Assert /** * A Kotlin DSL to configure the behaviour of multiple sessions using idiomatic @@ -44,12 +46,21 @@ class SessionConcurrencyDsl { var expiredSessionStrategy: SessionInformationExpiredStrategy? = null var maxSessionsPreventsLogin: Boolean? = null var sessionRegistry: SessionRegistry? = null + private var sessionLimit: SessionLimit? = null + + fun maximumSessions(max: SessionLimit) { + this.sessionLimit = max + } internal fun get(): (SessionManagementConfigurer.ConcurrencyControlConfigurer) -> Unit { + Assert.isTrue(maximumSessions == null || sessionLimit == null, "You cannot specify maximumSessions as both an Int and a SessionLimit. Please use only one.") return { sessionConcurrencyControl -> maximumSessions?.also { sessionConcurrencyControl.maximumSessions(maximumSessions!!) } + sessionLimit?.also { + sessionConcurrencyControl.maximumSessions(sessionLimit!!) + } expiredUrl?.also { sessionConcurrencyControl.expiredUrl(expiredUrl) } diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHeadersDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHeadersDsl.kt index 37bd1f177a9..86289744439 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHeadersDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHeadersDsl.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 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. @@ -17,6 +17,7 @@ package org.springframework.security.config.web.server import org.springframework.security.web.server.header.CacheControlServerHttpHeadersWriter +import org.springframework.security.web.server.header.ServerHttpHeadersWriter import org.springframework.security.web.server.header.ContentTypeOptionsServerHttpHeadersWriter import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter import org.springframework.security.web.server.header.StrictTransportSecurityServerHttpHeadersWriter @@ -43,6 +44,7 @@ class ServerHeadersDsl { private var crossOriginOpenerPolicy: ((ServerHttpSecurity.HeaderSpec.CrossOriginOpenerPolicySpec) -> Unit)? = null private var crossOriginEmbedderPolicy: ((ServerHttpSecurity.HeaderSpec.CrossOriginEmbedderPolicySpec) -> Unit)? = null private var crossOriginResourcePolicy: ((ServerHttpSecurity.HeaderSpec.CrossOriginResourcePolicySpec) -> Unit)? = null + private var writers = mutableListOf() private var disabled = false @@ -198,6 +200,16 @@ class ServerHeadersDsl { this.crossOriginResourcePolicy = ServerCrossOriginResourcePolicyDsl().apply(crossOriginResourcePolicyConfig).get() } + /** + * Configures custom headers writer + * + * @since 6.5 + * @param writer the [ServerHttpHeadersWriter] to provide custom headers writer + */ + fun writer(writer: ServerHttpHeadersWriter) { + this.writers.add(writer) + } + /** * Disables HTTP response headers. */ @@ -244,6 +256,9 @@ class ServerHeadersDsl { crossOriginResourcePolicy?.also { headers.crossOriginResourcePolicy(crossOriginResourcePolicy) } + writers.also { + writers.forEach { writer -> headers.writer(writer) } + } if (disabled) { headers.disable() } diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc index 9b2469aa879..9dcb7305714 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc +++ b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc @@ -934,6 +934,9 @@ concurrency-control = concurrency-control.attlist &= ## The maximum number of sessions a single authenticated user can have open at the same time. Defaults to "1". A negative value denotes unlimited sessions. attribute max-sessions {xsd:token}? +concurrency-control.attlist &= + ## Allows injection of the SessionLimit instance used by the ConcurrentSessionControlAuthenticationStrategy + attribute max-sessions-ref {xsd:token}? concurrency-control.attlist &= ## The URL a user will be redirected to if they attempt to use a session which has been "expired" because they have logged in again. attribute expired-url {xsd:token}? diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd index e46438d80dd..03a00f36657 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd +++ b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd @@ -2688,6 +2688,13 @@ + + + Allows injection of the SessionLimit instance used by the + ConcurrentSessionControlAuthenticationStrategy + + + The URL a user will be redirected to if they attempt to use a session which has been diff --git a/config/src/test/java/org/springframework/security/SpringSecurityCoreVersionSerializableTests.java b/config/src/test/java/org/springframework/security/SpringSecurityCoreVersionSerializableTests.java index 5c26d7c56c0..4a6243d16e1 100644 --- a/config/src/test/java/org/springframework/security/SpringSecurityCoreVersionSerializableTests.java +++ b/config/src/test/java/org/springframework/security/SpringSecurityCoreVersionSerializableTests.java @@ -116,8 +116,11 @@ 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.OAuth2IntrospectionAuthenticatedPrincipal; +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; @@ -127,6 +130,12 @@ import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; 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.www.NonceExpiredException; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; @@ -303,6 +312,10 @@ class SpringSecurityCoreVersionSerializableTests { (r) -> new LdapAuthority("USER", "username", Map.of("attribute", List.of("value1", "value2")))); // 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, @@ -324,6 +337,16 @@ 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"))); } @ParameterizedTest diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/builders/WebSecurityFilterChainValidatorTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/builders/WebSecurityFilterChainValidatorTests.java new file mode 100644 index 00000000000..450a3dfdc17 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/builders/WebSecurityFilterChainValidatorTests.java @@ -0,0 +1,119 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.config.annotation.web.builders; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.FilterChainProxy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.UnreachableFilterChainException; +import org.springframework.security.web.access.ExceptionTranslationFilter; +import org.springframework.security.web.access.intercept.FilterSecurityInterceptor; +import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatchers; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Tests for {@link WebSecurityFilterChainValidator} + * + * @author Max Batischev + */ +@ExtendWith(MockitoExtension.class) +public class WebSecurityFilterChainValidatorTests { + + private final WebSecurityFilterChainValidator validator = new WebSecurityFilterChainValidator(); + + @Mock + private AnonymousAuthenticationFilter authenticationFilter; + + @Mock + private ExceptionTranslationFilter exceptionTranslationFilter; + + @Mock + private FilterSecurityInterceptor authorizationInterceptor; + + @Test + void validateWhenFilterSecurityInterceptorConfiguredThenValidates() { + SecurityFilterChain chain = new DefaultSecurityFilterChain(AntPathRequestMatcher.antMatcher("/api"), + this.authenticationFilter, this.exceptionTranslationFilter, this.authorizationInterceptor); + FilterChainProxy proxy = new FilterChainProxy(List.of(chain)); + + assertThatNoException().isThrownBy(() -> this.validator.validate(proxy)); + } + + @Test + void validateWhenAnyRequestMatcherIsPresentThenUnreachableFilterChainException() { + SecurityFilterChain chain1 = new DefaultSecurityFilterChain(AntPathRequestMatcher.antMatcher("/api"), + this.authenticationFilter, this.exceptionTranslationFilter, this.authorizationInterceptor); + SecurityFilterChain chain2 = new DefaultSecurityFilterChain(AnyRequestMatcher.INSTANCE, + this.authenticationFilter, this.exceptionTranslationFilter, this.authorizationInterceptor); + List chains = new ArrayList<>(); + chains.add(chain2); + chains.add(chain1); + FilterChainProxy proxy = new FilterChainProxy(chains); + + assertThatExceptionOfType(UnreachableFilterChainException.class) + .isThrownBy(() -> this.validator.validate(proxy)); + } + + @Test + void validateWhenSameRequestMatchersArePresentThenUnreachableFilterChainException() { + SecurityFilterChain chain1 = new DefaultSecurityFilterChain(AntPathRequestMatcher.antMatcher("/api"), + this.authenticationFilter, this.exceptionTranslationFilter, this.authorizationInterceptor); + SecurityFilterChain chain2 = new DefaultSecurityFilterChain(AntPathRequestMatcher.antMatcher("/api"), + this.authenticationFilter, this.exceptionTranslationFilter, this.authorizationInterceptor); + List chains = new ArrayList<>(); + chains.add(chain2); + chains.add(chain1); + FilterChainProxy proxy = new FilterChainProxy(chains); + + assertThatExceptionOfType(UnreachableFilterChainException.class) + .isThrownBy(() -> this.validator.validate(proxy)); + } + + @Test + void validateWhenSameComposedRequestMatchersArePresentThenUnreachableFilterChainException() { + RequestMatcher matcher1 = RequestMatchers.anyOf(RequestMatchers.allOf(AntPathRequestMatcher.antMatcher("/api"), + AntPathRequestMatcher.antMatcher("*.do")), AntPathRequestMatcher.antMatcher("/admin")); + RequestMatcher matcher2 = RequestMatchers.anyOf(RequestMatchers.allOf(AntPathRequestMatcher.antMatcher("/api"), + AntPathRequestMatcher.antMatcher("*.do")), AntPathRequestMatcher.antMatcher("/admin")); + SecurityFilterChain chain1 = new DefaultSecurityFilterChain(matcher1, this.authenticationFilter, + this.exceptionTranslationFilter, this.authorizationInterceptor); + SecurityFilterChain chain2 = new DefaultSecurityFilterChain(matcher2, this.authenticationFilter, + this.exceptionTranslationFilter, this.authorizationInterceptor); + List chains = new ArrayList<>(); + chains.add(chain2); + chains.add(chain1); + FilterChainProxy proxy = new FilterChainProxy(chains); + + assertThatExceptionOfType(UnreachableFilterChainException.class) + .isThrownBy(() -> this.validator.validate(proxy)); + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configuration/WebSecurityConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configuration/WebSecurityConfigurationTests.java index 326e2bda108..f6a53bff458 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 @@ -323,7 +323,7 @@ public void loadConfigWhenTwoSecurityFilterChainsPresentAndSecondWithAnyRequestT assertThatExceptionOfType(BeanCreationException.class) .isThrownBy(() -> this.spring.register(MultipleAnyRequestSecurityFilterChainConfig.class).autowire()) .havingRootCause() - .isExactlyInstanceOf(IllegalArgumentException.class); + .isInstanceOf(IllegalArgumentException.class); } private void assertAnotherUserPermission(WebInvocationPrivilegeEvaluator privilegeEvaluator) { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurerTests.java index 6d683c4899a..f4646fe6f53 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurerTests.java index fbe52459a45..bca300ec521 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -59,6 +59,7 @@ import org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.ConcurrentSessionControlAuthenticationStrategy; import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; +import org.springframework.security.web.authentication.session.SessionLimit; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.security.web.savedrequest.RequestCache; @@ -249,6 +250,82 @@ public void loginWhenUserLoggedInAndMaxSessionsOneInLambdaThenLoginPrevented() t // @formatter:on } + @Test + public void loginWhenAdminUserLoggedInAndSessionLimitIsConfiguredThenLoginSuccessfully() throws Exception { + this.spring.register(ConcurrencyControlWithSessionLimitConfig.class).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + HttpSession secondSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(secondSession).isNotNull(); + // @formatter:on + assertThat(firstSession.getId()).isNotEqualTo(secondSession.getId()); + } + + @Test + public void loginWhenAdminUserLoggedInAndSessionLimitIsConfiguredThenLoginPrevented() throws Exception { + this.spring.register(ConcurrencyControlWithSessionLimitConfig.class).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + HttpSession secondSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(secondSession).isNotNull(); + assertThat(firstSession.getId()).isNotEqualTo(secondSession.getId()); + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + + @Test + public void loginWhenUserLoggedInAndSessionLimitIsConfiguredThenLoginPrevented() throws Exception { + this.spring.register(ConcurrencyControlWithSessionLimitConfig.class).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + @Test public void requestWhenSessionCreationPolicyStateLessInLambdaThenNoSessionCreated() throws Exception { this.spring.register(SessionCreationPolicyStateLessInLambdaConfig.class).autowire(); @@ -625,6 +702,42 @@ UserDetailsService userDetailsService() { } + @Configuration + @EnableWebSecurity + static class ConcurrencyControlWithSessionLimitConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http, SessionLimit sessionLimit) throws Exception { + // @formatter:off + http + .formLogin(withDefaults()) + .sessionManagement((sessionManagement) -> sessionManagement + .sessionConcurrency((sessionConcurrency) -> sessionConcurrency + .maximumSessions(sessionLimit) + .maxSessionsPreventsLogin(true) + ) + ); + // @formatter:on + return http.build(); + } + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(PasswordEncodedUser.admin(), PasswordEncodedUser.user()); + } + + @Bean + SessionLimit SessionLimit() { + return (authentication) -> { + if ("admin".equals(authentication.getName())) { + return 2; + } + return 1; + }; + } + + } + @Configuration @EnableWebSecurity static class SessionCreationPolicyStateLessInLambdaConfig { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java index 3957d416dae..e13bddf7073 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java @@ -484,6 +484,7 @@ public void saml2LogoutResponseWhenCustomLogoutResponseHandlerThenUses() throws verify(getBean(Saml2LogoutResponseValidator.class)).validate(any()); } + // gh-11363 @Test public void saml2LogoutWhenCustomLogoutRequestRepositoryThenUses() throws Exception { this.spring.register(Saml2LogoutComponentsConfig.class).autowire(); diff --git a/config/src/test/java/org/springframework/security/config/http/DefaultFilterChainValidatorTests.java b/config/src/test/java/org/springframework/security/config/http/DefaultFilterChainValidatorTests.java index a5b899db48c..d75ce815d58 100644 --- a/config/src/test/java/org/springframework/security/config/http/DefaultFilterChainValidatorTests.java +++ b/config/src/test/java/org/springframework/security/config/http/DefaultFilterChainValidatorTests.java @@ -16,7 +16,9 @@ package org.springframework.security.config.http; +import java.util.ArrayList; import java.util.Collection; +import java.util.List; import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.logging.Log; @@ -33,6 +35,8 @@ import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.FilterChainProxy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.UnreachableFilterChainException; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.access.intercept.DefaultFilterInvocationSecurityMetadataSource; @@ -40,9 +44,12 @@ import org.springframework.security.web.access.intercept.FilterSecurityInterceptor; import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.AnyRequestMatcher; import org.springframework.test.util.ReflectionTestUtils; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; @@ -97,6 +104,11 @@ public void setUp() { ReflectionTestUtils.setField(this.validator, "logger", this.logger); } + @Test + void validateWhenFilterSecurityInterceptorConfiguredThenValidates() { + assertThatNoException().isThrownBy(() -> this.validator.validate(this.chain)); + } + // SEC-1878 @SuppressWarnings("unchecked") @Test @@ -130,4 +142,21 @@ public void validateCustomMetadataSource() { verify(customMetaDataSource, atLeastOnce()).getAttributes(any()); } + @Test + void validateWhenSameRequestMatchersArePresentThenUnreachableFilterChainException() { + AnonymousAuthenticationFilter authenticationFilter = mock(AnonymousAuthenticationFilter.class); + ExceptionTranslationFilter exceptionTranslationFilter = mock(ExceptionTranslationFilter.class); + SecurityFilterChain chain1 = new DefaultSecurityFilterChain(AntPathRequestMatcher.antMatcher("/api"), + authenticationFilter, exceptionTranslationFilter, this.authorizationInterceptor); + SecurityFilterChain chain2 = new DefaultSecurityFilterChain(AntPathRequestMatcher.antMatcher("/api"), + authenticationFilter, exceptionTranslationFilter, this.authorizationInterceptor); + List chains = new ArrayList<>(); + chains.add(chain2); + chains.add(chain1); + FilterChainProxy proxy = new FilterChainProxy(chains); + + assertThatExceptionOfType(UnreachableFilterChainException.class) + .isThrownBy(() -> this.validator.validate(proxy)); + } + } diff --git a/config/src/test/java/org/springframework/security/config/http/HttpConfigTests.java b/config/src/test/java/org/springframework/security/config/http/HttpConfigTests.java index 9a4e3b041e1..c7f0590bc1b 100644 --- a/config/src/test/java/org/springframework/security/config/http/HttpConfigTests.java +++ b/config/src/test/java/org/springframework/security/config/http/HttpConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2022 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java b/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java index c66933de16c..2c41d1a3688 100644 --- a/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java +++ b/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import java.util.Set; import com.google.common.collect.ImmutableMap; +import jakarta.servlet.http.HttpSession; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -33,14 +34,21 @@ import org.springframework.beans.factory.xml.XmlBeanDefinitionStoreException; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.session.SessionLimit; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** @@ -49,6 +57,7 @@ * @author Josh Cummings * @author Rafiullah Hamedy * @author Marcus Da Coregio + * @author Claudenir Freitas */ @ExtendWith(SpringTestContextExtension.class) public class HttpHeadersConfigTests { @@ -782,6 +791,120 @@ public void requestWhenCrossOriginPoliciesRespondsCrossOriginPolicies() throws E // @formatter:on } + @Test + public void requestWhenSessionManagementConcurrencyControlMaxSessionIsOne() throws Exception { + System.setProperty("security.session-management.concurrency-control.max-sessions", "1"); + this.spring.configLocations(this.xml("DefaultsSessionManagementConcurrencyControlMaxSessions")).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + // @formatter:on + assertThat(firstSession).isNotNull(); + // @formatter:off + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + + @Test + public void requestWhenSessionManagementConcurrencyControlMaxSessionIsUnlimited() throws Exception { + System.setProperty("security.session-management.concurrency-control.max-sessions", "-1"); + this.spring.configLocations(this.xml("DefaultsSessionManagementConcurrencyControlMaxSessions")).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + HttpSession secondSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(secondSession).isNotNull(); + // @formatter:on + assertThat(firstSession.getId()).isNotEqualTo(secondSession.getId()); + } + + @Test + public void requestWhenSessionManagementConcurrencyControlMaxSessionRefIsOneForNonAdminUsers() throws Exception { + this.spring.configLocations(this.xml("DefaultsSessionManagementConcurrencyControlMaxSessionsRef")).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + // @formatter:on + assertThat(firstSession).isNotNull(); + // @formatter:off + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + + @Test + public void requestWhenSessionManagementConcurrencyControlMaxSessionRefIsTwoForAdminUsers() throws Exception { + this.spring.configLocations(this.xml("DefaultsSessionManagementConcurrencyControlMaxSessionsRef")).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + HttpSession secondSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(secondSession).isNotNull(); + // @formatter:on + assertThat(firstSession.getId()).isNotEqualTo(secondSession.getId()); + // @formatter:off + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + + @Test + public void requestWhenSessionManagementConcurrencyControlWithInvalidMaxSessionConfig() { + assertThatExceptionOfType(BeanDefinitionParsingException.class) + .isThrownBy(() -> this.spring + .configLocations(this.xml("DefaultsSessionManagementConcurrencyControlWithInvalidMaxSessionsConfig")) + .autowire()) + .withMessageContaining("Cannot use 'max-sessions' attribute and 'max-sessions-ref' attribute together."); + } + private static ResultMatcher includesDefaults() { return includes(defaultHeaders); } @@ -832,4 +955,16 @@ public String ok() { } + public static class CustomSessionLimit implements SessionLimit { + + @Override + public Integer apply(Authentication authentication) { + if ("admin".equals(authentication.getName())) { + return 2; + } + return 1; + } + + } + } diff --git a/config/src/test/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserTests.java b/config/src/test/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserTests.java index 152525d4a20..d51349440a5 100644 --- a/config/src/test/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserTests.java +++ b/config/src/test/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserTests.java @@ -63,6 +63,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.verify; @@ -380,6 +381,22 @@ public void saml2LogoutResponseWhenCustomLogoutResponseHandlerThenUses() throws verify(getBean(Saml2LogoutResponseValidator.class)).validate(any()); } + // gh-11363 + @Test + public void saml2LogoutWhenCustomLogoutRequestRepositoryThenUses() throws Exception { + this.spring.configLocations(this.xml("CustomComponents")).autowire(); + RelyingPartyRegistration registration = this.repository.findByRegistrationId("get"); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .samlRequest(this.rpLogoutRequest) + .id(this.rpLogoutRequestId) + .relayState(this.rpLogoutRequestRelayState) + .parameters((params) -> params.put("Signature", this.rpLogoutRequestSignature)) + .build(); + given(getBean(Saml2LogoutRequestResolver.class).resolve(any(), any())).willReturn(logoutRequest); + this.mvc.perform(post("/logout").with(authentication(this.saml2User)).with(csrf())); + verify(getBean(Saml2LogoutRequestRepository.class)).saveLogoutRequest(eq(logoutRequest), any(), any()); + } + private T getBean(Class clazz) { return this.spring.getContext().getBean(clazz); } diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDslTests.kt index 6437c54326d..9117ae757a7 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDslTests.kt @@ -18,18 +18,19 @@ package org.springframework.security.config.annotation.web.session import io.mockk.every import io.mockk.mockkObject -import java.util.Date import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.mock.web.MockHttpSession +import org.springframework.security.authorization.AuthorityAuthorizationManager +import org.springframework.security.authorization.AuthorizationManager import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke import org.springframework.security.config.test.SpringTestContext import org.springframework.security.config.test.SpringTestContextExtension -import org.springframework.security.config.annotation.web.invoke import org.springframework.security.core.session.SessionInformation import org.springframework.security.core.session.SessionRegistry import org.springframework.security.core.session.SessionRegistryImpl @@ -44,6 +45,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.* /** * Tests for [SessionConcurrencyDsl] @@ -173,16 +175,75 @@ class SessionConcurrencyDslTests { open fun sessionRegistry(): SessionRegistry = SESSION_REGISTRY } + @Test + fun `session concurrency when session limit then no more sessions allowed`() { + this.spring.register(MaximumSessionsFunctionConfig::class.java, UserDetailsConfig::class.java).autowire() + + this.mockMvc.perform(post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password")) + + this.mockMvc.perform(post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password")) + .andExpect(status().isFound) + .andExpect(redirectedUrl("/login?error")) + + this.mockMvc.perform(post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password")) + .andExpect(status().isFound) + .andExpect(redirectedUrl("/")) + + this.mockMvc.perform(post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password")) + .andExpect(status().isFound) + .andExpect(redirectedUrl("/")) + } + + @Configuration + @EnableWebSecurity + open class MaximumSessionsFunctionConfig { + + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + val isAdmin: AuthorizationManager = AuthorityAuthorizationManager.hasRole("ADMIN") + http { + sessionManagement { + sessionConcurrency { + maximumSessions { + authentication -> if (isAdmin.authorize({ authentication }, null)!!.isGranted) -1 else 1 + } + maxSessionsPreventsLogin = true + } + } + formLogin { } + } + return http.build() + } + + } + @Configuration open class UserDetailsConfig { @Bean open fun userDetailsService(): UserDetailsService { - val userDetails = User.withDefaultPasswordEncoder() + val user = User.withDefaultPasswordEncoder() .username("user") .password("password") .roles("USER") .build() - return InMemoryUserDetailsManager(userDetails) + val admin = User.withDefaultPasswordEncoder() + .username("admin") + .password("password") + .roles("ADMIN") + .build() + return InMemoryUserDetailsManager(user, admin) } } } diff --git a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerHeadersDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerHeadersDslTests.kt index dfa78726cac..78bfd4d57d4 100644 --- a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerHeadersDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerHeadersDslTests.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 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. @@ -37,6 +37,7 @@ import org.springframework.security.web.server.header.XFrameOptionsServerHttpHea import org.springframework.security.web.server.header.XXssProtectionServerHttpHeadersWriter import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.web.reactive.config.EnableWebFlux +import reactor.core.publisher.Mono /** * Tests for [ServerHeadersDsl] @@ -198,4 +199,48 @@ class ServerHeadersDslTests { } } } + + @Test + fun `request when custom server http headers writer configured then custom http headers added`() { + this.spring.register(ServerHttpHeadersWriterCustomConfig::class.java).autowire() + + this.client.get() + .uri("/") + .exchange() + .expectHeader().valueEquals("CUSTOM-HEADER-1", "CUSTOM-VALUE-1") + .expectHeader().valueEquals("CUSTOM-HEADER-2", "CUSTOM-VALUE-2") + } + + @Configuration + @EnableWebFluxSecurity + @EnableWebFlux + open class ServerHttpHeadersWriterCustomConfig { + @Bean + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + headers { + writer { exchange -> + Mono.just(exchange) + .doOnNext { + it.response.headers.add( + "CUSTOM-HEADER-1", + "CUSTOM-VALUE-1" + ) + } + .then() + } + writer { exchange -> + Mono.just(exchange) + .doOnNext { + it.response.headers.add( + "CUSTOM-HEADER-2", + "CUSTOM-VALUE-2" + ) + } + .then() + } + } + } + } + } } diff --git a/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessions.xml b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessions.xml new file mode 100644 index 00000000000..7e8c3d12a34 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessions.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessionsRef.xml b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessionsRef.xml new file mode 100644 index 00000000000..98215ca86cd --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessionsRef.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlWithInvalidMaxSessionsConfig.xml b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlWithInvalidMaxSessionsConfig.xml new file mode 100644 index 00000000000..7bf56c9a3a4 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlWithInvalidMaxSessionsConfig.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.saml2.Saml2Exception.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.saml2.Saml2Exception.serialized new file mode 100644 index 00000000000..4fd752b76ff Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.saml2.Saml2Exception.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException.serialized new file mode 100644 index 00000000000..f771882b3de Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.preauth.PreAuthenticatedCredentialsNotFoundException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.preauth.PreAuthenticatedCredentialsNotFoundException.serialized new file mode 100644 index 00000000000..6d7a94c2950 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.preauth.PreAuthenticatedCredentialsNotFoundException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.CookieTheftException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.CookieTheftException.serialized new file mode 100644 index 00000000000..e983ebc0136 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.CookieTheftException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.InvalidCookieException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.InvalidCookieException.serialized new file mode 100644 index 00000000000..b4f3a5f6acc Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.InvalidCookieException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException.serialized new file mode 100644 index 00000000000..fe88d36cd4b Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.session.SessionAuthenticationException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.session.SessionAuthenticationException.serialized new file mode 100644 index 00000000000..5b627fb9c7a Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.session.SessionAuthenticationException.serialized differ diff --git a/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.www.NonceExpiredException.serialized b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.www.NonceExpiredException.serialized new file mode 100644 index 00000000000..2d1621125f3 Binary files /dev/null and b/config/src/test/resources/serialized/6.4.x/org.springframework.security.web.authentication.www.NonceExpiredException.serialized differ diff --git a/config/src/test/resources/serialized/6.5.x/org.springframework.security.web.UnreachableFilterChainException.serialized b/config/src/test/resources/serialized/6.5.x/org.springframework.security.web.UnreachableFilterChainException.serialized new file mode 100644 index 00000000000..418c3b8ece1 Binary files /dev/null and b/config/src/test/resources/serialized/6.5.x/org.springframework.security.web.UnreachableFilterChainException.serialized differ diff --git a/core/src/main/java/org/springframework/security/authentication/dao/DaoAuthenticationProvider.java b/core/src/main/java/org/springframework/security/authentication/dao/DaoAuthenticationProvider.java index a854d6d58d1..b22843e0d9c 100644 --- a/core/src/main/java/org/springframework/security/authentication/dao/DaoAuthenticationProvider.java +++ b/core/src/main/java/org/springframework/security/authentication/dao/DaoAuthenticationProvider.java @@ -16,6 +16,8 @@ package org.springframework.security.authentication.dao; +import java.util.function.Supplier; + import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.InternalAuthenticationServiceException; @@ -31,6 +33,7 @@ import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.util.Assert; +import org.springframework.util.function.SingletonSupplier; /** * An {@link AuthenticationProvider} implementation that retrieves user details from a @@ -48,7 +51,8 @@ public class DaoAuthenticationProvider extends AbstractUserDetailsAuthentication */ private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword"; - private PasswordEncoder passwordEncoder; + private Supplier passwordEncoder = SingletonSupplier + .of(PasswordEncoderFactories::createDelegatingPasswordEncoder); /** * The password used to perform {@link PasswordEncoder#matches(CharSequence, String)} @@ -64,15 +68,25 @@ public class DaoAuthenticationProvider extends AbstractUserDetailsAuthentication private CompromisedPasswordChecker compromisedPasswordChecker; + /** + * @deprecated Please provide the {@link UserDetailsService} in the constructor + */ + @Deprecated public DaoAuthenticationProvider() { - this(PasswordEncoderFactories.createDelegatingPasswordEncoder()); + } + + public DaoAuthenticationProvider(UserDetailsService userDetailsService) { + setUserDetailsService(userDetailsService); } /** * Creates a new instance using the provided {@link PasswordEncoder} * @param passwordEncoder the {@link PasswordEncoder} to use. Cannot be null. * @since 6.0.3 + * @deprecated Please provide the {@link UserDetailsService} in the constructor + * followed by {@link #setPasswordEncoder(PasswordEncoder)} instead */ + @Deprecated public DaoAuthenticationProvider(PasswordEncoder passwordEncoder) { setPasswordEncoder(passwordEncoder); } @@ -87,7 +101,7 @@ protected void additionalAuthenticationChecks(UserDetails userDetails, .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } String presentedPassword = authentication.getCredentials().toString(); - if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) { + if (!this.passwordEncoder.get().matches(presentedPassword, userDetails.getPassword())) { this.logger.debug("Failed to authenticate since password does not match stored value"); throw new BadCredentialsException(this.messages .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); @@ -133,9 +147,9 @@ protected Authentication createSuccessAuthentication(Object principal, Authentic throw new CompromisedPasswordException("The provided password is compromised, please change your password"); } boolean upgradeEncoding = this.userDetailsPasswordService != null - && this.passwordEncoder.upgradeEncoding(user.getPassword()); + && this.passwordEncoder.get().upgradeEncoding(user.getPassword()); if (upgradeEncoding) { - String newPassword = this.passwordEncoder.encode(presentedPassword); + String newPassword = this.passwordEncoder.get().encode(presentedPassword); user = this.userDetailsPasswordService.updatePassword(user, newPassword); } return super.createSuccessAuthentication(principal, authentication, user); @@ -143,14 +157,14 @@ protected Authentication createSuccessAuthentication(Object principal, Authentic private void prepareTimingAttackProtection() { if (this.userNotFoundEncodedPassword == null) { - this.userNotFoundEncodedPassword = this.passwordEncoder.encode(USER_NOT_FOUND_PASSWORD); + this.userNotFoundEncodedPassword = this.passwordEncoder.get().encode(USER_NOT_FOUND_PASSWORD); } } private void mitigateAgainstTimingAttack(UsernamePasswordAuthenticationToken authentication) { if (authentication.getCredentials() != null) { String presentedPassword = authentication.getCredentials().toString(); - this.passwordEncoder.matches(presentedPassword, this.userNotFoundEncodedPassword); + this.passwordEncoder.get().matches(presentedPassword, this.userNotFoundEncodedPassword); } } @@ -163,14 +177,19 @@ private void mitigateAgainstTimingAttack(UsernamePasswordAuthenticationToken aut */ public void setPasswordEncoder(PasswordEncoder passwordEncoder) { Assert.notNull(passwordEncoder, "passwordEncoder cannot be null"); - this.passwordEncoder = passwordEncoder; + this.passwordEncoder = () -> passwordEncoder; this.userNotFoundEncodedPassword = null; } protected PasswordEncoder getPasswordEncoder() { - return this.passwordEncoder; + return this.passwordEncoder.get(); } + /** + * @param userDetailsService + * @deprecated Please provide the {@link UserDetailsService} in the constructor + */ + @Deprecated public void setUserDetailsService(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } diff --git a/core/src/main/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScanner.java b/core/src/main/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScanner.java index 5093e1bd1d1..e3f5c9297c6 100644 --- a/core/src/main/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScanner.java +++ b/core/src/main/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScanner.java @@ -18,6 +18,7 @@ import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Executable; import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.ArrayList; @@ -83,6 +84,7 @@ * * @param the annotation to search for and synthesize * @author Josh Cummings + * @author DingHao * @since 6.4 */ final class UniqueSecurityAnnotationScanner extends AbstractSecurityAnnotationScanner { @@ -107,7 +109,7 @@ final class UniqueSecurityAnnotationScanner extends Abstra MergedAnnotation merge(AnnotatedElement element, Class targetClass) { if (element instanceof Parameter parameter) { return this.uniqueParameterAnnotationCache.computeIfAbsent(parameter, (p) -> { - List> annotations = findDirectAnnotations(p); + List> annotations = findParameterAnnotations(p); return requireUnique(p, annotations); }); } @@ -137,6 +139,56 @@ private MergedAnnotation requireUnique(AnnotatedElement element, List> findParameterAnnotations(Parameter current) { + List> directAnnotations = findDirectAnnotations(current); + if (!directAnnotations.isEmpty()) { + return directAnnotations; + } + Executable executable = current.getDeclaringExecutable(); + if (executable instanceof Method method) { + Class clazz = method.getDeclaringClass(); + Set> visited = new HashSet<>(); + while (clazz != null && clazz != Object.class) { + directAnnotations = findClosestParameterAnnotations(method, clazz, current, visited); + if (!directAnnotations.isEmpty()) { + return directAnnotations; + } + clazz = clazz.getSuperclass(); + } + } + return Collections.emptyList(); + } + + private List> findClosestParameterAnnotations(Method method, Class clazz, Parameter current, + Set> visited) { + if (!visited.add(clazz)) { + return Collections.emptyList(); + } + List> annotations = new ArrayList<>(findDirectParameterAnnotations(method, clazz, current)); + for (Class ifc : clazz.getInterfaces()) { + annotations.addAll(findClosestParameterAnnotations(method, ifc, current, visited)); + } + return annotations; + } + + private List> findDirectParameterAnnotations(Method method, Class clazz, Parameter current) { + try { + Method methodToUse = clazz.getDeclaredMethod(method.getName(), method.getParameterTypes()); + for (Parameter parameter : methodToUse.getParameters()) { + if (parameter.getName().equals(current.getName())) { + List> directAnnotations = findDirectAnnotations(parameter); + if (!directAnnotations.isEmpty()) { + return directAnnotations; + } + } + } + } + catch (NoSuchMethodException ex) { + // move on + } + return Collections.emptyList(); + } + private List> findMethodAnnotations(Method method, Class targetClass) { // The method may be on an interface, but we need attributes from the target // class. diff --git a/core/src/test/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScannerTests.java b/core/src/test/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScannerTests.java index b1a7a779aac..976e0879ab1 100644 --- a/core/src/test/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScannerTests.java +++ b/core/src/test/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScannerTests.java @@ -16,7 +16,13 @@ package org.springframework.security.core.annotation; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.List; import org.junit.jupiter.api.Test; @@ -34,6 +40,9 @@ public class UniqueSecurityAnnotationScannerTests { private UniqueSecurityAnnotationScanner scanner = new UniqueSecurityAnnotationScanner<>( PreAuthorize.class); + private UniqueSecurityAnnotationScanner parameterScanner = new UniqueSecurityAnnotationScanner<>( + CustomParameterAnnotation.class); + @Test void scanWhenAnnotationOnInterfaceThenResolves() throws Exception { Method method = AnnotationOnInterface.class.getDeclaredMethod("method"); @@ -251,6 +260,101 @@ void scanWhenClassInheritingAbstractClassNoAnnotationsThenNoAnnotation() throws assertThat(preAuthorize).isNull(); } + @Test + void scanParameterAnnotationWhenAnnotationOnInterface() throws Exception { + Parameter parameter = UserService.class.getDeclaredMethod("add", String.class).getParameters()[0]; + CustomParameterAnnotation customParameterAnnotation = this.parameterScanner.scan(parameter); + assertThat(customParameterAnnotation.value()).isEqualTo("one"); + } + + @Test + void scanParameterAnnotationWhenClassInheritingInterfaceAnnotation() throws Exception { + Parameter parameter = UserServiceImpl.class.getDeclaredMethod("add", String.class).getParameters()[0]; + CustomParameterAnnotation customParameterAnnotation = this.parameterScanner.scan(parameter); + assertThat(customParameterAnnotation.value()).isEqualTo("one"); + } + + @Test + void scanParameterAnnotationWhenClassOverridingMethodOverridingInterface() throws Exception { + Parameter parameter = UserServiceImpl.class.getDeclaredMethod("get", String.class).getParameters()[0]; + CustomParameterAnnotation customParameterAnnotation = this.parameterScanner.scan(parameter); + assertThat(customParameterAnnotation.value()).isEqualTo("five"); + } + + @Test + void scanParameterAnnotationWhenMultipleMethodInheritanceThenException() throws Exception { + Parameter parameter = UserServiceImpl.class.getDeclaredMethod("list", String.class).getParameters()[0]; + assertThatExceptionOfType(AnnotationConfigurationException.class) + .isThrownBy(() -> this.parameterScanner.scan(parameter)); + } + + @Test + void scanParameterAnnotationWhenInterfaceNoAnnotationsThenException() throws Exception { + Parameter parameter = UserServiceImpl.class.getDeclaredMethod("delete", String.class).getParameters()[0]; + assertThatExceptionOfType(AnnotationConfigurationException.class) + .isThrownBy(() -> this.parameterScanner.scan(parameter)); + } + + interface UserService { + + void add(@CustomParameterAnnotation("one") String user); + + List list(@CustomParameterAnnotation("two") String user); + + String get(@CustomParameterAnnotation("three") String user); + + void delete(@CustomParameterAnnotation("five") String user); + + } + + interface OtherUserService { + + List list(@CustomParameterAnnotation("four") String user); + + } + + interface ThirdPartyUserService { + + void delete(@CustomParameterAnnotation("five") String user); + + } + + interface RemoteUserService extends ThirdPartyUserService { + + } + + static class UserServiceImpl implements UserService, OtherUserService, RemoteUserService { + + @Override + public void add(String user) { + + } + + @Override + public List list(String user) { + return List.of(user); + } + + @Override + public String get(@CustomParameterAnnotation("five") String user) { + return user; + } + + @Override + public void delete(String user) { + + } + + } + + @Target({ ElementType.PARAMETER }) + @Retention(RetentionPolicy.RUNTIME) + @interface CustomParameterAnnotation { + + String value(); + + } + @PreAuthorize("one") private interface AnnotationOnInterface { diff --git a/docs/modules/ROOT/pages/migration-7/web.adoc b/docs/modules/ROOT/pages/migration-7/web.adoc new file mode 100644 index 00000000000..024d5604494 --- /dev/null +++ b/docs/modules/ROOT/pages/migration-7/web.adoc @@ -0,0 +1,104 @@ += Web Migrations + +== Favor Relative URIs + +When redirecting to a login endpoint, Spring Security has favored absolute URIs in the past. +For example, if you set your login page like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +http + // ... + .formLogin((form) -> form.loginPage("/my-login")) + // ... +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +http { + formLogin { + loginPage = "/my-login" + } +} +---- + +Xml:: ++ +[source,kotlin,role="secondary"] +---- + + + +---- +====== + +then when redirecting to `/my-login` Spring Security would use a `Location:` like the following: + +[source] +---- +302 Found +// ... +Location: https://myapp.example.org/my-login +---- + +However, this is no longer necessary given that the RFC is was based on is now obsolete. + +In Spring Security 7, this is changed to use a relative URI like so: + +[source] +---- +302 Found +// ... +Location: /my-login +---- + +Most applications will not notice a difference. +However, in the event that this change causes problems, you can switch back to the Spring Security 6 behavior by setting the `favorRelativeUrls` value: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +LoginUrlAuthenticationEntryPoint entryPoint = new LoginUrlAuthenticationEntryPoint("/my-login"); +entryPoint.setFavorRelativeUris(false); +http + // ... + .exceptionHandling((exceptions) -> exceptions.authenticaitonEntryPoint(entryPoint)) + // ... +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +LoginUrlAuthenticationEntryPoint entryPoint = LoginUrlAuthenticationEntryPoint("/my-login") +entryPoint.setFavorRelativeUris(false) + +http { + exceptionHandling { + authenticationEntryPoint = entryPoint + } +} +---- + +Xml:: ++ +[source,kotlin,role="secondary"] +---- + + + + + + + +---- +====== diff --git a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc index 59e48e0986d..d49c2f12db3 100644 --- a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc +++ b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc @@ -2168,6 +2168,9 @@ Allows injection of the ExpiredSessionStrategy instance used by the ConcurrentSe Maps to the `maximumSessions` property of `ConcurrentSessionControlAuthenticationStrategy`. Specify `-1` as the value to support unlimited sessions. +[[nsa-concurrency-control-max-sessions-ref]] +* **max-sessions-ref** +Allows injection of the SessionLimit instance used by the ConcurrentSessionControlAuthenticationStrategy [[nsa-concurrency-control-session-registry-alias]] * **session-registry-alias** diff --git a/docs/modules/ROOT/pages/servlet/authentication/passwords/index.adoc b/docs/modules/ROOT/pages/servlet/authentication/passwords/index.adoc index b13cdb615d8..f52e95c4c58 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/passwords/index.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/passwords/index.adoc @@ -148,8 +148,7 @@ public class SecurityConfig { public AuthenticationManager authenticationManager( UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) { - DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); - authenticationProvider.setUserDetailsService(userDetailsService); + DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(userDetailsService); authenticationProvider.setPasswordEncoder(passwordEncoder); return new ProviderManager(authenticationProvider); @@ -229,8 +228,7 @@ class SecurityConfig { fun authenticationManager( userDetailsService: UserDetailsService, passwordEncoder: PasswordEncoder): AuthenticationManager { - val authenticationProvider = DaoAuthenticationProvider() - authenticationProvider.setUserDetailsService(userDetailsService) + val authenticationProvider = DaoAuthenticationProvider(userDetailsService) authenticationProvider.setPasswordEncoder(passwordEncoder) return ProviderManager(authenticationProvider) @@ -501,8 +499,7 @@ class SecurityConfig { @Bean fun authenticationManager(): AuthenticationManager { - val authenticationProvider = DaoAuthenticationProvider() - authenticationProvider.setUserDetailsService(userDetailsService()) + val authenticationProvider = DaoAuthenticationProvider(userDetailsService()) authenticationProvider.setPasswordEncoder(passwordEncoder()) val providerManager = ProviderManager(authenticationProvider) diff --git a/docs/modules/ROOT/pages/servlet/authentication/session-management.adoc b/docs/modules/ROOT/pages/servlet/authentication/session-management.adoc index fe0821a31df..c6282e2351c 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/session-management.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/session-management.adoc @@ -399,7 +399,62 @@ XML:: This will prevent a user from logging in multiple times - a second login will cause the first to be invalidated. -Using Spring Boot, you can test the above configuration scenario the following way: +You can also adjust this based on who the user is. +For example, administrators may be able to have more than one session: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +public SecurityFilterChain filterChain(HttpSecurity http) { + AuthorizationManager isAdmin = AuthorityAuthorizationManager.hasRole("ADMIN"); + http + .sessionManagement(session -> session + .maximumSessions((authentication) -> isAdmin.authorize(() -> authentication, null).isGranted() ? -1 : 1) + ); + return http.build(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +open fun filterChain(http: HttpSecurity): SecurityFilterChain { + val isAdmin: AuthorizationManager<*> = AuthorityAuthorizationManager.hasRole("ADMIN") + http { + sessionManagement { + sessionConcurrency { + maximumSessions { + authentication -> if (isAdmin.authorize({ authentication }, null)!!.isGranted) -1 else 1 + } + } + } + } + return http.build() +} +---- + +XML:: ++ +[source,xml,role="secondary"] +---- + +... + + + + + + +---- +====== + +Using Spring Boot, you can test the above configurations in the following way: [tabs] ====== diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 50aa9d233e5..6dd0c0fd157 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,14 +10,14 @@ org-aspectj = "1.9.22.1" org-bouncycastle = "1.79" org-eclipse-jetty = "11.0.24" org-jetbrains-kotlin = "1.9.25" -org-jetbrains-kotlinx = "1.9.0" +org-jetbrains-kotlinx = "1.10.0" org-mockito = "5.14.2" org-opensaml = "4.3.2" org-opensaml5 = "5.1.2" org-springframework = "6.2.1" [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.14" 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" @@ -64,18 +64,18 @@ org-apereo-cas-client-cas-client-core = "org.apereo.cas.client:cas-client-core:4 io-freefair-gradle-aspectj-plugin = "io.freefair.gradle:aspectj-plugin:8.11" 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.0" 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.4.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" org-jetbrains-kotlinx-kotlinx-coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version.ref = "org-jetbrains-kotlinx" } -org-junit-junit-bom = "org.junit:junit-bom:5.11.3" +org-junit-junit-bom = "org.junit:junit-bom:5.11.4" org-mockito-mockito-bom = { module = "org.mockito:mockito-bom", version.ref = "org-mockito" } org-opensaml-opensaml-saml-api = { module = "org.opensaml:opensaml-saml-api", version.ref = "org-opensaml" } org-opensaml-opensaml-saml-impl = { module = "org.opensaml:opensaml-saml-impl", version.ref = "org-opensaml" } @@ -88,7 +88,7 @@ org-seleniumhq-selenium-selenium-support = "org.seleniumhq.selenium:selenium-sup org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3" org-slf4j-log4j-over-slf4j = "org.slf4j:log4j-over-slf4j:1.7.36" org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.16" -org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2024.1.0" +org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2024.1.1" 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" diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AccessToken.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AccessToken.java index d288503f131..ea1124d0412 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AccessToken.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AccessToken.java @@ -107,9 +107,19 @@ public static final class TokenType implements Serializable { public static final TokenType BEARER = new TokenType("Bearer"); + /** + * @since 6.5 + */ + public static final TokenType DPOP = new TokenType("DPoP"); + private final String value; - private TokenType(String value) { + /** + * Constructs a {@code TokenType} using the provided value. + * @param value the value of the token type + * @since 6.5 + */ + public TokenType(String value) { Assert.hasText(value, "value cannot be empty"); this.value = value; } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/Saml2Exception.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/Saml2Exception.java index dc4e6bb770d..3595dec00ae 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/Saml2Exception.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/Saml2Exception.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,11 +16,16 @@ package org.springframework.security.saml2; +import java.io.Serial; + /** * @since 5.2 */ public class Saml2Exception extends RuntimeException { + @Serial + private static final long serialVersionUID = 6076252564189633016L; + public Saml2Exception(String message) { super(message); } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationException.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationException.java index 6ee38c6d60a..36075ba0df1 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationException.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,8 @@ package org.springframework.security.saml2.provider.service.authentication; +import java.io.Serial; + import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.saml2.core.Saml2Error; @@ -40,6 +42,9 @@ */ public class Saml2AuthenticationException extends AuthenticationException { + @Serial + private static final long serialVersionUID = -2996886630890949105L; + private final Saml2Error error; /** diff --git a/web/src/main/java/org/springframework/security/web/UnreachableFilterChainException.java b/web/src/main/java/org/springframework/security/web/UnreachableFilterChainException.java new file mode 100644 index 00000000000..ff697bd07d9 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/UnreachableFilterChainException.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web; + +import org.springframework.security.core.SpringSecurityCoreVersion; + +/** + * Thrown if {@link SecurityFilterChain securityFilterChain} is not valid. + * + * @author Max Batischev + * @since 6.5 + */ +public class UnreachableFilterChainException extends IllegalArgumentException { + + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + private final SecurityFilterChain filterChain; + + private final SecurityFilterChain unreachableFilterChain; + + /** + * Constructs an UnreachableFilterChainException with the specified + * message. + * @param message the detail message + */ + public UnreachableFilterChainException(String message, SecurityFilterChain filterChain, + SecurityFilterChain unreachableFilterChain) { + super(message); + this.filterChain = filterChain; + this.unreachableFilterChain = unreachableFilterChain; + } + + public SecurityFilterChain getFilterChain() { + return this.filterChain; + } + + public SecurityFilterChain getUnreachableFilterChain() { + return this.unreachableFilterChain; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHints.java b/web/src/main/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHints.java new file mode 100644 index 00000000000..c35cf5b81c6 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHints.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.aot.hint; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository; + +/** + * + * A JDBC implementation of an {@link PublicKeyCredentialUserEntityRepository} that uses a + * {@link JdbcOperations} for {@link PublicKeyCredentialUserEntity} persistence. + * + * @author Max Batischev + * @since 6.5 + */ +class PublicKeyCredentialUserEntityRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + hints.resources().registerPattern("org/springframework/security/user-entities-schema.sql"); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHints.java b/web/src/main/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHints.java new file mode 100644 index 00000000000..c3b4c95a148 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHints.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.aot.hint; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.security.web.webauthn.api.CredentialRecord; +import org.springframework.security.web.webauthn.management.UserCredentialRepository; + +/** + * + * A JDBC implementation of an {@link UserCredentialRepository} that uses a + * {@link JdbcOperations} for {@link CredentialRecord} persistence. + * + * @author Max Batischev + * @since 6.5 + */ +class UserCredentialRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + hints.resources().registerPattern("org/springframework/security/user-credentials-schema.sql"); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java b/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java index 3bf2c6f0dbf..5e62d2ebeb6 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java +++ b/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java @@ -61,6 +61,7 @@ * @author colin sampaleanu * @author Omri Spector * @author Luke Taylor + * @author Michal Okosy * @since 3.0 */ public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean { @@ -77,6 +78,8 @@ public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoin private boolean useForward = false; + private boolean favorRelativeUris = false; + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); /** @@ -146,27 +149,38 @@ protected String buildRedirectUrlToLoginPage(HttpServletRequest request, HttpSer if (UrlUtils.isAbsoluteUrl(loginForm)) { return loginForm; } + if (requiresRewrite(request)) { + return httpsUri(request, loginForm); + } + return this.favorRelativeUris ? loginForm : absoluteUri(request, loginForm).getUrl(); + } + + private boolean requiresRewrite(HttpServletRequest request) { + return this.forceHttps && "http".equals(request.getScheme()); + } + + private String httpsUri(HttpServletRequest request, String path) { int serverPort = this.portResolver.getServerPort(request); - String scheme = request.getScheme(); + Integer httpsPort = this.portMapper.lookupHttpsPort(serverPort); + if (httpsPort == null) { + logger.warn(LogMessage.format("Unable to redirect to HTTPS as no port mapping found for HTTP port %s", + serverPort)); + return this.favorRelativeUris ? path : absoluteUri(request, path).getUrl(); + } + RedirectUrlBuilder builder = absoluteUri(request, path); + builder.setScheme("https"); + builder.setPort(httpsPort); + return builder.getUrl(); + } + + private RedirectUrlBuilder absoluteUri(HttpServletRequest request, String path) { RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder(); - urlBuilder.setScheme(scheme); + urlBuilder.setScheme(request.getScheme()); urlBuilder.setServerName(request.getServerName()); - urlBuilder.setPort(serverPort); + urlBuilder.setPort(this.portResolver.getServerPort(request)); urlBuilder.setContextPath(request.getContextPath()); - urlBuilder.setPathInfo(loginForm); - if (this.forceHttps && "http".equals(scheme)) { - Integer httpsPort = this.portMapper.lookupHttpsPort(serverPort); - if (httpsPort != null) { - // Overwrite scheme and port in the redirect URL - urlBuilder.setScheme("https"); - urlBuilder.setPort(httpsPort); - } - else { - logger.warn(LogMessage.format("Unable to redirect to HTTPS as no port mapping found for HTTP port %s", - serverPort)); - } - } - return urlBuilder.getUrl(); + urlBuilder.setPathInfo(path); + return urlBuilder; } /** @@ -244,4 +258,18 @@ protected boolean isUseForward() { return this.useForward; } + /** + * Favor using relative URIs when formulating a redirect. + * + *

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

+ * @param favorRelativeUris whether to favor relative URIs or not + * @since 6.5 + */ + public void setFavorRelativeUris(boolean favorRelativeUris) { + this.favorRelativeUris = favorRelativeUris; + } + } diff --git a/web/src/main/java/org/springframework/security/web/authentication/preauth/PreAuthenticatedCredentialsNotFoundException.java b/web/src/main/java/org/springframework/security/web/authentication/preauth/PreAuthenticatedCredentialsNotFoundException.java index d18e7c0cc2b..57767d815c5 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/preauth/PreAuthenticatedCredentialsNotFoundException.java +++ b/web/src/main/java/org/springframework/security/web/authentication/preauth/PreAuthenticatedCredentialsNotFoundException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,10 +16,15 @@ package org.springframework.security.web.authentication.preauth; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; public class PreAuthenticatedCredentialsNotFoundException extends AuthenticationException { + @Serial + private static final long serialVersionUID = 2026209817833032728L; + public PreAuthenticatedCredentialsNotFoundException(String msg) { super(msg); } diff --git a/web/src/main/java/org/springframework/security/web/authentication/rememberme/CookieTheftException.java b/web/src/main/java/org/springframework/security/web/authentication/rememberme/CookieTheftException.java index dabaedd8954..3a477e0c504 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/rememberme/CookieTheftException.java +++ b/web/src/main/java/org/springframework/security/web/authentication/rememberme/CookieTheftException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,11 +16,16 @@ package org.springframework.security.web.authentication.rememberme; +import java.io.Serial; + /** * @author Luke Taylor */ public class CookieTheftException extends RememberMeAuthenticationException { + @Serial + private static final long serialVersionUID = -7215039140728554850L; + public CookieTheftException(String message) { super(message); } diff --git a/web/src/main/java/org/springframework/security/web/authentication/rememberme/InvalidCookieException.java b/web/src/main/java/org/springframework/security/web/authentication/rememberme/InvalidCookieException.java index 00668e06d88..d434bbc47b4 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/rememberme/InvalidCookieException.java +++ b/web/src/main/java/org/springframework/security/web/authentication/rememberme/InvalidCookieException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,8 @@ package org.springframework.security.web.authentication.rememberme; +import java.io.Serial; + /** * Exception thrown by a RememberMeServices implementation to indicate that a submitted * cookie is of an invalid format or has expired. @@ -24,6 +26,9 @@ */ public class InvalidCookieException extends RememberMeAuthenticationException { + @Serial + private static final long serialVersionUID = -7952247791921087125L; + public InvalidCookieException(String message) { super(message); } diff --git a/web/src/main/java/org/springframework/security/web/authentication/rememberme/RememberMeAuthenticationException.java b/web/src/main/java/org/springframework/security/web/authentication/rememberme/RememberMeAuthenticationException.java index dc727efa921..a1fc8c4ee89 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/rememberme/RememberMeAuthenticationException.java +++ b/web/src/main/java/org/springframework/security/web/authentication/rememberme/RememberMeAuthenticationException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,8 @@ package org.springframework.security.web.authentication.rememberme; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; /** @@ -27,6 +29,9 @@ */ public class RememberMeAuthenticationException extends AuthenticationException { + @Serial + private static final long serialVersionUID = 7028526952590057426L; + /** * Constructs a {@code RememberMeAuthenticationException} with the specified message * and root cause. diff --git a/web/src/main/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategy.java b/web/src/main/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategy.java index 8d528f56212..51be7bd0ab4 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategy.java +++ b/web/src/main/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategy.java @@ -76,7 +76,7 @@ public class ConcurrentSessionControlAuthenticationStrategy private boolean exceptionIfMaximumExceeded = false; - private int maximumSessions = 1; + private SessionLimit sessionLimit = SessionLimit.of(1); /** * @param sessionRegistry the session registry which should be updated when the @@ -130,7 +130,7 @@ public void onAuthentication(Authentication authentication, HttpServletRequest r * @return either -1 meaning unlimited, or a positive integer to limit (never zero) */ protected int getMaximumSessionsForThisUser(Authentication authentication) { - return this.maximumSessions; + return this.sessionLimit.apply(authentication); } /** @@ -172,15 +172,24 @@ public void setExceptionIfMaximumExceeded(boolean exceptionIfMaximumExceeded) { } /** - * Sets the maxSessions property. The default value is 1. Use -1 for + * Sets the sessionLimit property. The default value is 1. Use -1 for * unlimited sessions. * @param maximumSessions the maximum number of permitted sessions a user can have * open simultaneously. */ public void setMaximumSessions(int maximumSessions) { - Assert.isTrue(maximumSessions != 0, - "MaximumLogins must be either -1 to allow unlimited logins, or a positive integer to specify a maximum"); - this.maximumSessions = maximumSessions; + this.sessionLimit = SessionLimit.of(maximumSessions); + } + + /** + * Sets the sessionLimit property. The default value is 1. Use -1 for + * unlimited sessions. + * @param sessionLimit the session limit strategy + * @since 6.5 + */ + public void setMaximumSessions(SessionLimit sessionLimit) { + Assert.notNull(sessionLimit, "sessionLimit cannot be null"); + this.sessionLimit = sessionLimit; } /** diff --git a/web/src/main/java/org/springframework/security/web/authentication/session/SessionAuthenticationException.java b/web/src/main/java/org/springframework/security/web/authentication/session/SessionAuthenticationException.java index db1650b3a9e..6ec0835f758 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/session/SessionAuthenticationException.java +++ b/web/src/main/java/org/springframework/security/web/authentication/session/SessionAuthenticationException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,8 @@ package org.springframework.security.web.authentication.session; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; /** @@ -31,6 +33,9 @@ */ public class SessionAuthenticationException extends AuthenticationException { + @Serial + private static final long serialVersionUID = -2359914603911936474L; + public SessionAuthenticationException(String msg) { super(msg); } diff --git a/web/src/main/java/org/springframework/security/web/authentication/session/SessionLimit.java b/web/src/main/java/org/springframework/security/web/authentication/session/SessionLimit.java new file mode 100644 index 00000000000..362f3a7f7d7 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/session/SessionLimit.java @@ -0,0 +1,49 @@ +/* + * Copyright 2015-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.authentication.session; + +import java.util.function.Function; + +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; + +/** + * Represents the maximum number of sessions allowed. Use {@link #UNLIMITED} to indicate + * that there is no limit. + * + * @author Claudenir Freitas + * @since 6.5 + */ +public interface SessionLimit extends Function { + + /** + * Represents unlimited sessions. + */ + SessionLimit UNLIMITED = (authentication) -> -1; + + /** + * Creates a {@link SessionLimit} that always returns the given value for any user + * @param maxSessions the maximum number of sessions allowed + * @return a {@link SessionLimit} instance that returns the given value. + */ + static SessionLimit of(int maxSessions) { + Assert.isTrue(maxSessions != 0, + "MaximumLogins must be either -1 to allow unlimited logins, or a positive integer to specify a maximum"); + return (authentication) -> maxSessions; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/www/NonceExpiredException.java b/web/src/main/java/org/springframework/security/web/authentication/www/NonceExpiredException.java index 8ac38137d03..6628a9e27a2 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/www/NonceExpiredException.java +++ b/web/src/main/java/org/springframework/security/web/authentication/www/NonceExpiredException.java @@ -16,6 +16,8 @@ package org.springframework.security.web.authentication.www; +import java.io.Serial; + import org.springframework.security.core.AuthenticationException; /** @@ -25,6 +27,9 @@ */ public class NonceExpiredException extends AuthenticationException { + @Serial + private static final long serialVersionUID = -3487244679050681257L; + /** * Constructs a NonceExpiredException with the specified message. * @param msg the detail message diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/AndRequestMatcher.java b/web/src/main/java/org/springframework/security/web/util/matcher/AndRequestMatcher.java index b28b69bbba2..0630629cf25 100644 --- a/web/src/main/java/org/springframework/security/web/util/matcher/AndRequestMatcher.java +++ b/web/src/main/java/org/springframework/security/web/util/matcher/AndRequestMatcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,10 +20,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import jakarta.servlet.http.HttpServletRequest; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.springframework.util.Assert; @@ -36,8 +35,6 @@ */ public final class AndRequestMatcher implements RequestMatcher { - private final Log logger = LogFactory.getLog(getClass()); - private final List requestMatchers; /** @@ -90,6 +87,23 @@ public MatchResult matcher(HttpServletRequest request) { return MatchResult.match(variables); } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AndRequestMatcher that = (AndRequestMatcher) o; + return Objects.equals(this.requestMatchers, that.requestMatchers); + } + + @Override + public int hashCode() { + return Objects.hash(this.requestMatchers); + } + @Override public String toString() { return "And " + this.requestMatchers; diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/NegatedRequestMatcher.java b/web/src/main/java/org/springframework/security/web/util/matcher/NegatedRequestMatcher.java index 61da5dca623..60b7c32647b 100644 --- a/web/src/main/java/org/springframework/security/web/util/matcher/NegatedRequestMatcher.java +++ b/web/src/main/java/org/springframework/security/web/util/matcher/NegatedRequestMatcher.java @@ -17,8 +17,6 @@ package org.springframework.security.web.util.matcher; import jakarta.servlet.http.HttpServletRequest; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.springframework.util.Assert; @@ -33,8 +31,6 @@ */ public class NegatedRequestMatcher implements RequestMatcher { - private final Log logger = LogFactory.getLog(getClass()); - private final RequestMatcher requestMatcher; /** diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/OrRequestMatcher.java b/web/src/main/java/org/springframework/security/web/util/matcher/OrRequestMatcher.java index e3add8edf3a..53c0af8d922 100644 --- a/web/src/main/java/org/springframework/security/web/util/matcher/OrRequestMatcher.java +++ b/web/src/main/java/org/springframework/security/web/util/matcher/OrRequestMatcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ import java.util.Arrays; import java.util.List; +import java.util.Objects; import jakarta.servlet.http.HttpServletRequest; @@ -81,6 +82,23 @@ public MatchResult matcher(HttpServletRequest request) { return MatchResult.notMatch(); } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + OrRequestMatcher that = (OrRequestMatcher) o; + return Objects.equals(this.requestMatchers, that.requestMatchers); + } + + @Override + public int hashCode() { + return Objects.hash(this.requestMatchers); + } + @Override public String toString() { return "Or " + this.requestMatchers; diff --git a/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepository.java b/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepository.java new file mode 100644 index 00000000000..bfeaafb0e87 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepository.java @@ -0,0 +1,193 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.webauthn.management; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.ArgumentPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.PreparedStatementSetter; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.SqlParameterValue; +import org.springframework.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.util.Assert; + +/** + * A JDBC implementation of an {@link PublicKeyCredentialUserEntityRepository} that uses a + * {@link JdbcOperations} for {@link PublicKeyCredentialUserEntity} persistence. + * + * NOTE: This {@code PublicKeyCredentialUserEntityRepository} depends on the table + * definition described in + * "classpath:org/springframework/security/user-entities-schema.sql" and therefore MUST be + * defined in the database schema. + * + * @author Max Batischev + * @since 6.5 + * @see PublicKeyCredentialUserEntityRepository + * @see PublicKeyCredentialUserEntity + * @see JdbcOperations + * @see RowMapper + */ +public final class JdbcPublicKeyCredentialUserEntityRepository implements PublicKeyCredentialUserEntityRepository { + + private RowMapper userEntityRowMapper = new UserEntityRecordRowMapper(); + + private Function> userEntityParametersMapper = new UserEntityParametersMapper(); + + private final JdbcOperations jdbcOperations; + + private static final String TABLE_NAME = "user_entities"; + + // @formatter:off + private static final String COLUMN_NAMES = "id, " + + "name, " + + "display_name "; + // @formatter:on + + // @formatter:off + private static final String SAVE_USER_SQL = "INSERT INTO " + TABLE_NAME + + " (" + COLUMN_NAMES + ") VALUES (?, ?, ?)"; + // @formatter:on + + private static final String ID_FILTER = "id = ? "; + + private static final String USER_NAME_FILTER = "name = ? "; + + // @formatter:off + private static final String FIND_USER_BY_ID_SQL = "SELECT " + COLUMN_NAMES + + " FROM " + TABLE_NAME + + " WHERE " + ID_FILTER; + // @formatter:on + + // @formatter:off + private static final String FIND_USER_BY_NAME_SQL = "SELECT " + COLUMN_NAMES + + " FROM " + TABLE_NAME + + " WHERE " + USER_NAME_FILTER; + // @formatter:on + + private static final String DELETE_USER_SQL = "DELETE FROM " + TABLE_NAME + " WHERE " + ID_FILTER; + + // @formatter:off + private static final String UPDATE_USER_SQL = "UPDATE " + TABLE_NAME + + " SET name = ?, display_name = ? " + + " WHERE " + ID_FILTER; + // @formatter:on + + /** + * Constructs a {@code JdbcPublicKeyCredentialUserEntityRepository} using the provided + * parameters. + * @param jdbcOperations the JDBC operations + */ + public JdbcPublicKeyCredentialUserEntityRepository(JdbcOperations jdbcOperations) { + Assert.notNull(jdbcOperations, "jdbcOperations cannot be null"); + this.jdbcOperations = jdbcOperations; + } + + @Override + public PublicKeyCredentialUserEntity findById(Bytes id) { + Assert.notNull(id, "id cannot be null"); + List result = this.jdbcOperations.query(FIND_USER_BY_ID_SQL, + this.userEntityRowMapper, id.toBase64UrlString()); + return !result.isEmpty() ? result.get(0) : null; + } + + @Override + public PublicKeyCredentialUserEntity findByUsername(String username) { + Assert.hasText(username, "name cannot be null or empty"); + List result = this.jdbcOperations.query(FIND_USER_BY_NAME_SQL, + this.userEntityRowMapper, username); + return !result.isEmpty() ? result.get(0) : null; + } + + @Override + public void save(PublicKeyCredentialUserEntity userEntity) { + Assert.notNull(userEntity, "userEntity cannot be null"); + boolean existsUserEntity = null != this.findById(userEntity.getId()); + if (existsUserEntity) { + updateUserEntity(userEntity); + } + else { + try { + insertUserEntity(userEntity); + } + catch (DuplicateKeyException ex) { + updateUserEntity(userEntity); + } + } + } + + private void insertUserEntity(PublicKeyCredentialUserEntity userEntity) { + List parameters = this.userEntityParametersMapper.apply(userEntity); + PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray()); + this.jdbcOperations.update(SAVE_USER_SQL, pss); + } + + private void updateUserEntity(PublicKeyCredentialUserEntity userEntity) { + List parameters = this.userEntityParametersMapper.apply(userEntity); + SqlParameterValue userEntityId = parameters.remove(0); + parameters.add(userEntityId); + PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray()); + this.jdbcOperations.update(UPDATE_USER_SQL, pss); + } + + @Override + public void delete(Bytes id) { + Assert.notNull(id, "id cannot be null"); + SqlParameterValue[] parameters = new SqlParameterValue[] { + new SqlParameterValue(Types.VARCHAR, id.toBase64UrlString()), }; + PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters); + this.jdbcOperations.update(DELETE_USER_SQL, pss); + } + + private static class UserEntityParametersMapper + implements Function> { + + @Override + public List apply(PublicKeyCredentialUserEntity userEntity) { + List parameters = new ArrayList<>(); + + parameters.add(new SqlParameterValue(Types.VARCHAR, userEntity.getId().toBase64UrlString())); + parameters.add(new SqlParameterValue(Types.VARCHAR, userEntity.getName())); + parameters.add(new SqlParameterValue(Types.VARCHAR, userEntity.getDisplayName())); + + return parameters; + } + + } + + private static class UserEntityRecordRowMapper implements RowMapper { + + @Override + public PublicKeyCredentialUserEntity mapRow(ResultSet rs, int rowNum) throws SQLException { + Bytes id = Bytes.fromBase64(new String(rs.getString("id").getBytes())); + String name = rs.getString("name"); + String displayName = rs.getString("display_name"); + + return ImmutablePublicKeyCredentialUserEntity.builder().id(id).name(name).displayName(displayName).build(); + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepository.java b/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepository.java new file mode 100644 index 00000000000..aa012d6964b --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepository.java @@ -0,0 +1,305 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.webauthn.management; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +import org.springframework.jdbc.core.ArgumentPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.PreparedStatementSetter; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.SqlParameterValue; +import org.springframework.jdbc.support.lob.DefaultLobHandler; +import org.springframework.jdbc.support.lob.LobCreator; +import org.springframework.jdbc.support.lob.LobHandler; +import org.springframework.security.web.webauthn.api.AuthenticatorTransport; +import org.springframework.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.CredentialRecord; +import org.springframework.security.web.webauthn.api.ImmutableCredentialRecord; +import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCose; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialType; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * A JDBC implementation of an {@link UserCredentialRepository} that uses a + * {@link JdbcOperations} for {@link CredentialRecord} persistence. + * + * NOTE: This {@code UserCredentialRepository} depends on the table definition + * described in "classpath:org/springframework/security/user-credentials-schema.sql" and + * therefore MUST be defined in the database schema. + * + * @author Max Batischev + * @since 6.5 + * @see UserCredentialRepository + * @see CredentialRecord + * @see JdbcOperations + * @see RowMapper + */ +public final class JdbcUserCredentialRepository implements UserCredentialRepository { + + private RowMapper credentialRecordRowMapper = new CredentialRecordRowMapper(); + + private Function> credentialRecordParametersMapper = new CredentialRecordParametersMapper(); + + private LobHandler lobHandler = new DefaultLobHandler(); + + private final JdbcOperations jdbcOperations; + + private static final String TABLE_NAME = "user_credentials"; + + // @formatter:off + private static final String COLUMN_NAMES = "credential_id, " + + "user_entity_user_id, " + + "public_key, " + + "signature_count, " + + "uv_initialized, " + + "backup_eligible, " + + "authenticator_transports, " + + "public_key_credential_type, " + + "backup_state, " + + "attestation_object, " + + "attestation_client_data_json, " + + "created, " + + "last_used, " + + "label "; + // @formatter:on + + // @formatter:off + private static final String SAVE_CREDENTIAL_RECORD_SQL = "INSERT INTO " + TABLE_NAME + + " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + // @formatter:on + + private static final String ID_FILTER = "credential_id = ? "; + + private static final String USER_ID_FILTER = "user_entity_user_id = ? "; + + // @formatter:off + private static final String FIND_CREDENTIAL_RECORD_BY_ID_SQL = "SELECT " + COLUMN_NAMES + + " FROM " + TABLE_NAME + + " WHERE " + ID_FILTER; + // @formatter:on + + // @formatter:off + private static final String FIND_CREDENTIAL_RECORD_BY_USER_ID_SQL = "SELECT " + COLUMN_NAMES + + " FROM " + TABLE_NAME + + " WHERE " + USER_ID_FILTER; + // @formatter:on + + private static final String DELETE_CREDENTIAL_RECORD_SQL = "DELETE FROM " + TABLE_NAME + " WHERE " + ID_FILTER; + + /** + * Constructs a {@code JdbcUserCredentialRepository} using the provided parameters. + * @param jdbcOperations the JDBC operations + */ + public JdbcUserCredentialRepository(JdbcOperations jdbcOperations) { + Assert.notNull(jdbcOperations, "jdbcOperations cannot be null"); + this.jdbcOperations = jdbcOperations; + } + + @Override + public void delete(Bytes credentialId) { + Assert.notNull(credentialId, "credentialId cannot be null"); + SqlParameterValue[] parameters = new SqlParameterValue[] { + new SqlParameterValue(Types.VARCHAR, credentialId.toBase64UrlString()), }; + PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters); + this.jdbcOperations.update(DELETE_CREDENTIAL_RECORD_SQL, pss); + } + + @Override + public void save(CredentialRecord record) { + Assert.notNull(record, "record cannot be null"); + List parameters = this.credentialRecordParametersMapper.apply(record); + try (LobCreator lobCreator = this.lobHandler.getLobCreator()) { + PreparedStatementSetter pss = new LobCreatorArgumentPreparedStatementSetter(lobCreator, + parameters.toArray()); + this.jdbcOperations.update(SAVE_CREDENTIAL_RECORD_SQL, pss); + } + } + + @Override + public CredentialRecord findByCredentialId(Bytes credentialId) { + Assert.notNull(credentialId, "credentialId cannot be null"); + List result = this.jdbcOperations.query(FIND_CREDENTIAL_RECORD_BY_ID_SQL, + this.credentialRecordRowMapper, credentialId.toBase64UrlString()); + return !result.isEmpty() ? result.get(0) : null; + } + + @Override + public List findByUserId(Bytes userId) { + Assert.notNull(userId, "userId cannot be null"); + return this.jdbcOperations.query(FIND_CREDENTIAL_RECORD_BY_USER_ID_SQL, this.credentialRecordRowMapper, + userId.toBase64UrlString()); + } + + /** + * Sets a {@link LobHandler} for large binary fields and large text field parameters. + * @param lobHandler the lob handler + */ + public void setLobHandler(LobHandler lobHandler) { + Assert.notNull(lobHandler, "lobHandler cannot be null"); + this.lobHandler = lobHandler; + } + + private static class CredentialRecordParametersMapper + implements Function> { + + @Override + public List apply(CredentialRecord record) { + List parameters = new ArrayList<>(); + + List transports = new ArrayList<>(); + if (!CollectionUtils.isEmpty(record.getTransports())) { + for (AuthenticatorTransport transport : record.getTransports()) { + transports.add(transport.getValue()); + } + } + + parameters.add(new SqlParameterValue(Types.VARCHAR, record.getCredentialId().toBase64UrlString())); + parameters.add(new SqlParameterValue(Types.VARCHAR, record.getUserEntityUserId().toBase64UrlString())); + parameters.add(new SqlParameterValue(Types.BLOB, record.getPublicKey().getBytes())); + parameters.add(new SqlParameterValue(Types.BIGINT, record.getSignatureCount())); + parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isUvInitialized())); + parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isBackupEligible())); + parameters.add(new SqlParameterValue(Types.VARCHAR, + (!CollectionUtils.isEmpty(record.getTransports())) ? String.join(",", transports) : "")); + parameters.add(new SqlParameterValue(Types.VARCHAR, + (record.getCredentialType() != null) ? record.getCredentialType().getValue() : null)); + parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isBackupState())); + parameters.add(new SqlParameterValue(Types.BLOB, + (record.getAttestationObject() != null) ? record.getAttestationObject().getBytes() : null)); + parameters.add(new SqlParameterValue(Types.BLOB, (record.getAttestationClientDataJSON() != null) + ? record.getAttestationClientDataJSON().getBytes() : null)); + parameters.add(new SqlParameterValue(Types.TIMESTAMP, fromInstant(record.getCreated()))); + parameters.add(new SqlParameterValue(Types.TIMESTAMP, fromInstant(record.getLastUsed()))); + parameters.add(new SqlParameterValue(Types.VARCHAR, record.getLabel())); + + return parameters; + } + + private Timestamp fromInstant(Instant instant) { + if (instant == null) { + return null; + } + return Timestamp.from(instant); + } + + } + + private static final class LobCreatorArgumentPreparedStatementSetter extends ArgumentPreparedStatementSetter { + + private final LobCreator lobCreator; + + private LobCreatorArgumentPreparedStatementSetter(LobCreator lobCreator, Object[] args) { + super(args); + this.lobCreator = lobCreator; + } + + @Override + protected void doSetValue(PreparedStatement ps, int parameterPosition, Object argValue) throws SQLException { + if (argValue instanceof SqlParameterValue paramValue) { + if (paramValue.getSqlType() == Types.BLOB) { + if (paramValue.getValue() != null) { + Assert.isInstanceOf(byte[].class, paramValue.getValue(), + "Value of blob parameter must be byte[]"); + } + byte[] valueBytes = (byte[]) paramValue.getValue(); + this.lobCreator.setBlobAsBytes(ps, parameterPosition, valueBytes); + return; + } + } + super.doSetValue(ps, parameterPosition, argValue); + } + + } + + private static class CredentialRecordRowMapper implements RowMapper { + + private LobHandler lobHandler = new DefaultLobHandler(); + + @Override + public CredentialRecord mapRow(ResultSet rs, int rowNum) throws SQLException { + Bytes credentialId = Bytes.fromBase64(new String(rs.getString("credential_id").getBytes())); + Bytes userEntityUserId = Bytes.fromBase64(new String(rs.getString("user_entity_user_id").getBytes())); + ImmutablePublicKeyCose publicKey = new ImmutablePublicKeyCose( + this.lobHandler.getBlobAsBytes(rs, "public_key")); + long signatureCount = rs.getLong("signature_count"); + boolean uvInitialized = rs.getBoolean("uv_initialized"); + boolean backupEligible = rs.getBoolean("backup_eligible"); + PublicKeyCredentialType credentialType = PublicKeyCredentialType + .valueOf(rs.getString("public_key_credential_type")); + boolean backupState = rs.getBoolean("backup_state"); + + Bytes attestationObject = null; + byte[] rawAttestationObject = this.lobHandler.getBlobAsBytes(rs, "attestation_object"); + if (rawAttestationObject != null) { + attestationObject = new Bytes(rawAttestationObject); + } + + Bytes attestationClientDataJson = null; + byte[] rawAttestationClientDataJson = this.lobHandler.getBlobAsBytes(rs, "attestation_client_data_json"); + if (rawAttestationClientDataJson != null) { + attestationClientDataJson = new Bytes(rawAttestationClientDataJson); + } + + Instant created = fromTimestamp(rs.getTimestamp("created")); + Instant lastUsed = fromTimestamp(rs.getTimestamp("last_used")); + String label = rs.getString("label"); + String[] transports = rs.getString("authenticator_transports").split(","); + + Set authenticatorTransports = new HashSet<>(); + for (String transport : transports) { + authenticatorTransports.add(AuthenticatorTransport.valueOf(transport)); + } + return ImmutableCredentialRecord.builder() + .credentialId(credentialId) + .userEntityUserId(userEntityUserId) + .publicKey(publicKey) + .signatureCount(signatureCount) + .uvInitialized(uvInitialized) + .backupEligible(backupEligible) + .credentialType(credentialType) + .backupState(backupState) + .attestationObject(attestationObject) + .attestationClientDataJSON(attestationClientDataJson) + .created(created) + .label(label) + .lastUsed(lastUsed) + .transports(authenticatorTransports) + .build(); + } + + private Instant fromTimestamp(Timestamp timestamp) { + if (timestamp == null) { + return null; + } + return timestamp.toInstant(); + } + + } + +} diff --git a/web/src/main/resources/META-INF/spring/aot.factories b/web/src/main/resources/META-INF/spring/aot.factories index dcc4be6a067..2a3c8ad7681 100644 --- a/web/src/main/resources/META-INF/spring/aot.factories +++ b/web/src/main/resources/META-INF/spring/aot.factories @@ -1,2 +1,4 @@ org.springframework.aot.hint.RuntimeHintsRegistrar=\ -org.springframework.security.web.aot.hint.WebMvcSecurityRuntimeHints +org.springframework.security.web.aot.hint.WebMvcSecurityRuntimeHints,\ +org.springframework.security.web.aot.hint.UserCredentialRuntimeHints,\ +org.springframework.security.web.aot.hint.PublicKeyCredentialUserEntityRuntimeHints diff --git a/web/src/main/resources/org/springframework/security/user-credentials-schema.sql b/web/src/main/resources/org/springframework/security/user-credentials-schema.sql new file mode 100644 index 00000000000..1be48f2fb1e --- /dev/null +++ b/web/src/main/resources/org/springframework/security/user-credentials-schema.sql @@ -0,0 +1,18 @@ +create table user_credentials +( + credential_id varchar(1000) not null, + user_entity_user_id varchar(1000) not null, + public_key blob not null, + signature_count bigint, + uv_initialized boolean, + backup_eligible boolean not null, + authenticator_transports varchar(1000), + public_key_credential_type varchar(100), + backup_state boolean not null, + attestation_object blob, + attestation_client_data_json blob, + created timestamp, + last_used timestamp, + label varchar(1000) not null, + primary key (credential_id) +); diff --git a/web/src/main/resources/org/springframework/security/user-entities-schema.sql b/web/src/main/resources/org/springframework/security/user-entities-schema.sql new file mode 100644 index 00000000000..ec66c66519b --- /dev/null +++ b/web/src/main/resources/org/springframework/security/user-entities-schema.sql @@ -0,0 +1,7 @@ +create table user_entities +( + id varchar(1000) not null, + name varchar(100) not null, + display_name varchar(200), + primary key (id) +); diff --git a/web/src/test/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHintsTests.java b/web/src/test/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHintsTests.java new file mode 100644 index 00000000000..4909a643030 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHintsTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.aot.hint; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PublicKeyCredentialUserEntityRuntimeHints} + * + * @author Max Batischev + */ +public class PublicKeyCredentialUserEntityRuntimeHintsTests { + + private final RuntimeHints hints = new RuntimeHints(); + + @BeforeEach + void setup() { + SpringFactoriesLoader.forResourceLocation("META-INF/spring/aot.factories") + .load(RuntimeHintsRegistrar.class) + .forEach((registrar) -> registrar.registerHints(this.hints, ClassUtils.getDefaultClassLoader())); + } + + @ParameterizedTest + @MethodSource("getUserEntitiesSqlFiles") + void userEntitiesSqlFilesHasHints(String schemaFile) { + assertThat(RuntimeHintsPredicates.resource().forResource(schemaFile)).accepts(this.hints); + } + + private static Stream getUserEntitiesSqlFiles() { + return Stream.of("org/springframework/security/user-entities-schema.sql"); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHintsTests.java b/web/src/test/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHintsTests.java new file mode 100644 index 00000000000..33799cc6f92 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHintsTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.aot.hint; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link UserCredentialRuntimeHints} + * + * @author Max Batischev + */ +public class UserCredentialRuntimeHintsTests { + + private final RuntimeHints hints = new RuntimeHints(); + + @BeforeEach + void setup() { + SpringFactoriesLoader.forResourceLocation("META-INF/spring/aot.factories") + .load(RuntimeHintsRegistrar.class) + .forEach((registrar) -> registrar.registerHints(this.hints, ClassUtils.getDefaultClassLoader())); + } + + @ParameterizedTest + @MethodSource("getClientRecordsSqlFiles") + void credentialRecordsSqlFilesHasHints(String schemaFile) { + assertThat(RuntimeHintsPredicates.resource().forResource(schemaFile)).accepts(this.hints); + } + + private static Stream getClientRecordsSqlFiles() { + return Stream.of("org/springframework/security/user-credentials-schema.sql"); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPointTests.java b/web/src/test/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPointTests.java index 77b49be1a16..91e2d93cdfa 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPointTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPointTests.java @@ -135,6 +135,12 @@ public void testHttpsOperationFromOriginalHttpsUrl() throws Exception { ep.setPortResolver(new MockPortResolver(8080, 8443)); ep.commence(request, response, null); assertThat(response.getRedirectedUrl()).isEqualTo("https://www.example.com:8443/bigWebApp/hello"); + // access to https via http port + request.setServerPort(8080); + response = new MockHttpServletResponse(); + ep.setPortResolver(new MockPortResolver(8080, 8443)); + ep.commence(request, response, null); + assertThat(response.getRedirectedUrl()).isEqualTo("https://www.example.com:8443/bigWebApp/hello"); } @Test @@ -231,4 +237,54 @@ public void absoluteLoginFormUrlCantBeUsedWithForwarding() throws Exception { assertThatIllegalArgumentException().isThrownBy(ep::afterPropertiesSet); } + @Test + public void commenceWhenFavorRelativeUrisThenHttpsSchemeNotIncluded() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/some_path"); + request.setScheme("https"); + request.setServerName("www.example.com"); + request.setContextPath("/bigWebApp"); + request.setServerPort(443); + MockHttpServletResponse response = new MockHttpServletResponse(); + LoginUrlAuthenticationEntryPoint ep = new LoginUrlAuthenticationEntryPoint("/hello"); + ep.setFavorRelativeUris(true); + ep.setPortMapper(new PortMapperImpl()); + ep.setForceHttps(true); + ep.setPortMapper(new PortMapperImpl()); + ep.setPortResolver(new MockPortResolver(80, 443)); + ep.afterPropertiesSet(); + ep.commence(request, response, null); + assertThat(response.getRedirectedUrl()).isEqualTo("/bigWebApp/hello"); + request.setServerPort(8443); + response = new MockHttpServletResponse(); + ep.setPortResolver(new MockPortResolver(8080, 8443)); + ep.commence(request, response, null); + assertThat(response.getRedirectedUrl()).isEqualTo("/bigWebApp/hello"); + // access to https via http port + request.setServerPort(8080); + response = new MockHttpServletResponse(); + ep.setPortResolver(new MockPortResolver(8080, 8443)); + ep.commence(request, response, null); + assertThat(response.getRedirectedUrl()).isEqualTo("/bigWebApp/hello"); + } + + @Test + public void commenceWhenFavorRelativeUrisThenHttpSchemeNotIncluded() throws Exception { + LoginUrlAuthenticationEntryPoint ep = new LoginUrlAuthenticationEntryPoint("/hello"); + ep.setFavorRelativeUris(true); + ep.setPortMapper(new PortMapperImpl()); + ep.setPortResolver(new MockPortResolver(80, 443)); + ep.afterPropertiesSet(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/some_path"); + request.setContextPath("/bigWebApp"); + request.setScheme("http"); + request.setServerName("localhost"); + request.setContextPath("/bigWebApp"); + request.setServerPort(80); + MockHttpServletResponse response = new MockHttpServletResponse(); + ep.commence(request, response, null); + assertThat(response.getRedirectedUrl()).isEqualTo("/bigWebApp/hello"); + } + } diff --git a/web/src/test/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategyTests.java b/web/src/test/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategyTests.java index ffe51cc2a0e..aa1bed6d8f8 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategyTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,9 +41,11 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verifyNoInteractions; /** * @author Rob Winch + * @author Claudenir Freitas * */ @ExtendWith(MockitoExtension.class) @@ -144,6 +146,86 @@ public void onAuthenticationWhenMaxSessionsExceededByTwoThenTwoSessionsExpired() assertThat(this.sessionInformation.isExpired()).isFalse(); } + @Test + public void setMaximumSessionsWithNullValue() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> this.strategy.setMaximumSessions(null)) + .withMessage("sessionLimit cannot be null"); + } + + @Test + public void noRegisteredSessionUsingSessionLimit() { + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())).willReturn(Collections.emptyList()); + this.strategy.setMaximumSessions(SessionLimit.of(1)); + this.strategy.setExceptionIfMaximumExceeded(true); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + // no exception + } + + @Test + public void maxSessionsSameSessionIdUsingSessionLimit() { + MockHttpSession session = new MockHttpSession(new MockServletContext(), this.sessionInformation.getSessionId()); + this.request.setSession(session); + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Collections.singletonList(this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(1)); + this.strategy.setExceptionIfMaximumExceeded(true); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + // no exception + } + + @Test + public void maxSessionsWithExceptionUsingSessionLimit() { + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Collections.singletonList(this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(1)); + this.strategy.setExceptionIfMaximumExceeded(true); + assertThatExceptionOfType(SessionAuthenticationException.class) + .isThrownBy(() -> this.strategy.onAuthentication(this.authentication, this.request, this.response)); + } + + @Test + public void maxSessionsExpireExistingUserUsingSessionLimit() { + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Collections.singletonList(this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(1)); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + assertThat(this.sessionInformation.isExpired()).isTrue(); + } + + @Test + public void maxSessionsExpireLeastRecentExistingUserUsingSessionLimit() { + SessionInformation moreRecentSessionInfo = new SessionInformation(this.authentication.getPrincipal(), "unique", + new Date(1374766999999L)); + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Arrays.asList(moreRecentSessionInfo, this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(2)); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + assertThat(this.sessionInformation.isExpired()).isTrue(); + } + + @Test + public void onAuthenticationWhenMaxSessionsExceededByTwoThenTwoSessionsExpiredUsingSessionLimit() { + SessionInformation oldestSessionInfo = new SessionInformation(this.authentication.getPrincipal(), "unique1", + new Date(1374766134214L)); + SessionInformation secondOldestSessionInfo = new SessionInformation(this.authentication.getPrincipal(), + "unique2", new Date(1374766134215L)); + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Arrays.asList(oldestSessionInfo, secondOldestSessionInfo, this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(2)); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + assertThat(oldestSessionInfo.isExpired()).isTrue(); + assertThat(secondOldestSessionInfo.isExpired()).isTrue(); + assertThat(this.sessionInformation.isExpired()).isFalse(); + } + + @Test + public void onAuthenticationWhenSessionLimitIsUnlimited() { + this.strategy.setMaximumSessions(SessionLimit.UNLIMITED); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + verifyNoInteractions(this.sessionRegistry); + } + @Test public void setMessageSourceNull() { assertThatIllegalArgumentException().isThrownBy(() -> this.strategy.setMessageSource(null)); diff --git a/web/src/test/java/org/springframework/security/web/authentication/session/SessionLimitTests.java b/web/src/test/java/org/springframework/security/web/authentication/session/SessionLimitTests.java new file mode 100644 index 00000000000..134f9f6e7ae --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/session/SessionLimitTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.authentication.session; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; + +import org.springframework.security.core.Authentication; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * @author Claudenir Freitas + * @since 6.5 + */ +class SessionLimitTests { + + private final Authentication authentication = Mockito.mock(Authentication.class); + + @Test + void testUnlimitedInstance() { + SessionLimit sessionLimit = SessionLimit.UNLIMITED; + int result = sessionLimit.apply(this.authentication); + assertThat(result).isEqualTo(-1); + } + + @ParameterizedTest + @ValueSource(ints = { -1, 1, 2, 3 }) + void testInstanceWithValidMaxSessions(int maxSessions) { + SessionLimit sessionLimit = SessionLimit.of(maxSessions); + int result = sessionLimit.apply(this.authentication); + assertThat(result).isEqualTo(maxSessions); + } + + @Test + void testInstanceWithInvalidMaxSessions() { + assertThatIllegalArgumentException().isThrownBy(() -> SessionLimit.of(0)) + .withMessage( + "MaximumLogins must be either -1 to allow unlimited logins, or a positive integer to specify a maximum"); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/webauthn/api/TestCredentialRecord.java b/web/src/test/java/org/springframework/security/web/webauthn/api/TestCredentialRecord.java index 917125ae670..1ed190c03d5 100644 --- a/web/src/test/java/org/springframework/security/web/webauthn/api/TestCredentialRecord.java +++ b/web/src/test/java/org/springframework/security/web/webauthn/api/TestCredentialRecord.java @@ -16,6 +16,9 @@ package org.springframework.security.web.webauthn.api; +import java.time.Instant; +import java.util.Set; + public final class TestCredentialRecord { public static ImmutableCredentialRecord.ImmutableCredentialRecordBuilder userCredential() { @@ -29,6 +32,24 @@ public static ImmutableCredentialRecord.ImmutableCredentialRecordBuilder userCre .backupState(true); } + public static ImmutableCredentialRecord.ImmutableCredentialRecordBuilder fullUserCredential() { + return ImmutableCredentialRecord.builder() + .label("label") + .credentialId(Bytes.fromBase64("NauGCN7bZ5jEBwThcde51g")) + .userEntityUserId(Bytes.fromBase64("vKBFhsWT3gQnn-gHdT4VXIvjDkVXVYg5w8CLGHPunMM")) + .publicKey(ImmutablePublicKeyCose.fromBase64( + "pQECAyYgASFYIC7DAiV_trHFPjieOxXbec7q2taBcgLnIi19zrUwVhCdIlggvN6riHORK_velHcTLFK_uJhyKK0oBkJqzNqR2E-2xf8=")) + .backupEligible(true) + .created(Instant.now()) + .transports(Set.of(AuthenticatorTransport.BLE, AuthenticatorTransport.HYBRID)) + .signatureCount(100) + .uvInitialized(false) + .credentialType(PublicKeyCredentialType.PUBLIC_KEY) + .attestationObject(new Bytes("test".getBytes())) + .attestationClientDataJSON(new Bytes(("test").getBytes())) + .backupState(true); + } + private TestCredentialRecord() { } diff --git a/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepositoryTests.java b/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepositoryTests.java new file mode 100644 index 00000000000..503108ac4ea --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepositoryTests.java @@ -0,0 +1,182 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.webauthn.management; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialUserEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link JdbcPublicKeyCredentialUserEntityRepository} + * + * @author Max Batischev + */ +public class JdbcPublicKeyCredentialUserEntityRepositoryTests { + + private EmbeddedDatabase db; + + private JdbcPublicKeyCredentialUserEntityRepository repository; + + private static final String USER_ENTITIES_SQL_RESOURCE = "org/springframework/security/user-entities-schema.sql"; + + @BeforeEach + void setUp() { + this.db = createDb(); + JdbcOperations jdbcOperations = new JdbcTemplate(this.db); + this.repository = new JdbcPublicKeyCredentialUserEntityRepository(jdbcOperations); + } + + @AfterEach + void tearDown() { + this.db.shutdown(); + } + + private static EmbeddedDatabase createDb() { + // @formatter:off + return new EmbeddedDatabaseBuilder() + .generateUniqueName(true) + .setType(EmbeddedDatabaseType.HSQL) + .setScriptEncoding("UTF-8") + .addScript(USER_ENTITIES_SQL_RESOURCE) + .build(); + // @formatter:on + } + + @Test + void constructorWhenJdbcOperationsIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new JdbcPublicKeyCredentialUserEntityRepository(null)) + .withMessage("jdbcOperations cannot be null"); + // @formatter:on + } + + @Test + void saveWhenUserEntityIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.repository.save(null)) + .withMessage("userEntity cannot be null"); + // @formatter:on + } + + @Test + void findByUserEntityIdWheIdIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.repository.findById(null)) + .withMessage("id cannot be null"); + // @formatter:on + } + + @Test + void findByUserNameWheUserNameIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.repository.findByUsername(null)) + .withMessage("name cannot be null or empty"); + // @formatter:on + } + + @Test + void saveUserEntityWhenSaveThenReturnsSaved() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + + this.repository.save(userEntity); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId()); + assertThat(savedUserEntity).isNotNull(); + assertThat(savedUserEntity.getId()).isEqualTo(userEntity.getId()); + assertThat(savedUserEntity.getDisplayName()).isEqualTo(userEntity.getDisplayName()); + assertThat(savedUserEntity.getName()).isEqualTo(userEntity.getName()); + } + + @Test + void saveUserEntityWhenUserEntityExistsThenUpdates() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + this.repository.save(userEntity); + + this.repository.save(testUserEntity(userEntity.getId())); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId()); + assertThat(savedUserEntity).isNotNull(); + assertThat(savedUserEntity.getId()).isEqualTo(userEntity.getId()); + assertThat(savedUserEntity.getDisplayName()).isEqualTo("user2"); + assertThat(savedUserEntity.getName()).isEqualTo("user2"); + } + + @Test + void findUserEntityByUserNameWhenUserEntityExistsThenReturnsSaved() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + this.repository.save(userEntity); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findByUsername(userEntity.getName()); + + assertThat(savedUserEntity).isNotNull(); + } + + @Test + void deleteUserEntityWhenRecordExistThenSuccess() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + this.repository.save(userEntity); + + this.repository.delete(userEntity.getId()); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId()); + assertThat(savedUserEntity).isNull(); + } + + @Test + void findUserEntityByIdWhenUserEntityDoesNotExistThenReturnsNull() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId()); + assertThat(savedUserEntity).isNull(); + } + + @Test + void findUserEntityByUserNameWhenUserEntityDoesNotExistThenReturnsEmpty() { + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + + PublicKeyCredentialUserEntity savedUserEntity = this.repository.findByUsername(userEntity.getName()); + assertThat(savedUserEntity).isNull(); + } + + private PublicKeyCredentialUserEntity testUserEntity(Bytes id) { + // @formatter:off + return ImmutablePublicKeyCredentialUserEntity.builder() + .name("user2") + .id(id) + .displayName("user2") + .build(); + // @formatter:on + } + +} diff --git a/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepositoryTests.java b/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepositoryTests.java new file mode 100644 index 00000000000..4829b537f0a --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepositoryTests.java @@ -0,0 +1,180 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.webauthn.management; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.security.web.webauthn.api.AuthenticatorTransport; +import org.springframework.security.web.webauthn.api.CredentialRecord; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialType; +import org.springframework.security.web.webauthn.api.TestCredentialRecord; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link JdbcUserCredentialRepository} + * + * @author Max Batischev + */ +public class JdbcUserCredentialRepositoryTests { + + private EmbeddedDatabase db; + + private JdbcUserCredentialRepository jdbcUserCredentialRepository; + + private static final String USER_CREDENTIALS_SQL_RESOURCE = "org/springframework/security/user-credentials-schema.sql"; + + @BeforeEach + void setUp() { + this.db = createDb(); + JdbcOperations jdbcOperations = new JdbcTemplate(this.db); + this.jdbcUserCredentialRepository = new JdbcUserCredentialRepository(jdbcOperations); + } + + @AfterEach + void tearDown() { + this.db.shutdown(); + } + + private static EmbeddedDatabase createDb() { + // @formatter:off + return new EmbeddedDatabaseBuilder() + .generateUniqueName(true) + .setType(EmbeddedDatabaseType.HSQL) + .setScriptEncoding("UTF-8") + .addScript(USER_CREDENTIALS_SQL_RESOURCE) + .build(); + // @formatter:on + } + + @Test + void constructorWhenJdbcOperationsIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new JdbcUserCredentialRepository(null)) + .withMessage("jdbcOperations cannot be null"); + // @formatter:on + } + + @Test + void saveWhenCredentialRecordIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.jdbcUserCredentialRepository.save(null)) + .withMessage("record cannot be null"); + // @formatter:on + } + + @Test + void findByCredentialIdWheCredentialIdIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.jdbcUserCredentialRepository.findByCredentialId(null)) + .withMessage("credentialId cannot be null"); + // @formatter:on + } + + @Test + void findByCredentialIdWheUserIdIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.jdbcUserCredentialRepository.findByUserId(null)) + .withMessage("userId cannot be null"); + // @formatter:on + } + + @Test + void saveCredentialRecordWhenSaveThenReturnsSaved() { + CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build(); + this.jdbcUserCredentialRepository.save(userCredential); + + CredentialRecord savedUserCredential = this.jdbcUserCredentialRepository + .findByCredentialId(userCredential.getCredentialId()); + + assertThat(savedUserCredential).isNotNull(); + assertThat(savedUserCredential.getCredentialId()).isEqualTo(userCredential.getCredentialId()); + assertThat(savedUserCredential.getUserEntityUserId()).isEqualTo(userCredential.getUserEntityUserId()); + assertThat(savedUserCredential.getLabel()).isEqualTo(userCredential.getLabel()); + assertThat(savedUserCredential.getPublicKey().getBytes()).isEqualTo(userCredential.getPublicKey().getBytes()); + assertThat(savedUserCredential.isBackupEligible()).isEqualTo(userCredential.isBackupEligible()); + assertThat(savedUserCredential.isBackupState()).isEqualTo(userCredential.isBackupState()); + assertThat(savedUserCredential.getCreated()).isNotNull(); + assertThat(savedUserCredential.getLastUsed()).isNotNull(); + assertThat(savedUserCredential.isUvInitialized()).isFalse(); + assertThat(savedUserCredential.getSignatureCount()).isEqualTo(100); + assertThat(savedUserCredential.getCredentialType()).isEqualTo(PublicKeyCredentialType.PUBLIC_KEY); + assertThat(savedUserCredential.getTransports().contains(AuthenticatorTransport.HYBRID)).isTrue(); + assertThat(savedUserCredential.getTransports().contains(AuthenticatorTransport.BLE)).isTrue(); + assertThat(new String(savedUserCredential.getAttestationObject().getBytes())).isEqualTo("test"); + assertThat(new String(savedUserCredential.getAttestationClientDataJSON().getBytes())).isEqualTo("test"); + } + + @Test + void findCredentialRecordByUserIdWhenRecordExistsThenReturnsSaved() { + CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build(); + this.jdbcUserCredentialRepository.save(userCredential); + + List credentialRecords = this.jdbcUserCredentialRepository + .findByUserId(userCredential.getUserEntityUserId()); + + assertThat(credentialRecords).isNotNull(); + assertThat(credentialRecords.size()).isEqualTo(1); + } + + @Test + void findCredentialRecordByUserIdWhenRecordDoesNotExistThenReturnsEmpty() { + CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build(); + + List credentialRecords = this.jdbcUserCredentialRepository + .findByUserId(userCredential.getUserEntityUserId()); + + assertThat(credentialRecords.size()).isEqualTo(0); + } + + @Test + void findCredentialRecordByCredentialIdWhenRecordDoesNotExistThenReturnsNull() { + CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build(); + + CredentialRecord credentialRecord = this.jdbcUserCredentialRepository + .findByCredentialId(userCredential.getCredentialId()); + + assertThat(credentialRecord).isNull(); + } + + @Test + void deleteCredentialRecordWhenRecordExistThenSuccess() { + CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build(); + this.jdbcUserCredentialRepository.save(userCredential); + + this.jdbcUserCredentialRepository.delete(userCredential.getCredentialId()); + + CredentialRecord credentialRecord = this.jdbcUserCredentialRepository + .findByCredentialId(userCredential.getCredentialId()); + assertThat(credentialRecord).isNull(); + } + +}