Skip to content

Commit

Permalink
Merge pull request #15 from NetKnights-GmbH/13/different-realms
Browse files Browse the repository at this point in the history
Implement app cache and a realm mapper with two strategies

Closes #13
  • Loading branch information
fredreichbier authored Jun 21, 2017
2 parents 2b9f505 + c000ed1 commit af079e6
Show file tree
Hide file tree
Showing 13 changed files with 503 additions and 25 deletions.
39 changes: 36 additions & 3 deletions example-proxy.ini
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
[privacyidea]
# URL of your privacyIDEA installation
instance = http://10.0.0.1
# Realm to use for authentication (may also be left blank)
realm =

[ldap-backend]
# Location of the LDAP backend server, specified using the Twisted endpoint string syntax for clients:
Expand Down Expand Up @@ -58,11 +56,46 @@ attribute = sAMAccountName
#strategy = match
#pattern = "cn=([^,]+),cn=users,dc=test,dc=local"

[realm-mapping]
# The following configures how the privacyIDEA realm of incoming authentication requests
# is determined.
# The `static` strategy assigns one realm to all authentication requests.
strategy = static
# Realm to use for authentication (may also be left blank to make privacyIDEA use its default realm)
realm =
# The `app-cache` strategy is more sophisticated: Once a bind request is received, it checks
# whether the app cache contains a corresponding app marker (so be sure to enable the bind cache)
# If yes, this app marker is mapped to a privacyIDEA realm according to the mapping defined below.
# If the app cache contains no app marker or the app marker is not mapped to a realm,
# the authentication request fails.
# strategy = app-cache
# [[mappings]]
# This maps the app marker "someApp" to the privacyIDEA realm "somerealm".
# someApp = somerealm

[bind-cache]
# If this setting is enabled, successful user bind requests are added to a so-called "bind cache" in which
# they are kept for a specified time. During that time, incoming bind requests using the same credentials
# are internally replaced with bind requests using the service account credentials.
# The timeout is specified in seconds.
# This feature is EXPERIMENTAL.
enabled = false
timeout = 3
timeout = 3

[app-cache]
# If this setting is enabled, the LDAP proxy maintains a so-called "app cache".
# On user login, apps typically perform a LDAP search to locate the user. When
# they have resolved the DN, they perform the actual bind.
# We use the LDAP search to identify the app: For that, we add an expression like
# `objectclass=App-someApp` to the LDAP filter (without changing the filter semantics).
# We call `someApp` an "app marker". If the LDAP proxy witnesses a search request
# whose filter contains an app marker and whose result has exactly one entry,
# the DN of the entry (the user) as well as the app marker are stored in the app cache
# for a specific timeframe. This information may be used by the `app-cache` realm mapping
# strategy (see below).
enabled = false
#timeout = 5
# Attribute containing the app marker
#attribute = objectclass
# Prefix of the app marker
#value-prefix = App-
79 changes: 79 additions & 0 deletions pi_ldapproxy/appcache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from twisted.internet import reactor
from twisted.logger import Logger

log = Logger()

class AppCache(object):
"""
The app cache stores the association of a DN with a so-called "app marker" for a specific timeframe.
"""
# (see http://twistedmatrix.com/documents/current/core/howto/trial.html)
callLater = reactor.callLater

def __init__(self, timeout):
"""
:param timeout: The association is kept in the cache for this timeframe
"""
self.timeout = timeout

#: Map of dn to tuples (app marker, insertion timestamp)
self._entries = {}

def add_to_cache(self, dn, marker):
"""
Add the entry to the app cache. It will be automatically removed after ``timeout`` seconds.
If an entry for ``dn`` (with any marker) already exists, it will be overwritten.
Keep in mind that removal will then provoke a "Removal from app
cache failed: ... mapped to ... " log message!
If an entry for ``dn`` with the same marker exists, the eviction timeout will *not*
be extended if it is added again.
:param dn: DN
:param marker: App marker (a string)
"""
if dn in self._entries:
log.info('Entry {dn!r} already cached {marker!r}, overwriting ...',
dn=dn, marker=self._entries[dn])
current_time = reactor.seconds()
log.info('Adding to app cache: dn={dn!r}, marker={marker!r}, time={time!r}',
dn=dn, time=current_time, marker=marker)
self._entries[dn] = (marker, current_time)
self.callLater(self.timeout, self.remove_from_cache, dn, marker)

def remove_from_cache(self, dn, marker):
"""
Remove the entry from the app cache. If the DN is mapped to a different marker, a warning is emitted
and the entry is *not* removed! If the entry does not exist in the app cache, a message is
written to the log.
:param dn: DN
:param marker: App marker (a string)
"""
if dn in self._entries:
stored_marker, stored_timestamp = self._entries[dn]
if stored_marker == marker:
del self._entries[dn]
log.info('Removed {dn!r}/{marker!r} from app cache', dn=dn, marker=marker)
else:
log.warn('Removal from app cache failed: {dn!r} mapped to {stored!r}, not {marker!r}',
dn=dn, stored=stored_marker, marker=marker)
else:
log.info('Removal from app cache failed, as dn={dn!r} is not cached', dn=dn)

def get_cached_marker(self, dn):
"""
Retrieve the cached marker for the distinguished name ``dn``. This actually checks that the stored entry
is still valid. If ``dn`` is not found in the cache, ``None`` is returned and a message is written to the log.
:param dn: DN
:return: string or None
"""
if dn in self._entries:
marker, timestamp = self._entries[dn]
current_time = reactor.seconds()
if current_time - timestamp < self.timeout:
return marker
else:
log.warn('Inconsistent app cache: dn={dn!r}, inserted={inserted!r}, current={current!r}',
dn=dn, inserted=timestamp, current=current_time
)
else:
log.info('No entry in app cache for dn={dn!r}', dn=dn)
return None
8 changes: 4 additions & 4 deletions pi_ldapproxy/bindcache.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ def add_to_cache(self, dn, password):
item = (dn, password)
if item not in self._cache:
current_time = reactor.seconds()
log.info('Adding to cache: dn={dn!r}, time={time!r}', dn=dn, time=current_time)
log.info('Adding to bind cache: dn={dn!r}, time={time!r}', dn=dn, time=current_time)
self._cache[item] = current_time
self.callLater(self.timeout, self.remove_from_cache, dn, password)
else:
log.info('Already in the cache: dn={dn!r}', dn=dn)
log.info('Already in the bind cache: dn={dn!r}', dn=dn)

def remove_from_cache(self, dn, password):
"""
Expand All @@ -53,9 +53,9 @@ def remove_from_cache(self, dn, password):
item = (dn, password)
if item in self._cache:
del self._cache[item]
log.info('Removed from cache: dn={dn!r} ({remaining!r} remaining)', dn=dn, remaining=len(self._cache))
log.info('Removed from bind cache: dn={dn!r} ({remaining!r} remaining)', dn=dn, remaining=len(self._cache))
else:
log.info("Removal failed as dn={dn!r} cached", dn=dn)
log.info("Removal from bind cache failed as dn={dn!r} is not cached", dn=dn)

def is_cached(self, dn, password):
"""
Expand Down
13 changes: 12 additions & 1 deletion pi_ldapproxy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
CONFIG_SPEC = """
[privacyidea]
instance = string
realm = string(default='')
[ldap-backend]
endpoint = string
Expand All @@ -27,6 +26,18 @@
[bind-cache]
enabled = boolean
timeout = integer(default=3)
[app-cache]
enabled = boolean
timeout = integer(default=3)
attribute = string(default='objectclass')
value-prefix = string(default='App-')
[user-mapping]
strategy = string
[realm-mapping]
strategy = string
"""

def report_config_errors(config, result):
Expand Down
103 changes: 93 additions & 10 deletions pi_ldapproxy/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@

from pi_ldapproxy.bindcache import BindCache
from pi_ldapproxy.config import load_config
from pi_ldapproxy.usermapping import MAPPING_STRATEGIES, UserMappingError
from pi_ldapproxy.appcache import AppCache
from pi_ldapproxy.realmmapping import detect_login_preamble, REALM_MAPPING_STRATEGIES, RealmMappingError
from pi_ldapproxy.usermapping import USER_MAPPING_STRATEGIES, UserMappingError

log = Logger()

Expand All @@ -30,8 +32,20 @@ class ProxyError(Exception):
VALIDATE_URL_TEMPLATE = '{}validate/check'

class TwoFactorAuthenticationProxy(ProxyBase):
#: Specifies whether we have sent a bind request to the LDAP backend at some point
bound = False
def __init__(self):
ProxyBase.__init__(self)
#: Specifies whether we have sent a bind request to the LDAP backend at some point
self.bound = False
#: If we are currently processing a search request, this stores the last entry
#: sent during its response. Otherwise, it is None.
self.last_search_response_entry = None
#: If we are currently processing a search request, this stores the total number of
#: entries sent during its response.
# Why do we have these two attributes here? For preamble detection, we need to make sure
# that the search request returns only one entry. To achieve that, we could store all entries
# in a list. However, this introduces unnecessary space overhead (e.g. if the app queries
# all users). Thus, we only store the last entry and the total entry count.
self.search_response_entries = 0

def request_validate(self, url, user, realm, password):
"""
Expand Down Expand Up @@ -69,16 +83,22 @@ def authenticate_bind_request(self, request):
result = (False, '')
try:
user = yield self.factory.resolve_user(request.dn)
realm = yield self.factory.resolve_realm(request.dn)
except UserMappingError:
# User could not be found
log.info('Could not resolve {dn!r}', dn=request.dn)
log.info('Could not resolve {dn!r} to user', dn=request.dn)
result = (False, 'Invalid user.')
except RealmMappingError, e:
# Realm could not be mapped
log.info('Could not resolve {dn!r} to realm: {message!r}', dn=request.dn, message=e.message)
# TODO: too much information revealed?
result = (False, 'Could not determine realm.')
else:
log.info('Resolved {dn!r} to {user!r}', dn=request.dn, user=user)
log.info('Resolved {dn!r} to {user!r}@{realm!r}', dn=request.dn, user=user, realm=realm)
password = request.auth
response = yield self.request_validate(self.factory.validate_url,
user,
self.factory.validate_realm,
realm,
password)
json_body = yield readBody(response)
if response.code == 200:
Expand Down Expand Up @@ -135,6 +155,31 @@ def bind_service_account(self):
log.info('Binding service account ...')
yield self.client.bind(self.factory.service_account_dn, self.factory.service_account_password)

def handleProxiedResponse(self, response, request, controls):
"""
Called by `ProxyBase` to handle the response of an incoming request.
:param response:
:param request:
:param controls:
:return:
"""
# Try to detect login preamble
if isinstance(request, pureldap.LDAPSearchRequest):
# If we are sending back a search result entry, we just save it for preamble detection
# and count the total number of search result entries.
if isinstance(response, pureldap.LDAPSearchResultEntry):
self.last_search_response_entry = response
self.search_response_entries += 1
elif isinstance(response, pureldap.LDAPSearchResultDone):
# only check for preambles if we returned exactly one search result entry
if self.search_response_entries == 1:
# TODO: Check that this is connection is bound to the service account?
self.factory.process_search_response(request, self.last_search_response_entry)
# reset counter and storage
self.search_response_entries = 0
self.last_search_response_entry = None
return response

def handleBeforeForwardRequest(self, request, controls, reply):
"""
Called by `ProxyBase` to handle an incoming request.
Expand Down Expand Up @@ -209,7 +254,6 @@ def __init__(self, config):
if self.privacyidea_instance[-1] != '/':
self.privacyidea_instance += '/'
self.validate_url = VALIDATE_URL_TEMPLATE.format(self.privacyidea_instance)
self.validate_realm = config['privacyidea']['realm']

self.service_account_dn = config['service-account']['dn']
self.service_account_password = config['service-account']['password']
Expand All @@ -224,17 +268,30 @@ def __init__(self, config):
self.allow_search = config['ldap-proxy']['allow-search']
self.bind_service_account = config['ldap-proxy']['bind-service-account']

mapping_strategy = MAPPING_STRATEGIES[config['user-mapping']['strategy']]
log.info('Using mapping strategy: {strategy!r}', strategy=mapping_strategy)
user_mapping_strategy = USER_MAPPING_STRATEGIES[config['user-mapping']['strategy']]
log.info('Using user mapping strategy: {strategy!r}', strategy=user_mapping_strategy)

self.user_mapper = user_mapping_strategy(self, config['user-mapping'])

self.user_mapper = mapping_strategy(self, config['user-mapping'])
realm_mapping_strategy = REALM_MAPPING_STRATEGIES[config['realm-mapping']['strategy']]
log.info('Using realm mapping strategy: {strategy!r}', strategy=realm_mapping_strategy)

self.realm_mapper = realm_mapping_strategy(self, config['realm-mapping'])

enable_bind_cache = config['bind-cache']['enabled']
if enable_bind_cache:
self.bind_cache = BindCache(config['bind-cache']['timeout'])
else:
self.bind_cache = None

enable_app_cache = config['app-cache']['enabled']
if enable_app_cache:
self.app_cache = AppCache(config['app-cache']['timeout'])
else:
self.app_cache = None
self.app_cache_attribute = config['app-cache']['attribute']
self.app_cache_value_prefix = config['app-cache']['value-prefix']

if config['ldap-backend']['test-connection']:
self.test_connection()

Expand Down Expand Up @@ -264,6 +321,14 @@ def resolve_user(self, dn):
"""
return self.user_mapper.resolve(dn)

def resolve_realm(self, dn):
"""
Invoke the realm mapper to find the realm of the user identified by the DN *dn*.
:param dn: LDAP distinguished name as string
:return: a Deferred firing a string (or raising a RealmMappingError)
"""
return self.realm_mapper.resolve(dn)

def finalize_authentication(self, dn, password):
"""
Called when a user was successfully authenticated by privacyIDEA. If the bind cache is enabled,
Expand All @@ -274,6 +339,24 @@ def finalize_authentication(self, dn, password):
if self.bind_cache is not None:
self.bind_cache.add_to_cache(dn, password)

def process_search_response(self, request, response):
"""
Called when ``response`` is sent in response to ``request``. If the app cache is enabled,
``detect_login_preamble`` is invoked in order to detect a login preamble. If one was detected,
the corresponding entry is added to the app cache.
:param request: LDAPSearchRequest
:param response: LDAPSearchResultEntry or LDAPSearchResultDone
:return:
"""
if self.app_cache is not None:
result = detect_login_preamble(request,
response,
self.app_cache_attribute,
self.app_cache_value_prefix)
if result is not None:
dn, marker = result
self.app_cache.add_to_cache(dn, marker)

def is_bind_cached(self, dn, password):
"""
Check whether the given credentials are found in the bind cache.
Expand Down
Loading

0 comments on commit af079e6

Please sign in to comment.