Simple Magento Single Sign On (SSO) with SAML

Simple Magento Single Sign On (SSO) with SAML

The problem:

We have a Magento Shop and we're developing lots of smaller individual services around it. All these services require a customer to be logged in with his/her credentials from the shop. So, we need to allow our customers to log in once with their Magento credentials on all related applications.

These requirements raised the need for SSO.

With the aid of SSO, a user logs in with a single ID and password and gains access to all related subsystems. That way, the user has to remember just one password and the login can be made on all subsystems seamlessly.

Introducing SAML

Quoting from Wikipedia,

Security Assertion Markup Language 2.0 (SAML 2.0) is a version of the SAML standard for exchanging authentication and authorization data between security domains. SAML 2.0 is an XML-based protocol that uses security tokens containing assertions to pass information about a principal (usually an end user) between a SAML authority, named an Identity Provider, and a SAML consumer, named a Service Provider. SAML 2.0 enables web-based, cross-domain single sign-on (SSO), which helps reduce the administrative overhead of distributing multiple authentication tokens to the user.”

Based on the brief definition above, we can identify with the following terminology:

Identity Provider (IdP) - the server that owns (and will validate) the identity of a user who is asking for a service.

Service Provider (SP) - the application that requires login and provides services to the user.

Principal - typically the user that is asking for a service from SP and is validated by IdP.

SimpleSAMLphp is an application written in PHP that implements SAML protocol. Its main focus is to provide support for:

  • SAML as a Service Provider;
  • SAML as an Identity Provider.

We will use SimpleSAMLphp as Identity Provider and all applications will act as Service Providers. This is how a normal flow looks like:

  • user tries to log in on any of the applications (SP);
  • SP constructs a SAML AuthnRequest and redirects the user to IdP;
  • IdP gives the user the possibility to log in (via login form);
  • user successfully authenticates in IdP;
  • IdP sends the SAML Response back to SP;
  • SP will receive the response through the previously defined Assertion Consumer Service URL.

A simple workflow is illustrated in the following image:

Install and configure

The first step is installing (https://simplesamlphp.org/docs/stable/simplesamlphp-install) and configuring (https://simplesamlphp.org/docs/stable/simplesamlphp-idp) SimpleSAMLphp.

Configuring the SimpleSAMLphp application involves creating metadata files for Service Providers and Identity Provider. The metadata will contain URLs of endpoints, information about supported bindings, identifiers and public keys.

In Identity Provider’s metadata you need to define the idp host, public/private key (used for signing and/or encrypting the assertions or response) and the authorisation source.

Because we need users to authenticate with their Magento credentials, we are using sqlauth:SQL as authorisation source, which is an authentication module for authenticating a user against an SQL database.

The auth sources are defined in config/authsources.php. We need to provide an SQL that will run on the Magento database and will basically do whatever a regular Magento login does.

SELECT
  	ce.entity_id as customer_id,
  	email AS username, cev2.value as confirmation
	FROM customer_entity AS ce
   	LEFT JOIN customer_entity_varchar AS cev2
   		 ON ce.entity_id = cev2.entity_id
   		 and cev2.attribute_id = (select attribute_id from eav_attribute WHERE attribute_code = \'confirmation\')
   	INNER JOIN customer_entity_varchar AS cev ON ce.entity_id = cev.entity_id
	WHERE
  	ce.email = :username AND
  	(
        cev.value = CONCAT(
      	sha2(
          	CONCAT(
              	(SELECT substr(`value`, -32)
               	    FROM customer_entity_varchar cev1
               	    WHERE cev1.entity_id = ce.entity_id AND cev1.attribute_id = (select attribute_id from eav_attribute WHERE attribute_code = \'password_hash\')),
              	:password
          	), 256
      	),
      	(SELECT substr(`value`, -33)
          	FROM customer_entity_varchar cev2
          	WHERE cev2.entity_id = ce.entity_id AND cev2.attribute_id = (select attribute_id from eav_attribute WHERE attribute_code = \'password_hash\'))
 		 ) OR
        cev.value = CONCAT(
       	sha2(
           	CONCAT(
               	(SELECT substr(`value`, -2)
                	FROM customer_entity_varchar cev1
                	WHERE cev1.entity_id = ce.entity_id AND cev1.attribute_id = (select attribute_id from eav_attribute WHERE attribute_code = \'password_hash\')),
               	:password
           	), 256
       	),
       	(SELECT substr(`value`, -3)
           	FROM customer_entity_varchar cev2
           	WHERE cev2.entity_id = ce.entity_id AND cev2.attribute_id = (select attribute_id from eav_attribute WHERE attribute_code = \'password_hash\'))
    	)
  )

Next, we need to add Service Providers metadata. In metadata/saml20-sp-remote.php you need to add one configuration array for every SP that will connect to this Identity Provider.

$metadata['SP1_ID'] = array(
    'AssertionConsumerService' => array(
   	 'Location'  => 'https://example.com/acs',
   	 'Binding'   => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
   	 'index' 	=> '1'
    ),
    'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
    'SingleLogoutService' => array(
   	 'Location' => 'https://example.com/sls',
   	 'Binding'  => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
    ),
    'nameid.encryption' => false,
    'saml20.sign.response' => true,
    'saml20.sign.assertion' => true,
    'sign.logout' => true,
    'validate.authnrequest' => true,
    'assertion.encryption' => true,
    'certData'   => '...'
);
  • AssertionConsumerService - the URL on SP where the SAML Response will be sent;
  • SingleLogoutService - the URL for handling the logout request;
  • certData - public key generated for the SP.

The others are options for signing/validating/encrypting response/request/assertions and can be defined on IdP level or individually for each SP. More options can be found in SimpleSAMLphp documentation (https://simplesamlphp.org/docs/stable/simplesamlphp-reference-idp-hosted).

The Magento part

In order to configure the Magento shop as a Service Provider, we had to create a Magento module which handled:

  • the creation and sending the AuthnRequest to Idp;
  • receiving the SAML Response for authentication and logout.

Following the normal workflow from the above image, we can see that, when a user requests a resource from the Service Provider, he/she will be redirected to IdP to authenticate and will probably be given a login form.

For our project we wanted to keep the Magento login form to authenticate users instead of redirecting them to IdP, so we had to over-complicate the normal flow a little.

In order to achieve this, we let the Magento login flow work as default and we added some observers to send the authentication state to IdP so that the other applications can also authenticate.

The first approach was to add an observer on the customer_login event that constructs SAML AuthnRequest and redirects the user to the Identity Provider. This worked well for the login action, but there were cases where Magento logged in the customer and then did some additional actions (like creating an account and, after calling ‘setCustomerAsLoggedIn()’ on customer session, it sent the welcome email).

The problem was that, after the method was called, the ‘customer_login’ event was raised and then the redirect to Identity Provider process took place. Then, from the IdP, we got back in the shop through another entry point (AssertionConsumerService url) and the code that followed the setCustomerAsLoggedIn() method was not executed.

To avoid these problems we moved that logic in the ‘controller_action_postdispatch’ event and we marked the request with a flag in the observer that observed the ‘customer_login’ event.

So, in a nutshell, this is how the observer that sends the SAML Authn Request to IdP looks like:

/**
 * Observer listening on controller_action_postdispatch. If there is a pre-set value of request param from
 * customer_login observer it will send the login request to saml
 *
 * After customer is successfully logged in send a saml request that is custom handled in IdP (simplesamlmphp)
 * in order to automatically login user without asking for password.
 * The implementation is done that way to keep the login form in Magento
 * The RelayState parameter is used to pass to IdP the user's email address 
 * This method redirects to idp.
 *
 * @event controller_action_postdispatch
 *
 * @return $this|void
 * @throws OneLogin_Saml2_Error
 */
public function sendIdPLoginRequest() {

    if (!$this->getConfig()->getIsEnabled()) {
          return;
    }

    $customerSession = Mage::getSingleton('customer/session');
    $customer = $customerSession->getCustomer();

    if (!$customerSession->isLoggedIn()) {
        return;
    }

    $email = $customer->getEmail();
    $relayStateDataToEncrypt = array('email' => $email, 'customer_id' => $customer->getId());

    try {
      $settings = $this->_config->getAllSettingsAsArray();
      $redirectUrl = $this->_helper->getRedirectUrl();
      $relayStateUrl = $this->_helper->prepareRelayStateUrl(
           $redirectUrl, $relayStateDataToEncrypt
      );

      $auth = new OneLogin_Saml2_Auth($settings);
      $auth->login($relayStateUrl);
    } catch (OneLogin_Saml2_Error $e) {
      Mage::logException($e);
      throw $e;
    }
}

Another problem we faced was with custom redirects. To get the SAML Response back into Magento, the IdP issues a post request to the URL defined as AssertionConsumerService in metadata. Because we had some custom logic in Magento for redirecting users after login from different processes, the use of the ‘_loginPostRedirect()’ method from the Magento account controller was not an option.

We used the RelayState parameter to pass the URL for redirecting the user after login. The correct URL is taken from the "Location" header in the Response object just before sending the AuthnRequest. The IdP sends back the RelayState parameter without changing anything, so we only have to issue a redirect to that URL after we receive the SAML Response. We also had to pass a parameter to IdP, so it knows how to handle the authentication of the user without showing him/her a login form (the user has just logged in through the Magento form).

The above actions were accomplished by the getRedirectUrl() method called in the above observer and is defined like this:

/**
 * Search response for a Location header in order to pass it to sso server as relay param so that on return to
 * shop we have the correct redirect url.
 * @return string | bool
 */
public function getRedirectUrl()
{
  /* @var $response Mage_Core_Controller_Response_Http */
  $response = Mage::app()->getResponse();
  $headers = $response->getHeaders();
  $size = count($headers);
  for ($i = $size - 1; $i >= 0; $i--) {
     if ($headers[$i]['name'] == 'Location') {
        return $headers[$i]['value'];
     }
  }

  return false;
}

In the end, the RelayState URL is obtained by adding a query parameter formed by encrypting the user email and customer id using the Identity Provider’s public key.

We also need to define the controller actions which will handle the entry points AssertionConsumerService and SingleLogoutService.

In the AssertionConsumerService we receive the response from Identity Provider and we need to extract the RelayState to redirect the customer to the correct process.

Also, we handle the response from Identity Provider in the case that the login was made from another Service Provider and we now need to log in the user in Magento. For this case, we will validate the response from Identity Provider and extract the Assertion attributes. We will then have the email address of the customer that has logged in in the other system and we will log him/her in Magento:

/**
 * Assertion consumer service entry-point. Handle saml response
 */
public function acsAction()
{
  $customerSession = Mage::getSingleton('customer/session');
  $request = $this->getRequest();
  $redirect = Mage::helper('evozon_saml')->extractRedirectFromRelayState(
        $request->getParam("RelayState")
  );
  if (!$redirect) {
     $redirect = Mage::getUrl('customer/account/login');
  }

  /*
   * handles forced idp login response. This is the case when a login request was sent from Magento after a
   * customer_login event was raised in shop and a session was then created in saml
   */
  /* @var $helper Evozon_Saml_Helper_Data */
  $helper = Mage::helper('evozon_saml');
  if ($customerSession->isLoggedIn() && $helper->validateSSOCookieWithCurrentUser()) {
     $this->_redirectUrl($redirect);
     return;
  }

  $postSAMLResponse = $request->getPost("SAMLResponse");
  $settings = Mage::getModel('evozon_saml/config')->getAllSettingsAsArray();
  $SAMLSettings = new OneLogin_Saml2_Settings($settings);
  $samlResponse = new OneLogin_Saml2_Response($SAMLSettings, $postSAMLResponse);

  try {
     if (!$samlResponse->isValid() ) {
        Mage::throwException(Mage::helper('evozon_saml')->__('Invalid SAML response.'));
     }

     $assertionAttributes = $samlResponse->getAttributes();
     $this->_handleRegularIdpLoginResponse($assertionAttributes, $redirect);

  } catch (Exception $e) {
     Mage::logException($e);
     $this->_redirect('customer/account/login');
  }

}

In the ‘_handleRegularIdpLoginResponse()’ method we need to log in the user in Magento and handle the redirect for that case.

/**
 * Login customer in Magento after receiving the response from IdP
 *
 * @param $assertionAttributes
 * @param $redirect
 *
 * @throws Exception
 */
private function _handleRegularIdpLoginResponse($assertionAttributes, $redirect)
{
  $customerSession = Mage::getSingleton('customer/session');
  $email = isset($assertionAttributes['username'])? $assertionAttributes['username'][0] : '';
  if (!$email) {
     throw new Exception("Missing email from saml response");
  }

  $customer = Mage::getModel( 'customer/customer' )->loadByEmail($email);
  if ($customer && $customer->getId()) {
     $customerSession->setCustomerAsLoggedIn($customer);
     $this->_redirectUrl($redirect);
  } else {
     $this->_addSessionError(__('Account does not exist'));
     $this->_redirect('customer/account/login');
  }
}

Another event listened is ‘customer_logout’, where we send a logout request to IdP. The IdP will then terminate the session for that user and will call the SingleLogoutService URL for each defined SP.

/**
 * After customer is logged out from shop send a saml logout request so that user get logged out from Idp also
 *
 * @event customer_logout
 */
public function samlSingleLogout() {
    if (!$this->_config->getIsEnabled()) {
        return;
    }
    try {
        $settings = $this->_config->getAllSettingsAsArray();
        $auth = new OneLogin_Saml2_Auth($settings);
        $auth->logout();
    } catch (OneLogin_Saml2_Error $e) {
        Mage::logException($e);
    }
 }

For working with SAML entities (constructing the AuthnRequest, validating response, extracting assertions, etc.), we used the library provided by onelogin. You can get it from https://github.com/onelogin/php-saml.

If following the normal SAML flow, there is no need to extend the SimpleSAMLphp application. But in our case we had to create another module based on sqlauth:SQL to allow logging in users coming from Magento SP without asking again for user/password.

For another sub-systems that acts like SP we also used the onelogin library that I showed above.

There is also a WordPress plugin based on it that enables users to log in to WordPress by authenticating to IdP.

With our setup though, it worked out of the box.

This article tries to present the solution we adopted for implementing SSO. There are third party solutions available also, but installing SimpleSAMLphp and using the Magento database for authentication keeps user data in our environment and avoids synchronizations between Magento and the 3rd party.

It is not a step by step tutorial, because the details are not so important once you get the main idea about how it works, and of course any questions are welcomed.


NO COMMENTS

Tell us what you think

Fields marked with " * " are mandatory.

We use cookies to offer you the best experience on our website. Learn more

Got it