From e9a44bc0ce98db5a758c373f87dd2346d0c2aff5 Mon Sep 17 00:00:00 2001 From: Filip Hanik Date: Tue, 13 Aug 2019 14:45:39 -0700 Subject: [PATCH] HttpSecurity.saml2login() - MVP Core Code Implements minimal SAML 2.0 login/authentication functionality with the following feature set: - Supports IDP initiated login at the default url of /login/saml2/sso/{registrationId} - Supports SP initiated login at the default url of /saml2/authenticate/{registrationId} - Supports basic java-configuration via DSL - Provides an integration sample using Spring Boot Not implemented with this MVP - Single Logout - Dynamic Service Provider Metadata Fixes gh-6019 --- config/spring-security-config.gradle | 1 + .../web/builders/FilterComparator.java | 6 + .../annotation/web/builders/HttpSecurity.java | 192 ++++++- .../saml2/Saml2LoginConfigurer.java | 314 +++++++++++ ...ing-security-saml2-service-provider.gradle | 12 + .../security/saml2/Saml2Exception.java | 36 ++ .../credentials/Saml2X509Credential.java | 162 ++++++ .../OpenSamlAuthenticationProvider.java | 403 ++++++++++++++ .../OpenSamlAuthenticationRequestFactory.java | 77 +++ .../OpenSamlImplementation.java | 255 +++++++++ .../authentication/Saml2Authentication.java | 72 +++ .../Saml2AuthenticationRequest.java | 64 +++ .../Saml2AuthenticationRequestFactory.java | 38 ++ .../Saml2AuthenticationToken.java | 133 +++++ ...oryRelyingPartyRegistrationRepository.java | 72 +++ .../RelyingPartyRegistration.java | 304 +++++++++++ .../RelyingPartyRegistrationRepository.java | 37 ++ .../service/servlet/filter/Saml2Utils.java | 134 +++++ .../Saml2WebSsoAuthenticationFilter.java | 89 +++ ...aml2WebSsoAuthenticationRequestFilter.java | 120 ++++ .../OpenSamlImplementationTests.java | 27 + .../src/test/resources/logback-test.xml | 14 + samples/boot/saml2login/README.adoc | 44 ++ ...ng-security-samples-boot-saml2login.gradle | 29 + .../samples/OpenSamlActionTestingSupport.java | 513 ++++++++++++++++++ .../samples/Saml2LoginIntegrationTests.java | 362 ++++++++++++ .../config/Saml2LoginBootConfiguration.java | 183 +++++++ .../config/X509CredentialsConverters.java | 60 ++ .../src/main/java/sample/IndexController.java | 35 ++ .../java/sample/Saml2LoginApplication.java | 32 ++ .../src/main/java/sample/SecurityConfig.java | 38 ++ .../src/main/resources/application.yml | 69 +++ .../src/main/resources/templates/index.html | 37 ++ ...rity-samples-javaconfig-saml2-login.gradle | 10 + ...sageSecurityWebApplicationInitializer.java | 34 ++ .../samples/config/SecurityConfig.java | 162 ++++++ .../samples/config/SecurityConfigTests.java | 31 ++ .../ui/DefaultLoginPageGeneratingFilter.java | 39 +- ...DefaultLoginPageGeneratingFilterTests.java | 39 +- 39 files changed, 4260 insertions(+), 19 deletions(-) create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java create mode 100644 saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/Saml2Exception.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/credentials/Saml2X509Credential.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProvider.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationRequestFactory.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlImplementation.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationRequest.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationRequestFactory.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationToken.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/InMemoryRelyingPartyRegistrationRepository.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistrationRepository.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2Utils.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationFilter.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationRequestFilter.java create mode 100644 saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlImplementationTests.java create mode 100644 saml2/saml2-service-provider/src/test/resources/logback-test.xml create mode 100644 samples/boot/saml2login/README.adoc create mode 100644 samples/boot/saml2login/spring-security-samples-boot-saml2login.gradle create mode 100644 samples/boot/saml2login/src/integration-test/java/org/springframework/security/samples/OpenSamlActionTestingSupport.java create mode 100644 samples/boot/saml2login/src/integration-test/java/org/springframework/security/samples/Saml2LoginIntegrationTests.java create mode 100644 samples/boot/saml2login/src/main/java/boot/saml2/config/Saml2LoginBootConfiguration.java create mode 100644 samples/boot/saml2login/src/main/java/boot/saml2/config/X509CredentialsConverters.java create mode 100644 samples/boot/saml2login/src/main/java/sample/IndexController.java create mode 100644 samples/boot/saml2login/src/main/java/sample/Saml2LoginApplication.java create mode 100644 samples/boot/saml2login/src/main/java/sample/SecurityConfig.java create mode 100644 samples/boot/saml2login/src/main/resources/application.yml create mode 100644 samples/boot/saml2login/src/main/resources/templates/index.html create mode 100644 samples/javaconfig/saml2login/spring-security-samples-javaconfig-saml2-login.gradle create mode 100644 samples/javaconfig/saml2login/src/main/java/org/springframework/security/samples/config/MessageSecurityWebApplicationInitializer.java create mode 100644 samples/javaconfig/saml2login/src/main/java/org/springframework/security/samples/config/SecurityConfig.java create mode 100644 samples/javaconfig/saml2login/src/test/java/org/springframework/security/samples/config/SecurityConfigTests.java diff --git a/config/spring-security-config.gradle b/config/spring-security-config.gradle index 718d717a99..afce9fe003 100644 --- a/config/spring-security-config.gradle +++ b/config/spring-security-config.gradle @@ -11,6 +11,7 @@ dependencies { optional project(':spring-security-ldap') optional project(':spring-security-messaging') + optional project(':spring-security-saml2-service-provider') optional project(':spring-security-oauth2-client') optional project(':spring-security-oauth2-jose') optional project(':spring-security-oauth2-resource-server') diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterComparator.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterComparator.java index 99eca41ed8..ab1fd36791 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterComparator.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterComparator.java @@ -73,6 +73,9 @@ final class FilterComparator implements Comparator, Serializable { filterToOrder.put( "org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter", order.next()); + filterToOrder.put( + "org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationRequestFilter", + order.next()); put(X509AuthenticationFilter.class, order.next()); put(AbstractPreAuthenticatedProcessingFilter.class, order.next()); filterToOrder.put("org.springframework.security.cas.web.CasAuthenticationFilter", @@ -80,6 +83,9 @@ final class FilterComparator implements Comparator, Serializable { filterToOrder.put( "org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter", order.next()); + filterToOrder.put( + "org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationFilter", + order.next()); put(UsernamePasswordAuthenticationFilter.class, order.next()); put(ConcurrentSessionFilter.class, order.next()); filterToOrder.put( diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java index 8cf1552664..b26ca0daa2 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java @@ -53,10 +53,13 @@ import org.springframework.security.config.annotation.web.configurers.oauth2.cli import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.config.annotation.web.configurers.openid.OpenIDLoginConfigurer; +import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LoginConfigurer; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.PortMapper; import org.springframework.security.web.PortMapperImpl; @@ -75,11 +78,11 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.filter.CorsFilter; import org.springframework.web.servlet.handler.HandlerMappingIntrospector; -import javax.servlet.Filter; -import javax.servlet.http.HttpServletRequest; import java.util.ArrayList; import java.util.List; import java.util.Map; +import javax.servlet.Filter; +import javax.servlet.http.HttpServletRequest; /** * A {@link HttpSecurity} is similar to Spring Security's XML <http> element in the @@ -1857,6 +1860,191 @@ public final class HttpSecurity extends return HttpSecurity.this; } + /** + * Configures authentication support using an SAML 2.0 Service Provider. + *
+ *
+ * + * The "authentication flow" is implemented using the Web Browser SSO Profile, using POST and REDIRECT bindings, + * as documented in the SAML V2.0 Core,Profiles and Bindings + * specifications. + *
+ *
+ * + * As a prerequisite to using this feature, is that you have a SAML v2.0 Identity Provider to provide an assertion. + * The representation of the Service Provider, the relying party, and the remote Identity Provider, the asserting party + * is contained within {@link RelyingPartyRegistration}. + *
+ *
+ * + * {@link RelyingPartyRegistration}(s) are composed within a + * {@link RelyingPartyRegistrationRepository}, + * which is required and must be registered with the {@link ApplicationContext} or + * configured via saml2Login().relyingPartyRegistrationRepository(..). + *
+ *
+ * + * The default configuration provides an auto-generated login page at "/login" and + * redirects to "/login?error" when an authentication error occurs. + * The login page will display each of the identity providers with a link + * that is capable of initiating the "authentication flow". + *
+ *
+ * + *

+ *

Example Configuration

+ * + * The following example shows the minimal configuration required, using SimpleSamlPhp as the Authentication Provider. + * + *
+	 * @Configuration
+	 * public class Saml2LoginConfig {
+	 *
+	 * 	@EnableWebSecurity
+	 * 	public static class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {
+	 * 		@Override
+	 * 		protected void configure(HttpSecurity http) throws Exception {
+	 * 			http
+	 * 				.authorizeRequests()
+	 * 					.anyRequest().authenticated()
+	 * 					.and()
+	 * 				  .saml2Login();
+	 *		}
+	 *	}
+	 *
+	 *	@Bean
+	 *	public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() {
+	 *		return new InMemoryRelyingPartyRegistrationRepository(this.getSaml2RelyingPartyRegistration());
+	 *	}
+	 *
+	 * 	private RelyingPartyRegistration getSaml2RelyingPartyRegistration() {
+	 * 		//remote IDP entity ID
+	 * 		String idpEntityId = "https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php";
+	 * 		//remote WebSSO Endpoint - Where to Send AuthNRequests to
+	 * 		String webSsoEndpoint = "https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SSOService.php";
+	 * 		//local registration ID
+	 * 		String registrationId = "simplesamlphp";
+	 * 		//local entity ID - autogenerated based on URL
+	 * 		String localEntityIdTemplate = "{baseUrl}/saml2/service-provider-metadata/{registrationId}";
+	 * 		//local signing (and decryption key)
+	 * 		Saml2X509Credential signingCredential = getSigningCredential();
+	 * 		//IDP certificate for verification of incoming messages
+	 * 		Saml2X509Credential idpVerificationCertificate = getVerificationCertificate();
+	 * 		return RelyingPartyRegistration.withRegistrationId(registrationId)
+	 *  * 				.remoteIdpEntityId(idpEntityId)
+	 *  * 				.idpWebSsoUrl(webSsoEndpoint)
+	 *  * 				.credential(signingCredential)
+	 *  * 				.credential(idpVerificationCertificate)
+	 *  * 				.localEntityIdTemplate(localEntityIdTemplate)
+	 *  * 				.build();
+	 *	}
+	 * }
+	 * 
+ * + *

+ * + * @since 5.2 + * @return the {@link Saml2LoginConfigurer} for further customizations + * @throws Exception + */ + public Saml2LoginConfigurer saml2Login() throws Exception { + return getOrApply(new Saml2LoginConfigurer<>()); + } + + /** + * Configures authentication support using an SAML 2.0 Service Provider. + *
+ *
+ * + * The "authentication flow" is implemented using the Web Browser SSO Profile, using POST and REDIRECT bindings, + * as documented in the SAML V2.0 Core,Profiles and Bindings + * specifications. + *
+ *
+ * + * As a prerequisite to using this feature, is that you have a SAML v2.0 Identity Provider to provide an assertion. + * The representation of the Service Provider, the relying party, and the remote Identity Provider, the asserting party + * is contained within {@link RelyingPartyRegistration}. + *
+ *
+ * + * {@link RelyingPartyRegistration}(s) are composed within a + * {@link RelyingPartyRegistrationRepository}, + * which is required and must be registered with the {@link ApplicationContext} or + * configured via saml2Login().relyingPartyRegistrationRepository(..). + *
+ *
+ * + * The default configuration provides an auto-generated login page at "/login" and + * redirects to "/login?error" when an authentication error occurs. + * The login page will display each of the identity providers with a link + * that is capable of initiating the "authentication flow". + *
+ *
+ * + *

+ *

Example Configuration

+ * + * The following example shows the minimal configuration required, using SimpleSamlPhp as the Authentication Provider. + * + *
+	 * @Configuration
+	 * public class Saml2LoginConfig {
+	 *
+	 * 	@EnableWebSecurity
+	 * 	public static class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {
+	 * 		@Override
+	 * 		protected void configure(HttpSecurity http) throws Exception {
+	 * 			http
+	 * 				.authorizeRequests()
+	 * 					.anyRequest().authenticated()
+	 * 					.and()
+	 * 				  .saml2Login(withDefaults());
+	 *		}
+	 *	}
+	 *
+	 *	@Bean
+	 *	public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() {
+	 *		return new InMemoryRelyingPartyRegistrationRepository(this.getSaml2RelyingPartyRegistration());
+	 *	}
+	 *
+	 * 	private RelyingPartyRegistration getSaml2RelyingPartyRegistration() {
+	 * 		//remote IDP entity ID
+	 * 		String idpEntityId = "https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php";
+	 * 		//remote WebSSO Endpoint - Where to Send AuthNRequests to
+	 * 		String webSsoEndpoint = "https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SSOService.php";
+	 * 		//local registration ID
+	 * 		String registrationId = "simplesamlphp";
+	 * 		//local entity ID - autogenerated based on URL
+	 * 		String localEntityIdTemplate = "{baseUrl}/saml2/service-provider-metadata/{registrationId}";
+	 * 		//local signing (and decryption key)
+	 * 		Saml2X509Credential signingCredential = getSigningCredential();
+	 * 		//IDP certificate for verification of incoming messages
+	 * 		Saml2X509Credential idpVerificationCertificate = getVerificationCertificate();
+	 * 		return RelyingPartyRegistration.withRegistrationId(registrationId)
+	 *  * 				.remoteIdpEntityId(idpEntityId)
+	 *  * 				.idpWebSsoUrl(webSsoEndpoint)
+	 *  * 				.credential(signingCredential)
+	 *  * 				.credential(idpVerificationCertificate)
+	 *  * 				.localEntityIdTemplate(localEntityIdTemplate)
+	 *  * 				.build();
+	 *	}
+	 * }
+	 * 
+ * + *

+ * + * @since 5.2 + * @param saml2LoginCustomizer the {@link Customizer} to provide more options for + * the {@link Saml2LoginConfigurer} + * @return the {@link HttpSecurity} for further customizations + * @throws Exception + */ + public HttpSecurity saml2Login(Customizer> saml2LoginCustomizer) throws Exception { + saml2LoginCustomizer.customize(getOrApply(new Saml2LoginConfigurer<>())); + return HttpSecurity.this; + } + /** * Configures authentication support using an OAuth 2.0 and/or OpenID Connect 1.0 Provider. *
diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java new file mode 100644 index 0000000000..2b87c64418 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java @@ -0,0 +1,314 @@ +/* + * Copyright 2002-2019 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.configurers.saml2; + +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.context.ApplicationContext; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.web.HttpSecurityBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.saml2.provider.service.authentication.OpenSamlAuthenticationProvider; +import org.springframework.security.saml2.provider.service.authentication.OpenSamlAuthenticationRequestFactory; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationRequestFactory; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationFilter; +import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationRequestFilter; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +import java.util.LinkedHashMap; +import java.util.Map; +import javax.servlet.Filter; + +import static org.springframework.util.StringUtils.hasText; + +/** + * An {@link AbstractHttpConfigurer} for SAML 2.0 Login, + * which leverages the SAML 2.0 Web Browser Single Sign On (WebSSO) Flow. + * + *

+ * SAML 2.0 Login provides an application with the capability to have users log in + * by using their existing account at an SAML 2.0 Identity Provider. + * + *

+ * Defaults are provided for all configuration options with the only required configuration + * being {@link #relyingPartyRegistrationRepository(RelyingPartyRegistrationRepository)} . + * Alternatively, a {@link RelyingPartyRegistrationRepository} {@code @Bean} may be registered instead. + * + *

Security Filters

+ * + * The following {@code Filter}'s are populated: + * + *
    + *
  • {@link Saml2WebSsoAuthenticationFilter}
  • + *
  • {@link Saml2WebSsoAuthenticationRequestFilter}
  • + *
+ * + *

Shared Objects Created

+ * + * The following shared objects are populated: + * + *
    + *
  • {@link RelyingPartyRegistrationRepository} (required)
  • + *
  • {@link Saml2AuthenticationRequestFactory} (optional)
  • + *
+ * + *

Shared Objects Used

+ * + * The following shared objects are used: + * + *
    + *
  • {@link RelyingPartyRegistrationRepository} (required)
  • + *
  • {@link Saml2AuthenticationRequestFactory} (optional)
  • + *
  • {@link DefaultLoginPageGeneratingFilter} - if {@link #loginPage(String)} is not configured + * and {@code DefaultLoginPageGeneratingFilter} is available, than a default login page will be made available
  • + *
+ * + * @since 5.2 + * @see HttpSecurity#saml2Login() + * @see Saml2WebSsoAuthenticationFilter + * @see Saml2WebSsoAuthenticationRequestFilter + * @see RelyingPartyRegistrationRepository + * @see AbstractAuthenticationFilterConfigurer + */ +public final class Saml2LoginConfigurer> extends + AbstractAuthenticationFilterConfigurer, Saml2WebSsoAuthenticationFilter> { + + private String loginPage; + + private String loginProcessingUrl = Saml2WebSsoAuthenticationFilter.DEFAULT_FILTER_PROCESSES_URI; + + private AuthenticationRequestEndpointConfig authenticationRequestEndpoint = new AuthenticationRequestEndpointConfig(); + + private RelyingPartyRegistrationRepository relyingPartyRegistrationRepository; + + /** + * Sets the {@code RelyingPartyRegistrationRepository} of relying parties, each party representing a + * service provider, SP and this host, and identity provider, IDP pair that communicate with each other. + * @param repo the repository of relying parties + * @return the {@link Saml2LoginConfigurer} for further configuration + */ + public Saml2LoginConfigurer relyingPartyRegistrationRepository(RelyingPartyRegistrationRepository repo) { + this.relyingPartyRegistrationRepository = repo; + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public Saml2LoginConfigurer loginPage(String loginPage) { + Assert.hasText(loginPage, "loginPage cannot be empty"); + this.loginPage = loginPage; + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public Saml2LoginConfigurer loginProcessingUrl(String loginProcessingUrl) { + Assert.hasText(loginProcessingUrl, "loginProcessingUrl cannot be empty"); + Assert.state(loginProcessingUrl.contains("{registrationId}"), "{registrationId} path variable is required"); + this.loginProcessingUrl = loginProcessingUrl; + return this; + } + + /** + * {@inheritDoc} + */ + @Override + protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) { + return new AntPathRequestMatcher(loginProcessingUrl); + } + + /** + * {@inheritDoc} + * + * Initializes this filter chain for SAML 2 Login. + * The following actions are taken: + *
    + *
  • The WebSSO endpoint has CSRF disabled, typically {@code /login/saml2/sso}
  • + *
  • A {@link Saml2WebSsoAuthenticationFilter is configured}
  • + *
  • The {@code loginProcessingUrl} is set
  • + *
  • A custom login page is configured, or
  • + *
  • A default login page with all SAML 2.0 Identity Providers is configured
  • + *
  • An {@link OpenSamlAuthenticationProvider} is configured
  • + *
+ */ + @Override + public void init(B http) throws Exception { + registerDefaultCsrfOverride(http); + if (this.relyingPartyRegistrationRepository == null) { + this.relyingPartyRegistrationRepository = getSharedOrBean(http, RelyingPartyRegistrationRepository.class); + } + + Saml2WebSsoAuthenticationFilter webSsoFilter = new Saml2WebSsoAuthenticationFilter(this.relyingPartyRegistrationRepository); + setAuthenticationFilter(webSsoFilter); + super.loginProcessingUrl(this.loginProcessingUrl); + + if (hasText(this.loginPage)) { + // Set custom login page + super.loginPage(this.loginPage); + super.init(http); + } else { + final Map providerUrlMap = + getIdentityProviderUrlMap( + this.authenticationRequestEndpoint.filterProcessingUrl, + this.relyingPartyRegistrationRepository + ); + + boolean singleProvider = providerUrlMap.size() == 1; + if (singleProvider) { + // Setup auto-redirect to provider login page + // when only 1 IDP is configured + this.updateAuthenticationDefaults(); + this.updateAccessDefaults(http); + + String loginUrl = providerUrlMap.entrySet().iterator().next().getKey(); + final LoginUrlAuthenticationEntryPoint entryPoint = new LoginUrlAuthenticationEntryPoint(loginUrl); + registerAuthenticationEntryPoint(http, entryPoint); + } + else { + super.init(http); + } + } + http.authenticationProvider(getAuthenticationProvider()); + this.initDefaultLoginFilter(http); + } + + /** + * {@inheritDoc} + * + * During the {@code configure} phase, a {@link Saml2WebSsoAuthenticationRequestFilter} + * is added to handle SAML 2.0 AuthNRequest redirects + */ + @Override + public void configure(B http) throws Exception { + http.addFilter(this.authenticationRequestEndpoint.build(http)); + super.configure(http); + } + + private AuthenticationProvider getAuthenticationProvider() { + AuthenticationProvider provider = new OpenSamlAuthenticationProvider(); + return postProcess(provider); + } + + private void registerDefaultCsrfOverride(B http) { + CsrfConfigurer csrf = http.getConfigurer(CsrfConfigurer.class); + if (csrf == null) { + return; + } + + csrf.ignoringRequestMatchers( + new AntPathRequestMatcher(loginProcessingUrl) + ); + } + + private void initDefaultLoginFilter(B http) { + DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http.getSharedObject(DefaultLoginPageGeneratingFilter.class); + if (loginPageGeneratingFilter == null || this.isCustomLoginPage()) { + return; + } + + loginPageGeneratingFilter.setSaml2LoginEnabled(true); + loginPageGeneratingFilter.setSaml2AuthenticationUrlToProviderName( + this.getIdentityProviderUrlMap( + this.authenticationRequestEndpoint.filterProcessingUrl, + this.relyingPartyRegistrationRepository + ) + ); + loginPageGeneratingFilter.setLoginPageUrl(this.getLoginPage()); + loginPageGeneratingFilter.setFailureUrl(this.getFailureUrl()); + } + + @SuppressWarnings("unchecked") + private Map getIdentityProviderUrlMap( + String authRequestPrefixUrl, + RelyingPartyRegistrationRepository idpRepo + ) { + Map idps = new LinkedHashMap<>(); + if (idpRepo instanceof Iterable) { + Iterable repo = (Iterable) idpRepo; + repo.forEach( + p -> + idps.put( + authRequestPrefixUrl.replace("{registrationId}", p.getRegistrationId()), + p.getRegistrationId() + ) + ); + } + return idps; + } + + private C getSharedOrBean(B http, Class clazz) { + C shared = http.getSharedObject(clazz); + if (shared != null) { + return shared; + } + return getBeanOrNull(http, clazz); + } + + private C getBeanOrNull(B http, Class clazz) { + ApplicationContext context = http.getSharedObject(ApplicationContext.class); + if (context == null) { + return null; + } + try { + return context.getBean(clazz); + } catch (NoSuchBeanDefinitionException e) {} + return null; + } + + private void setSharedObject(B http, Class clazz, C object) { + if (http.getSharedObject(clazz) == null) { + http.setSharedObject(clazz, object); + } + } + + private final class AuthenticationRequestEndpointConfig { + private String filterProcessingUrl = "/saml2/authenticate/{registrationId}"; + private AuthenticationRequestEndpointConfig() { + } + + private Filter build(B http) { + Saml2AuthenticationRequestFactory authenticationRequestResolver = getResolver(http); + + Saml2WebSsoAuthenticationRequestFilter authenticationRequestFilter = + new Saml2WebSsoAuthenticationRequestFilter(Saml2LoginConfigurer.this.relyingPartyRegistrationRepository); + authenticationRequestFilter.setAuthenticationRequestFactory(authenticationRequestResolver); + return authenticationRequestFilter; + } + + private Saml2AuthenticationRequestFactory getResolver(B http) { + Saml2AuthenticationRequestFactory resolver = getSharedOrBean(http, Saml2AuthenticationRequestFactory.class); + if (resolver == null ) { + resolver = new OpenSamlAuthenticationRequestFactory(); + } + return resolver; + } + } + + +} diff --git a/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle b/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle new file mode 100644 index 0000000000..780c129335 --- /dev/null +++ b/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle @@ -0,0 +1,12 @@ +apply plugin: 'io.spring.convention.spring-module' + +dependencies { + compile project(':spring-security-core') + compile project(':spring-security-web') + + compile("org.opensaml:opensaml-core:3.3.0") + compile("org.opensaml:opensaml-saml-api:3.3.0") + compile("org.opensaml:opensaml-saml-impl:3.3.0") + + provided 'javax.servlet:javax.servlet-api' +} 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 new file mode 100644 index 0000000000..dc4e6bb770 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/Saml2Exception.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2019 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.saml2; + +/** + * @since 5.2 + */ +public class Saml2Exception extends RuntimeException { + + public Saml2Exception(String message) { + super(message); + } + + public Saml2Exception(String message, Throwable cause) { + super(message, cause); + } + + public Saml2Exception(Throwable cause) { + super(cause); + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/credentials/Saml2X509Credential.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/credentials/Saml2X509Credential.java new file mode 100644 index 0000000000..6b570010cb --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/credentials/Saml2X509Credential.java @@ -0,0 +1,162 @@ +/* + * Copyright 2002-2019 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.saml2.credentials; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.LinkedHashSet; +import java.util.Set; + +import static java.util.Arrays.asList; +import static org.springframework.util.Assert.notEmpty; +import static org.springframework.util.Assert.notNull; +import static org.springframework.util.Assert.state; + +/** + * Saml2X509Credential is meant to hold an X509 certificate, or an X509 certificate and a + * private key. Per: + * https://www.oasis-open.org/committees/download.php/8958/sstc-saml-implementation-guidelines-draft-01.pdf + * Line: 584, Section 4.3 Credentials Used for both signing, signature verification and encryption/decryption + * + * @since 5.2 + */ +public class Saml2X509Credential { + public enum Saml2X509CredentialType { + VERIFICATION, + ENCRYPTION, + SIGNING, + DECRYPTION, + } + + private final PrivateKey privateKey; + private final X509Certificate certificate; + private final Set credentialTypes; + + /** + * Creates a Saml2X509Credentials representing Identity Provider credentials for + * verification, encryption or both. + * @param certificate an IDP X509Certificate, cannot be null + * @param types credential types, must be one of {@link Saml2X509CredentialType#VERIFICATION} or + * {@link Saml2X509CredentialType#ENCRYPTION} or both. + */ + public Saml2X509Credential(X509Certificate certificate, Saml2X509CredentialType... types) { + this(null, false, certificate, types); + validateUsages(types, Saml2X509CredentialType.VERIFICATION, Saml2X509CredentialType.ENCRYPTION); + } + + /** + * Creates a Saml2X509Credentials representing Service Provider credentials for + * signing, decryption or both. + * @param privateKey a private key used for signing or decryption, cannot be null + * @param certificate an SP X509Certificate shared with identity providers, cannot be null + * @param types credential types, must be one of {@link Saml2X509CredentialType#SIGNING} or + * {@link Saml2X509CredentialType#DECRYPTION} or both. + */ + public Saml2X509Credential(PrivateKey privateKey, X509Certificate certificate, Saml2X509CredentialType... types) { + this(privateKey, true, certificate, types); + validateUsages(types, Saml2X509CredentialType.SIGNING, Saml2X509CredentialType.DECRYPTION); + } + + private Saml2X509Credential( + PrivateKey privateKey, + boolean keyRequired, + X509Certificate certificate, + Saml2X509CredentialType... types) { + notNull(certificate, "certificate cannot be null"); + notEmpty(types, "credentials types cannot be empty"); + if (keyRequired) { + notNull(privateKey, "privateKey cannot be null"); + } + this.privateKey = privateKey; + this.certificate = certificate; + this.credentialTypes = new LinkedHashSet<>(asList(types)); + } + + + /** + * Returns true if the credential has a private key and can be used for signing, the types will contain + * {@link Saml2X509CredentialType#SIGNING}. + * @return true if the credential is a {@link Saml2X509CredentialType#SIGNING} type + */ + public boolean isSigningCredential() { + return getCredentialTypes().contains(Saml2X509CredentialType.SIGNING); + } + + /** + * Returns true if the credential has a private key and can be used for decryption, the types will contain + * {@link Saml2X509CredentialType#DECRYPTION}. + * @return true if the credential is a {@link Saml2X509CredentialType#DECRYPTION} type + */ + public boolean isDecryptionCredential() { + return getCredentialTypes().contains(Saml2X509CredentialType.DECRYPTION); + } + + /** + * Returns true if the credential has a certificate and can be used for signature verification, the types will contain + * {@link Saml2X509CredentialType#VERIFICATION}. + * @return true if the credential is a {@link Saml2X509CredentialType#VERIFICATION} type + */ + public boolean isSignatureVerficationCredential() { + return getCredentialTypes().contains(Saml2X509CredentialType.VERIFICATION); + } + + /** + * Returns true if the credential has a certificate and can be used for signature verification, the types will contain + * {@link Saml2X509CredentialType#VERIFICATION}. + * @return true if the credential is a {@link Saml2X509CredentialType#VERIFICATION} type + */ + public boolean isEncryptionCredential() { + return getCredentialTypes().contains(Saml2X509CredentialType.ENCRYPTION); + } + + /** + * Returns the credential types for this credential. + * @return a set of credential types/usages that this credential can be used for + */ + protected Set getCredentialTypes() { + return this.credentialTypes; + } + + /** + * Returns the private key, or null if this credential type doesn't require one. + * @return the private key, or null + * @see {@link #Saml2X509Credential(PrivateKey, X509Certificate, Saml2X509CredentialType...)} + */ + public PrivateKey getPrivateKey() { + return this.privateKey; + } + + /** + * Returns the X509 certificate for ths credential. Cannot be null + * @return the X509 certificate + */ + public X509Certificate getCertificate() { + return this.certificate; + } + + private void validateUsages(Saml2X509CredentialType[] usages, Saml2X509CredentialType... validUsages) { + for (Saml2X509CredentialType usage : usages) { + boolean valid = false; + for (Saml2X509CredentialType validUsage : validUsages) { + if (usage == validUsage) { + valid = true; + break; + } + } + state(valid, () -> usage +" is not a valid usage for this credential"); + } + } +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProvider.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProvider.java new file mode 100644 index 0000000000..396d76dbb8 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProvider.java @@ -0,0 +1,403 @@ +/* + * Copyright 2002-2019 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.saml2.provider.service.authentication; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.saml2.Saml2Exception; +import org.springframework.security.saml2.credentials.Saml2X509Credential; +import org.springframework.util.Assert; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.opensaml.saml.common.SignableSAMLObject; +import org.opensaml.saml.common.assertion.AssertionValidationException; +import org.opensaml.saml.common.assertion.ValidationContext; +import org.opensaml.saml.common.assertion.ValidationResult; +import org.opensaml.saml.saml2.assertion.ConditionValidator; +import org.opensaml.saml.saml2.assertion.SAML20AssertionValidator; +import org.opensaml.saml.saml2.assertion.SAML2AssertionValidationParameters; +import org.opensaml.saml.saml2.assertion.StatementValidator; +import org.opensaml.saml.saml2.assertion.SubjectConfirmationValidator; +import org.opensaml.saml.saml2.assertion.impl.AudienceRestrictionConditionValidator; +import org.opensaml.saml.saml2.assertion.impl.BearerSubjectConfirmationValidator; +import org.opensaml.saml.saml2.core.Assertion; +import org.opensaml.saml.saml2.core.EncryptedAssertion; +import org.opensaml.saml.saml2.core.EncryptedID; +import org.opensaml.saml.saml2.core.NameID; +import org.opensaml.saml.saml2.core.Response; +import org.opensaml.saml.saml2.core.Subject; +import org.opensaml.saml.saml2.encryption.Decrypter; +import org.opensaml.saml.security.impl.SAMLSignatureProfileValidator; +import org.opensaml.security.credential.Credential; +import org.opensaml.security.credential.CredentialResolver; +import org.opensaml.security.credential.CredentialSupport; +import org.opensaml.security.credential.impl.CollectionCredentialResolver; +import org.opensaml.xmlsec.config.DefaultSecurityConfigurationBootstrap; +import org.opensaml.xmlsec.encryption.support.DecryptionException; +import org.opensaml.xmlsec.keyinfo.KeyInfoCredentialResolver; +import org.opensaml.xmlsec.keyinfo.impl.StaticKeyInfoCredentialResolver; +import org.opensaml.xmlsec.signature.support.SignatureException; +import org.opensaml.xmlsec.signature.support.SignaturePrevalidator; +import org.opensaml.xmlsec.signature.support.SignatureTrustEngine; +import org.opensaml.xmlsec.signature.support.SignatureValidator; +import org.opensaml.xmlsec.signature.support.impl.ExplicitKeySignatureTrustEngine; + +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static java.lang.String.format; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; +import static org.springframework.util.Assert.notNull; +import static org.springframework.util.StringUtils.hasText; + +/** + * @since 5.2 + */ +public final class OpenSamlAuthenticationProvider implements AuthenticationProvider { + + private static Log logger = LogFactory.getLog(OpenSamlAuthenticationProvider.class); + + private final OpenSamlImplementation saml = OpenSamlImplementation.getInstance(); + private Converter> authoritiesExtractor = (a -> singletonList(new SimpleGrantedAuthority("ROLE_USER"))); + private GrantedAuthoritiesMapper authoritiesMapper = (a -> a); + private Duration responseTimeValidationSkew = Duration.ofMinutes(5); + + /** + * Sets the {@link Converter} used for extracting assertion attributes that + * can be mapped to authorities. + * @param authoritiesExtractor the {@code Converter} used for mapping the + * assertion attributes to authorities + */ + public void setAuthoritiesExtractor(Converter> authoritiesExtractor) { + Assert.notNull(authoritiesExtractor, "authoritiesExtractor cannot be null"); + this.authoritiesExtractor = authoritiesExtractor; + } + + /** + * Sets the {@link GrantedAuthoritiesMapper} used for mapping assertion attributes + * to a new set of authorities which will be associated to the {@link Saml2Authentication}. + * Note: This implementation is only retrieving + * @param authoritiesMapper the {@link GrantedAuthoritiesMapper} used for mapping the user's authorities + */ + public void setAuthoritiesMapper(GrantedAuthoritiesMapper authoritiesMapper) { + notNull(authoritiesMapper, "authoritiesMapper cannot be null"); + this.authoritiesMapper = authoritiesMapper; + } + + /** + * Sets the duration for how much time skew an assertion may tolerate during + * timestamp, NotOnOrBefore and NotOnOrAfter, validation. + * @param responseTimeValidationSkew duration for skew tolerance + */ + public void setResponseTimeValidationSkew(Duration responseTimeValidationSkew) { + this.responseTimeValidationSkew = responseTimeValidationSkew; + } + + /** + * @param authentication the authentication request object, must be of type + * {@link Saml2AuthenticationToken} + * + * @return {@link Saml2Authentication} if the assertion is valid + * @throws AuthenticationException if a validation exception occurs + */ + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + try { + Saml2AuthenticationToken token = (Saml2AuthenticationToken) authentication; + String xml = token.getSaml2Response(); + Response samlResponse = getSaml2Response(xml); + + Assertion assertion = validateSaml2Response(token, token.getRecipientUri(), samlResponse); + final String username = getUsername(token, assertion); + if (username == null) { + throw new UsernameNotFoundException("Assertion [" + + assertion.getID() + + "] is missing a user identifier"); + } + return new Saml2Authentication( + () -> username, token.getSaml2Response(), + this.authoritiesMapper.mapAuthorities(getAssertionAuthorities(assertion)) + ); + }catch (Saml2Exception | IllegalArgumentException e) { + throw new AuthenticationServiceException(e.getMessage(), e); + } + } + + /** + * {@inheritDoc} + */ + @Override + public boolean supports(Class authentication) { + return authentication != null && Saml2AuthenticationToken.class.isAssignableFrom(authentication); + } + + private Collection getAssertionAuthorities(Assertion assertion) { + return this.authoritiesExtractor.convert(assertion); + } + + private String getUsername(Saml2AuthenticationToken token, Assertion assertion) { + final Subject subject = assertion.getSubject(); + if (subject == null) { + return null; + } + if (subject.getNameID() != null) { + return subject.getNameID().getValue(); + } + if (subject.getEncryptedID() != null) { + NameID nameId = decrypt(token, subject.getEncryptedID()); + return nameId.getValue(); + } + return null; + } + + private Assertion validateSaml2Response(Saml2AuthenticationToken token, + String recipient, + Response samlResponse) throws AuthenticationException { + if (hasText(samlResponse.getDestination()) && !recipient.equals(samlResponse.getDestination())) { + throw new Saml2Exception("Invalid SAML response destination: " + samlResponse.getDestination()); + } + + final String issuer = samlResponse.getIssuer().getValue(); + if (logger.isDebugEnabled()) { + logger.debug("Processing SAML response from " + issuer); + } + if (token == null) { + throw new Saml2Exception(format("SAML 2 Provider for %s was not found.", issuer)); + } + boolean responseSigned = hasValidSignature(samlResponse, token); + for (Assertion a : samlResponse.getAssertions()) { + if (logger.isDebugEnabled()) { + logger.debug("Checking plain assertion validity " + a); + } + if (isValidAssertion(recipient, a, token, !responseSigned)) { + if (logger.isDebugEnabled()) { + logger.debug("Found valid assertion. Skipping potential others."); + } + return a; + } + } + for (EncryptedAssertion ea : samlResponse.getEncryptedAssertions()) { + if (logger.isDebugEnabled()) { + logger.debug("Checking encrypted assertion validity " + ea); + } + + Assertion a = decrypt(token, ea); + if (isValidAssertion(recipient, a, token, false)) { + if (logger.isDebugEnabled()) { + logger.debug("Found valid encrypted assertion. Skipping potential others."); + } + return a; + } + } + throw new InsufficientAuthenticationException("Unable to find a valid assertion"); + } + + private boolean hasValidSignature(SignableSAMLObject samlResponse, Saml2AuthenticationToken token) { + if (!samlResponse.isSigned()) { + return false; + } + + final List verificationKeys = getVerificationKeys(token); + if (verificationKeys.isEmpty()) { + return false; + } + + for (X509Certificate key : verificationKeys) { + final Credential credential = getVerificationCredential(key); + try { + SignatureValidator.validate(samlResponse.getSignature(), credential); + return true; + } + catch (SignatureException ignored) { + logger.debug("Signature validation failed", ignored); + } + } + return false; + } + + private boolean isValidAssertion(String recipient, Assertion a, Saml2AuthenticationToken token, boolean signatureRequired) { + final SAML20AssertionValidator validator = getAssertionValidator(token); + Map validationParams = new HashMap<>(); + validationParams.put(SAML2AssertionValidationParameters.SIGNATURE_REQUIRED, false); + validationParams.put( + SAML2AssertionValidationParameters.CLOCK_SKEW, + this.responseTimeValidationSkew + ); + validationParams.put( + SAML2AssertionValidationParameters.COND_VALID_AUDIENCES, + singleton(token.getLocalSpEntityId()) + ); + if (hasText(recipient)) { + validationParams.put(SAML2AssertionValidationParameters.SC_VALID_RECIPIENTS, singleton(recipient)); + } + + if (signatureRequired && !hasValidSignature(a, token)) { + if (logger.isDebugEnabled()) { + logger.debug(format("Assertion [%s] does not a valid signature.", a.getID())); + } + return false; + } + a.setSignature(null); + + // validation for recipient + ValidationContext vctx = new ValidationContext(validationParams); + try { + final ValidationResult result = validator.validate(a, vctx); + final boolean valid = result.equals(ValidationResult.VALID); + if (!valid) { + if (logger.isDebugEnabled()) { + logger.debug(format("Failed to validate assertion from %s with user %s", token.getIdpEntityId(), + getUsername(token, a) + )); + } + } + return valid; + } + catch (AssertionValidationException e) { + if (logger.isDebugEnabled()) { + logger.debug("Failed to validate assertion:", e); + } + return false; + } + + } + + private Response getSaml2Response(String xml) throws Saml2Exception, AuthenticationException { + final Object result = this.saml.resolve(xml); + if (result == null) { + throw new AuthenticationCredentialsNotFoundException("SAMLResponse returned null object"); + } + else if (result instanceof Response) { + return (Response) result; + } + throw new IllegalArgumentException("Invalid response class:"+result.getClass().getName()); + } + + private SAML20AssertionValidator getAssertionValidator(Saml2AuthenticationToken provider) { + List conditions = Collections.singletonList(new AudienceRestrictionConditionValidator()); + final BearerSubjectConfirmationValidator subjectConfirmationValidator = + new BearerSubjectConfirmationValidator(); + + List subjects = Collections.singletonList(subjectConfirmationValidator); + List statements = Collections.emptyList(); + + Set credentials = new HashSet<>(); + for (X509Certificate key : getVerificationKeys(provider)) { + final Credential cred = getVerificationCredential(key); + credentials.add(cred); + } + CredentialResolver credentialsResolver = new CollectionCredentialResolver(credentials); + SignatureTrustEngine signatureTrustEngine = new ExplicitKeySignatureTrustEngine( + credentialsResolver, + DefaultSecurityConfigurationBootstrap.buildBasicInlineKeyInfoCredentialResolver() + ); + SignaturePrevalidator signaturePrevalidator = new SAMLSignatureProfileValidator(); + return new SAML20AssertionValidator( + conditions, + subjects, + statements, + signatureTrustEngine, + signaturePrevalidator + ); + } + + private Credential getVerificationCredential(X509Certificate certificate) { + return CredentialSupport.getSimpleCredential(certificate, null); + } + + private Decrypter getDecrypter(Saml2X509Credential key) { + Credential credential = CredentialSupport.getSimpleCredential(key.getCertificate(), key.getPrivateKey()); + KeyInfoCredentialResolver resolver = new StaticKeyInfoCredentialResolver(credential); + Decrypter decrypter = new Decrypter(null, resolver, this.saml.getEncryptedKeyResolver()); + decrypter.setRootInNewDocument(true); + return decrypter; + } + + private Assertion decrypt(Saml2AuthenticationToken token, EncryptedAssertion assertion) { + Saml2Exception last = null; + List decryptionCredentials = getDecryptionCredentials(token); + if (decryptionCredentials.isEmpty()) { + throw new Saml2Exception("No valid decryption credentials found."); + } + for (Saml2X509Credential key : decryptionCredentials) { + final Decrypter decrypter = getDecrypter(key); + try { + return decrypter.decrypt(assertion); + } + catch (DecryptionException e) { + last = new Saml2Exception(e); + } + } + throw last; + } + + private NameID decrypt(Saml2AuthenticationToken token, EncryptedID assertion) { + Saml2Exception last = null; + List decryptionCredentials = getDecryptionCredentials(token); + if (decryptionCredentials.isEmpty()) { + throw new Saml2Exception("No valid decryption credentials found."); + } + for (Saml2X509Credential key : decryptionCredentials) { + final Decrypter decrypter = getDecrypter(key); + try { + return (NameID) decrypter.decrypt(assertion); + } + catch (DecryptionException e) { + last = new Saml2Exception(e); + } + } + throw last; + } + + private List getDecryptionCredentials(Saml2AuthenticationToken token) { + List result = new LinkedList<>(); + for (Saml2X509Credential c : token.getX509Credentials()) { + if (c.isDecryptionCredential()) { + result.add(c); + } + } + return result; + } + + private List getVerificationKeys(Saml2AuthenticationToken token) { + List result = new LinkedList<>(); + for (Saml2X509Credential c : token.getX509Credentials()) { + if (c.isSignatureVerficationCredential()) { + result.add(c.getCertificate()); + } + } + return result; + } +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationRequestFactory.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationRequestFactory.java new file mode 100644 index 0000000000..6b5d68f32b --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationRequestFactory.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-2019 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.saml2.provider.service.authentication; + +import org.springframework.util.Assert; + +import org.joda.time.DateTime; +import org.opensaml.core.xml.io.MarshallingException; +import org.opensaml.saml.saml2.core.AuthnRequest; +import org.opensaml.saml.saml2.core.Issuer; +import org.opensaml.security.SecurityException; +import org.opensaml.xmlsec.signature.support.SignatureException; + +import java.time.Clock; +import java.time.Instant; +import java.util.UUID; + +/** + * @since 5.2 + */ +public class OpenSamlAuthenticationRequestFactory implements Saml2AuthenticationRequestFactory { + private Clock clock = Clock.systemUTC(); + private final OpenSamlImplementation saml = OpenSamlImplementation.getInstance(); + + /** + * {@inheritDoc} + */ + @Override + public String createAuthenticationRequest(Saml2AuthenticationRequest request) { + AuthnRequest auth = this.saml.buildSAMLObject(AuthnRequest.class); + auth.setID("ARQ" + UUID.randomUUID().toString().substring(1)); + auth.setIssueInstant(new DateTime(this.clock.millis())); + auth.setForceAuthn(Boolean.FALSE); + auth.setIsPassive(Boolean.FALSE); + auth.setProtocolBinding("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"); + Issuer issuer = this.saml.buildSAMLObject(Issuer.class); + issuer.setValue(request.getLocalSpEntityId()); + auth.setIssuer(issuer); + auth.setDestination(request.getWebSsoUri()); + try { + return this.saml.toXml( + auth, + request.getCredentials(), + request.getLocalSpEntityId() + ); + } + catch (MarshallingException | SignatureException | SecurityException e) { + throw new IllegalStateException(e); + } + } + + /** + * ' + * Use this {@link Clock} with {@link Instant#now()} for generating + * timestamps + * + * @param clock + */ + public void setClock(Clock clock) { + Assert.notNull(clock, "clock cannot be null"); + this.clock = clock; + } +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlImplementation.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlImplementation.java new file mode 100644 index 0000000000..574c7362ab --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlImplementation.java @@ -0,0 +1,255 @@ +/* + * Copyright 2002-2019 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.saml2.provider.service.authentication; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.xml.XMLConstants; +import javax.xml.namespace.QName; + +import org.springframework.security.saml2.Saml2Exception; +import org.springframework.security.saml2.credentials.Saml2X509Credential; + +import net.shibboleth.utilities.java.support.component.ComponentInitializationException; +import net.shibboleth.utilities.java.support.xml.BasicParserPool; +import net.shibboleth.utilities.java.support.xml.SerializeSupport; +import net.shibboleth.utilities.java.support.xml.XMLParserException; +import org.opensaml.core.config.ConfigurationService; +import org.opensaml.core.config.InitializationException; +import org.opensaml.core.config.InitializationService; +import org.opensaml.core.xml.XMLObject; +import org.opensaml.core.xml.config.XMLObjectProviderRegistry; +import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; +import org.opensaml.core.xml.io.MarshallerFactory; +import org.opensaml.core.xml.io.MarshallingException; +import org.opensaml.core.xml.io.UnmarshallerFactory; +import org.opensaml.core.xml.io.UnmarshallingException; +import org.opensaml.saml.common.SignableSAMLObject; +import org.opensaml.saml.saml2.encryption.EncryptedElementTypeEncryptedKeyResolver; +import org.opensaml.security.SecurityException; +import org.opensaml.security.credential.BasicCredential; +import org.opensaml.security.credential.Credential; +import org.opensaml.security.credential.CredentialSupport; +import org.opensaml.security.credential.UsageType; +import org.opensaml.security.x509.BasicX509Credential; +import org.opensaml.xmlsec.SignatureSigningParameters; +import org.opensaml.xmlsec.encryption.support.ChainingEncryptedKeyResolver; +import org.opensaml.xmlsec.encryption.support.EncryptedKeyResolver; +import org.opensaml.xmlsec.encryption.support.InlineEncryptedKeyResolver; +import org.opensaml.xmlsec.encryption.support.SimpleRetrievalMethodEncryptedKeyResolver; +import org.opensaml.xmlsec.signature.support.SignatureConstants; +import org.opensaml.xmlsec.signature.support.SignatureException; +import org.opensaml.xmlsec.signature.support.SignatureSupport; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; +import static java.util.Arrays.asList; +import static org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport.getBuilderFactory; + +/** + * @since 5.2 + */ +final class OpenSamlImplementation { + private static OpenSamlImplementation instance = new OpenSamlImplementation(); + + private final BasicParserPool parserPool = new BasicParserPool(); + private final EncryptedKeyResolver encryptedKeyResolver = new ChainingEncryptedKeyResolver( + asList( + new InlineEncryptedKeyResolver(), + new EncryptedElementTypeEncryptedKeyResolver(), + new SimpleRetrievalMethodEncryptedKeyResolver() + ) + ); + + private OpenSamlImplementation() { + bootstrap(); + } + + /* + * ============================================================== + * PRIVATE METHODS + * ============================================================== + */ + private void bootstrap() { + // configure default values + // maxPoolSize = 5; + this.parserPool.setMaxPoolSize(50); + // coalescing = true; + this.parserPool.setCoalescing(true); + // expandEntityReferences = false; + this.parserPool.setExpandEntityReferences(false); + // ignoreComments = true; + this.parserPool.setIgnoreComments(true); + // ignoreElementContentWhitespace = true; + this.parserPool.setIgnoreElementContentWhitespace(true); + // namespaceAware = true; + this.parserPool.setNamespaceAware(true); + // schema = null; + this.parserPool.setSchema(null); + // dtdValidating = false; + this.parserPool.setDTDValidating(false); + // xincludeAware = false; + this.parserPool.setXincludeAware(false); + + Map builderAttributes = new HashMap<>(); + this.parserPool.setBuilderAttributes(builderAttributes); + + Map parserBuilderFeatures = new HashMap<>(); + parserBuilderFeatures.put("http://apache.org/xml/features/disallow-doctype-decl", TRUE); + parserBuilderFeatures.put(XMLConstants.FEATURE_SECURE_PROCESSING, TRUE); + parserBuilderFeatures.put("http://xml.org/sax/features/external-general-entities", FALSE); + parserBuilderFeatures.put("http://apache.org/xml/features/validation/schema/normalized-value", FALSE); + parserBuilderFeatures.put("http://xml.org/sax/features/external-parameter-entities", FALSE); + parserBuilderFeatures.put("http://apache.org/xml/features/dom/defer-node-expansion", FALSE); + this.parserPool.setBuilderFeatures(parserBuilderFeatures); + + try { + this.parserPool.initialize(); + } + catch (ComponentInitializationException x) { + throw new Saml2Exception("Unable to initialize OpenSaml v3 ParserPool", x); + } + + try { + InitializationService.initialize(); + } + catch (InitializationException e) { + throw new Saml2Exception("Unable to initialize OpenSaml v3", e); + } + + XMLObjectProviderRegistry registry; + synchronized (ConfigurationService.class) { + registry = ConfigurationService.get(XMLObjectProviderRegistry.class); + if (registry == null) { + registry = new XMLObjectProviderRegistry(); + ConfigurationService.register(XMLObjectProviderRegistry.class, registry); + } + } + + registry.setParserPool(this.parserPool); + } + + /* + * ============================================================== + * PUBLIC METHODS + * ============================================================== + */ + static OpenSamlImplementation getInstance() { + return instance; + } + + EncryptedKeyResolver getEncryptedKeyResolver() { + return this.encryptedKeyResolver; + } + + T buildSAMLObject(final Class clazz) { + try { + QName defaultElementName = (QName) clazz.getDeclaredField("DEFAULT_ELEMENT_NAME").get(null); + return (T) getBuilderFactory().getBuilder(defaultElementName).buildObject(defaultElementName); + } + catch (IllegalAccessException e) { + throw new Saml2Exception("Could not create SAML object", e); + } + catch (NoSuchFieldException e) { + throw new Saml2Exception("Could not create SAML object", e); + } + } + + XMLObject resolve(String xml) { + return resolve(xml.getBytes(StandardCharsets.UTF_8)); + } + + private XMLObject resolve(byte[] xml) { + XMLObject parsed = parse(xml); + if (parsed != null) { + return parsed; + } + throw new Saml2Exception("Deserialization not supported for given data set"); + } + + private XMLObject parse(byte[] xml) { + try { + Document document = this.parserPool.parse(new ByteArrayInputStream(xml)); + Element element = document.getDocumentElement(); + return getUnmarshallerFactory().getUnmarshaller(element).unmarshall(element); + } + catch (UnmarshallingException | XMLParserException e) { + throw new Saml2Exception(e); + } + } + + private UnmarshallerFactory getUnmarshallerFactory() { + return XMLObjectProviderRegistrySupport.getUnmarshallerFactory(); + } + + String toXml(XMLObject object, List signingCredentials, String localSpEntityId) + throws MarshallingException, SignatureException, SecurityException { + if (object instanceof SignableSAMLObject && null != hasSigningCredential(signingCredentials)) { + signXmlObject( + (SignableSAMLObject) object, + getSigningCredential(signingCredentials, localSpEntityId) + ); + } + final MarshallerFactory marshallerFactory = XMLObjectProviderRegistrySupport.getMarshallerFactory(); + Element element = marshallerFactory.getMarshaller(object).marshall(object); + return SerializeSupport.nodeToString(element); + } + + private Saml2X509Credential hasSigningCredential(List credentials) { + for (Saml2X509Credential c : credentials) { + if (c.isSigningCredential()) { + return c; + } + } + return null; + } + + private void signXmlObject(SignableSAMLObject object, Credential credential) + throws MarshallingException, SecurityException, SignatureException { + SignatureSigningParameters parameters = new SignatureSigningParameters(); + parameters.setSigningCredential(credential); + parameters.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256); + parameters.setSignatureReferenceDigestMethod(SignatureConstants.ALGO_ID_DIGEST_SHA256); + parameters.setSignatureCanonicalizationAlgorithm(SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS); + SignatureSupport.signObject(object, parameters); + } + + private Credential getSigningCredential(List signingCredential, + String localSpEntityId + ) { + Saml2X509Credential credential = hasSigningCredential(signingCredential); + if (credential == null) { + throw new IllegalArgumentException("no signing credential configured"); + } + BasicCredential cred = getBasicCredential(credential); + cred.setEntityId(localSpEntityId); + cred.setUsageType(UsageType.SIGNING); + return cred; + } + + private BasicX509Credential getBasicCredential(Saml2X509Credential credential) { + return CredentialSupport.getSimpleCredential( + credential.getCertificate(), + credential.getPrivateKey() + ); + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java new file mode 100644 index 0000000000..a2a5951648 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java @@ -0,0 +1,72 @@ +/* + * Copyright 2002-2019 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.saml2.provider.service.authentication; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.AuthenticatedPrincipal; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.util.Assert; + +import java.util.Collection; + +/** + * An implementation of an {@link AbstractAuthenticationToken} + * that represents an authenticated SAML 2.0 {@link Authentication}. + *

+ * The {@link Authentication} associates valid SAML assertion + * data with a Spring Security authentication object + * The complete assertion is contained in the object in String format, + * {@link Saml2Authentication#getSaml2Response()} + * @since 5.2 + * @see AbstractAuthenticationToken + */ +public class Saml2Authentication extends AbstractAuthenticationToken { + + private final AuthenticatedPrincipal principal; + private final String saml2Response; + + public Saml2Authentication(AuthenticatedPrincipal principal, + String saml2Response, + Collection authorities) { + super(authorities); + Assert.notNull(principal, "principal cannot be null"); + Assert.hasText(saml2Response, "saml2Response cannot be null"); + this.principal = principal; + this.saml2Response = saml2Response; + setAuthenticated(true); + } + + @Override + public Object getPrincipal() { + return this.principal; + } + + /** + * Returns the SAML response object, as decoded XML. May contain encrypted elements + * @return string representation of the SAML Response XML object + */ + public String getSaml2Response() { + return this.saml2Response; + } + + @Override + public Object getCredentials() { + return getSaml2Response(); + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationRequest.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationRequest.java new file mode 100644 index 0000000000..4bf6c6bea7 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationRequest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2002-2019 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.saml2.provider.service.authentication; + +import org.springframework.security.saml2.credentials.Saml2X509Credential; +import org.springframework.util.Assert; + +import java.util.LinkedList; +import java.util.List; + +/** + * Data holder for information required to send an {@code AuthNRequest} + * from the service provider to the identity provider + * + * @see {@link Saml2AuthenticationRequestFactory} + * @see https://www.oasis-open.org/committees/download.php/35711/sstc-saml-core-errata-2.0-wd-06-diff.pdf (line 2031) + * @since 5.2 + */ +public class Saml2AuthenticationRequest { + private final String localSpEntityId; + private final List credentials; + private String webSsoUri; + + public Saml2AuthenticationRequest(String localSpEntityId, String webSsoUri, List credentials) { + Assert.hasText(localSpEntityId, "localSpEntityId cannot be null"); + Assert.hasText(localSpEntityId, "webSsoUri cannot be null"); + this.localSpEntityId = localSpEntityId; + this.webSsoUri = webSsoUri; + this.credentials = new LinkedList<>(); + for (Saml2X509Credential c : credentials) { + if (c.isSigningCredential()) { + this.credentials.add(c); + } + } + Assert.notEmpty(this.credentials, "at least one SIGNING credential must be present"); + } + + + public String getLocalSpEntityId() { + return this.localSpEntityId; + } + + public String getWebSsoUri() { + return this.webSsoUri; + } + + public List getCredentials() { + return this.credentials; + } +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationRequestFactory.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationRequestFactory.java new file mode 100644 index 0000000000..a77a4e06a6 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationRequestFactory.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2019 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.saml2.provider.service.authentication; + +/** + * Component that generates an AuthenticationRequest, samlp:AuthnRequestType as defined by + * https://www.oasis-open.org/committees/download.php/35711/sstc-saml-core-errata-2.0-wd-06-diff.pdf + * Page 50, Line 2147 + * + * @since 5.2 + */ +public interface Saml2AuthenticationRequestFactory { + /** + * Creates an authentication request from the Service Provider, sp, + * to the Identity Provider, idp. + * The authentication result is an XML string that may be signed, encrypted, both or neither. + * + * @param request - information about the identity provider, the recipient of this authentication request and + * accompanying data + * @return XML data in the format of a String. This data may be signed, encrypted, both signed and encrypted or + * neither signed and encrypted + */ + String createAuthenticationRequest(Saml2AuthenticationRequest request); +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationToken.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationToken.java new file mode 100644 index 0000000000..a19a024eed --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationToken.java @@ -0,0 +1,133 @@ +/* + * Copyright 2002-2019 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.saml2.provider.service.authentication; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.saml2.credentials.Saml2X509Credential; + +import java.util.List; + +/** + * Represents an incoming SAML 2.0 response containing an assertion that has not been validated. + * {@link Saml2AuthenticationToken#isAuthenticated()} will always return false. + * @since 5.2 + */ +public class Saml2AuthenticationToken extends AbstractAuthenticationToken { + + private final String saml2Response; + private final String recipientUri; + private String idpEntityId; + private String localSpEntityId; + private List credentials; + + /** + * Creates an authentication token from an incoming SAML 2 Response object + * @param saml2Response inflated and decoded XML representation of the SAML 2 Response + * @param recipientUri the URL that the SAML 2 Response was received at. Used for validation + * @param idpEntityId the entity ID of the asserting entity + * @param localSpEntityId the configured local SP, the relying party, entity ID + * @param credentials the credentials configured for signature verification and decryption + */ + public Saml2AuthenticationToken(String saml2Response, + String recipientUri, + String idpEntityId, + String localSpEntityId, + List credentials) { + super(null); + this.saml2Response = saml2Response; + this.recipientUri = recipientUri; + this.idpEntityId = idpEntityId; + this.localSpEntityId = localSpEntityId; + this.credentials = credentials; + } + + /** + * Returns the decoded and inflated SAML 2.0 Response XML object as a string + * @return decoded and inflated XML data as a {@link String} + */ + @Override + public Object getCredentials() { + return getSaml2Response(); + } + + /** + * Always returns null. + * @return null + */ + @Override + public Object getPrincipal() { + return null; + } + + /** + * Returns inflated and decoded XML representation of the SAML 2 Response + * @return inflated and decoded XML representation of the SAML 2 Response + */ + public String getSaml2Response() { + return this.saml2Response; + } + + /** + * Returns the URI that the SAML 2 Response object came in on + * @return URI as a string + */ + public String getRecipientUri() { + return this.recipientUri; + } + + /** + * Returns the configured entity ID of the receiving relying party, SP + * @return an entityID for the configured local relying party + */ + public String getLocalSpEntityId() { + return this.localSpEntityId; + } + + /** + * Returns all the credentials associated with the relying party configuraiton + * @return + */ + public List getX509Credentials() { + return this.credentials; + } + + /** + * @return false + */ + @Override + public boolean isAuthenticated() { + return false; + } + + /** + * The state of this object cannot be changed. Will always throw an exception + * @param authenticated ignored + * @throws {@link IllegalArgumentException} + */ + @Override + public void setAuthenticated(boolean authenticated) { + throw new IllegalArgumentException(); + } + + /** + * Returns the configured IDP, asserting party, entity ID + * @return a string representing the entity ID + */ + public String getIdpEntityId() { + return this.idpEntityId; + } +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/InMemoryRelyingPartyRegistrationRepository.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/InMemoryRelyingPartyRegistrationRepository.java new file mode 100644 index 0000000000..4f28cea5e8 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/InMemoryRelyingPartyRegistrationRepository.java @@ -0,0 +1,72 @@ +/* + * Copyright 2002-2019 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.saml2.provider.service.registration; + +import org.springframework.util.Assert; + +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + +import static java.util.Arrays.asList; +import static org.springframework.util.Assert.notEmpty; +import static org.springframework.util.Assert.notNull; + +/** + * @since 5.2 + */ +public class InMemoryRelyingPartyRegistrationRepository + implements RelyingPartyRegistrationRepository, Iterable { + + private final Map byRegistrationId; + + public InMemoryRelyingPartyRegistrationRepository(RelyingPartyRegistration... registrations) { + this(asList(registrations)); + } + + public InMemoryRelyingPartyRegistrationRepository(Collection registrations) { + notEmpty(registrations, "registrations cannot be empty"); + this.byRegistrationId = createMappingToIdentityProvider(registrations); + } + + private static Map createMappingToIdentityProvider( + Collection rps + ) { + LinkedHashMap result = new LinkedHashMap<>(); + for (RelyingPartyRegistration rp : rps) { + notNull(rp, "relying party collection cannot contain null values"); + String key = rp.getRegistrationId(); + notNull(rp, "relying party identifier cannot be null"); + Assert.isNull(result.get(key), () -> "relying party duplicate identifier '" + key+"' detected."); + result.put(key, rp); + } + return Collections.unmodifiableMap(result); + } + + @Override + public RelyingPartyRegistration findByRegistrationId(String id) { + return this.byRegistrationId.get(id); + } + + @Override + public Iterator iterator() { + return this.byRegistrationId.values().iterator(); + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java new file mode 100644 index 0000000000..fe0ff39caa --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java @@ -0,0 +1,304 @@ +/* + * Copyright 2002-2019 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.saml2.provider.service.registration; + +import org.springframework.security.saml2.credentials.Saml2X509Credential; +import org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType; +import org.springframework.util.Assert; + +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import static java.util.Collections.unmodifiableList; +import static org.springframework.util.Assert.hasText; +import static org.springframework.util.Assert.notEmpty; +import static org.springframework.util.Assert.notNull; + +/** + * Represents a configured service provider, SP, and a remote identity provider, IDP, pair. + * Each SP/IDP pair is uniquely identified using a registrationId, an arbitrary string. + * A fully configured registration may look like + *

+ *		//remote IDP entity ID
+ *		String idpEntityId = "https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php";
+ *		//remote WebSSO Endpoint - Where to Send AuthNRequests to
+ *		String webSsoEndpoint = "https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SSOService.php";
+ *		//local registration ID
+ *		String registrationId = "simplesamlphp";
+ *		//local entity ID - autogenerated based on URL
+ *		String localEntityIdTemplate = "{baseUrl}/saml2/service-provider-metadata/{registrationId}";
+ *		//local SSO URL - autogenerated, endpoint to receive SAML Response objects
+ *		String acsUrlTemplate = "{baseUrl}/login/saml2/sso/{registrationId}";
+ *		//local signing (and local decryption key and remote encryption certificate)
+ *		Saml2X509Credential signingCredential = getSigningCredential();
+ *		//IDP certificate for verification of incoming messages
+ *		Saml2X509Credential idpVerificationCertificate = getVerificationCertificate();
+ *		RelyingPartyRegistration rp = RelyingPartyRegistration.withRegistrationId(registrationId)
+ * 				.remoteIdpEntityId(idpEntityId)
+ * 				.idpWebSsoUrl(webSsoEndpoint)
+ * 				.credentials(c -> c.add(signingCredential))
+ * 				.credentials(c -> c.add(idpVerificationCertificate))
+ * 				.localEntityIdTemplate(localEntityIdTemplate)
+ * 				.assertionConsumerServiceUrlTemplate(acsTemplate)
+ * 				.build();
+ * 
+ * @since 5.2 + */ +public class RelyingPartyRegistration { + + private final String registrationId; + private final String remoteIdpEntityId; + private final String assertionConsumerServiceUrlTemplate; + private final String idpWebSsoUrl; + private final List credentials; + private final String localEntityIdTemplate; + + private RelyingPartyRegistration(String idpEntityId, String registrationId, String assertionConsumerServiceUrlTemplate, + String idpWebSsoUri, List credentials, String localEntityIdTemplate) { + hasText(idpEntityId, "idpEntityId cannot be empty"); + hasText(registrationId, "registrationId cannot be empty"); + hasText(assertionConsumerServiceUrlTemplate, "assertionConsumerServiceUrlTemplate cannot be empty"); + hasText(localEntityIdTemplate, "localEntityIdTemplate cannot be empty"); + notEmpty(credentials, "credentials cannot be empty"); + notNull(idpWebSsoUri, "idpWebSsoUri cannot be empty"); + for (Saml2X509Credential c : credentials) { + notNull(c, "credentials cannot contain null elements"); + } + this.registrationId = registrationId; + this.remoteIdpEntityId = idpEntityId; + this.assertionConsumerServiceUrlTemplate = assertionConsumerServiceUrlTemplate; + this.credentials = unmodifiableList(new LinkedList<>(credentials)); + this.idpWebSsoUrl = idpWebSsoUri; + this.localEntityIdTemplate = localEntityIdTemplate; + } + + /** + * Returns the entity ID of the IDP, the asserting party. + * @return entity ID of the asserting party + */ + public String getRemoteIdpEntityId() { + return this.remoteIdpEntityId; + } + + /** + * Returns the unique relying party registration ID + * @return registrationId + */ + public String getRegistrationId() { + return this.registrationId; + } + + /** + * returns the URL template for which ACS URL authentication requests should contain + * Possible variables are {@code baseUrl}, {@code registrationId}, + * {@code baseScheme}, {@code baseHost}, and {@code basePort}. + * @return string containing the ACS URL template, with or without variables present + */ + public String getAssertionConsumerServiceUrlTemplate() { + return this.assertionConsumerServiceUrlTemplate; + } + + /** + * Contains the URL for which to send the SAML 2 Authentication Request to initiate + * a single sign on flow. + * @return a IDP URL that accepts REDIRECT or POST binding for authentication requests + */ + public String getIdpWebSsoUrl() { + return this.idpWebSsoUrl; + } + + /** + * The local relying party, or Service Provider, can generate it's entity ID based on + * possible variables of {@code baseUrl}, {@code registrationId}, + * {@code baseScheme}, {@code baseHost}, and {@code basePort}, for example + * {@code {baseUrl}/saml2/service-provider-metadata/{registrationId}} + * @return a string containing the entity ID or entity ID template + */ + public String getLocalEntityIdTemplate() { + return this.localEntityIdTemplate; + } + + /** + * Returns a list of configured credentials to be used in message exchanges between relying party, SP, and + * asserting party, IDP. + * @return a list of credentials + */ + public List getCredentials() { + return this.credentials; + } + + /** + * @return a filtered list containing only credentials of type + * {@link Saml2X509CredentialType#VERIFICATION}. + * Returns an empty list of credentials are not found + */ + public List getVerificationCredentials() { + return filterCredentials(c -> c.isSignatureVerficationCredential()); + } + + /** + * @return a filtered list containing only credentials of type + * {@link Saml2X509CredentialType#SIGNING}. + * Returns an empty list of credentials are not found + */ + public List getSigningCredentials() { + return filterCredentials(c -> c.isSigningCredential()); + } + + /** + * @return a filtered list containing only credentials of type + * {@link Saml2X509CredentialType#ENCRYPTION}. + * Returns an empty list of credentials are not found + */ + public List getEncryptionCredentials() { + return filterCredentials(c -> c.isEncryptionCredential()); + } + + /** + * @return a filtered list containing only credentials of type + * {@link Saml2X509CredentialType#DECRYPTION}. + * Returns an empty list of credentials are not found + */ + public List getDecryptionCredentials() { + return filterCredentials(c -> c.isDecryptionCredential()); + } + + private List filterCredentials(Function filter) { + List result = new LinkedList<>(); + for (Saml2X509Credential c : getCredentials()) { + if (filter.apply(c)) { + result.add(c); + } + } + return result; + } + + /** + * Creates a {@code RelyingPartyRegistration} {@link Builder} with a known {@code registrationId} + * @param registrationId a string identifier for the {@code RelyingPartyRegistration} + * @return {@code Builder} to create a {@code RelyingPartyRegistration} object + */ + public static Builder withRegistrationId(String registrationId) { + Assert.hasText(registrationId, "registrationId cannot be empty"); + return new Builder(registrationId); + } + + public static class Builder { + private String registrationId; + private String remoteIdpEntityId; + private String idpWebSsoUrl; + private String assertionConsumerServiceUrlTemplate; + private List credentials = new LinkedList<>(); + private String localEntityIdTemplate = "{baseUrl}/saml2/service-provider-metadata/{registrationId}"; + + private Builder(String registrationId) { + this.registrationId = registrationId; + } + + + /** + * Sets the {@code registrationId} template. Often be used in URL paths + * @param id registrationId for this object, should be unique + * @return this object + */ + public Builder registrationId(String id) { + this.registrationId = id; + return this; + } + + /** + * Sets the {@code entityId} for the remote asserting party, the Identity Provider. + * @param entityId the IDP entityId + * @return this object + */ + public Builder remoteIdpEntityId(String entityId) { + this.remoteIdpEntityId = entityId; + return this; + } + + /** + * Assertion Consumer + * Service URL template. It can contain variables {@code baseUrl}, {@code registrationId}, + * {@code baseScheme}, {@code baseHost}, and {@code basePort}. + * @param assertionConsumerServiceUrlTemplate the Assertion Consumer Service URL template (i.e. + * "{baseUrl}/login/saml2/sso/{registrationId}". + * @return this object + */ + public Builder assertionConsumerServiceUrlTemplate(String assertionConsumerServiceUrlTemplate) { + this.assertionConsumerServiceUrlTemplate = assertionConsumerServiceUrlTemplate; + return this; + } + + /** + * Sets the {@code SSO URL} for the remote asserting party, the Identity Provider. + * @param url - a URL that accepts authentication requests via REDIRECT or POST bindings + * @return this object + */ + public Builder idpWebSsoUrl(String url) { + this.idpWebSsoUrl = url; + return this; + } + + /** + * Modifies the collection of {@link Saml2X509Credential} objects + * used in communication between IDP and SP + * For example: + * + * Saml2X509Credential credential = ...; + * return RelyingPartyRegistration.withRegistrationId("id") + * .credentials(c -> c.add(credential)) + * ... + * .build(); + * + * @param credentials - a consumer that can modify the collection of credentials + * @return this object + */ + public Builder credentials(Consumer> credentials) { + credentials.accept(this.credentials); + return this; + } + + /** + * Sets the local relying party, or Service Provider, entity Id template. + * can generate it's entity ID based on possible variables of {@code baseUrl}, {@code registrationId}, + * {@code baseScheme}, {@code baseHost}, and {@code basePort}, for example + * {@code {baseUrl}/saml2/service-provider-metadata/{registrationId}} + * @return a string containing the entity ID or entity ID template + */ + + public Builder localEntityIdTemplate(String template) { + this.localEntityIdTemplate = template; + return this; + } + + public RelyingPartyRegistration build() { + return new RelyingPartyRegistration( + remoteIdpEntityId, + registrationId, + assertionConsumerServiceUrlTemplate, + idpWebSsoUrl, + credentials, + localEntityIdTemplate + ); + } + } + + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistrationRepository.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistrationRepository.java new file mode 100644 index 0000000000..28e88b715c --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistrationRepository.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2019 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.saml2.provider.service.registration; + +/** + * Resolves a {@link RelyingPartyRegistration}, a configured service provider and remote identity provider pair, + * by entityId or registrationId + * @since 5.2 + */ +public interface RelyingPartyRegistrationRepository { + + /** + * Resolves an {@link RelyingPartyRegistration} by registrationId, or returns the default provider + * if no registrationId is provided + * + * @param registrationId - a provided registrationId, may be be null or empty + * @return {@link RelyingPartyRegistration} if found, {@code null} if an registrationId is provided and + * no registration is found. Returns a default, implementation specific, + * {@link RelyingPartyRegistration} if no registrationId is provided + */ + RelyingPartyRegistration findByRegistrationId(String registrationId); + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2Utils.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2Utils.java new file mode 100644 index 0000000000..3404883e82 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2Utils.java @@ -0,0 +1,134 @@ +/* + * Copyright 2002-2019 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.saml2.provider.service.servlet.filter; + +import org.springframework.security.saml2.Saml2Exception; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.Inflater; +import java.util.zip.InflaterOutputStream; +import javax.servlet.http.HttpServletRequest; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.zip.Deflater.DEFLATED; +import static org.springframework.security.web.util.UrlUtils.buildFullRequestUrl; +import static org.springframework.web.util.UriComponentsBuilder.fromHttpUrl; + +/** + * @since 5.2 + */ +final class Saml2Utils { + + private static final char PATH_DELIMITER = '/'; + private static Base64.Encoder ENCODER = Base64.getEncoder(); + private static Base64.Decoder DECODER = Base64.getDecoder(); + + static String encode(byte[] b) { + return ENCODER.encodeToString(b); + } + + static byte[] decode(String s) { + return DECODER.decode(s); + } + + static byte[] deflate(String s) { + try { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + DeflaterOutputStream deflater = new DeflaterOutputStream(b, new Deflater(DEFLATED, true)); + deflater.write(s.getBytes(UTF_8)); + deflater.finish(); + return b.toByteArray(); + } + catch (IOException e) { + throw new Saml2Exception("Unable to deflate string", e); + } + } + + static String inflate(byte[] b) { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + InflaterOutputStream iout = new InflaterOutputStream(out, new Inflater(true)); + iout.write(b); + iout.finish(); + return new String(out.toByteArray(), UTF_8); + } + catch (IOException e) { + throw new Saml2Exception("Unable to inflate string", e); + } + } + + static String getServiceProviderEntityId(RelyingPartyRegistration rp, HttpServletRequest request) { + return resolveUrlTemplate( + rp.getLocalEntityIdTemplate(), + getApplicationUri(request), + rp.getRemoteIdpEntityId(), + rp.getRegistrationId() + ); + } + + static String resolveUrlTemplate(String template, String baseUrl, String entityId, String registrationId) { + if (!StringUtils.hasText(template)) { + return baseUrl; + } + + Map uriVariables = new HashMap<>(); + UriComponents uriComponents = UriComponentsBuilder.fromHttpUrl(baseUrl) + .replaceQuery(null) + .fragment(null) + .build(); + String scheme = uriComponents.getScheme(); + uriVariables.put("baseScheme", scheme == null ? "" : scheme); + String host = uriComponents.getHost(); + uriVariables.put("baseHost", host == null ? "" : host); + // following logic is based on HierarchicalUriComponents#toUriString() + int port = uriComponents.getPort(); + uriVariables.put("basePort", port == -1 ? "" : ":" + port); + String path = uriComponents.getPath(); + if (StringUtils.hasLength(path)) { + if (path.charAt(0) != PATH_DELIMITER) { + path = PATH_DELIMITER + path; + } + } + uriVariables.put("basePath", path == null ? "" : path); + uriVariables.put("baseUrl", uriComponents.toUriString()); + uriVariables.put("entityId", StringUtils.hasText(entityId) ? entityId : ""); + uriVariables.put("registrationId", StringUtils.hasText(registrationId) ? registrationId : ""); + + return UriComponentsBuilder.fromUriString(template) + .buildAndExpand(uriVariables) + .toUriString(); + } + + static String getApplicationUri(HttpServletRequest request) { + UriComponents uriComponents = fromHttpUrl(buildFullRequestUrl(request)) + .replacePath(request.getContextPath()) + .replaceQuery(null) + .fragment(null) + .build(); + return uriComponents.toUriString(); + } +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationFilter.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationFilter.java new file mode 100644 index 0000000000..6b290ec9e9 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationFilter.java @@ -0,0 +1,89 @@ +/* + * Copyright 2002-2019 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.saml2.provider.service.servlet.filter; + +import org.springframework.http.HttpMethod; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationToken; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.springframework.util.StringUtils.hasText; + +/** + * @since 5.2 + */ +public class Saml2WebSsoAuthenticationFilter extends AbstractAuthenticationProcessingFilter { + + public static final String DEFAULT_FILTER_PROCESSES_URI = "/login/saml2/sso/{registrationId}"; + private final RequestMatcher matcher; + private final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository; + + public Saml2WebSsoAuthenticationFilter(RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { + super(DEFAULT_FILTER_PROCESSES_URI); + Assert.notNull(relyingPartyRegistrationRepository, "relyingPartyRegistrationRepository cannot be null"); + this.matcher = new AntPathRequestMatcher(DEFAULT_FILTER_PROCESSES_URI); + this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository; + setAllowSessionCreation(true); + setSessionAuthenticationStrategy(new ChangeSessionIdAuthenticationStrategy()); + } + + @Override + protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { + return (super.requiresAuthentication(request, response) && hasText(request.getParameter("SAMLResponse"))); + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException { + String saml2Response = request.getParameter("SAMLResponse"); + byte[] b = Saml2Utils.decode(saml2Response); + + String responseXml = inflateIfRequired(request, b); + RelyingPartyRegistration rp = + this.relyingPartyRegistrationRepository.findByRegistrationId(this.matcher.matcher(request).getVariables().get("registrationId")); + String localSpEntityId = Saml2Utils.getServiceProviderEntityId(rp, request); + final Saml2AuthenticationToken authentication = new Saml2AuthenticationToken( + responseXml, + request.getRequestURL().toString(), + rp.getRemoteIdpEntityId(), + localSpEntityId, + rp.getCredentials() + ); + return getAuthenticationManager().authenticate(authentication); + } + + private String inflateIfRequired(HttpServletRequest request, byte[] b) { + if (HttpMethod.GET.matches(request.getMethod())) { + return Saml2Utils.inflate(b); + } + else { + return new String(b, UTF_8); + } + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationRequestFilter.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationRequestFilter.java new file mode 100644 index 0000000000..f8357cd9a3 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationRequestFilter.java @@ -0,0 +1,120 @@ +/* + * Copyright 2002-2019 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.saml2.provider.service.servlet.filter; + +import org.springframework.security.saml2.provider.service.authentication.OpenSamlAuthenticationRequestFactory; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationRequest; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationRequestFactory; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher.MatchResult; +import org.springframework.util.Assert; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static java.lang.String.format; +import static org.springframework.security.saml2.provider.service.servlet.filter.Saml2Utils.deflate; +import static org.springframework.security.saml2.provider.service.servlet.filter.Saml2Utils.encode; + +/** + * @since 5.2 + */ +public class Saml2WebSsoAuthenticationRequestFilter extends OncePerRequestFilter { + + private final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository; + + private RequestMatcher redirectMatcher = new AntPathRequestMatcher("/saml2/authenticate/{registrationId}"); + + private Saml2AuthenticationRequestFactory authenticationRequestFactory = new OpenSamlAuthenticationRequestFactory(); + + public Saml2WebSsoAuthenticationRequestFilter(RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { + Assert.notNull(relyingPartyRegistrationRepository, "relyingPartyRegistrationRepository cannot be null"); + this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository; + } + + public void setAuthenticationRequestFactory(Saml2AuthenticationRequestFactory authenticationRequestFactory) { + Assert.notNull(authenticationRequestFactory, "authenticationRequestFactory cannot be null"); + this.authenticationRequestFactory = authenticationRequestFactory; + } + + public void setRedirectMatcher(RequestMatcher redirectMatcher) { + Assert.notNull(redirectMatcher, "redirectMatcher cannot be null"); + this.redirectMatcher = redirectMatcher; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + MatchResult matcher = this.redirectMatcher.matcher(request); + if (!matcher.isMatch()) { + filterChain.doFilter(request, response); + return; + } + + String registrationId = matcher.getVariables().get("registrationId"); + sendRedirect(request, response, registrationId); + } + + private void sendRedirect(HttpServletRequest request, HttpServletResponse response, String registrationId) + throws IOException { + if (this.logger.isDebugEnabled()) { + this.logger.debug(format("Creating SAML2 SP Authentication Request for IDP[%s]", registrationId)); + } + RelyingPartyRegistration relyingParty = this.relyingPartyRegistrationRepository.findByRegistrationId(registrationId); + String redirectUrl = createSamlRequestRedirectUrl(request, relyingParty); + response.sendRedirect(redirectUrl); + } + + private String createSamlRequestRedirectUrl(HttpServletRequest request, RelyingPartyRegistration relyingParty) { + Saml2AuthenticationRequest authNRequest = createAuthenticationRequest(relyingParty, request); + String xml = this.authenticationRequestFactory.createAuthenticationRequest(authNRequest); + String encoded = encode(deflate(xml)); + String relayState = request.getParameter("RelayState"); + String redirect = UriComponentsBuilder + .fromUriString(relyingParty.getIdpWebSsoUrl()) + .queryParam("SAMLRequest", UriUtils.encode(encoded, StandardCharsets.ISO_8859_1)) + .queryParam("RelayState", UriUtils.encode(relayState, StandardCharsets.ISO_8859_1)) + .build(true) + .toUriString(); + return redirect; + } + + private Saml2AuthenticationRequest createAuthenticationRequest(RelyingPartyRegistration relyingParty, HttpServletRequest request) { + String localSpEntityId = Saml2Utils.getServiceProviderEntityId(relyingParty, request); + return new Saml2AuthenticationRequest( + localSpEntityId, + Saml2Utils.resolveUrlTemplate( + relyingParty.getAssertionConsumerServiceUrlTemplate(), + Saml2Utils.getApplicationUri(request), + relyingParty.getRemoteIdpEntityId(), + relyingParty.getRegistrationId() + ), + relyingParty.getSigningCredentials() + ); + } + +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlImplementationTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlImplementationTests.java new file mode 100644 index 0000000000..8ff24b71e2 --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlImplementationTests.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2019 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.saml2.provider.service.authentication; + +import org.junit.Test; + +public class OpenSamlImplementationTests { + + @Test + public void getInstance() { + OpenSamlImplementation.getInstance(); + } +} diff --git a/saml2/saml2-service-provider/src/test/resources/logback-test.xml b/saml2/saml2-service-provider/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..7dbb84c99a --- /dev/null +++ b/saml2/saml2-service-provider/src/test/resources/logback-test.xml @@ -0,0 +1,14 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/samples/boot/saml2login/README.adoc b/samples/boot/saml2login/README.adoc new file mode 100644 index 0000000000..94feb1c826 --- /dev/null +++ b/samples/boot/saml2login/README.adoc @@ -0,0 +1,44 @@ += OAuth 2.0 Login Sample + +This guide provides instructions on setting up the sample application with SAML 2.0 Login using +Spring Security's `saml2Login()` feature. + +The sample application uses Spring Boot 2.2.0.M5 and the `spring-security-saml2-service-provider` +module which is new in Spring Security 5.2. + +== Goals + +`saml2Login()` provides a very simple, basic, implementation of a Service Provider +that can receive a SAML 2 Response XML object via the HTTP-POST and HTTP-REDIRECT bindings +against a known SAML reference implementation by SimpleSAMLPhp. + + +The following features are implemented in the MVP + +1. Receive and validate a SAML 2.0 Response object containing an assertion +and create a valid authentication in Spring Security +2. Send a SAML 2 AuthNRequest object to an Identity Provider +3. Provide a framework for components used in SAML 2.0 authentication that can +be swapped by configuration +4. Sample working against the SimpleSAMLPhP reference implementation + +== Run the Sample + +=== Start up the Sample Boot Application +``` + ./gradlew :spring-security-samples-boot-saml2login:bootRun +``` + +=== Open a Browser + +http://localhost:8080/ + +You will be redirect to the SimpleSAMLPhp IDP + +=== Type in your credentials + +``` +User: user +Password: password +``` + diff --git a/samples/boot/saml2login/spring-security-samples-boot-saml2login.gradle b/samples/boot/saml2login/spring-security-samples-boot-saml2login.gradle new file mode 100644 index 0000000000..f46ce4a8d9 --- /dev/null +++ b/samples/boot/saml2login/spring-security-samples-boot-saml2login.gradle @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2019 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. + */ + +apply plugin: 'io.spring.convention.spring-sample-boot' + +dependencies { + compile project(':spring-security-config') + compile project(':spring-security-saml2-service-provider') + compile 'org.springframework.boot:spring-boot-starter-thymeleaf' + compile 'org.springframework.boot:spring-boot-starter-web' + compile 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5' + + testCompile project(':spring-security-test') + testCompile 'net.sourceforge.htmlunit:htmlunit' + testCompile 'org.springframework.boot:spring-boot-starter-test' +} diff --git a/samples/boot/saml2login/src/integration-test/java/org/springframework/security/samples/OpenSamlActionTestingSupport.java b/samples/boot/saml2login/src/integration-test/java/org/springframework/security/samples/OpenSamlActionTestingSupport.java new file mode 100644 index 0000000000..b56eeb1a2d --- /dev/null +++ b/samples/boot/saml2login/src/integration-test/java/org/springframework/security/samples/OpenSamlActionTestingSupport.java @@ -0,0 +1,513 @@ +/* + * Copyright 2002-2019 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.samples; + +import org.springframework.security.saml2.Saml2Exception; + +import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty; +import org.apache.commons.codec.binary.Base64; +import org.apache.xml.security.algorithms.JCEMapper; +import org.apache.xml.security.encryption.XMLCipherParameters; +import org.joda.time.DateTime; +import org.joda.time.Duration; +import org.junit.Assert; +import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; +import org.opensaml.profile.action.EventIds; +import org.opensaml.profile.context.EventContext; +import org.opensaml.profile.context.ProfileRequestContext; +import org.opensaml.saml.common.SAMLObjectBuilder; +import org.opensaml.saml.common.SAMLVersion; +import org.opensaml.saml.saml2.core.Artifact; +import org.opensaml.saml.saml2.core.ArtifactResolve; +import org.opensaml.saml.saml2.core.ArtifactResponse; +import org.opensaml.saml.saml2.core.Assertion; +import org.opensaml.saml.saml2.core.AttributeQuery; +import org.opensaml.saml.saml2.core.AttributeStatement; +import org.opensaml.saml.saml2.core.AuthnRequest; +import org.opensaml.saml.saml2.core.AuthnStatement; +import org.opensaml.saml.saml2.core.Conditions; +import org.opensaml.saml.saml2.core.EncryptedAssertion; +import org.opensaml.saml.saml2.core.EncryptedID; +import org.opensaml.saml.saml2.core.Issuer; +import org.opensaml.saml.saml2.core.LogoutRequest; +import org.opensaml.saml.saml2.core.LogoutResponse; +import org.opensaml.saml.saml2.core.NameID; +import org.opensaml.saml.saml2.core.Response; +import org.opensaml.saml.saml2.core.Subject; +import org.opensaml.saml.saml2.core.SubjectConfirmation; +import org.opensaml.saml.saml2.core.SubjectConfirmationData; +import org.opensaml.saml.saml2.encryption.Encrypter; +import org.opensaml.security.credential.BasicCredential; +import org.opensaml.security.credential.Credential; +import org.opensaml.security.credential.CredentialSupport; +import org.opensaml.xmlsec.encryption.support.DataEncryptionParameters; +import org.opensaml.xmlsec.encryption.support.EncryptionException; +import org.opensaml.xmlsec.encryption.support.KeyEncryptionParameters; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.cert.X509Certificate; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.Inflater; +import java.util.zip.InflaterOutputStream; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.crypto.SecretKey; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Arrays.asList; +import static java.util.zip.Deflater.DEFLATED; +import static org.opensaml.security.crypto.KeySupport.generateKey; + +/** + * Copied from OpenSAML Source Code Helper methods for creating/testing SAML 2 + * objects within profile action tests. When methods herein refer to mock objects they are + * always objects that have been created via Mockito unless otherwise noted. + */ +public class OpenSamlActionTestingSupport { + + static Base64 UNCHUNKED_ENCODER = new Base64(0, new byte[] { '\n' }); + + /** ID used for all generated {@link Response} objects. */ + final static String REQUEST_ID = "request"; + + /** ID used for all generated {@link Response} objects. */ + final static String RESPONSE_ID = "response"; + + /** ID used for all generated {@link Assertion} objects. */ + final static String ASSERTION_ID = "assertion"; + + static String encode(byte[] b) { + return UNCHUNKED_ENCODER.encodeToString(b); + } + + static byte[] decode(String s) { + return UNCHUNKED_ENCODER.decode(s); + } + + static byte[] deflate(String s) { + try { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + DeflaterOutputStream deflater = new DeflaterOutputStream(b, new Deflater(DEFLATED, true)); + deflater.write(s.getBytes(UTF_8)); + deflater.finish(); + return b.toByteArray(); + } + catch (IOException e) { + throw new Saml2Exception("Unable to deflate string", e); + } + } + + static String inflate(byte[] b) { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + InflaterOutputStream iout = new InflaterOutputStream(out, new Inflater(true)); + iout.write(b); + iout.finish(); + return new String(out.toByteArray(), UTF_8); + } + catch (IOException e) { + throw new Saml2Exception("Unable to inflate string", e); + } + } + + static EncryptedAssertion encryptAssertion(Assertion assertion, X509Certificate certificate) { + Encrypter encrypter = getEncrypter(certificate); + try { + Encrypter.KeyPlacement keyPlacement = Encrypter.KeyPlacement.valueOf("PEER"); + encrypter.setKeyPlacement(keyPlacement); + return encrypter.encrypt(assertion); + } + catch (EncryptionException e) { + throw new Saml2Exception("Unable to encrypt assertion.", e); + } + } + + static EncryptedID encryptNameId(NameID nameID, X509Certificate certificate) { + Encrypter encrypter = getEncrypter(certificate); + try { + Encrypter.KeyPlacement keyPlacement = Encrypter.KeyPlacement.valueOf("PEER"); + encrypter.setKeyPlacement(keyPlacement); + return encrypter.encrypt(nameID); + } + catch (EncryptionException e) { + throw new Saml2Exception("Unable to encrypt nameID.", e); + } + } + + static Encrypter getEncrypter(X509Certificate certificate) { + Credential credential = CredentialSupport.getSimpleCredential(certificate, null); + final String dataAlgorithm = XMLCipherParameters.AES_256; + final String keyAlgorithm = XMLCipherParameters.RSA_1_5; + SecretKey secretKey = generateKeyFromURI(dataAlgorithm); + BasicCredential dataCredential = new BasicCredential(secretKey); + DataEncryptionParameters dataEncryptionParameters = new DataEncryptionParameters(); + dataEncryptionParameters.setEncryptionCredential(dataCredential); + dataEncryptionParameters.setAlgorithm(dataAlgorithm); + + KeyEncryptionParameters keyEncryptionParameters = new KeyEncryptionParameters(); + keyEncryptionParameters.setEncryptionCredential(credential); + keyEncryptionParameters.setAlgorithm(keyAlgorithm); + + Encrypter encrypter = new Encrypter(dataEncryptionParameters, asList(keyEncryptionParameters)); + + return encrypter; + } + + static SecretKey generateKeyFromURI(String algoURI) { + try { + String jceAlgorithmName = JCEMapper.getJCEKeyAlgorithmFromURI(algoURI); + int keyLength = JCEMapper.getKeyLengthFromURI(algoURI); + return generateKey(jceAlgorithmName, keyLength, null); + } + catch (NoSuchAlgorithmException | NoSuchProviderException e) { + throw new Saml2Exception(e); + } + } + + /** + * Builds an empty response. The ID of the message is {@link #OUTBOUND_MSG_ID}, the + * issue instant is 1970-01-01T00:00:00Z and the SAML version is + * {@link SAMLVersion#VERSION_11}. + * @return the constructed response + */ + @Nonnull + static Response buildResponse() { + final SAMLObjectBuilder responseBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(Response.DEFAULT_ELEMENT_NAME); + + final Response response = responseBuilder.buildObject(); + response.setID(OUTBOUND_MSG_ID); + response.setIssueInstant(DateTime.now()); + response.setVersion(SAMLVersion.VERSION_20); + + return response; + } + + /** + * Builds an empty artifact response. The ID of the message is + * {@link #OUTBOUND_MSG_ID}, the issue instant is 1970-01-01T00:00:00Z and the SAML + * version is {@link SAMLVersion#VERSION_11}. + * @return the constructed response + */ + @Nonnull + static ArtifactResponse buildArtifactResponse() { + final SAMLObjectBuilder responseBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(ArtifactResponse.DEFAULT_ELEMENT_NAME); + + final ArtifactResponse response = responseBuilder.buildObject(); + response.setID(OUTBOUND_MSG_ID); + response.setIssueInstant(DateTime.now()); + response.setVersion(SAMLVersion.VERSION_20); + + return response; + } + + /** + * Builds an {@link LogoutRequest}. If a {@link NameID} is given, it will be added to + * the constructed {@link LogoutRequest}. + * @param name the NameID to add to the request + * @return the built request + */ + @Nonnull + static LogoutRequest buildLogoutRequest(final @Nullable NameID name) { + final SAMLObjectBuilder issuerBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(Issuer.DEFAULT_ELEMENT_NAME); + + final SAMLObjectBuilder reqBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(LogoutRequest.DEFAULT_ELEMENT_NAME); + + final Issuer issuer = issuerBuilder.buildObject(); + issuer.setValue(INBOUND_MSG_ISSUER); + + final LogoutRequest req = reqBuilder.buildObject(); + req.setID(REQUEST_ID); + req.setIssueInstant(DateTime.now()); + req.setIssuer(issuer); + req.setVersion(SAMLVersion.VERSION_20); + + if (name != null) { + req.setNameID(name); + } + + return req; + } + + /** + * Builds an empty logout response. The ID of the message is {@link #OUTBOUND_MSG_ID}, + * the issue instant is 1970-01-01T00:00:00Z and the SAML version is + * {@link SAMLVersion#VERSION_11}. + * @return the constructed response + */ + @Nonnull + static LogoutResponse buildLogoutResponse() { + final SAMLObjectBuilder responseBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(LogoutResponse.DEFAULT_ELEMENT_NAME); + + final LogoutResponse response = responseBuilder.buildObject(); + response.setID(OUTBOUND_MSG_ID); + response.setIssueInstant(DateTime.now()); + response.setVersion(SAMLVersion.VERSION_20); + + return response; + } + + /** + * Builds an empty assertion. The ID of the message is {@link #ASSERTION_ID}, the + * issue instant is 1970-01-01T00:00:00Z and the SAML version is + * {@link SAMLVersion#VERSION_11}. + * @return the constructed assertion + */ + @Nonnull + static Assertion buildAssertion() { + final SAMLObjectBuilder assertionBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(Assertion.DEFAULT_ELEMENT_NAME); + + final Assertion assertion = assertionBuilder.buildObject(); + assertion.setID(ASSERTION_ID); + assertion.setIssueInstant(DateTime.now()); + assertion.setVersion(SAMLVersion.VERSION_20); + + return assertion; + } + + @Nonnull + static SubjectConfirmation buildSubjectConfirmation() { + final SAMLObjectBuilder subjectConfirmation = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(SubjectConfirmation.DEFAULT_ELEMENT_NAME); + + return subjectConfirmation.buildObject(); + } + + /** + * Builds an authentication statement. The authn instant is set to + * 1970-01-01T00:00:00Z. + * @return the constructed statement + */ + @Nonnull + static AuthnStatement buildAuthnStatement() { + final SAMLObjectBuilder statementBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(AuthnStatement.DEFAULT_ELEMENT_NAME); + + final AuthnStatement statement = statementBuilder.buildObject(); + statement.setAuthnInstant(DateTime.now()); + + return statement; + } + + /** + * Builds an empty attribute statement. + * @return the constructed statement + */ + @Nonnull + static AttributeStatement buildAttributeStatement() { + final SAMLObjectBuilder statementBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(AttributeStatement.DEFAULT_ELEMENT_NAME); + + final AttributeStatement statement = statementBuilder.buildObject(); + + return statement; + } + + /** + * Builds a {@link Subject}. If a principal name is given a {@link NameID}, whose + * value is the given principal name, will be created and added to the + * {@link Subject}. + * @param principalName the principal name to add to the subject + * @return the built subject + */ + @Nonnull + static Subject buildSubject(final @Nullable String principalName) { + final SAMLObjectBuilder subjectBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(Subject.DEFAULT_ELEMENT_NAME); + final Subject subject = subjectBuilder.buildObject(); + + if (principalName != null) { + subject.setNameID(buildNameID(principalName)); + } + + return subject; + } + + @Nonnull + static SubjectConfirmationData buildSubjectConfirmationData(String localSpEntityId) { + final SAMLObjectBuilder subjectBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory() + .getBuilderOrThrow(SubjectConfirmationData.DEFAULT_ELEMENT_NAME); + final SubjectConfirmationData subject = subjectBuilder.buildObject(); + subject.setRecipient(localSpEntityId); + subject.setNotBefore(DateTime.now().minus(Duration.millis(5 * 60 * 1000))); + subject.setNotOnOrAfter(DateTime.now().plus(Duration.millis(5 * 60 * 1000))); + return subject; + } + + @Nonnull + static Conditions buildConditions() { + final SAMLObjectBuilder subjectBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(Conditions.DEFAULT_ELEMENT_NAME); + final Conditions conditions = subjectBuilder.buildObject(); + conditions.setNotBefore(DateTime.now().minus(Duration.millis(5 * 60 * 1000))); + conditions.setNotOnOrAfter(DateTime.now().plus(Duration.millis(5 * 60 * 1000))); + return conditions; + } + + /** + * Builds a {@link NameID}. + * @param principalName the principal name to use in the NameID + * @return the built NameID + */ + @Nonnull + static NameID buildNameID(final @Nonnull @NotEmpty String principalName) { + final SAMLObjectBuilder nameIdBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(NameID.DEFAULT_ELEMENT_NAME); + final NameID nameId = nameIdBuilder.buildObject(); + nameId.setValue(principalName); + return nameId; + } + + /** + * Builds a {@link Issuer}. + * @param entityID the entity ID to use in the Issuer + * @return the built Issuer + */ + @Nonnull + static Issuer buildIssuer(final @Nonnull @NotEmpty String entityID) { + final SAMLObjectBuilder issuerBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(Issuer.DEFAULT_ELEMENT_NAME); + final Issuer issuer = issuerBuilder.buildObject(); + issuer.setValue(entityID); + return issuer; + } + + /** + * Builds an {@link AttributeQuery}. If a {@link Subject} is given, it will be added + * to the constructed {@link AttributeQuery}. + * @param subject the subject to add to the query + * @return the built query + */ + @Nonnull + static AttributeQuery buildAttributeQueryRequest(final @Nullable Subject subject) { + final SAMLObjectBuilder issuerBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(Issuer.DEFAULT_ELEMENT_NAME); + + final SAMLObjectBuilder queryBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(AttributeQuery.DEFAULT_ELEMENT_NAME); + + final Issuer issuer = issuerBuilder.buildObject(); + issuer.setValue(INBOUND_MSG_ISSUER); + + final AttributeQuery query = queryBuilder.buildObject(); + query.setID(REQUEST_ID); + query.setIssueInstant(DateTime.now()); + query.setIssuer(issuer); + query.setVersion(SAMLVersion.VERSION_20); + + if (subject != null) { + query.setSubject(subject); + } + + return query; + } + + /** + * Builds an {@link AuthnRequest}. + * @return the built request + */ + @Nonnull + static AuthnRequest buildAuthnRequest() { + final SAMLObjectBuilder issuerBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(Issuer.DEFAULT_ELEMENT_NAME); + + final SAMLObjectBuilder requestBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(AuthnRequest.DEFAULT_ELEMENT_NAME); + + final Issuer issuer = issuerBuilder.buildObject(); + issuer.setValue(INBOUND_MSG_ISSUER); + + final AuthnRequest request = requestBuilder.buildObject(); + request.setID(REQUEST_ID); + request.setIssueInstant(DateTime.now()); + request.setIssuer(issuer); + request.setVersion(SAMLVersion.VERSION_20); + + return request; + } + + /** + * Builds a {@link ArtifactResolve}. + * @param artifact the artifact to add to the request + * @return the built request + */ + @Nonnull + static ArtifactResolve buildArtifactResolve(final @Nullable String artifact) { + final SAMLObjectBuilder requestBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(ArtifactResolve.DEFAULT_ELEMENT_NAME); + final ArtifactResolve request = requestBuilder.buildObject(); + request.setID(REQUEST_ID); + request.setIssueInstant(DateTime.now()); + request.setVersion(SAMLVersion.VERSION_11); + + if (artifact != null) { + final SAMLObjectBuilder artifactBuilder = (SAMLObjectBuilder) XMLObjectProviderRegistrySupport + .getBuilderFactory().getBuilderOrThrow(Artifact.DEFAULT_ELEMENT_NAME); + final Artifact art = artifactBuilder.buildObject(); + art.setArtifact(artifact); + request.setArtifact(art); + } + + return request; + } + + /** ID of the inbound message. */ + public final static String INBOUND_MSG_ID = "inbound"; + + /** Issuer of the inbound message. */ + public final static String INBOUND_MSG_ISSUER = "http://sp.example.org"; + + /** ID of the outbound message. */ + public final static String OUTBOUND_MSG_ID = "outbound"; + + /** Issuer of the outbound message. */ + public final static String OUTBOUND_MSG_ISSUER = "http://idp.example.org"; + + /** + * Checks that the request context contains an EventContext, and that the event + * content is as given. + * @param profileRequestContext the context to check + * @param event event to check + */ + static void assertEvent(@Nonnull final ProfileRequestContext profileRequestContext, + @Nonnull final Object event) { + EventContext ctx = profileRequestContext.getSubcontext(EventContext.class); + Assert.assertNotNull(ctx); + Assert.assertEquals(ctx.getEvent(), event); + } + + /** + * Checks that the given request context does not contain an EventContext (thus + * signaling a "proceed" event). + * @param profileRequestContext the context to check + */ + static void assertProceedEvent(@Nonnull final ProfileRequestContext profileRequestContext) { + EventContext ctx = profileRequestContext.getSubcontext(EventContext.class); + Assert.assertTrue(ctx == null || ctx.getEvent().equals(EventIds.PROCEED_EVENT_ID)); + } + +} diff --git a/samples/boot/saml2login/src/integration-test/java/org/springframework/security/samples/Saml2LoginIntegrationTests.java b/samples/boot/saml2login/src/integration-test/java/org/springframework/security/samples/Saml2LoginIntegrationTests.java new file mode 100644 index 0000000000..f405126680 --- /dev/null +++ b/samples/boot/saml2login/src/integration-test/java/org/springframework/security/samples/Saml2LoginIntegrationTests.java @@ -0,0 +1,362 @@ +/* + * Copyright 2002-2019 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.samples; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import net.shibboleth.utilities.java.support.xml.SerializeSupport; +import org.joda.time.DateTime; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.opensaml.core.xml.XMLObject; +import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; +import org.opensaml.core.xml.io.MarshallerFactory; +import org.opensaml.core.xml.io.MarshallingException; +import org.opensaml.saml.common.SignableSAMLObject; +import org.opensaml.saml.saml2.core.Assertion; +import org.opensaml.saml.saml2.core.EncryptedAssertion; +import org.opensaml.saml.saml2.core.EncryptedID; +import org.opensaml.saml.saml2.core.Response; +import org.opensaml.saml.saml2.core.SubjectConfirmation; +import org.opensaml.saml.saml2.core.SubjectConfirmationData; +import org.opensaml.security.SecurityException; +import org.opensaml.security.credential.BasicCredential; +import org.opensaml.security.credential.Credential; +import org.opensaml.security.credential.CredentialSupport; +import org.opensaml.security.credential.UsageType; +import org.opensaml.security.crypto.KeySupport; +import org.opensaml.xmlsec.SignatureSigningParameters; +import org.opensaml.xmlsec.signature.support.SignatureConstants; +import org.opensaml.xmlsec.signature.support.SignatureException; +import org.opensaml.xmlsec.signature.support.SignatureSupport; +import org.w3c.dom.Element; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.UUID; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.startsWith; +import static org.springframework.security.samples.OpenSamlActionTestingSupport.buildConditions; +import static org.springframework.security.samples.OpenSamlActionTestingSupport.buildIssuer; +import static org.springframework.security.samples.OpenSamlActionTestingSupport.buildSubject; +import static org.springframework.security.samples.OpenSamlActionTestingSupport.buildSubjectConfirmation; +import static org.springframework.security.samples.OpenSamlActionTestingSupport.buildSubjectConfirmationData; +import static org.springframework.security.samples.OpenSamlActionTestingSupport.encryptNameId; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +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; + +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +public class Saml2LoginIntegrationTests { + + static final String LOCAL_SP_ENTITY_ID = "http://localhost:8080/saml2/service-provider-metadata/simplesamlphp"; + + @Autowired + MockMvc mockMvc; + + @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan(basePackages = "sample") + public static class SpringBootApplicationTestConfig { + } + + @Test + public void redirectToLoginPageSingleProvider() throws Exception { + mockMvc.perform(get("http://localhost:8080/some/url")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost:8080/saml2/authenticate/simplesamlphp")); + } + + @Test + public void testAuthNRequest() throws Exception { + mockMvc.perform(get("http://localhost:8080/saml2/authenticate/simplesamlphp")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", startsWith("https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SSOService.php?SAMLRequest="))); + } + + @Test + public void testRelayState() throws Exception { + mockMvc.perform( + get("http://localhost:8080/saml2/authenticate/simplesamlphp") + .param("RelayState", "relay state value with spaces") + ) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", startsWith("https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SSOService.php?SAMLRequest="))) + .andExpect(header().string("Location", containsString("RelayState=relay%20state%20value%20with%20spaces"))); + } + + @Test + public void signedResponse() throws Exception { + final String username = "testuser@spring.security.saml"; + Assertion assertion = buildAssertion(username); + Response response = buildResponse(assertion); + signXmlObject(response, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING)); + String xml = toXml(response); + mockMvc.perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8)))) + .andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/")) + .andExpect(authenticated().withUsername(username)); + } + + @Test + public void signedAssertion() throws Exception { + final String username = "testuser@spring.security.saml"; + Assertion assertion = buildAssertion(username); + Response response = buildResponse(assertion); + signXmlObject(assertion, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING)); + String xml = toXml(response); + final ResultActions actions = mockMvc + .perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8)))) + .andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/")) + .andExpect(authenticated().withUsername(username)); + } + + @Test + public void unsigned() throws Exception { + Assertion assertion = buildAssertion("testuser@spring.security.saml"); + Response response = buildResponse(assertion); + String xml = toXml(response); + mockMvc.perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8)))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login?error")) + .andExpect(unauthenticated()); + } + + @Test + public void signedResponseEncryptedAssertion() throws Exception { + final String username = "testuser@spring.security.saml"; + Assertion assertion = buildAssertion(username); + EncryptedAssertion encryptedAssertion = + OpenSamlActionTestingSupport.encryptAssertion(assertion, decodeCertificate(spCertificate)); + Response response = buildResponse(encryptedAssertion); + signXmlObject(assertion, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING)); + String xml = toXml(response); + final ResultActions actions = mockMvc + .perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8)))) + .andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/")) + .andExpect(authenticated().withUsername(username)); + } + + @Test + public void unsignedResponseEncryptedAssertion() throws Exception { + final String username = "testuser@spring.security.saml"; + Assertion assertion = buildAssertion(username); + EncryptedAssertion encryptedAssertion = + OpenSamlActionTestingSupport.encryptAssertion(assertion, decodeCertificate(spCertificate)); + Response response = buildResponse(encryptedAssertion); + String xml = toXml(response); + final ResultActions actions = mockMvc + .perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8)))) + .andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/")) + .andExpect(authenticated().withUsername(username)); + } + + @Test + public void signedResponseEncryptedNameId() throws Exception { + final String username = "testuser@spring.security.saml"; + Assertion assertion = buildAssertion(username); + final EncryptedID nameId = encryptNameId(assertion.getSubject().getNameID(), decodeCertificate(spCertificate)); + assertion.getSubject().setEncryptedID(nameId); + assertion.getSubject().setNameID(null); + Response response = buildResponse(assertion); + signXmlObject(assertion, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING)); + String xml = toXml(response); + final ResultActions actions = mockMvc + .perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8)))) + .andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/")) + .andExpect(authenticated().withUsername(username)); + } + + private Response buildResponse(Assertion assertion) { + Response response = buildResponse(); + response.getAssertions().add(assertion); + return response; + } + + private Response buildResponse(EncryptedAssertion assertion) { + Response response = buildResponse(); + response.getEncryptedAssertions().add(assertion); + return response; + } + + private Response buildResponse() { + Response response = OpenSamlActionTestingSupport.buildResponse(); + response.setID("_" + UUID.randomUUID().toString()); + response.setDestination("http://localhost:8080/login/saml2/sso/simplesamlphp"); + response.setIssuer(buildIssuer("https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php")); + return response; + } + + private Assertion buildAssertion(String username) { + Assertion assertion = OpenSamlActionTestingSupport.buildAssertion(); + assertion.setIssueInstant(DateTime.now()); + assertion.setIssuer(buildIssuer("https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php")); + assertion.setSubject(buildSubject(username)); + assertion.setConditions(buildConditions()); + + SubjectConfirmation subjectConfirmation = buildSubjectConfirmation(); + + // Default to bearer with basic valid confirmation data, but the test can change + // as appropriate + subjectConfirmation.setMethod(SubjectConfirmation.METHOD_BEARER); + final SubjectConfirmationData confirmationData = buildSubjectConfirmationData(LOCAL_SP_ENTITY_ID); + confirmationData.setRecipient("http://localhost:8080/login/saml2/sso/simplesamlphp"); + subjectConfirmation.setSubjectConfirmationData(confirmationData); + assertion.getSubject().getSubjectConfirmations().add(subjectConfirmation); + return assertion; + } + + protected Credential getSigningCredential(String certificate, String key, UsageType usageType) + throws CertificateException, KeyException { + PublicKey publicKey = decodeCertificate(certificate).getPublicKey(); + final PrivateKey privateKey = KeySupport.decodePrivateKey(key.getBytes(UTF_8), new char[0]); + BasicCredential cred = CredentialSupport.getSimpleCredential(publicKey, privateKey); + cred.setUsageType(usageType); + cred.setEntityId("https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php"); + return cred; + } + + private void signXmlObject(SignableSAMLObject object, Credential credential) + throws MarshallingException, SecurityException, SignatureException { + SignatureSigningParameters parameters = new SignatureSigningParameters(); + parameters.setSigningCredential(credential); + parameters.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256); + parameters.setSignatureReferenceDigestMethod(SignatureConstants.ALGO_ID_DIGEST_SHA256); + parameters.setSignatureCanonicalizationAlgorithm(SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS); + SignatureSupport.signObject(object, parameters); + } + + private String toXml(XMLObject object) throws MarshallingException { + final MarshallerFactory marshallerFactory = XMLObjectProviderRegistrySupport.getMarshallerFactory(); + Element element = marshallerFactory.getMarshaller(object).marshall(object); + return SerializeSupport.nodeToString(element); + } + + private X509Certificate decodeCertificate(String source) { + try { + final CertificateFactory factory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) factory.generateCertificate( + new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8)) + ); + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } + + private String idpCertificate = "-----BEGIN CERTIFICATE-----\n" + + "MIIEEzCCAvugAwIBAgIJAIc1qzLrv+5nMA0GCSqGSIb3DQEBCwUAMIGfMQswCQYD\n" + + "VQQGEwJVUzELMAkGA1UECAwCQ08xFDASBgNVBAcMC0Nhc3RsZSBSb2NrMRwwGgYD\n" + + "VQQKDBNTYW1sIFRlc3RpbmcgU2VydmVyMQswCQYDVQQLDAJJVDEgMB4GA1UEAwwX\n" + + "c2ltcGxlc2FtbHBocC5jZmFwcHMuaW8xIDAeBgkqhkiG9w0BCQEWEWZoYW5pa0Bw\n" + + "aXZvdGFsLmlvMB4XDTE1MDIyMzIyNDUwM1oXDTI1MDIyMjIyNDUwM1owgZ8xCzAJ\n" + + "BgNVBAYTAlVTMQswCQYDVQQIDAJDTzEUMBIGA1UEBwwLQ2FzdGxlIFJvY2sxHDAa\n" + + "BgNVBAoME1NhbWwgVGVzdGluZyBTZXJ2ZXIxCzAJBgNVBAsMAklUMSAwHgYDVQQD\n" + + "DBdzaW1wbGVzYW1scGhwLmNmYXBwcy5pbzEgMB4GCSqGSIb3DQEJARYRZmhhbmlr\n" + + "QHBpdm90YWwuaW8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC4cn62\n" + + "E1xLqpN34PmbrKBbkOXFjzWgJ9b+pXuaRft6A339uuIQeoeH5qeSKRVTl32L0gdz\n" + + "2ZivLwZXW+cqvftVW1tvEHvzJFyxeTW3fCUeCQsebLnA2qRa07RkxTo6Nf244mWW\n" + + "RDodcoHEfDUSbxfTZ6IExSojSIU2RnD6WllYWFdD1GFpBJOmQB8rAc8wJIBdHFdQ\n" + + "nX8Ttl7hZ6rtgqEYMzYVMuJ2F2r1HSU1zSAvwpdYP6rRGFRJEfdA9mm3WKfNLSc5\n" + + "cljz0X/TXy0vVlAV95l9qcfFzPmrkNIst9FZSwpvB49LyAVke04FQPPwLgVH4gph\n" + + "iJH3jvZ7I+J5lS8VAgMBAAGjUDBOMB0GA1UdDgQWBBTTyP6Cc5HlBJ5+ucVCwGc5\n" + + "ogKNGzAfBgNVHSMEGDAWgBTTyP6Cc5HlBJ5+ucVCwGc5ogKNGzAMBgNVHRMEBTAD\n" + + "AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAvMS4EQeP/ipV4jOG5lO6/tYCb/iJeAduO\n" + + "nRhkJk0DbX329lDLZhTTL/x/w/9muCVcvLrzEp6PN+VWfw5E5FWtZN0yhGtP9R+v\n" + + "ZnrV+oc2zGD+no1/ySFOe3EiJCO5dehxKjYEmBRv5sU/LZFKZpozKN/BMEa6CqLu\n" + + "xbzb7ykxVr7EVFXwltPxzE9TmL9OACNNyF5eJHWMRMllarUvkcXlh4pux4ks9e6z\n" + + "V9DQBy2zds9f1I3qxg0eX6JnGrXi/ZiCT+lJgVe3ZFXiejiLAiKB04sXW3ti0LW3\n" + + "lx13Y1YlQ4/tlpgTgfIJxKV6nyPiLoK0nywbMd+vpAirDt2Oc+hk\n" + "-----END CERTIFICATE-----\n"; + + private String idpPrivateKey = "-----BEGIN PRIVATE KEY-----\n" + + "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4cn62E1xLqpN3\n" + + "4PmbrKBbkOXFjzWgJ9b+pXuaRft6A339uuIQeoeH5qeSKRVTl32L0gdz2ZivLwZX\n" + + "W+cqvftVW1tvEHvzJFyxeTW3fCUeCQsebLnA2qRa07RkxTo6Nf244mWWRDodcoHE\n" + + "fDUSbxfTZ6IExSojSIU2RnD6WllYWFdD1GFpBJOmQB8rAc8wJIBdHFdQnX8Ttl7h\n" + + "Z6rtgqEYMzYVMuJ2F2r1HSU1zSAvwpdYP6rRGFRJEfdA9mm3WKfNLSc5cljz0X/T\n" + + "Xy0vVlAV95l9qcfFzPmrkNIst9FZSwpvB49LyAVke04FQPPwLgVH4gphiJH3jvZ7\n" + + "I+J5lS8VAgMBAAECggEBAKyxBlIS7mcp3chvq0RF7B3PHFJMMzkwE+t3pLJcs4cZ\n" + + "nezh/KbREfP70QjXzk/llnZCvxeIs5vRu24vbdBm79qLHqBuHp8XfHHtuo2AfoAQ\n" + + "l4h047Xc/+TKMivnPQ0jX9qqndKDLqZDf5wnbslDmlskvF0a/MjsLU0TxtOfo+dB\n" + + "t55FW11cGqxZwhS5Gnr+cbw3OkHz23b9gEOt9qfwPVepeysbmm9FjU+k4yVa7rAN\n" + + "xcbzVb6Y7GCITe2tgvvEHmjB9BLmWrH3mZ3Af17YU/iN6TrpPd6Sj3QoS+2wGtAe\n" + + "HbUs3CKJu7bIHcj4poal6Kh8519S+erJTtqQ8M0ZiEECgYEA43hLYAPaUueFkdfh\n" + + "9K/7ClH6436CUH3VdizwUXi26fdhhV/I/ot6zLfU2mgEHU22LBECWQGtAFm8kv0P\n" + + "zPn+qjaR3e62l5PIlSYbnkIidzoDZ2ztu4jF5LgStlTJQPteFEGgZVl5o9DaSZOq\n" + + "Yd7G3XqXuQ1VGMW58G5FYJPtA1cCgYEAz5TPUtK+R2KXHMjUwlGY9AefQYRYmyX2\n" + + "Tn/OFgKvY8lpAkMrhPKONq7SMYc8E9v9G7A0dIOXvW7QOYSapNhKU+np3lUafR5F\n" + + "4ZN0bxZ9qjHbn3AMYeraKjeutHvlLtbHdIc1j3sxe/EzltRsYmiqLdEBW0p6hwWg\n" + + "tyGhYWVyaXMCgYAfDOKtHpmEy5nOCLwNXKBWDk7DExfSyPqEgSnk1SeS1HP5ctPK\n" + + "+1st6sIhdiVpopwFc+TwJWxqKdW18tlfT5jVv1E2DEnccw3kXilS9xAhWkfwrEvf\n" + + "V5I74GydewFl32o+NZ8hdo9GL1I8zO1rIq/et8dSOWGuWf9BtKu/vTGTTQKBgFxU\n" + + "VjsCnbvmsEwPUAL2hE/WrBFaKocnxXx5AFNt8lEyHtDwy4Sg1nygGcIJ4sD6koQk\n" + + "RdClT3LkvR04TAiSY80bN/i6ZcPNGUwSaDGZEWAIOSWbkwZijZNFnSGOEgxZX/IG\n" + + "yd39766vREEMTwEeiMNEOZQ/dmxkJm4OOVe25cLdAoGACOtPnq1Fxay80UYBf4rQ\n" + + "+bJ9yX1ulB8WIree1hD7OHSB2lRHxrVYWrglrTvkh63Lgx+EcsTV788OsvAVfPPz\n" + + "BZrn8SdDlQqalMxUBYEFwnsYD3cQ8yOUnijFVC4xNcdDv8OIqVgSk4KKxU5AshaA\n" + "xk6Mox+u8Cc2eAK12H13i+8=\n" + + "-----END PRIVATE KEY-----\n"; + + private String spCertificate = "-----BEGIN CERTIFICATE-----\n" + + "MIICgTCCAeoCCQCuVzyqFgMSyDANBgkqhkiG9w0BAQsFADCBhDELMAkGA1UEBhMC\n" + + "VVMxEzARBgNVBAgMCldhc2hpbmd0b24xEjAQBgNVBAcMCVZhbmNvdXZlcjEdMBsG\n" + + "A1UECgwUU3ByaW5nIFNlY3VyaXR5IFNBTUwxCzAJBgNVBAsMAnNwMSAwHgYDVQQD\n" + + "DBdzcC5zcHJpbmcuc2VjdXJpdHkuc2FtbDAeFw0xODA1MTQxNDMwNDRaFw0yODA1\n" + + "MTExNDMwNDRaMIGEMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGluZ3RvbjES\n" + + "MBAGA1UEBwwJVmFuY291dmVyMR0wGwYDVQQKDBRTcHJpbmcgU2VjdXJpdHkgU0FN\n" + + "TDELMAkGA1UECwwCc3AxIDAeBgNVBAMMF3NwLnNwcmluZy5zZWN1cml0eS5zYW1s\n" + + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDRu7/EI0BlNzMEBFVAcbx+lLos\n" + + "vzIWU+01dGTY8gBdhMQNYKZ92lMceo2CuVJ66cUURPym3i7nGGzoSnAxAre+0YIM\n" + + "+U0razrWtAUE735bkcqELZkOTZLelaoOztmWqRbe5OuEmpewH7cx+kNgcVjdctOG\n" + + "y3Q6x+I4qakY/9qhBQIDAQABMA0GCSqGSIb3DQEBCwUAA4GBAAeViTvHOyQopWEi\n" + + "XOfI2Z9eukwrSknDwq/zscR0YxwwqDBMt/QdAODfSwAfnciiYLkmEjlozWRtOeN+\n" + + "qK7UFgP1bRl5qksrYX5S0z2iGJh0GvonLUt3e20Ssfl5tTEDDnAEUMLfBkyaxEHD\n" + + "RZ/nbTJ7VTeZOSyRoVn5XHhpuJ0B\n" + + "-----END CERTIFICATE-----"; + +} diff --git a/samples/boot/saml2login/src/main/java/boot/saml2/config/Saml2LoginBootConfiguration.java b/samples/boot/saml2login/src/main/java/boot/saml2/config/Saml2LoginBootConfiguration.java new file mode 100644 index 0000000000..cd6bf8adfb --- /dev/null +++ b/samples/boot/saml2login/src/main/java/boot/saml2/config/Saml2LoginBootConfiguration.java @@ -0,0 +1,183 @@ +/* + * Copyright 2002-2019 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 boot.saml2.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.security.saml2.credentials.Saml2X509Credential; +import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationFilter; +import org.springframework.util.StringUtils; + +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPrivateKey; +import java.util.LinkedList; +import java.util.List; +import java.util.stream.Collectors; + +import static java.util.Collections.emptyList; +import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.DECRYPTION; +import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.ENCRYPTION; +import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.SIGNING; +import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.VERIFICATION; + +@Configuration +@ConfigurationProperties(prefix = "spring.security.saml2.login") +@Import(X509CredentialsConverters.class) +public class Saml2LoginBootConfiguration { + + private List relyingParties; + + @Bean + @ConditionalOnMissingBean + public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() { + return new InMemoryRelyingPartyRegistrationRepository(getRelyingParties(relyingParties)); + } + + public void setRelyingParties(List providers) { + this.relyingParties = providers; + } + + private List getRelyingParties(List sampleRelyingParties) { + String acsUrlTemplate = "{baseUrl}" + Saml2WebSsoAuthenticationFilter.DEFAULT_FILTER_PROCESSES_URI; + return sampleRelyingParties.stream() + .map( + p -> StringUtils.hasText(p.getLocalSpEntityIdTemplate()) ? + RelyingPartyRegistration.withRegistrationId(p.getRegistrationId()) + .assertionConsumerServiceUrlTemplate(acsUrlTemplate) + .remoteIdpEntityId(p.getEntityId()) + .idpWebSsoUrl(p.getWebSsoUrl()) + .credentials(c -> c.addAll(p.getProviderCredentials())) + .localEntityIdTemplate(p.getLocalSpEntityIdTemplate()) + .build() : + RelyingPartyRegistration.withRegistrationId(p.getRegistrationId()) + .assertionConsumerServiceUrlTemplate(acsUrlTemplate) + .remoteIdpEntityId(p.getEntityId()) + .idpWebSsoUrl(p.getWebSsoUrl()) + .credentials(c -> c.addAll(p.getProviderCredentials())) + .build() + ) + .collect(Collectors.toList()); + } + + public static class SampleRelyingParty { + + private String entityId; + private List signingCredentials = emptyList(); + private List verificationCredentials = emptyList(); + private String registrationId; + private String webSsoUrl; + private String localSpEntityIdTemplate; + + public String getEntityId() { + return entityId; + } + + public String getLocalSpEntityIdTemplate() { + return localSpEntityIdTemplate; + } + + public void setEntityId(String entityId) { + this.entityId = entityId; + } + + public List getSigningCredentials() { + return signingCredentials; + } + + public void setSigningCredentials(List credentials) { + this.signingCredentials = credentials + .stream() + .map(c -> + new Saml2X509Credential( + c.getPrivateKey(), + c.getCertificate(), + SIGNING, + DECRYPTION + ) + ) + .collect(Collectors.toList()); + } + + public void setVerificationCredentials(List credentials) { + this.verificationCredentials = new LinkedList<>(credentials); + } + + public List getVerificationCredentials() { + return verificationCredentials; + } + + public List getProviderCredentials() { + LinkedList result = new LinkedList<>(getSigningCredentials()); + for (X509Certificate c : getVerificationCredentials()) { + result.add(new Saml2X509Credential(c, ENCRYPTION, VERIFICATION)); + } + return result; + } + + public String getRegistrationId() { + return registrationId; + } + + public SampleRelyingParty setRegistrationId(String registrationId) { + this.registrationId = registrationId; + return this; + } + + public String getWebSsoUrl() { + return webSsoUrl; + } + + public SampleRelyingParty setWebSsoUrl(String webSsoUrl) { + this.webSsoUrl = webSsoUrl; + return this; + } + + public void setLocalSpEntityIdTemplate(String localSpEntityIdTemplate) { + this.localSpEntityIdTemplate = localSpEntityIdTemplate; + } + } + + public static class X509KeyCertificatePair { + + private RSAPrivateKey privateKey; + private X509Certificate certificate; + + public RSAPrivateKey getPrivateKey() { + return this.privateKey; + } + + public void setPrivateKey(RSAPrivateKey privateKey) { + this.privateKey = privateKey; + } + + public X509Certificate getCertificate() { + return certificate; + } + + public void setCertificate(X509Certificate certificate) { + this.certificate = certificate; + } + + } + +} diff --git a/samples/boot/saml2login/src/main/java/boot/saml2/config/X509CredentialsConverters.java b/samples/boot/saml2login/src/main/java/boot/saml2/config/X509CredentialsConverters.java new file mode 100644 index 0000000000..6cdb295046 --- /dev/null +++ b/samples/boot/saml2login/src/main/java/boot/saml2/config/X509CredentialsConverters.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2019 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 boot.saml2.config; + +import org.springframework.boot.context.properties.ConfigurationPropertiesBinding; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.converter.RsaKeyConverters; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPrivateKey; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@Configuration +public class X509CredentialsConverters { + + @Component + @ConfigurationPropertiesBinding + public static class X509CertificateConverter implements Converter { + @Override + public X509Certificate convert (String source){ + try { + final CertificateFactory factory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) factory.generateCertificate( + new ByteArrayInputStream(source.getBytes(UTF_8)) + ); + } + catch (Exception e) { + throw new IllegalArgumentException(e); + } + } + } + + @Component + @ConfigurationPropertiesBinding + public static class RSAPrivateKeyConverter implements Converter { + @Override + public RSAPrivateKey convert (String source){ + return RsaKeyConverters.pkcs8().convert(new ByteArrayInputStream(source.getBytes(UTF_8))); + } + } +} diff --git a/samples/boot/saml2login/src/main/java/sample/IndexController.java b/samples/boot/saml2login/src/main/java/sample/IndexController.java new file mode 100644 index 0000000000..3c336c4dfa --- /dev/null +++ b/samples/boot/saml2login/src/main/java/sample/IndexController.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2019 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 sample; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import sample.Saml2LoginApplication; + +@Controller +public class IndexController { + + private static final Log logger = LogFactory.getLog(Saml2LoginApplication.class); + + @GetMapping("/") + public String index() { + return "index"; + } +} diff --git a/samples/boot/saml2login/src/main/java/sample/Saml2LoginApplication.java b/samples/boot/saml2login/src/main/java/sample/Saml2LoginApplication.java new file mode 100644 index 0000000000..2b05ba2376 --- /dev/null +++ b/samples/boot/saml2login/src/main/java/sample/Saml2LoginApplication.java @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2019 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 sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; + +import boot.saml2.config.Saml2LoginBootConfiguration; + +@SpringBootApplication +@Import(Saml2LoginBootConfiguration.class) +public class Saml2LoginApplication { + + public static void main(String[] args) { + SpringApplication.run(Saml2LoginApplication.class, args); + } + +} diff --git a/samples/boot/saml2login/src/main/java/sample/SecurityConfig.java b/samples/boot/saml2login/src/main/java/sample/SecurityConfig.java new file mode 100644 index 0000000000..bf9ba6790d --- /dev/null +++ b/samples/boot/saml2login/src/main/java/sample/SecurityConfig.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2019 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 sample; + +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; + +@EnableWebSecurity +public class SecurityConfig extends WebSecurityConfigurerAdapter { + + @Override + protected void configure(HttpSecurity http) throws Exception { + //@formatter:off + http + .authorizeRequests() + .anyRequest().authenticated() + .and() + .saml2Login() + ; + //@formatter:on + } + +} diff --git a/samples/boot/saml2login/src/main/resources/application.yml b/samples/boot/saml2login/src/main/resources/application.yml new file mode 100644 index 0000000000..b7d6ab8382 --- /dev/null +++ b/samples/boot/saml2login/src/main/resources/application.yml @@ -0,0 +1,69 @@ +spring: + security: + saml2: + login: + relying-parties: + - entity-id: https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php + registration-id: simplesamlphp + web-sso-url: https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SSOService.php + signing-credentials: + - private-key: | + -----BEGIN PRIVATE KEY----- + MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBANG7v8QjQGU3MwQE + VUBxvH6Uuiy/MhZT7TV0ZNjyAF2ExA1gpn3aUxx6jYK5UnrpxRRE/KbeLucYbOhK + cDECt77Rggz5TStrOta0BQTvfluRyoQtmQ5Nkt6Vqg7O2ZapFt7k64Sal7AftzH6 + Q2BxWN1y04bLdDrH4jipqRj/2qEFAgMBAAECgYEAj4ExY1jjdN3iEDuOwXuRB+Nn + x7pC4TgntE2huzdKvLJdGvIouTArce8A6JM5NlTBvm69mMepvAHgcsiMH1zGr5J5 + wJz23mGOyhM1veON41/DJTVG+cxq4soUZhdYy3bpOuXGMAaJ8QLMbQQoivllNihd + vwH0rNSK8LTYWWPZYIECQQDxct+TFX1VsQ1eo41K0T4fu2rWUaxlvjUGhK6HxTmY + 8OMJptunGRJL1CUjIb45Uz7SP8TPz5FwhXWsLfS182kRAkEA3l+Qd9C9gdpUh1uX + oPSNIxn5hFUrSTW1EwP9QH9vhwb5Vr8Jrd5ei678WYDLjUcx648RjkjhU9jSMzIx + EGvYtQJBAMm/i9NR7IVyyNIgZUpz5q4LI21rl1r4gUQuD8vA36zM81i4ROeuCly0 + KkfdxR4PUfnKcQCX11YnHjk9uTFj75ECQEFY/gBnxDjzqyF35hAzrYIiMPQVfznt + YX/sDTE2AdVBVGaMj1Cb51bPHnNC6Q5kXKQnj/YrLqRQND09Q7ParX0CQQC5NxZr + 9jKqhHj8yQD6PlXTsY4Occ7DH6/IoDenfdEVD5qlet0zmd50HatN2Jiqm5ubN7CM + INrtuLp4YHbgk1mi + -----END PRIVATE KEY----- + certificate: | + -----BEGIN CERTIFICATE----- + MIICgTCCAeoCCQCuVzyqFgMSyDANBgkqhkiG9w0BAQsFADCBhDELMAkGA1UEBhMC + VVMxEzARBgNVBAgMCldhc2hpbmd0b24xEjAQBgNVBAcMCVZhbmNvdXZlcjEdMBsG + A1UECgwUU3ByaW5nIFNlY3VyaXR5IFNBTUwxCzAJBgNVBAsMAnNwMSAwHgYDVQQD + DBdzcC5zcHJpbmcuc2VjdXJpdHkuc2FtbDAeFw0xODA1MTQxNDMwNDRaFw0yODA1 + MTExNDMwNDRaMIGEMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGluZ3RvbjES + MBAGA1UEBwwJVmFuY291dmVyMR0wGwYDVQQKDBRTcHJpbmcgU2VjdXJpdHkgU0FN + TDELMAkGA1UECwwCc3AxIDAeBgNVBAMMF3NwLnNwcmluZy5zZWN1cml0eS5zYW1s + MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDRu7/EI0BlNzMEBFVAcbx+lLos + vzIWU+01dGTY8gBdhMQNYKZ92lMceo2CuVJ66cUURPym3i7nGGzoSnAxAre+0YIM + +U0razrWtAUE735bkcqELZkOTZLelaoOztmWqRbe5OuEmpewH7cx+kNgcVjdctOG + y3Q6x+I4qakY/9qhBQIDAQABMA0GCSqGSIb3DQEBCwUAA4GBAAeViTvHOyQopWEi + XOfI2Z9eukwrSknDwq/zscR0YxwwqDBMt/QdAODfSwAfnciiYLkmEjlozWRtOeN+ + qK7UFgP1bRl5qksrYX5S0z2iGJh0GvonLUt3e20Ssfl5tTEDDnAEUMLfBkyaxEHD + RZ/nbTJ7VTeZOSyRoVn5XHhpuJ0B + -----END CERTIFICATE----- + verification-credentials: + - | + -----BEGIN CERTIFICATE----- + MIIEEzCCAvugAwIBAgIJAIc1qzLrv+5nMA0GCSqGSIb3DQEBCwUAMIGfMQswCQYD + VQQGEwJVUzELMAkGA1UECAwCQ08xFDASBgNVBAcMC0Nhc3RsZSBSb2NrMRwwGgYD + VQQKDBNTYW1sIFRlc3RpbmcgU2VydmVyMQswCQYDVQQLDAJJVDEgMB4GA1UEAwwX + c2ltcGxlc2FtbHBocC5jZmFwcHMuaW8xIDAeBgkqhkiG9w0BCQEWEWZoYW5pa0Bw + aXZvdGFsLmlvMB4XDTE1MDIyMzIyNDUwM1oXDTI1MDIyMjIyNDUwM1owgZ8xCzAJ + BgNVBAYTAlVTMQswCQYDVQQIDAJDTzEUMBIGA1UEBwwLQ2FzdGxlIFJvY2sxHDAa + BgNVBAoME1NhbWwgVGVzdGluZyBTZXJ2ZXIxCzAJBgNVBAsMAklUMSAwHgYDVQQD + DBdzaW1wbGVzYW1scGhwLmNmYXBwcy5pbzEgMB4GCSqGSIb3DQEJARYRZmhhbmlr + QHBpdm90YWwuaW8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC4cn62 + E1xLqpN34PmbrKBbkOXFjzWgJ9b+pXuaRft6A339uuIQeoeH5qeSKRVTl32L0gdz + 2ZivLwZXW+cqvftVW1tvEHvzJFyxeTW3fCUeCQsebLnA2qRa07RkxTo6Nf244mWW + RDodcoHEfDUSbxfTZ6IExSojSIU2RnD6WllYWFdD1GFpBJOmQB8rAc8wJIBdHFdQ + nX8Ttl7hZ6rtgqEYMzYVMuJ2F2r1HSU1zSAvwpdYP6rRGFRJEfdA9mm3WKfNLSc5 + cljz0X/TXy0vVlAV95l9qcfFzPmrkNIst9FZSwpvB49LyAVke04FQPPwLgVH4gph + iJH3jvZ7I+J5lS8VAgMBAAGjUDBOMB0GA1UdDgQWBBTTyP6Cc5HlBJ5+ucVCwGc5 + ogKNGzAfBgNVHSMEGDAWgBTTyP6Cc5HlBJ5+ucVCwGc5ogKNGzAMBgNVHRMEBTAD + AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAvMS4EQeP/ipV4jOG5lO6/tYCb/iJeAduO + nRhkJk0DbX329lDLZhTTL/x/w/9muCVcvLrzEp6PN+VWfw5E5FWtZN0yhGtP9R+v + ZnrV+oc2zGD+no1/ySFOe3EiJCO5dehxKjYEmBRv5sU/LZFKZpozKN/BMEa6CqLu + xbzb7ykxVr7EVFXwltPxzE9TmL9OACNNyF5eJHWMRMllarUvkcXlh4pux4ks9e6z + V9DQBy2zds9f1I3qxg0eX6JnGrXi/ZiCT+lJgVe3ZFXiejiLAiKB04sXW3ti0LW3 + lx13Y1YlQ4/tlpgTgfIJxKV6nyPiLoK0nywbMd+vpAirDt2Oc+hk + -----END CERTIFICATE----- diff --git a/samples/boot/saml2login/src/main/resources/templates/index.html b/samples/boot/saml2login/src/main/resources/templates/index.html new file mode 100644 index 0000000000..5251b3a8e9 --- /dev/null +++ b/samples/boot/saml2login/src/main/resources/templates/index.html @@ -0,0 +1,37 @@ + + + + + + Spring Security - SAML 2 Log In + + + +

Success

+
You are authenticated as
+ + + diff --git a/samples/javaconfig/saml2login/spring-security-samples-javaconfig-saml2-login.gradle b/samples/javaconfig/saml2login/spring-security-samples-javaconfig-saml2-login.gradle new file mode 100644 index 0000000000..baa1385e4c --- /dev/null +++ b/samples/javaconfig/saml2login/spring-security-samples-javaconfig-saml2-login.gradle @@ -0,0 +1,10 @@ +apply plugin: 'io.spring.convention.spring-sample-war' + +dependencies { + compile project(':spring-security-saml2-service-provider') + compile project(':spring-security-config') + compile "org.bouncycastle:bcprov-jdk15on" + compile "org.bouncycastle:bcpkix-jdk15on" + + testCompile project(':spring-security-test') +} diff --git a/samples/javaconfig/saml2login/src/main/java/org/springframework/security/samples/config/MessageSecurityWebApplicationInitializer.java b/samples/javaconfig/saml2login/src/main/java/org/springframework/security/samples/config/MessageSecurityWebApplicationInitializer.java new file mode 100644 index 0000000000..7de3308deb --- /dev/null +++ b/samples/javaconfig/saml2login/src/main/java/org/springframework/security/samples/config/MessageSecurityWebApplicationInitializer.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2013 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.samples.config; + +import org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer; +import org.springframework.security.web.session.HttpSessionEventPublisher; + +/** + * We customize {@link AbstractSecurityWebApplicationInitializer} to enable the + * {@link HttpSessionEventPublisher}. + * + * @author Rob Winch + */ +public class MessageSecurityWebApplicationInitializer extends + AbstractSecurityWebApplicationInitializer { + + @Override + protected boolean enableHttpSessionEventPublisher() { + return true; + } +} diff --git a/samples/javaconfig/saml2login/src/main/java/org/springframework/security/samples/config/SecurityConfig.java b/samples/javaconfig/saml2login/src/main/java/org/springframework/security/samples/config/SecurityConfig.java new file mode 100644 index 0000000000..ea4f74a04a --- /dev/null +++ b/samples/javaconfig/saml2login/src/main/java/org/springframework/security/samples/config/SecurityConfig.java @@ -0,0 +1,162 @@ +/* + * Copyright 2002-2019 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.samples.config; + +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.converter.RsaKeyConverters; +import org.springframework.security.saml2.credentials.Saml2X509Credential; +import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationFilter; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.PrivateKey; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.DECRYPTION; +import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.SIGNING; +import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.VERIFICATION; + +@EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled = true) +public class SecurityConfig extends WebSecurityConfigurerAdapter { + + RelyingPartyRegistration getSaml2AuthenticationConfiguration() throws Exception { + //remote IDP entity ID + String idpEntityId = "https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php"; + //remote WebSSO Endpoint - Where to Send AuthNRequests to + String webSsoEndpoint = "https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SSOService.php"; + //local registration ID + String registrationId = "simplesamlphp"; + //local entity ID - autogenerated based on URL + String localEntityIdTemplate = "{baseUrl}/saml2/service-provider-metadata/{registrationId}"; + //local signing (and decryption key) + Saml2X509Credential signingCredential = getSigningCredential(); + //IDP certificate for verification of incoming messages + Saml2X509Credential idpVerificationCertificate = getVerificationCertificate(); + String acsUrlTemplate = "{baseUrl}" + Saml2WebSsoAuthenticationFilter.DEFAULT_FILTER_PROCESSES_URI; + return RelyingPartyRegistration.withRegistrationId(registrationId) + .remoteIdpEntityId(idpEntityId) + .idpWebSsoUrl(webSsoEndpoint) + .credentials(c -> c.add(signingCredential)) + .credentials(c -> c.add(idpVerificationCertificate)) + .localEntityIdTemplate(localEntityIdTemplate) + .assertionConsumerServiceUrlTemplate(acsUrlTemplate) + .build(); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests() + .anyRequest().authenticated() + .and() + .saml2Login() + .relyingPartyRegistrationRepository( + new InMemoryRelyingPartyRegistrationRepository( + getSaml2AuthenticationConfiguration() + ) + ) + ; + // @formatter:on + } + + private Saml2X509Credential getVerificationCertificate() { + String certificate = "-----BEGIN CERTIFICATE-----\n" + + "MIIEEzCCAvugAwIBAgIJAIc1qzLrv+5nMA0GCSqGSIb3DQEBCwUAMIGfMQswCQYD\n" + + "VQQGEwJVUzELMAkGA1UECAwCQ08xFDASBgNVBAcMC0Nhc3RsZSBSb2NrMRwwGgYD\n" + + "VQQKDBNTYW1sIFRlc3RpbmcgU2VydmVyMQswCQYDVQQLDAJJVDEgMB4GA1UEAwwX\n" + + "c2ltcGxlc2FtbHBocC5jZmFwcHMuaW8xIDAeBgkqhkiG9w0BCQEWEWZoYW5pa0Bw\n" + + "aXZvdGFsLmlvMB4XDTE1MDIyMzIyNDUwM1oXDTI1MDIyMjIyNDUwM1owgZ8xCzAJ\n" + + "BgNVBAYTAlVTMQswCQYDVQQIDAJDTzEUMBIGA1UEBwwLQ2FzdGxlIFJvY2sxHDAa\n" + + "BgNVBAoME1NhbWwgVGVzdGluZyBTZXJ2ZXIxCzAJBgNVBAsMAklUMSAwHgYDVQQD\n" + + "DBdzaW1wbGVzYW1scGhwLmNmYXBwcy5pbzEgMB4GCSqGSIb3DQEJARYRZmhhbmlr\n" + + "QHBpdm90YWwuaW8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC4cn62\n" + + "E1xLqpN34PmbrKBbkOXFjzWgJ9b+pXuaRft6A339uuIQeoeH5qeSKRVTl32L0gdz\n" + + "2ZivLwZXW+cqvftVW1tvEHvzJFyxeTW3fCUeCQsebLnA2qRa07RkxTo6Nf244mWW\n" + + "RDodcoHEfDUSbxfTZ6IExSojSIU2RnD6WllYWFdD1GFpBJOmQB8rAc8wJIBdHFdQ\n" + + "nX8Ttl7hZ6rtgqEYMzYVMuJ2F2r1HSU1zSAvwpdYP6rRGFRJEfdA9mm3WKfNLSc5\n" + + "cljz0X/TXy0vVlAV95l9qcfFzPmrkNIst9FZSwpvB49LyAVke04FQPPwLgVH4gph\n" + + "iJH3jvZ7I+J5lS8VAgMBAAGjUDBOMB0GA1UdDgQWBBTTyP6Cc5HlBJ5+ucVCwGc5\n" + + "ogKNGzAfBgNVHSMEGDAWgBTTyP6Cc5HlBJ5+ucVCwGc5ogKNGzAMBgNVHRMEBTAD\n" + + "AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAvMS4EQeP/ipV4jOG5lO6/tYCb/iJeAduO\n" + + "nRhkJk0DbX329lDLZhTTL/x/w/9muCVcvLrzEp6PN+VWfw5E5FWtZN0yhGtP9R+v\n" + + "ZnrV+oc2zGD+no1/ySFOe3EiJCO5dehxKjYEmBRv5sU/LZFKZpozKN/BMEa6CqLu\n" + + "xbzb7ykxVr7EVFXwltPxzE9TmL9OACNNyF5eJHWMRMllarUvkcXlh4pux4ks9e6z\n" + + "V9DQBy2zds9f1I3qxg0eX6JnGrXi/ZiCT+lJgVe3ZFXiejiLAiKB04sXW3ti0LW3\n" + + "lx13Y1YlQ4/tlpgTgfIJxKV6nyPiLoK0nywbMd+vpAirDt2Oc+hk\n" + + "-----END CERTIFICATE-----"; + return new Saml2X509Credential( + x509Certificate(certificate), + VERIFICATION + ); + } + + private X509Certificate x509Certificate(String source) { + try { + final CertificateFactory factory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) factory.generateCertificate( + new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8)) + ); + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } + + private Saml2X509Credential getSigningCredential() { + String key = "-----BEGIN PRIVATE KEY-----\n" + + "MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBANG7v8QjQGU3MwQE\n" + + "VUBxvH6Uuiy/MhZT7TV0ZNjyAF2ExA1gpn3aUxx6jYK5UnrpxRRE/KbeLucYbOhK\n" + + "cDECt77Rggz5TStrOta0BQTvfluRyoQtmQ5Nkt6Vqg7O2ZapFt7k64Sal7AftzH6\n" + + "Q2BxWN1y04bLdDrH4jipqRj/2qEFAgMBAAECgYEAj4ExY1jjdN3iEDuOwXuRB+Nn\n" + + "x7pC4TgntE2huzdKvLJdGvIouTArce8A6JM5NlTBvm69mMepvAHgcsiMH1zGr5J5\n" + + "wJz23mGOyhM1veON41/DJTVG+cxq4soUZhdYy3bpOuXGMAaJ8QLMbQQoivllNihd\n" + + "vwH0rNSK8LTYWWPZYIECQQDxct+TFX1VsQ1eo41K0T4fu2rWUaxlvjUGhK6HxTmY\n" + + "8OMJptunGRJL1CUjIb45Uz7SP8TPz5FwhXWsLfS182kRAkEA3l+Qd9C9gdpUh1uX\n" + + "oPSNIxn5hFUrSTW1EwP9QH9vhwb5Vr8Jrd5ei678WYDLjUcx648RjkjhU9jSMzIx\n" + + "EGvYtQJBAMm/i9NR7IVyyNIgZUpz5q4LI21rl1r4gUQuD8vA36zM81i4ROeuCly0\n" + + "KkfdxR4PUfnKcQCX11YnHjk9uTFj75ECQEFY/gBnxDjzqyF35hAzrYIiMPQVfznt\n" + + "YX/sDTE2AdVBVGaMj1Cb51bPHnNC6Q5kXKQnj/YrLqRQND09Q7ParX0CQQC5NxZr\n" + + "9jKqhHj8yQD6PlXTsY4Occ7DH6/IoDenfdEVD5qlet0zmd50HatN2Jiqm5ubN7CM\n" + + "INrtuLp4YHbgk1mi\n" + + "-----END PRIVATE KEY-----"; + String certificate = "-----BEGIN CERTIFICATE-----\n" + + "MIICgTCCAeoCCQCuVzyqFgMSyDANBgkqhkiG9w0BAQsFADCBhDELMAkGA1UEBhMC\n" + + "VVMxEzARBgNVBAgMCldhc2hpbmd0b24xEjAQBgNVBAcMCVZhbmNvdXZlcjEdMBsG\n" + + "A1UECgwUU3ByaW5nIFNlY3VyaXR5IFNBTUwxCzAJBgNVBAsMAnNwMSAwHgYDVQQD\n" + + "DBdzcC5zcHJpbmcuc2VjdXJpdHkuc2FtbDAeFw0xODA1MTQxNDMwNDRaFw0yODA1\n" + + "MTExNDMwNDRaMIGEMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGluZ3RvbjES\n" + + "MBAGA1UEBwwJVmFuY291dmVyMR0wGwYDVQQKDBRTcHJpbmcgU2VjdXJpdHkgU0FN\n" + + "TDELMAkGA1UECwwCc3AxIDAeBgNVBAMMF3NwLnNwcmluZy5zZWN1cml0eS5zYW1s\n" + + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDRu7/EI0BlNzMEBFVAcbx+lLos\n" + + "vzIWU+01dGTY8gBdhMQNYKZ92lMceo2CuVJ66cUURPym3i7nGGzoSnAxAre+0YIM\n" + + "+U0razrWtAUE735bkcqELZkOTZLelaoOztmWqRbe5OuEmpewH7cx+kNgcVjdctOG\n" + + "y3Q6x+I4qakY/9qhBQIDAQABMA0GCSqGSIb3DQEBCwUAA4GBAAeViTvHOyQopWEi\n" + + "XOfI2Z9eukwrSknDwq/zscR0YxwwqDBMt/QdAODfSwAfnciiYLkmEjlozWRtOeN+\n" + + "qK7UFgP1bRl5qksrYX5S0z2iGJh0GvonLUt3e20Ssfl5tTEDDnAEUMLfBkyaxEHD\n" + + "RZ/nbTJ7VTeZOSyRoVn5XHhpuJ0B\n" + + "-----END CERTIFICATE-----"; + PrivateKey pk = RsaKeyConverters.pkcs8().convert(new ByteArrayInputStream(key.getBytes())); + X509Certificate cert = x509Certificate(certificate); + return new Saml2X509Credential(pk, cert, SIGNING, DECRYPTION); + } +} diff --git a/samples/javaconfig/saml2login/src/test/java/org/springframework/security/samples/config/SecurityConfigTests.java b/samples/javaconfig/saml2login/src/test/java/org/springframework/security/samples/config/SecurityConfigTests.java new file mode 100644 index 0000000000..d550276a9b --- /dev/null +++ b/samples/javaconfig/saml2login/src/test/java/org/springframework/security/samples/config/SecurityConfigTests.java @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2019 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.samples.config; + +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = SecurityConfig.class) +public class SecurityConfigTests { + + @Test + public void securityConfigurationLoads() { + } +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java index 3e27e4c986..0f12fb0875 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java @@ -24,6 +24,11 @@ import org.springframework.util.Assert; import org.springframework.web.filter.GenericFilterBean; import org.springframework.web.util.HtmlUtils; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; +import java.util.function.Function; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.ServletRequest; @@ -31,11 +36,6 @@ import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Collections; -import java.util.Map; -import java.util.function.Function; /** * For internal use with namespace configuration in the case where a user doesn't @@ -56,6 +56,7 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { private boolean formLoginEnabled; private boolean openIdEnabled; private boolean oauth2LoginEnabled; + private boolean saml2LoginEnabled; private String authenticationUrl; private String usernameParameter; private String passwordParameter; @@ -64,6 +65,7 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { private String openIDusernameParameter; private String openIDrememberMeParameter; private Map oauth2AuthenticationUrlToClientName; + private Map saml2AuthenticationUrlToProviderName; private Function> resolveHiddenInputs = request -> Collections .emptyMap(); @@ -126,7 +128,7 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { } public boolean isEnabled() { - return formLoginEnabled || openIdEnabled || oauth2LoginEnabled; + return formLoginEnabled || openIdEnabled || oauth2LoginEnabled || this.saml2LoginEnabled; } public void setLogoutSuccessUrl(String logoutSuccessUrl) { @@ -157,6 +159,10 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { this.oauth2LoginEnabled = oauth2LoginEnabled; } + public void setSaml2LoginEnabled(boolean saml2LoginEnabled) { + this.saml2LoginEnabled = saml2LoginEnabled; + } + public void setAuthenticationUrl(String authenticationUrl) { this.authenticationUrl = authenticationUrl; } @@ -186,6 +192,10 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { this.oauth2AuthenticationUrlToClientName = oauth2AuthenticationUrlToClientName; } + public void setSaml2AuthenticationUrlToProviderName(Map saml2AuthenticationUrlToProviderName) { + this.saml2AuthenticationUrlToProviderName = saml2AuthenticationUrlToProviderName; + } + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; @@ -287,6 +297,23 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { } sb.append("\n"); } + + if (this.saml2LoginEnabled) { + sb.append(""); + sb.append(createError(loginError, errorMsg)); + sb.append(createLogoutSuccess(logoutSuccess)); + sb.append("\n"); + for (Map.Entry relyingPartyUrlToName : saml2AuthenticationUrlToProviderName.entrySet()) { + sb.append(" \n"); + } + sb.append("
"); + String url = relyingPartyUrlToName.getKey(); + sb.append(""); + String partyName = HtmlUtils.htmlEscape(relyingPartyUrlToName.getValue()); + sb.append(partyName); + sb.append(""); + sb.append("
\n"); + } sb.append("\n"); sb.append(""); diff --git a/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java b/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java index bc66d17e46..fc4b43ba2b 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java @@ -15,17 +15,6 @@ */ package org.springframework.security.web.authentication; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; - -import java.util.Collections; -import java.util.Locale; - -import javax.servlet.FilterChain; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -import org.junit.Test; import org.springframework.context.support.MessageSourceAccessor; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; @@ -36,6 +25,17 @@ import org.springframework.security.core.SpringSecurityMessageSource; import org.springframework.security.web.WebAttributes; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; +import org.junit.Test; + +import java.util.Collections; +import java.util.Locale; +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + /** * * @author Luke Taylor @@ -205,4 +205,21 @@ public class DefaultLoginPageGeneratingFilterTests { assertThat(response.getContentAsString()).contains("Google < > " ' &"); } + + @Test + public void generatesForSaml2LoginAndEscapesClientName() throws Exception { + DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter(); + filter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL); + filter.setSaml2LoginEnabled(true); + + String clientName = "Google < > \" \' &"; + filter.setSaml2AuthenticationUrlToProviderName( + Collections.singletonMap("/saml/sso/google", clientName)); + + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(new MockHttpServletRequest("GET", "/login"), response, chain); + + assertThat(response.getContentAsString()).contains("Login with SAML 2.0"); + assertThat(response.getContentAsString()).contains("Google < > " ' &"); + } }