You are here

saml_sp.module in SAML Service Provider 7

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
 */

// Path to this directory. Requires PHP 5.3 or greater.
define('DRUPAL_SAML_SP__HOME', __DIR__);

// 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);

/**
 * Implements hook_theme().
 */
function saml_sp_theme() {
  return array(
    'saml_sp__idp_list' => array(
      'render element' => 'idps',
      'file' => 'saml_sp.theme.inc',
    ),
  );
}

/**
 * Implements hook_permission().
 */
function saml_sp_permission() {
  return array(
    'configure saml sp' => array(
      'title' => t('Configure SAML SP'),
      'description' => t('Configure the SAML Service Provider integration.'),
      'restrict access' => TRUE,
    ),
  );
}

/**
 * Implements hook_menu().
 */
function saml_sp_menu() {
  $items = array();
  $items['admin/config/people/saml_sp'] = array(
    'title' => 'SAML Service Providers',
    'page callback' => 'saml_sp__admin_overview',
    'access arguments' => array(
      'configure saml sp',
    ),
    'file' => 'saml_sp.admin.inc',
  );
  $items['admin/config/people/saml_sp/IDP'] = array(
    'title' => 'Identiy Providers',
    'type' => MENU_DEFAULT_LOCAL_TASK,
    'weight' => -10,
  );

  // Add a new IDP.
  $items['admin/config/people/saml_sp/IDP/add'] = array(
    'title' => 'Add SAML IDP',
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'saml_sp__configure_idp_form',
    ),
    'access arguments' => array(
      'configure saml sp',
    ),
    'file' => 'saml_sp.admin.inc',
    'type' => MENU_LOCAL_ACTION,
  );

  // Configure an existing IDP.
  $items['admin/config/people/saml_sp/IDP/%saml_sp_idp'] = array(
    'title' => 'SAML IDP: @idp_name',
    'title callback' => 'saml_sp__menu_title',
    'title arguments' => array(
      'SAML IDP: @idp_name',
      5,
    ),
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'saml_sp__configure_idp_form',
      5,
    ),
    'access arguments' => array(
      'configure saml sp',
    ),
    'file' => 'saml_sp.admin.inc',
  );

  // Confirmation form to delete an IDP.
  $items['admin/config/people/saml_sp/IDP/%saml_sp_idp/delete'] = array(
    'title' => 'Delete SAML IDP: @idp_name',
    'title callback' => 'saml_sp__menu_title',
    'title arguments' => array(
      'Delete SAML IDP: @idp_name',
      5,
    ),
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'saml_sp__delete_idp_form',
      5,
    ),
    'access arguments' => array(
      'configure saml sp',
    ),
    'file' => 'saml_sp.admin.inc',
  );

  // Export the IDP configuration to code.
  $items['admin/config/people/saml_sp/IDP/%saml_sp_idp/export'] = array(
    'title' => 'Export SAML IDP: @idp_name',
    'title callback' => 'saml_sp__menu_title',
    'title arguments' => array(
      'Export SAML IDP: @idp_name',
      5,
    ),
    'page callback' => 'saml_sp__export_idp',
    'page arguments' => array(
      5,
    ),
    'access arguments' => array(
      'configure saml sp',
    ),
    'file' => 'saml_sp.admin.inc',
  );

  // 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,
  );
  return $items;
}

/**
 * Title callback for SAML SP menu items.
 */
function saml_sp__menu_title($title, $idp) {
  return t($title, array(
    '@idp_name' => $idp->name,
  ));
}

/******************************************************************************
 * CRUD handlers.
 *****************************************************************************/

/**
 * Load a single IDP.
 * Also a menu argument loader.
 *
 * @param String $idp_machine_name
 *
 * @return Object
 */
function saml_sp_idp_load($idp_machine_name) {
  $all_idps = saml_sp__load_all_idps();
  return isset($all_idps[$idp_machine_name]) ? $all_idps[$idp_machine_name] : FALSE;
}

/**
 * Save an IDP configuration.
 *
 * @param Object $idp
 * A populated IDP object, with the keys:
 * - name
 * - machine_name
 * - app_name
 * - login_url
 * - x509_cert
 *
 * @return Int
 * One of:
 * - SAVED_NEW
 * - SAVED_UPDATED
 */
function saml_sp_idp_save($idp) {

  // Prevent PHP notices by ensure 'export_type' is populated.
  if (empty($idp->export_type)) {
    $idp->export_type = NULL;
  }

  // Handle changes of machine name (if which case $idp->orig_machine_name
  // should be populated).
  if (!empty($idp->orig_machine_name)) {
    saml_sp_idp_delete($idp->orig_machine_name);
    $idp->export_type = NULL;
  }

  // Delegate to the CTools CRUD handler.
  $result = ctools_export_crud_save('saml_sp_idps', $idp);
  return isset($idp->orig_machine_name) && $result == SAVED_NEW ? SAVED_UPDATED : $result;
}

/**
 * Delete an IDP.
 *
 * @param String $idp_machine_name
 */
function saml_sp_idp_delete($idp_machine_name) {

  // No success feedback is provided.
  ctools_export_crud_delete('saml_sp_idps', $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() {

  // Use CTools export API to fetch all presets.
  ctools_include('export');
  $result = ctools_export_crud_load_all('saml_sp_idps');
  return $result;
}

/******************************************************************************
 * API library integration.
 *****************************************************************************/

/**
 * 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) {

  // Require all the relevant libraries.
  _saml_sp__prepare();

  // The consumer endpoint will always be /saml/consume.
  $endpoint_url = url("saml/consume", array(
    'absolute' => TRUE,
  ));
  $settings = new OneLogin_Saml_Settings();

  // URL of the IDP server.
  $settings->idpSingleSignOnUrl = $idp->login_url;

  // The IDP's public x.509 certificate.
  $settings->idpPublicCertificate = $idp->x509_cert;

  // Name to identify this application
  $settings->spIssuer = $idp->app_name;

  // Drupal URL to consume the response from the IDP.
  $settings->spReturnUrl = $endpoint_url;

  // Tells the IdP to return the email address of the current user
  $settings->requestedNameIdFormat = OneLogin_Saml_Settings::NAMEID_EMAIL_ADDRESS;

  // Invoke hook_saml_sp_settings_alter().
  drupal_alter('saml_sp_settings', $settings);
  return $settings;
}

/******************************************************************************
 * 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) {

  // Settings is a OneLogin_Saml_Settings object.
  $settings = saml_sp__get_settings($idp);
  $authRequest = new OneLogin_Saml_AuthRequest($settings);
  $url = $authRequest
    ->getRedirectUrl();

  // Track the ID of the outbound request.
  $prefix_length = strlen($settings->idpSingleSignOnUrl . "?SAMLRequest=");
  $id = _saml_sp__extract_outbound_id(substr($url, $prefix_length));
  saml_sp__track_request($id, $idp, $callback);

  // Redirect the user to the IDP.
  header("Location: {$url}");
}

/**
 * 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->machine_name,
    'callback' => $callback,
  );
  $expire = REQUEST_TIME + SAML_SP_REQUEST_CACHE_TIMEOUT;
  cache_set($id, $data, 'saml_sp_request_tracking_cache', $expire);
}

/**
 * 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) {
  if ($cache = cache_get($id, 'saml_sp_request_tracking_cache')) {
    return $cache->data;
  }
  return FALSE;
}

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

/**
 * Get a default IDP object.
 */
function _saml_sp__default_idp() {
  return (object) array(
    'name' => '',
    'machine_name' => '',
    // If the app-name is NULL, the global app-name will be used instead.
    'app_name' => NULL,
    'login_url' => '',
    'x509_cert' => '',
  );
}

/**
 * Load the required OneLogin SAML-PHP toolkit files.
 */
function _saml_sp__prepare() {
  static $has_run = FALSE;
  if (!$has_run) {
    require_once DRUPAL_SAML_SP__HOME . '/lib/ext/xmlseclibs/xmlseclibs.php';
    require_once DRUPAL_SAML_SP__HOME . '/lib/src/OneLogin/Saml/AuthRequest.php';
    require_once DRUPAL_SAML_SP__HOME . '/lib/src/OneLogin/Saml/Response.php';
    require_once DRUPAL_SAML_SP__HOME . '/lib/src/OneLogin/Saml/Settings.php';
    require_once DRUPAL_SAML_SP__HOME . '/lib/src/OneLogin/Saml/XmlSec.php';
    $has_run = TRUE;
  }
}

/**
 * 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 = @base64_decode($string);
  $string = @gzinflate($string);

  // This regex is based on the prelude provided in
  // OneLogin_Saml_AuthRequest::getRedirectUrl().
  $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_Saml_AuthRequest::ID_PREFIX . '[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;
      return $id;
    } catch (Exception $e) {
      return FALSE;
    }
  }
  return FALSE;
}

Functions

Namesort descending Description
saml_sp_idp_delete Delete an IDP.
saml_sp_idp_load Load a single IDP. Also a menu argument loader.
saml_sp_idp_save Save an IDP configuration.
saml_sp_menu Implements hook_menu().
saml_sp_permission Implements hook_permission().
saml_sp_start Start a SAML authentication request.
saml_sp_theme Implements hook_theme().
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__menu_title Title callback for SAML SP menu items.
saml_sp__track_request Track an outbound request.
_saml_sp__default_idp Get a default IDP object.
_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