ISAM

Using Federated-SSO Access Policies for Conditional Two-Factor Authentication

Share this post:

In the ongoing effort for digital enterprises to reduce online identity fraud, two-factor authentication (2FA) of end users is becoming mainstream. Two-factor authentication can be achieved using a wide variety of methods, such as (but not limited to):

  • Using a verified email address or phone number to deliver and validate a one-time password
  • Using a software OTP generator based on a shared secret such as TOTP/HOTP
  • Using mobile-push authentication technology
  • Using a hardware 2FA token such as those supporting FIDO U2F

In order to reduce user authentication friction (inconvenience to the user caused by having to provide 2FA on every login), websites often choose to conditionally request two-factor authentication – that is, only require 2FA under particular circumstances which are deemed risky. The “conditions” may vary widely and include consideration for time of day, login location, deviation from “normal” behaviour based on behavioural analytics, or login from new device. One very simple and commonly used pattern is to require 2FA the first time a user logs in from a particular browser, and on successful 2FA simply tag the browser with a large random number in a persistent cookie (linking the cookie value to the user’s account) so that it is recognised as associated with the user on subsequent logins.

In IBM Security Access Manager (ISAM) this type of pattern has traditionally been implemented using risk-based access, and I have previously written about how to achieve this scenario:

In this article I’m going to demonstrate a simpler, alternative way to achieve the scenario that is applicable to authentication servers that are acting as either a SAML 2.0 or OpenID Connect (OIDC) identity provider. The technique shown in this article will make use of a new capability in ISAM 9.0.4 – SSO access policies.

Useful references:

The introduction of SSO access policies in ISAM 9.0.4 permits the injection of conditional code and optional user interaction during the invocation of federated single sign-on flows at an identity provider. Specifically, this allows additional checks and user-agent engagement during either:
– A SAML 2.0 single sign-on operation, or
– Processing of the /authorize endpoint of OAuth 2.0 or OpenID Connect.

After initial user authentication at the SSO endpoint, Javascript will be called to determine whether the request should proceed to complete the normal single sign-on processing or require further interaction by way of sending HTML or a HTTP redirect back to the browser for user interaction.

We will use this Javascript SSO access policy to implement the following logical processing:

The first time a user attempts SSO with a not-before-seen browser, they will perform basic username/password authentication, and end up being redirected to the 2FA authentication policy. After successful 2FA, the policy is re-evaluated. This (2nd) time the user is still not visiting from a known browser, but 2FA has been completed in the session. The policy will generate a browserFingerprint cookie, and store that server-side associated with the user’s account. The browser will be sent a set-cookie header, along with a redirect back to the /sps/auth page where the SSO policy will be re-evaluated again. This (3rd) time the browser will be recognised as a trusted device, and the federated SSO processing will complete.

If the user attempts federated SSO from the same browser again, the browser will be recognised as having been used before because of the browserFingerprint cookie and it’s association with the user’s account server-side, and only primary authentication will be required.

Here is the complete code of the SSO authentication policy that implements the algorithm shown in the flowchart above.

importClass(Packages.com.tivoli.am.fim.trustserver.sts.utilities.IDMappingExtUtils);
importClass(Packages.com.ibm.security.access.policy.decision.Decision);
importClass(Packages.com.ibm.security.access.policy.decision.RedirectChallengeDecisionHandler);
importClass(Packages.com.ibm.security.access.policy.decision.HtmlPageChallengeDecisionHandler);

/**
* This access policy performs simple conditional 2FA enforcement based on whether or not
* the user is authenticating from a browser we have seen them login from before. Browsers
* will be remembered via persistent cookie (browserFingerprint), and stored browser information
* will be kept in persistent IDMappingExtCache entries, which expire every 90 days (configurable). 
* The 2FA policy to redirect to, along with the authenticationMechanismTypes value to enforce 2FA is 
* configurable below. In my example I am using FIDO U2F, and it is assumed the user has already 
* registered their FIDO U2F authenticator.
* 
* The policy is written to remember a fixed number of browsers (again configurable). 
*/
var _federationPOC = "https://www.myidp.ibm.com/isam";
var _aacPOC = "https://www.myidp.ibm.com/mga";
var _redirect2FAPolicy = _aacPOC + 
    "/sps/authsvc?PolicyId=urn:ibm:security:authentication:asf:u2f_authenticate&Target=" + 
    _federationPOC + "@ACTION@";
var _requiredAuthenticationMechanismType = "urn:ibm:security:authentication:asf:mechanism:u2f";
//var _rememberedDeviceTimeoutSeconds = 60*60*24*90; // 90 days as seconds is what I would recommend
var _rememberedDeviceTimeoutSeconds = 2147483; // this is the max until an ISAM limitation is addressed
var _maxBrowsersPerUser = 3;  // remember at most this many browsers, with LRU policy for discard
var _fingerprintCookieName = "browserFingerprint";


/*
 * Determine some commonly used information from the context and this request. 
 */
var user = context.getUser();
var username = user.getUsername();
var request = context.getRequest();
var fingerprintCookieValue = getCookieValue(request, _fingerprintCookieName);

// get the JSON array of remembered browsers from cache; set to empty array if none found
var lookupKey = username + "_rememberedBrowsers";
var cache = IDMappingExtUtils.getIDMappingExtCache();
var rememberedBrowsers = [];
var rememberedBrowsersTxt = cache.get(lookupKey);
if (rememberedBrowsersTxt != null) {
	rememberedBrowsers = JSON.parse(rememberedBrowsersTxt);
}

/********************* BEGIN SET OF UTILITY FUNCTIONS ******************************************/

/**
 * Generate a random alpha-numeric string of given length
 */
function generateRandom(len) {
    // generates a random string of alpha-numerics
    var chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    var result = "";
    for (var i = 0; i < len; i++) {
            result = result + chars.charAt(Math.floor(Math.random()*chars.length));
    }
    return result;
}

/**
 * Get the value of a given cookie name from the request
 */
function getCookieValue(request, cookieName) {
	var result = null;

	var cookies = request.getCookies();
	for (var it = cookies.iterator(); it.hasNext() && result == null;) {
		var cookie = it.next();
		if (cookie.getName().equals(cookieName)) {
			result = ''+cookie.getValue();
		}
	}
	return result;
}

/**
 * Given a cookie value(cv) of the browser to register, and the existing list of 
 * registered browsers (rb), update the cache so that cv is the most recently used.
 */
function updateRegisteredBrowsers(cv, rb) {
	var now = (new Date()).getTime();
	var newrb = [];
	var js = {};
	js["cv"] = cv;
	js["exp"] = now + (_rememberedDeviceTimeoutSeconds*1000);
	newrb.push(js);
	if (rb != null) {
		var i = 0;
		// add all other existing registered browsers, up to max number of 
		// remembered browsers
		while (newrb.length < _maxBrowsersPerUser && i < rb.length) {
			if (rb[i].cv != cv) {
				newrb.push(rb[i]);
			}
			i++;
		}
	}
	cache.put(lookupKey, JSON.stringify(newrb), _rememberedDeviceTimeoutSeconds);
}

/**
 * Given a cookie value (cv), check if it is in one of the registered browsers (rb).
 * If it is, update the last used time to now in the cache.
 */
function isRegisteredBrowser(cv, rb) {
	var found = false;
	var now = (new Date()).getTime();
	if (cv != null && rb != null) {
		for (var i = 0; i < rb.length && !found; i++) {
			var b = rb[i];
			// check if this registered browser has matching value and 
			// registration is not expired
			if (b.cv == cv && now < b.exp) {
				found = true;
			}
		}
	}
	
	// update registered browser list if found. Most recently used are first in the array.
	if (found) {
		updateRegisteredBrowsers(cv, rb);
	}
	
	return found;
}

/**
 * Determine if the required 2FA authentication mechanism has been performed
 */
function has2FABeenPerformedThisSession(user) {
	var result = false;
	var authenticationMechanismTypes = user.getAttribute("authenticationMechanismTypes");
	if (authenticationMechanismTypes != null) {
		var vals = authenticationMechanismTypes.getValues();
		for (var it = vals.iterator(); it.hasNext() && result == false;) {
			var val = it.next();
			if (val.equals(_requiredAuthenticationMechanismType)) {
				result = true;
			}
		}
	}
	
	return result;
}

/********************* END SET OF UTILITY FUNCTIONS ********************************************/

/********************* MAIN PROCESSING LOGIC STARTS HERE ***************************************/


// this will be populated with what to do next
var decision = null;

/* 
 * Check if the user is coming from a registered browser. This will also update the cache with
 * the most recently used browser information.
 */
if (isRegisteredBrowser(fingerprintCookieValue, rememberedBrowsers)) {
	// no need for 2FA as this is a registered browser
	IDMappingExtUtils.traceString("Registered browser, allowing access");
	decision = Decision.allow();
} else {
	if (has2FABeenPerformedThisSession(user)) {
		IDMappingExtUtils.traceString("2FA has been performed this session");

		// Update registered browsers list then send back page which sets persistent cookie
		// and comes straight back to us.
		if (fingerprintCookieValue == null) {
			fingerprintCookieValue = generateRandom(50);
		}

		updateRegisteredBrowsers(fingerprintCookieValue, rememberedBrowsers);

	    var expires = new Date();
	    expires.setTime(expires.getTime() + (_rememberedDeviceTimeoutSeconds*1000));
		
		var handler = new HtmlPageChallengeDecisionHandler();
		handler.setPageId("/access_policy/setcookie.html");
		handler.setMacro("@COOKIENAME@", _fingerprintCookieName);
		handler.setMacro("@COOKIEVALUE@", fingerprintCookieValue);
		handler.setMacro("@EXPIRES@", expires.toUTCString());
		decision = Decision.challenge(handler);
	} else {
		// redirect to 2FA
		IDMappingExtUtils.traceString(
                    "2FA has NOT yet been performed this session, redirecting to 2FA policy");

		var handler = new RedirectChallengeDecisionHandler();
		handler.setRedirectUri(_redirect2FAPolicy);
		decision = Decision.challenge(handler);
	}
}

// act on the decision - should never be null
context.setDecision(decision);

Note that there is a reference to a page template /access_policy/setcookie.html when setting the browserFingerprint persistent cookie to tag the browser. In this case we have made use of another recent ISAM feature – server side Javascript in page templates – to make this page a transparent redirect to the browser. The setcookie.html page template looks like:


<% 
    // set the browser fingerprint cookie via server-side template page scripting, then redirect back to ourself 
    templateContext.response.setHeader("Set-Cookie", 
        templateContext.macros["@COOKIENAME@"] + "=" + templateContext.macros["@COOKIEVALUE@"] + 
        "; expires="+templateContext.macros["@EXPIRES@"] + "; path=/; secure; HttpOnly"); 
    templateContext.response.sendRedirect(templateContext.macros["@ACTION@"]); 
%>

There are several variables at the start of the SSO access policy that you should review and update – most notably the point-of-contact URL’s (the hostname and junction seen in the _federationPOC and _aacPOC variables) used for both the path to the Federation runtime, and the Advanced Access Control (AAC) runtime. These could be the same values on your systems, but they don’t have to be. It will all depend on how you choose to set up your Web Reverse Proxy (WRP) junctions for Federation and AAC.

You can also choose to use any authentication poilcy URL and corresponding authentication mechanism URI for redirecting to (and detecting completion of) 2FA for the user. In my environment I happened to use the FIDO U2F policy and mechanism, but you could just as easily use TOTP, SMS or EMAIL OTP, or even mobile push authentication with IBM Verify. You could also implement your own custom Infomap-based 2FA policy and redirect to it, or even a custom EAI. What matters is that the authentication policy that you redirect to eventually results in the setting of a credential attribute (in the case of AAC this is authenticationMechanismTypes) that can be checked in the SSO access policy to ensure 2FA was completed by the user.

There are other configuration variables present to decide the maximum number of registered browser per user (I suggest keeping this to a reasonably small list), as well as a timeout for how long to consider a browser trusted before requiring 2FA from that browser again. At the time of writing this article there is a limitation in that this timeout maximum can only be about 24 days, however I expect that to be fixed soon to allow much longer lifetimes. The timeout is used a couple of different ways – it determines both the lifetime of the persistent cookie sent to the browser, and also the expiry time of the remembered devices list in the server-side cache. It is not a bad idea to occassionally require 2FA even for users that are regularly using the same browser(s). This ensures that users are able to satisfy 2FA policies from time to time, and helps with eventual clean-up of trusted status of abandoned or lost devices. A policy of about 90 days for forced 2FA would seem reasonable to me.

In order to configure this scenario:

  • First ensure you have a working SAML, OIDC, or OAuth 2.0 flow in place that you wish to apply the policy to, and that you have also configured Advanced Access Control with the authentication policies that you intend to enforce for 2FA.
  • Update the access policy code – specifically you need to update the _federationPOC and _aacPOC variables to match your environment, then the _redirect2FAPolicy and _requiredAuthenticationMechanismType variables depending on the 2FA policy you intend to enforce. Finally review and update if necessary the _rememberedDeviceTimeoutSeconds variable for the maximum period a browser will be considered trusted, along with _maxBrowsersPerUser to determine how many browser you are going to allow a user to simultaneously consider trusted.
  • Upload your updated access policy Javascript to the Federation -> Access Policies panel in the LMI.

  • Upload the setcookie.html page template to the /C/access_policy template files directory using the LMI.
  • Adjust either your federation or partner configuration to use the new access policy.

Test your SSO flow. You should notice on first run through that you are redirected to your 2FA authentication policy (in my case this was the FIDO U2F authentication policy):

Provided you complete 2FA, a persistent cookie should be set on the browser called browserFingerprint. This may be re-written by ISAM as shown:

Federated SSO should complete normally.

On subsequent visits from the same browser, no 2FA should be required. You can easily reset the scenario by deleting the persistent cookie on the browser.

Hopefully this article has given you a taste for some of the posibilities of federated SSO access policies. If you have a particular policy in mind that you are struggling to implement, please reach out to me via the blog comments with contact information and I’ll do my best to help you out!

More ISAM stories

ISAM 9.0.7 brings commercial FIDO2 service and more

This week I am excited to share that IBM has just released the latest version of IBM Security Access Manager (version 9.0.7.0). As usual, the best place to find out what’s new, is the What’s new in this release page, however two things stand out as significant new features: FIDO2 and WebAuthn authentication services API-friendly […]

Continue reading