Skip to content

Commit cbfec34

Browse files
committed
Merge pull request 'Improve SAML Logout' (#28) from feature/saml into develop
2 parents 556a3c8 + ba7d12c commit cbfec34

File tree

7 files changed

+193
-23
lines changed

7 files changed

+193
-23
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
/target/
22
application.yml
33
logs
4+
5+
.idea
6+
*.iml

pom.xml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,7 @@
252252
<!-- UI frameworks -->
253253
<dependency>
254254
<groupId>org.thymeleaf.extras</groupId>
255-
<artifactId>thymeleaf-extras-springsecurity4</artifactId>
256-
<version>3.0.2.RELEASE</version>
255+
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
257256
</dependency>
258257
<dependency>
259258
<groupId>org.webjars</groupId>

src/main/java/eu/openanalytics/containerproxy/auth/IAuthenticationBackend.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ public interface IAuthenticationBackend {
5959
public default String getLogoutSuccessURL() {
6060
return "/login";
6161
}
62-
62+
63+
public default String getLogoutURL() {
64+
return "/logout";
65+
}
66+
6367
public default void customizeContainer(ContainerSpec spec) {
6468
// Default: do nothing.
6569
}

src/main/java/eu/openanalytics/containerproxy/auth/impl/SAMLAuthenticationBackend.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
public class SAMLAuthenticationBackend implements IAuthenticationBackend {
4343

4444
public static final String NAME = "saml";
45+
46+
private static final String PROP_LOGOUT_URL = "proxy.saml.logout-url";
4547

4648
@Autowired(required = false)
4749
private SAMLEntryPoint samlEntryPoint;
@@ -86,9 +88,17 @@ public void configureAuthenticationManagerBuilder(AuthenticationManagerBuilder a
8688
auth.authenticationProvider(samlAuthenticationProvider);
8789
}
8890

91+
@Override
92+
public String getLogoutURL() {
93+
if (environment.getProperty(PROP_LOGOUT_URL) != null) {
94+
return "/logout";
95+
}
96+
return "/saml/logout";
97+
}
98+
8999
@Override
90100
public String getLogoutSuccessURL() {
91-
String logoutURL = environment.getProperty("proxy.saml.logout-url");
101+
String logoutURL = environment.getProperty(PROP_LOGOUT_URL);
92102
System.out.println("LogoutURL: " + logoutURL);
93103
if (logoutURL == null || logoutURL.trim().isEmpty()) logoutURL = "/";
94104
return logoutURL;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* ContainerProxy
3+
*
4+
* Copyright (C) 2016-2020 Open Analytics
5+
*
6+
* ===========================================================================
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the Apache License as published by
10+
* The Apache Software Foundation, either version 2 of the License, or
11+
* (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* Apache License for more details.
17+
*
18+
* You should have received a copy of the Apache License
19+
* along with this program. If not, see <http://www.apache.org/licenses/>
20+
*/
21+
package eu.openanalytics.containerproxy.auth.impl.saml;
22+
23+
import org.apache.logging.log4j.Logger;
24+
import org.opensaml.saml2.core.Attribute;
25+
import org.opensaml.xml.XMLObject;
26+
import org.opensaml.xml.schema.XSAny;
27+
import org.opensaml.xml.schema.XSString;
28+
import org.springframework.security.saml.SAMLCredential;
29+
30+
import java.util.List;
31+
32+
public class AttributeUtils {
33+
34+
public static String getAttributeValue(Attribute attribute) {
35+
// copied from Attribute class ...
36+
List<XMLObject> attributeValues = attribute.getAttributeValues();
37+
if (attributeValues == null || attributeValues.size() == 0) {
38+
return null;
39+
}
40+
XMLObject xmlValue = attributeValues.iterator().next();
41+
return getString(xmlValue);
42+
}
43+
44+
public static String[] getAttributeAsStringArray(Attribute attribute) {
45+
if (attribute == null) {
46+
return null;
47+
}
48+
List<XMLObject> attributeValues = attribute.getAttributeValues();
49+
if (attributeValues == null || attributeValues.size() == 0) {
50+
return new String[0];
51+
}
52+
String[] result = new String[attributeValues.size()];
53+
int i = 0;
54+
for (XMLObject attributeValue : attributeValues) {
55+
result[i++] = getString(attributeValue);
56+
}
57+
return result;
58+
}
59+
60+
private static String getString(XMLObject xmlValue) {
61+
if (xmlValue instanceof XSString) {
62+
return ((XSString) xmlValue).getValue();
63+
} else if (xmlValue instanceof XSAny) {
64+
return ((XSAny) xmlValue).getTextContent();
65+
} else {
66+
return null;
67+
}
68+
}
69+
70+
public static void logAttributes(Logger logger, SAMLCredential credential) {
71+
String userID = credential.getNameID().getValue();
72+
List<Attribute> attributes = credential.getAttributes();
73+
attributes.forEach((attribute) -> {
74+
logger.info(String.format("[SAML] User: \"%s\" => attribute => name=\"%s\"(\"%s\") => value \"%s\" - \"%s\"",
75+
userID,
76+
attribute.getName(),
77+
attribute.getFriendlyName(),
78+
getAttributeValue(attribute),
79+
String.join(", ", getAttributeAsStringArray(attribute))));
80+
});
81+
82+
}
83+
}

src/main/java/eu/openanalytics/containerproxy/auth/impl/saml/SAMLConfiguration.java

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
*/
2121
package eu.openanalytics.containerproxy.auth.impl.saml;
2222

23+
import eu.openanalytics.containerproxy.auth.UserLogoutHandler;
2324
import java.util.ArrayList;
2425
import java.util.Arrays;
2526
import java.util.Collection;
@@ -31,13 +32,19 @@
3132
import javax.inject.Inject;
3233

3334
import org.apache.commons.httpclient.HttpClient;
35+
import org.apache.logging.log4j.LogManager;
36+
import org.apache.logging.log4j.Logger;
3437
import org.apache.velocity.app.VelocityEngine;
38+
import org.opensaml.saml2.core.Attribute;
3539
import org.opensaml.saml2.metadata.provider.HTTPMetadataProvider;
3640
import org.opensaml.saml2.metadata.provider.MetadataProvider;
3741
import org.opensaml.saml2.metadata.provider.MetadataProviderException;
3842
import org.opensaml.util.resource.ResourceException;
43+
import org.opensaml.xml.XMLObject;
3944
import org.opensaml.xml.parse.StaticBasicParserPool;
4045
import org.opensaml.xml.parse.XMLParserException;
46+
import org.opensaml.xml.schema.XSAny;
47+
import org.opensaml.xml.schema.XSString;
4148
import org.springframework.beans.factory.annotation.Qualifier;
4249
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
4350
import org.springframework.context.annotation.Bean;
@@ -51,11 +58,7 @@
5158
import org.springframework.security.core.authority.SimpleGrantedAuthority;
5259
import org.springframework.security.core.userdetails.User;
5360
import org.springframework.security.core.userdetails.UsernameNotFoundException;
54-
import org.springframework.security.saml.SAMLAuthenticationProvider;
55-
import org.springframework.security.saml.SAMLBootstrap;
56-
import org.springframework.security.saml.SAMLCredential;
57-
import org.springframework.security.saml.SAMLEntryPoint;
58-
import org.springframework.security.saml.SAMLProcessingFilter;
61+
import org.springframework.security.saml.*;
5962
import org.springframework.security.saml.context.SAMLContextProvider;
6063
import org.springframework.security.saml.context.SAMLContextProviderImpl;
6164
import org.springframework.security.saml.key.EmptyKeyManager;
@@ -70,6 +73,8 @@
7073
import org.springframework.security.saml.processor.SAMLProcessorImpl;
7174
import org.springframework.security.saml.userdetails.SAMLUserDetailsService;
7275
import org.springframework.security.saml.util.VelocityFactory;
76+
import org.springframework.security.saml.websso.SingleLogoutProfile;
77+
import org.springframework.security.saml.websso.SingleLogoutProfileImpl;
7378
import org.springframework.security.saml.websso.WebSSOProfile;
7479
import org.springframework.security.saml.websso.WebSSOProfileConsumer;
7580
import org.springframework.security.saml.websso.WebSSOProfileConsumerHoKImpl;
@@ -81,33 +86,86 @@
8186
import org.springframework.security.web.SecurityFilterChain;
8287
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
8388
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
89+
import org.springframework.security.web.authentication.logout.LogoutHandler;
90+
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
91+
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
8492
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
8593

8694
@Configuration
8795
@ConditionalOnProperty(name="proxy.authentication", havingValue="saml")
8896
public class SAMLConfiguration {
8997

9098
private static final String DEFAULT_NAME_ATTRIBUTE = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress";
91-
99+
100+
private static final String PROP_LOG_ATTRIBUTES = "proxy.saml.log-attributes";
101+
private static final String PROP_FORCE_AUTHN = "proxy.saml.force-authn";
102+
private static final String PROP_KEYSTORE = "proxy.saml.keystore";
103+
private static final String PROP_ENCRYPTION_CERT_NAME = "proxy.saml.encryption-cert-name";
104+
private static final String PROP_ENCRYPTION_CERT_PASSWORD = "proxy.saml.encryption-cert-password";
105+
private static final String PROP_ENCRYPTION_KEYSTORE_PASSWORD = "proxy.saml.keystore-password";
106+
private static final String PROP_APP_ENTITY_ID = "proxy.saml.app-entity-id";
107+
private static final String PROP_BASE_URL = "proxy.saml.app-base-url";
108+
private static final String PROP_METADATA_URL = "proxy.saml.idp-metadata-url";
109+
92110
@Inject
93111
private Environment environment;
94112

95113
@Inject
96114
@Lazy
97115
private AuthenticationManager authenticationManager;
116+
117+
@Inject
118+
private UserLogoutHandler userLogoutHandler;
98119

99120
@Bean
100121
public SAMLEntryPoint samlEntryPoint() {
101122
SAMLEntryPoint samlEntryPoint = new SAMLEntryPoint();
102123
samlEntryPoint.setDefaultProfileOptions(defaultWebSSOProfileOptions());
103124
return samlEntryPoint;
104125
}
126+
127+
@Bean
128+
public SingleLogoutProfile logoutProfile() {
129+
return new SingleLogoutProfileImpl();
130+
}
131+
132+
@Bean
133+
public SAMLLogoutFilter samlLogoutFilter() {
134+
return new SAMLLogoutFilter(successLogoutHandler(),
135+
new LogoutHandler[]{userLogoutHandler, securityContextLogoutHandler()},
136+
new LogoutHandler[]{userLogoutHandler, securityContextLogoutHandler()});
137+
}
138+
139+
/**
140+
* Filter responsible for the `/saml/SingleLogout` endpoint. This makes it possible for users to logout in the IDP
141+
* or any other application and get automatically logged out in ShinyProxy as well.
142+
*/
143+
@Bean
144+
public SAMLLogoutProcessingFilter samlLogoutProcessingFilter() {
145+
return new SAMLLogoutProcessingFilter(successLogoutHandler(),
146+
securityContextLogoutHandler());
147+
}
148+
149+
@Bean
150+
public SecurityContextLogoutHandler securityContextLogoutHandler() {
151+
SecurityContextLogoutHandler logoutHandler = new SecurityContextLogoutHandler();
152+
logoutHandler.setInvalidateHttpSession(true);
153+
logoutHandler.setClearAuthentication(true);
154+
return logoutHandler;
155+
}
156+
157+
@Bean
158+
public SimpleUrlLogoutSuccessHandler successLogoutHandler() {
159+
SimpleUrlLogoutSuccessHandler successLogoutHandler = new SimpleUrlLogoutSuccessHandler();
160+
successLogoutHandler.setDefaultTargetUrl("/");
161+
return successLogoutHandler;
162+
}
105163

106164
@Bean
107165
public WebSSOProfileOptions defaultWebSSOProfileOptions() {
108166
WebSSOProfileOptions webSSOProfileOptions = new WebSSOProfileOptions();
109167
webSSOProfileOptions.setIncludeScoping(false);
110-
webSSOProfileOptions.setForceAuthN(Boolean.valueOf(environment.getProperty("proxy.saml.force-authn", "false")));
168+
webSSOProfileOptions.setForceAuthN(Boolean.valueOf(environment.getProperty(PROP_FORCE_AUTHN, "false")));
111169
return webSSOProfileOptions;
112170
}
113171

@@ -123,13 +181,13 @@ public WebSSOProfile webSSOprofile() {
123181

124182
@Bean
125183
public KeyManager keyManager() {
126-
String keystore = environment.getProperty("proxy.saml.keystore");
184+
String keystore = environment.getProperty(PROP_KEYSTORE);
127185
if (keystore == null || keystore.isEmpty()) {
128186
return new EmptyKeyManager();
129187
} else {
130-
String certName = environment.getProperty("proxy.saml.encryption-cert-name");
131-
String certPW = environment.getProperty("proxy.saml.encryption-cert-password");
132-
String keystorePW = environment.getProperty("proxy.saml.keystore-password", certPW);
188+
String certName = environment.getProperty(PROP_ENCRYPTION_CERT_NAME);
189+
String certPW = environment.getProperty(PROP_ENCRYPTION_CERT_PASSWORD);
190+
String keystorePW = environment.getProperty(PROP_ENCRYPTION_KEYSTORE_PASSWORD, certPW);
133191

134192
Resource keystoreFile = new FileSystemResource(keystore);
135193
Map<String, String> passwords = new HashMap<>();
@@ -193,8 +251,8 @@ public MetadataDisplayFilter metadataDisplayFilter() throws MetadataProviderExce
193251

194252
@Bean
195253
public MetadataGenerator metadataGenerator() {
196-
String appEntityId = environment.getProperty("proxy.saml.app-entity-id");
197-
String appBaseURL = environment.getProperty("proxy.saml.app-base-url");
254+
String appEntityId = environment.getProperty(PROP_APP_ENTITY_ID);
255+
String appBaseURL = environment.getProperty(PROP_BASE_URL);
198256

199257
MetadataGenerator metadataGenerator = new MetadataGenerator();
200258
metadataGenerator.setEntityId(appEntityId);
@@ -215,7 +273,7 @@ public ExtendedMetadata extendedMetadata() {
215273

216274
@Bean
217275
public ExtendedMetadataDelegate idpMetadata() throws MetadataProviderException, ResourceException {
218-
String metadataURL = environment.getProperty("proxy.saml.idp-metadata-url");
276+
String metadataURL = environment.getProperty(PROP_METADATA_URL);
219277

220278
Timer backgroundTaskTimer = new Timer(true);
221279
HTTPMetadataProvider httpMetadataProvider = new HTTPMetadataProvider(backgroundTaskTimer, new HttpClient(), metadataURL); httpMetadataProvider.setParserPool(parserPool());
@@ -281,21 +339,32 @@ public WebSSOProfileConsumerHoKImpl hokWebSSOprofileConsumer() {
281339
public SAMLFilterSet samlFilter() throws Exception {
282340
List<SecurityFilterChain> chains = new ArrayList<SecurityFilterChain>();
283341
chains.add(new DefaultSecurityFilterChain(new AntPathRequestMatcher("/saml/login/**"), samlEntryPoint()));
342+
chains.add(new DefaultSecurityFilterChain(new AntPathRequestMatcher("/saml/logout/**"), samlLogoutFilter()));
343+
chains.add(new DefaultSecurityFilterChain(new AntPathRequestMatcher("/saml/SingleLogout/**"), samlLogoutProcessingFilter()));
284344
chains.add(new DefaultSecurityFilterChain(new AntPathRequestMatcher("/saml/SSO/**"), samlWebSSOProcessingFilter()));
285345
return new SAMLFilterSet(chains);
286346
}
287347

348+
private final Logger log = LogManager.getLogger(getClass());
349+
350+
351+
288352
@Bean
289353
public SAMLAuthenticationProvider samlAuthenticationProvider() {
290354
SAMLAuthenticationProvider samlAuthenticationProvider = new SAMLAuthenticationProvider();
291355
samlAuthenticationProvider.setUserDetails(new SAMLUserDetailsService() {
292356
@Override
293357
public Object loadUserBySAML(SAMLCredential credential) throws UsernameNotFoundException {
294-
String nameAttribute = environment.getProperty("proxy.saml.name-attribute", DEFAULT_NAME_ATTRIBUTE);
295-
String nameValue = credential.getAttributeAsString(nameAttribute);
296-
if (nameValue == null) throw new UsernameNotFoundException("Name attribute missing from SAML assertion: " + nameAttribute);
297-
298-
List<GrantedAuthority> auth = new ArrayList<>();
358+
359+
if (Boolean.parseBoolean(environment.getProperty(PROP_LOG_ATTRIBUTES, "false"))) {
360+
AttributeUtils.logAttributes(log, credential);
361+
}
362+
363+
String nameAttribute = environment.getProperty("proxy.saml.name-attribute", DEFAULT_NAME_ATTRIBUTE);
364+
String nameValue = credential.getAttributeAsString(nameAttribute);
365+
if (nameValue == null) throw new UsernameNotFoundException("Name attribute missing from SAML assertion: " + nameAttribute);
366+
367+
List<GrantedAuthority> auth = new ArrayList<>();
299368
String rolesAttribute = environment.getProperty("proxy.saml.roles-attribute");
300369
if (rolesAttribute != null && !rolesAttribute.trim().isEmpty()) {
301370
String[] roles = credential.getAttributeAsStringArray(rolesAttribute);

src/main/java/eu/openanalytics/containerproxy/security/WebSecurityConfig.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ protected void configure(HttpSecurity http) throws Exception {
135135
.loginPage("/login")
136136
.and()
137137
.logout()
138+
.logoutUrl(auth.getLogoutURL())
139+
// important: set the next option after logoutUrl because it would otherwise get overwritten
138140
.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
139141
.addLogoutHandler(logoutHandler)
140142
.logoutSuccessUrl(auth.getLogoutSuccessURL());

0 commit comments

Comments
 (0)