You are here

saml_sp.module in SAML Service Provider 8.2

SAML Service Provider

Allow users to log in to Drupal via a third-party SAML Identity Provider. Users authenticate to the third-party SAML IDP (e.g. http://idp.example.com) and a series of redirects allows that authentication to be recognised in Drupal.

Uses the OneLogin PHP-SAML toolkit: https://github.com/onelogin/php-saml

File

saml_sp.module
View source
<?php

/**
 * @file
 * SAML Service Provider
 *
 * Allow users to log in to Drupal via a third-party SAML Identity Provider.
 * Users authenticate to the third-party SAML IDP (e.g. http://idp.example.com)
 * and a series of redirects allows that authentication to be recognised in
 * Drupal.
 *
 * Uses the OneLogin PHP-SAML toolkit: https://github.com/onelogin/php-saml
 */

// Default name to identify this application to IDPs.
define('DRUPAL_SAML_SP__APP_NAME_DEFAULT', 'drupal-saml-sp');

// Expect a response from the IDP within 2 minutes.
define('SAML_SP_REQUEST_CACHE_TIMEOUT', 120);
use Drupal\saml_sp\Entity\Idp;
use Drupal\saml_sp\SAML\SamlSPAuth;
use Drupal\Core\Url;

/**
 * Implements hook_menu().
 */
function saml_sp_menu() {
  $items = array();

  // SAML endpoint for all requests.
  // Some IDPs ignore the URL provided in the authentication request
  // (the AssertionConsumerServiceURL attribute) and hard-code a return URL in
  // their configuration, therefore all modules using SAML SP will have the
  // same consumer endpoint: /saml/consume.
  // A unique ID is generated for each outbound request, and responses are
  // expected to reference this ID in the `inresponseto` attribute of the
  // `<samlp:response` XML node.
  $items['saml/consume'] = array(
    'page callback' => 'saml_sp__endpoint',
    // This endpoint should not be under access control.
    'access callback' => TRUE,
    'file' => 'saml_sp.pages.inc',
    'type' => MENU_CALLBACK,
  );
  $items['saml/logout'] = array(
    'page callback' => 'saml_sp__logout',
    // This endpoint should not be under access control.
    'access callback' => TRUE,
    'file' => 'saml_sp.pages.inc',
    'type' => MENU_CALLBACK,
  );
  return $items;
}

/**
 * generate a url for the idp metadata
 */
function saml_sp__metadata_url($idp) {
  return \Drupal::url('saml_sp.metadata', array(), array(
    'absolute' => TRUE,
  ));
}

/**
 * Load a single IDP.
 *
 * @param String $idp_machine_name
 *
 * @return Object
 */
function saml_sp_idp_load($idp_machine_name) {
  if (is_string($idp_machine_name)) {
    return entity_load('idp', $idp_machine_name);
  }
  if (is_array($idp_machine_name)) {
    return $idp_machine_name;
  }
}

/**
 * Load all the registered IDPs.
 *
 * @return Array
 * An array of IDP objects, keyed by the machine name.
 */
function saml_sp__load_all_idps() {
  $result = entity_load_multiple('idp');
  return $result;
}

/**
 * Get the SAML settings for an IdP.
 *
 * @param Object $idp
 * An IDP object, such as that provided by saml_sp_idp_load($machine_name).
 *
 * @return OneLogin_Saml_Settings
 * IdP Settings data.
 */
function saml_sp__get_settings($idp = NULL) {
  if (empty($idp)) {
    $idp = new Idp(array());
  }
  $settings = array();

  // The consumer endpoint will always be /saml/consume.
  $endpoint_url = \Drupal::url('saml_sp.consume', array(), array(
    'absolute' => TRUE,
  ));
  $settings['idp']['entityId'] = $idp->id ?: 'none_given';

  // URL to login of the IdP server.
  $settings['idp']['singleSignOnService']['url'] = $idp->login_url ?: 'https://www.example.com/login';

  // URL to logout of the IdP server.
  $settings['idp']['singleLogoutService'] = array(
    'url' => $idp->logout_url,
    'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
  );

  // The IdP's public x.509 certificate.
  if (is_array($idp->x509_cert)) {

    // we only need one key, so use the first one
    $settings['idp']['x509cert'] = $idp->x509_cert[0] ?: 'blank';
  }
  else {
    $settings['idp']['x509cert'] = $idp->x509_cert ?: 'blank';
  }

  // The authentication method we want to use with the IdP
  $settings['idp']['AuthnContextClassRef'] = $idp->authn_context_class_ref ?: 'blank';

  // Name to identify IdP
  $settings['idp']['entityId'] = $idp->entity_id ?: 'blank';
  $config = \Drupal::config('saml_sp.settings');
  $settings['strict'] = (bool) $config
    ->get('strict');

  // Name to identify this application, if none is given use the absolute URL
  // instead
  $settings['sp']['entityId'] = $config
    ->get('entity_id') ?: \Drupal::url('user.page', array(), array(
    'absolute' => TRUE,
  ));

  // Drupal URL to consume the response from the IdP.
  $settings['sp']['assertionConsumerService'] = array(
    'url' => $endpoint_url,
    'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
  );

  // Tells the IdP to return the email address of the current user
  $settings['sp']['NameIDFormat'] = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress';

  // add the contact information for the SP
  $settings['contactPerson'] = array();
  if (!empty($config
    ->get('contact.technical.name')) && !empty($config
    ->get('contact.technical.email'))) {
    $settings['contactPerson']['technical'] = array(
      'givenName' => $config
        ->get('contact.technical.name'),
      'emailAddress' => $config
        ->get('contact.technical.email'),
    );
  }
  if (!empty($config
    ->get('contact.support.name')) && !empty($config
    ->get('contact.support.email'))) {
    $settings['contactPerson']['support'] = array(
      'givenName' => $config
        ->get('contact.support.name'),
      'emailAddress' => $config
        ->get('contact.support.email'),
    );
  }

  // add the organization information

  //$organization = variable_get('saml_sp__organization', array());
  $settings['organization'] = array(
    'en-US' => array(
      'name' => $config
        ->get('organization.name'),
      'displayname' => $config
        ->get('organization.display_name'),
      'url' => $config
        ->get('organization.url'),
    ),
  );
  $refs = saml_sp_authn_context_class_refs(TRUE);
  $authnContexts = array();
  if (isset($idp->authn_context_class_ref) && !empty($idp->authn_context_class_ref)) {
    foreach ($idp->authn_context_class_ref as $value) {
      if (!empty($value)) {
        $authnContexts[] = $refs[$value];
      }
    }
  }

  // add the security settings
  $settings['security'] = array(
    // signatures and encryptions offered
    'nameIdEncrypted' => (bool) $config
      ->get('security.nameIdEncrypted'),
    'authnRequestsSigned' => (bool) $config
      ->get('security.authnRequestsSigned'),
    'logoutRequestSigned' => (bool) $config
      ->get('security.logoutRequestSigned'),
    'logoutResponseSigned' => (bool) $config
      ->get('security.logoutResponseSigned'),
    // Sign the Metadata
    'signMetadata' => (bool) $config
      ->get('security.signMetaData'),
    // signatures and encryptions required
    'wantMessagesSigned' => (bool) $config
      ->get('security.wantMessagesSigned'),
    'wantAssertionsSigned' => (bool) $config
      ->get('security.wantAssertionsSigned'),
    'wantNameIdEncrypted' => (bool) $config
      ->get('security.wantNameIdEncrypted'),
    'signatureAlgorithm' => $config
      ->get('security.signatureAlgorithm'),
    'lowercaseUrlencoding' => (bool) $config
      ->get('security.lowercaseUrlencoding'),
    'requestedAuthnContext' => empty($authnContexts) ? FALSE : $authnContexts,
  );
  $cert_location = $config
    ->get('cert_location');
  if ($cert_location && file_exists($cert_location)) {
    $settings['sp']['x509cert'] = file_get_contents($cert_location);
  }
  $new_cert_location = $config
    ->get('new_cert_location');
  if ($new_cert_location && file_exists($new_cert_location)) {
    $settings['sp']['x509certNew'] = file_get_contents($new_cert_location);
  }

  // Invoke hook_saml_sp_settings_alter().
  \Drupal::moduleHandler()
    ->alter('saml_sp_settings', $settings);

  // we are adding in the private key after the alter function because we don't
  // want to risk the private key getting out and in the hands of a rogue module
  $key_location = $config
    ->get('key_location');
  if ($key_location && file_exists($key_location)) {
    $settings['sp']['privateKey'] = file_get_contents($key_location);
  }
  return $settings;
}

/**
 * load the settings and get the metadata
 */
function saml_sp__get_metadata($output_page = FALSE) {
  _saml_sp__prepare();
  $settings = saml_sp__get_settings();
  try {
    $auth = new \OneLogin_Saml2_Auth($settings);
    $settings = $auth
      ->getSettings();
    $metadata = $settings
      ->getSPMetadata();
    $errors = $settings
      ->validateMetadata($metadata);
  } catch (Exception $e) {
    $metadata = get_class($e) . ' - ' . $e
      ->getMessage();
  }
  return array(
    $metadata,
    isset($errors) ? $errors : array(),
  );
}

/******************************************************************************
 * Start and finish SAML authentication process.
 *****************************************************************************/

/**
 * Start a SAML authentication request.
 *
 * @param Object $idp
 * @param String $callback
 * A function to call with the results of the SAML authentication process.
 */
function saml_sp_start($idp, $callback) {
  global $base_url;
  $language = \Drupal::languageManager()
    ->getCurrentLanguage();
  if (isset($_GET['returnTo'])) {

    // If a returnTo parameter is present, then use that
    $return_to = $_GET['returnTo'];
  }
  else {

    // By default user is returned to the front page in the same language
    $return_to = Url::fromRoute('<front>')
      ->toString();
  }
  $settings = saml_sp__get_settings($idp);
  $auth = new SamlSPAuth($settings);
  $auth
    ->setAuthCallback($callback);
  return $auth
    ->login($return_to);
}

/**
 * Track an outbound request.
 *
 * @param String $id
 * The unique ID of an outbound request.
 * $param Object $idp
 * IDP data.
 * @param String $callback
 * The function to invoke on completion of a SAML authentication request.
 */
function saml_sp__track_request($id, $idp, $callback) {
  $data = array(
    'id' => $id,
    'idp' => $idp->id,
    'callback' => $callback,
  );
  $store = saml_sp_get_tempstore('track_request');
  $store
    ->set($id, $data);
}

/**
 * get the appropriate tempstore for the version of Drupal we are using
 */
function saml_sp_get_tempstore($name) {

  // determine is the Drupal version is one that has 'tempstore.shared' and use it,
  // otherwise use 'user.shared_tempstore'
  switch (version_compare(Drupal::VERSION, '8.5.0')) {
    case -1:

      // Drupal::Version is less than 8.5.0
      $service = 'user.shared_tempstore';
      break;
    default:

      // Drupal::VERSION is greater than or equal to
      $service = 'tempstore.shared';
  }
  $factory = \Drupal::service($service);
  $store = $factory
    ->get('saml_sp.' . $name);
  return $store;
}

/**
 * Get the IDP and callback from a tracked request.
 *
 *
 * @param String $id
 * The unique ID of an outbound request.
 *
 * @return Array|FALSE
 * An array of tracked data, giving the keys:
 * - id       The original outbound ID.
 * - idp      The machine name of the IDP.
 * - callback The function to invoke on authentication.
 */
function saml_sp__get_tracked_request($id) {
  $store = saml_sp_get_tempstore('track_request');
  if ($data = $store
    ->get($id)) {
    return $data;
  }
  return FALSE;
}

/******************************************************************************
 * Internal helper functions.
 *****************************************************************************/

/**
 * Load the required OneLogin SAML-PHP toolkit files.
 *
 * for some reason the OneLogin_Saml2_Constants class sometimes cannot be
 * found find it and load it explicitely
 */
function _saml_sp__prepare() {
  if (!class_exists('OneLogin_Saml2_Constants')) {
    $location = \Drupal::root() . '/vendor//onelogin/php-saml/lib/Saml2/Constants.php';
    if (file_exists($location)) {
      require_once $location;
    }
  }
}

/**
 * Extract the unique ID of an outbound request.
 *
 * @param String $encoded_url
 * The response of OneLogin_Saml_AuthRequest::getRedirectUrl(), which is
 * multiple-encoded.
 *
 * @return String|FALSE
 * The unique ID of the outbound request, if it can be decoded.
 * This will be OneLogin_Saml_AuthRequest::ID_PREFIX, followed by a sha1 hash.
 */
function _saml_sp__extract_outbound_id($encoded_url) {
  $string = $encoded_url;
  $string = @urldecode($string);
  $string = @substr($string, 0, strpos($string, '&'));
  $string = @base64_decode($string);
  $string = @gzinflate($string);

  // This regex is based on the constructor code  provided in
  // OneLogin_Saml2_AuthnRequest.
  $regex = '/^<samlp:AuthnRequest
    xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
    ID="(ONELOGIN_[0-9a-f]{40})"/m';
  $result = FALSE;
  if (preg_match($regex, $string, $matches)) {
    $result = $matches[1];
  }
  return $result;
}

/**
 * Extract the unique ID in an inbound request.
 *
 * @param String $assertion
 * UUEncoded SAML assertion from the IdP (i.e. the POST request).
 *
 * @return String|FALSE
 * The unique ID of the inbound request, if it can be decoded.
 * This will be OneLogin_Saml_AuthRequest::ID_PREFIX, followed by a sha1 hash.
 */
function _saml_sp__extract_inbound_id($assertion) {

  // Decode the request.
  $xml = base64_decode($assertion);

  // Load the XML.
  $document = new DOMDocument();
  if ($document
    ->loadXML($xml)) {
    try {
      $id = @$document->firstChild->attributes
        ->getNamedItem('InResponseTo')->value;
      \Drupal::logger('saml_sp')
        ->notice('SAML login attempt with inbound ID: %id', array(
        '%id' => $id,
      ));
      return $id;
    } catch (Exception $e) {
      \Drupal::logger('saml_sp')
        ->error('Could not extract inbound ID. %exception', array(
        '%exception' => $e,
      ));
      return FALSE;
    }
  }
  \Drupal::logger('saml_sp')
    ->error('Cannot parse XM response:<br/> <pre>@response</pre>', array(
    '@response' => $xml,
  ));
  return FALSE;
}

/**
 * alternate keys for the authn_context_class_ref
 */
function saml_sp_authn_context_class_refs($reverse = FALSE) {
  $array = array(
    'urn:oasis:names:tc:SAML:2.0:ac:classes:Password' => 'user_name_and_password',
    'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport' => 'password_protected_transport',
    'urn:oasis:names:tc:SAML:2.0:ac:classes:TLSClient' => 'tls_client',
    'urn:oasis:names:tc:SAML:2.0:ac:classes:X509' => 'x509_certificate',
    'urn:federation:authentication:windows' => 'integrated_windows_authentication',
    'urn:oasis:names:tc:SAML:2.0:ac:classes:Kerberos' => 'kerberos',
  );
  if ($reverse) {
    $array = array_flip($array);
  }
  return $array;
}

/**
 * Implements hook_form_alter().
 */

/**
  * comment out the changes to the user form which is causing problems... this
  * will be uncommented when a better solution conceived of.
function saml_sp_form_alter(&$form, &$form_state, $form_id) {
   switch ($form_id) {
    case 'user_profile_form' :
      // Disable email field because it should not be changed when using SSO.
      // Users who have access to configure the module can do it.
      if (!user_access('configure saml sp')) {
        $form['account']['mail']['#disabled'] = TRUE;
      }
      $form['account']['mail']['#description'] = t('Email address cannot be changed here, because the information comes from the SSO server. You need to change it there instead. After it has been changed, you need to logout and login to this service to see the updated address.');
      // Disable all password fields because they need to be changed on the IdP
      // server
      // are we sure that we want to remoev all password fields? some
      // configurations they will still want to allow for separate Drupal logins
      //$validate_unset = array_search('user_validate_current_pass', $form['#validate']);
      //unset($form['#validate'][$validate_unset], $form['account']['pass'], $form['account']['current_pass']);
    break;
   }
}
*/

Functions

Namesort descending Description
saml_sp_authn_context_class_refs alternate keys for the authn_context_class_ref
saml_sp_get_tempstore get the appropriate tempstore for the version of Drupal we are using
saml_sp_idp_load Load a single IDP.
saml_sp_menu Implements hook_menu().
saml_sp_start Start a SAML authentication request.
saml_sp__get_metadata load the settings and get the metadata
saml_sp__get_settings Get the SAML settings for an IdP.
saml_sp__get_tracked_request Get the IDP and callback from a tracked request.
saml_sp__load_all_idps Load all the registered IDPs.
saml_sp__metadata_url generate a url for the idp metadata
saml_sp__track_request Track an outbound request.
_saml_sp__extract_inbound_id Extract the unique ID in an inbound request.
_saml_sp__extract_outbound_id Extract the unique ID of an outbound request.
_saml_sp__prepare Load the required OneLogin SAML-PHP toolkit files.

Constants