diff --git a/ChangeLog b/ChangeLog index bf242b9c..77430f12 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,6 @@ +2024-04-15 Andrew Ruthven + * Add caching of user credential success/failure + 2024-04-14 Stonewall Jackson * Add support for fallback to LDAP password even if Kerberos is active. diff --git a/config/example-config.php b/config/example-config.php index 7b080d59..5fa72d57 100644 --- a/config/example-config.php +++ b/config/example-config.php @@ -616,6 +616,33 @@ $c->admin_email = 'calendar-admin@example.com'; // $_SERVER['REMOTE_ADDR'] = $_SERVER['Client-IP']; +/*************************************************************************** +* * +* Authentication Settings * +* * +***************************************************************************/ + +/** +* Cache credentials, requires memcached to be enabled. +* +* Default: false +*/ +// $c->auth_cache = false; + +/** +* How long to cache credentials which username & password match. +* +* Default: 15 minutes +*/ +// $c->auth_cache_pass = 15 * 60; + +/** +* How long to cache credentials which username & password don't match. +* +* Default: 15 minutes +*/ +// $c->auth_cache_fail = 15 * 60; + /*************************************************************************** * * * External Authentication Sources * diff --git a/htdocs/always.php b/htdocs/always.php index f3177b47..a2c8eacf 100644 --- a/htdocs/always.php +++ b/htdocs/always.php @@ -165,6 +165,11 @@ $c->readonly_webdav_collections = true; // WebDAV access is readonly // find more instances. $c->rrule_loop_limit = 100; +// Authentication caching details +$c->auth_cache = false; // Default to off +$c->auth_cache_pass = 15 * 60; // 15 minutes +$c->auth_cache_fail = 15 * 60; // 15 minutes + // Kind of private configuration values $c->total_query_time = 0; diff --git a/inc/HTTPAuthSession.php b/inc/HTTPAuthSession.php index b80f012b..e31b2a9d 100644 --- a/inc/HTTPAuthSession.php +++ b/inc/HTTPAuthSession.php @@ -314,6 +314,34 @@ class HTTPAuthSession { if(isset($c->login_append_domain_if_missing) && $c->login_append_domain_if_missing && !preg_match('/@/',$username)) $username.='@'.$c->domain_name; + $cache_result = $this->CheckCache($username, $password); + + if ($cache_result == 0) { + # noop as CheckCache has nothing for this username/password combo + # and we need to perform full authentication check. + + } else if ($cache_result == 1) { + # cached credentials match and are valid + + if ( $principal = new Principal('username', $username) ) { + if ( isset($c->dbg['password']) ) dbg_error_log( "password", ":CheckPassword (cached): Name:%s, Pass:%s, File:%s, Active:%s", $username, $password, $principal->password, ($principal->user_active?'Yes':'No') ); + if ( $principal->user_active ) { + return $principal; + } else { + return false; + } + } else { + + # If we fail to find the Principal, then fall through to full authentication below. + dbg_error_log('password', 'Cache check: Failed to find principal after valid cache result, falling through to full authentication check.'); + } + + } else if ($cache_result == 2) { + # cached credentials match and are invalid + + return false; + } + if ( !isset($c->authenticate_hook) || !isset($c->authenticate_hook['call']) || !function_exists($c->authenticate_hook['call']) || (isset($c->authenticate_hook['optional']) && $c->authenticate_hook['optional']) ) @@ -321,6 +349,7 @@ class HTTPAuthSession { if ( $principal = new Principal('username', $username) ) { if ( isset($c->dbg['password']) ) dbg_error_log( "password", ":CheckPassword: Name:%s, Pass:%s, File:%s, Active:%s", $username, $password, $principal->password, ($principal->user_active?'Yes':'No') ); if ( $principal->user_active && session_validate_password( $password, $principal->password ) ) { + $this->SetCache($username, $password, 'pass'); return $principal; } } @@ -339,12 +368,17 @@ class HTTPAuthSession { * - Configuration data will be in $c->authenticate_hook['config'], which might be an array, or whatever is needed. */ $principal = call_user_func( $c->authenticate_hook['call'], $username, $password ); - if ( $principal !== false && !($principal instanceof Principal) ) { + if ( $principal === false ) { + $this->SetCache($username, $password, 'fail'); + } else if (!($principal instanceof Principal) ) { + $this->SetCache($username, $password, 'pass'); $principal = new Principal('username', $username); } + return $principal; } + $this->SetCache($username, $password, 'fail'); return false; } @@ -405,6 +439,96 @@ class HTTPAuthSession { } } + /** + * Internal function used to check a cache for username/password details. This is + * intended to reduce the load on external authentication sources. + */ + function CheckCache( $username, $password ) { + global $c; + if (! $c->auth_cache) return 0; + + $cache = getCacheInstance(); + if ($cache->isActive() === false) return 0; + + $cache_ns = 'auth-' . $username; + + $salt = $cache->get($cache_ns, 'salt'); + + if (isset($salt)) { + $sha1_sent = session_salted_sha1($password, $salt); + $cached_credentials = $cache->get($cache_ns, $sha1_sent); + + if (isset($cached_credentials)) { + if ($cached_credentials == 'pass') { + dbg_error_log('HTTPAuthLogin', 'CheckCache: Cached credentials are good and valid'); + return 1; + + } else if ($cached_credentials == 'fail') { + dbg_error_log('HTTPAuthLogin', 'CheckCache: Cached credentials are good and invalid'); + return 2; + } + + } else { + dbg_error_log('HTTPAuthLogin', 'CheckCache: No stored salted password, need to use auth source'); + } + + } else { + dbg_error_log('HTTPAuthLogin', 'CheckCache: No salt, assuming no cached credentials, need to use auth source'); + } + + # All hope is lost, we must have failed to find anything decent. + return 0; + } + + /** + * Internal function used to set a cache for username/password details. This is + * intended to reduce the load on external authentication sources. + */ + function SetCache( $username, $password, $state ) { + global $c; + + if (! $c->auth_cache) return 0; + + $cache = getCacheInstance(); + if ($cache->isActive() === false) return 0; + + $cache_ns = 'auth-' . $username; + + $salt = $cache->get($cache_ns, 'salt'); + + if (!isset($salt) || $salt == '') { + $salt = substr( str_replace('*','',base64_encode(sha1(rand(100000,9999999),true))), 2, 9); + + # We use the default expiry setting for the salt, because the worse + # case scenario is that we won't access the cached credentials and + # need to fail to the full authentication source. + if (! $cache->set($cache_ns, 'salt', $salt) ) { + dbg_error_log('ERROR', 'HTTPCheckCache: SetCache: Failed to store salt, bailing out from caching credential.'); + return 0; + } + } + + # The hashed password to store + $sha1_sent = session_salted_sha1($password, $salt); + + # Work out the expiry to use, some sites might prefer different TTLs for + # pass/fail results. + if ($state == 'pass') { + $expiry = $c->auth_cache_pass; + } else if ($state == 'fail') { + $expiry = $c->auth_cache_fail; + } else { + dbg_error_log('ERROR', 'HTTPCheckCache: SetCache: Unexpected state %s, bailing out from caching credential.', $state); + return 0; + } + + if (! $cache->set($cache_ns, $sha1_sent, $state, $expiry) ) { + dbg_error_log('ERROR', 'HTTPCheckCache: SetCache: Failed to store credential.'); + return 0; + } + + return 1; + } } diff --git a/inc/always.php.in b/inc/always.php.in index e9ead53f..e3872a5d 100644 --- a/inc/always.php.in +++ b/inc/always.php.in @@ -161,6 +161,14 @@ $c->template_usr = array( 'active' => true, $c->hide_TODO = true; // VTODO only visible to collection owner $c->readonly_webdav_collections = true; // WebDAV access is readonly +// Authentication caching details +$c->auth_cache = false; // Default to off +$c->auth_cache_pass = 15 * 60; // 15 minutes +$c->auth_cache_fail = 15 * 60; // 15 minutes + +// Kind of private configuration values +$c->total_query_time = 0; + // Any many times GetMoreInstances in inc/RRule.php should loop trying to // find more instances. $c->rrule_loop_limit = 100;