Skip to content

Commit

Permalink
[JENKINS-38971] Add support SAML ForceAuthn, AuthnContextClassRef, cu…
Browse files Browse the repository at this point in the history
…stom EntityId, and session timeout (#20)

* added expirationTime to SamlAuthenticationToken

* added advanced configuration support

* integrated advanced configuration

* Now passing the session to SamlAuthenticationToken

* removing -SNAPSHOT for testing

* Add support SAML ForceAuthn, AuthnContextClassRef, custom EntityId, and session timeout

* configuration tests
  • Loading branch information
kuisathaverat committed Apr 20, 2017
1 parent d47d2c1 commit 8232c21
Show file tree
Hide file tree
Showing 16 changed files with 331 additions and 41 deletions.
@@ -0,0 +1,69 @@
/* Licensed to Jenkins CI under one or more contributor license
agreements. See the NOTICE file distributed with this work
for additional information regarding copyright ownership.
Jenkins CI licenses this file to you under the Apache License,
Version 2.0 (the "License"); you may not use this file except
in compliance with the License. You may obtain a copy of the
License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License. */

package org.jenkinsci.plugins.saml;

import hudson.Util;

import org.apache.commons.lang.StringUtils;
import org.kohsuke.stapler.DataBoundConstructor;

/**
* Simple immutable data class to hold the optional advanced configuration data section
* of the plugin's configuration page
*/
public class SamlAdvancedConfiguration {
private final Boolean forceAuthn;
private final String authnContextClassRef;
private final String spEntityId;
private final Integer maximumSessionLifetime;

@DataBoundConstructor
public SamlAdvancedConfiguration(Boolean forceAuthn, String authnContextClassRef, String spEntityId, Integer maximumSessionLifetime) {
this.forceAuthn = (forceAuthn != null) ? forceAuthn : false;
this.authnContextClassRef = Util.fixEmptyAndTrim(authnContextClassRef);
this.spEntityId = Util.fixEmptyAndTrim(spEntityId);
this.maximumSessionLifetime = maximumSessionLifetime;
}

public Boolean getForceAuthn() {
return forceAuthn;
}

public String getAuthnContextClassRef() {
return authnContextClassRef;
}

public String getSpEntityId() {
return spEntityId;
}

public Integer getMaximumSessionLifetime() {
return maximumSessionLifetime;
}

@Override
public String toString() {
final StringBuffer sb = new StringBuffer("SamlAdvancedConfiguration{");
sb.append("forceAuthn=").append(forceAuthn);
sb.append(", authnContextClassRef='").append(StringUtils.defaultIfBlank(authnContextClassRef,"none")).append('\'');
sb.append(", spEntityId='").append(StringUtils.defaultIfBlank(spEntityId,"none")).append('\'');
sb.append(", maximumSessionLifetime=").append(maximumSessionLifetime != null ? maximumSessionLifetime : "none");
sb.append('}');
return sb.toString();
}
}
Expand Up @@ -17,7 +17,15 @@

package org.jenkinsci.plugins.saml;

import org.kohsuke.stapler.Stapler;

import org.acegisecurity.providers.AbstractAuthenticationToken;
import org.acegisecurity.context.SecurityContextHolder;

import javax.servlet.http.HttpSession;

import jenkins.model.Jenkins;
import jenkins.security.SecurityListener;

import javax.annotation.Nonnull;

Expand All @@ -29,15 +37,29 @@ public final class SamlAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 2L;

private final SamlUserDetails userDetails;
private final transient HttpSession session;
private final Long expiration;

public SamlAuthenticationToken(@Nonnull SamlUserDetails userDetails) {
public SamlAuthenticationToken(@Nonnull SamlUserDetails userDetails,@Nonnull HttpSession session) {
super(userDetails.getAuthorities());
this.userDetails = userDetails;
this.setDetails(userDetails);
this.setAuthenticated(true);
this.session = session;
this.expiration = (Long)session.getAttribute(SamlSecurityRealm.EXPIRATION_ATTRIBUTE);
}

public SamlUserDetails getPrincipal() {
// check if session should have expired
if (expiration != null &&
System.currentTimeMillis() > expiration.longValue()) {
// log the current user out and invalidate this session
this.setAuthenticated(false);
session.invalidate();
SecurityContextHolder.clearContext();
SecurityListener.fireLoggedOut(userDetails.getUsername());
}

return userDetails;
}

Expand Down
14 changes: 4 additions & 10 deletions src/main/java/org/jenkinsci/plugins/saml/SamlEncryptionData.java
Expand Up @@ -39,22 +39,16 @@ public SamlEncryptionData(String keystorePath, String keystorePassword, String p
this.privateKeyPassword = Util.fixEmptyAndTrim(privateKeyPassword);
}

public String getKeystorePath() {
return keystorePath;
}
public String getKeystorePath() { return keystorePath; }

public String getKeystorePassword() {
return keystorePassword;
}
public String getKeystorePassword() { return keystorePassword; }

public String getPrivateKeyPassword() {
return privateKeyPassword;
}
public String getPrivateKeyPassword() { return privateKeyPassword; }

@Override
public String toString() {
final StringBuffer sb = new StringBuffer("SamlEncryptionData{");
sb.append("keystorePath='").append(keystorePath).append('\'');
sb.append("keystorePath='").append(StringUtils.defaultIfBlank(keystorePath,"none")).append('\'');
sb.append(", keystorePassword is NOT empty='").append(StringUtils.isNotEmpty(keystorePassword)).append('\'');
sb.append(", privateKeyPassword is NOT empty='").append(StringUtils.isNotEmpty(privateKeyPassword)).append('\'');
sb.append('}');
Expand Down
96 changes: 68 additions & 28 deletions src/main/java/org/jenkinsci/plugins/saml/SamlSecurityRealm.java
Expand Up @@ -27,6 +27,7 @@
import jenkins.security.SecurityListener;
import org.acegisecurity.*;
import org.acegisecurity.context.SecurityContextHolder;
import org.apache.commons.lang.StringUtils;
import org.kohsuke.stapler.*;
import org.opensaml.common.xml.SAMLConstants;
import org.pac4j.core.client.RedirectAction;
Expand Down Expand Up @@ -59,36 +60,36 @@ public class SamlSecurityRealm extends SecurityRealm {
*/
public static final String CONSUMER_SERVICE_URL_PATH = "securityRealm/finishLogin";


private static final Logger LOG = Logger.getLogger(SamlSecurityRealm.class.getName());
private static final String REFERER_ATTRIBUTE = SamlSecurityRealm.class.getName() + ".referer";
protected static final String EXPIRATION_ATTRIBUTE = SamlSecurityRealm.class.getName() + ".expiration";
private static final String DEFAULT_DISPLAY_NAME_ATTRIBUTE_NAME = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name";
private static final String DEFAULT_GROUPS_ATTRIBUTE_NAME = "http://schemas.xmlsoap.org/claims/Group";
private static final int DEFAULT_MAXIMUM_AUTHENTICATION_LIFETIME = 24 * 60 * 60; // 24h
private static final String DEFAULT_USERNAME_CASE_CONVERSION = "none";

private String idpMetadata;
private String displayNameAttributeName;
private String groupsAttributeName;
private int maximumAuthenticationLifetime;
private String usernameCaseConversion;

private String usernameAttributeName;
private final String idpMetadata;
private final String usernameCaseConversion;
private final String usernameAttributeName;

private SamlEncryptionData encryptionData = null;
private SamlEncryptionData encryptionData;
private SamlAdvancedConfiguration advancedConfiguration;

/**
* Jenkins passes these parameters in when you update the settings.
* It does this because of the @DataBoundConstructor
*/
@DataBoundConstructor
public SamlSecurityRealm(String signOnUrl, String idpMetadata, String displayNameAttributeName, String groupsAttributeName, Integer maximumAuthenticationLifetime, String usernameAttributeName, SamlEncryptionData encryptionData, String usernameCaseConversion) {
public SamlSecurityRealm(String signOnUrl, String idpMetadata, String displayNameAttributeName, String groupsAttributeName, Integer maximumAuthenticationLifetime, String usernameAttributeName, SamlAdvancedConfiguration advancedConfiguration, SamlEncryptionData encryptionData, String usernameCaseConversion) {
super();
this.idpMetadata = Util.fixEmptyAndTrim(idpMetadata);
this.displayNameAttributeName = DEFAULT_DISPLAY_NAME_ATTRIBUTE_NAME;
this.groupsAttributeName = DEFAULT_GROUPS_ATTRIBUTE_NAME;
this.maximumAuthenticationLifetime = DEFAULT_MAXIMUM_AUTHENTICATION_LIFETIME;
this.usernameCaseConversion = DEFAULT_USERNAME_CASE_CONVERSION;

if (displayNameAttributeName != null && !displayNameAttributeName.isEmpty()) {
this.displayNameAttributeName = displayNameAttributeName;
Expand All @@ -100,15 +101,14 @@ public SamlSecurityRealm(String signOnUrl, String idpMetadata, String displayNam
this.maximumAuthenticationLifetime = maximumAuthenticationLifetime;
}
this.usernameAttributeName = Util.fixEmptyAndTrim(usernameAttributeName);
this.advancedConfiguration = advancedConfiguration;
this.encryptionData = encryptionData;
if (usernameCaseConversion != null && !usernameCaseConversion.isEmpty()) {
this.usernameCaseConversion = Util.fixEmptyAndTrim(usernameCaseConversion);
}
this.usernameCaseConversion = StringUtils.defaultIfBlank(usernameCaseConversion,DEFAULT_USERNAME_CASE_CONVERSION);
LOG.finer(this.toString());
}

public SamlSecurityRealm(String signOnUrl, String idpMetadata, String displayNameAttributeName, String groupsAttributeName, Integer maximumAuthenticationLifetime, String usernameAttributeName, SamlEncryptionData encryptionData) {
this(signOnUrl, idpMetadata, displayNameAttributeName, groupsAttributeName, maximumAuthenticationLifetime, usernameAttributeName, encryptionData, "none");
public SamlSecurityRealm(String signOnUrl, String idpMetadata, String displayNameAttributeName, String groupsAttributeName, Integer maximumAuthenticationLifetime, String usernameAttributeName, SamlAdvancedConfiguration advancedConfiguration, SamlEncryptionData encryptionData) {
this(signOnUrl, idpMetadata, displayNameAttributeName, groupsAttributeName, maximumAuthenticationLifetime, usernameAttributeName, advancedConfiguration, encryptionData, "none");
}

@Override
Expand Down Expand Up @@ -213,7 +213,16 @@ public HttpResponse doFinishLogin(StaplerRequest request, StaplerResponse respon
}
// create user data
SamlUserDetails userDetails = new SamlUserDetails(username, authorities.toArray(new GrantedAuthority[authorities.size()]));
SamlAuthenticationToken samlAuthToken = new SamlAuthenticationToken(userDetails);
// set session expiration, if needed.

if (getMaximumSessionLifetime() != null) {
request.getSession().setAttribute(
EXPIRATION_ATTRIBUTE,
System.currentTimeMillis() + 1000 * getMaximumSessionLifetime()
);
}

SamlAuthenticationToken samlAuthToken = new SamlAuthenticationToken(userDetails, request.getSession());

// initialize security context
SecurityContextHolder.getContext().setAuthentication(samlAuthToken);
Expand All @@ -233,6 +242,7 @@ public HttpResponse doFinishLogin(StaplerRequest request, StaplerResponse respon
}
}


// redirect back to original page
String referer = (String) request.getSession().getAttribute(REFERER_ATTRIBUTE);
String redirectUrl = referer != null ? referer : baseUrl();
Expand Down Expand Up @@ -278,12 +288,32 @@ private Saml2Client newClient() {
client.setIdpMetadata(idpMetadata);
client.setCallbackUrl(getConsumerServiceUrl());
client.setDestinationBindingType(SAMLConstants.SAML2_REDIRECT_BINDING_URI);
if (encryptionData != null) {
client.setKeystorePath(encryptionData.getKeystorePath());
client.setKeystorePassword(encryptionData.getKeystorePassword());
client.setPrivateKeyPassword(encryptionData.getPrivateKeyPassword());
if (getEncryptionData() != null) {
client.setKeystorePath(getKeystorePath());
client.setKeystorePassword(getKeystorePassword());
client.setPrivateKeyPassword(getPrivateKeyPassword());
}

client.setMaximumAuthenticationLifetime(this.maximumAuthenticationLifetime);

if(getAdvancedConfiguration()!=null) {

// request forced authentication at the IdP, if selected
client.setForceAuth(getForceAuthn());

// override the default EntityId for this SP, if one is set
if (getSpEntityId() != null) {
client.setSpEntityId(getSpEntityId());
}

// if a specific authentication type (authentication context class
// reference) is set, include it in the request to the IdP, and request
// that the IdP uses exact matching for authentication types
if (getAuthnContextClassRef() != null) {
client.setAuthnContextClassRef(getAuthnContextClassRef());
client.setComparisonType("exact");
}
}
if (LOG.isLoggable(Level.FINE)) {
LOG.fine(client.printClientMetadata());
}
Expand Down Expand Up @@ -325,17 +355,10 @@ public String getIdpMetadata() {
return idpMetadata;
}

public void setIdpMetadata(String idpMetadata) {
this.idpMetadata = idpMetadata;
}

public String getUsernameAttributeName() {
return usernameAttributeName;
}

public void setUsernameAttributeName(String attribute) {
this.usernameAttributeName = attribute;
}

public String getSpMetadata() {
return newClient().printClientMetadata();
Expand All @@ -353,6 +376,26 @@ public Integer getMaximumAuthenticationLifetime() {
return maximumAuthenticationLifetime;
}

public SamlAdvancedConfiguration getAdvancedConfiguration() {
return advancedConfiguration;
}

public Boolean getForceAuthn() {
return advancedConfiguration != null ? advancedConfiguration.getForceAuthn() : Boolean.FALSE;
}

public String getAuthnContextClassRef() {
return advancedConfiguration != null ? advancedConfiguration.getAuthnContextClassRef() : null;
}

public String getSpEntityId() {
return advancedConfiguration != null ? advancedConfiguration.getSpEntityId() : null;
}

public Integer getMaximumSessionLifetime() {
return advancedConfiguration != null ? advancedConfiguration.getMaximumSessionLifetime() : null;
}

public SamlEncryptionData getEncryptionData() {
return encryptionData;
}
Expand All @@ -373,10 +416,6 @@ public String getUsernameCaseConversion() {
return usernameCaseConversion;
}

public void setUsernameCaseConversion(String usernameCaseConversion) {
this.usernameCaseConversion = usernameCaseConversion;
}

@Extension
public static final class DescriptorImpl extends Descriptor<SecurityRealm> {

Expand Down Expand Up @@ -405,6 +444,7 @@ public String toString() {
sb.append(", usernameCaseConversion='").append(usernameCaseConversion).append('\'');
sb.append(", usernameAttributeName='").append(usernameAttributeName).append('\'');
sb.append(", encryptionData=").append(encryptionData);
sb.append(", advancedConfiguration=").append(advancedConfiguration);
sb.append('}');
return sb.toString();
}
Expand Down
Expand Up @@ -27,10 +27,35 @@
<f:option value="uppercase" selected="${instance.usernameCaseConversion == 'uppercase'}">Uppercase</f:option>
</select>
</f:entry>


<f:entry>
<table>
<f:optionalBlock title="Advanced Configuration" field="advancedConfiguration"
checked="${instance.advancedConfiguration != null}" help="/plugin/saml/help/advancedConfiguration.html">

<f:entry title="Force Authentication" field="forceAuthn" help="/plugin/saml/help/forceAuthn.html">
<f:checkbox />
</f:entry>

<f:entry title="Authentication Context" field="authnContextClassRef" help="/plugin/saml/help/authnContextClassRef.html">
<f:textbox />
</f:entry>

<f:entry title="SP Entity ID" field="spEntityId" help="/plugin/saml/help/spEntityId.html">
<f:textbox />
</f:entry>

<f:entry title="Maximum Session Lifetime" field="maximumSessionLifetime" help="/plugin/saml/help/maximumSessionLifetime.html">
<f:textbox default="86400" />
</f:entry>

</f:optionalBlock>
</table>
</f:entry>

<f:entry>
<table>
<f:optionalBlock title="Use Encryption ?" field="encryptionData"
<f:optionalBlock title="Use Encryption" field="encryptionData"
checked="${instance.encryptionData != null}"
help="/plugin/saml/help/encryption.html">
<f:entry title="Keystore path" field="keystorePath" help="/plugin/saml/help/keystorePath.html">
Expand Down
6 changes: 6 additions & 0 deletions src/main/webapp/help/advancedConfiguration.html
@@ -0,0 +1,6 @@
<div>
You could enable this options to use SAML ForceAuthn to force logins at our IdP,
AuthnContextClassRef to override the default authentication mechanism,
and force multi-factor authentication;
you also could set the sessions on Jenkins to be shorter than those on your IdP.
</div>
5 changes: 5 additions & 0 deletions src/main/webapp/help/authnContextClassRef.html
@@ -0,0 +1,5 @@
<div>
If this field is not empty, request that the SAML IdP uses a specific
authentication context, rather than its default. Check with the IdP
administrators to find out which authentication contexts are available.
</div>
3 changes: 3 additions & 0 deletions src/main/webapp/help/forceAuthn.html
@@ -0,0 +1,3 @@
<div>
Whether to request the SAML IdP to force (re)authentication of the user, rather than allowing an existing session with the IdP to be reused. Off by default.
</div>

0 comments on commit 8232c21

Please sign in to comment.