├── .gitignore
├── .travis.yml
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── pom.xml
└── src
├── main
└── java
│ └── com
│ └── github
│ └── choonchernlim
│ └── security
│ └── adfs
│ └── saml2
│ ├── CsrfHeaderFilter.java
│ ├── CustomAuthnContext.java
│ ├── DefaultSAMLBootstrap.java
│ ├── JndiBackedKeystoreService.java
│ ├── KeystoreBean.java
│ ├── MockFilterSecurityInterceptor.java
│ ├── SAMLConfigBean.java
│ ├── SAMLWebSecurityConfigurerAdapter.java
│ └── SpringSecurityAdfsSaml2Exception.java
├── site
└── site.xml
└── test
├── groovy
└── com
│ └── github
│ └── choonchernlim
│ └── security
│ └── adfs
│ └── saml2
│ ├── CsrfHeaderFilterSpec.groovy
│ ├── DefaultSAMLBootstrapSpec.groovy
│ ├── JndiBackedKeystoreServiceSpec.groovy
│ ├── KeystoreBeanSpec.groovy
│ ├── MockFilterSecurityInterceptorSpec.groovy
│ ├── SAMLConfigBeanSpec.groovy
│ ├── SAMLWebSecurityConfigurerAdapterSpec.groovy
│ └── SpringSecurityAdfsSaml2ExceptionSpec.groovy
├── java
└── com
│ └── github
│ └── choonchernlim
│ └── security
│ └── adfs
│ └── saml2
│ └── DummyTest.java
└── resources
├── README.md
└── test.jks
/.gitignore:
--------------------------------------------------------------------------------
1 | *.class
2 |
3 | # Mobile Tools for Java (J2ME)
4 | .mtj.tmp/
5 |
6 | # Package Files #
7 | *.jar
8 | *.war
9 | *.ear
10 |
11 | target/
12 | .idea/
13 | *.iml
14 |
15 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
16 | hs_err_pid*
17 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: java
2 |
3 | notifications:
4 | email:
5 | on_success: never
6 | on_failure: change
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | ## 0.9.0 - 2019-01-09
4 |
5 | * FEATURE - Added `SAMLConfigBean.useJdkCacertsForSslVerification` flag to allow SSL verifications to be performed by using JDK's cacerts instead of app's keystore file.
6 |
7 | ## 0.8.0 - 2018-07-11
8 |
9 | * Moved from Java 7 to Java 8.
10 | * Dependencies update... organized POM.
11 |
12 | ## 0.7.1 - 2018-05-04
13 |
14 | * BUG - `CsrfHeaderFilter` creates multiple cookies with same name but different path due to possible empty context path, which then uses current request's path. This may cause client side to read the wrong cookie when retrieving the CSRF token.
15 |
16 | ## 0.7.0 - 2017-11-28
17 |
18 | * Dropped autowired `Environment` from `SAMLWebSecurityConfigurerAdapter` and replaced with `ApplicationContext` to allow concrete class to access any Spring beans instead of just `Environment` to configure the security. This will also prevent any lifecycle or circular dependency problems when trying to autowire beans in concrete class.
19 | * Replaced `@PostContruct` with `@Bean` for `SAMLWebSecurityConfigurerAdapter.socketFactoryInitialization()`.
20 |
21 | ## 0.6.0 - 2016-07-18
22 |
23 | * Helper class `JndiBackedKeystoreService` to retrieve keystore info from JNDI value with following format: `jks-path,alias,storepass,keypass`
24 |
25 | ## 0.5.0 - 2016-07-13
26 |
27 | * If `samlConfigBean.storeCsrfTokenInCookie` is `true`, then store CSRF token in cookie.
28 | * Decoupled `WebSSOProfileOptions` from `SAMLEntryPoint` to allow user to override `SAMLEntryPoint` easily.
29 | * Dependency updates.
30 |
31 | ```
32 | [INFO] cglib:cglib-nodep ..................................... 3.2.2 -> 3.2.4
33 | [INFO] org.codehaus.groovy:groovy-all ........................ 2.4.6 -> 2.4.7
34 | [INFO] org.spockframework:spock-core ...
35 | [INFO] 1.0-groovy-2.4 -> 1.1-groovy-2.4-rc-1
36 | [INFO] org.springframework:spring-test ....... 4.2.6.RELEASE -> 4.3.1.RELEASE
37 | [INFO] org.springframework.security:spring-security-config ...
38 | [INFO] 4.1.0.RELEASE -> 4.1.1.RELEASE
39 | [INFO] org.springframework.security:spring-security-core ...
40 | [INFO] 4.1.0.RELEASE -> 4.1.1.RELEASE
41 | [INFO] org.springframework.security:spring-security-web ...
42 | [INFO] 4.1.0.RELEASE -> 4.1.1.RELEASE
43 | ```
44 |
45 | ## 0.4.0 - 2016-06-05
46 |
47 | * If `samlConfigBean.samlUserDetailsService` is provided, then set `samlAuthenticationProvider.forcePrincipalAsString` to `false` so that `principal` represents the `userDetails` object.
48 | * Ability to mock security to bypass authentication against ADFS during rapid app development. To use this, `samlConfigBean.samlUserDetailsService` must be set.
49 | * Dependency, parent and plugins updates.
50 |
51 | ```
52 | com.github.choonchernlim:build-reports ................ 0.2.4 -> 0.3.2
53 | com.google.guava:guava-testlib .......................... 18.0 -> 19.0
54 | junit:junit ............................................. 4.11 -> 4.12
55 | org.codehaus.groovy:groovy-all .............. 2.4.3 -> 2.4.6
56 | org.springframework.security:spring-security-config ...
57 | 4.0.3.RELEASE -> 4.1.0.RELEASE
58 | org.springframework.security:spring-security-core ...
59 | 4.0.3.RELEASE -> 4.1.0.RELEASE
60 | org.springframework.security:spring-security-web ...
61 | 4.0.3.RELEASE -> 4.1.0.RELEASE
62 | org.springframework.security.extensions:spring-security-saml2-core ...
63 | 1.0.1.RELEASE -> 1.0.2.RELEASE
64 | maven-compiler-plugin ................................... 3.3 -> 3.5.1
65 | ```
66 |
67 | ## 0.3.3 - 2016-04-13
68 | * Inject Spring environment to get access to project properties file. ([#1](https://github.com/choonchernlim/spring-security-adfs-saml2/pull/1))
69 |
70 | ## 0.3.2 - 2016-03-14
71 |
72 | * Used `SAMLContextProviderLB` instead of `SAMLContextProviderImpl` to handle servers doing SSL termination.
73 | * Dropped `SAMLConfigBean.spMetadataBaseUrl`.
74 | * Renamed `SAMLConfigBean.adfsHostName` to `SAMLConfigBean.idpHostName`.
75 | * Added `SAMLConfigBean.spServerName`.
76 | * Added `SAMLConfigBean.spHttpsPort`.
77 | * Added `SAMLConfigBean.spContextPath`.
78 |
79 | ## 0.3.1 - 2016-03-10
80 |
81 | * Added `SAMLConfigBean.spMetadataBaseUrl` to manually specify the Sp's metadata base URL to handle situations where servers do SSL termination (HTTPS -> HTTP).
82 | * Configured metadata generator to use user defined Sp's metadata base URL when generating SAML endpoints URLs.
83 |
84 | ## 0.2.2 - 2016-03-08
85 |
86 | * Fixed casing typo from `SAMLConfigBean.keyStoreResource` to `SAMLConfigBean.keystoreResource`.
87 |
88 | ## 0.2.1 - 2016-03-07
89 |
90 | * Added `SAMLConfigBean.keystorePrivateKeyPassword` to add password for private key.
91 | * Kept storepass and keypass separate.
92 | * Excluded `xml-apis` from dependency because it's known to cause problems in WAS.
93 |
94 | ## 0.2.0 - 2016-03-02
95 |
96 | * Options to allow different authentication method. Default is user/password using IdP's form login page.
97 | * `CustomAuthnContext.WINDOWS_INTEGRATED_AUTHN_CTX` to allow Windows Integrated Authentication.
98 |
99 | ## 0.1.0 - 2016-02-28
100 |
101 | * Initial.
102 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2016 Choon-Chern Lim
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # spring-security-adfs-saml2 [](https://travis-ci.org/choonchernlim/spring-security-adfs-saml2)
2 |
3 | Spring Security module for service provider (Sp) to authenticate against identity provider's (IdP) ADFS using SAML2 protocol.
4 |
5 | How this module is configured:-
6 |
7 | * `HTTP-Redirect` binding for sending SAML messages to IdP.
8 | * Handles Sp servers doing SSL termination.
9 | * Default authentication method is user/password using IdP's form login page.
10 | * Default signature algorithm is `SHA256withRSA`.
11 | * Default digest algorithm is `SHA-256`.
12 |
13 | Tested against Sp's environments:-
14 |
15 | * Local Tomcat server without SSL termination.
16 | * Azure Tomcat server with SSL termination.
17 |
18 | Tested against IdP's environments:-
19 |
20 | * ADFS 2.0 - Windows Server 2008 R2.
21 | * ADFS 2.1 - Windows Server 2012.
22 |
23 | ## Maven Dependency
24 |
25 | ```xml
26 |
27 | com.github.choonchernlim
28 | spring-security-adfs-saml2
29 | 0.9.0
30 |
31 | ```
32 |
33 | ## Prerequisites
34 |
35 | * Java 8.
36 | * Both Sp and IdP must use HTTPS protocol.
37 | * 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.
38 | * Keystore contains the following:-
39 | * (REQUIRED) Sp's public/private keys - to generate digital signature before sending SAML messages to IdP.
40 | * (OPTIONAL) IdP's public certificate - to verify IdP's SAML messages to prevent man-in-the-middle attack. This certificate can also be stored under JDK's cacerts.
41 | * To generate Sp's public/private keys:-
42 |
43 | ```
44 | keytool -genkeypair \
45 | -v \
46 | -keystore /path/to/keystore.jks \
47 | -storepass mystorepass \
48 | -alias myapp \
49 | -dname 'CN=[COMMON-NAME], OU=[ORGANIZATION-UNIT], O=[ORGANIZATION-NAME], L=[CITY-NAME], ST=[STATE-NAME], C=[COUNTRY]' \
50 | -keypass mykeypass \
51 | -keyalg RSA \
52 | -keysize 2048 \
53 | -sigalg SHA256withRSA
54 | ```
55 |
56 | * To import IdP's public certificate into keystore:-
57 |
58 | ```
59 | keytool -importcert \
60 | -file idp-adfs-server.crt \
61 | -keystore /path/to/keystore.jks \
62 | -alias idp-adfs-server \
63 | -storepass mystorepass
64 | ```
65 |
66 | ## Usage
67 |
68 | ### Simplest Configuration
69 |
70 | If you are configuring for one IDP server, the easiest approach is to hardcode all the SAML config in the `@Configuration` file.
71 |
72 | ```java
73 | // Create a Java-based Spring configuration that extends SAMLWebSecurityConfigurerAdapter.
74 | @Configuration
75 | @EnableWebSecurity
76 | class AppSecurityConfig extends SAMLWebSecurityConfigurerAdapter {
77 |
78 | // See `SAMLConfigBean Properties` section below for more info.
79 | @Override
80 | protected SAMLConfigBean samlConfigBean() {
81 | return new SAMLConfigBeanBuilder()
82 | .withIdpServerName("idp-server")
83 | .withSpServerName("sp-server")
84 | .withSpContextPath("/app")
85 | .withKeystoreResource(new DefaultResourceLoader().getResource("classpath:keystore.jks"))
86 | .withKeystorePassword("storepass")
87 | .withKeystoreAlias("alias")
88 | .withKeystorePrivateKeyPassword("keypass")
89 | .withSuccessLoginDefaultUrl("/")
90 | .withSuccessLogoutUrl("/goodbye")
91 | .withStoreCsrfTokenInCookie(true)
92 | .build();
93 | }
94 |
95 | // This configuration is not needed if your signature algorithm is SHA256withRSA and
96 | // digest algorithm is SHA-256. However, if you are using different algorithm(s), then
97 | // add this bean with the correct algorithms.
98 | @Bean
99 | public static SAMLBootstrap samlBootstrap() {
100 | return new DefaultSAMLBootstrap("RSA",
101 | SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA512,
102 | SignatureConstants.ALGO_ID_DIGEST_SHA512);
103 | }
104 |
105 | // call `samlizedConfig(http)` first to decorate `http` with SAML configuration
106 | // before configuring app specific HTTP security
107 | @Override
108 | protected void configure(final HttpSecurity http) throws Exception {
109 | samlizedConfig(http)
110 | .authorizeRequests().antMatchers("/admin/**").hasRole("ADMIN")
111 | .anyRequest().authenticated();
112 | }
113 |
114 | // call `samlizedConfig(web)` first to decorate `web` with SAML configuration
115 | // before configuring app specific web security
116 | @Override
117 | public void configure(final WebSecurity web) throws Exception {
118 | samlizedConfig(web).ignoring().antMatchers("/resources/**");
119 | }
120 | }
121 | ```
122 |
123 | ### Customizing SSL Verification
124 |
125 | By default, the keystore file serves 2 purposes:-
126 |
127 | * Acts as a keystore, containing app's public/private key.
128 | * Acts as a truststore, containing IdP's certificate with public key.
129 |
130 | If the keystore does not contain IdP's certificate, the SSL verification will fail with the following error when attempting to retrieve IdP's metadata:-
131 |
132 | ```
133 | PKIX path construction failed for untrusted credential: [subjectName='CN=idp.server.com,OU=IDP,C=US']: unable to find valid certification path to requested target
134 | I/O exception (javax.net.ssl.SSLPeerUnverifiedException) caught when processing request: SSL peer failed hostname validation for name: null
135 | Error retrieving metadata from https://idp.server.com/federationmetadata/2007-06/federationmetadata.xml
136 | ```
137 |
138 | If you store the IdP's certificate under JDK's truststore (ie: cacerts) and you want the SSL verification to rely on that file, do this:-
139 |
140 | ```java
141 | @Configuration
142 | @EnableWebSecurity
143 | class AppSecurityConfig extends SAMLWebSecurityConfigurerAdapter {
144 |
145 | @Override
146 | protected SAMLConfigBean samlConfigBean() {
147 | return new SAMLConfigBeanBuilder()
148 | // ... other configurations
149 | .withUseJdkCacertsForSslVerification(true)
150 | .build();
151 | }
152 |
153 | ...
154 | }
155 | ```
156 |
157 | ### Environment Properties Driven Configuration
158 |
159 | If you don't want to use `@Profile` to configure environment-specific security, you may pass the configuration values through environment properties.
160 |
161 | To prevent lifecycle loading or circular dependency issues, instead of autowiring `Environment` into the concrete class, use the given autowired `applicationContext` to get hold of the Spring bean.
162 |
163 | ```java
164 | @Configuration
165 | @EnableWebSecurity
166 | class AppSecurityConfig extends SAMLWebSecurityConfigurerAdapter {
167 |
168 | @Override
169 | protected SAMLConfigBean samlConfigBean() {
170 | final Environment env = applicationContext.getBean(Environment.class);
171 |
172 | return new SAMLConfigBeanBuilder()
173 | .withIdpServerName(env.getProperty("idpServerName"))
174 | .withSpServerName(env.getProperty("spServerName"))
175 | .withSpContextPath(env.getProperty("spContextPath"))
176 | .withKeystoreResource(new DefaultResourceLoader().getResource(env.getProperty("keystoreResource")))
177 | .withKeystorePassword(env.getProperty("keystorePassword"))
178 | .withKeystoreAlias(env.getProperty("keystoreAlias"))
179 | .withKeystorePrivateKeyPassword(env.getProperty("keystorePrivateKeyPassword"))
180 | .withSuccessLoginDefaultUrl(env.getProperty("successLoginDefaultUrl"))
181 | .withSuccessLogoutUrl(env.getProperty("successLogoutUrl"))
182 | .withStoreCsrfTokenInCookie(env.getProperty("storeCsrfTokenInCookie"))
183 | .build();
184 | }
185 |
186 | ...
187 | }
188 | ```
189 |
190 | ### Database Driven Configuration
191 |
192 | You may also configure `SAMLConfigBean` by retrieving the configuration values from database.
193 |
194 | Let's assume you have the following Spring JPA repository:-
195 |
196 | ```java
197 | public interface SecurityConfigRepository extends JpaRepository {
198 | SecurityConfigEntity findByEnvironment(String environment);
199 | }
200 | ```
201 |
202 | To prevent lifecycle loading or circular dependency issues, instead of autowiring `SecurityConfigRepository` into the concrete class, use the given autowired `applicationContext` to get hold of the Spring repository bean.
203 |
204 | ```java
205 | @Configuration
206 | @EnableWebSecurity
207 | class AppSecurityConfig extends SAMLWebSecurityConfigurerAdapter {
208 |
209 | @Override
210 | protected SAMLConfigBean samlConfigBean() {
211 | final SecurityConfigRepository repository = applicationContext.getBean(SecurityConfigRepository.class);
212 | final SecurityConfigEntity entity = repository.findByEnvironment("dev");
213 |
214 | return new SAMLConfigBeanBuilder()
215 | .withIdpServerName(entity.getIdpServerName())
216 | .withSpServerName(entity.getSpServerName())
217 | .withSpContextPath(entity.getSpContextPath())
218 | .withKeystoreResource(new DefaultResourceLoader().getResource(entity.getKeystoreResource()))
219 | .withKeystorePassword(entity.getKeystorePassword())
220 | .withKeystoreAlias(entity.getKeystoreAlias())
221 | .withKeystorePrivateKeyPassword(entity.getKeystorePrivateKeyPassword())
222 | .withSuccessLoginDefaultUrl(entity.getSuccessLoginDefaultUrl())
223 | .withSuccessLogoutUrl(entity.getSuccessLogoutUrl())
224 | .withStoreCsrfTokenInCookie(entity.getStoreCsrfTokenInCookie())
225 | .build();
226 | }
227 |
228 | ...
229 | }
230 | ```
231 |
232 | ### Mocking Security by Hardcoding a Given User for Rapid App Development
233 |
234 | ```java
235 | @Override
236 | protected void configure(final HttpSecurity http) throws Exception {
237 | // `CurrentUser` must extend `User`
238 | final CurrentUser currentUser = new CurrentUser("First name", "Last Name", "ROLE_ADMIN");
239 |
240 | mockSecurity(http, currentUser)
241 | .authorizeRequests().antMatchers("/admin/**").hasRole("ADMIN")
242 | .anyRequest().authenticated();
243 | }
244 | ```
245 |
246 | ## SAMLConfigBean Properties
247 |
248 | `SAMLConfigBean` stores app-specific security configuration.
249 |
250 | |Property |Required? |Description |
251 | |--------------------------------|----------|----------------------------------------------------------------------------------------------------------|
252 | |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`. |
253 | |spServerName |Yes |Sp server name. Used for generating correct SAML endpoints in Sp metadata to handle servers doing SSL termination. If Sp link is `https://sp-server:8443/myapp`, value should be `sp-server`. |
254 | |spHttpsPort |No |Sp HTTPS port. Used for generating correct SAML endpoints in Sp metadata to handle servers doing SSL termination. If Sp link is `https://sp-server:8443/myapp`, value should be `8443`.
Default is `443`. |
255 | |spContextPath |No |Sp context path. Used for generating correct SAML endpoints in Sp metadata to handle servers doing SSL termination. If Sp link is `https://sp-server:8443/myapp`, value should be `/myapp`.
Default is `''`. |
256 | |keystoreResource |Yes |App's keystore containing its public/private key and ADFS' certificate with public key. |
257 | |keystorePassword |Yes |Password to access app's keystore. |
258 | |keystoreAlias |Yes |Alias of app's public/private key pair. |
259 | |keystorePrivateKeyPassword |Yes |Password to access app's private key. |
260 | |successLoginDefaultUrl |Yes |Where to redirect user on successful login if no saved request is found in the session. |
261 | |successLogoutUrl |Yes |Where to redirect user on successful logout. |
262 | |failedLoginDefaultUrl |No |Where to redirect user on failed login. This value is set to null, which returns 401 error code on failed login. But, in theory, this will never be used because IdP will handled the failed login on IdP login page.
Default is `''`, which return 401 error code.|
263 | |storeCsrfTokenInCookie |No |Whether to store CSRF token in cookie named `XSRF-TOKEN` and expecting CSRF token to be set using header named `X-XSRF-TOKEN` to cater single-page app using frameworks like React and AngularJS.
Default is `false`. |
264 | |samlUserDetailsService |No |For configuring user details and authorities. When set, `userDetails` will be set as `principal`.
Default is `null`. |
265 | |authnContexts |No |Determine what authentication methods to use. To use the order of authentication methods defined by IdP, set as empty set. To enable Windows Integrated Auth (WIA), use `CustomAuthnContext.WINDOWS_INTEGRATED_AUTHN_CTX`.
Default is `AuthnContext.PASSWORD_AUTHN_CTX` where IdP login page is displayed to obtain user/password.|
266 | |useJdkCacertsForSslVerification |No |When performing IdP's SSL verification, find IdP's certs under JDK's cacerts instead of app's keystore file.
Default is `false`.|
267 |
268 |
269 | ## Important SAML Endpoints
270 |
271 | There are several SAML processing endpoints, but these are the ones you probably care:-
272 |
273 | |Endpoint |Description |
274 | |----------------------|----------------------------------------------------------------------------------------------------------------------------------------|
275 | |`/saml/login` |Initiates login process between Sp and IdP. Upon successful login, user will be redirected to `SAMLConfigBean.successLoginDefaultUrl`. |
276 | |`/saml/logout` |Initiates logout process between Sp and IdP. Upon successful logout, user will be redirected to `SAMLConfigBean.successLogoutUrl`. |
277 | |`/saml/metadata` |Returns Sp metadata. IdP may need this link to register Sp on ADFS. |
278 |
279 | ## Relevant Links
280 |
281 | * [Travis CI Reports](https://travis-ci.org/choonchernlim/spring-security-adfs-saml2)
282 | * [Maven Site](https://choonchernlim.github.io/spring-security-adfs-saml2/project-info.html)
283 |
284 | Learn about my pains and lessons learned while building this module.
285 |
286 | * [Replacing SHA-1 with SHA-256 on Signature and Digest Algorithms](http://myshittycode.com/2016/02/23/spring-security-saml-replacing-sha-1-with-sha-256-on-signature-and-digest-algorithms/)
287 | * [Handling IdP’s Public Certificate When Loading Metadata Over HTTPS](http://myshittycode.com/2016/02/19/spring-security-saml-handling-idps-public-certificate-when-loading-metadata-over-https/)
288 | * [Configuring Binding for Sending SAML Messages to IdP](http://myshittycode.com/2016/02/18/spring-security-saml-configuring-binding-for-sending-saml-messages-to-idp/)
289 | * [Java + SAML: Illegal Key Size](http://myshittycode.com/2016/02/18/java-saml-illegal-key-size/)
290 |
291 |
--------------------------------------------------------------------------------
/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 | 4.0.0
5 |
6 |
7 | com.github.choonchernlim
8 | spring-boot-ci
9 | 0.4.0
10 |
11 |
12 |
13 | spring-security-adfs-saml2
14 | 0.9.0
15 | jar
16 |
17 | Spring Security ADFS SAML2
18 | Spring Security ADFS module using SAML2 protocol
19 | https://github.com/choonchernlim/spring-security-adfs-saml2
20 |
21 |
22 |
23 | MIT License
24 | http://www.opensource.org/licenses/mit-license.php
25 |
26 |
27 |
28 |
29 |
30 | choonchernlim
31 | Choon-Chern Lim
32 | choonchernlim@gmail.com
33 | https://github.com/choonchernlim
34 |
35 |
36 |
37 |
38 | scm:git:git@github.com:choonchernlim/spring-security-adfs-saml2.git
39 | scm:git:git@github.com:choonchernlim/spring-security-adfs-saml2.git
40 | git@github.com:choonchernlim/spring-security-adfs-saml2.git
41 |
42 |
43 |
44 | UTF-8
45 | UTF-8
46 | 1.8
47 |
48 | 0.1.1
49 | 2.6.4
50 | 8.0
51 | 4.2.2
52 | 3.0.1
53 | 1.0.3.RELEASE
54 |
55 |
56 |
57 |
58 |
59 | javax
60 | javaee-api
61 | ${javaee-api.version}
62 |
63 |
64 | net.karneim
65 | pojobuilder
66 | ${pojobuilder.version}
67 |
68 |
69 | org.springframework.security.extensions
70 | spring-security-saml2-core
71 | ${spring-security-saml2.version}
72 |
73 |
74 | xml-apis
75 | xml-apis
76 |
77 |
78 |
79 |
80 | org.opensaml
81 | opensaml
82 | ${opensaml.version}
83 |
84 |
85 | com.github.choonchernlim
86 | better-preconditions
87 | ${better-preconditions.version}
88 |
89 |
90 | org.objenesis
91 | objenesis
92 | ${objenesis.version}
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | javax
101 | javaee-api
102 | provided
103 |
104 |
105 | net.karneim
106 | pojobuilder
107 | provided
108 |
109 |
110 |
111 |
112 | org.springframework.security.extensions
113 | spring-security-saml2-core
114 |
115 |
116 | org.opensaml
117 | opensaml
118 |
119 |
120 | com.github.choonchernlim
121 | better-preconditions
122 |
123 |
124 |
125 |
126 | javax.servlet
127 | javax.servlet-api
128 | test
129 |
130 |
131 | org.springframework
132 | spring-test
133 | test
134 |
135 |
136 | org.spockframework
137 | spock-spring
138 | test
139 |
140 |
141 | cglib
142 | cglib-nodep
143 | test
144 |
145 |
146 | org.objenesis
147 | objenesis
148 | test
149 |
150 |
151 |
152 |
153 |
154 |
161 |
162 | org.apache.maven.plugins
163 | maven-antrun-plugin
164 |
165 |
166 | initialize
167 |
168 | run
169 |
170 |
171 |
172 |
173 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 | org.springframework.boot
184 | spring-boot-maven-plugin
185 |
186 | true
187 |
188 |
189 |
190 |
191 |
--------------------------------------------------------------------------------
/src/main/java/com/github/choonchernlim/security/adfs/saml2/CsrfHeaderFilter.java:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2;
2 |
3 | import com.google.common.base.MoreObjects;
4 | import com.google.common.base.Strings;
5 | import org.springframework.security.web.csrf.CsrfToken;
6 | import org.springframework.web.filter.OncePerRequestFilter;
7 | import org.springframework.web.util.WebUtils;
8 |
9 | import javax.servlet.FilterChain;
10 | import javax.servlet.ServletException;
11 | import javax.servlet.http.Cookie;
12 | import javax.servlet.http.HttpServletRequest;
13 | import javax.servlet.http.HttpServletResponse;
14 | import java.io.IOException;
15 |
16 | /**
17 | * Filter to pass CSRF token to single-page app through cookie.
18 | *
19 | * This approach is approved by Rob Winch, the project lead of Spring Security.
20 | */
21 | final class CsrfHeaderFilter extends OncePerRequestFilter {
22 | /**
23 | * Header name to match AngularJS's spec to ensure this filter is more compatible with major
24 | * client-side MV* frameworks.
25 | */
26 | static final String HEADER_NAME = "X-XSRF-TOKEN";
27 |
28 | /**
29 | * Cookie name to match AngularJS's spec to ensure this filter is more compatible with major
30 | * client-side MV* frameworks.
31 | */
32 | static final String COOKIE_NAME = "XSRF-TOKEN";
33 |
34 | @Override
35 | protected void doFilterInternal(final HttpServletRequest request,
36 | final HttpServletResponse response,
37 | final FilterChain filterChain)
38 | throws ServletException, IOException {
39 |
40 | final CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
41 |
42 | if (csrf != null) {
43 | final String token = csrf.getToken();
44 | final Cookie existingCookie = WebUtils.getCookie(request, COOKIE_NAME);
45 |
46 | // If there's no existing cookie or the token value doesn't match, create/update it.
47 | //
48 | // `domain` = Don't need to set this so that the current request's domain will be used.
49 | // `httpOnly` = Cannot set this value because we need JS to be able to read this cookie to get the token.
50 | // `path` = Match app's root context so that only the app can access this cooke.
51 | // If path is empty, set it as '/' to prevent using the resource path currently requested.
52 | // This prevents creating too many cookies with the same name but different paths
53 | // (due to bookmark-able links) because client side will have difficulties grabbing
54 | // the right CSRF token value from the right cookie.
55 | // Regarding path not set, see See https://en.wikipedia.org/wiki/HTTP_cookie#Domain_and_path
56 | //
57 | // `secure` = Cookie to only be transmitted over secure protocol as https
58 | //
59 | // `maxAge` = Expire the cookie after 8 hours. Cannot use HTTP session timeout value because this
60 | // cookie will only get created/updated if the token value is different instead of
61 | // every time user refreshes the session by interacting with server side.
62 | if (existingCookie == null || !token.equals(existingCookie.getValue())) {
63 | final Cookie cookie = new Cookie(COOKIE_NAME, token);
64 | final String path = MoreObjects.firstNonNull(
65 | Strings.emptyToNull(request.getServletContext().getContextPath()), "/");
66 |
67 | cookie.setPath(path);
68 | cookie.setSecure(true);
69 | cookie.setMaxAge(60 * 60 * 8);
70 | response.addCookie(cookie);
71 | }
72 | }
73 |
74 | filterChain.doFilter(request, response);
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/main/java/com/github/choonchernlim/security/adfs/saml2/CustomAuthnContext.java:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2;
2 |
3 | /**
4 | * ADFS-specific authentication method.
5 | */
6 | public final class CustomAuthnContext {
7 | /**
8 | * Windows integration authentication.
9 | */
10 | public static final String WINDOWS_INTEGRATED_AUTHN_CTX = "urn:federation:authentication:windows";
11 | }
12 |
--------------------------------------------------------------------------------
/src/main/java/com/github/choonchernlim/security/adfs/saml2/DefaultSAMLBootstrap.java:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2;
2 |
3 | import static com.github.choonchernlim.betterPreconditions.preconditions.PreconditionFactory.expect;
4 | import org.opensaml.Configuration;
5 | import org.opensaml.xml.security.BasicSecurityConfiguration;
6 | import org.opensaml.xml.signature.SignatureConstants;
7 | import org.springframework.beans.BeansException;
8 | import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
9 | import org.springframework.security.saml.SAMLBootstrap;
10 |
11 | /**
12 | * By default, Spring Security SAML uses SHA1withRSA for signature algorithm and SHA-1 for digest algorithm.
13 | *
14 | * This class allows app to use stronger encryption such as SHA-256.
15 | *
16 | * See: http://stackoverflow.com/questions/23681362/how-to-change-the-signature-algorithm-of-saml-request-in-spring-security
17 | * See: http://stackoverflow.com/questions/25982093/setting-the-extendedmetadata-signingalgorithm-field/26004147
18 | */
19 | public final class DefaultSAMLBootstrap extends SAMLBootstrap {
20 |
21 | private final String signatureAlgorithmName;
22 | private final String signatureAlgorithmURI;
23 | private final String digestAlgorithmURI;
24 |
25 | /**
26 | * Default signature algorithm is SHA256withRSA and default digest algorithm is SHA-256.
27 | */
28 | public DefaultSAMLBootstrap() {
29 | this("RSA", SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256, SignatureConstants.ALGO_ID_DIGEST_SHA256);
30 | }
31 |
32 | /**
33 | * Allows user to specify different algorithm URIs.
34 | *
35 | * @param signatureAlgorithmName Signature algorithm name
36 | * @param signatureAlgorithmURI Signature algorithm URI
37 | * @param digestAlgorithmURI Digest algorithm URI
38 | */
39 | public DefaultSAMLBootstrap(final String signatureAlgorithmName,
40 | final String signatureAlgorithmURI,
41 | final String digestAlgorithmURI) {
42 | //@formatter:off
43 | this.signatureAlgorithmName = expect(signatureAlgorithmName, "Signature algorithm name").not().toBeBlank().check();
44 | this.signatureAlgorithmURI = expect(signatureAlgorithmURI, "Signature algorithm URI").not().toBeBlank().check();
45 | this.digestAlgorithmURI = expect(digestAlgorithmURI, "Digest algorithm URI").not().toBeBlank().check();
46 | //@formatter:on
47 | }
48 |
49 | @Override
50 | public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
51 | super.postProcessBeanFactory(beanFactory);
52 | BasicSecurityConfiguration config = (BasicSecurityConfiguration) Configuration.getGlobalSecurityConfiguration();
53 | config.registerSignatureAlgorithmURI(signatureAlgorithmName, signatureAlgorithmURI);
54 | config.setSignatureReferenceDigestMethod(digestAlgorithmURI);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/main/java/com/github/choonchernlim/security/adfs/saml2/JndiBackedKeystoreService.java:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2;
2 |
3 | import static com.github.choonchernlim.betterPreconditions.preconditions.PreconditionFactory.expect;
4 | import com.google.common.base.Splitter;
5 | import org.springframework.core.io.DefaultResourceLoader;
6 | import org.springframework.core.io.Resource;
7 | import org.springframework.core.io.ResourceLoader;
8 | import org.springframework.jndi.JndiTemplate;
9 |
10 | import java.io.InputStream;
11 | import java.security.KeyStore;
12 | import java.util.Iterator;
13 | import java.util.List;
14 |
15 | /**
16 | * Helper class that retrieves JNDI value and returns a keystore bean.
17 | * The JNDI value has the following format: "jks-path,alias,storepass,keypass"
18 | */
19 | public class JndiBackedKeystoreService {
20 | private final ResourceLoader resourceLoader = new DefaultResourceLoader();
21 |
22 | /**
23 | * JNDI name
24 | */
25 | private final String jndiName;
26 |
27 | private JndiTemplate jndiTemplate = new JndiTemplate();
28 |
29 | public JndiBackedKeystoreService(final String jndiName) {
30 | this.jndiName = jndiName;
31 | }
32 |
33 | /**
34 | * For mocking out instance during testing.
35 | *
36 | * @param jndiTemplate Jndi template
37 | */
38 | public void setJndiTemplate(final JndiTemplate jndiTemplate) {
39 | this.jndiTemplate = jndiTemplate;
40 | }
41 |
42 | /**
43 | * Retrieves keystore info from JNDI.
44 | *
45 | * @return Keystore bean
46 | */
47 | public KeystoreBean get() {
48 | final Iterator ite = getJndiValues();
49 | return getKeystoreBean(ite.next(), ite.next(), ite.next(), ite.next());
50 | }
51 |
52 | /**
53 | * Returns transformed JNDI value from comma separated value to collection.
54 | *
55 | * @return JNDI values
56 | */
57 | private Iterator getJndiValues() {
58 | final String jndiValue;
59 | try {
60 | jndiValue = jndiTemplate.lookup(jndiName, String.class);
61 | }
62 | catch (Exception e) {
63 | throw new SpringSecurityAdfsSaml2Exception(String.format("Unable to get value from JNDI: %s", jndiName), e);
64 | }
65 |
66 | final List jndiValues = Splitter.on(",").trimResults().splitToList(jndiValue);
67 |
68 | expect(jndiValues.size(), "jndiValues size").toBeEqual(4).check();
69 |
70 | return jndiValues.iterator();
71 | }
72 |
73 | /**
74 | * Ensures the input values are all valid before returning keystore bean.
75 | *
76 | * @param jksPath JKS path
77 | * @param keystoreAlias Keystore alias
78 | * @param keystorePassword Keystore password
79 | * @param keystorePrivateKeyPassword Keystore private key password
80 | * @return Keystore bean
81 | */
82 | private KeystoreBean getKeystoreBean(final String jksPath,
83 | final String keystoreAlias,
84 | final String keystorePassword,
85 | final String keystorePrivateKeyPassword) {
86 | final Resource keystoreResource = resourceLoader.getResource(jksPath);
87 |
88 | final InputStream keystoreInputStream;
89 | try {
90 | keystoreInputStream = keystoreResource.getInputStream();
91 | }
92 | catch (Exception e) {
93 | throw new SpringSecurityAdfsSaml2Exception("Invalid keystore path", e);
94 | }
95 |
96 | final KeyStore keyStore;
97 | try {
98 | keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
99 | }
100 | catch (Exception e) {
101 | throw new SpringSecurityAdfsSaml2Exception("Unable to initialize keystore", e);
102 | }
103 |
104 | try {
105 | keyStore.load(keystoreInputStream, keystorePassword.toCharArray());
106 | }
107 | catch (Exception e) {
108 | throw new SpringSecurityAdfsSaml2Exception("Invalid keystore password", e);
109 | }
110 |
111 | try {
112 | if (!keyStore.isKeyEntry(keystoreAlias)) {
113 | throw new IllegalArgumentException("Provided alias not found");
114 | }
115 | }
116 | catch (Exception e) {
117 | throw new SpringSecurityAdfsSaml2Exception("Invalid keystore alias", e);
118 | }
119 |
120 | try {
121 | keyStore.getKey(keystoreAlias, keystorePrivateKeyPassword.toCharArray());
122 | }
123 | catch (Exception e) {
124 | throw new SpringSecurityAdfsSaml2Exception("Invalid keystore private key password", e);
125 | }
126 |
127 | return new KeystoreBeanBuilder()
128 | .withJksPath(jksPath)
129 | .withKeystoreAlias(keystoreAlias)
130 | .withKeystorePassword(keystorePassword)
131 | .withKeystorePrivateKeyPassword(keystorePrivateKeyPassword)
132 | .withKeystoreResource(keystoreResource)
133 | .build();
134 | }
135 |
136 |
137 | }
138 |
--------------------------------------------------------------------------------
/src/main/java/com/github/choonchernlim/security/adfs/saml2/KeystoreBean.java:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2;
2 |
3 | import net.karneim.pojobuilder.GeneratePojoBuilder;
4 | import org.springframework.core.io.Resource;
5 |
6 | /**
7 | * Keystore related info.
8 | */
9 | public final class KeystoreBean {
10 | private final String jksPath;
11 | private final String keystoreAlias;
12 | private final String keystorePassword;
13 | private final String keystorePrivateKeyPassword;
14 | private final Resource keystoreResource;
15 |
16 | @GeneratePojoBuilder
17 | KeystoreBean(final String jksPath,
18 | final String keystoreAlias,
19 | final String keystorePassword,
20 | final String keystorePrivateKeyPassword,
21 | final Resource keystoreResource) {
22 | this.jksPath = jksPath;
23 | this.keystoreAlias = keystoreAlias;
24 | this.keystorePassword = keystorePassword;
25 | this.keystorePrivateKeyPassword = keystorePrivateKeyPassword;
26 | this.keystoreResource = keystoreResource;
27 | }
28 |
29 | public String getJksPath() {
30 | return jksPath;
31 | }
32 |
33 | public String getKeystoreAlias() {
34 | return keystoreAlias;
35 | }
36 |
37 | public String getKeystorePassword() {
38 | return keystorePassword;
39 | }
40 |
41 | public String getKeystorePrivateKeyPassword() {
42 | return keystorePrivateKeyPassword;
43 | }
44 |
45 | public Resource getKeystoreResource() {
46 | return keystoreResource;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/main/java/com/github/choonchernlim/security/adfs/saml2/MockFilterSecurityInterceptor.java:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2;
2 |
3 | import static com.github.choonchernlim.betterPreconditions.preconditions.PreconditionFactory.expect;
4 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
5 | import org.springframework.security.core.context.SecurityContext;
6 | import org.springframework.security.core.context.SecurityContextHolder;
7 | import org.springframework.security.core.userdetails.User;
8 | import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
9 |
10 | import javax.servlet.Filter;
11 | import javax.servlet.FilterChain;
12 | import javax.servlet.FilterConfig;
13 | import javax.servlet.ServletException;
14 | import javax.servlet.ServletRequest;
15 | import javax.servlet.ServletResponse;
16 | import javax.servlet.http.HttpServletRequest;
17 | import java.io.IOException;
18 |
19 | /**
20 | * This filter class can be placed before Spring Security's FilterSecurityInterceptor to bypass any security
21 | * by setting the given user to the security context and HTTP session. This allows developer to do rapid app
22 | * development without the need to log into the app again and again.
23 | */
24 | final class MockFilterSecurityInterceptor implements Filter {
25 |
26 | private final User user;
27 |
28 | MockFilterSecurityInterceptor(final User user) {
29 | expect(user, "user").not().toBeNull().check();
30 | this.user = user;
31 | }
32 |
33 | @Override
34 | public void doFilter(final ServletRequest servletRequest,
35 | final ServletResponse servletResponse,
36 | final FilterChain filterChain) throws IOException, ServletException {
37 |
38 | // To be consistent with SAML configuration, the `userDetails` is set as `principal` too
39 | final UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
40 | user,
41 | null,
42 | user.getAuthorities());
43 |
44 | // setting user details
45 | authentication.setDetails(user);
46 |
47 | // setting `authentication` to security context
48 | final SecurityContext securityContext = SecurityContextHolder.getContext();
49 | securityContext.setAuthentication(authentication);
50 |
51 | // setting `authentication` to HTTP session
52 | ((HttpServletRequest) servletRequest)
53 | .getSession(true)
54 | .setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext);
55 |
56 | filterChain.doFilter(servletRequest, servletResponse);
57 | }
58 |
59 | @Override
60 | public void init(final FilterConfig filterConfig) throws ServletException {
61 | }
62 |
63 | @Override
64 | public void destroy() {
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/main/java/com/github/choonchernlim/security/adfs/saml2/SAMLConfigBean.java:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2;
2 |
3 | import static com.github.choonchernlim.betterPreconditions.preconditions.PreconditionFactory.expect;
4 | import com.google.common.collect.ImmutableSet;
5 | import net.karneim.pojobuilder.GeneratePojoBuilder;
6 | import org.opensaml.saml2.core.AuthnContext;
7 | import org.springframework.core.io.Resource;
8 | import org.springframework.security.saml.userdetails.SAMLUserDetailsService;
9 |
10 | import java.util.Optional;
11 | import java.util.Set;
12 |
13 | /**
14 | * This class contains all properties that can be configured by Sp using the provided builder class.
15 | */
16 | public final class SAMLConfigBean {
17 |
18 | /**
19 | * (REQUIRED) IdP's server name.
20 | */
21 | private final String idpServerName;
22 |
23 | /**
24 | * (REQUIRED) Sp's server name.
25 | */
26 | private final String spServerName;
27 |
28 | /**
29 | * (OPTIONAL) Sp's HTTPS port.
30 | *
31 | * Default is 443.
32 | */
33 | private final Integer spHttpsPort;
34 |
35 | /**
36 | * (OPTIONAL) Sp's context path.
37 | *
38 | * Default is "".
39 | */
40 | private final String spContextPath;
41 |
42 | /**
43 | * (REQUIRED) Keystore containing app's public/private key and ADFS' certificate with public key.
44 | */
45 | private final Resource keystoreResource;
46 |
47 | /**
48 | * (REQUIRED) Keystore alias.
49 | */
50 | private final String keystoreAlias;
51 |
52 | /**
53 | * (REQUIRED) Keystore password.
54 | */
55 | private final String keystorePassword;
56 |
57 | /**
58 | * (REQUIRED) Keystore private key password.
59 | */
60 | private final String keystorePrivateKeyPassword;
61 |
62 | /**
63 | * (REQUIRED) Where to redirect user on successful login if no saved request is found in the session.
64 | */
65 | private final String successLoginDefaultUrl;
66 |
67 | /**
68 | * (REQUIRED) Where to redirect user on successful logout.
69 | */
70 | private final String successLogoutUrl;
71 |
72 | /**
73 | * Where to redirect user on failed login. This value is set to null, which returns
74 | * 401 error code on failed login. But, in theory, this will never be used because
75 | * IdP will handled the failed login on IdP login page.
76 | *
77 | * Default is blank.
78 | */
79 | private final String failedLoginDefaultUrl;
80 |
81 | /**
82 | * For configuring user details and authorities.
83 | *
84 | * Default is null.
85 | */
86 | private final SAMLUserDetailsService samlUserDetailsService;
87 |
88 | /**
89 | * Whether to store CSRF token in cookie.
90 | *
91 | * Default is false.
92 | */
93 | private final Boolean storeCsrfTokenInCookie;
94 |
95 | /**
96 | * Determine what authentication methods to use.
97 | *
98 | * To use the order of authentication methods defined by IdP, set as empty set.
99 | *
100 | * To enable Windows Integrated Auth (WIA) cross browsers and OSes, use `CustomAuthnContext.WINDOWS_INTEGRATED_AUTHN_CTX`.
101 | *
102 | * Default is user/password authentication where IdP login page is displayed.
103 | */
104 | private final Set authnContexts;
105 |
106 | /**
107 | * Whether to rely on JDK's cacerts for SSL verification or not.
108 | *
109 | * By default, the provided keystore contains ADFS' certificate(s) to perform SSL verification.
110 | *
111 | * Default is false.
112 | */
113 | private final Boolean useJdkCacertsForSslVerification;
114 |
115 | @GeneratePojoBuilder
116 | SAMLConfigBean(final String idpServerName,
117 | final String spServerName,
118 | final Integer spHttpsPort,
119 | final String spContextPath,
120 | final Resource keystoreResource,
121 | final String keystoreAlias,
122 | final String keystorePassword,
123 | final String keystorePrivateKeyPassword,
124 | final String successLoginDefaultUrl,
125 | final String successLogoutUrl,
126 | final String failedLoginDefaultUrl,
127 | final Boolean storeCsrfTokenInCookie,
128 | final SAMLUserDetailsService samlUserDetailsService,
129 | final Set authnContexts,
130 | final Boolean useJdkCacertsForSslVerification) {
131 |
132 | //@formatter:off
133 | this.idpServerName = expect(idpServerName, "IdP server name").not().toBeBlank().check();
134 |
135 | this.spServerName = expect(spServerName, "Sp server name").not().toBeBlank().check();
136 | this.spHttpsPort = Optional.ofNullable(spHttpsPort).orElse(443);
137 | this.spContextPath = Optional.ofNullable(spContextPath).orElse("");
138 |
139 | this.keystoreResource = (Resource) expect(keystoreResource, "Key store").not().toBeNull().check();
140 | this.keystoreAlias = expect(keystoreAlias, "Keystore alias").not().toBeBlank().check();
141 | this.keystorePassword = expect(keystorePassword, "Keystore password").not().toBeBlank().check();
142 | this.keystorePrivateKeyPassword = expect(keystorePrivateKeyPassword, "Keystore private key password").not().toBeBlank().check();
143 |
144 | this.successLoginDefaultUrl = expect(successLoginDefaultUrl, "Success login URL").not().toBeBlank().check();
145 | this.successLogoutUrl = expect(successLogoutUrl, "Success logout URL").not().toBeBlank().check();
146 | this.failedLoginDefaultUrl = Optional.ofNullable(failedLoginDefaultUrl).orElse("");
147 |
148 | this.storeCsrfTokenInCookie = Optional.ofNullable(storeCsrfTokenInCookie).orElse( false);
149 | this.samlUserDetailsService = samlUserDetailsService;
150 |
151 | this.authnContexts = Optional.ofNullable(authnContexts).orElse(ImmutableSet.of(AuthnContext.PASSWORD_AUTHN_CTX));
152 | this.useJdkCacertsForSslVerification = Optional.ofNullable(useJdkCacertsForSslVerification).orElse(false);
153 | //@formatter:on
154 | }
155 |
156 | public String getIdpServerName() {
157 | return idpServerName;
158 | }
159 |
160 | public String getSpServerName() {
161 | return spServerName;
162 | }
163 |
164 | public Integer getSpHttpsPort() {
165 | return spHttpsPort;
166 | }
167 |
168 | public String getSpContextPath() {
169 | return spContextPath;
170 | }
171 |
172 | public Resource getKeystoreResource() {
173 | return keystoreResource;
174 | }
175 |
176 | public String getKeystoreAlias() {
177 | return keystoreAlias;
178 | }
179 |
180 | public String getKeystorePassword() {
181 | return keystorePassword;
182 | }
183 |
184 | public String getKeystorePrivateKeyPassword() {
185 | return keystorePrivateKeyPassword;
186 | }
187 |
188 | public String getSuccessLoginDefaultUrl() {
189 | return successLoginDefaultUrl;
190 | }
191 |
192 | public String getSuccessLogoutUrl() {
193 | return successLogoutUrl;
194 | }
195 |
196 | public String getFailedLoginDefaultUrl() {
197 | return failedLoginDefaultUrl;
198 | }
199 |
200 | public Boolean getStoreCsrfTokenInCookie() {
201 | return storeCsrfTokenInCookie;
202 | }
203 |
204 | public SAMLUserDetailsService getSamlUserDetailsService() {
205 | return samlUserDetailsService;
206 | }
207 |
208 | public Set getAuthnContexts() {
209 | return authnContexts;
210 | }
211 |
212 | public Boolean getUseJdkCacertsForSslVerification() {
213 | return useJdkCacertsForSslVerification;
214 | }
215 | }
216 |
--------------------------------------------------------------------------------
/src/main/java/com/github/choonchernlim/security/adfs/saml2/SAMLWebSecurityConfigurerAdapter.java:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2;
2 |
3 | import static com.github.choonchernlim.betterPreconditions.preconditions.PreconditionFactory.expect;
4 | import com.google.common.collect.ImmutableList;
5 | import com.google.common.collect.ImmutableMap;
6 | import org.apache.commons.httpclient.HttpClient;
7 | import org.apache.commons.httpclient.MultiThreadedHttpConnectionManager;
8 | import org.apache.commons.httpclient.protocol.Protocol;
9 | import org.apache.commons.httpclient.protocol.ProtocolSocketFactory;
10 | import org.apache.velocity.app.VelocityEngine;
11 | import org.opensaml.common.xml.SAMLConstants;
12 | import org.opensaml.saml2.metadata.provider.HTTPMetadataProvider;
13 | import org.opensaml.saml2.metadata.provider.MetadataProvider;
14 | import org.opensaml.saml2.metadata.provider.MetadataProviderException;
15 | import org.opensaml.xml.parse.StaticBasicParserPool;
16 | import org.springframework.beans.factory.annotation.Autowired;
17 | import org.springframework.beans.factory.config.MethodInvokingFactoryBean;
18 | import org.springframework.context.ApplicationContext;
19 | import org.springframework.context.annotation.Bean;
20 | import org.springframework.security.authentication.AuthenticationManager;
21 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
22 | import org.springframework.security.config.annotation.web.builders.HttpSecurity;
23 | import org.springframework.security.config.annotation.web.builders.WebSecurity;
24 | import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
25 | import org.springframework.security.core.userdetails.User;
26 | import org.springframework.security.saml.SAMLAuthenticationProvider;
27 | import org.springframework.security.saml.SAMLBootstrap;
28 | import org.springframework.security.saml.SAMLDiscovery;
29 | import org.springframework.security.saml.SAMLEntryPoint;
30 | import org.springframework.security.saml.SAMLLogoutFilter;
31 | import org.springframework.security.saml.SAMLLogoutProcessingFilter;
32 | import org.springframework.security.saml.SAMLProcessingFilter;
33 | import org.springframework.security.saml.SAMLWebSSOHoKProcessingFilter;
34 | import org.springframework.security.saml.context.SAMLContextProviderLB;
35 | import org.springframework.security.saml.key.JKSKeyManager;
36 | import org.springframework.security.saml.key.KeyManager;
37 | import org.springframework.security.saml.log.SAMLDefaultLogger;
38 | import org.springframework.security.saml.metadata.CachingMetadataManager;
39 | import org.springframework.security.saml.metadata.ExtendedMetadataDelegate;
40 | import org.springframework.security.saml.metadata.MetadataDisplayFilter;
41 | import org.springframework.security.saml.metadata.MetadataGenerator;
42 | import org.springframework.security.saml.metadata.MetadataGeneratorFilter;
43 | import org.springframework.security.saml.parser.ParserPoolHolder;
44 | import org.springframework.security.saml.processor.HTTPArtifactBinding;
45 | import org.springframework.security.saml.processor.HTTPPAOS11Binding;
46 | import org.springframework.security.saml.processor.HTTPPostBinding;
47 | import org.springframework.security.saml.processor.HTTPRedirectDeflateBinding;
48 | import org.springframework.security.saml.processor.HTTPSOAP11Binding;
49 | import org.springframework.security.saml.processor.SAMLBinding;
50 | import org.springframework.security.saml.processor.SAMLProcessorImpl;
51 | import org.springframework.security.saml.trust.httpclient.TLSProtocolConfigurer;
52 | import org.springframework.security.saml.trust.httpclient.TLSProtocolSocketFactory;
53 | import org.springframework.security.saml.userdetails.SAMLUserDetailsService;
54 | import org.springframework.security.saml.util.VelocityFactory;
55 | import org.springframework.security.saml.websso.ArtifactResolutionProfileImpl;
56 | import org.springframework.security.saml.websso.SingleLogoutProfile;
57 | import org.springframework.security.saml.websso.SingleLogoutProfileImpl;
58 | import org.springframework.security.saml.websso.WebSSOProfile;
59 | import org.springframework.security.saml.websso.WebSSOProfileConsumer;
60 | import org.springframework.security.saml.websso.WebSSOProfileConsumerHoKImpl;
61 | import org.springframework.security.saml.websso.WebSSOProfileConsumerImpl;
62 | import org.springframework.security.saml.websso.WebSSOProfileECPImpl;
63 | import org.springframework.security.saml.websso.WebSSOProfileImpl;
64 | import org.springframework.security.saml.websso.WebSSOProfileOptions;
65 | import org.springframework.security.web.DefaultSecurityFilterChain;
66 | import org.springframework.security.web.FilterChainProxy;
67 | import org.springframework.security.web.SecurityFilterChain;
68 | import org.springframework.security.web.access.channel.ChannelProcessingFilter;
69 | import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
70 | import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
71 | import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
72 | import org.springframework.security.web.authentication.logout.LogoutHandler;
73 | import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
74 | import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
75 | import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
76 | import org.springframework.security.web.csrf.CsrfFilter;
77 | import org.springframework.security.web.csrf.CsrfTokenRepository;
78 | import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
79 | import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
80 |
81 | import java.util.Timer;
82 |
83 | /**
84 | * Spring Security configuration to authenticate against ADFS using SAML protocol.
85 | * This class should be extended by Sp's Java-based Spring configuration for web security.
86 | */
87 | public abstract class SAMLWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
88 |
89 | /**
90 | * Provides an opportunity for child class to access any Spring beans, if needed.
91 | */
92 | @Autowired
93 | protected ApplicationContext applicationContext;
94 |
95 | @Autowired
96 | private SAMLAuthenticationProvider samlAuthenticationProvider;
97 |
98 | // Initialization of OpenSAML library, must be static to prevent "ObjectPostProcessor is a required bean" exception
99 | // By default, Spring Security SAML uses SHA-1. So, use `DefaultSAMLBootstrap` to use SHA-256.
100 | @Bean
101 | public static SAMLBootstrap samlBootstrap() {
102 | return new DefaultSAMLBootstrap();
103 | }
104 |
105 | /**
106 | * Sp's SAMLConfigBean to further customize this security configuration.
107 | *
108 | * @return SAML config bean
109 | */
110 | @Bean
111 | protected abstract SAMLConfigBean samlConfigBean();
112 |
113 | /**
114 | * Fluent API that pre-configures HttpSecurity with SAML specific configuration.
115 | *
116 | * @param http HttpSecurity instance
117 | * @return Same HttpSecurity instance
118 | * @throws Exception Exception
119 | */
120 | // CSRF must be disabled when processing /saml/** to prevent "Expected CSRF token not found" exception.
121 | // See: http://stackoverflow.com/questions/26508835/spring-saml-extension-and-spring-security-csrf-protection-conflict/26560447
122 | protected final HttpSecurity samlizedConfig(final HttpSecurity http) throws Exception {
123 | http.httpBasic().authenticationEntryPoint(samlEntryPoint())
124 | .and()
125 | .csrf().ignoringAntMatchers("/saml/**")
126 | .and()
127 | .authorizeRequests().antMatchers("/saml/**").permitAll()
128 | .and()
129 | .addFilterBefore(metadataGeneratorFilter(), ChannelProcessingFilter.class)
130 | .addFilterAfter(filterChainProxy(), BasicAuthenticationFilter.class);
131 |
132 | // store CSRF token in cookie
133 | if (samlConfigBean().getStoreCsrfTokenInCookie()) {
134 | http.csrf()
135 | .csrfTokenRepository(csrfTokenRepository())
136 | .and()
137 | .addFilterAfter(new CsrfHeaderFilter(), CsrfFilter.class);
138 | }
139 |
140 | return http;
141 | }
142 |
143 | /**
144 | * Configure CSRF token repository to accept CSRF token from AngularJS friendly header.
145 | *
146 | * @return CsrfTokenRepository
147 | */
148 | private CsrfTokenRepository csrfTokenRepository() {
149 | HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
150 | repository.setHeaderName(CsrfHeaderFilter.HEADER_NAME);
151 | return repository;
152 | }
153 |
154 |
155 | /**
156 | * Mocks security by hardcoding a given user so that it will always appear that user is accessing the protected
157 | * resources. This is useful to allow developer to bypass any web authentication against ADFS during rapid
158 | * app development.
159 | *
160 | * @param http HttpSecurity instance
161 | * @param user User instance
162 | * @return HttpSecurity that will never authenticate against ADFS
163 | */
164 | protected final HttpSecurity mockSecurity(final HttpSecurity http, final User user) {
165 | expect(user, "user").not().toBeNull().check();
166 |
167 | if (samlConfigBean().getSamlUserDetailsService() == null) {
168 | throw new SpringSecurityAdfsSaml2Exception(
169 | "`samlConfigBean.samlUserDetailsService` cannot be null. " +
170 | "When mocking security, the given user details object will be set as principal. " +
171 | "Because setting `samlConfigBean.samlUserDetailsService` will set the user details object as principal, " +
172 | "this property must be configured to ensure the mock security mimics the actual security configuration."
173 | );
174 | }
175 |
176 | return http.addFilterBefore(new MockFilterSecurityInterceptor(user), FilterSecurityInterceptor.class);
177 | }
178 |
179 | /**
180 | * Fluent API that pre-configures WebSecurity with SAML specific configuration.
181 | *
182 | * @param web WebSecurity instance
183 | * @return Same WebSecurity instance
184 | * @throws Exception Exception
185 | */
186 | protected final WebSecurity samlizedConfig(final WebSecurity web) throws Exception {
187 | web.ignoring().antMatchers(samlConfigBean().getSuccessLogoutUrl());
188 | return web;
189 | }
190 |
191 | // IDP metadata URL
192 | private String getMetdataUrl() {
193 | return String.format("https://%s/federationmetadata/2007-06/federationmetadata.xml",
194 | samlConfigBean().getIdpServerName());
195 | }
196 |
197 | // Entry point to initialize authentication
198 | @Bean
199 | public SAMLEntryPoint samlEntryPoint() {
200 | SAMLEntryPoint samlEntryPoint = new SAMLEntryPoint();
201 | samlEntryPoint.setDefaultProfileOptions(webSSOProfileOptions());
202 | return samlEntryPoint;
203 | }
204 |
205 | /**
206 | * Customizing SAML request message to be sent to the IDP.
207 | *
208 | * @return WebSSOProfileOptions
209 | */
210 | @Bean
211 | public WebSSOProfileOptions webSSOProfileOptions() {
212 | WebSSOProfileOptions webSSOProfileOptions = new WebSSOProfileOptions();
213 |
214 | // Disable element scoping when sending requests to IdP to prevent
215 | // "Response has invalid status code urn:oasis:names:tc:SAML:2.0:status:Responder, status message is null"
216 | // exception
217 | webSSOProfileOptions.setIncludeScoping(false);
218 |
219 | // Always use HTTP-Redirect instead of HTTP-Post, although both works with ADFS
220 | webSSOProfileOptions.setBinding(SAMLConstants.SAML2_REDIRECT_BINDING_URI);
221 |
222 | // Force IdP to re-authenticate user if issued token is too old to prevent
223 | // "Authentication statement is too old to be used with value" exception
224 | // See: http://stackoverflow.com/questions/30528636/saml-login-errors
225 | webSSOProfileOptions.setForceAuthN(true);
226 |
227 | // Determine what authentication method to use (WIA, user/password, etc).
228 | // If not set, it will use authentication method order defined by IdP
229 | if (!samlConfigBean().getAuthnContexts().isEmpty()) {
230 | webSSOProfileOptions.setAuthnContexts(samlConfigBean().getAuthnContexts());
231 | }
232 |
233 | return webSSOProfileOptions;
234 | }
235 |
236 | // Filter automatically generates default SP metadata
237 | @Bean
238 | public MetadataGeneratorFilter metadataGeneratorFilter() {
239 | // generates base URL that matches `SAMLContextProviderLB` configuration
240 | // to ensure SAML endpoints work for server doing SSL termination
241 | StringBuilder sb = new StringBuilder();
242 | sb.append("https://").append(samlConfigBean().getSpServerName());
243 | if (samlConfigBean().getSpHttpsPort() != 443) {
244 | sb.append(":").append(samlConfigBean().getSpHttpsPort());
245 | }
246 | sb.append(samlConfigBean().getSpContextPath());
247 | String entityBaseUrl = sb.toString();
248 |
249 | MetadataGenerator metadataGenerator = new MetadataGenerator();
250 | metadataGenerator.setKeyManager(keyManager());
251 | metadataGenerator.setEntityBaseURL(entityBaseUrl);
252 | return new MetadataGeneratorFilter(metadataGenerator);
253 | }
254 |
255 | // HTTP client
256 | @Bean
257 | public HttpClient httpClient() {
258 | return new HttpClient(new MultiThreadedHttpConnectionManager());
259 | }
260 |
261 | // Filters for processing of SAML messages
262 | @Bean
263 | public FilterChainProxy filterChainProxy() throws Exception {
264 | //@formatter:off
265 | return new FilterChainProxy(ImmutableList.of(
266 | new DefaultSecurityFilterChain(new AntPathRequestMatcher("/saml/login/**"), samlEntryPoint()),
267 | new DefaultSecurityFilterChain(new AntPathRequestMatcher("/saml/logout/**"), samlLogoutFilter()),
268 | new DefaultSecurityFilterChain(new AntPathRequestMatcher("/saml/metadata/**"), metadataDisplayFilter()),
269 | new DefaultSecurityFilterChain(new AntPathRequestMatcher("/saml/SSO/**"), samlProcessingFilter()),
270 | new DefaultSecurityFilterChain(new AntPathRequestMatcher("/saml/SSOHoK/**"), samlWebSSOHoKProcessingFilter()),
271 | new DefaultSecurityFilterChain(new AntPathRequestMatcher("/saml/SingleLogout/**"), samlLogoutProcessingFilter()),
272 | new DefaultSecurityFilterChain(new AntPathRequestMatcher("/saml/discovery/**"), samlIDPDiscovery())
273 | ));
274 | //@formatter:on
275 | }
276 |
277 | // Handler deciding where to redirect user after successful login
278 | @Bean
279 | public SavedRequestAwareAuthenticationSuccessHandler successRedirectHandler() {
280 | SavedRequestAwareAuthenticationSuccessHandler successRedirectHandler = new SavedRequestAwareAuthenticationSuccessHandler();
281 | successRedirectHandler.setDefaultTargetUrl(samlConfigBean().getSuccessLoginDefaultUrl());
282 | return successRedirectHandler;
283 | }
284 |
285 | // Handler deciding where to redirect user after failed login
286 | @Bean
287 | public SimpleUrlAuthenticationFailureHandler failureRedirectHandler() {
288 | SimpleUrlAuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();
289 |
290 | // The precondition on `setDefaultFailureUrl(..)` will cause an exception if the value is null.
291 | // So, only set this value if it is not null
292 | if (!samlConfigBean().getFailedLoginDefaultUrl().isEmpty()) {
293 | failureHandler.setDefaultFailureUrl(samlConfigBean().getFailedLoginDefaultUrl());
294 | }
295 |
296 | return failureHandler;
297 | }
298 |
299 | // Handler for successful logout
300 | @Bean
301 | public SimpleUrlLogoutSuccessHandler successLogoutHandler() {
302 | SimpleUrlLogoutSuccessHandler successLogoutHandler = new SimpleUrlLogoutSuccessHandler();
303 | successLogoutHandler.setDefaultTargetUrl(samlConfigBean().getSuccessLogoutUrl());
304 | return successLogoutHandler;
305 | }
306 |
307 | // Authentication manager
308 | @Bean
309 | @Override
310 | public AuthenticationManager authenticationManagerBean() throws Exception {
311 | return super.authenticationManagerBean();
312 | }
313 |
314 | // Register authentication manager for SAML provider
315 | @Override
316 | protected void configure(AuthenticationManagerBuilder auth) throws Exception {
317 | auth.authenticationProvider(samlAuthenticationProvider);
318 | }
319 |
320 | // Logger for SAML messages and events
321 | @Bean
322 | public SAMLDefaultLogger samlLogger() {
323 | return new SAMLDefaultLogger();
324 | }
325 |
326 | // Central storage of cryptographic keys
327 | @Bean
328 | public KeyManager keyManager() {
329 | return new JKSKeyManager(samlConfigBean().getKeystoreResource(),
330 | samlConfigBean().getKeystorePassword(),
331 | ImmutableMap.of(samlConfigBean().getKeystoreAlias(),
332 | samlConfigBean().getKeystorePrivateKeyPassword()),
333 | samlConfigBean().getKeystoreAlias());
334 | }
335 |
336 | // IDP Discovery service
337 | @Bean
338 | public SAMLDiscovery samlIDPDiscovery() {
339 | return new SAMLDiscovery();
340 | }
341 |
342 | // The filter is waiting for connections on URL suffixed with filterSuffix and presents SP metadata there
343 | @Bean
344 | public MetadataDisplayFilter metadataDisplayFilter() {
345 | return new MetadataDisplayFilter();
346 | }
347 |
348 | // Configure HTTP Client to accept certificates from the keystore or from JDK cacerts for IDP SSL verification
349 | @Bean
350 | public TLSProtocolConfigurer tlsProtocolConfigurer() {
351 | // To perform IDP SSL verification against app keystore file, return `TLSProtocolConfigurer`.
352 | // Otherwise, return null so that it will try to find the IDP certs in JDK's cacerts.
353 | //
354 | // See https://stackoverflow.com/questions/28505824/spring-security-saml-https-to-another-page/28538799#28538799
355 | return !samlConfigBean().getUseJdkCacertsForSslVerification() ?
356 | new TLSProtocolConfigurer() :
357 | null;
358 | }
359 |
360 | // Configure TLSProtocolConfigurer
361 | @Bean
362 | public ProtocolSocketFactory protocolSocketFactory() {
363 | return new TLSProtocolSocketFactory(keyManager(), null, "default");
364 | }
365 |
366 | // Configure TLSProtocolConfigurer
367 | @Bean
368 | public Protocol protocol() {
369 | return new Protocol("https", protocolSocketFactory(), 443);
370 | }
371 |
372 | // Configure TLSProtocolConfigurer
373 | @Bean
374 | public MethodInvokingFactoryBean socketFactoryInitialization() {
375 | // Since it is not possible to return `null`, if app needs to perform SSL verification against JDK cacerts,
376 | // just set the protocol ID to anything but "http" or "https" so that it will be ignored.
377 | String protocolId = !samlConfigBean().getUseJdkCacertsForSslVerification() ? "https" : "ignored";
378 |
379 | MethodInvokingFactoryBean methodInvokingFactoryBean = new MethodInvokingFactoryBean();
380 | methodInvokingFactoryBean.setTargetClass(Protocol.class);
381 | methodInvokingFactoryBean.setTargetMethod("registerProtocol");
382 | methodInvokingFactoryBean.setArguments(protocolId, protocol());
383 | return methodInvokingFactoryBean;
384 | }
385 |
386 | // IDP Metadata configuration - paths to metadata of IDPs in circle of trust is here
387 | @Bean
388 | public CachingMetadataManager metadata() throws MetadataProviderException {
389 | HTTPMetadataProvider httpMetadataProvider = new HTTPMetadataProvider(new Timer(true),
390 | httpClient(),
391 | getMetdataUrl());
392 | httpMetadataProvider.setParserPool(parserPool());
393 |
394 | ExtendedMetadataDelegate extendedMetadataDelegate = new ExtendedMetadataDelegate(httpMetadataProvider);
395 | // Disable metadata trust check to prevent "Signature trust establishment failed for metadata entry" exception
396 | extendedMetadataDelegate.setMetadataTrustCheck(false);
397 |
398 | return new CachingMetadataManager(ImmutableList.of(extendedMetadataDelegate));
399 | }
400 |
401 | // SAML Authentication Provider responsible for validating of received SAML messages
402 | @Bean
403 | public SAMLAuthenticationProvider samlAuthenticationProvider() {
404 | SAMLAuthenticationProvider samlAuthenticationProvider = new SAMLAuthenticationProvider();
405 | SAMLUserDetailsService samlUserDetailsService = samlConfigBean().getSamlUserDetailsService();
406 |
407 | if (samlUserDetailsService != null) {
408 | samlAuthenticationProvider.setUserDetails(samlUserDetailsService);
409 |
410 | // By default, `principal` is always going to be `NameID` even though the `Authentication` object
411 | // contain `userDetails` object. So, if `userDetails` is provided, then don't force `principal` as
412 | // string so that `principal` represents `userDetails` object.
413 | // See: http://stackoverflow.com/questions/33786861/how-to-override-the-nameid-value-in-samlauthenticationprovider
414 | samlAuthenticationProvider.setForcePrincipalAsString(false);
415 | }
416 |
417 | return samlAuthenticationProvider;
418 | }
419 |
420 | // In order to get SAML to work for Sp servers doing SSL termination, `SAMLContextProviderLB` has
421 | // to be used instead of `SAMLContextProviderImpl` to prevent the following exception:-
422 | //
423 | // "SAML message intended destination endpoint 'https://server/app/saml/SSO' did not match the
424 | // recipient endpoint 'http://server/app/saml/SSO'"
425 | //
426 | // This configuration will work for Sp servers (not) doing SSL termination.
427 | @Bean
428 | public SAMLContextProviderLB contextProvider() {
429 | SAMLContextProviderLB contextProviderLB = new SAMLContextProviderLB();
430 | contextProviderLB.setScheme("https");
431 | contextProviderLB.setServerName(samlConfigBean().getSpServerName());
432 | contextProviderLB.setServerPort(samlConfigBean().getSpHttpsPort());
433 | contextProviderLB.setIncludeServerPortInRequestURL(samlConfigBean().getSpHttpsPort() != 443);
434 | contextProviderLB.setContextPath(samlConfigBean().getSpContextPath());
435 | return contextProviderLB;
436 | }
437 |
438 | // Processing filter for WebSSO profile messages
439 | @Bean
440 | public SAMLProcessingFilter samlProcessingFilter() throws Exception {
441 | SAMLProcessingFilter samlWebSSOProcessingFilter = new SAMLProcessingFilter();
442 | samlWebSSOProcessingFilter.setAuthenticationManager(authenticationManager());
443 | samlWebSSOProcessingFilter.setAuthenticationSuccessHandler(successRedirectHandler());
444 | samlWebSSOProcessingFilter.setAuthenticationFailureHandler(failureRedirectHandler());
445 | return samlWebSSOProcessingFilter;
446 | }
447 |
448 | // Processing filter for WebSSO Holder-of-Key profile
449 | @Bean
450 | public SAMLWebSSOHoKProcessingFilter samlWebSSOHoKProcessingFilter() throws Exception {
451 | SAMLWebSSOHoKProcessingFilter samlWebSSOHoKProcessingFilter = new SAMLWebSSOHoKProcessingFilter();
452 | samlWebSSOHoKProcessingFilter.setAuthenticationSuccessHandler(successRedirectHandler());
453 | samlWebSSOHoKProcessingFilter.setAuthenticationManager(authenticationManager());
454 | samlWebSSOHoKProcessingFilter.setAuthenticationFailureHandler(failureRedirectHandler());
455 | return samlWebSSOHoKProcessingFilter;
456 | }
457 |
458 | // Logout handler terminating local session
459 | @Bean
460 | public SecurityContextLogoutHandler logoutHandler() {
461 | SecurityContextLogoutHandler logoutHandler = new SecurityContextLogoutHandler();
462 | logoutHandler.setInvalidateHttpSession(true);
463 | logoutHandler.setClearAuthentication(true);
464 | return logoutHandler;
465 | }
466 |
467 | // Override default logout processing filter with the one processing SAML messages
468 | @Bean
469 | public SAMLLogoutFilter samlLogoutFilter() {
470 | return new SAMLLogoutFilter(successLogoutHandler(),
471 | new LogoutHandler[]{logoutHandler()},
472 | new LogoutHandler[]{logoutHandler()});
473 | }
474 |
475 | // Filter processing incoming logout messages
476 | // First argument determines URL user will be redirected to after successful global logout
477 | @Bean
478 | public SAMLLogoutProcessingFilter samlLogoutProcessingFilter() {
479 | return new SAMLLogoutProcessingFilter(successLogoutHandler(), logoutHandler());
480 | }
481 |
482 | // Class loading incoming SAML messages from httpRequest stream
483 | @Bean
484 | public SAMLProcessorImpl processor() {
485 | return new SAMLProcessorImpl(ImmutableList.of(redirectDeflateBinding(),
486 | postBinding(),
487 | artifactBinding(),
488 | soapBinding(),
489 | paosBinding()));
490 | }
491 |
492 | // SAML 2.0 WebSSO Assertion Consumer
493 | @Bean
494 | public WebSSOProfileConsumer webSSOprofileConsumer() {
495 | return new WebSSOProfileConsumerImpl();
496 | }
497 |
498 | // SAML 2.0 Holder-of-Key WebSSO Assertion Consumer
499 | @Bean
500 | public WebSSOProfileConsumerHoKImpl hokWebSSOprofileConsumer() {
501 | return new WebSSOProfileConsumerHoKImpl();
502 | }
503 |
504 | // SAML 2.0 Web SSO profile
505 | @Bean
506 | public WebSSOProfile webSSOprofile() {
507 | return new WebSSOProfileImpl();
508 | }
509 |
510 | // SAML 2.0 Holder-of-Key Web SSO profile
511 | @Bean
512 | public WebSSOProfileConsumerHoKImpl hokWebSSOProfile() {
513 | return new WebSSOProfileConsumerHoKImpl();
514 | }
515 |
516 | // SAML 2.0 ECP profile
517 | @Bean
518 | public WebSSOProfileECPImpl ecpprofile() {
519 | return new WebSSOProfileECPImpl();
520 | }
521 |
522 | // SAML 2.0 Logout profile
523 | @Bean
524 | public SingleLogoutProfile logoutprofile() {
525 | return new SingleLogoutProfileImpl();
526 | }
527 |
528 | // Bindings, encoders and decoders used for creating and parsing messages
529 | @Bean
530 | public HTTPPostBinding postBinding() {
531 | return new HTTPPostBinding(parserPool(), velocityEngine());
532 | }
533 |
534 | @Bean
535 | public HTTPRedirectDeflateBinding redirectDeflateBinding() {
536 | return new HTTPRedirectDeflateBinding(parserPool());
537 | }
538 |
539 | @Bean
540 | public HTTPArtifactBinding artifactBinding() {
541 | ArtifactResolutionProfileImpl artifactResolutionProfile = new ArtifactResolutionProfileImpl(httpClient());
542 | artifactResolutionProfile.setProcessor(new SAMLProcessorImpl(soapBinding()));
543 | return new HTTPArtifactBinding(parserPool(), velocityEngine(), artifactResolutionProfile);
544 | }
545 |
546 | @Bean
547 | public HTTPSOAP11Binding soapBinding() {
548 | return new HTTPSOAP11Binding(parserPool());
549 | }
550 |
551 | @Bean
552 | public HTTPPAOS11Binding paosBinding() {
553 | return new HTTPPAOS11Binding(parserPool());
554 | }
555 |
556 | // Initialization of the velocity engine
557 | @Bean
558 | public VelocityEngine velocityEngine() {
559 | return VelocityFactory.getEngine();
560 | }
561 |
562 | // XML parser pool needed for OpenSAML parsing
563 | @Bean(initMethod = "initialize")
564 | public StaticBasicParserPool parserPool() {
565 | return new StaticBasicParserPool();
566 | }
567 |
568 | @Bean
569 | public ParserPoolHolder parserPoolHolder() {
570 | return new ParserPoolHolder();
571 | }
572 | }
--------------------------------------------------------------------------------
/src/main/java/com/github/choonchernlim/security/adfs/saml2/SpringSecurityAdfsSaml2Exception.java:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2;
2 |
3 | /**
4 | * Module specific exception.
5 | */
6 | public final class SpringSecurityAdfsSaml2Exception extends RuntimeException {
7 |
8 | SpringSecurityAdfsSaml2Exception(final String message) {
9 | super(message);
10 | }
11 |
12 | SpringSecurityAdfsSaml2Exception(final String message, final Throwable cause) {
13 | super(message, cause);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/site/site.xml:
--------------------------------------------------------------------------------
1 |
5 |
6 | ${project.name}
7 | ${project.url}
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | org.apache.maven.skins
18 | maven-fluido-skin
19 | 1.5
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/CsrfHeaderFilterSpec.groovy:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2
2 |
3 | import org.springframework.mock.web.MockFilterChain
4 | import org.springframework.mock.web.MockHttpServletRequest
5 | import org.springframework.mock.web.MockHttpServletResponse
6 | import org.springframework.mock.web.MockServletContext
7 | import org.springframework.security.web.csrf.CsrfToken
8 | import org.springframework.security.web.csrf.DefaultCsrfToken
9 | import spock.lang.Specification
10 |
11 | import javax.servlet.http.Cookie
12 |
13 | class CsrfHeaderFilterSpec extends Specification {
14 | def servletContext = new MockServletContext(
15 | contextPath: '/'
16 | )
17 |
18 | def request = new MockHttpServletRequest(servletContext)
19 | def response = new MockHttpServletResponse()
20 | def filterChain = new MockFilterChain()
21 | def filter = new CsrfHeaderFilter()
22 |
23 |
24 | def setup() {
25 | request.setContextPath('/app')
26 | }
27 |
28 | def "doFilterInternal - given no csrf token, should not create csrf cookie"() {
29 | when:
30 | filter.doFilterInternal(request, response, filterChain)
31 |
32 | then:
33 | response.getCookie(CsrfHeaderFilter.COOKIE_NAME) == null
34 | }
35 |
36 | def "doFilterInternal - given csrf token but no existing cookie, should create csrf cookie"() {
37 | given:
38 | request.setAttribute(CsrfToken.class.getName(),
39 | new DefaultCsrfToken(CsrfHeaderFilter.HEADER_NAME, 'paramToken', 'token'))
40 |
41 | when:
42 | filter.doFilterInternal(request, response, filterChain)
43 |
44 | then:
45 | def cookie = response.getCookie(CsrfHeaderFilter.COOKIE_NAME)
46 | cookie.value == 'token'
47 | cookie.path == '/'
48 | cookie.secure
49 | cookie.maxAge == 60 * 60 * 8
50 | !cookie.isHttpOnly()
51 | }
52 |
53 | def "doFilterInternal - given csrf token and existing cookie but with old token value, should create csrf cookie"() {
54 | given:
55 | request.setAttribute(CsrfToken.class.getName(),
56 | new DefaultCsrfToken(CsrfHeaderFilter.HEADER_NAME, 'paramToken', 'token'))
57 |
58 | request.setCookies(new Cookie(CsrfHeaderFilter.COOKIE_NAME, 'old-token'))
59 |
60 | when:
61 | filter.doFilterInternal(request, response, filterChain)
62 |
63 | then:
64 | def cookie = response.getCookie(CsrfHeaderFilter.COOKIE_NAME)
65 | cookie.value == 'token'
66 | cookie.path == '/'
67 | cookie.secure
68 | cookie.maxAge == 60 * 60 * 8
69 | !cookie.isHttpOnly()
70 | }
71 |
72 | def "doFilterInternal - given csrf token and existing cookie with matching token value, should not create csrf cookie"() {
73 | given:
74 | request.setAttribute(CsrfToken.class.getName(),
75 | new DefaultCsrfToken(CsrfHeaderFilter.HEADER_NAME, 'paramToken', 'token'))
76 |
77 | request.setCookies(new Cookie(CsrfHeaderFilter.COOKIE_NAME, 'token'))
78 |
79 | when:
80 | filter.doFilterInternal(request, response, filterChain)
81 |
82 | then:
83 | response.getCookie(CsrfHeaderFilter.COOKIE_NAME) == null
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/DefaultSAMLBootstrapSpec.groovy:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2
2 |
3 | import org.opensaml.Configuration
4 | import org.opensaml.xml.security.BasicSecurityConfiguration
5 | import org.opensaml.xml.signature.SignatureConstants
6 | import org.springframework.beans.factory.config.ConfigurableListableBeanFactory
7 | import org.springframework.security.saml.SAMLBootstrap
8 | import spock.lang.Specification
9 |
10 | class DefaultSAMLBootstrapSpec extends Specification {
11 |
12 | def beanFactory = Mock ConfigurableListableBeanFactory
13 |
14 | def getConfig(def samlBootstrap) {
15 | samlBootstrap.postProcessBeanFactory(beanFactory)
16 | return (BasicSecurityConfiguration) Configuration.getGlobalSecurityConfiguration()
17 | }
18 |
19 | def "SAMLBootstrap - Spring provided implementation, for comparison reason"() {
20 | when:
21 | def config = getConfig(new SAMLBootstrap())
22 |
23 | then:
24 | config.getSignatureReferenceDigestMethod() == SignatureConstants.ALGO_ID_DIGEST_SHA1
25 | config.getSignatureAlgorithmURI('RSA') == SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA1
26 | }
27 |
28 | def "DefaultSAMLBootstrap - no param"() {
29 | when:
30 | def config = getConfig(new DefaultSAMLBootstrap())
31 |
32 | then:
33 | config.getSignatureReferenceDigestMethod() == SignatureConstants.ALGO_ID_DIGEST_SHA256
34 | config.getSignatureAlgorithmURI('RSA') == SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256
35 | }
36 |
37 | def "DefaultSAMLBootstrap - with param"() {
38 | when:
39 | def config = getConfig(new DefaultSAMLBootstrap('RSA',
40 | SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA512,
41 | SignatureConstants.ALGO_ID_DIGEST_SHA512))
42 |
43 | then:
44 | config.getSignatureReferenceDigestMethod() == SignatureConstants.ALGO_ID_DIGEST_SHA512
45 | config.getSignatureAlgorithmURI('RSA') == SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA512
46 | }
47 |
48 |
49 | }
50 |
--------------------------------------------------------------------------------
/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/JndiBackedKeystoreServiceSpec.groovy:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2
2 |
3 | import com.github.choonchernlim.betterPreconditions.exception.NumberNotEqualPreconditionException
4 | import org.springframework.mock.jndi.ExpectedLookupTemplate
5 | import spock.lang.Specification
6 | import spock.lang.Unroll
7 |
8 | class JndiBackedKeystoreServiceSpec extends Specification {
9 |
10 | def jndiName = 'jks/idm'
11 |
12 | def "given invalid jndi name, should throw exception"() {
13 | given:
14 | def service = new JndiBackedKeystoreService('jks/invalid')
15 | service.setJndiTemplate(new ExpectedLookupTemplate(jndiName, 'bla'))
16 |
17 | when:
18 | service.get()
19 |
20 | then:
21 | thrown SpringSecurityAdfsSaml2Exception
22 | }
23 |
24 | @Unroll
25 | def "given invalid jndi value ( #jndiValue ), should throw exception"() {
26 | given:
27 | def service = new JndiBackedKeystoreService(jndiName)
28 | service.setJndiTemplate(new ExpectedLookupTemplate(jndiName, jndiValue))
29 |
30 | when:
31 | service.get()
32 |
33 | then:
34 | thrown expectedException
35 |
36 | where:
37 | jndiValue | expectedException
38 | 'classpath:test.jks,test,test-storepass,test-keypass,extra' | NumberNotEqualPreconditionException
39 | 'classpath:test.jks,test,test-storepass' | NumberNotEqualPreconditionException
40 | 'classpath:invalid.jks,test,test-storepass,test-keypass' | SpringSecurityAdfsSaml2Exception
41 | 'classpath:test.jks,invalid,test-storepass,test-keypass' | SpringSecurityAdfsSaml2Exception
42 | 'classpath:test.jks,test,invalid-storepass,test-keypass' | SpringSecurityAdfsSaml2Exception
43 | 'classpath:test.jks,test,test-storepass,invalid-keypass' | SpringSecurityAdfsSaml2Exception
44 | }
45 |
46 | def "given valid jndi value, should return keystore bean"() {
47 | given:
48 | def service = new JndiBackedKeystoreService(jndiName)
49 | def jndiValue = 'classpath:test.jks,test,test-storepass,test-keypass'
50 | service.setJndiTemplate(new ExpectedLookupTemplate(jndiName, jndiValue))
51 |
52 | when:
53 | def bean = service.get()
54 |
55 | then:
56 | bean.jksPath == 'classpath:test.jks'
57 | bean.keystoreAlias == 'test'
58 | bean.keystorePassword == 'test-storepass'
59 | bean.keystorePrivateKeyPassword == 'test-keypass'
60 | bean.keystoreResource.exists()
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/KeystoreBeanSpec.groovy:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2
2 |
3 | import org.springframework.core.io.DefaultResourceLoader
4 | import spock.lang.Specification
5 |
6 | class KeystoreBeanSpec extends Specification {
7 |
8 | def "give no params, should return default values"() {
9 | when:
10 | def bean = new KeystoreBeanBuilder().build()
11 |
12 | then:
13 | bean.jksPath == null
14 | bean.keystoreAlias == null
15 | bean.keystorePassword == null
16 | bean.keystorePrivateKeyPassword == null
17 | bean.keystoreResource == null
18 | }
19 |
20 | def "give with params, should return actual values"() {
21 | when:
22 | def bean = new KeystoreBeanBuilder().
23 | withJksPath('jksPath').
24 | withKeystoreAlias('keystoreAlias').
25 | withKeystorePassword('keystorePassword').
26 | withKeystorePrivateKeyPassword('keystorePrivateKeyPassword').
27 | withKeystoreResource(new DefaultResourceLoader().getResource('bla')).
28 | build()
29 |
30 | then:
31 | bean.jksPath == 'jksPath'
32 | bean.keystoreAlias == 'keystoreAlias'
33 | bean.keystorePassword == 'keystorePassword'
34 | bean.keystorePrivateKeyPassword == 'keystorePrivateKeyPassword'
35 | bean.keystoreResource != null
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/MockFilterSecurityInterceptorSpec.groovy:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2
2 |
3 | import com.github.choonchernlim.betterPreconditions.exception.ObjectNullPreconditionException
4 | import org.springframework.mock.web.MockFilterChain
5 | import org.springframework.mock.web.MockHttpServletRequest
6 | import org.springframework.mock.web.MockHttpServletResponse
7 | import org.springframework.mock.web.MockHttpSession
8 | import org.springframework.security.core.authority.SimpleGrantedAuthority
9 | import org.springframework.security.core.context.SecurityContext
10 | import org.springframework.security.core.context.SecurityContextHolder
11 | import org.springframework.security.core.userdetails.User
12 | import org.springframework.security.web.context.HttpSessionSecurityContextRepository
13 | import spock.lang.Specification
14 |
15 | class MockFilterSecurityInterceptorSpec extends Specification {
16 |
17 | def request = new MockHttpServletRequest()
18 | def session = new MockHttpSession()
19 | def response = new MockHttpServletResponse()
20 | def chain = new MockFilterChain()
21 |
22 | def "setup"() {
23 | request.setSession(session)
24 | }
25 |
26 | def "given null user, should throw exception"() {
27 | when:
28 | new MockFilterSecurityInterceptor(null)
29 |
30 | then:
31 | thrown ObjectNullPreconditionException
32 | }
33 |
34 | def "given mock user, should be in security context and in session"() {
35 | given:
36 | def userAuthorities = [new SimpleGrantedAuthority('ROLE_USER')]
37 | def user = new User('RAMBO', '', userAuthorities)
38 | def filter = new MockFilterSecurityInterceptor(user)
39 |
40 | when:
41 | filter.doFilter(request, response, chain)
42 |
43 | then:
44 | def authentication = SecurityContextHolder.context.authentication
45 |
46 | with(authentication) {
47 | principal == user
48 | details == user
49 | credentials == null
50 | authorities == userAuthorities
51 | }
52 |
53 | authentication == (
54 | (SecurityContext) request.session.getAttribute(
55 | HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)
56 | ).authentication
57 | }
58 |
59 | }
60 |
--------------------------------------------------------------------------------
/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/SAMLConfigBeanSpec.groovy:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2
2 |
3 | import com.github.choonchernlim.betterPreconditions.exception.ObjectNullPreconditionException
4 | import com.github.choonchernlim.betterPreconditions.exception.StringBlankPreconditionException
5 | import org.opensaml.saml2.core.AuthnContext
6 | import org.springframework.core.io.DefaultResourceLoader
7 | import org.springframework.security.core.authority.SimpleGrantedAuthority
8 | import org.springframework.security.core.userdetails.User
9 | import org.springframework.security.core.userdetails.UsernameNotFoundException
10 | import org.springframework.security.saml.SAMLCredential
11 | import org.springframework.security.saml.userdetails.SAMLUserDetailsService
12 | import spock.lang.Specification
13 | import spock.lang.Unroll
14 |
15 | class SAMLConfigBeanSpec extends Specification {
16 |
17 | def keystoreResource = new DefaultResourceLoader().getResource("classpath:keystore.jks")
18 |
19 | def samlUserDetailsService = new SAMLUserDetailsService() {
20 | @Override
21 | Object loadUserBySAML(final SAMLCredential credential) throws UsernameNotFoundException {
22 | return new User('limc', '', [new SimpleGrantedAuthority('ROLE_USER')])
23 | }
24 | }
25 | def allFieldsBeanBuilder = new SAMLConfigBeanBuilder().
26 | withIdpServerName('idpServerName').
27 | withSpServerName('spServerName').
28 | withSpHttpsPort(8443).
29 | withSpContextPath('spContextPath').
30 | withKeystoreResource(keystoreResource).
31 | withKeystoreAlias('keystoreAlias').
32 | withKeystorePassword('keystorePassword').
33 | withKeystorePrivateKeyPassword('keystorePrivateKeyPassword').
34 | withSuccessLoginDefaultUrl('successLoginDefaultUrl').
35 | withSuccessLogoutUrl('successLogoutUrl').
36 | withFailedLoginDefaultUrl('failedLoginDefaultUrl').
37 | withStoreCsrfTokenInCookie(true).
38 | withSamlUserDetailsService(samlUserDetailsService).
39 | withAuthnContexts([CustomAuthnContext.WINDOWS_INTEGRATED_AUTHN_CTX] as Set).
40 | withUseJdkCacertsForSslVerification(true)
41 |
42 | def "required and optional fields"() {
43 | when:
44 | def bean = allFieldsBeanBuilder.build()
45 |
46 | then:
47 | bean.idpServerName == 'idpServerName'
48 | bean.spServerName == 'spServerName'
49 | bean.spHttpsPort == 8443
50 | bean.spContextPath == 'spContextPath'
51 | bean.keystoreResource == keystoreResource
52 | bean.keystoreAlias == 'keystoreAlias'
53 | bean.keystorePassword == 'keystorePassword'
54 | bean.keystorePrivateKeyPassword == 'keystorePrivateKeyPassword'
55 | bean.successLoginDefaultUrl == 'successLoginDefaultUrl'
56 | bean.successLogoutUrl == 'successLogoutUrl'
57 | bean.failedLoginDefaultUrl == 'failedLoginDefaultUrl'
58 | bean.storeCsrfTokenInCookie
59 | bean.samlUserDetailsService == samlUserDetailsService
60 | bean.authnContexts == [CustomAuthnContext.WINDOWS_INTEGRATED_AUTHN_CTX] as Set
61 | bean.useJdkCacertsForSslVerification
62 | }
63 |
64 | def "only required fields"() {
65 | when:
66 | def bean = allFieldsBeanBuilder.
67 | withSpHttpsPort(null).
68 | withSpContextPath(null).
69 | withFailedLoginDefaultUrl(null).
70 | withSamlUserDetailsService(null).
71 | withAuthnContexts(null).
72 | withStoreCsrfTokenInCookie(null).
73 | withUseJdkCacertsForSslVerification(null).
74 | build()
75 |
76 | then:
77 | bean.idpServerName == 'idpServerName'
78 | bean.spServerName == 'spServerName'
79 | bean.spHttpsPort == 443
80 | bean.spContextPath == ''
81 | bean.keystoreResource == keystoreResource
82 | bean.keystoreAlias == 'keystoreAlias'
83 | bean.keystorePassword == 'keystorePassword'
84 | bean.keystorePrivateKeyPassword == 'keystorePrivateKeyPassword'
85 | bean.successLoginDefaultUrl == 'successLoginDefaultUrl'
86 | bean.successLogoutUrl == 'successLogoutUrl'
87 | bean.failedLoginDefaultUrl == ''
88 | !bean.storeCsrfTokenInCookie
89 | bean.samlUserDetailsService == null
90 | bean.authnContexts == [AuthnContext.PASSWORD_AUTHN_CTX] as Set
91 | !bean.useJdkCacertsForSslVerification
92 | }
93 |
94 | def "authnContexts - empty set is fine"() {
95 | when:
96 | def bean = allFieldsBeanBuilder.
97 | withAuthnContexts([] as Set).
98 | build()
99 |
100 | then:
101 | bean.authnContexts == [] as Set
102 | }
103 |
104 | @Unroll
105 | def "missing required field - #field"() {
106 | when:
107 | allFieldsBeanBuilder."with$field"(null).build()
108 |
109 | then:
110 | thrown expectedException
111 |
112 | where:
113 | field | expectedException
114 | 'IdpServerName' | StringBlankPreconditionException
115 | 'SpServerName' | StringBlankPreconditionException
116 | 'KeystoreResource' | ObjectNullPreconditionException
117 | 'KeystoreAlias' | StringBlankPreconditionException
118 | 'KeystorePassword' | StringBlankPreconditionException
119 | 'KeystorePrivateKeyPassword' | StringBlankPreconditionException
120 | 'SuccessLoginDefaultUrl' | StringBlankPreconditionException
121 | 'SuccessLogoutUrl' | StringBlankPreconditionException
122 | }
123 |
124 | }
125 |
--------------------------------------------------------------------------------
/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/SAMLWebSecurityConfigurerAdapterSpec.groovy:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2
2 |
3 | import com.github.choonchernlim.betterPreconditions.exception.ObjectNullPreconditionException
4 | import org.springframework.core.io.DefaultResourceLoader
5 | import org.springframework.security.config.annotation.ObjectPostProcessor
6 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
7 | import org.springframework.security.config.annotation.web.builders.HttpSecurity
8 | import org.springframework.security.core.authority.SimpleGrantedAuthority
9 | import org.springframework.security.core.userdetails.User
10 | import org.springframework.security.core.userdetails.UsernameNotFoundException
11 | import org.springframework.security.saml.SAMLCredential
12 | import org.springframework.security.saml.userdetails.SAMLUserDetailsService
13 | import spock.lang.Specification
14 | import spock.lang.Unroll
15 |
16 | class SAMLWebSecurityConfigurerAdapterSpec extends Specification {
17 | private static final String ALIAS = 'test'
18 | private static final String STOREPASS = 'test-storepass'
19 | private static final String KEYPASS = 'test-keypass'
20 |
21 | def keystoreResource = new DefaultResourceLoader().getResource("classpath:test.jks")
22 |
23 | static def samlUserDetailsService = new SAMLUserDetailsService() {
24 | @Override
25 | Object loadUserBySAML(final SAMLCredential credential) throws UsernameNotFoundException {
26 | return new User('limc', '', [new SimpleGrantedAuthority('ROLE_USER')])
27 | }
28 | }
29 |
30 | def allFieldsBeanBuilder = new SAMLConfigBeanBuilder().
31 | withIdpServerName('idpServerName').
32 | withSpServerName('spServerName').
33 | withSpHttpsPort(8443).
34 | withSpContextPath('spContextPath').
35 | withKeystoreResource(keystoreResource).
36 | withKeystoreAlias(ALIAS).
37 | withKeystorePassword(STOREPASS).
38 | withKeystorePrivateKeyPassword(KEYPASS).
39 | withSuccessLoginDefaultUrl('successLoginDefaultUrl').
40 | withSuccessLogoutUrl('successLogoutUrl').
41 | withFailedLoginDefaultUrl('failedLoginDefaultUrl').
42 | withSamlUserDetailsService(samlUserDetailsService).
43 | withAuthnContexts([CustomAuthnContext.WINDOWS_INTEGRATED_AUTHN_CTX] as Set)
44 |
45 | @Unroll
46 | @SuppressWarnings("all")
47 | def "metadataGeneratorFilter - entityBaseURL - #expectedValue"() {
48 | given:
49 | def samlConfigBean = allFieldsBeanBuilder.
50 | withSpServerName(server).
51 | withSpHttpsPort(port).
52 | withSpContextPath(contextPath).
53 | build()
54 |
55 | when:
56 | def adapter = new SAMLWebSecurityConfigurerAdapter() {
57 | @Override
58 | protected SAMLConfigBean samlConfigBean() {
59 | return samlConfigBean
60 | }
61 | }
62 |
63 | then:
64 | expectedValue == adapter.metadataGeneratorFilter().generator.entityBaseURL
65 |
66 | where:
67 | server | port | contextPath | expectedValue
68 | 'server' | null | null | 'https://server'
69 | 'server' | 443 | null | 'https://server'
70 | 'server' | 443 | '/app' | 'https://server/app'
71 | 'server' | 8443 | null | 'https://server:8443'
72 | 'server' | 8443 | '/app' | 'https://server:8443/app'
73 | }
74 |
75 | @Unroll
76 | @SuppressWarnings("all")
77 | def "contextProvider - #expectedValue"() {
78 | given:
79 | def samlConfigBean = allFieldsBeanBuilder.
80 | withSpServerName(aServer).
81 | withSpHttpsPort(aPort).
82 | withSpContextPath(aContextPath).
83 | build()
84 |
85 | when:
86 | def adapter = new SAMLWebSecurityConfigurerAdapter() {
87 | @Override
88 | protected SAMLConfigBean samlConfigBean() {
89 | return samlConfigBean
90 | }
91 | }
92 |
93 | then:
94 | with(adapter.contextProvider()) {
95 | scheme == 'https'
96 | serverName == aServer
97 | serverPort == ePort
98 | contextPath == eContextPath
99 | includeServerPortInRequestURL == ePortIncluded
100 | }
101 |
102 | where:
103 | aServer | aPort | aContextPath | ePort | ePortIncluded | eContextPath | expectedValue
104 | 'server' | null | null | 443 | false | '' | 'https://server'
105 | 'server' | 443 | null | 443 | false | '' | 'https://server'
106 | 'server' | 443 | '/app' | 443 | false | '/app' | 'https://server/app'
107 | 'server' | 8443 | null | 8443 | true | '' | 'https://server:8443'
108 | 'server' | 8443 | '/app' | 8443 | true | '/app' | 'https://server:8443/app'
109 | }
110 |
111 | @Unroll
112 | def "authenticationProvider - given samlUserDetailsService as #actualSamlUserDetailsService, then forcePrincipalAsString should be #expectedForcePrincipalAsString"() {
113 | given:
114 | SAMLUserDetailsService userDetailsService = actualSamlUserDetailsService
115 |
116 | when:
117 | def adapter = new SAMLWebSecurityConfigurerAdapter() {
118 | @Override
119 | protected SAMLConfigBean samlConfigBean() {
120 | return allFieldsBeanBuilder.
121 | withSamlUserDetailsService(userDetailsService).
122 | build()
123 | }
124 | }
125 |
126 | then:
127 | expectedForcePrincipalAsString == adapter.samlAuthenticationProvider().forcePrincipalAsString
128 |
129 | where:
130 | actualSamlUserDetailsService | expectedForcePrincipalAsString
131 | null | true
132 | samlUserDetailsService | false
133 | }
134 |
135 | def "mockSecurity - given null user, should throw exception"() {
136 | given:
137 | def http = new HttpSecurity(Mock(ObjectPostProcessor), Mock(AuthenticationManagerBuilder), [:] as Map)
138 |
139 | def adapter = new SAMLWebSecurityConfigurerAdapter() {
140 | @Override
141 | protected SAMLConfigBean samlConfigBean() {
142 | return allFieldsBeanBuilder.build()
143 | }
144 | }
145 |
146 | when:
147 | adapter.mockSecurity(http, null)
148 |
149 | then:
150 | thrown ObjectNullPreconditionException
151 | }
152 |
153 | def "mockSecurity - given null samlUserDetailsService, should throw exception"() {
154 | given:
155 | def http = new HttpSecurity(Mock(ObjectPostProcessor), Mock(AuthenticationManagerBuilder), [:] as Map)
156 |
157 | def adapter = new SAMLWebSecurityConfigurerAdapter() {
158 | @Override
159 | protected SAMLConfigBean samlConfigBean() {
160 | return allFieldsBeanBuilder.withSamlUserDetailsService(null).build()
161 | }
162 | }
163 |
164 | when:
165 | adapter.mockSecurity(http, new User('USER', '', []))
166 |
167 | then:
168 | thrown SpringSecurityAdfsSaml2Exception
169 | }
170 |
171 | def "mockSecurity - given user and samlUserDetailsService, should not throw exception"() {
172 | given:
173 | def http = new HttpSecurity(Mock(ObjectPostProcessor), Mock(AuthenticationManagerBuilder), [:] as Map)
174 |
175 | def adapter = new SAMLWebSecurityConfigurerAdapter() {
176 | @Override
177 | protected SAMLConfigBean samlConfigBean() {
178 | return allFieldsBeanBuilder.build()
179 | }
180 | }
181 |
182 | when:
183 | adapter.mockSecurity(http, new User('USER', '', []))
184 |
185 | then:
186 | notThrown Exception
187 | }
188 | }
189 |
--------------------------------------------------------------------------------
/src/test/groovy/com/github/choonchernlim/security/adfs/saml2/SpringSecurityAdfsSaml2ExceptionSpec.groovy:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2
2 |
3 | import spock.lang.Specification
4 |
5 | class SpringSecurityAdfsSaml2ExceptionSpec extends Specification {
6 |
7 | def "exception"() {
8 | when:
9 | def exception = new SpringSecurityAdfsSaml2Exception('test')
10 |
11 | then:
12 | exception instanceof RuntimeException
13 | exception.message == 'test'
14 | }
15 |
16 | def "exception with throwable"() {
17 | when:
18 | def exception = new SpringSecurityAdfsSaml2Exception('test', new IllegalArgumentException('illegal'))
19 |
20 | then:
21 | exception instanceof RuntimeException
22 | exception.message == 'test'
23 | exception.cause instanceof IllegalArgumentException
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/test/java/com/github/choonchernlim/security/adfs/saml2/DummyTest.java:
--------------------------------------------------------------------------------
1 | package com.github.choonchernlim.security.adfs.saml2;
2 |
3 | import static org.junit.Assert.assertTrue;
4 | import org.junit.Test;
5 |
6 | public class DummyTest {
7 |
8 | @Test
9 | public void testDummy() {
10 | assertTrue("Needed to ensure surefire plugin runs Spock specs", true);
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/test/resources/README.md:
--------------------------------------------------------------------------------
1 | keytool -genkeypair -keystore test.jks -storepass test-storepass -alias test -keypass test-keypass -dname "CN=test" -keyalg RSA -keysize 2048 -sigalg SHA256withRSA -validity 99999
--------------------------------------------------------------------------------
/src/test/resources/test.jks:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/choonchernlim/spring-security-adfs-saml2/f774c55c7c90753e73d623357c02a78f7cf1f1d3/src/test/resources/test.jks
--------------------------------------------------------------------------------