Skip to content

Commit 62e724b

Browse files
CopilotKenneth Rowland
authored andcommitted
HPCC-35300 LDAP should handle brute force password hack attempts
Added cache of failed user authentication attempts with configurable number of allowed failed attempts and lockout timeout Signed-Off-By: Kenneth Rowland kenneth.rowland@lexisnexisrisk.com
1 parent b1bc1a5 commit 62e724b

3 files changed

Lines changed: 273 additions & 18 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
#pragma once
2+
3+
#include <unordered_map>
4+
#include <string>
5+
#include <algorithm>
6+
#include <limits>
7+
8+
#include "jmutex.hpp"
9+
#include "jutil.hpp"
10+
11+
struct FailedAuthEntry
12+
{
13+
unsigned firstFailureTick;
14+
unsigned failedAttempts;
15+
16+
FailedAuthEntry() : firstFailureTick(0), failedAttempts(0) {}
17+
FailedAuthEntry(unsigned tick, unsigned attempts)
18+
: firstFailureTick(tick), failedAttempts(attempts) {}
19+
};
20+
21+
// FailedAuthCache is primarily a load-reduction mechanism to prevent repeated failed
22+
// authentication attempts from hammering the LDAP/Active Directory server with unnecessary
23+
// traffic and round-trips. Once a configurable threshold of local failures is reached, the cache
24+
// blocks further attempts for the configured timeout period, avoiding LDAP queries.
25+
// NOTE: This is NOT the primary lockout mechanism; Active Directory's own account.lockout
26+
// policy remains authoritative. This cache layer simply reduces network load during failure bursts.
27+
// IMPORTANT: The TTL is measured from the FIRST failure, not continuously updated on each new failure.
28+
// This allows automatic recovery: if the underlying AD condition (temporary outage, account unlock, etc.)
29+
// is fixed within the timeout period, the entry expires and retries are allowed. This prevents permanent
30+
// blocking of services that are retrying after a transient AD issue.
31+
class FailedAuthCache
32+
{
33+
public:
34+
static constexpr unsigned defaultMaxFailedAttempts = 5;
35+
static constexpr unsigned defaultCacheTimeoutSeconds = 300; // 5 minutes
36+
static constexpr unsigned defaultMaxAllowedEntries = 25;
37+
38+
FailedAuthCache(unsigned maxFailedAttempts = defaultMaxFailedAttempts,
39+
unsigned cacheTimeoutSeconds = defaultCacheTimeoutSeconds,
40+
unsigned maxAllowedEntries = defaultMaxAllowedEntries)
41+
: m_maxFailedAttempts(maxFailedAttempts),
42+
m_cacheTimeoutSeconds(cacheTimeoutSeconds),
43+
m_maxAllowedEntries(maxAllowedEntries)
44+
{
45+
}
46+
47+
// Check if a user should be blocked locally to prevent repeated LDAP queries on failure.
48+
// This is a load-reduction mechanism, not account lockout enforcement.
49+
// Returns true if the user has exceeded the failed attempt threshold within the timeout window.
50+
bool isUserLockedOut(const char* username)
51+
{
52+
if (!username || !*username)
53+
return false;
54+
55+
CriticalBlock block(m_lock);
56+
57+
auto it = m_cache.find(username);
58+
if (it == m_cache.end())
59+
return false;
60+
61+
const unsigned currentTick = msTick();
62+
if (isExpired(it->second, currentTick))
63+
{
64+
m_cache.erase(it);
65+
trimFailedAuthCache();
66+
return false;
67+
}
68+
69+
return it->second.failedAttempts >= m_maxFailedAttempts;
70+
}
71+
72+
// Record a failed authentication attempt for a username to track repeated failures.
73+
// Used to compute local blocking to reduce LDAP traffic.
74+
// IMPORTANT: The timeout window is measured from the FIRST failure, not updated on each new failure.
75+
// This allows automatic recovery if the underlying AD condition (e.g., service outage, password reset)
76+
// is fixed within the timeout period. Once the timeout expires, the entry resets and retries are allowed.
77+
// Without this behavior, a service in a failed-auth loop would be permanently blocked even after AD recovers.
78+
void updateUserLockoutStatus(const char* username)
79+
{
80+
if (!username || !*username)
81+
return;
82+
83+
CriticalBlock block(m_lock);
84+
85+
const unsigned currentTick = msTick();
86+
auto it = m_cache.find(username);
87+
if (it == m_cache.end())
88+
{
89+
m_cache[username] = FailedAuthEntry(currentTick, 1);
90+
trimFailedAuthCache();
91+
return;
92+
}
93+
94+
if (isExpired(it->second, currentTick))
95+
{
96+
// Timeout expired from first failure; reset to allow retries.
97+
// If the underlying AD condition (e.g., temporary outage, account unlock) was fixed,
98+
// the service can now attempt authentication again.
99+
it->second.firstFailureTick = currentTick;
100+
it->second.failedAttempts = 1;
101+
}
102+
else
103+
++(it->second.failedAttempts);
104+
}
105+
106+
// Remove a user from the failed auth cache (e.g., on successful authentication).
107+
void removeUser(const char* username)
108+
{
109+
if (!username || !*username)
110+
return;
111+
112+
CriticalBlock block(m_lock);
113+
114+
auto it = m_cache.find(username);
115+
if (it != m_cache.end())
116+
{
117+
m_cache.erase(it);
118+
trimFailedAuthCache();
119+
}
120+
}
121+
122+
// Optional setters/getters
123+
void setMaxFailedAttempts(unsigned v) { m_maxFailedAttempts = v; }
124+
void setCacheTimeoutSeconds(unsigned v) { m_cacheTimeoutSeconds = v; }
125+
void setMaxAllowedEntries(unsigned v) { m_maxAllowedEntries = v; }
126+
127+
unsigned getMaxFailedAttempts() const { return m_maxFailedAttempts; }
128+
unsigned getCacheTimeoutSeconds() const { return m_cacheTimeoutSeconds; }
129+
unsigned getMaxAllowedEntries() const { return m_maxAllowedEntries; }
130+
131+
// Clear entire cache
132+
void clear()
133+
{
134+
CriticalBlock block(m_lock);
135+
m_cache.clear();
136+
}
137+
138+
private:
139+
unsigned queryCacheTimeoutMs() const
140+
{
141+
if (m_cacheTimeoutSeconds >= (std::numeric_limits<unsigned>::max() / 1000U))
142+
return std::numeric_limits<unsigned>::max();
143+
return m_cacheTimeoutSeconds * 1000U;
144+
}
145+
146+
// Check if a cached entry has expired based on time elapsed since FIRST failure.
147+
// NOTE: TTL is from firstFailureTick (the initial failure time), NOT updated to the latest failure.
148+
// This design prevents permanent blocking and allows recovery if the underlying condition is resolved.
149+
bool isExpired(const FailedAuthEntry &entry, unsigned currentTick) const
150+
{
151+
const unsigned elapsedMs = currentTick - entry.firstFailureTick; // unsigned wrap is intentional
152+
return elapsedMs >= queryCacheTimeoutMs();
153+
}
154+
155+
void trimFailedAuthCache()
156+
{
157+
// Expect lock is held by caller.
158+
const unsigned currentTick = msTick();
159+
160+
// Remove expired entries.
161+
for (auto it = m_cache.begin(); it != m_cache.end();)
162+
{
163+
if (isExpired(it->second, currentTick))
164+
it = m_cache.erase(it);
165+
else
166+
++it;
167+
}
168+
169+
// If still too large, remove oldest entries by age.
170+
while (m_cache.size() > m_maxAllowedEntries)
171+
{
172+
auto oldestIt = std::max_element(
173+
m_cache.begin(),
174+
m_cache.end(),
175+
[currentTick](const auto& a, const auto& b) {
176+
const unsigned ageA = currentTick - a.second.firstFailureTick;
177+
const unsigned ageB = currentTick - b.second.firstFailureTick;
178+
return ageA < ageB;
179+
});
180+
if (oldestIt != m_cache.end())
181+
m_cache.erase(oldestIt);
182+
else
183+
break;
184+
}
185+
}
186+
187+
private:
188+
std::unordered_map<std::string, FailedAuthEntry> m_cache;
189+
CriticalSection m_lock;
190+
191+
unsigned m_maxFailedAttempts;
192+
unsigned m_cacheTimeoutSeconds;
193+
unsigned m_maxAllowedEntries;
194+
};

system/security/LdapSecurity/ldapsecurity.cpp

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
limitations under the License.
1616
############################################################################## */
1717

18+
#include "jlog.hpp"
1819
#define AXA_API DECL_EXPORT
1920

2021
#include "ldapsecurity.ipp"
@@ -27,6 +28,22 @@
2728
using namespace cryptohelper;
2829

2930
#include "workunit.hpp"
31+
#include <ctime>
32+
33+
/**********************************************************
34+
* Failed Authentication Cache (Load Reduction) *
35+
**********************************************************/
36+
// This cache tracks repeated authentication failures to reduce LDAP/AD traffic
37+
// by avoiding redundant queries when a user has failed authentication multiple times
38+
// within a short window. Active Directory's account lockout policy remains authoritative;
39+
// this cache is purely a load-reduction layer.
40+
41+
#include "failedAuthCache.hpp"
42+
43+
// Static, class-scoped failed-auth cache instance. It will be initialized once
44+
// (on first manager init) with values from configuration.
45+
FailedAuthCache CLdapSecManager::s_failedAuthCache;
46+
CriticalSection CLdapSecManager::s_failedAuthCacheInitLock;
3047

3148
/**********************************************************
3249
* CLdapSecUser *
@@ -557,6 +574,9 @@ ISecProperty* CLdapSecResourceList::findProperty(const char* name)
557574
}
558575

559576

577+
// The failed-auth helper functions are now provided by FailedAuthCache.
578+
579+
560580
/**********************************************************
561581
* CLdapSecManager *
562582
**********************************************************/
@@ -616,7 +636,18 @@ void CLdapSecManager::init(const char *serviceName, IPropertyTree* cfg)
616636

617637
m_passwordExpirationWarningDays = cfg->getPropInt(".//@passwordExpirationWarningDays", 10); //Default to 10 days
618638
m_checkViewPermissions = cfg->getPropBool(".//@checkViewPermissions", false);
639+
unsigned maxFailedAuthAttempts = cfg->getPropInt(".//@maxFailedAuthAttempts", FailedAuthCache::defaultMaxFailedAttempts);
640+
unsigned failedAuthCacheTimeout = cfg->getPropInt(".//@failedAuthCacheTimeoutSeconds", FailedAuthCache::defaultCacheTimeoutSeconds);
641+
unsigned maxAllowedFailedAuthEntries = cfg->getPropInt(".//@maxAllowedFailedAuthEntries", FailedAuthCache::defaultMaxAllowedEntries);
619642
m_hpccInternalScope.set(queryDfsXmlBranchName(DXB_Internal)).append("::");//HpccInternal::
643+
644+
// Initialize/update the shared failed-auth cache with configured values
645+
{
646+
CriticalBlock block(CLdapSecManager::s_failedAuthCacheInitLock);
647+
CLdapSecManager::s_failedAuthCache.setMaxFailedAttempts(maxFailedAuthAttempts);
648+
CLdapSecManager::s_failedAuthCache.setCacheTimeoutSeconds(failedAuthCacheTimeout);
649+
CLdapSecManager::s_failedAuthCache.setMaxAllowedEntries(maxAllowedFailedAuthEntries);
650+
}
620651
};
621652

622653

@@ -675,6 +706,35 @@ bool CLdapSecManager::authenticate(ISecUser* user)
675706
if(!user)
676707
return false;
677708

709+
const char* username = user->getName();
710+
if (isEmptyString(username))
711+
{
712+
DBGLOG("CLdapSecManager::authenticate username cannot be empty");
713+
return false;
714+
}
715+
716+
// Check failed-auth cache before proceeding to reduce LDAP queries on repeated failures
717+
// (load reduction mechanism; Active Directory's own lockout policy is authoritative)
718+
if (CLdapSecManager::s_failedAuthCache.isUserLockedOut(username))
719+
{
720+
user->setAuthenticateStatus(AS_INVALID_CREDENTIALS);
721+
m_permissionsCache->removePermissions(*user);
722+
m_permissionsCache->removeFromUserCache(*user);
723+
return false;
724+
}
725+
726+
bool rc = doUserAuthenticate(user);
727+
if (rc)
728+
CLdapSecManager::s_failedAuthCache.removeUser(username);
729+
else
730+
CLdapSecManager::s_failedAuthCache.updateUserLockoutStatus(username);
731+
732+
return rc;
733+
}
734+
735+
bool CLdapSecManager::doUserAuthenticate(ISecUser* user)
736+
{
737+
const char* username = user->getName();
678738
bool isCaching = m_permissionsCache->isCacheEnabled() && !m_usercache_off;//caching enabled?
679739
bool isUserCached = false;
680740
Owned<ISecUser> cachedUser = new CLdapSecUser(user->getName(), "");
@@ -735,35 +795,28 @@ bool CLdapSecManager::authenticate(ISecUser* user)
735795
}
736796

737797
if (isUserCached && cachedUser->getAuthenticateStatus() == AS_AUTHENTICATED)//only authenticated users will be cached
738-
{
739798
return true;
740-
}
741799

742800
//User not in cache. Look for session token, or call LDAP to authenticate
743-
744801
if (0 != user->credentials().getSessionToken())//check for token existence
745802
{
746803
user->setAuthenticateStatus(AS_AUTHENTICATED);
747804
}
748805
else if (m_ldap_client->authenticate(*user)) //call LDAP to authenticate
749806
user->setAuthenticateStatus(AS_AUTHENTICATED);
750-
751-
if (AS_AUTHENTICATED == user->getAuthenticateStatus())
807+
if (isCaching)
808+
m_permissionsCache->add(*user);
809+
else if (isEmptyString(user->credentials().getPassword()) && (0 == user->credentials().getSessionToken()) && isEmptyString(user->credentials().getSignature()))
752810
{
753-
if (isCaching)
754-
m_permissionsCache->add(*user);
755-
else if (isEmptyString(user->credentials().getPassword()) && (0 == user->credentials().getSessionToken()) && isEmptyString(user->credentials().getSignature()))
811+
//No need to sign if password or authenticated session based user
812+
if (!pDSM)
813+
pDSM = queryDigitalSignatureManagerInstanceFromEnv();
814+
if (pDSM && pDSM->isDigiSignerConfigured())
756815
{
757-
//No need to sign if password or authenticated session based user
758-
if (!pDSM)
759-
pDSM = queryDigitalSignatureManagerInstanceFromEnv();
760-
if (pDSM && pDSM->isDigiSignerConfigured())
761-
{
762-
//Set user digital signature
763-
StringBuffer b64Signature;
764-
pDSM->digiSign(b64Signature, user->getName());
765-
user->credentials().setSignature(b64Signature);
766-
}
816+
//Set user digital signature
817+
StringBuffer b64Signature;
818+
pDSM->digiSign(b64Signature, user->getName());
819+
user->credentials().setSignature(b64Signature);
767820
}
768821
}
769822

system/security/LdapSecurity/ldapsecurity.ipp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
#endif
3535
#include "seclib.hpp"
3636

37+
// forward declare the new FailedAuthCache used by the manager
38+
class FailedAuthCache;
39+
3740
#ifndef LDAPSECURITY_EXPORTS
3841
#define LDAPSECURITY_API DECL_IMPORT
3942
#else
@@ -380,6 +383,8 @@ private:
380383
StringBuffer m_description;
381384
unsigned m_passwordExpirationWarningDays;
382385
bool m_checkViewPermissions;
386+
static FailedAuthCache s_failedAuthCache;
387+
static CriticalSection s_failedAuthCacheInitLock;
383388
static const SecFeatureSet s_safeFeatures = SMF_ALL_FEATURES;
384389
static const SecFeatureSet s_implementedFeatures = s_safeFeatures & ~(SMF_RetrieveUserData | SMF_RemoveResources);
385390
StringBuffer m_hpccInternalScope;
@@ -522,6 +527,9 @@ public:
522527
virtual void removeViewMembers(const char * viewName, StringArray & viewUsers, StringArray & viewGroups) override;
523528
virtual void queryViewMembers(const char * viewName, StringArray & viewUsers, StringArray & viewGroups) override;
524529
virtual bool userInView(const char * user, const char* viewName) override;
530+
531+
private:
532+
bool doUserAuthenticate(ISecUser* user);
525533
};
526534

527535
#ifdef _MSC_VER

0 commit comments

Comments
 (0)