Force the use of Refeds MFA for a specific service provider

There are still many cases in which it becomes necessary to force the use of specific login methods due to lack of support on the SP side. This can be achieved on the IdP in different ways:

  • by overriding the SP in the relying-party.xml using the defaultAuthenticationMethods property.
  • by writing code in an MFA transition script in mfa-authn-config.xml

Relying party method

This is the simplest method, but provides no fine-grain control. More information can be found in Shibboleth's documentation: https://shibboleth.atlassian.net/wiki/spaces/IDP5/pages/3199508270/ProfileConfiguration-Authentication

The latest version is published at https://mds.swamid.se/entity-configurations/Shibboleth-IdP/v5/relying-party.xml and is also shown below.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:util="http://www.springframework.org/schema/util"
       xmlns:p="http://www.springframework.org/schema/p"
       xmlns:c="http://www.springframework.org/schema/c"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
                           
       default-init-method="initialize"
       default-destroy-method="destroy">

    <!--
    Unverified RP configuration, defaults to no support for any profiles. Add <ref> elements to the list
    to enable specific default profile settings (as below), or create new beans inline to override defaults.
    
    "Unverified" typically means the IdP has no metadata, or equivalent way of assuring the identity and
    legitimacy of a requesting system. To run an "open" IdP, you can enable profiles here.
    -->
    <bean id="shibboleth.UnverifiedRelyingParty" parent="RelyingParty">
        <property name="profileConfigurations">
            <list>
            <!-- <bean parent="SAML2.SSO" p:encryptAssertions="false" /> -->
            </list>
        </property>
    </bean>

    <!--
    Default configuration, with default settings applied for all profiles.
    
    Take care with any defaults you apply at this level because you will have to create
    overrides or apply metadata tags for every single SP that requires a different setting.
    Changed defaults should be things you really do want to apply to nearly every SP.
    -->
    <bean id="shibboleth.DefaultRelyingParty" parent="RelyingParty">
        <property name="profileConfigurations">
            <list>
                <!-- SAML 1.1 and SAML 2.0 AttributeQuery are disabled by default. -->
                <!--
                <ref bean="Shibboleth.SSO" />
                <ref bean="SAML1.AttributeQuery" />
                <ref bean="SAML1.ArtifactResolution" />
                -->
                <ref bean="SAML2.SSO" />
                <ref bean="SAML2.ECP" />
                <ref bean="SAML2.Logout" />
                <!--
                <ref bean="SAML2.AttributeQuery" />
                -->
                <ref bean="SAML2.ArtifactResolution" />
            </list>
        </property>
    </bean>

    <!-- Bean to override AuthnContextClassRef with Refeds MFA -->
    <bean id="MFASAML2Principal" parent="shibboleth.SAML2AuthnContextClassRef" c:_0="https://refeds.org/profile/mfa" />
    
    <!-- Container for any overrides you want to add. -->

    <util:list id="shibboleth.RelyingPartyOverrides">
    
        <!--
        Override example that identifies a single RP by name and configures it
        to force AuthnContextClass to become Refeds MFA
        -->
        <bean id="ExampleSP" parent="RelyingPartyByName" c:relyingPartyIds="https://sp.example.org">
            <property name="profileConfigurations">
                <list>
                    <bean parent="SAML2.SSO" p:disallowedFeatures-ref="SAML2.SSO.FEATURE_AUTHNCONTEXT">
                      <property name="defaultAuthenticationMethods">
                        <list>
                          <ref bean="MFASAML2Principal" />
                        </list>
                      </property>
                    </bean>
                </list>
            </property>
        </bean>
        
    </util:list>

</beans>

MFA authn config method

Using an inlineScript to check the MFA context is described in the Shibboleth wiki at https://shibboleth.atlassian.net/wiki/spaces/IDP5/pages/3199505534/MultiFactorAuthnConfiguration

This provides more powerful and fine-grained control than the relying-party method.

Here are some examples of how the standard script provided by Shibboleth can be extended to decide if MFA should be required based upon:

  • the network the user is connecting from
  • group membership
  • attribute values

The code examples have been generously contributed by Högskolan i Borås.

The latest version is published at https://mds.swamid.se/entity-configurations/Shibboleth-IdP/v5/mfa-authn-config.xml and is also shown below.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:util="http://www.springframework.org/schema/util"
       xmlns:p="http://www.springframework.org/schema/p"
       xmlns:c="http://www.springframework.org/schema/c"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"

       default-init-method="initialize"
       default-destroy-method="destroy">

    <!--
    This is a map of transition rules that guide the behavior of the MFA flow
    and controls how factors are sequenced, skipped, etc. The key of each entry
    is the name of the step/flow out of which control is passing. The starting
    rule has an empty key.

    Each entry is a bean inherited from "shibboleth.authn.MFA.Transition". Per
    the Javadoc for net.shibboleth.idp.authn.MultiFactorAuthenticationTransition:

        p:nextFlow (String)
            - A flow to run if the previous step signaled a "proceed" event, for simple
                transitions.

        p:nextFlowStrategy (Function<ProfileRequestContext,String>)
            - A function to run if the previous step signaled a "proceed" event, for dynamic
                transitions. Returning null ends the MFA process.

        p:nextFlowStrategyMap (Map<String,Object> where Object is String or Function<ProfileRequestContext,String>)
            - Fully dynamic way of expressing control paths. Map is keyed by a previously
                signaled event and the value is a flow to run or a function to
                return the flow to run. Returning null ends the MFA process.

    When no rule is provided, there's an implicit "null" that ends the MFA flow
    with whatever event was last signaled. If the "proceed" event from a step is
    the final event, then the MFA process attempts to complete itself successfully.
    -->
    <util:map id="shibboleth.authn.MFA.TransitionMap">
        <!-- First rule runs the Password login flow. -->
        <entry key="">
            <bean parent="shibboleth.authn.MFA.Transition" p:nextFlow="authn/Password" />
        </entry>

        <!--
        Second rule runs a function if Password succeeds, to determine whether an additional
        factor is required.
        -->
        <entry key="authn/Password">
            <bean parent="shibboleth.authn.MFA.Transition" p:nextFlowStrategy-ref="checkFor2FAToken" />
        </entry>

        <!-- An implicit final rule will return whatever the final flow returns. -->
    </util:map>

    <bean id="shibboleth.ScriptDependencies" class="java.util.ArrayList">
    <constructor-arg>
        <list>
            <ref bean="shibboleth.HttpServletRequestSupplier"/>
            <ref bean="shibboleth.AttributeResolverService"/>
        </list>
    </constructor-arg>
    </bean>

    <!-- Script to see if second factor is required. -->
    <bean id="checkFor2FAToken" parent="shibboleth.ContextFunctions.Scripted" factory-method="inlineScript"
	p:customObject-ref="shibboleth.ScriptDependencies">
        <constructor-arg>
            <value>
            <![CDATA[
                logger = Java.type("org.slf4j.LoggerFactory").getLogger("Check.Second.Factor");
                RelyingPartyID = profileContext.getSubcontext("net.shibboleth.profile.context.RelyingPartyContext").getRelyingPartyId();
                resCtx = input.getSubcontext("net.shibboleth.idp.attribute.resolver.context.AttributeResolutionContext", true);
                usernameLookupStrategyClass = Java.type("net.shibboleth.idp.session.context.navigate.CanonicalUsernameLookupStrategy");
                usernameLookupStrategy = new usernameLookupStrategyClass();
                resCtx.setPrincipal(usernameLookupStrategy.apply(input));
                logger.info("Running MFA flow....");

                var InternalNet = false

                var requestSupplier = custom[0];  // First dependency (HttpServletRequestSupplier)
                var attributeResolver = custom[1];  // Second dependency (AttributeResolverService)

                var request = requestSupplier.get();

                var clientIP = request.getRemoteAddr();

                logger.info("Client IP Address: " + clientIP);

                // List of trusted networks where MFA is not required
                var trustedNetworks = [
                   "192.0.2.0/24",
                   "198.51.100.0/24",
                   "203.0.113.0/24"
                ];

                function ipToBigInt(ip) {
                  return ip.split(".")
                    .map(function(octet) { return parseInt(octet, 10); })
                    .reduce(function(acc, octet) { return (acc << 8) + octet; }, 0);
                }

                function isInRange(ip, cidr) {
                  var parts = cidr.split("/");
                  var network = parts[0];
                  var prefix = parseInt(parts[1], 10);

                  var ipValue = ipToBigInt(ip);
                  var networkValue = ipToBigInt(network);
                  var mask = (0xFFFFFFFF << (32 - prefix)) >>> 0;
                    return (ipValue & mask) === (networkValue & mask);
                }

                // Check if client IP address is in one of the trusted networks
                for (var i = 0; i < trustedNetworks.length; i++) {
                  if (isInRange(clientIP, trustedNetworks[i])) {
                      InternalNet = true
                      break;
                  }
                }

                logger.info("Client on internal network: " + InternalNet);

                nextFlow = "authn/secondFlow";

                authCtx = input.getSubcontext("net.shibboleth.idp.authn.context.AuthenticationContext");
                mfaCtx = authCtx.getSubcontext("net.shibboleth.idp.authn.context.MultiFactorAuthenticationContext");

                if (mfaCtx.isAcceptable()) {
                    logger.info('Service Provider NOT requesting MFA.');
                    nextFlow = null;

                    // Unconditionally use MFA for specific SP
                    if (RelyingPartyID.equals("https://sp.example.se/shibboleth")) {
                        logger.info("Service Provider entityID " + RelyingPartyID);
                        logger.info("Forcing second factor MFA.");
                        nextFlow = "authn/secondFlow";
                    }

                    // Use MFA if principal connecting from external network
                    if (RelyingPartyID.equals("https://sp2.example.se/shibboleth")) {
                        logger.info("Service Provider entityID " + RelyingPartyID);
                        if (InternalNet == false) {
                            logger.info("Client connecting from external network. Forcing second factor MFA.");
                            nextFlow = "authn/secondFlow";
                        }
                    }

                    // Use MFA if principal is a member of a group.
                    // Uses a mapped attribute definition in resolver which returns a value of true if user is in group.
                    if (RelyingPartyID.equals("https://sp3.example.se/shibboleth")) {
                        logger.info("Service Provider entityID " + RelyingPartyID);
                        resCtx.getRequestedIdPAttributeNames().add("sp3ForceMFA");
                        resCtx.resolveAttributes(attributeResolver);
                        attribute = resCtx.getResolvedIdPAttributes().get("sp3ForceMFA");
                        valueType = Java.type("net.shibboleth.idp.attribute.StringAttributeValue");
                        if (attribute != null && attribute.getValues().contains(new valueType("true"))) {
                            logger.info("User is a member of group. Forcing second factor MFA.");
                            nextFlow = "authn/secondFlow";
                        }
                        input.removeSubcontext(resCtx);   // cleanup
                    }

                    // Use MFA if principal is a member of a group and on external network (advanced example)
                    // Assumes resolver returns a list of group DNs, then matches the CN part against a regex
                    if (RelyingPartyID.equals("https://sp4.example.se/shibboleth")) {
                        logger.info("Service Provider entityID " + RelyingPartyID);
                        resCtx.getRequestedIdPAttributeNames().add("groups");
                        resCtx.resolveAttributes(attributeResolver);
                        attribute = resCtx.getResolvedIdPAttributes().get("groups");
                        valueType = Java.type("net.shibboleth.idp.attribute.StringAttributeValue");
                        if (attribute != null && InternalNet == false) {
                            // Matches: App-Group  OR  App-Group-Test
                            var regex = /^App-Group(-Test)?$/;
                            var matchedGroup = false;
                            for (var i = 0; i < attribute.getValues().size(); i++) {
                                var groupValue = attribute.getValues().get(i).getValue(); // Full DN string
                                var match = groupValue.match(/^CN=([^,]+),/);             // Extract CN=GroupName
                                if (match) {
                                    var groupName = match[1];  // Actual group name extracted from CN
                                    if (regex.test(groupName)) {
                                        logger.info("User is a member of a matching restricted group: " + groupName);
                                        matchedGroup = true;
                                    }
                                }
                            }
                            if (matchedGroup == true) {
                                logger.info("Client connecting from external network & user member of restricted group. Forcing second-factor MFA.");
                                nextFlow = "authn/secondFlow";
                            }
                        }
                        input.removeSubcontext(resCtx); // cleanup
                    }

                    // Use MFA if the principal has eduPersonAffilation employee and is connecting from external network
                    if (RelyingPartyID.equals("https://sp5.example.se/shibboleth")) {
                        logger.info("Service Provider entityID " + RelyingPartyID);
                        resCtx.getRequestedIdPAttributeNames().add("eduPersonAffiliation");
                        resCtx.resolveAttributes(attributeResolver);
                        attribute = resCtx.getResolvedIdPAttributes().get("eduPersonAffiliation");
                        valueType = Java.type("net.shibboleth.idp.attribute.StringAttributeValue");
                        if (attribute != null && attribute.getValues().contains(new valueType("employee")) && InternalNet == false) {
                            logger.info("Client connecting from external network & eduPersonAffiliation set to employee. Forcing second factor MFA.");
                            nextFlow = "authn/secondFlow";
                        }
                        input.removeSubcontext(resCtx);   // cleanup
                    }


                } else {
                    logger.info("MFA required for Service Provider " + RelyingPartyID);
                }
                nextFlow;   // pass control to second factor or end with the first
            ]]>
            </value>
        </constructor-arg>
    </bean>

</beans>

Contact us

Please contact service manager Pål Axelsson, pax@sunet.se

  • No labels