<?php
// $Id: ldapauth.module,v 1.46 2009/10/27 14:29:16 miglius Exp $

/**
 * @file
 * ldapauth provides authentication against ldap server.
 */

//////////////////////////////////////////////////////////////////////////////

define('LDAPAUTH_AUTH_MIXED',           0);
define('LDAPAUTH_AUTH_EXCLUSIVED',      1);
define('LDAPAUTH_CONFLICT_LOG',         0);
define('LDAPAUTH_CONFLICT_RESOLVE',     1);
define('LDAPAUTH_EMAIL_FIELD_NO',       0);
define('LDAPAUTH_EMAIL_FIELD_REMOVE',   1);
define('LDAPAUTH_EMAIL_FIELD_DISABLE',  2);
define('LDAPAUTH_PROFILE',              'LDAP authentication');
define('LDAPAUTH_PROFILE_WEIGHT',       4);

define('LDAPAUTH_LOGIN_PROCESS',       variable_get('ldapauth_login_process', LDAPAUTH_AUTH_MIXED));
define('LDAPAUTH_LOGIN_CONFLICT',      variable_get('ldapauth_login_conflict', LDAPAUTH_CONFLICT_LOG));
define('LDAPAUTH_FORGET_PASSWORDS',    variable_get('ldapauth_forget_passwords', TRUE));
define('LDAPAUTH_SYNC_PASSWORDS',      variable_get('ldapauth_sync_passwords', FALSE));
define('LDAPAUTH_DISABLE_PASS_CHANGE', variable_get('ldapauth_disable_pass_change', FALSE));
define('LDAPAUTH_ALTER_EMAIL_FIELD',   variable_get('ldapauth_alter_email_field', LDAPAUTH_EMAIL_FIELD_NO));
define('LDAPAUTH_DEFAULT_USER_ATTR',   variable_get('ldapauth_default_user_attr', 'uid'));
define('LDAPAUTH_DEFAULT_MAIL_ATTR',   variable_get('ldapauth_default_mail_attr', 'mail'));

//////////////////////////////////////////////////////////////////////////////
// Core API hooks

/**
 * Implements hook_init().
 */
function ldapauth_init() {
  require_once(drupal_get_path('module', 'ldapauth') .'/includes/LDAPInterface.inc');
}

/**
 * Implementation of hook_help().
 */
function ldapauth_help($path, $arg) {
  switch ($path) {
    case 'admin/settings/ldapauth':
      return '<p>'. t('A system wide settings will affect all configured LDAP servers.') .'</p>';
  }
}

/**
 * Implements hook_menu().
 */
function ldapauth_menu() {
  return array(
    'admin/settings/ldap' => array(
      'title' => 'LDAP',
      'description' => 'Configure LDAP integration settings.',
      'page callback' => 'ldapauth_admin_menu_block_page',
      'access arguments' => array('administer ldap modules'),
      'file' => 'ldapauth.admin.inc',
    ),
    'admin/settings/ldap/ldapauth' => array(
      'title' => 'Authentication',
      'description' => 'Configure LDAP authentication settings.',
      'page callback' => 'drupal_get_form',
      'page arguments' => array('ldapauth_admin_settings'),
      'access arguments' => array('administer ldap modules'),
     'file' => 'ldapauth.admin.inc',
    ),
    'admin/settings/ldap/ldapauth/configure' => array(
      'title' => 'Settings',
      'type' => MENU_DEFAULT_LOCAL_TASK,
    ),
    'admin/settings/ldap/ldapauth/list' => array(
      'title' => 'List',
      'page callback' => 'drupal_get_form',
      'page arguments' => array('ldapauth_admin_list'),
      'type' => MENU_LOCAL_TASK,
      'weight' => 1,
      'access arguments' => array('administer ldap modules'),
      'file' => 'ldapauth.admin.inc',
    ),
    'admin/settings/ldap/ldapauth/add' => array(
      'title' => 'Add Server',
      'page callback' => 'drupal_get_form',
      'page arguments' => array('ldapauth_admin_form', 4),
      'type' => MENU_LOCAL_TASK,
      'weight' => 2,
      'access arguments' => array('administer ldap modules'),
      'file' => 'ldapauth.admin.inc',
    ),
    'admin/settings/ldap/ldapauth/edit' => array(
      'title' => 'Configure LDAP Server',
      'page callback' => 'drupal_get_form',
      'page arguments' => array('ldapauth_admin_form', 4, 5),
      'type' => MENU_CALLBACK,
      'access arguments' => array('administer ldap modules'),
      'file' => 'ldapauth.admin.inc',
    ),
    'admin/settings/ldap/ldapauth/edit/%/test' => array(
      'title' => 'Test LDAP Server',
      'page callback' => '_ldapauth_ajax_test',
      'page arguments' => array(5),
      'type' => MENU_CALLBACK,
      'access arguments' => array('administer ldap modules'),
      'file' => 'ldapauth.admin.inc',
    ),
    'admin/settings/ldap/ldapauth/delete' => array(
      'title' => 'Delete LDAP Server',
      'page callback' => 'drupal_get_form',
      'page arguments' => array('ldapauth_admin_delete', 5),
      'type' => MENU_CALLBACK,
      'access arguments' => array('administer ldap modules'),
      'file' => 'ldapauth.admin.inc',
    ),
    'admin/settings/ldap/ldapauth/activate' => array(
      'title' => 'Activate LDAP Source',
      'page callback' => 'drupal_get_form',
      'page arguments' => array('ldapauth_admin_activate'),
      'access arguments' => array('administer ldap modules'),
      'type' => MENU_CALLBACK,
      'file' => 'ldapauth.admin.inc',
    ),
    'admin/settings/ldap/ldapauth/deactivate' => array(
      'title' => 'De-activate LDAP Source',
      'page callback' => 'drupal_get_form',
      'page arguments' => array('ldapauth_admin_deactivate'),
      'access arguments' => array('administer ldap modules'),
      'type' => MENU_CALLBACK,
      'file' => 'ldapauth.admin.inc',
    ),
  );
}

/**
 * Implements hook_theme().
 */
function ldapauth_theme() {
  return array(
    'ldapauth_admin_list' => array(
      'arguments' => array('form' => NULL),
      'file' => 'ldapauth.theme.inc'
    ),
  );
}

/**
 * Implements hook_user().
 */
function ldapauth_user($op, &$edit, &$account, $category = NULL) {
  switch ($op) {
    case 'update':
      if ($category == 'account') {

        // If authentication is being done in "LDAP only" mode, passwords
        // should not be written to the database, or users would be able
        // to log in even after removing their LDAP entry.
        if (isset($account->ldap_authentified) && (LDAPAUTH_LOGIN_PROCESS == LDAPAUTH_AUTH_EXCLUSIVED || !LDAPAUTH_SYNC_PASSWORDS))
          $edit['pass'] = NULL;
      }

      if (LDAPAUTH_ALTER_EMAIL_FIELD == LDAPAUTH_EMAIL_FIELD_REMOVE)
        unset($edit['mail']);
      break;
    case 'view':
      if (user_access('administer users') && isset($account->ldap_authentified) && $account->ldap_dn) {
        $row = db_fetch_object(db_query("SELECT * FROM {ldapauth} WHERE sid = %d", $account->ldap_config));
        $account->content[t(LDAPAUTH_PROFILE)] = array(
         '#type' => 'user_profile_category',
         '#title' => t(LDAPAUTH_PROFILE),
         '#attributes' => array('class' => 'ldapauth-entry'),
         '#weight' => LDAPAUTH_PROFILE_WEIGHT,
         'ldap_server' => array('#type' => 'user_profile_item', '#title' => t('LDAP server'), '#value' => l($row->name, 'admin/settings/ldap/ldapauth/edit/'. $row->sid), '#weight' => 0),
         'ldap_dn' => array('#type' => 'user_profile_item', '#title' => t('LDAP dn'), '#value' => $account->ldap_dn, '#weight' => 1),
        );
      }
      break;
  }
}

/**
 * Implementation of hook_menu_alter().
 */
function ldapauth_menu_alter(&$callbacks) {
  if (variable_get('ldapauth_disable_pass_change', FALSE))
    unset($callbacks['user/password']);
}

/**
 * Implements hook_perm().
 */
function ldapauth_perm() {
  return array('administer ldap modules');
}

/**
 * Implements hook_form_alter().
 */
function ldapauth_form_alter(&$form, $form_state, $form_id) {
  global $user;

  // Replace the drupal authenticate function is it's used as validation.
  if (isset($form['#validate']) && is_array($form['#validate']) && ($key = array_search('user_login_authenticate_validate', $form['#validate'])))
    $form['#validate'][$key] = 'ldapauth_login_authenticate_validate';

  switch ($form_id) {
    case 'user_login_block':
      if (LDAPAUTH_DISABLE_PASS_CHANGE)
        unset($form['links']);
      break;
    case 'user_profile_form':
      $account = $form["_account"]["#value"];
      if ($user->uid != 1 && isset($account->ldap_authentified)) {
        if (LDAPAUTH_DISABLE_PASS_CHANGE)
        unset($form['account']['pass']);

        switch (LDAPAUTH_ALTER_EMAIL_FIELD) {
          case LDAPAUTH_EMAIL_FIELD_REMOVE :
            $form['account']['mail']['#type'] = 'hidden';
            $form['account']['mail']['#attributes']['READONLY'] = 'READONLY';
            break;
          case LDAPAUTH_EMAIL_FIELD_DISABLE :
            $form['account']['mail']['#attributes']['READONLY'] = 'READONLY';
            break;
        }

        // Remove fieldset if empty.
        if (isset($form['account']) && !isset($form['account']['pass']) && $form['account']['mail']['#type'] == 'hidden' && count(array_filter($form['account'], create_function('$a', 'return is_array($a) ? TRUE : FALSE;'))) == 1) {
          $form['mail'] = $form['account']['mail'];
          unset($form['account']);
        }
      }
      break;
  }
}

/**
 * Implements hook_cron().
 */
function ldapauth_cron() {
  cache_clear_all(NULL, 'cache_filter');
}

/**
 * Implements hook_exit().
 */
function ldapauth_exit() {
  // We delete the login info here, instead of just not storing it at
  // _ldapauth_auth(), so at least ldapgroups can use it at login time.
  if (LDAPAUTH_FORGET_PASSWORDS && isset($_SESSION['ldap_login'])) {
    unset($_SESSION['ldap_login']);
  }
}

//////////////////////////////////////////////////////////////////////////////
// Login process functions

/**
 * Main user validation function.
 *
 * If successful, sets the global $user object.
 */
function ldapauth_login_authenticate_validate($form, &$form_state) {
  ldapauth_authenticate($form_state['values']);
}

/**
 * Main user authentication function.
 *
 * If successful, sets the global $user object.
 */
function ldapauth_authenticate($form_values = array()) {
  global $user, $_ldapauth_ldap;

  $name = $form_values['name'];
  $pass = trim($form_values['pass']);

  // The user_login_name_validate() is not called if the user is being authenticated
  // from the httpauth or services modules, therefore call it here.
  $form_state['values'] = $form_values;
  user_login_name_validate(NULL, $form_state);

  // (Design decision) uid=1 (admin user) must always authenticate to local database
  // this user is critical for all drupal admin and upgrade operations so it is best
  // left with drupal's native authentication.
  $result = db_query("SELECT uid FROM {users} WHERE name = '%s' AND uid = '1'", $name);
  if ($account = db_fetch_object($result)) {
    user_authenticate($form_values);
    return;
  }

  if (LDAPAUTH_LOGIN_PROCESS == LDAPAUTH_AUTH_MIXED) {
    // Authenticate local users first.
    $result = db_query("SELECT name, data FROM {users} WHERE name='%s'", $name);
    if ($row = db_fetch_array($result)) {
      $data = unserialize($row['data']);
      if (!isset($data['ldap_authentified']) || $data['ldap_authentified'] == 0) {
        // A local user with same name exists - authenticate that user.
        if (user_authenticate($form_values)) {
          // Nullify global ldap resource for good measure.
          unset($_ldapauth_ldap);
          return;
        }
      }
    }
  }

  $account = user_load(array('name' => $name, 'status' => 1));
  if ($account && drupal_is_denied('mail', $account->mail)) {
    form_set_error('name', t('The name %name is registered using a reserved e-mail address and therefore could not be logged in.', array('%name' => $account->name)));
  }

  // If there is any validations errors, we do not query LDAP.
  if (form_get_errors())
    return;

  // Authenticate LDAP user.
  if (!($dn = _ldapauth_auth($name, $pass)))
    return;

  if (!$account) {
    // Register this new user.
    if ($ldap_user = _ldapauth_user_lookup($name)) {
      // If mail attribute is missing, set the name as mail.
      $init = $mail = key_exists(($_ldapauth_ldap->getOption('mail_attr') ? $_ldapauth_ldap->getOption('mail_attr') : LDAPAUTH_DEFAULT_MAIL_ATTR), $ldap_user) ? $ldap_user[$_ldapauth_ldap->getOption('mail_attr')][0] : $name;

      // Check if the e-mail is not denied.
      if (drupal_is_denied('mail', $mail)) {
        form_set_error('name', t('The name %name is registered using a reserved e-mail address and therefore could not be logged in.', array('%name' => $name)));
        return;
      }

      // Generate a random drupal password. LDAP password will be used anyways.
      $pass_new = (LDAPAUTH_LOGIN_PROCESS == LDAPAUTH_AUTH_EXCLUSIVED || !LDAPAUTH_SYNC_PASSWORDS) ? user_password(20) : $pass;

      $userinfo = array('name' => $name, 'pass' => $pass_new, 'mail' => $mail, 'init' => $init, 'status' => 1, 'authname_ldapauth' => $name, 'ldap_authentified' => TRUE, 'ldap_dn' => $ldap_user['dn'], 'ldap_config' => $_ldapauth_ldap->getOption('sid'));
      $user = user_save('', $userinfo);
      watchdog('ldapauth', 'New external user %name created from the LDAP server %server.', array('%name' => $name, '%server' => $_ldapauth_ldap->getOption('name')), WATCHDOG_NOTICE, l(t('edit'), 'user/'. $user->uid .'/edit'));
    }
  }
  else {
    // Login existing user.
    $data = array(
      'ldap_dn' => $dn,
      'ldap_config' => $_ldapauth_ldap->getOption('sid'),
    );

    if (!isset($account->ldap_authentified)) {
      // LDAP and local user conflict.
      if (LDAPAUTH_LOGIN_CONFLICT == LDAPAUTH_CONFLICT_LOG) {
        watchdog('ldapauth', 'LDAP user with DN %dn has a naming conflict with a local drupal user %name', array('%dn' => $dn, '%name' => $account->name), WATCHDOG_ERROR);
        drupal_set_message(t('Another user already exists in the system with the same login name. You should contact the system administrator in order to solve this conflict.'), 'error');
        return;
      }
      else {
        $data['ldap_authentified'] = TRUE;
        $data['authname_ldapauth'] = $name;
      }
    }

    // Successfull login.
    // Save the new login data.
    if (LDAPAUTH_LOGIN_PROCESS == LDAPAUTH_AUTH_MIXED && LDAPAUTH_SYNC_PASSWORDS)
      $data['pass'] = $pass;
    $user = user_save($account, $data);
  }

  // Save user's authentication data to the session.
  $_SESSION['ldap_login']['dn'] = $dn;
  $_SESSION['ldap_login']['pass'] = $pass;

  user_authenticate_finalize($form_values);
  return $user;
}

/**
 * Authenticate the user against LDAP server.
 *
 * @param $name
 *   A username.
 * @param $pass
 *   A password.
 *
 * @return
 *  User's LDAP dn success, FALSE otherwise.
 */
function _ldapauth_auth($name, $pass) {
  global $_ldapauth_ldap;

  // Don't allow empty passwords because they cause problems on some setups.
  // http://drupal.org/node/87831
  if (empty($pass))
    return FALSE;

  // Cycle through LDAP configurations.  First one to succeed wins.
  $result = db_query("SELECT sid FROM {ldapauth} WHERE status = 1 ORDER BY weight");
  while ($row = db_fetch_object($result)) {

    // Initialize LDAP.
    if (!_ldapauth_init($row->sid))
      return FALSE;

    // Look up the user in LDAP.
    if (!($ldap = _ldapauth_user_lookup($name)) || !isset($ldap['dn']))
      continue;

    // Filter users based on their LDAP data.
    if (($code = _ldapauth_ldap_info($row->sid, 'filter_php')) && !eval($code))
      continue;

    // Try to authenticate.
    if (!$_ldapauth_ldap->connect($ldap['dn'], $pass))
      continue;

    return $ldap['dn'];
  }
  return FALSE;
}

/**
 * Queries LDAP server for the user.
 *
 * @param $name
 *   A login name.
 *
 * @return
 *   An array with user's LDAP data or NULL if not found.
 */
function _ldapauth_user_lookup($name) {
  global $_ldapauth_ldap;

  if (!$_ldapauth_ldap)
    return;

  // Transform login name.
  $login_name = ($code = _ldapauth_ldap_info($_ldapauth_ldap->getOption('sid'), 'login_php')) ? eval($code) : $name;

  // If there is no bindn and bindpw - the connect will be an anonymous connect.
  $_ldapauth_ldap->connect($_ldapauth_ldap->getOption('binddn'), $_ldapauth_ldap->getOption('bindpw'));
  foreach (explode("\r\n", $_ldapauth_ldap->getOption('basedn')) as $base_dn) {
    if (empty($base_dn))
      continue;

    $name_attr = $_ldapauth_ldap->getOption('user_attr') ? $_ldapauth_ldap->getOption('user_attr') : LDAPAUTH_DEFAULT_USER_ATTR;
    $filter = $name_attr .'='. $login_name;
    $result = $_ldapauth_ldap->search($base_dn, $filter);
    if (!$result)
      continue;

    $num_matches = $result['count'];
    // Must find exactly one user for authentication to.
    if ($num_matches != 1) {
      watchdog('ldapauth', "Error: %num_matches users found with $%filter under %base_dn.", array('%num_matches' => $num_matches, '%filter' => $filter, '%base_dn' => $base_dn), WATCHDOG_ERROR);
      continue;
    }
    $match = $result[0];

    // These lines serve to fix the attribute name in case a
    // naughty server (i.e.: MS Active Directory) is messing the
    // characters' case.
    // This was contributed by Dan "Gribnif" Wilga, and described
    // here: http://drupal.org/node/87833
    if (!isset($match[$name_attr][0])) {
      $name_attr = drupal_strtolower($name_attr);
      if (!isset($match[$name_attr][0]))
        continue;
    }
    // Finally, we must filter out results with spaces added before
    // or after, which are considered OK by LDAP but are no good for us
    // We allow lettercase independence, as requested by Marc Galera
    // on http://drupal.org/node/97728
    //
    // Some setups have multiple $name_attr per entry, as pointed out by
    // Clarence "sparr" Risher on http://drupal.org/node/102008, so we
    // loop through all possible options.
    foreach ($match[$name_attr] as $value) {
      if (drupal_strtolower(trim($value)) == drupal_strtolower($login_name))
        return $match;
    }
  }
}

//////////////////////////////////////////////////////////////////////////////
// Auxiliary functions

/**
 * Initiates the LDAPInterfase class.
 *
 * @param $sid
 *   An ID of the LDAP server configuration or user object.
 *
 * @return
 */
function _ldapauth_init($sid) {
  global $_ldapauth_ldap;

  if (!($sid = is_object($sid) ? (isset($sid->ldap_config) ? $sid->ldap_config : NULL) : $sid))
    return;

  static $servers = array();
  if (!isset($servers[$sid]))
    $servers[$sid] = db_fetch_object(db_query("SELECT * FROM {ldapauth} WHERE status = 1 AND sid = %d", $sid));

  if ($servers[$sid]) {
    $_ldapauth_ldap = new LDAPInterface();
    $_ldapauth_ldap->setOption('sid', $sid);
    $_ldapauth_ldap->setOption('name', $servers[$sid]->name);
    $_ldapauth_ldap->setOption('server', $servers[$sid]->server);
    $_ldapauth_ldap->setOption('port', $servers[$sid]->port);
    $_ldapauth_ldap->setOption('tls', $servers[$sid]->tls);
    $_ldapauth_ldap->setOption('encrypted', $servers[$sid]->encrypted);
    $_ldapauth_ldap->setOption('basedn', $servers[$sid]->basedn);
    $_ldapauth_ldap->setOption('user_attr', $servers[$sid]->user_attr);
    $_ldapauth_ldap->setOption('mail_attr', $servers[$sid]->mail_attr);
    $_ldapauth_ldap->setOption('binddn', $servers[$sid]->binddn);
    $_ldapauth_ldap->setOption('bindpw', $servers[$sid]->bindpw);
    return $_ldapauth_ldap;
  }
  return FALSE;
}

/**
 * Retrieve the saved ldapgroups saved setting.
 *
 * @param $sid
 *   A server ID or user object.
 * @param $req
 *   An attribute name.
 *
 * @return
 *   The attribute value.
 */
function _ldapauth_ldap_info($sid, $req) {
  if (!($sid = is_object($sid) ? (isset($sid->ldap_config) ? $sid->ldap_config : NULL) : $sid))
    return;

  static $servers = array();
  if (!isset($servers[$sid]))
    $servers[$sid] = db_fetch_object(db_query("SELECT * FROM {ldapauth} WHERE sid = %d", $sid));

  switch ($req) {
    case 'login_php':
      return $servers[$sid]->login_php;
    case 'filter_php':
      return $servers[$sid]->filter_php;
  }
}

