From 0e46e1b0bc64e0d52aa74cd4bda23d7be9258cae Mon Sep 17 00:00:00 2001 From: Choon-Chern Lim Date: Mon, 14 Mar 2016 19:17:36 -0500 Subject: [PATCH] 0.3.2 --- CHANGELOG.md | 8 ++ README.md | 31 +++-- pom.xml | 2 +- .../security/adfs/saml2/SAMLConfigBean.java | 51 +++++++-- .../adfs/saml2/SAMLConfigBeanBuilder.java | 30 +++-- .../SAMLWebSecurityConfigurerAdapter.java | 36 ++++-- .../adfs/saml2/SAMLConfigBeanSpec.groovy | 24 ++-- ...AMLWebSecurityConfigurerAdapterSpec.groovy | 107 ++++++++++++++++++ src/test/resources/test.jks | Bin 0 -> 2061 bytes 9 files changed, 242 insertions(+), 47 deletions(-) create mode 100644 src/test/groovy/com/github/choonchernlim/security/adfs/saml2/SAMLWebSecurityConfigurerAdapterSpec.groovy create mode 100644 src/test/resources/test.jks diff --git a/CHANGELOG.md b/CHANGELOG.md index f2280e6..9deabad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change Log +## 0.3.2 - 2016-03-14 + +* Used `SAMLContextProviderLB` instead of `SAMLContextProviderImpl` to handle servers doing SSL termination. +* Dropped `SAMLConfigBean.spMetadataBaseUrl`. +* Renamed `SAMLConfigBean.adfsHostName` to `SAMLConfigBean.idpHostName`. +* Added `SAMLConfigBean.spServerName`. +* Added `SAMLConfigBean.spHttpsPort`. +* Added `SAMLConfigBean.spContextPath`. ## 0.3.1 - 2016-03-10 diff --git a/README.md b/README.md index d3bc794..ab96a80 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,20 @@ Spring Security module for service provider (Sp) to authenticate against identit How this module is configured:- * `HTTP-Redirect` binding for sending SAML messages to IdP. +* Handles Sp servers doing SSL termination. * Default authentication method is user/password using IdP's form login page. -* Default signature algorithm is SHA256withRSA. -* Default digest algorithm is SHA-256. +* Default signature algorithm is `SHA256withRSA`. +* Default digest algorithm is `SHA-256`. -Tested against:- +Tested against Sp's environments:- -* ADFS 2.0 - Windows Server 2008 R2 -* ADFS 2.1 - Windows Server 2012 +* Local Tomcat server without SSL termination. +* Azure Tomcat server with SSL termination. + +Tested against IdP's environments:- + +* ADFS 2.0 - Windows Server 2008 R2. +* ADFS 2.1 - Windows Server 2012. ## Maven Dependency @@ -20,13 +26,13 @@ Tested against:- com.github.choonchernlim spring-security-adfs-saml2 - 0.3.1 + 0.3.2 ``` ## Prerequisites -* Sp must use HTTPS protocol. +* Both Sp and IdP must use HTTPS protocol. * Java’s default keysize is limited to 128-bit key due to US export laws and a few countries’ import laws. So, Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files must be installed to allow larger key size, such as 256-bit key. * Keystore must contain both Sp's public/private keys and imported IdP's public certificate. * Sp's public/private keys - to generate digital signature before sending SAML messages to IdP. @@ -46,8 +52,9 @@ class AppSecurityConfig extends SAMLWebSecurityConfigurerAdapter { @Override protected SAMLConfigBean samlConfigBean() { return new SAMLConfigBeanBuilder() - .setSpMetadataBaseUrl("https://localhost:8443/my-app") - .setAdfsHostName("idp-adfs-server") + .setIdpServerName("idp-server") + .setSpServerName("sp-server") + .setSpContextPath("/app") .setKeystoreResource(new DefaultResourceLoader().getResource("classpath:keystore.jks")) .setKeystorePassword("storepass") .setKeystoreAlias("alias") @@ -91,8 +98,10 @@ class AppSecurityConfig extends SAMLWebSecurityConfigurerAdapter { |Property |Required? |Description | |---------------------------|----------|----------------------------------------------------------------------------------------------------------| -|spMetadataBaseUrl |Yes |Sp's metadata base URL with format `https://server(:port)/contextPath` for constructing SAML endpoints (ex: `/saml/**`). This configuration is important to prevent servers doing SSL termination from generating wrong endpoints.| -|adfsHostName |Yes |ADFS host name without HTTPS protocol.

If ADFS link is `https://idp-adfs-server/adfs/ls`, the value should be `idp-adfs-server`.| +|idpServerName |Yes |IdP server name.

Used for retrieving IdP metadata using HTTPS. If IdP link is `https://idp-server/adfs/ls`, value should be `idp-server`. | +|spServerName |Yes |Sp server name. If Sp link is `https://sp-server:8443/myapp`, value should be `sp-server`.

Used for generating correct SAML endpoints in Sp metadata to handle servers doing SSL termination. | +|spHttpsPort |No |Sp HTTPS port. If Sp link is `https://sp-server:8443/myapp`, value should be `8443`.

Default is `443`.

Used for generating correct SAML endpoints in Sp metadata to handle servers doing SSL termination. | +|spContextPath |No |Sp context path. If Sp link is `https://sp-server:8443/myapp`, value should be `/myapp`.

Default is `''`.

Used for generating correct SAML endpoints in Sp metadata to handle servers doing SSL termination. | |keystoreResource |Yes |App's keystore containing its public/private key and ADFS' certificate with public key. | |keystorePassword |Yes |Password to access app's keystore. | |keystoreAlias |Yes |Alias of app's public/private key pair. | diff --git a/pom.xml b/pom.xml index 52f92e1..0073202 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ spring-security-adfs-saml2 - 0.3.1 + 0.3.2 jar Spring Security ADFS SAML2 diff --git a/src/main/java/com/github/choonchernlim/security/adfs/saml2/SAMLConfigBean.java b/src/main/java/com/github/choonchernlim/security/adfs/saml2/SAMLConfigBean.java index e79a0b1..f9803ce 100644 --- a/src/main/java/com/github/choonchernlim/security/adfs/saml2/SAMLConfigBean.java +++ b/src/main/java/com/github/choonchernlim/security/adfs/saml2/SAMLConfigBean.java @@ -15,14 +15,28 @@ public final class SAMLConfigBean { /** - * Sp's metadata base URL for constructing SAML endpoints in SAML payload to IdP. + * (REQUIRED) IdP's server name. */ - private final String spMetadataBaseUrl; + private final String idpServerName; /** - * (REQUIRED) ADFS host name without HTTPS protocol. + * (REQUIRED) Sp's server name. */ - private final String adfsHostName; + private final String spServerName; + + /** + * (OPTIONAL) Sp's HTTPS port. + *

+ * Default is 443. + */ + private final Integer spHttpsPort; + + /** + * (OPTIONAL) Sp's context path. + *

+ * Default is "". + */ + private final String spContextPath; /** * (REQUIRED) Keystore containing app's public/private key and ADFS' certificate with public key. @@ -81,8 +95,10 @@ public final class SAMLConfigBean { */ private final Set authnContexts; - SAMLConfigBean(final String spMetadataBaseUrl, - final String adfsHostName, + SAMLConfigBean(final String idpServerName, + final String spServerName, + final Integer spHttpsPort, + final String spContextPath, final Resource keystoreResource, final String keystoreAlias, final String keystorePassword, @@ -94,8 +110,11 @@ public final class SAMLConfigBean { final Set authnContexts) { //@formatter:off - this.spMetadataBaseUrl = expect(spMetadataBaseUrl, "Sp's metadata base URL").not().toBeBlank().check(); - this.adfsHostName = expect(adfsHostName, "ADFS host name").not().toBeBlank().check(); + this.idpServerName = expect(idpServerName, "IdP server name").not().toBeBlank().check(); + + this.spServerName = expect(spServerName, "Sp server name").not().toBeBlank().check(); + this.spHttpsPort = Optional.fromNullable(spHttpsPort).or(443); + this.spContextPath = Optional.fromNullable(spContextPath).or(""); this.keystoreResource = (Resource) expect(keystoreResource, "Key store").not().toBeNull().check(); this.keystoreAlias = expect(keystoreAlias, "Keystore alias").not().toBeBlank().check(); @@ -113,12 +132,20 @@ public final class SAMLConfigBean { //@formatter:on } - public String getSpMetadataBaseUrl() { - return spMetadataBaseUrl; + public String getIdpServerName() { + return idpServerName; + } + + public String getSpServerName() { + return spServerName; + } + + public Integer getSpHttpsPort() { + return spHttpsPort; } - public String getAdfsHostName() { - return adfsHostName; + public String getSpContextPath() { + return spContextPath; } public Resource getKeystoreResource() { diff --git a/src/main/java/com/github/choonchernlim/security/adfs/saml2/SAMLConfigBeanBuilder.java b/src/main/java/com/github/choonchernlim/security/adfs/saml2/SAMLConfigBeanBuilder.java index d4c779d..b187ff6 100644 --- a/src/main/java/com/github/choonchernlim/security/adfs/saml2/SAMLConfigBeanBuilder.java +++ b/src/main/java/com/github/choonchernlim/security/adfs/saml2/SAMLConfigBeanBuilder.java @@ -9,8 +9,10 @@ * Builder class for constructing SAMLConfigBean. */ public final class SAMLConfigBeanBuilder { - private String spMetadataBaseUrl; - private String adfsHostName; + private String idpServerName; + private String spServerName; + private Integer spHttpsPort; + private String spContextPath; private Resource keystoreResource; private String keystoreAlias; private String keystorePassword; @@ -21,13 +23,23 @@ public final class SAMLConfigBeanBuilder { private SAMLUserDetailsService samlUserDetailsService; private Set authnContexts; - public SAMLConfigBeanBuilder setSpMetadataBaseUrl(final String spMetadataBaseUrl) { - this.spMetadataBaseUrl = spMetadataBaseUrl; + public SAMLConfigBeanBuilder setIdpServerName(final String idpServerName) { + this.idpServerName = idpServerName; return this; } - public SAMLConfigBeanBuilder setAdfsHostName(final String adfsHostName) { - this.adfsHostName = adfsHostName; + public SAMLConfigBeanBuilder setSpServerName(final String spServerName) { + this.spServerName = spServerName; + return this; + } + + public SAMLConfigBeanBuilder setSpHttpsPort(final Integer spHttpsPort) { + this.spHttpsPort = spHttpsPort; + return this; + } + + public SAMLConfigBeanBuilder setSpContextPath(final String spContextPath) { + this.spContextPath = spContextPath; return this; } @@ -77,8 +89,10 @@ public SAMLConfigBeanBuilder setAuthnContexts(final Set authnContexts) { } public SAMLConfigBean createSAMLConfigBean() { - return new SAMLConfigBean(spMetadataBaseUrl, - adfsHostName, + return new SAMLConfigBean(idpServerName, + spServerName, + spHttpsPort, + spContextPath, keystoreResource, keystoreAlias, keystorePassword, diff --git a/src/main/java/com/github/choonchernlim/security/adfs/saml2/SAMLWebSecurityConfigurerAdapter.java b/src/main/java/com/github/choonchernlim/security/adfs/saml2/SAMLWebSecurityConfigurerAdapter.java index 9e145ce..6373bb4 100644 --- a/src/main/java/com/github/choonchernlim/security/adfs/saml2/SAMLWebSecurityConfigurerAdapter.java +++ b/src/main/java/com/github/choonchernlim/security/adfs/saml2/SAMLWebSecurityConfigurerAdapter.java @@ -28,7 +28,7 @@ import org.springframework.security.saml.SAMLLogoutProcessingFilter; import org.springframework.security.saml.SAMLProcessingFilter; import org.springframework.security.saml.SAMLWebSSOHoKProcessingFilter; -import org.springframework.security.saml.context.SAMLContextProviderImpl; +import org.springframework.security.saml.context.SAMLContextProviderLB; import org.springframework.security.saml.key.JKSKeyManager; import org.springframework.security.saml.key.KeyManager; import org.springframework.security.saml.log.SAMLDefaultLogger; @@ -133,7 +133,7 @@ protected final WebSecurity samlizedConfig(final WebSecurity web) throws Excepti // IDP metadata URL private String getMetdataUrl() { return String.format("https://%s/federationmetadata/2007-06/federationmetadata.xml", - samlConfigBean().getAdfsHostName()); + samlConfigBean().getIdpServerName()); } // Entry point to initialize authentication @@ -169,9 +169,19 @@ public SAMLEntryPoint samlEntryPoint() { // Filter automatically generates default SP metadata @Bean public MetadataGeneratorFilter metadataGeneratorFilter() { + // generates base URL that matches `SAMLContextProviderLB` configuration + // to ensure SAML endpoints work for server doing SSL termination + StringBuilder sb = new StringBuilder(); + sb.append("https://").append(samlConfigBean().getSpServerName()); + if (samlConfigBean().getSpHttpsPort() != 443) { + sb.append(":").append(samlConfigBean().getSpHttpsPort()); + } + sb.append(samlConfigBean().getSpContextPath()); + String entityBaseUrl = sb.toString(); + MetadataGenerator metadataGenerator = new MetadataGenerator(); metadataGenerator.setKeyManager(keyManager()); - metadataGenerator.setEntityBaseURL(samlConfigBean().getSpMetadataBaseUrl()); + metadataGenerator.setEntityBaseURL(entityBaseUrl); return new MetadataGeneratorFilter(metadataGenerator); } @@ -321,10 +331,22 @@ public SAMLAuthenticationProvider samlAuthenticationProvider() { return samlAuthenticationProvider; } - // Provider of default SAML Context - @Bean - public SAMLContextProviderImpl contextProvider() { - return new SAMLContextProviderImpl(); + // In order to get SAML to work for Sp servers doing SSL termination, `SAMLContextProviderLB` has + // to be used instead of `SAMLContextProviderImpl` to prevent the following exception:- + // + // "SAML message intended destination endpoint 'https://server/app/saml/SSO' did not match the + // recipient endpoint 'http://server/app/saml/SSO'" + // + // This configuration will work for Sp servers (not) doing SSL termination. + @Bean + public SAMLContextProviderLB contextProvider() { + SAMLContextProviderLB contextProviderLB = new SAMLContextProviderLB(); + contextProviderLB.setScheme("https"); + contextProviderLB.setServerName(samlConfigBean().getSpServerName()); + contextProviderLB.setServerPort(samlConfigBean().getSpHttpsPort()); + contextProviderLB.setIncludeServerPortInRequestURL(samlConfigBean().getSpHttpsPort() != 443); + contextProviderLB.setContextPath(samlConfigBean().getSpContextPath()); + return contextProviderLB; } // Processing filter for WebSSO profile messages diff --git a/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/SAMLConfigBeanSpec.groovy b/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/SAMLConfigBeanSpec.groovy index 252dcaf..909cdc5 100644 --- a/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/SAMLConfigBeanSpec.groovy +++ b/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/SAMLConfigBeanSpec.groovy @@ -23,8 +23,10 @@ class SAMLConfigBeanSpec extends Specification { } } def allFieldsBeanBuilder = new SAMLConfigBeanBuilder(). - setSpMetadataBaseUrl('spMetadataBaseUrl'). - setAdfsHostName('adfsHostName'). + setIdpServerName('idpServerName'). + setSpServerName('spServerName'). + setSpHttpsPort(8443). + setSpContextPath('spContextPath'). setKeystoreResource(keystoreResource). setKeystoreAlias('keystoreAlias'). setKeystorePassword('keystorePassword'). @@ -40,8 +42,10 @@ class SAMLConfigBeanSpec extends Specification { def bean = allFieldsBeanBuilder.createSAMLConfigBean() then: - bean.spMetadataBaseUrl == 'spMetadataBaseUrl' - bean.adfsHostName == 'adfsHostName' + bean.idpServerName == 'idpServerName' + bean.spServerName == 'spServerName' + bean.spHttpsPort == 8443 + bean.spContextPath == 'spContextPath' bean.keystoreResource == keystoreResource bean.keystoreAlias == 'keystoreAlias' bean.keystorePassword == 'keystorePassword' @@ -56,14 +60,18 @@ class SAMLConfigBeanSpec extends Specification { def "only required fields"() { when: def bean = allFieldsBeanBuilder. + setSpHttpsPort(null). + setSpContextPath(null). setFailedLoginDefaultUrl(null). setSamlUserDetailsService(null). setAuthnContexts(null). createSAMLConfigBean() then: - bean.spMetadataBaseUrl == 'spMetadataBaseUrl' - bean.adfsHostName == 'adfsHostName' + bean.idpServerName == 'idpServerName' + bean.spServerName == 'spServerName' + bean.spHttpsPort == 443 + bean.spContextPath == '' bean.keystoreResource == keystoreResource bean.keystoreAlias == 'keystoreAlias' bean.keystorePassword == 'keystorePassword' @@ -95,8 +103,8 @@ class SAMLConfigBeanSpec extends Specification { where: field | expectedException - 'SpMetadataBaseUrl' | StringBlankPreconditionException - 'AdfsHostName' | StringBlankPreconditionException + 'IdpServerName' | StringBlankPreconditionException + 'SpServerName' | StringBlankPreconditionException 'KeystoreResource' | ObjectNullPreconditionException 'KeystoreAlias' | StringBlankPreconditionException 'KeystorePassword' | StringBlankPreconditionException diff --git a/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/SAMLWebSecurityConfigurerAdapterSpec.groovy b/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/SAMLWebSecurityConfigurerAdapterSpec.groovy new file mode 100644 index 0000000..9b93737 --- /dev/null +++ b/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/SAMLWebSecurityConfigurerAdapterSpec.groovy @@ -0,0 +1,107 @@ +package com.github.choonchernlim.security.adfs.saml2 + +import org.springframework.core.io.DefaultResourceLoader +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.security.saml.SAMLCredential +import org.springframework.security.saml.userdetails.SAMLUserDetailsService +import spock.lang.Specification +import spock.lang.Unroll + +class SAMLWebSecurityConfigurerAdapterSpec extends Specification { + private static final String ALIAS = 'test-alias' + private static final String STOREPASS = 'test-storepass' + private static final String KEYPASS = 'test-keypass' + + // keytool -genkeypair -keystore test.jks -storepass test-storepass -alias test-alias -keypass test-keypass -dname cn=test -keyalg RSA -keysize 2048 -sigalg SHA256withRSA + def keystoreResource = new DefaultResourceLoader().getResource("classpath:test.jks") + + def samlUserDetailsService = new SAMLUserDetailsService() { + @Override + Object loadUserBySAML(final SAMLCredential credential) throws UsernameNotFoundException { + return new User('limc', '', [new SimpleGrantedAuthority('ROLE_USER')]) + } + } + + def allFieldsBeanBuilder = new SAMLConfigBeanBuilder(). + setIdpServerName('idpServerName'). + setSpServerName('spServerName'). + setSpHttpsPort(8443). + setSpContextPath('spContextPath'). + setKeystoreResource(keystoreResource). + setKeystoreAlias(ALIAS). + setKeystorePassword(STOREPASS). + setKeystorePrivateKeyPassword(KEYPASS). + setSuccessLoginDefaultUrl('successLoginDefaultUrl'). + setSuccessLogoutUrl('successLogoutUrl'). + setFailedLoginDefaultUrl('failedLoginDefaultUrl'). + setSamlUserDetailsService(samlUserDetailsService). + setAuthnContexts([CustomAuthnContext.WINDOWS_INTEGRATED_AUTHN_CTX] as Set) + + @Unroll + @SuppressWarnings("all") + def "metadataGeneratorFilter - entityBaseURL - #expectedValue"() { + given: + def samlConfigBean = allFieldsBeanBuilder. + setSpServerName(server). + setSpHttpsPort(port). + setSpContextPath(contextPath). + createSAMLConfigBean() + + when: + def adapter = new SAMLWebSecurityConfigurerAdapter() { + @Override + protected SAMLConfigBean samlConfigBean() { + return samlConfigBean + } + } + + then: + expectedValue == adapter.metadataGeneratorFilter().generator.entityBaseURL + + where: + server | port | contextPath | expectedValue + 'server' | null | null | 'https://server' + 'server' | 443 | null | 'https://server' + 'server' | 443 | '/app' | 'https://server/app' + 'server' | 8443 | null | 'https://server:8443' + 'server' | 8443 | '/app' | 'https://server:8443/app' + } + + @Unroll + @SuppressWarnings("all") + def "contextProvider - #expectedValue"() { + given: + def samlConfigBean = allFieldsBeanBuilder. + setSpServerName(aServer). + setSpHttpsPort(aPort). + setSpContextPath(aContextPath). + createSAMLConfigBean() + + when: + def adapter = new SAMLWebSecurityConfigurerAdapter() { + @Override + protected SAMLConfigBean samlConfigBean() { + return samlConfigBean + } + } + + then: + with(adapter.contextProvider()) { + scheme == 'https' + serverName == aServer + serverPort == ePort + contextPath == eContextPath + includeServerPortInRequestURL == ePortIncluded + } + + where: + aServer | aPort | aContextPath | ePort | ePortIncluded | eContextPath | expectedValue + 'server' | null | null | 443 | false | '' | 'https://server' + 'server' | 443 | null | 443 | false | '' | 'https://server' + 'server' | 443 | '/app' | 443 | false | '/app' | 'https://server/app' + 'server' | 8443 | null | 8443 | true | '' | 'https://server:8443' + 'server' | 8443 | '/app' | 8443 | true | '/app' | 'https://server:8443/app' + } +} diff --git a/src/test/resources/test.jks b/src/test/resources/test.jks new file mode 100644 index 0000000000000000000000000000000000000000..e84caae0e1c5cfd1b859fa411bdc0a74c0915966 GIT binary patch literal 2061 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 literal 0 HcmV?d00001