r2666 - introduced LDAPCache table (ticket:193)
authorDenis Ovsienko <infrastation@yandex.ru>
Mon, 27 Apr 2009 15:22:49 +0000 (15:22 +0000)
committerDenis Ovsienko <infrastation@yandex.ru>
Mon, 27 Apr 2009 15:22:49 +0000 (15:22 +0000)
 - authenticated_via_ldap(): only deal with higher-level cache decisions
 - queryLDAPServer(): new function is the place for LDAP code
 - acquireLDAPCache(): new function
 - releaseLDAPCache(): idem
 - touchLDAPCacheRecord(): idem
 - replaceLDAPCacheRecord(): idem
 - deleteLDAPCacheRecord(): idem
 - explicitly state local account records as such

ChangeLog
inc/auth.php
inc/database.php
inc/navigation.php
inc/pagetitles.php
install/init-structure.sql
local/secret-sample.php
upgrade.php

index cdd9f45..2f14b11 100644 (file)
--- a/ChangeLog
+++ b/ChangeLog
@@ -9,6 +9,7 @@
        new feature: RackCode expressions as source for load balancer lists
        new feature: wireless hardware in dictionary
        new feature: "racks per row" option (by Frank Brodbeck)
+       new feature: LDAP cache
        update: display row name when listing objects. closes ticket 16 (by Aaron)
        update: ability to manage rows from the Rackspace page in addition to the Dictionary (by Aaron)
        update: IPv4 calculations were optimized for better speed
index 72f2685..fff5c63 100644 (file)
@@ -71,10 +71,7 @@ function authenticate ()
                // Just trust the server, because the password isn't known.
                case ('httpd' == $user_auth_src):
                        if (authenticated_via_httpd ($remote_username))
-                       {
-                               $remote_displayname = "EXT: ${remote_username}";
                                return;
-                       }
                        break;
                // When using LDAP, leave a mean to fix things. Admin user is always authenticated locally.
                case ('database' == $user_auth_src or $accounts[$remote_username]['user_id'] == 1):
@@ -163,91 +160,179 @@ function permitted ($p = NULL, $t = NULL, $o = NULL, $annex = array())
 }
 
 function authenticated_via_ldap ($username, $password)
+{
+       global
+               $ldap_cache_refresh, // read
+               $ldap_cache_retry, // read
+               $ldap_cache_expiry, // read
+               $remote_displayname, // set
+               $auto_tags; // set
+
+       $oldinfo = acquireLDAPCache ($username, sha1 ($password), $ldap_cache_expiry);
+       // Remember to have releaseLDAPCache() called before any return statement.
+       if ($oldinfo === NULL) // cache miss
+       {
+               // On cache miss execute complete procedure and return the result. In case
+               // of successful authentication put a record into cache.
+               $newinfo = queryLDAPServer ($username, $password);
+               if ($newinfo['result'] == 'ACK')
+               {
+                       $remote_displayname = $newinfo['displayed_name'];
+                       foreach ($newinfo['memberof'] as $autotag)
+                               $auto_tags[] = array ('tag' => $autotag);
+                       replaceLDAPCacheRecord ($username, sha1 ($password), $newinfo['displayed_name'], $newinfo['memberof']);
+               }
+               releaseLDAPCache();
+               return $newinfo['result'] == 'ACK';
+       }
+       // There are two confidence levels of cache hits: "certain" and "uncertain". In either case
+       // expect authentication success, unless it's well-timed to perform a retry,
+       // which may sometimes bring a NAK decision.
+       if ($oldinfo['success_age'] < $ldap_cache_refresh or $oldinfo['retry_age'] < $ldap_cache_retry)
+       {
+               releaseLDAPCache();
+               $remote_displayname = $oldinfo['displayed_name'];
+               foreach ($oldinfo['memberof'] as $autotag)
+                       $auto_tags[] = array ('tag' => $autotag);
+               return TRUE;
+       }
+       // Either refresh threshold or retry threshold reached.
+       $newinfo = queryLDAPServer ($username, $password);
+       switch ($newinfo['result'])
+       {
+       case 'ACK': // refresh existing record
+               $remote_displayname = $newinfo['displayed_name'];
+               foreach ($newinfo['memberof'] as $autotag)
+                       $auto_tags[] = array ('tag' => $autotag);
+               replaceLDAPCacheRecord ($username, sha1 ($password), $newinfo['displayed_name'], $newinfo['memberof']);
+               releaseLDAPCache();
+               return TRUE;
+       case 'NAK': // The record isn't valid any more.
+               deleteLDAPCacheRecord ($username);
+               releaseLDAPCache();
+               return FALSE;
+       case 'CAN': // retry failed, do nothing, use old value till next retry
+               $remote_displayname = $oldinfo['displayed_name'];
+               foreach ($oldinfo['memberof'] as $autotag)
+                       $auto_tags[] = array ('tag' => $autotag);
+               touchLDAPCacheRecord ($username);
+               releaseLDAPCache();
+               return TRUE;
+       default:
+               showError ('Internal error during LDAP cache dispatching', __FUNCTION__);
+               die;
+       }
+       // This is never reached.
+       return FALSE;
+}
+
+// Attempt a server conversation and return an array describing the outcome:
+//
+// 'result' => 'CAN' : connect (or search) failed completely
+//
+// 'result' => 'NAK' : server replied and denied access (or search returned odd data)
+//
+// 'result' => 'ACK' : server replied and cleared access, there were no search errors
+// 'displayed_name' : a string built according to ldap_displayname_attrs option
+// 'memberof' => filtered list of all LDAP groups the user belongs to
+//
+function queryLDAPServer ($username, $password)
 {
        global $ldap_server, $ldap_domain, $ldap_search_dn, $ldap_search_attr;
-       global $remote_username, $remote_displayname, $ldap_displayname_attrs;
-       if ($connect = @ldap_connect ($ldap_server))
+       global
+               $ldap_server,
+               $ldap_domain,
+               $ldap_search_dn,
+               $ldap_search_attr,
+               $ldap_displayname_attrs;
+
+       $connect = @ldap_connect ($ldap_server);
+       if ($connect === FALSE)
+               return array ('result' => 'CAN');
+
+       // Decide on the username we will actually authenticate for.
+       if (isset ($ldap_domain) and !empty ($ldap_domain))
+               $auth_user_name = $username . "@" . $ldap_domain;
+       elseif
+       (
+               isset ($ldap_search_dn) and
+               !empty ($ldap_search_dn) and
+               isset ($ldap_search_attr) and
+               !empty ($ldap_search_attr)
+       )
        {
-               if (isset ($ldap_domain) and !empty ($ldap_domain))
-                       $auth_user_name = $username . "@" . $ldap_domain;
-               elseif
-               (
-                       isset ($ldap_search_dn) and
-                       !empty ($ldap_search_dn) and
-                       isset ($ldap_search_attr) and
-                       !empty ($ldap_search_attr)
-               )
+               $results = @ldap_search ($connect, $ldap_search_dn, "(${ldap_search_attr}=${username})", array("dn"));
+               if ($results === FALSE)
+                       return array ('result' => 'CAN');
+               if (@ldap_count_entries ($connect, $results) != 1)
                {
-                       $results = @ldap_search ($connect, $ldap_search_dn, "(${ldap_search_attr}=${username})", array("dn"));
-                       if (@ldap_count_entries ($connect, $results) != 1)
-                       {
-                               @ldap_close ($connect);
-                               return FALSE;
-                       }
-                       $info = @ldap_get_entries ($connect, $results);
-                       ldap_free_result ($results);
-                       $auth_user_name = $info[0]['dn'];
+                       @ldap_close ($connect);
+                       return array ('result' => 'NAK');
                }
-               else
+               $info = @ldap_get_entries ($connect, $results);
+               ldap_free_result ($results);
+               $auth_user_name = $info[0]['dn'];
+       }
+       else
+       {
+               showError ('LDAP misconfiguration. Cannon build username for authentication.', __FUNCTION__);
+               die;
+       }
+       $bind = @ldap_bind ($connect, $auth_user_name, $password);
+       if ($bind === FALSE)
+               switch (ldap_errno ($connect))
                {
-                       showError ('LDAP misconfiguration. Cannon build username for authentication.', __FUNCTION__);
-                       die;
+               case 49: // LDAP_INVALID_CREDENTIALS
+                       return array ('result' => 'NAK');
+               default:
+                       return array ('result' => 'CAN');
                }
-               if ($bind = @ldap_bind ($connect, $auth_user_name, $password))
+       // preliminary decision may change during searching
+       $ret = array ('result' => 'ACK', 'displayed_name' => '', 'memberof' => array());
+       // Some servers deny anonymous search, thus search (if requested) only after binding.
+       // Displayed name only makes sense for authenticated users anyway.
+       if
+       (
+               isset ($ldap_displayname_attrs) and
+               count ($ldap_displayname_attrs) and
+               isset ($ldap_search_dn) and
+               !empty ($ldap_search_dn) and
+               isset ($ldap_search_attr) and
+               !empty ($ldap_search_attr)
+       )
+       {
+               $results = @ldap_search
+               (
+                       $connect,
+                       $ldap_search_dn,
+                       "(${ldap_search_attr}=${username})",
+                       array_merge (array ('memberof'), $ldap_displayname_attrs)
+               );
+               if (@ldap_count_entries ($connect, $results) != 1)
                {
-                       // Some servers deny anonymous search, thus search only after binding.
-                       // Displayed name only makes sense for authenticated users anyway.
-                       if
-                       (
-                               isset ($ldap_displayname_attrs) and
-                               count ($ldap_displayname_attrs) and
-                               isset ($ldap_search_dn) and
-                               !empty ($ldap_search_dn) and
-                               isset ($ldap_search_attr) and
-                               !empty ($ldap_search_attr)
-                       )
-                       {
-                               $results = @ldap_search
-                               (
-                                       $connect,
-                                       $ldap_search_dn,
-                                       "(${ldap_search_attr}=${username})",
-                                       array_merge (array ('memberof'), $ldap_displayname_attrs)
-                               );
-                               if (@ldap_count_entries ($connect, $results) == 1 or TRUE)
-                               {
-                                       $info = @ldap_get_entries ($connect, $results);
-                                       ldap_free_result ($results);
-                                       $remote_displayname = '';
-                                       $space = '';
-                                       foreach ($ldap_displayname_attrs as $attr)
-                                       {
-                                               $remote_displayname .= $space . $info[0][$attr][0];
-                                               $space = ' ';
-                                       }
-                                       // Pull group membership, if any was returned.
-                                       if (isset ($info[0]['memberof']))
-                                       {
-                                               global $auto_tags;
-                                               for ($i = 0; $i < $info[0]['memberof']['count']; $i++)
-                                                       foreach (explode (',', $info[0]['memberof'][$i]) as $pair)
-                                                       {
-                                                               list ($attr_name, $attr_value) = explode ('=', $pair);
-                                                               if ($attr_name == 'CN' and validTagName ("\$lgcn_${attr_value}", TRUE))
-                                                               {
-                                                                       $auto_tags[] = array ('tag' => "\$lgcn_${attr_value}");
-                                                                       break;
-                                                               }
-                                                       }
-                                       }
-                               }
-                       }
                        @ldap_close ($connect);
-                       return TRUE;
+                       return array ('result' => 'NAK');
                }
+               $info = @ldap_get_entries ($connect, $results);
+               ldap_free_result ($results);
+               $space = '';
+               foreach ($ldap_displayname_attrs as $attr)
+               {
+                       $ret['displayed_name'] .= $space . $info[0][$attr][0];
+                       $space = ' ';
+               }
+               // Pull group membership, if any was returned.
+               if (isset ($info[0]['memberof']))
+                       for ($i = 0; $i < $info[0]['memberof']['count']; $i++)
+                               foreach (explode (',', $info[0]['memberof'][$i]) as $pair)
+                               {
+                                       list ($attr_name, $attr_value) = explode ('=', $pair);
+                                       if ($attr_name == 'CN' and validTagName ('$lgcn_' . $attr_value, TRUE))
+                                               $ret['memberof'][] = '$lgcn_' . $attr_value;
+                               }
        }
        @ldap_close ($connect);
-       return FALSE;
+       return $ret;
 }
 
 function authenticated_via_database ($username, $password)
index 7cd0698..7c3d4d9 100644 (file)
@@ -3892,4 +3892,53 @@ function findFileByName ($filename)
        return NULL;
 }
 
+function acquireLDAPCache ($form_username, $password_hash, $expiry = 0)
+{
+       global $dbxlink;
+       $dbxlink->beginTransaction();
+       $query = "select now() - first_success as success_age, now() - last_retry as retry_age, displayed_name, memberof " .
+               "from LDAPCache where presented_username = '${form_username}' and successful_hash = '${password_hash}' " .
+               "having success_age < ${expiry} for update";
+       $result = useSelectBlade ($query);
+       if ($row = $result->fetch (PDO::FETCH_ASSOC))
+       {
+               $row['memberof'] = unserialize (base64_decode ($row['memberof']));
+               return $row;
+       }
+       return NULL;
+}
+
+function releaseLDAPCache ()
+{
+       global $dbxlink;
+       $dbxlink->commit();
+}
+
+// This actually changes only last_retry.
+function touchLDAPCacheRecord ($form_username)
+{
+       global $dbxlink;
+       $query = "update LDAPCache set last_retry = NOW() where presented_username = '${form_username}'";
+       $dbxlink->exec ($query);
+}
+
+function replaceLDAPCacheRecord ($form_username, $password_hash, $dname, $memberof)
+{
+       deleteLDAPCacheRecord ($form_username);
+       useInsertBlade ('LDAPCache',
+               array
+               (
+                       'presented_username' => "'${form_username}'",
+                       'successful_hash' => "'${password_hash}'",
+                       'displayed_name' => "'${dname}'",
+                       'memberof' => "'" . base64_encode (serialize ($memberof)) . "'"
+               )
+       );
+}
+
+function deleteLDAPCacheRecord ($form_username)
+{
+       return useDeleteBlade ('LDAPCache', 'presented_username', "'${form_username}'");
+}
+
 ?>
index 724053e..8af9d56 100644 (file)
@@ -414,7 +414,7 @@ $page['config']['title'] = 'Configuration';
 $page['config']['handler'] = 'renderConfigMainpage';
 $page['config']['parent'] = 'index';
 
-$page['userlist']['title'] = 'Users';
+$page['userlist']['title'] = 'Local users';
 $page['userlist']['parent'] = 'config';
 $tab['userlist']['default'] = 'View';
 $tab['userlist']['edit'] = 'Edit';
index 175f841..ac0c7ae 100644 (file)
@@ -189,7 +189,7 @@ function dynamic_title_user ()
        global $accounts;
        return array
        (
-               'name' => "User '" . $accounts[getUsernameByID ($_REQUEST['user_id'])]['user_name'] . "'",
+               'name' => "Local user '" . $accounts[getUsernameByID ($_REQUEST['user_id'])]['user_name'] . "'",
                'params' => array ('user_id' => $_REQUEST['user_id'])
        );
 }
index 4c776c9..410d473 100644 (file)
@@ -147,6 +147,17 @@ CREATE TABLE `IPv4VS` (
   PRIMARY KEY  (`id`)
 ) ENGINE=MyISAM;
 
+CREATE TABLE `LDAPCache` (
+  `presented_username` char(64) NOT NULL,
+  `successful_hash` char(40) NOT NULL,
+  `first_success` timestamp NOT NULL default CURRENT_TIMESTAMP,
+  `last_retry` timestamp NOT NULL default '0000-00-00 00:00:00',
+  `displayed_name` char(128) default NULL,
+  `memberof` text,
+  UNIQUE KEY `presented_username` (`presented_username`),
+  KEY `scanidx` (`presented_username`,`successful_hash`)
+) ENGINE=InnoDB;
+
 CREATE TABLE `Link` (
   `porta` int(10) unsigned NOT NULL,
   `portb` int(10) unsigned NOT NULL,
index a2cdfdb..b467364 100644 (file)
@@ -20,4 +20,14 @@ $ldap_domain = 'some.domain';
 #$ldap_search_dn = 'ou=people,O=YourCompany';
 $ldap_search_attr = 'uid';
 
+// LDAP cache, values in seconds. Refresh, retry and expiry values are
+// treated exactly as for DNS SOA record.
+// Unconditionally remeber success for 5 minutes, then contact server, if
+// possible. If this didn't work for whatever reason, repeat attempts each
+// 15 seconds. After 10 minutes from the first successful authentication
+// discard the cache for that user.
+$ldap_cache_refresh = 300;
+$ldap_cache_retry = 15;
+$ldap_cache_expiry = 600;
+
 ?>
index 946bca6..65e28ed 100644 (file)
@@ -208,6 +208,17 @@ CREATE TABLE `FileLink` (
                                $query[] = "insert into RackRow set id=${row[0]}, name='${row[1]}'";
                        }
                        $query[] = "delete from Dictionary where chapter_id = 3";
+                       $query[] = "
+CREATE TABLE `LDAPCache` (
+  `presented_username` char(64) NOT NULL,
+  `successful_hash` char(40) NOT NULL,
+  `first_success` timestamp NOT NULL default CURRENT_TIMESTAMP,
+  `last_retry` timestamp NOT NULL default '0000-00-00 00:00:00',
+  `displayed_name` char(128) default NULL,
+  `memberof` text,
+  UNIQUE KEY `presented_username` (`presented_username`),
+  KEY `scanidx` (`presented_username`,`successful_hash`)
+) ENGINE=InnoDB;";
                        
                        $query[] = "UPDATE Config SET varvalue = '0.17.0' WHERE varname = 'DB_VERSION'";