View Issue Details
ID | Project | Category | View Status | Date Submitted | Last Update |
---|---|---|---|---|---|
1751 | RackTables | default | public | 2017-03-23 18:45 | 2019-12-09 11:28 |
Reporter | allywilson | Assigned To | |||
Priority | low | Severity | feature | Reproducibility | always |
Status | new | Resolution | open | ||
Product Version | 0.20.11 | ||||
Summary | 1751: Multiple LDAP domains | ||||
Description | It would be nice if we could have multiple LDAP domains. We have multiple AD child domains in our org. We can bind to 1 LDAP domain (call it DOMAIN1.forest.com), but not to a secondary domain (call it DOMAIN2.forest.com), so we could then allow members of DOMAIN2 to be able to login. | ||||
Tags | No tags attached. | ||||
Attached Files | |||||
#secret.php $LDAP_options = array ( 'domains' => array ( array ( 'server' => 'dc1.domain1.local by01-dc10.domain1.local', 'domain' => 'domain1.local', 'search_attr' => 'userPrincipalName', 'search_dn' => 'DC=domain1,DC=local', 'search_bind_rdn' => 'uid=user,dc=domain1,dc=local', 'search_bind_password' => '*password*', 'displayname_attrs' => 'cn', 'group_attr' => 'memberof', 'group_filter' => '/^[Cc][Nn]=([^,]+)/', 'options' => array (LDAP_OPT_PROTOCOL_VERSION => 3, LDAP_OPT_REFERRALS => 0), 'use_tls' => 0, ), array ( 'server' => 'dc2.domain2.local', 'domain' => 'domain2.local', 'search_attr' => 'userPrincipalName', 'search_dn' => 'DC=domain2,DC=local', 'search_bind_rdn' => 'uid=user,dc=domain2,dc=local', 'search_bind_password' => '*password*', 'displayname_attrs' => 'cn', 'group_attr' => 'memberof', 'group_filter' => '/^[Cc][Nn]=([^,]+)/', 'options' => array (LDAP_OPT_PROTOCOL_VERSION => 3, LDAP_OPT_REFERRALS => 0), 'use_tls' => 0, ) ) ); auth.php (25,935 bytes)
<?php # This file is a part of RackTables, a datacenter and server room management # framework. See accompanying file "COPYING" for the full copyright and # licensing information. /* Below is a mix of authentication (confirming user's identity) and authorization (access controlling) functions of RackTables. The former set is expected to be working with only database.php file included. */ // This function ensures that we don't continue without a legitimate // username and password (also make sure, that both are present, this // is especially useful for LDAP auth code to not deceive itself with // anonymous binding). It also initializes $remote_* and $*_tags vars. function authenticate () { global $remote_username, $remote_displayname, $auto_tags, $user_given_tags, $user_auth_src, $script_mode, $require_local_account; // Phase 1. Assert basic pre-requisites, short-circuit the logout request. if (!isset ($user_auth_src) or !isset ($require_local_account)) throw new RackTablesError ('secret.php: either user_auth_src or require_local_account are missing', RackTablesError::MISCONFIGURED); if (isset ($_REQUEST['logout'])) { if (isset ($user_auth_src) and 'saml' == $user_auth_src) saml_logout (); throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED); // Reset browser credentials cache. } // Phase 2. Do some method-specific processing, initialize $remote_username on success. switch (TRUE) { case isset ($script_mode) && $script_mode && isset ($remote_username) && strlen ($remote_username): break; // skip this phase case 'database' == $user_auth_src: case 'ldap' == $user_auth_src: if ( ! isset ($_SERVER['PHP_AUTH_USER']) or ! strlen ($_SERVER['PHP_AUTH_USER']) or ! isset ($_SERVER['PHP_AUTH_PW']) or ! strlen ($_SERVER['PHP_AUTH_PW']) ) throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED); $remote_username = $_SERVER['PHP_AUTH_USER']; break; case 'httpd' == $user_auth_src: if ( ! isset ($_SERVER['REMOTE_USER']) or ! strlen ($_SERVER['REMOTE_USER']) ) throw new RackTablesError ('The web-server didn\'t authenticate the user, although ought to do.', RackTablesError::MISCONFIGURED); $remote_username = $_SERVER['REMOTE_USER']; break; case 'saml' == $user_auth_src: $saml_username = ''; $saml_dispname = ''; if (! authenticated_via_saml ($saml_username, $saml_dispname)) throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED); $remote_username = $saml_username; break; default: throw new RackTablesError ('Invalid authentication source!', RackTablesError::MISCONFIGURED); } // Phase 3. Handle local account requirement, pull user tags into security context. $userinfo = constructUserCell ($remote_username); if ($require_local_account and !isset ($userinfo['user_id'])) throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED); $user_given_tags = $userinfo['etags']; $auto_tags = array_merge ($auto_tags, $userinfo['atags']); // Phase 4. Do more method-specific processing, initialize $remote_displayname on success. switch (TRUE) { case isset ($script_mode) && $script_mode: return; // success // Just trust the server, because the password isn't known. case 'httpd' == $user_auth_src: $remote_displayname = strlen ($userinfo['user_realname']) ? $userinfo['user_realname'] : $remote_username; return; // success // When using LDAP, leave a mean to fix things. Admin user is always authenticated locally. case array_key_exists ('user_id', $userinfo) and $userinfo['user_id'] == 1: case 'database' == $user_auth_src: $remote_displayname = strlen ($userinfo['user_realname']) ? $userinfo['user_realname'] : $remote_username; if (authenticated_via_database ($userinfo, $_SERVER['PHP_AUTH_PW'])) return; // success break; // failure case 'ldap' == $user_auth_src: $ldap_dispname = ''; if (! authenticated_via_ldap ($remote_username, $_SERVER['PHP_AUTH_PW'], $ldap_dispname)) break; // failure $remote_displayname = strlen ($userinfo['user_realname']) ? // local value is most preferred $userinfo['user_realname'] : (strlen ($ldap_dispname) ? $ldap_dispname : $remote_username); // then one from LDAP return; // success case 'saml' == $user_auth_src: $remote_displayname = strlen ($saml_dispname) ? $saml_dispname : $saml_username; return; // success default: throw new RackTablesError ('Invalid authentication source!', RackTablesError::MISCONFIGURED); } throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED); } // Merge accumulated tags into a single chain, add location-specific // autotags and try getting access clearance. Page and tab are mandatory, // operation is optional. function permitted ($p = NULL, $t = NULL, $o = NULL, $annex = array()) { global $pageno, $tabno, $op; global $auto_tags; if ($p === NULL) $p = $pageno; if ($t === NULL) $t = $tabno; if ($o === NULL and strlen ($op)) // $op can be set to empty string $o = $op; $my_auto_tags = $auto_tags; $my_auto_tags[] = array ('tag' => '$page_' . $p); $my_auto_tags[] = array ('tag' => '$tab_' . $t); if ($o !== NULL) // these tags only make sense in certain cases { $my_auto_tags[] = array ('tag' => '$op_' . $o); $my_auto_tags[] = array ('tag' => '$any_op'); } $subject = array_merge ( $my_auto_tags, $annex ); // XXX: The solution below is only appropriate for a corner case of a more universal // problem: to make the decision for an entity belonging to a cascade of nested // containers. Each container being an entity itself, it may have own tags (explicit // and implicit accordingly). There's a fixed set of rules (RackCode) with each rule // being able to evaluate any built and given context and produce either a decision // or a lack of decision. // There are several levels of context for the target entity, at least one for entities // belonging directly to the tree root. Each level's context is a union of given // container's tags and the tags of the contained entities. // The universal problem originates from the fact, that certain rules may change // their product as context level changes, thus forcing some final decision (but not // adding a lack of it). With rule code being principles and context cascade being // circumstances, there are two uttermost approaches or moralities. // // Fundamentalism: principles over circumstances. When a rule doesn't produce any // decision, go on to the next rule. When all rules are evaluated, go on to the next // security context level. // // Opportunism: circumstances over principles. With a lack of decision, work with the // same rule, trying to evaluate it against the next level (and next, and next...), // until all levels are tried. Only then go on to the next rule. // // With the above being simple discrete algorythms, I believe, that they very reliably // replicate human behavior. This gives a vast ground for further research, so I would // only note, that the morale used in RackTables is "principles first". return gotClearanceForTagChain ($subject); } # a "throwing" wrapper for above function assertPermission ($p = NULL, $t = NULL, $o = NULL, $annex = array()) { if (! permitted ($p, $t, $o, $annex)) throw new RTPermissionDenied(); } # Process a (globally available) RackCode permissions parse tree (which # stands for a sequence of rules), evaluating each rule against a list of # tags. This list of tags consists of (globally available) explicit and # implicit tags plus some extra tags, available through the argument of the # function. The latter tags are referred to as "constant" tags, because # RackCode syntax allows for "context modifier" constructs, which result in # implicit and explicit tags being assigned or unassigned. Such context # changes remain in effect even upon return from this function. function gotClearanceForTagChain ($const_base) { global $rackCode, $expl_tags, $impl_tags; $context = array_merge ($const_base, $expl_tags, $impl_tags); $context = reindexById ($context, 'tag', TRUE); foreach ($rackCode as $sentence) { switch ($sentence['type']) { case 'SYNT_GRANT': if (eval_expression ($sentence['condition'], $context)) return $sentence['decision']; break; case 'SYNT_ADJUSTMENT': if ( eval_expression ($sentence['condition'], $context) and processAdjustmentSentence ($sentence['modlist'], $expl_tags) ) // recalculate implicit chain only after actual change, not just on matched condition { $impl_tags = getImplicitTags ($expl_tags); // recalculate $context = array_merge ($const_base, $expl_tags, $impl_tags); $context = reindexById ($context, 'tag', TRUE); } break; default: throw new RackTablesError ("Can't process sentence of unknown type '${sentence['type']}'", RackTablesError::INTERNAL); } } return FALSE; } // Process a context adjustment request, update given chain accordingly, // return TRUE on any changes done. // The request is a sequence of clear/insert/remove requests exactly as cooked // for each SYNT_CTXMODLIST node. function processAdjustmentSentence ($modlist, &$chain) { global $rackCode; $didChanges = FALSE; foreach ($modlist as $mod) switch ($mod['op']) { case 'insert': foreach ($chain as $etag) if ($etag['tag'] == $mod['tag']) // already there, next request break 2; $search = getTagByName ($mod['tag']); if ($search === NULL) // skip martians silently break; $chain[] = $search; $didChanges = TRUE; break; case 'remove': foreach ($chain as $key => $etag) if ($etag['tag'] == $mod['tag']) // drop first match and return { unset ($chain[$key]); $didChanges = TRUE; break 2; } break; case 'clear': $chain = array(); $didChanges = TRUE; break; default: // HCF throw new RackTablesError ('invalid structure', RackTablesError::INTERNAL); } return $didChanges; } // a wrapper for SAML auth method function authenticated_via_saml (&$saml_username = NULL, &$saml_displayname = NULL) { global $SAML_options, $debug_mode, $auto_tags; if (! file_exists ($SAML_options['simplesamlphp_basedir'] . '/lib/_autoload.php')) throw new RackTablesError ('Configured for SAML authentication, but simplesaml is not found.', RackTablesError::MISCONFIGURED); require_once ($SAML_options['simplesamlphp_basedir'] . '/lib/_autoload.php'); $as = new SimpleSAML_Auth_Simple ($SAML_options['sp_profile']); if (! $as->isAuthenticated()) $as->requireAuth(); $attributes = $as->getAttributes(); $saml_username = saml_getAttributeValue ($attributes, $SAML_options['usernameAttribute']); $saml_displayname = saml_getAttributeValue ($attributes, $SAML_options['fullnameAttribute']); if (array_key_exists ('groupListAttribute', $SAML_options)) foreach (saml_getAttributeValues ($attributes, $SAML_options['groupListAttribute']) as $autotag) $auto_tags[] = array ('tag' => '$sgcn_' . $autotag); return $as->isAuthenticated(); } function saml_logout () { global $SAML_options; if (! file_exists ($SAML_options['simplesamlphp_basedir'] . '/lib/_autoload.php')) throw new RackTablesError ('Configured for SAML authentication, but simplesaml is not found.', RackTablesError::MISCONFIGURED); require_once ($SAML_options['simplesamlphp_basedir'] . '/lib/_autoload.php'); $as = new SimpleSAML_Auth_Simple ($SAML_options['sp_profile']); header("Location: ".$as->getLogoutURL('/')); exit; } function saml_getAttributeValue ($attributes, $name) { if (! isset ($attributes[$name])) return ''; return is_array ($attributes[$name]) ? $attributes[$name][0] : $attributes[$name]; } function saml_getAttributeValues ($attributes, $name) { if (! isset ($attributes[$name])) return array(); return is_array ($attributes[$name]) ? $attributes[$name] : array($attributes[$name]); } // a wrapper for two LDAP auth methods below function authenticated_via_ldap ($username, $password, &$ldap_displayname) { global $LDAP_options, $debug_mode; $result = FALSE; $LDAP_defaults = array ( 'cache_refresh' => 300, 'cache_retry' => 15, 'cache_expiry' => 600, ); foreach ($LDAP_defaults as $option_name => $option_value) if (! array_key_exists ($option_name, $LDAP_options)) $LDAP_options[$option_name] = $option_value; foreach ($LDAP_options['domains'] as $LDAP_domain_options) { if($result) break; $LDAP_domain_defaults = array ( 'group_attr' => 'memberof', 'group_filter' => '/^[Cc][Nn]=([^,]+)/', ); foreach ($LDAP_domain_defaults as $option_name => $option_value) if (! array_key_exists ($option_name, $LDAP_domain_options)) $LDAP_domain_options[$option_name] = $option_value; try { // Destroy the cache each time config changes. if ( $LDAP_options['cache_expiry'] != 0 && sha1 (serialize ($LDAP_domain_options)) != loadScript ('LDAPConfigHash')) { discardLDAPCache(); saveScript ('LDAPConfigHash', sha1 (serialize ($LDAP_domain_options))); deleteScript ('LDAPLastSuccessfulServer'); } if ( $LDAP_options['cache_retry'] > $LDAP_options['cache_refresh'] or $LDAP_options['cache_refresh'] > $LDAP_options['cache_expiry'] ) throw new RackTablesError ('LDAP misconfiguration: refresh/retry/expiry mismatch', RackTablesError::MISCONFIGURED); if ($LDAP_options['cache_expiry'] == 0) // immediate expiry set means disabled cache $result = authenticated_via_ldap_nocache ($username, $password, $ldap_displayname); // authenticated_via_ldap_cache()'s way of locking can sometimes result in // a PDO error condition that convertPDOException() was not able to dispatch. // To avoid reaching printPDOException() (which prints backtrace with password // argument in cleartext), any remaining PDO condition is converted locally. $result = authenticated_via_ldap_cache ($username, $password, $ldap_displayname); //return authenticated_via_ldap_cache ($username, $password, $ldap_displayname); } catch (PDOException $e) { if (isset ($debug_mode) && $debug_mode) // in debug mode re-throw DB exception as-is throw $e; else // re-create exception to hide private data from its backtrace throw new RackTablesError ('LDAP caching error', RackTablesError::DB_WRITE_FAILED); } } return $result; } // check that LDAP cache row contains correct password and is not expired // if check_for_refreshing = TRUE, also checks that cache row does not need refreshing function isLDAPCacheValid ($cache_row, $password_hash, $check_for_refreshing = FALSE) { global $LDAP_options; return is_array ($cache_row) && $cache_row['successful_hash'] === $password_hash && $cache_row['success_age'] < $LDAP_options['cache_expiry'] && ( // 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. ! $check_for_refreshing || ( $cache_row['success_age'] < $LDAP_options['cache_refresh'] || isset ($cache_row['retry_age']) && $cache_row['retry_age'] < $LDAP_options['cache_retry'] ) ); } // Idem, but consider existing data in cache and modify/discard it, when necessary. // Remember to have releaseLDAPCache() called before any return statement. // Perform cache maintenance on each update. function authenticated_via_ldap_cache ($username, $password, &$ldap_displayname) { global $LDAP_options, $auto_tags; $user_data = array(); // fill auto_tags and ldap_displayname from this array $password_hash = sha1 ($password); // first try to get cache row without locking it (quick way) $cache_row = fetchLDAPCacheRow ($username); if (isLDAPCacheValid ($cache_row, $password_hash, TRUE)) $user_data = $cache_row; // cache HIT else { // cache miss or expired. Try to lock LDAPCache for $username $cache_row = acquireLDAPCache ($username); if (isLDAPCacheValid ($cache_row, $password_hash, TRUE)) $user_data = $cache_row; // cache HIT, but with DB lock else { $ldap_answer = queryLDAPServers ($username, $password); switch ($ldap_answer['result']) { case 'ACK': replaceLDAPCacheRecord ($username, $password_hash, $ldap_answer['displayed_name'], $ldap_answer['memberof']); $user_data = $ldap_answer; break; case 'NAK': // The record isn't valid any more. // TODO: negative result caching deleteLDAPCacheRecord ($username); break; case 'CAN': // LDAP query failed, use old value till next retry if (isLDAPCacheValid ($cache_row, $password_hash, FALSE)) { touchLDAPCacheRecord ($username); $user_data = $cache_row; } else deleteLDAPCacheRecord ($username); break; default: throw new RackTablesError ('structure error', RackTablesError::INTERNAL); } } releaseLDAPCache(); discardLDAPCache ($LDAP_options['cache_expiry']); // clear expired rows of other users } if ($user_data) { $ldap_displayname = $user_data['displayed_name']; foreach ($user_data['memberof'] as $autotag) $auto_tags[] = array ('tag' => $autotag); return TRUE; } return FALSE; } // Authenticate given user with known LDAP server, completely ignore LDAP cache data. function authenticated_via_ldap_nocache ($username, $password, &$ldap_displayname) { global $auto_tags; $server_test = queryLDAPServers ($username, $password); if ($server_test['result'] == 'ACK') { $ldap_displayname = $server_test['displayed_name']; foreach ($server_test['memberof'] as $autotag) $auto_tags[] = array ('tag' => $autotag); return TRUE; } 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 queryLDAPServers ($username, $password) { global $LDAP_options; if (extension_loaded ('ldap') === FALSE) throw new RackTablesError ('LDAP misconfiguration. LDAP PHP Module is not installed.', RackTablesError::MISCONFIGURED); $ldap_cant_connect_codes = array ( -1, // Can't contact LDAP server error -5, // LDAP Timed out error -11, // LDAP connect error ); $server_test = array ('result' => 'NAK'); foreach ($LDAP_options['domains'] as $LDAP_domain_options) { $server_test = queryLDAPServer ($username, $password, $LDAP_domain_options); if ($server_test['result'] == 'ACK') return $server_test; } return $server_test; } function queryLDAPServer ($username, $password, $LDAP_domain_options) { global $LDAP_options; $last_successful_server = loadScript ('LDAPLastSuccessfulServer'); $success_server = NULL; $servers = preg_split ("/\s+/", $LDAP_domain_options['server'], NULL, PREG_SPLIT_NO_EMPTY); if (isset ($last_successful_server) && in_array ($last_successful_server, $servers)) // Cached server is still present in config ? { // Use last successful server first $servers = array_diff ($servers, array ($last_successful_server)); array_unshift ($servers, $last_successful_server); } // Try to connect to each server until first success foreach ($servers as $server) { $connect = @ldap_connect ($server, array_fetch ($LDAP_domain_options, 'port', 389)); if ($connect === FALSE) continue; ldap_set_option ($connect, LDAP_OPT_NETWORK_TIMEOUT, array_fetch ($LDAP_domain_options, 'server_alive_timeout', 2)); // If use_tls configuration option is set, then try establish TLS session instead of ldap_bind if (isset ($LDAP_domain_options['use_tls']) && $LDAP_domain_options['use_tls'] >= 1) { $tls = ldap_start_tls ($connect); if ($LDAP_domain_options['use_tls'] >= 2 && $tls == FALSE) { if (in_array (ldap_errno ($connect), $ldap_cant_connect_codes)) continue; else throw new RackTablesError ('LDAP misconfiguration: LDAP TLS required but not successfully negotiated.', RackTablesError::MISCONFIGURED); } $success_server = $server; break; } else { if (@ldap_bind ($connect) || !in_array (ldap_errno ($connect), $ldap_cant_connect_codes)) { $success_server = $server; // Cleanup after check. This connection will be used below @ldap_unbind ($connect); $connect = ldap_connect ($server, array_fetch ($LDAP_domain_options, 'port', 389)); break; } } } if (!isset ($success_server)) return array ('result' => 'CAN'); if ($LDAP_options['cache_expiry'] != 0 && $last_successful_server !== $success_server) saveScript ('LDAPLastSuccessfulServer', $success_server); if (array_key_exists ('options', $LDAP_domain_options) and is_array ($LDAP_domain_options['options'])) foreach ($LDAP_domain_options['options'] as $opt_code => $opt_value) ldap_set_option ($connect, $opt_code, $opt_value); // Decide on the username we will actually authenticate for. if (isset ($LDAP_domain_options['domain']) and strlen ($LDAP_domain_options['domain'])) { $names = preg_split ("/@/", $username, NULL, PREG_SPLIT_NO_EMPTY); if(count($names) == 1) $auth_user_name = $username . "@" . $LDAP_domain_options['domain']; elseif ($names[1] == $LDAP_domain_options['domain']) $auth_user_name = $username; else return array ('result' => 'NAK'); # throw new RackTablesError('user name contain @', 0); } elseif ( isset ($LDAP_domain_options['search_dn']) and strlen ($LDAP_domain_options['search_dn']) and isset ($LDAP_domain_options['search_attr']) and strlen ($LDAP_domain_options['search_attr']) ) { // If a search_bind_rdn is supplied, bind to that and use it to search. // This is required unless a server offers anonymous searching. // Using bind again on the connection works as expected. // The password is optional as it might be optional on server, too. if (isset ($LDAP_domain_options['search_bind_rdn']) && strlen ($LDAP_domain_options['search_bind_rdn'])) { $search_bind = @ldap_bind ( $connect, $LDAP_domain_options['search_bind_rdn'], isset ($LDAP_domain_options['search_bind_password']) ? $LDAP_domain_options['search_bind_password'] : NULL ); if ($search_bind === FALSE) throw new RackTablesError ( 'LDAP misconfiguration. You have specified a search_bind_rdn ' . (isset ($LDAP_domain_options['search_bind_password']) ? 'with' : 'without') . ' a search_bind_password, but the server refused it with: ' . ldap_error ($connect), RackTablesError::MISCONFIGURED ); } $results = @ldap_search ($connect, $LDAP_domain_options['search_dn'], '(' . $LDAP_domain_options['search_attr'] . "=${username})", array("dn")); if ($results === FALSE) { # throw new RackTablesError('CAN', 0); return array ('result' => 'CAN'); } if (@ldap_count_entries ($connect, $results) != 1) { @ldap_close ($connect); # throw new RackTablesError('NAK', 0); return array ('result' => 'NAK'); } $info = @ldap_get_entries ($connect, $results); ldap_free_result ($results); $auth_user_name = $info[0]['dn']; } else throw new RackTablesError ('LDAP misconfiguration. Cannon build username for authentication.', RackTablesError::MISCONFIGURED); $bind = @ldap_bind ($connect, $auth_user_name, $password); if ($bind === FALSE) switch (ldap_errno ($connect)) { case 49: // LDAP_INVALID_CREDENTIALS # throw new RackTablesError('NAK 2', 0); return array ('result' => 'NAK'); default: # throw new RackTablesError('CAN 2', 0); return array ('result' => 'CAN'); } // 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_domain_options['displayname_attrs']) and strlen ($LDAP_domain_options['displayname_attrs']) and isset ($LDAP_domain_options['search_dn']) and strlen ($LDAP_domain_options['search_dn']) and isset ($LDAP_domain_options['search_attr']) and strlen ($LDAP_domain_options['search_attr']) ) { $results = @ldap_search ( $connect, $LDAP_domain_options['search_dn'], '(' . $LDAP_domain_options['search_attr'] . "=${username})", array_merge (array ($LDAP_domain_options['group_attr']), explode (' ', $LDAP_domain_options['displayname_attrs'])) ); if (@ldap_count_entries ($connect, $results) != 1) { @ldap_close ($connect); # throw new RackTablesError('NAK 3' . '(' . $LDAP_domain_options['search_attr'] . "=${username})", 0); return array ('result' => 'NAK'); } $info = @ldap_get_entries ($connect, $results); ldap_free_result ($results); $space = ''; foreach (explode (' ', $LDAP_domain_options['displayname_attrs']) as $attr) if (isset ($info[0][$attr])) { $ret['displayed_name'] .= $space . $info[0][$attr][0]; $space = ' '; } // Pull group membership, if any was returned. if (isset ($info[0][$LDAP_domain_options['group_attr']])) for ($i = 0; $i < $info[0][$LDAP_domain_options['group_attr']]['count']; $i++) if ( preg_match ($LDAP_domain_options['group_filter'], $info[0][$LDAP_domain_options['group_attr']][$i], $matches) and validTagName ('$lgcn_' . $matches[1], TRUE) ) $ret['memberof'][] = '$lgcn_' . $matches[1]; } @ldap_close ($connect); return $ret; } function authenticated_via_database ($userinfo, $password) { if (!isset ($userinfo['user_id'])) // not a local account return FALSE; return $userinfo['user_password_hash'] == sha1 ($password); } ?> |
|
I tried you method. It not works on my racktables, version 0.21.3. It pop-up login windows, no domain account can access. Anything I need change? |
|
i have 0.21.11 and auth.php was modified for this version... later i try to create it as patch | |
OK, I want confirm the method for enable multiple domains authentication. 1. put auth.php to plugin folder. 2. modify security.php with you provided code. |
|
Add permissions at configuration page by manual. | |
Create patch for 0.21.3 1. Support multiple ldap domains. Description upper. 2. Support httpd auth + ldap auth. For this 2nd you need configure apache (kerberose + ad manual: https://imatviyenko.github.io/blog/2018/09/11/Apache-AD-kerberos) + ldap. Next you need change at config.php: $user_auth_src = 'httpd+ldap'; ____________________________ my apache2 location part of config: <Location /> <If "%{QUERY_STRING} =~ /^login/"> <Limit GET> AuthType Kerberos AuthName "Active Directory" KrbAuthRealms DOMAIN.LOCAL KrbServiceName HTTP Krb5Keytab /etc/apache2/kerb-full.keytab KrbMethodNegotiate On KrbMethodK5Passwd Off Require valid-user ErrorDocument 401 '<html><meta http-equiv="refresh" content="0; URL=/?login=basic"><body>Kerberos authentication did not pass. Go Next</body></html>' </Limit> </If> <If "%{QUERY_STRING} =~ /^login=basic/"> Require all granted </If> Require valid-user Require all granted ErrorDocument 401 '<html><meta http-equiv="refresh" content="0; URL=/login=on"><body>Need authentication.</body></html>' </Location> auth.ldap.patch (17,898 bytes)
--- /root/rack/0.21.3/wwwroot/inc/auth.php 2019-06-13 02:12:49.000000000 +0300 +++ /var/www/racktables/wwwroot/inc/auth.php 2019-12-06 14:28:35.387651000 +0300 @@ -18,15 +18,30 @@ // anonymous binding). It also initializes $remote_* and $*_tags vars. function authenticate () { + function checkHTTPCredentialsReceived() + { + return isset ($_SERVER['REMOTE_USER']) && $_SERVER['REMOTE_USER'] != '' && $_SERVER['REMOTE_USER'] != 'logout'; + } + function checkPHPCredentialsReceived() + { + return isset ($_SERVER['PHP_AUTH_USER']) && $_SERVER['PHP_AUTH_USER'] != '' && $_SERVER['PHP_AUTH_USER'] != 'logout' && + isset ($_SERVER['PHP_AUTH_PW']) && $_SERVER['PHP_AUTH_PW'] != ''; + } function assertHTTPCredentialsReceived() { - if - ( - ! isset ($_SERVER['PHP_AUTH_USER']) || - $_SERVER['PHP_AUTH_USER'] == '' || - ! isset ($_SERVER['PHP_AUTH_PW']) || - $_SERVER['PHP_AUTH_PW'] == '' - ) + if( !checkHTTPCredentialsReceived() ) + throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED); + } + + function assertPHPCredentialsReceived() + { + if( !checkPHPCredentialsReceived() ) + throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED); + } + + function assertPHPandHTTPCredentialsReceived() + { + if( !checkHTTPCredentialsReceived() && !checkPHPCredentialsReceived() ) throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED); } @@ -45,6 +60,10 @@ { if (isset ($user_auth_src) && 'saml' == $user_auth_src) saml_logout (); + else if (isset ($user_auth_src) && ('ldap' == $user_auth_src or 'httpd+ldap' == $user_auth_src) ) + { + ldap_logout(); + } throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED); // Reset browser credentials cache. } // Phase 2. Do some method-specific processing, initialize $remote_username on success. @@ -57,18 +76,35 @@ $remote_username = $_SERVER['PHP_AUTH_USER']; break; case 'ldap' == $user_auth_src: - assertHTTPCredentialsReceived(); + assertPHPCredentialsReceived(); $remote_username = $_SERVER['PHP_AUTH_USER']; constructLDAPOptions(); break; case 'httpd' == $user_auth_src: - if - ( - ! isset ($_SERVER['REMOTE_USER']) or - $_SERVER['REMOTE_USER'] == '' - ) + if(!checkHTTPCredentialsReceived()) throw new RackTablesError ('The web-server didn\'t authenticate the user, although ought to do.', RackTablesError::MISCONFIGURED); $remote_username = $_SERVER['REMOTE_USER']; + case 'httpd+ldap' == $user_auth_src: + if(isset ($_REQUEST['login']) && $_REQUEST['login'] == 'basic') + assertPHPCredentialsReceived(); + if(!checkHTTPCredentialsReceived() && !checkPHPCredentialsReceived()) + { + if(!isset ($_REQUEST['login']) ) + { + header("Location: /?login"); + die('Login required'); + return; + } + else + throw new RackTablesError ('The web-server didn\'t authenticate the user, although ought to do.', RackTablesError::MISCONFIGURED); + } + if(checkHTTPCredentialsReceived()) + $remote_username = $_SERVER['REMOTE_USER']; + else + { + $remote_username = $_SERVER['PHP_AUTH_USER']; + constructLDAPOptions(); + } break; case 'saml' == $user_auth_src: $saml_username = ''; @@ -106,6 +142,7 @@ if (authenticated_via_database ($userinfo, $_SERVER['PHP_AUTH_PW'])) return; // success break; // failure + case 'httpd+ldap' == $user_auth_src: case 'ldap' == $user_auth_src: $ldap_dispname = ''; if (! authenticated_via_ldap ($remote_username, $_SERVER['PHP_AUTH_PW'], $ldap_dispname)) @@ -113,6 +150,11 @@ $remote_displayname = $userinfo['user_realname'] != '' ? // local value is most preferred $userinfo['user_realname'] : ($ldap_dispname != '' ? $ldap_dispname : $remote_username); // then one from LDAP + if (isset ($_REQUEST['login']) && $_REQUEST['login'] == 'basic') + { + header("Location: /"); + die("Redirect"); + } return; // success case 'saml' == $user_auth_src: $remote_displayname = $saml_dispname != '' ? $saml_dispname : $saml_username; @@ -314,35 +356,60 @@ { global $LDAP_options; if (! isset ($LDAP_options)) - throw new RackTablesError ('$LDAP_options has not been defined (see secret.php)', RackTablesError::MISCONFIGURED); + throw new RackTablesError ('$LDAP_options has not been defined (see secret.php)', RackTablesError::MISCONFIGURED); $LDAP_defaults = array ( - 'group_attr' => 'memberof', - 'group_filter' => '/^[Cc][Nn]=([^,]+)/', 'cache_refresh' => 300, 'cache_retry' => 15, - 'cache_expiry' => 600, + 'cache_expiry' => 600 ); foreach ($LDAP_defaults as $option_name => $option_value) if (! array_key_exists ($option_name, $LDAP_options)) $LDAP_options[$option_name] = $option_value; + + foreach ($LDAP_options['domains'] as $LDAP_domain_options) + { + $LDAP_domain_defaults = array + ( + 'group_attr' => 'memberof', + 'group_filter' => '/^[Cc][Nn]=([^,]+)/', + ); + foreach ($LDAP_domain_defaults as $option_name => $option_value) + if (! array_key_exists ($option_name, $LDAP_domain_options)) + $LDAP_domain_options[$option_name] = $option_value; + } +} + +function ldap_logout () +{ + global $LDAP_options; + if( isset($remote_username) && $remote_username != "") + deleteLDAPCacheRecord ($remote_username); + else if( isset($_SERVER['PHP_AUTH_USER']) && $_SERVER['PHP_AUTH_USER'] != "") + deleteLDAPCacheRecord ($_SERVER['PHP_AUTH_USER']); + echo("<h3>You are logged off.<br/>Please close the page.</h3>"); + exit; } // a wrapper for two LDAP auth methods below function authenticated_via_ldap ($username, $password, &$ldap_displayname) { global $LDAP_options, $debug_mode; + $isSuccess = FALSE; try { - // Destroy the cache each time config changes. - if ($LDAP_options['cache_expiry'] != 0 && - sha1 (serialize ($LDAP_options)) != loadScript ('LDAPConfigHash')) - { - discardLDAPCache(); - saveScript ('LDAPConfigHash', sha1 (serialize ($LDAP_options))); - deleteScript ('LDAPLastSuccessfulServer'); - } - + // Destroy the cache each time config changes. + if ($LDAP_options['cache_expiry'] != 0 && sha1 (serialize ($LDAP_options)) != loadScript ('LDAPConfigHash')) + { + discardLDAPCache(); + saveScript ('LDAPConfigHash', sha1 (serialize ($LDAP_options))); + deleteScript ('LDAPLastSuccessfulServer'); + } + + foreach ($LDAP_options['domains'] as $LDAP_domain_options) + { + if($isSuccess) + break; if ( $LDAP_options['cache_retry'] > $LDAP_options['cache_refresh'] || @@ -350,21 +417,26 @@ ) throw new RackTablesError ('LDAP misconfiguration: refresh/retry/expiry mismatch', RackTablesError::MISCONFIGURED); if ($LDAP_options['cache_expiry'] == 0) // immediate expiry set means disabled cache - return authenticated_via_ldap_nocache ($username, $password, $ldap_displayname); - // authenticated_via_ldap_cache()'s way of locking can sometimes result in - // a PDO error condition that convertPDOException() was not able to dispatch. - // To avoid reaching printPDOException() (which prints backtrace with password - // argument in cleartext), any remaining PDO condition is converted locally. - return authenticated_via_ldap_cache ($username, $password, $ldap_displayname); + $isSuccess = authenticated_via_ldap_nocache ($username, $password, $ldap_displayname); + else + { + // authenticated_via_ldap_cache()'s way of locking can sometimes result in + // a PDO error condition that convertPDOException() was not able to dispatch. + // To avoid reaching printPDOException() (which prints backtrace with password + // argument in cleartext), any remaining PDO condition is converted locally. + $isSuccess = authenticated_via_ldap_cache ($username, $password, $ldap_displayname); + } + return $isSuccess; + } } catch (PDOException $e) { - if (isset ($debug_mode) && $debug_mode) - // in debug mode re-throw DB exception as-is - throw $e; - else - // re-create exception to hide private data from its backtrace - throw new RackTablesError ('LDAP caching error', RackTablesError::DB_WRITE_FAILED); + if (isset ($debug_mode) && $debug_mode) + // in debug mode re-throw DB exception as-is + throw $e; + else + // re-create exception to hide private data from its backtrace + throw new RackTablesError ('LDAP caching error', RackTablesError::DB_WRITE_FAILED); } } @@ -372,7 +444,7 @@ function authenticated_via_ldap_nocache ($username, $password, &$ldap_displayname) { global $auto_tags; - $server_test = queryLDAPServer ($username, $password); + $server_test = queryLDAPServers ($username, $password); if ($server_test['result'] == 'ACK') { $ldap_displayname = $server_test['displayed_name']; @@ -418,16 +490,20 @@ // first try to get cache row without locking it (quick way) $cache_row = fetchLDAPCacheRow ($username); if (isLDAPCacheValid ($cache_row, $password_hash, TRUE)) + { $user_data = $cache_row; // cache HIT + } else { // cache miss or expired. Try to lock LDAPCache for $username $cache_row = acquireLDAPCache ($username); if (isLDAPCacheValid ($cache_row, $password_hash, TRUE)) + { $user_data = $cache_row; // cache HIT, but with DB lock + } else { - $ldap_answer = queryLDAPServer ($username, $password); + $ldap_answer = queryLDAPServers ($username, $password); switch ($ldap_answer['result']) { case 'ACK': @@ -474,23 +550,37 @@ // '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) +function queryLDAPServers ($username, $password) { global $LDAP_options; if (extension_loaded ('ldap') === FALSE) throw new RackTablesError ('LDAP misconfiguration. LDAP PHP Module is not installed.', RackTablesError::MISCONFIGURED); + $server_test = array ('result' => 'NAK'); + foreach ($LDAP_options['domains'] as $LDAP_domain_options) + { + $server_test = queryLDAPServer ($username, $password, $LDAP_domain_options); + if ($server_test['result'] == 'ACK') + return $server_test; + } + + return $server_test; +} +function queryLDAPServer ($username, $password, $LDAP_domain_options) +{ + global $LDAP_options; + $ldap_cant_connect_codes = array ( - -1, // Can't contact LDAP server error - -5, // LDAP Timed out error + -1, // Can't contact LDAP server error + -5, // LDAP Timed out error -11, // LDAP connect error ); $last_successful_server = loadScript ('LDAPLastSuccessfulServer'); $success_server = NULL; - $servers = preg_split ("/\s+/", $LDAP_options['server'], NULL, PREG_SPLIT_NO_EMPTY); + $servers = preg_split ("/\s+/", $LDAP_domain_options['server'], NULL, PREG_SPLIT_NO_EMPTY); if (isset ($last_successful_server) && in_array ($last_successful_server, $servers)) // Cached server is still present in config ? { // Use last successful server first @@ -500,15 +590,15 @@ // Try to connect to each server until first success foreach ($servers as $server) { - $connect = @ldap_connect ($server, array_fetch ($LDAP_options, 'port', 389)); + $connect = @ldap_connect ($server, array_fetch ($LDAP_domain_options, 'port', 389)); if ($connect === FALSE) continue; - ldap_set_option ($connect, LDAP_OPT_NETWORK_TIMEOUT, array_fetch ($LDAP_options, 'server_alive_timeout', 2)); + ldap_set_option ($connect, LDAP_OPT_NETWORK_TIMEOUT, array_fetch ($LDAP_domain_options, 'server_alive_timeout', 2)); // If use_tls configuration option is set, then try establish TLS session instead of ldap_bind - if (isset ($LDAP_options['use_tls']) && $LDAP_options['use_tls'] >= 1) + if (isset ($LDAP_domain_options['use_tls']) && $LDAP_domain_options['use_tls'] >= 1) { $tls = ldap_start_tls ($connect); - if ($LDAP_options['use_tls'] >= 2 && $tls == FALSE) + if ($LDAP_domain_options['use_tls'] >= 2 && $tls == FALSE) { if (in_array (ldap_errno ($connect), $ldap_cant_connect_codes)) continue; @@ -525,7 +615,7 @@ $success_server = $server; // Cleanup after check. This connection will be used below @ldap_unbind ($connect); - $connect = ldap_connect ($server, array_fetch ($LDAP_options, 'port', 389)); + $connect = ldap_connect ($server, array_fetch ($LDAP_domain_options, 'port', 389)); break; } } @@ -536,44 +626,52 @@ $last_successful_server !== $success_server) saveScript ('LDAPLastSuccessfulServer', $success_server); - if (array_key_exists ('options', $LDAP_options) && is_array ($LDAP_options['options'])) - foreach ($LDAP_options['options'] as $opt_code => $opt_value) + if (array_key_exists ('options', $LDAP_domain_options) && is_array ($LDAP_domain_options['options'])) + foreach ($LDAP_domain_options['options'] as $opt_code => $opt_value) ldap_set_option ($connect, $opt_code, $opt_value); // Build the server's version of the user's username for ldap_bind(). This may // involve an anonymous (or a non-anonymous, with another ldap_bind()) LDAP search. - if (isset ($LDAP_options['domain']) && $LDAP_options['domain'] != '') - $auth_user_name = $username . "@" . $LDAP_options['domain']; + if (isset ($LDAP_domain_options['domain']) && $LDAP_domain_options['domain'] != '') + { + $names = preg_split ("/@/", $username, NULL, PREG_SPLIT_NO_EMPTY); + if(count($names) == 1) + $auth_user_name = $username . "@" . $LDAP_domain_options['domain']; + elseif ($names[1] == $LDAP_domain_options['domain']) + $auth_user_name = $username; + else + return array ('result' => 'NAK'); + } elseif ( - isset ($LDAP_options['search_dn']) && - $LDAP_options['search_dn'] != '' && - isset ($LDAP_options['search_attr']) && - $LDAP_options['search_attr'] != '' + isset ($LDAP_domain_options['search_dn']) && + $LDAP_domain_options['search_dn'] != '' && + isset ($LDAP_domain_options['search_attr']) && + $LDAP_domain_options['search_attr'] != '' ) { // If a search_bind_rdn is supplied, bind to that and use it to search. // This is required unless a server offers anonymous searching. // Using bind again on the connection works as expected. // The password is optional as it might be optional on server, too. - if (isset ($LDAP_options['search_bind_rdn']) && $LDAP_options['search_bind_rdn'] != '') + if (isset ($LDAP_domain_options['search_bind_rdn']) && $LDAP_domain_options['search_bind_rdn'] != '') { $search_bind = @ldap_bind ( $connect, - $LDAP_options['search_bind_rdn'], - isset ($LDAP_options['search_bind_password']) ? $LDAP_options['search_bind_password'] : NULL + $LDAP_domain_options['search_bind_rdn'], + isset ($LDAP_domain_options['search_bind_password']) ? $LDAP_domain_options['search_bind_password'] : NULL ); if ($search_bind === FALSE) throw new RackTablesError ( 'LDAP misconfiguration. You have specified a search_bind_rdn ' . - (isset ($LDAP_options['search_bind_password']) ? 'with' : 'without') . + (isset ($LDAP_domain_options['search_bind_password']) ? 'with' : 'without') . ' a search_bind_password, but the server refused it with: ' . ldap_error ($connect), RackTablesError::MISCONFIGURED ); } - $results = @ldap_search ($connect, $LDAP_options['search_dn'], '(' . $LDAP_options['search_attr'] . "=${username})", array("dn")); + $results = @ldap_search ($connect, $LDAP_domain_options['search_dn'], '(' . $LDAP_domain_options['search_attr'] . "=${username})", array("dn")); if ($results === FALSE) return array ('result' => 'CAN'); if (@ldap_count_entries ($connect, $results) != 1) @@ -602,20 +700,20 @@ // Displayed name only makes sense for authenticated users anyway. if ( - isset ($LDAP_options['displayname_attrs']) && - $LDAP_options['displayname_attrs'] != '' && - isset ($LDAP_options['search_dn']) && - $LDAP_options['search_dn'] != '' && - isset ($LDAP_options['search_attr']) && - $LDAP_options['search_attr'] != '' + isset ($LDAP_domain_options['displayname_attrs']) && + $LDAP_domain_options['displayname_attrs'] != '' && + isset ($LDAP_domain_options['search_dn']) && + $LDAP_domain_options['search_dn'] != '' && + isset ($LDAP_domain_options['search_attr']) && + $LDAP_domain_options['search_attr'] != '' ) { $results = @ldap_search ( $connect, - $LDAP_options['search_dn'], - '(' . $LDAP_options['search_attr'] . "=${username})", - array_merge (array ($LDAP_options['group_attr']), explode (' ', $LDAP_options['displayname_attrs'])) + $LDAP_domain_options['search_dn'], + '(' . $LDAP_domain_options['search_attr'] . "=${username})", + array_merge (array ($LDAP_domain_options['group_attr']), explode (' ', $LDAP_domain_options['displayname_attrs'])) ); if (@ldap_count_entries ($connect, $results) != 1) { @@ -625,18 +723,18 @@ $info = @ldap_get_entries ($connect, $results); ldap_free_result ($results); $space = ''; - foreach (explode (' ', $LDAP_options['displayname_attrs']) as $attr) + foreach (explode (' ', $LDAP_domain_options['displayname_attrs']) as $attr) if (isset ($info[0][$attr])) { $ret['displayed_name'] .= $space . $info[0][$attr][0]; $space = ' '; } // Pull group membership, if any was returned. - if (isset ($info[0][$LDAP_options['group_attr']])) - for ($i = 0; $i < $info[0][$LDAP_options['group_attr']]['count']; $i++) + if (isset ($info[0][$LDAP_domain_options['group_attr']])) + for ($i = 0; $i < $info[0][$LDAP_domain_options['group_attr']]['count']; $i++) if ( - preg_match ($LDAP_options['group_filter'], $info[0][$LDAP_options['group_attr']][$i], $matches) && + preg_match ($LDAP_domain_options['group_filter'], $info[0][$LDAP_domain_options['group_attr']][$i], $matches) && validTagName ('$lgcn_' . $matches[1], TRUE) ) $ret['memberof'][] = '$lgcn_' . $matches[1]; |
|
Create patch for 0.21.3 1. Support multiple ldap domains. Description upper. 2. Support httpd auth + ldap auth. For this 2nd you need configure apache (kerberose + ad manual: https://imatviyenko.github.io/blog/2018/09/11/Apache-AD-kerberos) + ldap. Next you need change at config.php: $user_auth_src = 'httpd+ldap'; ____________________________ my apache2 location part of config: <Location /> <If "%{QUERY_STRING} =~ /^login/"> <Limit GET> AuthType Kerberos AuthName "Active Directory" KrbAuthRealms DOMAIN.LOCAL KrbServiceName HTTP Krb5Keytab /etc/apache2/kerb-full.keytab KrbMethodNegotiate On KrbMethodK5Passwd Off Require valid-user ErrorDocument 401 '<html><meta http-equiv="refresh" content="0; URL=/?login=basic"><body>Kerberos authentication did not pass. Go Next</body></html>' </Limit> </If> <If "%{QUERY_STRING} =~ /^login=basic/"> Require all granted </If> Require valid-user Require all granted ErrorDocument 401 '<html><meta http-equiv="refresh" content="0; URL=/login=on"><body>Need authentication.</body></html>' </Location> P.S. 2nd not work. sorry. |
|
Working apache config. I send to kerberos for local network and to ldap for others <Location /> Order allow,deny Allow from all <If "-R '192.168.1.0/24'"> <Limit GET> AuthType Kerberos AuthName "Active Directory" KrbAuthRealms DOMAIN.LOCAL KrbServiceName HTTP Krb5Keytab /etc/apache2/kerb-full.keytab KrbMethodNegotiate On KrbMethodK5Passwd Off Require valid-user ErrorDocument 401 '<html><meta http-equiv="refresh" content="0; URL=/?login"><body>Kerberos authentication did not pass. Go Next</body></html>' </Limit> </If> </Else> Satisfy Any </Else> <If "%{QUERY_STRING} =~ /^login=basic/"> Require all granted </If> Require valid-user ErrorDocument 401 '<html><meta http-equiv="refresh" content="0; URL=/login=on"><body>Need authentication.</body></html>' </Location> |
|
Date Modified | Username | Field | Change |
---|---|---|---|
2017-03-23 18:45 | allywilson | New Issue | |
2018-10-12 17:42 | Lucky | File Added: auth.php | |
2018-10-12 17:42 | Lucky | Note Added: 0003825 | |
2019-07-19 11:43 | syunwei | Note Added: 0003983 | |
2019-07-30 19:00 | Lucky | Note Added: 0003985 | |
2019-09-18 05:45 | syunwei | Note Added: 0003987 | |
2019-11-26 17:39 | Lucky | Note Added: 0004013 | |
2019-12-06 12:52 | Lucky | File Added: auth.ldap.patch | |
2019-12-06 12:52 | Lucky | Note Added: 0004027 | |
2019-12-09 08:51 | Lucky | Note Added: 0004029 | |
2019-12-09 11:28 | Lucky | Note Added: 0004031 |