Skip to content

Commit

Permalink
Add Support for SAML Authentication. (#370)
Browse files Browse the repository at this point in the history
  • Loading branch information
DiogoMRSilva authored Aug 10, 2020
1 parent 8e567e6 commit 88a1912
Show file tree
Hide file tree
Showing 13 changed files with 523 additions and 1 deletion.
1 change: 1 addition & 0 deletions bennu-saml-client/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
configuration.properties
23 changes: 23 additions & 0 deletions bennu-saml-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# SAML client

## Setup
1. Define properties: define the properties required on SAMLClientConfiguration in the `configuration.properties` file.
Sample available at `bennu-saml-client/src/main/resources/configuration.properties.sample`.
You need to define these properties in the webapp and on
`bennu-saml-client/src/main/resources/configuration.properties`) if you want to generate the metadata
automatically.

2. Generate Metadata file: run the ManualMetadataGenerator

3. Add More callbacks: if you need the IDP to callback to more than one location after authentication you
need to change the file generated on the previous step. Search for `AssertionConsumerService` and add similar
entries but with the other urls.

4. Copy the metadata file with all the `AssertionConsumerService` to the location you specified in the variable
`saml.serviceProviderFinalMetadataLocation`, this file will be made available at your webapp endpoint
`/api/saml-client/metadata`

5. Configure IDP to fetch metadata from the path `/api/saml-client/metadata`

IMPORTANT: at this moment filters on fenix are causing problems on the requests, and when they arrive at the saml client
they are broken. To solve this you should add the filter `FixRequestArgumentsForSAMLFilter` to the callback urls
58 changes: 58 additions & 0 deletions bennu-saml-client/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.fenixedu</groupId>
<artifactId>bennu</artifactId>
<version>6.10.0-SNAPSHOT</version>
</parent>

<artifactId>bennu-saml-client</artifactId>
<name>Bennu SAML Client</name>

<properties>
<pac4j.version>4.0.3</pac4j.version>
<jax-rs-pac4j.version>2.3.0</jax-rs-pac4j.version>
</properties>

<dependencies>
<dependency>
<groupId>org.fenixedu</groupId>
<artifactId>bennu-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.fenixedu</groupId>
<artifactId>bennu-portal</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.fenixedu</groupId>
<artifactId>fenixedu-commons</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
<dependency>
<groupId>javax.ws.rs</groupId>
<artifactId>javax.ws.rs-api</artifactId>
</dependency>
<dependency>
<groupId>org.pac4j</groupId>
<artifactId>pac4j-saml-opensamlv3</artifactId>
<version>${pac4j.version}</version>
</dependency>
</dependencies>
<repositories>
<repository>
<id>fenixedu-maven-repository</id>
<url>https://repo.fenixedu.org/fenixedu-maven-repository</url>
</repository>
<repository>
<id>central</id>
<url>https://repo1.maven.org/maven2/</url>
</repository>
</repositories>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.fenixedu.bennu.saml.client;

import org.pac4j.saml.client.SAML2Client;

import java.io.File;
import java.io.IOException;

public class ManualMetadataGenerator {
public static void main(String[] args) throws IOException {
// Make sure you have the configurations.properties files with the correct variables
SAML2Client client = SAMLClientSDK.getClient();
client.init();
String destinationFilePath =
SAMLClientConfiguration.getConfiguration().serviceProviderMetadataGenerationDestinationPath();
File file = new File(destinationFilePath);
if (file.exists()) {
System.out.println("SAML metadata written to: " + destinationFilePath
+ " If you need more than one saml callback location you need add mode AssertionConsumerService");
} else {
System.out.println("Failed to write to SAML metadata file: " + destinationFilePath);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package org.fenixedu.bennu.saml.client;

import org.fenixedu.commons.configuration.ConfigurationInvocationHandler;
import org.fenixedu.commons.configuration.ConfigurationManager;
import org.fenixedu.commons.configuration.ConfigurationProperty;

public class SAMLClientConfiguration {

@ConfigurationManager(description = "Bennu SAML Client Configuration")
public static interface ConfigurationProperties {

@ConfigurationProperty(key = "saml.enabled", defaultValue = "false", description = "Whether the SAML client is enabled")
public Boolean samlEnabled();

@ConfigurationProperty(key = "saml.keystorePath", description = "Where the keystore for SAML is")
public String keystorePath();

@ConfigurationProperty(key = "saml.keystorePassword", description = "The Password for SAML keystore")
public String keystorePassword();

@ConfigurationProperty(key = "saml.privateKeyPassword", description = "The Password for SAML private key in the keystore")
public String privateKeyPassword();

@ConfigurationProperty(key = "saml.identityProviderMetadataPath", description = "The path to the identity provider metadata")
public String identityProviderMetadataPath();

@ConfigurationProperty(key = "saml.serviceProviderMetadataGenerationDestinationPath", description = "The path where to store the automatically generated service provider metadata with only one assertion consumer service")
public String serviceProviderMetadataGenerationDestinationPath();

@ConfigurationProperty(key = "saml.serviceProviderFinalMetadataLocation", description = "The path to the final metadata(with all the possible callback/AssertionConsumerService), it probably needs to be generated by hand")
public String serviceProviderFinalMetadataPath();

@ConfigurationProperty(key = "saml.serviceProviderEntityId", description = "The id we want this service to have in the SAML")
public String serviceProviderEntityId();

@ConfigurationProperty(key = "saml.callbackUrl", description = "The url to where the client is redirected to after logging in the SAML identity provide")
public String callbackUrl();
}

public static ConfigurationProperties getConfiguration() {
return ConfigurationInvocationHandler.getConfiguration(ConfigurationProperties.class);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package org.fenixedu.bennu.saml.client;

import org.opensaml.core.xml.schema.XSAny;
import org.opensaml.core.xml.schema.impl.XSAnyBuilder;
import org.opensaml.saml.common.xml.SAMLConstants;
import org.pac4j.saml.client.SAML2Client;
import org.pac4j.saml.config.SAML2Configuration;

import javax.xml.namespace.QName;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.function.Supplier;

public class SAMLClientSDK {

private static final SAML2Configuration CONFIG = getNewDefaultConfiguration();

private static final List<String> AUT_CONTEXT_CLASSES_LDAP =
new ArrayList<>(Arrays.asList("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"));

private static final List<String> AUT_CONTEXT_CLASSES_GOV = new ArrayList<>(
Arrays.asList("urn:oasis:names:tc:SAML:2.0:ac:classes:SmartcardPKI",
"urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract"));

// These are the attributes that are necessary for the IDP to work with the GOV IDP
private static final String[] GOV_REQUIRED_ATTRIBUTES = new String[] { "NIC" };

private static SAML2Configuration getNewDefaultConfiguration() {
SAML2Configuration config = new SAML2Configuration(SAMLClientConfiguration.getConfiguration().keystorePath(),
SAMLClientConfiguration.getConfiguration().keystorePassword(),
SAMLClientConfiguration.getConfiguration().privateKeyPassword(),
SAMLClientConfiguration.getConfiguration().identityProviderMetadataPath());

config.setAuthnRequestBindingType(SAMLConstants.SAML2_REDIRECT_BINDING_URI);
config.setResponseBindingType(SAMLConstants.SAML2_POST_BINDING_URI);

config.setAuthnRequestSigned(true);
config.setWantsResponsesSigned(true);
config.setMaximumAuthenticationLifetime(3600 * 8);
// custom SP entity ID
config.setServiceProviderEntityId(SAMLClientConfiguration.getConfiguration().serviceProviderEntityId());
config.setForceServiceProviderMetadataGeneration(true);

config.setServiceProviderMetadataPath(
new File(SAMLClientConfiguration.getConfiguration().serviceProviderMetadataGenerationDestinationPath())
.getAbsolutePath());
return config;
}

private static SAML2Configuration getNewConfiguration(String[] govAttributesToRequire) {
SAML2Configuration config = getNewDefaultConfiguration();

Supplier<List<XSAny>> requestExtensions = () -> {
List<XSAny> extensionList = new ArrayList<>();

/*
<samlp:Extensions>
<RequestedAttributes xmlns="http://autenticacao.cartaodecidadao.pt/atributos">
<RequestedAttribute Name="http://interop.gov.pt/MDC/Cidadao/NIC" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" isRequired="true"/>
</RequestedAttributes>
<FAAALevel xmlns="http://autenticacao.cartaodecidadao.pt/atributos">3</FAAALevel>
</samlp:Extensions>
*/

XSAny requestedAttributes = new XSAnyBuilder()
.buildObject(new QName("http://autenticacao.cartaodecidadao.pt/atributos", "RequestedAttributes"));
extensionList.add(requestedAttributes);

HashSet<String> attributesToRequire = new HashSet<>(Arrays.asList(GOV_REQUIRED_ATTRIBUTES));
attributesToRequire.addAll(Arrays.asList(govAttributesToRequire));

for (String name : attributesToRequire) {
XSAny requestedAttribute = new XSAnyBuilder()
.buildObject(new QName("http://autenticacao.cartaodecidadao.pt/atributos", "RequestedAttribute"));
requestedAttribute.getUnknownAttributes().put(new QName("Name"), "http://interop.gov.pt/MDC/Cidadao/" + name);
requestedAttribute.getUnknownAttributes()
.put(new QName("NameFormat"), "urn:oasis:names:tc:SAML:2.0:attrname-format:uri");
requestedAttribute.getUnknownAttributes().put(new QName("isRequired"), "true");
requestedAttributes.getUnknownXMLObjects().add(requestedAttribute);
}

XSAny FAAALevel =
new XSAnyBuilder().buildObject(new QName("http://autenticacao.cartaodecidadao.pt/atributos", "FAAALevel"));
FAAALevel.setTextContent("3");
extensionList.add(FAAALevel);

return extensionList;
};
config.setAuthnRequestExtensions(requestExtensions);

return config;
}

private static final SAML2Client CLIENT = new SAML2Client(CONFIG);

static {
CLIENT.setCallbackUrl(SAMLClientConfiguration.getConfiguration().callbackUrl());
}

public static SAML2Client getClient() {
return CLIENT;
}

public static SAML2Client getClientForGov(String[] govAttributesToRequire, String callbackUrl) {
SAML2Configuration config = getNewConfiguration(govAttributesToRequire);
config.setAuthnContextClassRefs(AUT_CONTEXT_CLASSES_GOV);
config.setComparisonType("exact"); // ignored by SP but needed for pac4f to use context classes
SAML2Client client = new SAML2Client(config);
client.setCallbackUrl(callbackUrl);
return client;
}

public static SAML2Client getClient(String callbackUrl) {
SAML2Client client = new SAML2Client(CONFIG);
client.setCallbackUrl(callbackUrl);
return client;
}

public static SAML2Client getClientWithSpecificAuthContextClasses(List<String> authContextClasses,
String[] govAttributesToRequire, String callbackUrl) {
SAML2Configuration config;
if (govAttributesToRequire != null) {
config = getNewConfiguration(govAttributesToRequire);
} else {
config = getNewDefaultConfiguration();
}

if (authContextClasses != null) {
config.setAuthnContextClassRefs(authContextClasses);
config.setComparisonType("exact"); // ignored by SP but needed for pac4f to use context classes
}

SAML2Client client = new SAML2Client(config);
if (callbackUrl != null) {
client.setCallbackUrl(callbackUrl);
} else {
client.setCallbackUrl(SAMLClientConfiguration.getConfiguration().callbackUrl());
}
return client;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package org.fenixedu.bennu.saml.client;

import com.google.common.base.Strings;
import org.fenixedu.bennu.core.security.Authenticate;
import org.fenixedu.bennu.portal.login.LoginProvider;
import org.pac4j.core.context.JEEContext;
import org.pac4j.core.exception.http.HttpAction;
import org.pac4j.core.http.adapter.JEEHttpActionAdapter;
import org.pac4j.saml.client.SAML2Client;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class SAMLLoginProvider implements LoginProvider {

@Override
public void showLogin(final HttpServletRequest request, final HttpServletResponse response, final String callback) {
Authenticate.logout(request, response);
final JEEContext context = new JEEContext(request, response);

final SAML2Client client = SAMLClientSDK.getClient();

try {
final HttpAction action = client.getRedirectionAction(context).get();
JEEHttpActionAdapter.INSTANCE.adapt(action, context);
} catch (final HttpAction ex) {
throw new Error(ex);
}
}

@Override
public String getKey() {
return "saml";
}

@Override
public String getName() {
return "SAML";
}

@Override
public boolean isEnabled() {
return SAMLClientConfiguration.getConfiguration().samlEnabled();
}
}
Loading

0 comments on commit 88a1912

Please sign in to comment.