├── pi_ldapproxy ├── __init__.py ├── test │ ├── __init__.py │ ├── test_failing_privacyidea.py │ ├── test_https_policy.py │ ├── test_realmmapping.py │ ├── mock.py │ ├── test_proxy_user_lookup.py │ ├── test_bindcache.py │ ├── test_appcache.py │ ├── util.py │ ├── test_proxy_user_bind.py │ ├── test_proxy_bind_cache.py │ ├── test_proxy_simple.py │ └── test_proxy_app_cache.py ├── util.py ├── config.py ├── usermapping.py ├── bindcache.py ├── appcache.py ├── realmmapping.py └── proxy.py ├── deploy ├── debian-ubuntu │ ├── compat │ ├── rules │ ├── privacyidea-ldap-proxy.install │ ├── privacyidea-ldap-proxy.postinst │ ├── control │ └── changelog ├── privacyidea-ldap-proxy.service └── ubuntu-config │ ├── privacyidea-ldap-proxy.service │ └── proxy.ini ├── MANIFEST.in ├── scenarios ├── requirements.txt ├── README.md ├── example-config.ini ├── 1-only-user-bind.py ├── common.py ├── 2-user-bind-search.py ├── 3-service-account-user-bind-search.py ├── 4-service-account-user-multiple-bind-search.py └── 5-multiple-search-app-marker.py ├── .travis.yml ├── load-testing ├── requirements.txt ├── README.md └── locustfile.py ├── .gitignore ├── requirements.txt ├── .github └── workflows │ └── python.yml ├── README.md ├── twisted └── plugins │ └── ldapproxy_plugin.py ├── tools └── logging-proxy.py ├── Makefile ├── setup.py ├── Changelog ├── example-proxy.ini └── LICENSE /pi_ldapproxy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /deploy/debian-ubuntu/compat: -------------------------------------------------------------------------------- 1 | 7 2 | -------------------------------------------------------------------------------- /pi_ldapproxy/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include twisted/plugins/*.py -------------------------------------------------------------------------------- /scenarios/requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.0 2 | configobj==5.0.6 3 | ldap3==2.2.0 4 | packaging==16.8 5 | pyasn1==0.2.2 6 | pyparsing==2.1.10 7 | six==1.10.0 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | python: 4 | - 2.7 5 | # command to install dependencies 6 | install: 7 | - "pip install ." 8 | - "pip install -r requirements.txt" 9 | script: "trial pi_ldapproxy.test" 10 | -------------------------------------------------------------------------------- /deploy/debian-ubuntu/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | # This file was automatically generated by stdeb 0.6.0+git at 4 | # Mon, 14 Jul 2014 18:56:55 +0200 5 | 6 | #%: 7 | # dh $@ --with python2 --buildsystem=python_distutils 8 | %: 9 | dh $@ 10 | -------------------------------------------------------------------------------- /deploy/debian-ubuntu/privacyidea-ldap-proxy.install: -------------------------------------------------------------------------------- 1 | deploy/ubuntu-config/privacyidea-ldap-proxy.service /lib/systemd/system/ 2 | deploy/ubuntu-config/proxy.ini /etc/privacyidea-ldap-proxy/ 3 | buildenv/* /opt/privacyidea-ldap-proxy/ 4 | -------------------------------------------------------------------------------- /load-testing/requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.0 2 | click==6.7 3 | Flask==0.12 4 | gevent==1.1.1 5 | greenlet==0.4.12 6 | itsdangerous==0.24 7 | Jinja2==2.9.5 8 | ldap3==2.2.0 9 | locustio==0.7.5 10 | MarkupSafe==0.23 11 | msgpack-python==0.4.8 12 | packaging==16.8 13 | pyasn1==0.2.2 14 | pyparsing==2.1.10 15 | requests==2.20.1 16 | six==1.10.0 17 | Werkzeug==0.11.15 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # Distribution 9 | *.egg-info/ 10 | 11 | # Virtual Environments 12 | venv*/ 13 | 14 | # IDE 15 | .idea/ 16 | 17 | # Tests 18 | _trial_temp/ 19 | 20 | # Cache 21 | twisted/plugins/dropin.cache 22 | -------------------------------------------------------------------------------- /deploy/privacyidea-ldap-proxy.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=privacyIDEA LDAP proxy 3 | 4 | [Service] 5 | ExecStart=/path/to/privacyidea-ldap-proxy/venv/bin/twistd \ 6 | --nodaemon \ 7 | --pidfile= \ 8 | -u nobody \ 9 | -g nogroup \ 10 | ldap-proxy \ 11 | -c /path/to/proxy.ini 12 | 13 | User=root 14 | Group=root 15 | 16 | Restart=always 17 | 18 | [Install] 19 | WantedBy=multi-user.target 20 | -------------------------------------------------------------------------------- /deploy/ubuntu-config/privacyidea-ldap-proxy.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=privacyIDEA LDAP proxy 3 | After=network.target 4 | 5 | [Service] 6 | ExecStart=/opt/privacyidea-ldap-proxy/bin/twistd \ 7 | --nodaemon \ 8 | --pidfile=/var/run/privacyidea-ldap-proxy.pid \ 9 | -u ldapproxy \ 10 | -g ldapproxy \ 11 | ldap-proxy \ 12 | -c /etc/privacyidea-ldap-proxy/proxy.ini 13 | 14 | User=root 15 | Group=root 16 | 17 | Restart=always 18 | 19 | [Install] 20 | WantedBy=multi-user.target 21 | -------------------------------------------------------------------------------- /load-testing/README.md: -------------------------------------------------------------------------------- 1 | privacyIDEA LDAP Proxy Load Testing 2 | =================================== 3 | 4 | Proof-of-concept of LDAP load testing using [locust](http://locust.io) 5 | 6 | How to run: 7 | 8 | virtualenv2 venv 9 | . ./venv/bin/activate 10 | pip install -r requirements.txt 11 | 12 | Then, edit the USER_DN and USER_PASSWORD constants in `locustfile.py` and run 13 | 14 | locust -H 10.1.2.3 15 | 16 | with `10.1.2.3` being the LDAP server IP and visit `127.0.0.1:8089` in your web browser. -------------------------------------------------------------------------------- /scenarios/README.md: -------------------------------------------------------------------------------- 1 | LDAP Proxy Scenarios 2 | ==================== 3 | 4 | The scripts in this directory implement the client side of some exemplary scenarios involving the LDAP server. 5 | They could, for example, be used to test the LDAP proxy. 6 | 7 | The scripts are best run in a virtualenv: 8 | 9 | virtualenv2 venv 10 | . ./venv/bin/activate 11 | pip install -r requirements.txt 12 | 13 | They are configured in a file `config.ini`. `example-config.ini` provides an exemplary configuration. 14 | 15 | Each script's header contains a docstring explaining the respective scenario. -------------------------------------------------------------------------------- /deploy/debian-ubuntu/privacyidea-ldap-proxy.postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # see: dh_installdeb(1) 3 | 4 | set -e 5 | 6 | # source debconf library 7 | . /usr/share/debconf/confmodule 8 | 9 | # Source dbconfig-common functions 10 | if [ -f /usr/share/dbconfig-common/dpkg/postinst.pgsql ]; then 11 | . /usr/share/dbconfig-common/dpkg/postinst.pgsql 12 | fi 13 | 14 | USERNAME=ldapproxy 15 | 16 | create_user() { 17 | useradd -r $USERNAME -m || true 18 | } 19 | 20 | 21 | case "$1" in 22 | 23 | configure) 24 | create_user 25 | ;; 26 | 27 | abort-upgrade|abort-remove|abort-deconfigure) 28 | exit 0 29 | ;; 30 | 31 | *) 32 | echo "postinst called with unknown argument \`$1'" >&2 33 | exit 1 34 | ;; 35 | 36 | esac 37 | 38 | 39 | #DEBHELPER# 40 | 41 | db_stop 42 | 43 | exit 0 44 | 45 | -------------------------------------------------------------------------------- /scenarios/example-config.ini: -------------------------------------------------------------------------------- 1 | ldap-server = 2 | # Credentials for user logins. 'password' may be kept blank -- in this case, the user is prompted for the password. 3 | username = test 4 | password = pass 5 | # Base DN for LDAP searches 6 | base-dn = "cn=users,dc=test,dc=intranet" 7 | # 'uid-attribute' is used in scenarios 1 and 2 to construct a user DN without querying the LDAP backend. 8 | uid-attribute = cn 9 | # 'loginname-attribute' is used in scenario 3 to retrieve the user DN by querying the LDAP backend. 10 | loginname-attribute = sAMAccountName 11 | # Service account credentials 12 | service-account-dn = "cn=service,cn=users,dc=test,dc=intranet" 13 | service-account-password = "" 14 | # Scenario 4: Wait some seconds between bind requests 15 | wait-seconds = 2 16 | # Scenario 5: Include an app marker 17 | marker-filter = "objectclass=App-something" -------------------------------------------------------------------------------- /pi_ldapproxy/test/test_failing_privacyidea.py: -------------------------------------------------------------------------------- 1 | from ldaptor.protocols.ldap import ldaperrors 2 | from twisted.internet import defer 3 | 4 | from pi_ldapproxy.test.util import ProxyTestCase 5 | 6 | 7 | class TestFailingPrivacyIDEA(ProxyTestCase): 8 | privacyidea_credentials = { 9 | 'hugo@default': 'secret' 10 | } 11 | 12 | def test_bind_fails_internal_server_error(self): 13 | server, client = self.create_server_and_client([]) 14 | self.privacyidea.response_code = 500 15 | d = client.bind('uid=hugo,cn=users,dc=test,dc=local', 'secret') 16 | return self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 17 | 18 | def test_bind_fails_status_false(self): 19 | server, client = self.create_server_and_client([]) 20 | self.privacyidea.status = False 21 | d = client.bind('uid=hugo,cn=users,dc=test,dc=local', 'secret') 22 | return self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) -------------------------------------------------------------------------------- /pi_ldapproxy/test/test_https_policy.py: -------------------------------------------------------------------------------- 1 | from ldaptor.protocols.ldap import ldaperrors 2 | from twisted.internet import defer 3 | from twisted.web.client import BrowserLikePolicyForHTTPS 4 | 5 | from pi_ldapproxy.test.util import ProxyTestCase 6 | from pi_ldapproxy.util import DisabledVerificationPolicyForHTTPS 7 | 8 | 9 | class TestHTTPSPolicyDefault(ProxyTestCase): 10 | def test_browserlike(self): 11 | server, client = self.create_server_and_client([]) 12 | self.assertIsInstance(server.factory.agent._endpointFactory._policyForHTTPS, 13 | BrowserLikePolicyForHTTPS) 14 | 15 | 16 | class TestHTTPSPolicyDisabled(ProxyTestCase): 17 | additional_config = { 18 | 'privacyidea': { 19 | 'verify': False, 20 | } 21 | } 22 | 23 | def test_disabled(self): 24 | server, client = self.create_server_and_client([]) 25 | self.assertIsInstance(server.factory.agent._endpointFactory._policyForHTTPS, 26 | DisabledVerificationPolicyForHTTPS) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==21.2.0 2 | Automat==20.2.0 3 | cffi==1.16.0 4 | configobj==5.0.6 5 | constantly==15.1.0 6 | cryptography==36.0.0; python_version > '3.0' 7 | cryptography==3.3.2; python_version < '3.0' 8 | enum34==1.1.10; python_version < '3.0' 9 | future==0.18.2; python_version < '3.0' 10 | hyperlink==21.0.0 11 | idna==3.3; python_version > '3.0' 12 | idna==2.10; python_version < '3.0' 13 | incremental==21.3.0 14 | ipaddress==1.0.23; python_version < '3.0' 15 | ldaptor==21.2.0; python_version > '3.0' 16 | ldaptor==20.0.0; python_version < '3.0' 17 | passlib==1.7.4 18 | pyasn1==0.4.8 19 | pyasn1-modules==0.2.8 20 | pycparser==2.21 21 | PyHamcrest==1.10.1; python_version < '3.0' 22 | pyOpenSSL==21.0.0 23 | pyparsing==3.0.6; python_version > '3.0' 24 | pyparsing==2.4.7; python_version < '3.0' 25 | service-identity==21.1.0 26 | six==1.16.0 27 | Twisted==21.7.0; python_version > '3.0' 28 | Twisted==20.3.0; python_version < '3.0' 29 | typing_extensions==4.0.0; python_version > '3.0' 30 | typing==3.10.0.0; python_version < '3.0' 31 | zope.interface==5.4.0 32 | -------------------------------------------------------------------------------- /deploy/debian-ubuntu/control: -------------------------------------------------------------------------------- 1 | Source: privacyidea-ldap-proxy 2 | Maintainer: NetKnights GmbH 3 | Section: python 4 | Priority: optional 5 | Build-Depends: python-setuptools (>= 0.6b3), python-all (>= 2.7), debhelper (>= 7.4.3), python-setuptools, dh-python 6 | Standards-Version: 3.9.6 7 | 8 | Package: privacyidea-ldap-proxy 9 | Architecture: all 10 | Depends: ${misc:Depends}, ${python:Depends} 11 | X-Python-Version: >= 2.7 12 | Description: Intercepts LDAP Binds to add 2FA to LDAP 13 | privacyIDEA: identity, multifactor authentication, authorization. 14 | This package contains the python module for privacyIDEA. If you want 15 | to run it in a productive webserver you might want to install 16 | privacyidea-nginx or privacyidea-apache2. 17 | privacyIDEA is an open solution for strong two-factor authentication. 18 | privacyIDEA aims to not bind you to any decision of the authentication protocol 19 | or it does not dictate you where your user information should be stored. 20 | This is achieved by its totally modular architecture. 21 | privacyIDEA is not only open as far as its modular architecture is concerned. 22 | But privacyIDEA is completely licensed under the AGPLv3. 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - 'master' 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-18.04 12 | strategy: 13 | max-parallel: 3 14 | fail-fast: false 15 | matrix: 16 | python-version: [2.7, 3.6, 3.9] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Cache pip installation 25 | uses: actions/cache@v2 26 | id: pip-cache 27 | with: 28 | path: ~/.cache/pip 29 | key: ${{ runner.os }}-pip-py_${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} 30 | restore-keys: | 31 | ${{ runner.os }}-pip-py_${{ matrix.python-version }}- 32 | ${{ runner.os }}-pip- 33 | 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install -r requirements.txt 38 | pip install pytest 39 | - name: Test with pytest 40 | run: python -b -m pytest -v pi_ldapproxy/test 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | privacyidea-ldap-proxy 2 | ====================== 3 | 4 | `ldap-proxy` is implemented as a [twistd plugin](http://twistedmatrix.com/documents/current/core/howto/tap.html). 5 | 6 | Installation 7 | ------------ 8 | 9 | It is recommended to install ldap-proxy inside a virtualenv. 10 | 11 | virtualenv2 venv 12 | . ./venv/bin/activate 13 | pip install -r requirements.txt 14 | pip install . 15 | 16 | If you wish to develop ldap-proxy, you could instead install it in "editable mode" using 17 | 18 | pip install -e . 19 | 20 | Configuration 21 | ------------- 22 | 23 | `ldap-proxy` is configured via a configuration file. See `example-proxy.ini` as an example. 24 | 25 | Running 26 | ------- 27 | 28 | ldap-proxy can be run in the foreground as follows: 29 | 30 | twistd -n ldap-proxy -c config.ini 31 | 32 | twistd can be used to configure, e.g., logging and daemonizing. Refer to its 33 | [documentation](https://twistedmatrix.com/documents/current/core/howto/basics.html) for more information. 34 | 35 | `deploy/` contains an exemplary systemd service file. 36 | 37 | Testing 38 | ------- 39 | 40 | Unit tests are implemented using [Trial](http://twistedmatrix.com/documents/current/core/howto/trial.html), which 41 | is part of Twisted. They can be run using: 42 | 43 | trial pi_ldapproxy.test 44 | 45 | There are also a number of client-side scenarios implemented in the `scenarios/` directory. -------------------------------------------------------------------------------- /load-testing/locustfile.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import ldap3 4 | import time 5 | 6 | from locust import TaskSet 7 | from locust import events, Locust 8 | from locust import task 9 | 10 | USER_DNS = 'uid=user{:03},cn=users,dc=test,dc=intranet' 11 | USER_PASSWORDS = 'pin{:03}' 12 | 13 | USERS = dict((USER_DNS.format(i), USER_PASSWORDS.format(i)) for i in range(1000)) 14 | 15 | class LDAPConnection(ldap3.Connection): 16 | def bind(self, *args, **kwargs): 17 | start_time = time.time() 18 | result = ldap3.Connection.bind(self, *args, **kwargs) 19 | total_time = int((time.time() - start_time) * 1000) 20 | if result: 21 | events.request_success.fire(request_type='ldap-bind', name=self.user, response_time=total_time, 22 | response_length=0) 23 | else: 24 | events.request_failure.fire(request_type='ldap-bind', name=self.user, response_time=total_time, 25 | exception="{result}: {message}".format(**self.result)) 26 | 27 | class LDAPLocust(Locust): 28 | def __init__(self, *args, **kwargs): 29 | super(LDAPLocust, self).__init__(*args, **kwargs) 30 | dn = random.choice(list(USERS.keys())) 31 | pin = USERS[dn] 32 | self.client = LDAPConnection(self.host, 33 | user=dn, 34 | password=pin) 35 | 36 | class ApiUser(LDAPLocust): 37 | min_wait = 100 38 | max_wait = 1000 39 | 40 | class task_set(TaskSet): 41 | @task 42 | def bind(self): 43 | try: 44 | self.client.bind() 45 | finally: 46 | self.client.unbind() 47 | -------------------------------------------------------------------------------- /twisted/plugins/ldapproxy_plugin.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from twisted.internet import reactor 4 | from twisted.internet.endpoints import serverFromString 5 | from zope.interface import implementer 6 | 7 | from twisted.python import usage, log 8 | from twisted.plugin import IPlugin 9 | from twisted.application.service import IServiceMaker 10 | from twisted.application import internet 11 | 12 | from pi_ldapproxy.config import load_config 13 | from pi_ldapproxy.proxy import ProxyServerFactory 14 | 15 | 16 | class Options(usage.Options): 17 | #: The configuration file (which is mandatory) is passed as a parameter. 18 | #: It might be desirable to use a positional argument instead. 19 | optParameters = [["config", "c", None, "Configuration file"]] 20 | 21 | 22 | @implementer(IServiceMaker, IPlugin) 23 | class ProxyServiceMaker(object): 24 | tapname = "ldap-proxy" 25 | description = "privacyIDEA LDAP Proxy" 26 | options = Options 27 | 28 | def makeService(self, options): 29 | """ 30 | Called by Twisted after having parsed the command-line options. 31 | :param options: ``usage.Options`` instance 32 | :return: the server instance 33 | """ 34 | # Configuration is mandatory 35 | if options['config'] is None: 36 | print('You need to specify a configuration file via `twistd ldap-proxy -c config.ini`.') 37 | sys.exit(1) 38 | 39 | config = load_config(options['config']) 40 | factory = ProxyServerFactory(config) 41 | 42 | endpoint_string = serverFromString(reactor, config['ldap-proxy']['endpoint']) 43 | return internet.StreamServerEndpointService(endpoint_string, factory) 44 | 45 | 46 | serviceMaker = ProxyServiceMaker() 47 | -------------------------------------------------------------------------------- /deploy/debian-ubuntu/changelog: -------------------------------------------------------------------------------- 1 | privacyidea-ldap-proxy (0.6-1xenial) xenial; urgency=low 2 | 3 | * Close connection to backend in case of health checks (#44) 4 | * Completely disable hostname validation if verify = false (#42) 5 | * Set User-Agent to "privacyIDEA-LDAP-Proxy". 6 | * Improve comments, add notes about configuring LDAPS and 7 | deprecate confusing ``use-tls`` option. 8 | 9 | -- NetKnights GmbH Mon, 19 Nov 2018 17:30:00 +0200 10 | 11 | privacyidea-ldap-proxy (0.5-1xenial) xenial; urgency=low 12 | 13 | * Make handling of anonymous binds configurable (#31) 14 | * Allow connection reuse (#30) 15 | * Add an option to ignore SEARCH result references (#32) 16 | * Fix systemd service file and its location (#36) 17 | * Make errors and example config less confusing 18 | * Add case-insensitive option to app cache (#38) 19 | 20 | -- NetKnights GmbH Tue, 12 Dec 2017 16:30:00 +0200 21 | 22 | privacyidea-ldap-proxy (0.4.1~dev0-1xenial) xenial; urgency=low 23 | 24 | * Update requirements.txt to fix build on CentOS 25 | 26 | -- NetKnights GmbH Wed, 02 Aug 2017 13:00:00 +0200 27 | 28 | privacyidea-ldap-proxy (0.4~dev0-1xenial) xenial; urgency=low 29 | 30 | * Implement HTTPS certificate options for Proxy<->privacyIDEA connection (#27) 31 | * The app cache is now only triggered by searches by passthrough binds (#23) 32 | * Disallow multiple binds per connection for now (see #30) 33 | 34 | -- NetKnights GmbH Mon, 17 Jul 2017 17:00:00 +0200 35 | 36 | privacyidea-ldap-proxy (0.3~dev0-1xenial) xenial; urgency=low 37 | 38 | * init 39 | 40 | -- NetKnights GmbH Tue, 11 Jul 2017 08:30:00 +0200 41 | 42 | -------------------------------------------------------------------------------- /scenarios/1-only-user-bind.py: -------------------------------------------------------------------------------- 1 | """ 2 | Scenario 1) Web Application has no service account, performs only user bind, no search. 3 | 4 | Given a user's login name, the web application can automatically determine the corresponding DN 5 | (without having to ask the LDAP server). The web application does not query the LDAP server 6 | for any information about the user. 7 | """ 8 | import configobj 9 | import ldap3 10 | from common import construct_dn 11 | 12 | def login(username, password, ldap_server, base_dn, uid_attribute): 13 | """ 14 | Given username, password and a LDAP configuration, attempt a login. 15 | :param username: login name of the user 16 | :param password: supplied password 17 | :param ldap_server: LDAP server IP 18 | :param base_dn: see `construct_dn` 19 | :param uid_attribute: see `construct_dn` 20 | :return: boolean 21 | """ 22 | dn = construct_dn(username, base_dn, uid_attribute) 23 | print('Given username {!r}, constructed dn: {!r}'.format(username, dn)) 24 | print('Connecting to LDAP server {!r} ...'.format(ldap_server)) 25 | conn = ldap3.Connection(ldap_server, user=dn, password=password) 26 | print('Bind with password {!r} ...'.format(password), end=' ') 27 | result = conn.bind() 28 | if result: 29 | print('Successful bind!') 30 | else: 31 | print('Bind FAILED!') 32 | return result 33 | 34 | if __name__ == '__main__': 35 | with open('config.ini') as f: 36 | config = configobj.ConfigObj(f) 37 | password = config['password'] 38 | if not password: 39 | password = input('Password? ') 40 | login(config['username'], 41 | password, 42 | config['ldap-server'], 43 | config['base-dn'], 44 | config['uid-attribute']) 45 | -------------------------------------------------------------------------------- /pi_ldapproxy/util.py: -------------------------------------------------------------------------------- 1 | from OpenSSL.SSL import SSL_CB_HANDSHAKE_DONE 2 | from twisted.internet._sslverify import OpenSSLCertificateOptions, ClientTLSOptions 3 | from twisted.internet.interfaces import IOpenSSLClientConnectionCreator 4 | from twisted.web.client import _requireSSL 5 | from twisted.web.iweb import IPolicyForHTTPS 6 | from zope.interface import implementer 7 | 8 | 9 | @implementer(IOpenSSLClientConnectionCreator) 10 | class DisabledVerificationClientTLSOptions(ClientTLSOptions): 11 | """ 12 | ClientTLSOptions replacement that does not validate the hostname certificate at all, i.e. 13 | neither checks if the certificate matches the hostname nor the certificate's trust chain. 14 | """ 15 | def _identityVerifyingInfoCallback(self, connection, where, ret): 16 | """ 17 | In case *where* indicates that the SSL handshake has been done, 18 | this does nothing (as opposed to ClientTLSOptions._identityVerifyingInfoCallback, 19 | which would validate the certificate). In all other cases, 20 | the superclass method is called. 21 | """ 22 | if where & SSL_CB_HANDSHAKE_DONE: 23 | # ClientTLSOptions._identityVerifyingInfoCallback would validate the certificate 24 | # in that case. Instead, we just do nothing. 25 | pass 26 | else: 27 | return ClientTLSOptions._identityVerifyingInfoCallback(self, connection, where, ret) 28 | 29 | 30 | @implementer(IPolicyForHTTPS) 31 | class DisabledVerificationPolicyForHTTPS(object): 32 | """ HTTPS policy that does not check the certificate hostname """ 33 | @_requireSSL 34 | def creatorForNetloc(self, hostname, port): 35 | hostname = hostname.decode("ascii") 36 | certificate_options = OpenSSLCertificateOptions( 37 | trustRoot=None, 38 | acceptableProtocols=None, 39 | ) 40 | return DisabledVerificationClientTLSOptions(hostname, certificate_options.getContext()) 41 | -------------------------------------------------------------------------------- /scenarios/common.py: -------------------------------------------------------------------------------- 1 | import ldap3 2 | 3 | def construct_dn(username, base_dn, uid_attribute): 4 | """ 5 | Given username, base DN and the uid attribute (probably "cn"), construct the user's DN. 6 | :param username: supplied username 7 | :param base_dn: configured LDAP base DN 8 | :param uid_attribute: name of the RDN attribute against which we should match the username 9 | :return: 10 | """ 11 | return '{attr}={username},{base_dn}'.format( 12 | username=username, 13 | base_dn=base_dn, 14 | attr=uid_attribute 15 | ) 16 | 17 | 18 | def lookup_user(username,ldap_server, service_account_dn, service_account_password, 19 | base_dn, loginname_attribute, filter_template='({attr}={username})'): 20 | """ 21 | Given an user-provided username, lookup the user's DN. If the user couldn't be found, raise a RuntimeError. 22 | :param username: login name 23 | :param ldap_server: LDAP server IP 24 | :param service_account_dn: DN of the service account 25 | :param service_account_password: Password of the service account 26 | :param base_dn: DN under which users are located 27 | :param loginname_attribute: Attribute which should match *username* 28 | :return: User's DN as a string 29 | """ 30 | conn = ldap3.Connection(ldap_server, user=service_account_dn, password=service_account_password) 31 | result = conn.bind() 32 | if result: 33 | print('[Service Account] Successful bind!') 34 | conn.search(base_dn, 35 | filter_template.format(attr=loginname_attribute, username=username), 36 | attributes=['cn']) 37 | print('[Service Account] Looking for entry that satisfies {attr}={username}'.format( 38 | attr=loginname_attribute, 39 | username=username)) 40 | if len(conn.entries) != 1: 41 | raise RuntimeError('Expected one entry, found {}!'.format(len(conn.entries))) 42 | entry = conn.entries[0] 43 | return entry.entry_dn 44 | else: 45 | raise RuntimeError('[Service Account] Bind FAILED!') -------------------------------------------------------------------------------- /tools/logging-proxy.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | from ldaptor.protocols import pureldap 4 | from ldaptor.protocols.ldap.ldapclient import LDAPClient 5 | from ldaptor.protocols.ldap.ldapconnector import connectToLDAPEndpoint 6 | from ldaptor.protocols.ldap.proxybase import ProxyBase 7 | from twisted.internet import defer, protocol, reactor 8 | from twisted.python import log 9 | from functools import partial 10 | import sys 11 | 12 | class LoggingProxy(ProxyBase): 13 | """ 14 | A simple example of using `ProxyBase` to log requests and responses. 15 | """ 16 | def handleProxiedResponse(self, response, request, controls): 17 | """ 18 | Log the representation of the responses received. 19 | """ 20 | log.msg("Request [{:02x}] => {!r}".format(id(self), request)) 21 | log.msg("Response [{:02x}] => {!r}".format(id(self), response)) 22 | return defer.succeed(response) 23 | 24 | def ldapBindRequestRepr(self): 25 | l=[] 26 | l.append('version={0}'.format(self.version)) 27 | l.append('dn={0}'.format(repr(self.dn))) 28 | l.append('auth=****') 29 | if self.tag!=self.__class__.tag: 30 | l.append('tag={0}'.format(self.tag)) 31 | l.append('sasl={0}'.format(repr(self.sasl))) 32 | return self.__class__.__name__+'('+', '.join(l)+')' 33 | 34 | pureldap.LDAPBindRequest.__repr__ = ldapBindRequestRepr 35 | 36 | if __name__ == '__main__': 37 | """ 38 | Demonstration LDAP proxy; listens on localhost:10389 and 39 | passes all requests to localhost:8080. 40 | """ 41 | log.startLogging(sys.stderr) 42 | factory = protocol.ServerFactory() 43 | proxiedEndpointStr = sys.argv[1] 44 | use_tls = False 45 | clientConnector = partial( 46 | connectToLDAPEndpoint, 47 | reactor, 48 | proxiedEndpointStr, 49 | LDAPClient) 50 | 51 | def buildProtocol(): 52 | proto = LoggingProxy() 53 | proto.clientConnector = clientConnector 54 | proto.use_tls = use_tls 55 | return proto 56 | 57 | factory.protocol = buildProtocol 58 | reactor.listenTCP(389, factory) 59 | reactor.run() -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | info: 2 | @echo "make clean - remove all automatically created files" 3 | @echo "make debianzie - prepare the debian build environment in DEBUILD" 4 | @echo "make builddeb - build .deb file locally on ubuntu 14.04LTS!" 5 | 6 | #VERSION=1.3~dev5 7 | SHORT_VERSION=0.6 8 | #SHORT_VERSION=2.10~dev7 9 | VERSION_JESSIE=${SHORT_VERSION} 10 | VERSION=${SHORT_VERSION} 11 | LOCAL_SERIES=`lsb_release -a | grep Codename | cut -f2` 12 | BUILDENV_DIR=buildenv 13 | SRCDIRS=deploy pi_ldapproxy tools twisted ${BUILDENV_DIR} 14 | SRCFILES=setup.py MANIFEST.in Makefile Changelog LICENSE requirements.txt example-proxy.ini README.md 15 | 16 | INSTALL_DIR=\/opt\/privacyidea-ldap-proxy 17 | 18 | clean: 19 | find . -name \*.pyc -exec rm {} \; 20 | rm -fr build/ 21 | rm -fr dist/ 22 | rm -fr DEBUILD 23 | rm -fr RHBUILD 24 | rm -fr cover 25 | rm -f .coverage 26 | 27 | createvenv: 28 | rm -fr ${BUILDENV_DIR} 29 | virtualenv ${BUILDENV_DIR} 30 | (. buildenv/bin/activate; pip install -r requirements.txt) 31 | (. buildenv/bin/activate; pip install .) 32 | virtualenv --relocatable ${BUILDENV_DIR} 33 | sed -e s/'^VIRTUAL_ENV=.*'/'VIRTUAL_ENV=${INSTALL_DIR}'/ buildenv/bin/activate > activate.tmp 34 | mv activate.tmp buildenv/bin/activate 35 | 36 | debianize: 37 | make clean 38 | make createvenv 39 | mkdir -p DEBUILD/privacyidea-ldap-proxy.org/debian 40 | cp -r ${SRCDIRS} ${SRCFILES} DEBUILD/privacyidea-ldap-proxy.org || true 41 | cp LICENSE DEBUILD/privacyidea-ldap-proxy.org/debian/copyright 42 | cp LICENSE DEBUILD/privacyidea-ldap-proxy.org/debian/python-privacyidea.copyright 43 | cp LICENSE DEBUILD/privacyidea-ldap-proxy.org/debian/privacyidea-all.copyright 44 | (cd DEBUILD; tar -zcf privacyidea-ldap-proxy_${SHORT_VERSION}.orig.tar.gz --exclude=privacyidea-ldap-proxy.org/debian privacyidea-ldap-proxy.org) 45 | 46 | builddeb: 47 | make debianize 48 | ################## Renew the changelog 49 | cp -r deploy/debian-ubuntu/* DEBUILD/privacyidea-ldap-proxy.org/debian/ 50 | ################# Build 51 | (cd DEBUILD/privacyidea-ldap-proxy.org; debuild --no-lintian) 52 | 53 | pypi: 54 | python setup.py sdist upload 55 | 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | import sys 5 | import os 6 | from setuptools import setup 7 | 8 | package_directory = os.path.dirname(os.path.abspath(__file__)) 9 | 10 | 11 | def get_file_contents(file_path): 12 | """Get the context of the file using full path name.""" 13 | content = "" 14 | try: 15 | full_path = os.path.join(package_directory, file_path) 16 | content = open(full_path, 'r').read() 17 | except: 18 | print("### could not open file {0!r}".format(file_path), file=sys.stderr) 19 | return content 20 | 21 | 22 | setup(name='pi-ldapproxy', 23 | version='0.7', 24 | description='privacyIDEA LDAP Proxy based on Twisted', 25 | packages=['pi_ldapproxy', 'pi_ldapproxy.test', 'twisted.plugins'], 26 | author='privacyidea.org', 27 | license='AGPLv3', 28 | url='http://www.privacyidea.org', 29 | install_requires=['ldaptor', 30 | 'six', 31 | 'Twisted', 32 | 'configobj', 33 | 'pyOpenSSL', 34 | 'zope.interface'], 35 | long_description=get_file_contents('README.md'), 36 | classifiers=[ 37 | "Framework :: Twisted", 38 | "Intended Audience :: System Administrators", 39 | "License :: OSI Approved :: GNU Affero General Public License v3", 40 | "Programming Language :: Python", 41 | "Development Status :: 5 - Production/Stable", 42 | "Topic :: Internet", 43 | "Topic :: Security", 44 | "Topic :: System :: Systems Administration", 45 | "Topic :: System :: Systems Administration :: Authentication/Directory", 46 | "Topic :: System :: Systems Administration :: Authentication/Directory :: LDAP", 47 | "Intended Audience :: System Administrators", 48 | "Programming Language :: Python", 49 | "Programming Language :: Python :: 2", 50 | "Programming Language :: Python :: 2.7", 51 | "Programming Language :: Python :: 3", 52 | "Programming Language :: Python :: 3.6", 53 | "Programming Language :: Python :: 3.9"] 54 | ) 55 | -------------------------------------------------------------------------------- /scenarios/2-user-bind-search.py: -------------------------------------------------------------------------------- 1 | """ 2 | Scenario 2) Web Application has no service account, performs user bind and search. 3 | 4 | Given a user's login name, the web application can automatically determine the corresponding DN 5 | (without having to ask the LDAP server). The web application issues a bind request on behalf of the 6 | user and performs an LDAP search to retrieve the user's display name afterwards. 7 | """ 8 | from pprint import pprint 9 | 10 | import ldap3 11 | import configobj 12 | 13 | from common import construct_dn 14 | 15 | def login(username, password, ldap_server, base_dn, uid_attribute): 16 | """ 17 | Given username, password and a LDAP configuration, attempt a login. 18 | :param username: login name of the user 19 | :param password: supplied password 20 | :param ldap_server: LDAP server IP 21 | :param base_dn: see `construct_dn` 22 | :param uid_attribute: see `construct_dn` 23 | :return: dictionary with boolean key 'success'. In case of success, it also contains user information. 24 | """ 25 | dn = construct_dn(username, base_dn, uid_attribute) 26 | print('Given username {!r}, constructed dn: {!r}'.format(username, dn)) 27 | print('Connecting to LDAP server {!r} ...'.format(ldap_server)) 28 | conn = ldap3.Connection(ldap_server, user=dn, password=password) 29 | print('Bind with password {!r} ...'.format(password), end=' ') 30 | result = conn.bind() 31 | if result: 32 | print('Successful bind!') 33 | # Fetch user information 34 | conn.search(dn, 35 | '(objectClass=*)', 36 | attributes=ldap3.ALL_ATTRIBUTES) 37 | if len(conn.entries) != 1: 38 | raise RuntimeError('Expected one entry, found {}!'.format(len(conn.entries))) 39 | entry = conn.entries[0] 40 | return { 41 | 'success': True, 42 | 'displayName': entry.displayName.value, 43 | } 44 | else: 45 | print('Bind FAILED!') 46 | return { 47 | 'success': False, 48 | } 49 | 50 | if __name__ == '__main__': 51 | with open('config.ini') as f: 52 | config = configobj.ConfigObj(f) 53 | password = config['password'] 54 | if not password: 55 | password = input('Password? ') 56 | pprint(login(config['username'], 57 | password, 58 | config['ldap-server'], 59 | config['base-dn'], 60 | config['uid-attribute'])) 61 | -------------------------------------------------------------------------------- /pi_ldapproxy/test/test_realmmapping.py: -------------------------------------------------------------------------------- 1 | import twisted 2 | from ldaptor.ldapfilter import parseFilter 3 | from ldaptor.protocols import pureldap 4 | 5 | from pi_ldapproxy.realmmapping import find_app_marker, detect_login_preamble 6 | 7 | 8 | class TestRealmMapping(twisted.trial.unittest.TestCase): 9 | def test_find_app_marker(self): 10 | filter = parseFilter('(&(|(objectclass=person)(objectclass=App-someApp))(cn=user123))') 11 | self.assertEqual(find_app_marker(filter), 'someApp') 12 | 13 | filter = parseFilter('(&(|(objectclass=person)(someOtherAttribute=App-someApp))(cn=user123))') 14 | self.assertIsNone(find_app_marker(filter)) 15 | self.assertEqual(find_app_marker(filter, attribute='someOtherAttribute'), 'someApp') 16 | 17 | filter = parseFilter('(&(|(objectclass=person)(someOtherAttribute=Prefix-someApp))(cn=user123))') 18 | self.assertEqual(find_app_marker(filter, attribute='someOtherAttribute', value_prefix='Prefix-'), 'someApp') 19 | 20 | filter = parseFilter('(&(|(objectclass=person))(cn=user123))') 21 | self.assertIsNone(find_app_marker(filter)) 22 | 23 | def test_detect_login_preamble(self): 24 | filter = parseFilter('(&(|(objectclass=person)(objectclass=App-someApp))(cn=user123))') 25 | request = pureldap.LDAPSearchRequest(baseObject='cn=users,dc=test,dc=local', 26 | scope=pureldap.LDAP_SCOPE_wholeSubtree, derefAliases=0, 27 | sizeLimit=0, timeLimit=0, typesOnly=0, 28 | filter=filter, 29 | attributes=()) 30 | dn = 'cn=user123,cn=users,dc=test,dc=local' 31 | response = pureldap.LDAPSearchResultEntry(dn, [('cn', ['user123'])]) 32 | self.assertEqual(detect_login_preamble(request, response), (dn, 'someApp')) 33 | 34 | self.assertIsNone(detect_login_preamble(request, pureldap.LDAPSearchResultDone(0))) 35 | 36 | filter = parseFilter('(&(|(objectclass=person)(someATTRIBuTE=Foo-someApp))(cn=user123))') 37 | request = pureldap.LDAPSearchRequest(baseObject='cn=users,dc=test,dc=local', 38 | scope=pureldap.LDAP_SCOPE_wholeSubtree, derefAliases=0, 39 | sizeLimit=0, timeLimit=0, typesOnly=0, 40 | filter=filter, 41 | attributes=()) 42 | dn = 'cn=user123,cn=users,dc=test,dc=local' 43 | response = pureldap.LDAPSearchResultEntry(dn, [('cn', ['user123'])]) 44 | self.assertEqual(detect_login_preamble(request, response, 'someAttribute', 'Foo-'), (dn, 'someApp')) 45 | -------------------------------------------------------------------------------- /Changelog: -------------------------------------------------------------------------------- 1 | Version 0.7, 2021-11-29 2 | 3 | * Enable GitHub workflow 55 4 | * Update requirements #56 5 | 6 | Version 0.6.2, 2021-09-13 7 | 8 | * python2 to python3 #54 9 | * fix py3k issues #54 10 | * pi_ldapproxy/proxy: reference correct error attribute #54 11 | * pi_ldapproxy/test/mock: pass header value as string #54 12 | * pi_ldapproxy/test/test_proxy_simple: pass ldap uris to ldaptor #54 13 | * remove enum34 #54 14 | * Fix tests. But at what cost? #54 15 | * use six.ensure_str for stringification #54 16 | 17 | Version 0.6.1, 2018-11-22 18 | 19 | * Fix setup.py 20 | * Update cryptography to 2.4.2 Fixes #48 21 | 22 | Version 0.6, 2018-11-19 23 | 24 | * Close connection to backend in case of health checks (#44) 25 | * Completely disable hostname validation if verify = false (#42) 26 | * Set User-Agent to "privacyIDEA-LDAP-Proxy". 27 | * Improve comments, add notes about configuring LDAPS and 28 | deprecate confusing ``use-tls`` option. 29 | 30 | Version 0.5, 2017-12-12 31 | 32 | * Make handling of anonymous binds configurable (#31) 33 | * Allow connection reuse (#30) 34 | * Add an option to ignore SEARCH result references (#32) 35 | * Fix systemd service file and its location (#36) 36 | * Make errors and example config less confusing 37 | * Add case-insensitive option to app cache (#38) 38 | 39 | Version 0.4.1, 2017-08-02 40 | 41 | * Update requirements.txt to fix build on CentOS 42 | 43 | Version 0.4, 2017-07-17 44 | 45 | * Implement HTTPS certificate options for Proxy<->privacyIDEA connection (#27) 46 | * The app cache is now only triggered by searches by passthrough binds (#23) 47 | * Disallow multiple binds per connection for now (see #30) 48 | 49 | Version 0.3, 2017-07-03 50 | 51 | * Map some attributes case-insensitively (#22) 52 | * Test connection using the service account 53 | * Bind cache now takes the app marker into account (#20) 54 | * Implement LDAPS for Proxy<->Backend connection 55 | * Authentication requests handled by the bind cache: Only bind the service user 56 | if the corresponding config option is set 57 | 58 | Version 0.2, 2017-06-21 59 | 60 | * Add the app cache which stores the association of user DNs with app markers (#13) 61 | * Implement a realm mapper which assigns privacyIDEA realms to authentication requests 62 | based on a strategy, the default being the "static" realm mapper strategy (#13) 63 | * Add the "app-cache" realm mapper strategy to assign privacyIDEA realms 64 | based on app markers retrieved from the app cache (#13) 65 | * Improve error reporting in some corner cases (#17) 66 | 67 | Version 0.1, 2017-06-19 68 | 69 | * Initial release of the privacyIDEA LDAP Proxy 70 | -------------------------------------------------------------------------------- /pi_ldapproxy/config.py: -------------------------------------------------------------------------------- 1 | import configobj 2 | import sys 3 | import validate 4 | 5 | #: ``validator``-compatible config specification 6 | #: This is useful not only to report errors to the user, but also to perform automatic type conversion. 7 | CONFIG_SPEC = """ 8 | [privacyidea] 9 | instance = string 10 | certificate = string(default='') 11 | verify = boolean(default=True) 12 | 13 | [ldap-backend] 14 | endpoint = string 15 | use-tls = boolean(default=False) 16 | test-connection = boolean(default=True) 17 | 18 | [ldap-proxy] 19 | endpoint = string 20 | passthrough-binds = force_list 21 | bind-service-account = boolean(default=False) 22 | allow-search = boolean(default=False) 23 | allow-connection-reuse = boolean(default=False) 24 | ignore-search-result-references = boolean(default=False) 25 | forward-anonymous-binds = boolean(default=False) 26 | 27 | [service-account] 28 | dn = string 29 | password = string 30 | 31 | [bind-cache] 32 | enabled = boolean 33 | timeout = integer(default=3) 34 | 35 | [app-cache] 36 | enabled = boolean 37 | timeout = integer(default=3) 38 | attribute = string(default='objectclass') 39 | value-prefix = string(default='App-') 40 | case-insensitive = boolean(default=False) 41 | 42 | [user-mapping] 43 | strategy = string 44 | 45 | [realm-mapping] 46 | strategy = string 47 | """ 48 | 49 | def report_config_errors(config, result): 50 | """ 51 | Interpret configobj results and report configuration errors to the user. 52 | """ 53 | # from http://www.voidspace.org.uk/python/configobj.html#example-usage 54 | print('Invalid config file:') 55 | for entry in configobj.flatten_errors(config, result): 56 | # each entry is a tuple 57 | section_list, key, error = entry 58 | if key is not None: 59 | section_list.append(key) 60 | else: 61 | section_list.append('(missing section)') 62 | section_string = ', '.join(section_list) 63 | if error == False: 64 | error = 'Invalid value (or section).' 65 | print('{}: {}'.format(section_string, error)) 66 | 67 | 68 | def load_config(filename): 69 | """ 70 | Load, validate and return the configuration file stored at *filename*. 71 | In case the configuration is invalid, report the error to the user 72 | and exit with return code 1. 73 | :param filename: config filename as a string 74 | :return: a dictionary 75 | """ 76 | with open(filename, 'r') as f: 77 | config = configobj.ConfigObj(f, configspec=CONFIG_SPEC.splitlines()) 78 | 79 | validator = validate.Validator() 80 | result = config.validate(validator, preserve_errors=True) 81 | if result != True: 82 | report_config_errors(config, result) 83 | sys.exit(1) 84 | return config -------------------------------------------------------------------------------- /pi_ldapproxy/test/mock.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import http.client 4 | from ldaptor import testutil 5 | from ldaptor.protocols import pureldap 6 | from ldaptor.protocols.ldap.ldapclient import LDAPClient 7 | from twisted.internet import defer 8 | from twisted.web.client import Response 9 | from twisted.web.http_headers import Headers 10 | 11 | SUCCESSFUL_HEADERS = { 12 | 'Date': ['Fri, 24 Feb 2017 09:16:29 GMT'], 13 | 'Server': ['privacyIDEA Mock'], 14 | 'Cache-Control': ['no-cache'], 15 | 'Content-Type': ['application/json'], 16 | } 17 | 18 | class MockResponse(Response): 19 | def __init__(self, version, code, phrase, headers, body): 20 | headers.addRawHeader('Content-Length', str(len(body))) 21 | Response.__init__(self, version, code, phrase, headers, None) 22 | self.body = body 23 | 24 | def deliverBody(self, protocol): 25 | protocol.deferred.callback(self.body) 26 | 27 | 28 | class MockPrivacyIDEA(object): 29 | def __init__(self, credentials): 30 | self.credentials = credentials 31 | self.status = True 32 | self.response_code = 200 33 | self.authentication_requests = [] 34 | 35 | def is_password_correct(self, user, realm, password): 36 | key = '{}@{}'.format(user, realm) 37 | if key in self.credentials: 38 | expected = self.credentials[key] 39 | if isinstance(expected, list): 40 | expected_password = expected[0] 41 | if password == expected_password: 42 | expected.pop() 43 | return True 44 | else: 45 | return False 46 | else: 47 | return password == expected 48 | else: 49 | return False 50 | 51 | def build_response(self, result): 52 | data = { 53 | 'result': { 54 | 'status': self.status, 55 | }, 56 | } 57 | if self.status: 58 | data['result']['value'] = result 59 | body = json.dumps(data) 60 | headers = Headers(SUCCESSFUL_HEADERS) 61 | response = MockResponse(b'HTTP/1.1', self.response_code, http.client.responses[self.response_code], headers, body) 62 | return response 63 | 64 | def authenticate(self, url, user, realm, password): 65 | result = self.is_password_correct(user, realm, password) 66 | self.authentication_requests.append((user, realm, password, result)) 67 | return defer.succeed(self.build_response(result)) 68 | 69 | def inject(self, server): 70 | server.request_validate = self.authenticate 71 | 72 | 73 | class MockLDAPClient(testutil.LDAPClientTestDriver): 74 | def bind(self, dn, auth): 75 | self.send(pureldap.LDAPBindRequest(dn=dn, auth=auth)) 76 | 77 | -------------------------------------------------------------------------------- /scenarios/3-service-account-user-bind-search.py: -------------------------------------------------------------------------------- 1 | """ 2 | Scenario 3) App uses a service account to look up user's DN and uses user search after bind. 3 | 4 | Given a username, the app uses a service account to find the user's DN. For that, it performs an LDAP bind followed 5 | by an LDAP server. After that, it issues a bind on behalf of the user and uses an LDAP search under the user's 6 | context to retrieve profile information of the user. 7 | """ 8 | from pprint import pprint 9 | 10 | import configobj 11 | import ldap3 12 | from common import lookup_user 13 | 14 | def login(username, password, ldap_server, service_account_dn, service_account_password, base_dn, loginname_attribute): 15 | """ 16 | Given username, password and a LDAP configuration, attempt a login. 17 | :param username: login name of the user 18 | :param password: supplied password 19 | :param ldap_server: LDAP server IP 20 | :param service_account_dn: Distinguished Name of the service account 21 | :param service_account_password: Password of the service account 22 | :param base_dn: the base DN under which user search should be performed 23 | :param loginname_attribute: the attribute which contains the login name 24 | :return: dictionary with boolean key 'success'. In case of success, it also contains user information. 25 | """ 26 | dn = lookup_user(username, ldap_server, service_account_dn, service_account_password, base_dn, loginname_attribute) 27 | print('Given username {!r}, looked up dn: {!r}'.format(username, dn)) 28 | print('Connecting to LDAP server {!r} ...'.format(ldap_server)) 29 | conn = ldap3.Connection(ldap_server, user=dn, password=password) 30 | print('Bind with password {!r} ...'.format(password), end=' ') 31 | result = conn.bind() 32 | if result: 33 | print('Successful bind!') 34 | # Fetch user information 35 | conn.search(dn, '(objectClass=*)', attributes=ldap3.ALL_ATTRIBUTES) 36 | if len(conn.entries) != 1: 37 | raise RuntimeError('Expected one entry, found {}!'.format(len(conn.entries))) 38 | entry = conn.entries[0] 39 | return { 40 | 'success': True, 41 | 'displayName': entry.displayName.value, 42 | } 43 | else: 44 | print('Bind FAILED!') 45 | return { 46 | 'success': False, 47 | } 48 | 49 | if __name__ == '__main__': 50 | with open('config.ini') as f: 51 | config = configobj.ConfigObj(f) 52 | password = config['password'] 53 | if not password: 54 | password = input('Password? ') 55 | pprint(login(config['username'], 56 | password, 57 | config['ldap-server'], 58 | config['service-account-dn'], 59 | config['service-account-password'], 60 | config['base-dn'], 61 | config['loginname-attribute'])) -------------------------------------------------------------------------------- /pi_ldapproxy/test/test_proxy_user_lookup.py: -------------------------------------------------------------------------------- 1 | from ldaptor.protocols import pureldap 2 | from ldaptor.protocols.ldap import ldaperrors 3 | from twisted.internet import defer 4 | 5 | from pi_ldapproxy.test.util import ProxyTestCase 6 | 7 | 8 | class TestProxyUserLookup(ProxyTestCase): 9 | privacyidea_credentials = { 10 | 'hugo@default': 'secret' 11 | } 12 | 13 | additional_config = { 14 | 'user-mapping': { 15 | 'strategy': 'lookup', 16 | 'attribute': 'sAMAccountName' 17 | } 18 | } 19 | 20 | @defer.inlineCallbacks 21 | def test_simple_bind(self): 22 | dn = 'uid=thegreathugo,cn=users,dc=test,dc=local' 23 | server, client = self.create_server_and_client() 24 | service_account_client = self.inject_service_account_server([ 25 | pureldap.LDAPBindResponse(resultCode=0), # for service account 26 | ], [ 27 | pureldap.LDAPSearchResultEntry(dn, [('sAMAccountName', ['hugo'])]), 28 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 29 | ]) 30 | yield client.bind(dn, 'secret') 31 | # Assert that Proxy<->Backend (the actual connection) did not send anything 32 | server.client.assertNothingSent() 33 | # Assert that Proxy<->Backend (the lookup connection) did send something 34 | service_account_client.assertSent( 35 | pureldap.LDAPBindRequest(dn='uid=service,cn=users,dc=test,dc=local', auth='service-secret'), 36 | pureldap.LDAPSearchRequest(baseObject='uid=thegreathugo,cn=users,dc=test,dc=local', scope=0, derefAliases=0, 37 | sizeLimit=0, timeLimit=0, typesOnly=0, 38 | filter=pureldap.LDAPFilter_present(value='objectClass'), 39 | attributes=()), 40 | 'fake-unbind-by-LDAPClientTestDriver' 41 | ) 42 | 43 | def test_missing_attribute(self): 44 | dn = 'uid=thegreathugo,cn=users,dc=test,dc=local' 45 | server, client = self.create_server_and_client() 46 | service_account_client = self.inject_service_account_server([ 47 | pureldap.LDAPBindResponse(resultCode=0), # for service account 48 | ], [ 49 | pureldap.LDAPSearchResultEntry(dn, [('someOtherAttribute', ['hugo'])]), 50 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 51 | ]) 52 | d = client.bind(dn, 'secret') 53 | return self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 54 | 55 | def test_unknown_user(self): 56 | dn = 'uid=thegreathugo,cn=users,dc=test,dc=local' 57 | server, client = self.create_server_and_client() 58 | service_account_client = self.inject_service_account_server([ 59 | pureldap.LDAPBindResponse(resultCode=0), # for service account 60 | ], [ 61 | pureldap.LDAPSearchResultDone(ldaperrors.LDAPNoSuchObject.resultCode), 62 | ]) 63 | d = client.bind(dn, 'secret') 64 | return self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) -------------------------------------------------------------------------------- /pi_ldapproxy/test/test_bindcache.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import time 4 | from twisted.internet import task 5 | 6 | from pi_ldapproxy.bindcache import BindCache 7 | 8 | DN = 'cn=test,cn=users,dc=test,dc=intranet' 9 | DN_OTHER = 'cn=other,cn=users,dc=test,dc=intranet' 10 | APP = 'app1' 11 | APP_OTHER = 'app2' 12 | PASSWORD = 'test' 13 | PASSWORD_OTHER = 'foo' 14 | 15 | class BindCacheTest(unittest.TestCase): 16 | def test_multiple_entries(self): 17 | cache = BindCache() 18 | clock = task.Clock() 19 | cache.callLater = clock.callLater 20 | cache.add_to_cache(DN, APP, PASSWORD) 21 | cache.add_to_cache(DN_OTHER, APP_OTHER, PASSWORD_OTHER) 22 | self.assertTrue(cache.is_cached(DN, APP, PASSWORD)) 23 | self.assertTrue(cache.is_cached(DN_OTHER, APP_OTHER, PASSWORD_OTHER)) 24 | self.assertFalse(cache.is_cached(DN_OTHER, APP, PASSWORD)) 25 | self.assertFalse(cache.is_cached(DN, APP, PASSWORD_OTHER)) 26 | self.assertFalse(cache.is_cached(DN_OTHER, APP_OTHER, PASSWORD)) 27 | self.assertFalse(cache.is_cached(DN, APP_OTHER, PASSWORD_OTHER)) 28 | 29 | def test_manual_removal(self): 30 | cache = BindCache() 31 | clock = task.Clock() 32 | cache.callLater = clock.callLater 33 | cache.add_to_cache(DN, APP, PASSWORD) 34 | cache.add_to_cache(DN_OTHER, APP_OTHER, PASSWORD_OTHER) 35 | self.assertTrue(cache.is_cached(DN, APP, PASSWORD)) 36 | self.assertTrue(cache.is_cached(DN_OTHER, APP_OTHER, PASSWORD_OTHER)) 37 | # Remove (DN, APP, PASSWORD) 38 | cache.remove_from_cache(DN, APP, PASSWORD) 39 | self.assertFalse(cache.is_cached(DN, APP, PASSWORD)) 40 | self.assertTrue(cache.is_cached(DN_OTHER, APP_OTHER, PASSWORD_OTHER)) 41 | # Remove (DN_OTHER, APP_OTHER, PASSWORD_OTHER) 42 | cache.remove_from_cache(DN_OTHER, APP_OTHER, PASSWORD_OTHER) 43 | self.assertFalse(cache.is_cached(DN, APP, PASSWORD)) 44 | self.assertFalse(cache.is_cached(DN_OTHER, APP_OTHER, PASSWORD_OTHER)) 45 | # Remove (DN_OTHER, PASSWORD_OTHER) again 46 | cache.remove_from_cache(DN_OTHER, APP_OTHER, PASSWORD_OTHER) 47 | self.assertFalse(cache.is_cached(DN, APP, PASSWORD)) 48 | self.assertFalse(cache.is_cached(DN_OTHER, APP_OTHER, PASSWORD_OTHER)) 49 | 50 | def test_automatic_removal(self): 51 | clock = task.Clock() 52 | cache = BindCache(2) 53 | cache.callLater = clock.callLater 54 | # Add and wait a second, it should still be there 55 | cache.add_to_cache(DN, APP, PASSWORD) 56 | clock.advance(1) 57 | self.assertTrue(cache.is_cached(DN, APP, PASSWORD)) 58 | # Wait another two seconds, it should not be there anymore 59 | clock.advance(2) 60 | self.assertFalse(cache.is_cached(DN, APP, PASSWORD)) 61 | 62 | def test_callLater_failure(self): 63 | """ 64 | Test what happens in case ``reactor.callLater`` does not fire for some reason 65 | """ 66 | cache = BindCache(1) 67 | cache.callLater = lambda *args, **kwargs: None 68 | # Add and wait half a second, it should still be there 69 | cache.add_to_cache(DN, APP, PASSWORD) 70 | time.sleep(0.5) 71 | self.assertTrue(cache.is_cached(DN, APP, PASSWORD)) 72 | self.assertFalse(cache.is_cached(DN, APP_OTHER, PASSWORD)) 73 | # TODO: This is not perfect - find a way to test this without sleeping 74 | time.sleep(1) 75 | self.assertFalse(cache.is_cached(DN, APP, PASSWORD)) 76 | self.assertFalse(cache.is_cached(DN, APP_OTHER, PASSWORD)) 77 | -------------------------------------------------------------------------------- /scenarios/4-service-account-user-multiple-bind-search.py: -------------------------------------------------------------------------------- 1 | """ 2 | Scenario 4) App uses a service account to look up user's DN. Afterwards, the user sends *two* bind and search 3 | requests. 4 | 5 | Given a username, the app uses a service account to find the user's DN. For that, it performs an LDAP bind followed 6 | by an LDAP search. After that, it issues a bind on behalf of the user and uses an LDAP search under the user's 7 | context to retrieve profile information of the user. This is done twice during a 3-second timeframe. 8 | 9 | This will only work if the LDAP proxy caches bind requests. 10 | """ 11 | from pprint import pprint 12 | 13 | import ldap3 14 | import configobj 15 | import time 16 | 17 | from common import lookup_user 18 | 19 | def perform_login_search(dn, password, ldap_server): 20 | conn = ldap3.Connection(ldap_server, user=dn, password=password) 21 | print('Bind with password {!r} ...'.format(password), end=' ') 22 | result = conn.bind() 23 | if result: 24 | print('Successful bind!') 25 | # Fetch user information 26 | conn.search(dn, '(objectClass=*)', attributes=ldap3.ALL_ATTRIBUTES) 27 | if len(conn.entries) != 1: 28 | raise RuntimeError('Expected one entry, found {}!'.format(len(conn.entries))) 29 | entry = conn.entries[0] 30 | return { 31 | 'success': True, 32 | 'displayName': entry.displayName.value, 33 | } 34 | else: 35 | print('Bind FAILED!') 36 | return { 37 | 'success': False, 38 | } 39 | 40 | def login(username, password, ldap_server, service_account_dn, service_account_password, 41 | base_dn, loginname_attribute, wait_seconds): 42 | """ 43 | Given username, password and a LDAP configuration, attempt a login. 44 | :param username: login name of the user 45 | :param password: supplied password 46 | :param ldap_server: LDAP server IP 47 | :param service_account_dn: Distinguished Name of the service account 48 | :param service_account_password: Password of the service account 49 | :param base_dn: the base DN under which user search should be performed 50 | :param loginname_attribute: the attribute which contains the login name 51 | :param wait_seconds: Wait a specific number of seconds before issuing the second user bind request. 52 | :return: dictionary with boolean key 'success'. In case of success, it also contains user information. 53 | """ 54 | dn = lookup_user(username, ldap_server, service_account_dn, service_account_password, base_dn, loginname_attribute) 55 | print('Given username {!r}, looked up dn: {!r}'.format(username, dn)) 56 | print('[1] Connecting to LDAP server {!r} ...'.format(ldap_server)) 57 | result1 = perform_login_search(dn, password, ldap_server) 58 | if not result1['success']: 59 | print('exiting ...') 60 | return result1 61 | print('Waiting for {!r} seconds ...'.format(wait_seconds)) 62 | time.sleep(wait_seconds) 63 | print('[2] Connecting to LDAP server {!r} ...'.format(ldap_server)) 64 | result2 = perform_login_search(dn, password, ldap_server) 65 | return result2 66 | 67 | if __name__ == '__main__': 68 | with open('config.ini') as f: 69 | config = configobj.ConfigObj(f) 70 | password = config['password'] 71 | if not password: 72 | password = input('Password? ') 73 | pprint(login(config['username'], 74 | password, 75 | config['ldap-server'], 76 | config['service-account-dn'], 77 | config['service-account-password'], 78 | config['base-dn'], 79 | config['loginname-attribute'], 80 | int(config['wait-seconds']))) -------------------------------------------------------------------------------- /pi_ldapproxy/usermapping.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from ldaptor.protocols import pureldap 4 | from ldaptor.protocols.ldap import ldaperrors 5 | from ldaptor.protocols.ldap.ldapsyntax import LDAPEntry 6 | from twisted.internet import defer 7 | from twisted.logger import Logger 8 | 9 | log = Logger() 10 | 11 | class UserMappingError(RuntimeError): 12 | pass 13 | 14 | class UserMappingStrategy(object): 15 | """ 16 | Base class for user mappers, which are used to determine the user's privacyIDEA login name 17 | from an incoming LDAP Bind Request's distinguished name. 18 | """ 19 | def __init__(self, factory, config): 20 | """ 21 | :param factory: `ProxyServerFactory` instance 22 | :param config: `[user-mapping]` section of the config file, as a dictionary 23 | """ 24 | self.factory = factory 25 | self.config = config 26 | 27 | def resolve(self, dn): 28 | """ 29 | Given the distinguished name, determine the login name or raise UserMappingError. 30 | :param dn: DN as string 31 | :return: A Deferred which fires the login name (as a string) 32 | """ 33 | raise NotImplementedError() 34 | 35 | class MatchMappingStrategy(UserMappingStrategy): 36 | """ 37 | `match` mapping strategy: Expects a regular expression pattern which is matched against the incoming DN. 38 | It should contain one group which yields the username. 39 | 40 | Configuration: 41 | `pattern` contains the regular expression 42 | 43 | """ 44 | def __init__(self, factory, config): 45 | UserMappingStrategy.__init__(self, factory, config) 46 | self.pattern = re.compile(config['pattern'], re.IGNORECASE) 47 | 48 | def resolve(self, dn): 49 | match = self.pattern.match(dn) 50 | if match is not None: 51 | return defer.succeed(match.group(1)) 52 | else: 53 | raise UserMappingError(dn) 54 | 55 | class LookupMappingStrategy(UserMappingStrategy): 56 | """ 57 | `lookup` mapping strategy: Connect to the LDAP backend using the service account, find the 58 | corresponding entry and read a predefined attribute's value. 59 | 60 | Configuration: 61 | `attribute` contains the attribute name (e.g. sAMAccountName). 62 | 63 | """ 64 | def __init__(self, factory, config): 65 | UserMappingStrategy.__init__(self, factory, config) 66 | self.attribute = config['attribute'] 67 | 68 | @defer.inlineCallbacks 69 | def resolve(self, dn): 70 | """ 71 | Given a distinguished name, return the login name to be used with privacyIDEA 72 | :param dn: distinguished name as string 73 | :return: Deferred that fires the login name 74 | """ 75 | # Perform a LDAP bind, search for an object with the distinguished name *dn* 76 | client = yield self.factory.connect_service_account() 77 | entry = LDAPEntry(client, dn) 78 | try: 79 | results = yield entry.search('(objectClass=*)', scope=pureldap.LDAP_SCOPE_baseObject) 80 | # Assuming we found one, extract the login name attribute 81 | assert len(results) == 1 82 | if self.attribute not in results[0]: 83 | log.warn('Unknown lookup attribute: {attribute}', attribute=self.attribute) 84 | raise UserMappingError(dn) 85 | login_name_set = results[0][self.attribute] 86 | assert len(login_name_set) == 1 87 | (login_name,) = login_name_set 88 | defer.returnValue(login_name.decode('utf8')) 89 | except ldaperrors.LDAPNoSuchObject as e: 90 | # Apparently, the user could not be found. Raise the appropriate exception. 91 | raise UserMappingError(dn) 92 | finally: 93 | # TODO: Are there cases in which we can't unbind? 94 | yield client.unbind() 95 | 96 | USER_MAPPING_STRATEGIES = { 97 | 'match': MatchMappingStrategy, 98 | 'lookup': LookupMappingStrategy, 99 | } 100 | -------------------------------------------------------------------------------- /scenarios/5-multiple-search-app-marker.py: -------------------------------------------------------------------------------- 1 | """ 2 | Scenario 5) App uses a service account to look up user's DN. The search request's filter contains a marker which the 3 | LDAP proxy uses to identify the requesting application. 4 | Afterwards, the user sends *two* bind and search requests. 5 | 6 | Given a username, the app uses a service account to find the user's DN. For that, it performs an LDAP bind followed 7 | by an LDAP search. After that, it issues a bind on behalf of the user and uses an LDAP search under the user's 8 | context to retrieve profile information of the user. This is done twice during a 3-second timeframe. 9 | 10 | This will only work if the LDAP proxy caches bind requests. 11 | We cannot determine whether realm mapping is carried out correctly on the client side. 12 | """ 13 | from pprint import pprint 14 | 15 | import ldap3 16 | import configobj 17 | import time 18 | 19 | from common import lookup_user 20 | 21 | def perform_login_search(dn, password, ldap_server): 22 | conn = ldap3.Connection(ldap_server, user=dn, password=password) 23 | print('Bind with password {!r} ...'.format(password), end=' ') 24 | result = conn.bind() 25 | if result: 26 | print('Successful bind!') 27 | # Fetch user information 28 | conn.search(dn, '(objectClass=*)', attributes=ldap3.ALL_ATTRIBUTES) 29 | if len(conn.entries) != 1: 30 | raise RuntimeError('Expected one entry, found {}!'.format(len(conn.entries))) 31 | entry = conn.entries[0] 32 | return { 33 | 'success': True, 34 | 'displayName': entry.displayName.value, 35 | } 36 | else: 37 | print('Bind FAILED!') 38 | return { 39 | 'success': False, 40 | } 41 | 42 | def login(username, password, ldap_server, service_account_dn, service_account_password, 43 | base_dn, loginname_attribute, wait_seconds, marker_filter): 44 | """ 45 | Given username, password and a LDAP configuration, attempt a login. 46 | :param username: login name of the user 47 | :param password: supplied password 48 | :param ldap_server: LDAP server IP 49 | :param service_account_dn: Distinguished Name of the service account 50 | :param service_account_password: Password of the service account 51 | :param base_dn: the base DN under which user search should be performed 52 | :param loginname_attribute: the attribute which contains the login name 53 | :param wait_seconds: Wait a specific number of seconds before issuing the second user bind request. 54 | :param marker_filter: something like "objectclass=App-something" to implement an app marker 55 | :return: dictionary with boolean key 'success'. In case of success, it also contains user information. 56 | """ 57 | dn = lookup_user(username, ldap_server, service_account_dn, service_account_password, 58 | base_dn, loginname_attribute, '(|({attr}={username})(%s))' % marker_filter) 59 | print('Given username {!r}, looked up dn: {!r}'.format(username, dn)) 60 | print('[1] Connecting to LDAP server {!r} ...'.format(ldap_server)) 61 | result1 = perform_login_search(dn, password, ldap_server) 62 | if not result1['success']: 63 | print('exiting ...') 64 | return result1 65 | print('Waiting for {!r} seconds ...'.format(wait_seconds)) 66 | time.sleep(wait_seconds) 67 | print('[2] Connecting to LDAP server {!r} ...'.format(ldap_server)) 68 | result2 = perform_login_search(dn, password, ldap_server) 69 | return result2 70 | 71 | if __name__ == '__main__': 72 | with open('config.ini') as f: 73 | config = configobj.ConfigObj(f) 74 | password = config['password'] 75 | if not password: 76 | password = input('Password? ') 77 | pprint(login(config['username'], 78 | password, 79 | config['ldap-server'], 80 | config['service-account-dn'], 81 | config['service-account-password'], 82 | config['base-dn'], 83 | config['loginname-attribute'], 84 | int(config['wait-seconds']), 85 | config['marker-filter'])) -------------------------------------------------------------------------------- /pi_ldapproxy/bindcache.py: -------------------------------------------------------------------------------- 1 | from twisted.internet import reactor 2 | from twisted.logger import Logger 3 | 4 | log = Logger() 5 | 6 | class BindCache(object): 7 | """ 8 | A "bind cache" can be used to cache successful bind credentials for a predefined timeframe. This might be useful 9 | if applications issue multiple bind requests using the same credentials in a short timeframe. 10 | Obviously, using a bind cache has serious security implications: An eavesdropper could just reuse 11 | credentials. 12 | 13 | .. todo:: Consider the following scenario: Credentials are added to the bind cache, but ``reactor.callLater`` 14 | does not fire for some reason. Right now, the credentials can then never be added to the bind cache again. 15 | Is this wanted behavior? 16 | """ 17 | # Only indirectly calling reactor.callLater here to enable efficient unit testing 18 | # (see http://twistedmatrix.com/documents/current/core/howto/trial.html) 19 | callLater = reactor.callLater 20 | 21 | def __init__(self, timeout=5): 22 | """ 23 | :param timeout: Number of seconds after which the entry is removed from the bind cache 24 | """ 25 | self.timeout = timeout 26 | #: Map of tuples (dn, app_marker, password) to insertion timestamps (determined using ``reactor.seconds``) 27 | self._cache = {} 28 | 29 | def add_to_cache(self, dn, app_marker, password): 30 | """ 31 | Add the credentials to the bind cache. They are automatically removed from the cache after ``self.timeout`` 32 | seconds using the ``reactor.callLater`` mechanism. 33 | If the credentials are already found in the bind cache, the time until their removal is **not** extended! 34 | :param dn: user distinguished name 35 | :param app_marker: app marker 36 | :param password: user password 37 | """ 38 | item = (dn, app_marker, password) 39 | if item not in self._cache: 40 | current_time = reactor.seconds() 41 | log.info('Adding to bind cache: dn={dn!r}, marker={marker!r}, time={time!r}', 42 | dn=dn, marker=app_marker, time=current_time) 43 | self._cache[item] = current_time 44 | self.callLater(self.timeout, self.remove_from_cache, dn, app_marker, password) 45 | else: 46 | log.info('Already in the bind cache: dn={dn!r}, marker={marker!r}', 47 | dn=dn, marker=app_marker) 48 | 49 | def remove_from_cache(self, dn, app_marker, password): 50 | """ 51 | If the given credentials are found in the cache, they are removed. 52 | If they cannot be found, nothing happens. 53 | :param dn: user distinguished name 54 | :param app_marker: app marker 55 | :param password: user password 56 | """ 57 | item = (dn, app_marker, password) 58 | if item in self._cache: 59 | del self._cache[item] 60 | log.info('Removed from bind cache: dn={dn!r}/marker={marker!r} ({remaining!r} remaining)', 61 | dn=dn, marker=app_marker, remaining=len(self._cache)) 62 | else: 63 | log.info("Removal from bind cache failed as dn={dn!r} is not cached", dn=dn) 64 | 65 | def is_cached(self, dn, app_marker, password): 66 | """ 67 | Determines whether the given credentials are found in the bind cache. 68 | :param dn: user distinguished name 69 | :param app_marker: app marker as string 70 | :param password: user password 71 | :return: a boolean 72 | """ 73 | item = (dn, app_marker, password) 74 | if item in self._cache: 75 | current_time = reactor.seconds() 76 | inserted_time = self._cache[item] 77 | # Even though credentials **should** be removed automatically by ``callLater``, check 78 | # the stored timestamp. 79 | if current_time - inserted_time < self.timeout: 80 | return True 81 | else: 82 | log.info('Inconsistent bind cache: dn={dn!r}, marker={marker!r},' 83 | 'inserted={inserted!r}, current={current!r}', 84 | dn=dn, marker=app_marker, inserted=inserted_time, current=current_time, 85 | ) 86 | return False 87 | -------------------------------------------------------------------------------- /pi_ldapproxy/appcache.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from twisted.internet import reactor 4 | from twisted.logger import Logger 5 | 6 | log = Logger() 7 | 8 | def case_insensitive_dn(wrapped_function): 9 | """ 10 | This decorator is used in the ``AppCache`` class to implement case-insensitive DN storage. 11 | :param wrapped_function: A function accepting ``dn`` as first argument 12 | :return: Wrapper function which converts the DN to lowercase before passing it to ``wrapped_function`` 13 | """ 14 | @functools.wraps(wrapped_function) 15 | def dn_wrapper(self, dn, *args, **kwargs): 16 | if self.case_insensitive: 17 | dn = dn.lower() 18 | return wrapped_function(self, dn, *args, **kwargs) 19 | return dn_wrapper 20 | 21 | 22 | class AppCache(object): 23 | """ 24 | The app cache stores the association of a DN with a so-called "app marker" for a specific timeframe. 25 | """ 26 | # (see http://twistedmatrix.com/documents/current/core/howto/trial.html) 27 | callLater = reactor.callLater 28 | 29 | def __init__(self, timeout, case_insensitive=False): 30 | """ 31 | :param timeout: The association is kept in the cache for this timeframe 32 | :param case_insensitive: Convert DNs to lower case before storing them 33 | """ 34 | self.timeout = timeout 35 | self.case_insensitive = case_insensitive 36 | 37 | #: Map of dn to tuples (app marker, insertion timestamp) 38 | self._entries = {} 39 | 40 | @case_insensitive_dn 41 | def add_to_cache(self, dn, marker): 42 | """ 43 | Add the entry to the app cache. It will be automatically removed after ``timeout`` seconds. 44 | If an entry for ``dn`` (with any marker) already exists, it will be overwritten. 45 | Keep in mind that removal will then provoke a "Removal from app 46 | cache failed: ... mapped to ... " log message! 47 | If an entry for ``dn`` with the same marker exists, the eviction timeout will *not* 48 | be extended if it is added again. 49 | This function respects the ``case_insensitive`` option. 50 | :param dn: DN 51 | :param marker: App marker (a string) 52 | """ 53 | if dn in self._entries: 54 | log.info('Entry {dn!r} already cached {marker!r}, overwriting ...', 55 | dn=dn, marker=self._entries[dn]) 56 | current_time = reactor.seconds() 57 | log.info('Adding to app cache: dn={dn!r}, marker={marker!r}, time={time!r}', 58 | dn=dn, time=current_time, marker=marker) 59 | self._entries[dn] = (marker, current_time) 60 | self.callLater(self.timeout, self.remove_from_cache, dn, marker) 61 | 62 | @case_insensitive_dn 63 | def remove_from_cache(self, dn, marker): 64 | """ 65 | Remove the entry from the app cache. If the DN is mapped to a different marker, a warning is emitted 66 | and the entry is *not* removed! If the entry does not exist in the app cache, a message is 67 | written to the log. 68 | This function respects the ``case_insensitive`` option. 69 | :param dn: DN 70 | :param marker: App marker (a string) 71 | """ 72 | if dn in self._entries: 73 | stored_marker, stored_timestamp = self._entries[dn] 74 | if stored_marker == marker: 75 | del self._entries[dn] 76 | log.info('Removed {dn!r}/{marker!r} from app cache', dn=dn, marker=marker) 77 | else: 78 | log.warn('Removal from app cache failed: {dn!r} mapped to {stored!r}, not {marker!r}', 79 | dn=dn, stored=stored_marker, marker=marker) 80 | else: 81 | log.info('Removal from app cache failed, as dn={dn!r} is not cached', dn=dn) 82 | 83 | @case_insensitive_dn 84 | def get_cached_marker(self, dn): 85 | """ 86 | Retrieve the cached marker for the distinguished name ``dn``. This actually checks that the stored entry 87 | is still valid. If ``dn`` is not found in the cache, ``None`` is returned and a message is written to the log. 88 | This function respects the ``case_insensitive`` option. 89 | :param dn: DN 90 | :return: string or None 91 | """ 92 | if dn in self._entries: 93 | marker, timestamp = self._entries[dn] 94 | current_time = reactor.seconds() 95 | if current_time - timestamp < self.timeout: 96 | return marker 97 | else: 98 | log.warn('Inconsistent app cache: dn={dn!r}, inserted={inserted!r}, current={current!r}', 99 | dn=dn, inserted=timestamp, current=current_time 100 | ) 101 | else: 102 | log.info('No entry in app cache for dn={dn!r}', dn=dn) 103 | return None 104 | -------------------------------------------------------------------------------- /pi_ldapproxy/test/test_appcache.py: -------------------------------------------------------------------------------- 1 | import time 2 | import twisted 3 | from twisted.internet import task 4 | from twisted.trial.unittest import TestCase 5 | 6 | from pi_ldapproxy.appcache import AppCache 7 | 8 | DN1 = 'cn=test,cn=users,dc=test,dc=intranet' 9 | DN1_OTHERCASE = 'cn=test,cn=Users,dc=test,DC=IntraNET' 10 | DN2 = 'cn=other,cn=users,dc=test,dc=intranet' 11 | DN3 = 'cn=someone,cn=users,dc=test,dc=intranet' 12 | MARKER1 = 'marker1' 13 | MARKER2 = 'marker2' 14 | TIMEOUT = 5 15 | 16 | 17 | class TestAppCache(TestCase): 18 | def test_multiple_entries(self): 19 | cache = AppCache(TIMEOUT) 20 | clock = task.Clock() 21 | cache.callLater = clock.callLater 22 | cache.add_to_cache(DN1, MARKER1) 23 | cache.add_to_cache(DN1, MARKER2) 24 | cache.add_to_cache(DN2, MARKER1) 25 | self.assertEqual(cache.get_cached_marker(DN1), MARKER2) # is overwritten! 26 | self.assertEqual(cache.get_cached_marker(DN1_OTHERCASE), None) 27 | self.assertEqual(cache.get_cached_marker(DN2), MARKER1) 28 | self.assertEqual(cache.get_cached_marker(DN3), None) 29 | 30 | def test_manual_removal(self): 31 | cache = AppCache(TIMEOUT) 32 | clock = task.Clock() 33 | cache.callLater = clock.callLater 34 | cache.add_to_cache(DN1, MARKER1) 35 | cache.add_to_cache(DN2, MARKER2) 36 | self.assertEqual(cache.get_cached_marker(DN1), MARKER1) 37 | self.assertEqual(cache.get_cached_marker(DN2), MARKER2) 38 | # Remove (DN2, MARKER2) 39 | cache.remove_from_cache(DN2, MARKER2) 40 | self.assertEqual(cache.get_cached_marker(DN1), MARKER1) 41 | self.assertEqual(cache.get_cached_marker(DN2), None) 42 | # Overwrite (DN1, MARKER1) with MARKER2 43 | cache.add_to_cache(DN1, MARKER2) 44 | self.assertEqual(cache.get_cached_marker(DN1), MARKER2) 45 | # Remove (DN1, MARKER1) -- no effect. 46 | cache.remove_from_cache(DN1, MARKER1) 47 | self.assertEqual(cache.get_cached_marker(DN1), MARKER2) 48 | # Remove (DN1_OTHERCASE, MAKER2) -- no effect. 49 | cache.remove_from_cache(DN1_OTHERCASE, MARKER2) 50 | self.assertEqual(cache.get_cached_marker(DN1), MARKER2) 51 | # Remove (DN1, MARKER2) -- removed! 52 | cache.remove_from_cache(DN1, MARKER2) 53 | self.assertEqual(cache.get_cached_marker(DN1), None) 54 | self.assertEqual(cache.get_cached_marker(DN2), None) 55 | 56 | def test_case_insensitive(self): 57 | clock = task.Clock() 58 | cache = AppCache(2, True) 59 | cache.callLater = clock.callLater 60 | # Add and wait a second, it should still be there (also in another case) 61 | cache.add_to_cache(DN1, MARKER1) 62 | clock.advance(1) 63 | self.assertEqual(cache.get_cached_marker(DN1_OTHERCASE), MARKER1) 64 | # Wait another two seconds, it should not be there anymore 65 | clock.advance(2) 66 | self.assertEqual(cache.get_cached_marker(DN1_OTHERCASE), None) 67 | # Add and remove manually 68 | cache.add_to_cache(DN1, MARKER1) 69 | self.assertEqual(cache.get_cached_marker(DN1.upper()), MARKER1) 70 | self.assertEqual(cache.get_cached_marker(DN1_OTHERCASE.upper()), MARKER1) 71 | clock.advance(1) 72 | self.assertEqual(cache.get_cached_marker(DN1.upper()), MARKER1) 73 | self.assertEqual(cache.get_cached_marker(DN1_OTHERCASE.upper()), MARKER1) 74 | cache.remove_from_cache(DN1_OTHERCASE, MARKER1) 75 | self.assertEqual(cache.get_cached_marker(DN1.upper()), None) 76 | self.assertEqual(cache.get_cached_marker(DN1_OTHERCASE.upper()), None) 77 | 78 | def test_automatic_removal(self): 79 | clock = task.Clock() 80 | cache = AppCache(2) 81 | cache.callLater = clock.callLater 82 | # Add and wait a second, it should still be there 83 | cache.add_to_cache(DN1, MARKER1) 84 | clock.advance(1) 85 | self.assertEqual(cache.get_cached_marker(DN1), MARKER1) 86 | # Wait another two seconds, it should not be there anymore 87 | clock.advance(2) 88 | self.assertEqual(cache.get_cached_marker(DN1), None) 89 | 90 | def test_automatic_removal_overwrite_different(self): 91 | clock = task.Clock() 92 | cache = AppCache(2) 93 | cache.callLater = clock.callLater 94 | # Add and wait a second, it should still be there 95 | cache.add_to_cache(DN1, MARKER1) 96 | clock.advance(1) 97 | self.assertEqual(cache.get_cached_marker(DN1), MARKER1) 98 | cache.add_to_cache(DN1, MARKER2) 99 | # Wait a second, the new entry should still be there 100 | clock.advance(1) 101 | self.assertEqual(cache.get_cached_marker(DN1), MARKER2) 102 | # Wait another two seconds, it should not be there anymore 103 | clock.advance(2) 104 | self.assertEqual(cache.get_cached_marker(DN1), None) 105 | 106 | def test_callLater_failure(self): 107 | """ 108 | Test what happens in case ``reactor.callLater`` does not fire for some reason 109 | """ 110 | cache = AppCache(1) 111 | cache.callLater = lambda *args, **kwargs: None 112 | # Add and wait half a second, it should still be there 113 | cache.add_to_cache(DN1, MARKER1) 114 | time.sleep(0.5) 115 | self.assertEqual(cache.get_cached_marker(DN1), MARKER1) 116 | # TODO: This is not perfect - find a way to test this without sleeping 117 | time.sleep(1) 118 | self.assertEqual(cache.get_cached_marker(DN1), None) -------------------------------------------------------------------------------- /pi_ldapproxy/realmmapping.py: -------------------------------------------------------------------------------- 1 | from ldaptor.protocols.pureldap import LDAPFilter_and, LDAPFilter_or, LDAPFilter_equalityMatch, LDAPSearchRequest, \ 2 | LDAPSearchResultEntry 3 | from twisted.internet import defer 4 | from twisted.logger import Logger 5 | from six import ensure_str 6 | 7 | log = Logger() 8 | 9 | 10 | def find_app_marker(filter, attribute='objectclass', value_prefix='App-'): 11 | """ 12 | Given an ldaptor filter, try to extract an app marker, i.e. find 13 | a marker such that the filter contains an expression (=), 14 | e.g. (objectclass=App-ownCloud). 15 | It may be nested in &() and |() expressions. 16 | :param filter: ldaptor filter 17 | :param attribute: attribute name whose value contains the app marker (matched case-insensitively) 18 | :param value_prefix: prefix of the app marker (matched case-sensitively) 19 | :return: None or an app marker (a string) 20 | """ 21 | if isinstance(filter, LDAPFilter_and) or isinstance(filter, LDAPFilter_or): 22 | # recursively search and/or expressions 23 | for subfilter in filter: 24 | app_marker = find_app_marker(subfilter, attribute, value_prefix) 25 | if app_marker: 26 | return app_marker 27 | elif isinstance(filter, LDAPFilter_equalityMatch): 28 | # check attribute name and value prefix 29 | if ensure_str(filter.attributeDesc.value).lower() == attribute.lower(): 30 | value = ensure_str(filter.assertionValue.value) 31 | if value.startswith(value_prefix): 32 | return value[len(value_prefix):] 33 | return None 34 | 35 | 36 | def detect_login_preamble(request, response, attribute='objectclass', value_prefix='App-'): 37 | """ 38 | Determine whether the request/response pair constitutes a login preamble. 39 | If it does, return the login DN and the app marker. 40 | :param request: LDAP request 41 | :param response: LDAP response 42 | :param attribute: see ``find_app_marker`` 43 | :param value_prefix: see ``find_app_marker`` 44 | :return: A tuple ``(DN, app marker)`` or None 45 | """ 46 | if isinstance(request, LDAPSearchRequest) and request.filter: 47 | # TODO: Check base dn? 48 | marker = find_app_marker(request.filter, attribute, value_prefix) 49 | # i.e. we do not notice if the response has >1 entries 50 | if marker is not None and isinstance(response, LDAPSearchResultEntry): 51 | return (response.objectName, marker) 52 | return None 53 | 54 | 55 | class RealmMappingError(Exception): 56 | pass 57 | 58 | 59 | class RealmMappingStrategy(object): 60 | """ 61 | Base class for realm mappers, which are used to determine the user's privacyIDEA realm 62 | from an incoming LDAP Bind Request's distinguished name. 63 | """ 64 | def __init__(self, factory, config): 65 | """ 66 | :param factory: `ProxyServerFactory` instance 67 | :param config: `[realm-mapping]` section of the config file, as a dictionary 68 | """ 69 | self.factory = factory 70 | self.config = config 71 | 72 | def resolve(self, dn): 73 | """ 74 | Given the distinguished name, determine the realm name or raise RealmMappingError. 75 | :param dn: DN as string 76 | :return: A Deferred which fires (app marker, realm name) (as strings) 77 | """ 78 | raise NotImplementedError() 79 | 80 | 81 | class StaticMappingStrategy(RealmMappingStrategy): 82 | """ 83 | `static` mapping strategy: Simply assign the same static realm to all authentication request. 84 | 85 | Configuration: 86 | `realm` contains the realm name (can also be empty) 87 | 88 | """ 89 | def __init__(self, factory, config): 90 | RealmMappingStrategy.__init__(self, factory, config) 91 | self.realm = config['realm'] 92 | 93 | def resolve(self, dn): 94 | return defer.succeed((self.realm, self.realm)) 95 | 96 | 97 | class AppCacheMappingStrategy(RealmMappingStrategy): 98 | """ 99 | `app-cache` mapping strategy: Look up the app cache to find the correct realm. 100 | If you use this mapping strategy, make sure the app cache is enabled 101 | (see `[app-cache]`). 102 | 103 | Configuration: 104 | `mappings` is a subsection which maps app markers (as witnessed in LDAP search requests) 105 | to realm names. 106 | 107 | e.g.: 108 | 109 | [realm-mapping] 110 | strategy = app-cache 111 | 112 | [[mappings]] 113 | myapp-marker = myapp_realm 114 | """ 115 | def __init__(self, factory, config): 116 | RealmMappingStrategy.__init__(self, factory, config) 117 | self.mappings = config['mappings'] 118 | 119 | def resolve(self, dn): 120 | """ 121 | Look up ``dn`` in the app cache, find the associated marker, look up the associated 122 | realm in the mapping config, return it. 123 | """ 124 | marker = self.factory.app_cache.get_cached_marker(dn) # TODO: app cache might be None 125 | if marker is None: 126 | raise RealmMappingError('No entry in app cache for dn={dn!r}'.format(dn=dn)) 127 | realm = self.mappings.get(marker) 128 | if realm is None: 129 | raise RealmMappingError('No mapping for marker={marker!r}'.format(marker=marker)) 130 | return defer.succeed((marker, realm)) 131 | 132 | REALM_MAPPING_STRATEGIES = { 133 | 'static': StaticMappingStrategy, 134 | 'app-cache': AppCacheMappingStrategy, 135 | } 136 | -------------------------------------------------------------------------------- /pi_ldapproxy/test/util.py: -------------------------------------------------------------------------------- 1 | # Contains code from ldaptor (createServer from ldaptor/testutil.py), 2 | # which is licensed under the MIT license as follows. 3 | # Copyright (c) 2002-2014, Ldaptor Contributors (see AUTHORS) 4 | # 5 | # Ldaptor is licensed under the MIT license for the majority of the 6 | # files, with exceptions listed below. 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining 9 | # a copy of this software and associated documentation files (the 10 | # "Software"), to deal in the Software without restriction, including 11 | # without limitation the rights to use, copy, modify, merge, publish, 12 | # distribute, sublicense, and/or sell copies of the Software, and to 13 | # permit persons to whom the Software is furnished to do so, subject to 14 | # the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be 17 | # included in all copies or substantial portions of the Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 20 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 21 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 22 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 23 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 24 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 25 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | 27 | import configobj 28 | import twisted 29 | import validate 30 | from ldaptor import testutil 31 | from ldaptor.protocols.ldap.ldapclient import LDAPClient 32 | from ldaptor.test.util import returnConnected, IOPump 33 | from twisted.internet import defer 34 | from twisted.internet import reactor 35 | from twisted.internet.task import LoopingCall 36 | 37 | from pi_ldapproxy.config import CONFIG_SPEC 38 | from pi_ldapproxy.proxy import TwoFactorAuthenticationProxy, ProxyServerFactory 39 | from pi_ldapproxy.test.mock import MockPrivacyIDEA, MockLDAPClient 40 | 41 | BASE_CONFIG = """ 42 | [privacyidea] 43 | instance = http://example.com 44 | 45 | [ldap-backend] 46 | endpoint = tcp:host=example.com:port=1337:timeout=1 47 | use-tls = false 48 | test-connection = false 49 | 50 | [service-account] 51 | dn = "uid=service,cn=users,dc=test,dc=local" 52 | password = service-secret 53 | 54 | [ldap-proxy] 55 | endpoint = tcp:1389 56 | passthrough-binds = "uid=passthrough,cn=users,dc=test,dc=local" 57 | bind-service-account = false 58 | allow-search = false 59 | 60 | [user-mapping] 61 | #strategy = lookup 62 | #attribute = uid 63 | strategy = match 64 | pattern = "uid=([^,]+),cn=users,dc=test,dc=local" 65 | 66 | [realm-mapping] 67 | strategy = static 68 | realm = default 69 | 70 | [app-cache] 71 | enabled = false 72 | 73 | [bind-cache] 74 | enabled = false 75 | """ 76 | 77 | def load_test_config(): 78 | config = configobj.ConfigObj(BASE_CONFIG.splitlines(), configspec=CONFIG_SPEC.splitlines()) 79 | validator = validate.Validator() 80 | result = config.validate(validator, preserve_errors=True) 81 | assert result == True, "Invalid test config" 82 | return config 83 | 84 | class ProxyTestCase(twisted.trial.unittest.TestCase): 85 | additional_config = {} 86 | privacyidea_credentials = {} 87 | 88 | def get_config(self): 89 | config = load_test_config() 90 | for section, contents in self.additional_config.items(): 91 | for key, value in contents.items(): 92 | config[section][key] = value 93 | return config 94 | 95 | def inject_service_account_server(self, *responses): 96 | client = MockLDAPClient(*responses) 97 | 98 | @defer.inlineCallbacks 99 | def _factory_connect_service_account(): 100 | client.connectionMade() # TODO: Necessary here? 101 | yield client.bind(self.factory.service_account_dn, self.factory.service_account_password) 102 | defer.returnValue(client) 103 | 104 | self.factory.connect_service_account = _factory_connect_service_account 105 | return client 106 | 107 | def setUp(self): 108 | self.factory = ProxyServerFactory(self.get_config()) 109 | self.pump_call = LoopingCall(self.pump_all) 110 | self.pump_call.start(0.1) 111 | 112 | self.privacyidea = MockPrivacyIDEA(self.privacyidea_credentials) 113 | self.pumps = set() 114 | 115 | def tearDown(self): 116 | self.pump_call.stop() 117 | # remove all pumps that have been created 118 | for pump in self.pumps: 119 | IOPump.active.remove(pump) 120 | self.pumps = set() 121 | 122 | def pump_all(self): 123 | for pump in IOPump.active: 124 | pump.pump() 125 | 126 | def create_server(self, *responses, **kwds): 127 | """ 128 | Create a server for each test. 129 | """ 130 | protocol = kwds.get("protocol", TwoFactorAuthenticationProxy) 131 | server = protocol() 132 | clientTestDriver = MockLDAPClient(*responses) 133 | 134 | def simulateConnectToServer(): 135 | d = defer.Deferred() 136 | 137 | def onConnect(): 138 | clientTestDriver.connectionMade() 139 | d.callback(clientTestDriver) 140 | 141 | reactor.callLater(0, onConnect) 142 | return d 143 | 144 | clientConnector = kwds.get('clientConnector', simulateConnectToServer) 145 | server.clientConnector = clientConnector 146 | server.factory = self.factory 147 | server.clientTestDriver = clientTestDriver 148 | self.privacyidea.inject(server) 149 | return server 150 | 151 | def create_server_and_client(self, *responses, **kwds): 152 | client = LDAPClient() 153 | server = self.create_server(*responses, **kwds) 154 | self.pumps.add(returnConnected(server, client)) 155 | return server, client -------------------------------------------------------------------------------- /pi_ldapproxy/test/test_proxy_user_bind.py: -------------------------------------------------------------------------------- 1 | from ldaptor.protocols import pureldap 2 | from ldaptor.protocols.ldap import ldaperrors 3 | from ldaptor.protocols.ldap.ldapsyntax import LDAPEntry 4 | from twisted.internet import defer 5 | 6 | from pi_ldapproxy.test.util import ProxyTestCase 7 | 8 | 9 | class TestProxyUserBind(ProxyTestCase): 10 | privacyidea_credentials = { 11 | 'hugo@default': 'secret' 12 | } 13 | 14 | additional_config = { 15 | 'ldap-proxy': { 16 | 'bind-service-account': True, 17 | 'allow-search': True, 18 | } 19 | } 20 | 21 | @defer.inlineCallbacks 22 | def test_simple_search(self): 23 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 24 | server, client = self.create_server_and_client([ 25 | pureldap.LDAPBindResponse(resultCode=0), # for service account 26 | ], [ 27 | pureldap.LDAPSearchResultEntry(dn, [('someattr', ['somevalue'])]), 28 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 29 | ]) 30 | yield client.bind(dn, 'secret') 31 | # Assert that Proxy<->Backend uses the correct credentials 32 | server.client.assertSent( 33 | pureldap.LDAPBindRequest(dn='uid=service,cn=users,dc=test,dc=local', auth='service-secret'), 34 | ) 35 | # Perform a simple search in the context of the service account 36 | entry = LDAPEntry(client, dn) 37 | results = yield entry.search('(objectClass=*)', scope=pureldap.LDAP_SCOPE_baseObject) 38 | self.assertEqual(len(results), 1) 39 | self.assertEqual(len(results[0]['someattr']), 1) 40 | (value,) = results[0]['someattr'] 41 | self.assertEqual(value.decode('utf8'), 'somevalue') 42 | 43 | def test_wrong_credentials(self): 44 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 45 | server, client = self.create_server_and_client([ 46 | pureldap.LDAPBindResponse(resultCode=0), # for service account 47 | ], [ 48 | pureldap.LDAPSearchResultEntry(dn, [('someattr', ['somevalue'])]), 49 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 50 | ]) 51 | d = client.bind(dn, 'wrong') 52 | return self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 53 | 54 | class TestProxyUserBindNoSearch(ProxyTestCase): 55 | privacyidea_credentials = { 56 | 'hugo@default': 'secret' 57 | } 58 | 59 | additional_config = { 60 | 'ldap-proxy': { 61 | 'bind-service-account': True, 62 | 'allow-search': False, 63 | } 64 | } 65 | 66 | @defer.inlineCallbacks 67 | def test_user_search_fails(self): 68 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 69 | server, client = self.create_server_and_client([ 70 | pureldap.LDAPBindResponse(resultCode=0), # for service account 71 | ], [ 72 | pureldap.LDAPSearchResultEntry(dn, [('someattr', ['somevalue'])]), 73 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 74 | ]) 75 | yield client.bind(dn, 'secret') 76 | # Assert that Proxy<->Backend uses the correct credentials 77 | server.client.assertSent( 78 | pureldap.LDAPBindRequest(dn='uid=service,cn=users,dc=test,dc=local', auth='service-secret'), 79 | ) 80 | # Try to perform a simple search in the context of the service account 81 | entry = LDAPEntry(client, dn) 82 | d = entry.search('(objectClass=*)', scope=pureldap.LDAP_SCOPE_baseObject) 83 | yield self.assertFailure(d, ldaperrors.LDAPInsufficientAccessRights) 84 | 85 | class TestProxyPassthroughSearch(ProxyTestCase): 86 | privacyidea_credentials = { 87 | 'hugo@default': 'secret' 88 | } 89 | 90 | additional_config = { 91 | 'ldap-proxy': { 92 | 'bind-service-account': False, 93 | 'allow-search': True, 94 | } 95 | } 96 | 97 | @defer.inlineCallbacks 98 | def test_user_search_fails(self): 99 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 100 | server, client = self.create_server_and_client([ 101 | # TODO: Would the backend actually answer like that? 102 | pureldap.LDAPSearchResultDone(ldaperrors.LDAPInsufficientAccessRights.resultCode), 103 | ]) 104 | yield client.bind(dn, 'secret') 105 | # Assert that there was no traffic between Proxy<->Backend 106 | server.client.assertNothingSent() 107 | # Try to perform a simple search in the context of the service account 108 | entry = LDAPEntry(client, dn) 109 | d = entry.search('(objectClass=*)', scope=pureldap.LDAP_SCOPE_baseObject) 110 | yield self.assertFailure(d, ldaperrors.LDAPInsufficientAccessRights) 111 | 112 | @defer.inlineCallbacks 113 | def test_passthrough_account_search_succeeds(self): 114 | dn = 'uid=passthrough,cn=users,dc=test,dc=local' 115 | server, client = self.create_server_and_client([ 116 | pureldap.LDAPBindResponse(resultCode=0), # for service account 117 | ], [ 118 | pureldap.LDAPSearchResultEntry(dn, [('someattr', ['somevalue'])]), 119 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 120 | ]) 121 | yield client.bind(dn, 'service-secret') 122 | # Assert that Proxy<->Backend uses the correct credentials 123 | server.client.assertSent( 124 | pureldap.LDAPBindRequest(dn=dn, auth='service-secret'), 125 | ) 126 | # Perform a simple search in the context of the service account 127 | entry = LDAPEntry(client, dn) 128 | results = yield entry.search('(objectClass=*)', scope=pureldap.LDAP_SCOPE_baseObject) 129 | self.assertEqual(len(results), 1) 130 | self.assertEqual(len(results[0]['someattr']), 1) 131 | (value,) = results[0]['someattr'] 132 | self.assertEqual(value.decode('utf8'), 'somevalue') 133 | -------------------------------------------------------------------------------- /pi_ldapproxy/test/test_proxy_bind_cache.py: -------------------------------------------------------------------------------- 1 | import time 2 | from ldaptor.protocols import pureldap 3 | from ldaptor.protocols.ldap import ldaperrors 4 | from ldaptor.protocols.ldap.ldapsyntax import LDAPEntry 5 | from twisted.internet import defer 6 | 7 | from pi_ldapproxy.test.util import ProxyTestCase 8 | 9 | 10 | class TestProxyUserBind(ProxyTestCase): 11 | privacyidea_credentials = { 12 | 'hugo@default': 'secret', 13 | } 14 | 15 | additional_config = { 16 | 'bind-cache': { 17 | 'enabled': True, 18 | 'timeout': 2, 19 | } 20 | } 21 | 22 | @defer.inlineCallbacks 23 | def test_simple_bind_succeeds(self): 24 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 25 | server, client = self.create_server_and_client() 26 | yield client.bind(dn, 'secret') 27 | self.assertEqual(self.privacyidea.authentication_requests, 28 | [('hugo', 'default', 'secret', True)]) 29 | time.sleep(3) # clean bind cache 30 | 31 | @defer.inlineCallbacks 32 | def test_subsequent_binds_succeed(self): 33 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 34 | server, client = self.create_server_and_client([ 35 | pureldap.LDAPBindResponse(resultCode=0), # for service account 36 | ]) 37 | yield client.bind(dn, 'secret') 38 | time.sleep(0.5) 39 | server2, client2 = self.create_server_and_client([ 40 | pureldap.LDAPBindResponse(resultCode=0), # for service account 41 | ]) 42 | yield client2.bind(dn, 'secret') 43 | # but only one authentication request to privacyIDEA! 44 | self.assertEqual(self.privacyidea.authentication_requests, 45 | [('hugo', 'default', 'secret', True)]) 46 | time.sleep(2) # to clean the reactor 47 | 48 | @defer.inlineCallbacks 49 | def test_bind_cache_cleared(self): 50 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 51 | server, client = self.create_server_and_client([ 52 | pureldap.LDAPBindResponse(resultCode=0), # for service account 53 | ]) 54 | yield client.bind(dn, 'secret') 55 | time.sleep(3) # which cleans the bind cache 56 | server2, client2 = self.create_server_and_client([ 57 | pureldap.LDAPBindResponse(resultCode=0), # for service account 58 | ]) 59 | yield client2.bind(dn, 'secret') 60 | # two authentication requests to privacyIDEA! 61 | self.assertEqual(self.privacyidea.authentication_requests, 62 | [('hugo', 'default', 'secret', True), 63 | ('hugo', 'default', 'secret', True)]) 64 | time.sleep(2) # to clean the reactor 65 | 66 | @defer.inlineCallbacks 67 | def test_bind_cache_different_password(self): 68 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 69 | server, client = self.create_server_and_client([ 70 | pureldap.LDAPBindResponse(resultCode=0), # for service account 71 | ]) 72 | yield client.bind(dn, 'secret') 73 | time.sleep(0.5) 74 | server2, client2 = self.create_server_and_client([ 75 | pureldap.LDAPBindResponse(resultCode=0), # for service account 76 | ]) 77 | d = client2.bind(dn, 'something-else') 78 | yield self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 79 | # two authentication requests to privacyIDEA! 80 | self.assertEqual(self.privacyidea.authentication_requests, 81 | [('hugo', 'default', 'secret', True), 82 | ('hugo', 'default', 'something-else', False)]) 83 | time.sleep(2) # to clean the reactor 84 | 85 | @defer.inlineCallbacks 86 | def test_bind_cache_different_password(self): 87 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 88 | server, client = self.create_server_and_client([ 89 | pureldap.LDAPBindResponse(resultCode=0), # for service account 90 | ]) 91 | yield client.bind(dn, 'secret') 92 | time.sleep(0.5) 93 | server2, client2 = self.create_server_and_client([ 94 | pureldap.LDAPBindResponse(resultCode=0), # for service account 95 | ]) 96 | d = client2.bind(dn, 'something-else') 97 | yield self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 98 | # two authentication requests to privacyIDEA! 99 | self.assertEqual(self.privacyidea.authentication_requests, 100 | [('hugo', 'default', 'secret', True), 101 | ('hugo', 'default', 'something-else', False)]) 102 | time.sleep(2) # to clean the reactor 103 | 104 | @defer.inlineCallbacks 105 | def test_bind_cache_different_app(self): 106 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 107 | server, client = self.create_server_and_client([ 108 | pureldap.LDAPBindResponse(resultCode=0), # for service account 109 | ]) 110 | yield client.bind(dn, 'secret') 111 | time.sleep(0.5) 112 | server2, client2 = self.create_server_and_client([ 113 | pureldap.LDAPBindResponse(resultCode=0), # for service account 114 | ]) 115 | # Monkey-patch the realm mapper (this is suboptimal) 116 | _old_resolve = self.factory.realm_mapper.resolve 117 | # Model a second app which maps to the same realm 118 | self.factory.realm_mapper.resolve = lambda dn: defer.succeed(('other-app', 'default')) 119 | yield client2.bind(dn, 'secret') 120 | # two authentication requests to privacyIDEA! 121 | # this means that the second request was not taken from the bind cache 122 | self.assertEqual(self.privacyidea.authentication_requests, 123 | [('hugo', 'default', 'secret', True), 124 | ('hugo', 'default', 'secret', True)]) 125 | time.sleep(2) # to clean the reactor 126 | self.factory.realm_mapper.resolve = _old_resolve -------------------------------------------------------------------------------- /deploy/ubuntu-config/proxy.ini: -------------------------------------------------------------------------------- 1 | [privacyidea] 2 | # URL of your privacyIDEA installation 3 | instance = http://192.0.2.2 4 | # In case you use HTTPS: 5 | # You can disable hostname validation completely, which is not recommended. 6 | #verify = False 7 | # By default, verify is set to True. 8 | # If verify=True, you can manually pass a HTTPS server certificate (PEM format). 9 | #certificate = /etc/privacyidea/server.pem 10 | # If verify=True but you do not explicitly pass a certficate, 11 | # the server certificate is validated against the OS certificate store. 12 | 13 | [ldap-backend] 14 | # Location of the LDAP backend server, specified using the Twisted endpoint string syntax for clients: 15 | # https://twistedmatrix.com/documents/16.4.1/core/howto/endpoints.html#clients 16 | # You can also specify a timeout for connection establishment here (by providing timeout=X) 17 | # CAUTION: STARTTLS is currently unsupported! 18 | # LDAPS can be configured using the Twisted endpoint syntax. Be sure to install the service_identity 19 | # module to enable proper certificate validation. The `trustRoots` option can be used to 20 | # point to a directory containing trusted roots (only .pem files are considered!) 21 | #endpoint = tls:host=foo:port=636:trustRoots=/path/to/pems 22 | endpoint = tcp:host=192.0.2.1:port=389 23 | # Enabling/disabling of STARTTLS (currently unsupported) 24 | use-tls = false 25 | # If enabled, the LDAP proxy will perform an anonymous bind against the LDAP backend at startup 26 | # to ensure that the connection works and write a message to the log. (default is true) 27 | test-connection = true 28 | 29 | [service-account] 30 | # DN and password of the service account for the LDAP backend which is used to forward, e.g., search requests 31 | # or to lookup the User DN in case the "lookup" user mapping strategy is used. 32 | # If the service account is not used in your particular setup, you may leave it blank. 33 | # Be sure to quote the DN. 34 | dn = "cn=service,cn=users,dc=test,dc=local" 35 | password = test 36 | 37 | [ldap-proxy] 38 | # Host and port to bind the LDAP proxy to, specified using the Twisted endpoint string syntax for servers 39 | # https://twistedmatrix.com/documents/16.4.1/core/howto/endpoints.html#servers 40 | # CAUTION: LDAPS/STARTTLS is currently unsupported! 41 | endpoint = tcp:port=1389 42 | # List of DNs for which Bind Requests should simply be forwarded to the LDAP backend. 43 | # Individual DNs must be quoted and separated by a comma. 44 | passthrough-binds = "cn=service2,cn=users,dc=test,dc=local","cn=admin,cn=users,dc=test,dc=local" 45 | # If this is set to `true`, the LDAP proxy will send a bind request using the credentials of the service 46 | # account (see above) to the LDAP backend once an incoming LDAP user bind has been successfully authenticated 47 | # by privacyIDEA. As a result, subsequent LDAP operations forwarded to the LDAP backend are processed 48 | # in the context of the service account. 49 | bind-service-account = false 50 | # If this is set to `true`, the LDAP proxy will forward search requests to the LDAP backend. 51 | # Mostly useful in conjunction with `bind-service-account`: If both are set to `true`, applications 52 | # may issue search operations after a successful user authentiation. 53 | # If `bind-service-account` is set to false, but `allow-search` is set to true, only the DNs 54 | # in `passthrough-binds` will be able to issue search requests. 55 | allow-search = false 56 | # By default, the LDAP proxy only allows one bind request per connection. For some apps, this may 57 | # be sufficient. However, other apps may reuse a LDAP connection and thus require setting this 58 | # configuration option. 59 | allow-connection-reuse = false 60 | # Currently, ldaptor does not support SEARCH result references which will break 61 | # some apps in combination with AD. This option provides a workaround: 62 | # If it is set to `true`, the LDAP proxy ignores all LDAP SEARCH result references. 63 | # The LDAP Proxy does not send any LDAP SEARCH result references to the application. 64 | ignore-search-result-references = false 65 | # By default, the LDAP Proxy rejects anonymous binds. With the following option, it can be configured 66 | # to forward anonymous binds to the LDAP backend. 67 | forward-anonymous-binds = false 68 | 69 | [user-mapping] 70 | # This setting determines the strategy the LDAP proxy uses to determine the username that is sent to privacyIDEA 71 | # from an incoming bind request's DN. There are currently two strategies: 72 | # (1) "lookup": Processing an incoming user bind request, the LDAP proxy connects to the LDAP backend 73 | # using the service account credentials (see above), looks up the user entry using the given DN and retrieves 74 | # the value of a specific attribute (given in the "attribute" setting), which is subsequently used to authenticate 75 | # the user against privacyIDEA 76 | strategy = lookup 77 | attribute = sAMAccountName 78 | # (2) "match": Processing an incoming user bind request, the LDAP proxy extracts the username directly from the 79 | # user DN. For that, it expects a setting "pattern", a regular expression pattern containing one group, the username. 80 | #strategy = match 81 | #pattern = "cn=([^,]+),cn=users,dc=test,dc=local" 82 | 83 | [realm-mapping] 84 | # The following configures how the privacyIDEA realm of incoming authentication requests 85 | # is determined. 86 | # The `static` strategy assigns one realm to all authentication requests. 87 | strategy = static 88 | # Realm to use for authentication (may also be left blank to make privacyIDEA use its default realm) 89 | realm = 90 | # The `app-cache` strategy is more sophisticated: Once a bind request is received, it checks 91 | # whether the app cache contains a corresponding app marker (so be sure to enable the bind cache) 92 | # If yes, this app marker is mapped to a privacyIDEA realm according to the mapping defined below. 93 | # If the app cache contains no app marker or the app marker is not mapped to a realm, 94 | # the authentication request fails. 95 | # strategy = app-cache 96 | # [[mappings]] 97 | # This maps the app marker "someApp" to the privacyIDEA realm "somerealm". 98 | # someApp = somerealm 99 | 100 | [bind-cache] 101 | # If this setting is enabled, successful user bind requests are added to a so-called "bind cache" in which 102 | # they are kept for a specified time. During that time, incoming bind requests using the same credentials 103 | # are internally replaced with bind requests using the service account credentials. 104 | # The timeout is specified in seconds. 105 | # This feature is EXPERIMENTAL. 106 | enabled = false 107 | timeout = 3 108 | 109 | [app-cache] 110 | # If this setting is enabled, the LDAP proxy maintains a so-called "app cache". 111 | # On user login, apps typically perform a LDAP search to locate the user. When 112 | # they have resolved the DN, they perform the actual bind. 113 | # We use the LDAP search to identify the app: For that, we add an expression like 114 | # `objectclass=App-someApp` to the LDAP filter (without changing the filter semantics). 115 | # We call `someApp` an "app marker". If the LDAP proxy witnesses a search request 116 | # whose filter contains an app marker and whose result has exactly one entry, 117 | # the DN of the entry (the user) as well as the app marker are stored in the app cache 118 | # for a specific timeframe. This information may be used by the `app-cache` realm mapping 119 | # strategy (see below). 120 | enabled = false 121 | #timeout = 5 122 | # Attribute containing the app marker 123 | #attribute = objectclass 124 | # Prefix of the app marker 125 | #value-prefix = App- 126 | -------------------------------------------------------------------------------- /example-proxy.ini: -------------------------------------------------------------------------------- 1 | [privacyidea] 2 | # URL of your privacyIDEA installation 3 | instance = http://192.0.2.2 4 | # In case you use HTTPS: 5 | # You can disable hostname validation completely, which is not recommended. 6 | #verify = False 7 | # By default, verify is set to True. 8 | # If verify=True, you can manually pass a HTTPS server certificate (PEM format). 9 | #certificate = /etc/privacyidea/server.pem 10 | # If verify=True but you do not explicitly pass a certficate, 11 | # the server certificate is validated against the OS certificate store. 12 | 13 | [ldap-backend] 14 | # Location of the LDAP backend server, specified using the Twisted endpoint string syntax for clients: 15 | # https://twistedmatrix.com/documents/16.4.1/core/howto/endpoints.html#clients 16 | # You can also specify a timeout for connection establishment here (by providing timeout=X) 17 | # CAUTION: STARTTLS is currently unsupported! 18 | # LDAPS can be configured using the Twisted endpoint syntax. Be sure to install the service_identity 19 | # module to enable proper certificate validation. The `trustRoots` option can be used to 20 | # point to a directory containing trusted roots (only .pem files are considered!) 21 | #endpoint = tls:host=foo:port=636:trustRoots=/path/to/pems 22 | endpoint = tcp:host=192.0.2.1:port=389 23 | # If enabled, the LDAP proxy will perform an anonymous bind against the LDAP backend at startup 24 | # to ensure that the connection works and write a message to the log. (default is true) 25 | test-connection = true 26 | 27 | [service-account] 28 | # DN and password of the service account for the LDAP backend which is used to forward, e.g., search requests 29 | # or to lookup the User DN in case the "lookup" user mapping strategy is used. 30 | # If the service account is not used in your particular setup, you may leave it blank. 31 | # Be sure to quote the DN. 32 | dn = "cn=service,cn=users,dc=test,dc=local" 33 | password = test 34 | 35 | [ldap-proxy] 36 | # Host and port to bind the LDAP proxy to, specified using the Twisted endpoint string syntax for servers 37 | # https://twistedmatrix.com/documents/16.4.1/core/howto/endpoints.html#servers 38 | # CAUTION: STARTTLS is currently unsupported! 39 | # LDAPS can be configured using the Twisted endpoint syntax, most importantly the `privateKey` option. 40 | # See https://twistedmatrix.com/documents/current/core/howto/endpoints.html#servers for more information. 41 | #endpoint = ssl:port=1636:privateKey=/path/to/cert.pem 42 | endpoint = tcp:port=1389 43 | # List of DNs for which Bind Requests should simply be forwarded to the LDAP backend. 44 | # Individual DNs must be quoted and separated by a comma. 45 | passthrough-binds = "cn=service2,cn=users,dc=test,dc=local","cn=admin,cn=users,dc=test,dc=local" 46 | # If this is set to `true`, the LDAP proxy will send a bind request using the credentials of the service 47 | # account (see above) to the LDAP backend once an incoming LDAP user bind has been successfully authenticated 48 | # by privacyIDEA. As a result, subsequent LDAP operations forwarded to the LDAP backend are processed 49 | # in the context of the service account. 50 | bind-service-account = false 51 | # If this is set to `true`, the LDAP proxy will forward search requests to the LDAP backend. 52 | # Mostly useful in conjunction with `bind-service-account`: If both are set to `true`, applications 53 | # may issue search operations after a successful user authentiation. 54 | # If `bind-service-account` is set to false, but `allow-search` is set to true, only the DNs 55 | # in `passthrough-binds` will be able to issue search requests. 56 | allow-search = false 57 | # By default, the LDAP proxy only allows one bind request per connection. For some apps, this may 58 | # be sufficient. However, other apps may reuse a LDAP connection and thus require setting this 59 | # configuration option. 60 | allow-connection-reuse = false 61 | # Currently, ldaptor does not support SEARCH result references which will break 62 | # some apps in combination with AD. This option provides a workaround: 63 | # If it is set to `true`, the LDAP proxy ignores all LDAP SEARCH result references. 64 | # The LDAP Proxy does not send any LDAP SEARCH result references to the application. 65 | ignore-search-result-references = false 66 | # By default, the LDAP Proxy rejects anonymous binds. With the following option, it can be configured 67 | # to forward anonymous binds to the LDAP backend. 68 | forward-anonymous-binds = false 69 | 70 | [user-mapping] 71 | # This setting determines the strategy the LDAP proxy uses to determine the username that is sent to privacyIDEA 72 | # from an incoming bind request's DN. There are currently two strategies: 73 | # (1) "lookup": Processing an incoming user bind request, the LDAP proxy connects to the LDAP backend 74 | # using the service account credentials (see above), looks up the user entry using the given DN and retrieves 75 | # the value of a specific attribute (given in the "attribute" setting), which is subsequently used to authenticate 76 | # the user against privacyIDEA 77 | strategy = lookup 78 | attribute = sAMAccountName 79 | # (2) "match": Processing an incoming user bind request, the LDAP proxy extracts the username directly from the 80 | # user DN. For that, it expects a setting "pattern", a regular expression pattern containing one group, the username. 81 | #strategy = match 82 | #pattern = "cn=([^,]+),cn=users,dc=test,dc=local" 83 | 84 | [realm-mapping] 85 | # The following configures how the privacyIDEA realm of incoming authentication requests 86 | # is determined. 87 | # The `static` strategy assigns one realm to all authentication requests. 88 | strategy = static 89 | # Realm to use for authentication (may also be left blank to make privacyIDEA use its default realm) 90 | realm = 91 | # The `app-cache` strategy is more sophisticated: Once a bind request is received, it checks 92 | # whether the app cache contains a corresponding app marker (so be sure to enable the bind cache) 93 | # If yes, this app marker is mapped to a privacyIDEA realm according to the mapping defined below. 94 | # If the app cache contains no app marker or the app marker is not mapped to a realm, 95 | # the authentication request fails. 96 | # strategy = app-cache 97 | # [[mappings]] 98 | # This maps the app marker "someApp" to the privacyIDEA realm "somerealm". 99 | # someApp = somerealm 100 | 101 | [bind-cache] 102 | # If this setting is enabled, successful user bind requests are added to a so-called "bind cache" in which 103 | # they are kept for a specified time. During that time, incoming bind requests using the same credentials 104 | # are internally replaced with bind requests using the service account credentials. 105 | # The timeout is specified in seconds. 106 | # This feature is EXPERIMENTAL. 107 | enabled = false 108 | timeout = 3 109 | 110 | [app-cache] 111 | # If this setting is enabled, the LDAP proxy maintains a so-called "app cache". 112 | # On user login, apps typically perform a LDAP search to locate the user. When 113 | # they have resolved the DN, they perform the actual bind. 114 | # We use the LDAP search to identify the app: For that, we add an expression like 115 | # `objectclass=App-someApp` to the LDAP filter (without changing the filter semantics). 116 | # We call `someApp` an "app marker". If the LDAP proxy witnesses a search request 117 | # whose filter contains an app marker and whose result has exactly one entry, 118 | # the DN of the entry (the user) as well as the app marker are stored in the app cache 119 | # for a specific timeframe. This information may be used by the `app-cache` realm mapping 120 | # strategy (see below). 121 | enabled = false 122 | #timeout = 5 123 | # Attribute containing the app marker 124 | #attribute = objectclass 125 | # Prefix of the app marker 126 | #value-prefix = App- 127 | # 128 | # By default the app cache stores the DN case-sensitive. If you want to 129 | # store the DN case-insensitive set this to true 130 | # case-insensitive = false 131 | -------------------------------------------------------------------------------- /pi_ldapproxy/test/test_proxy_simple.py: -------------------------------------------------------------------------------- 1 | from ldaptor.protocols import pureldap 2 | from ldaptor.protocols.ldap import ldaperrors 3 | from ldaptor.protocols.ldap.ldapsyntax import LDAPEntry 4 | from twisted.internet import defer, error, reactor 5 | 6 | from pi_ldapproxy.test.util import ProxyTestCase 7 | 8 | 9 | class TestProxySimple(ProxyTestCase): 10 | privacyidea_credentials = { 11 | 'hugo@default': 'secret' 12 | } 13 | 14 | def test_anonymous_bind_fails(self): 15 | server, client = self.create_server_and_client([]) 16 | d = client.bind('', '') 17 | return self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 18 | 19 | @defer.inlineCallbacks 20 | def test_bind_succeeds(self): 21 | server, client = self.create_server_and_client([]) 22 | yield client.bind('uid=hugo,cn=users,dc=test,dc=local', 'secret') 23 | 24 | def test_bind_fails_wrong_password(self): 25 | server, client = self.create_server_and_client([]) 26 | d = client.bind('uid=hugo,cn=users,dc=test,dc=local', 'wrong') 27 | return self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 28 | 29 | def test_bind_fails_unknown_user(self): 30 | server, client = self.create_server_and_client([]) 31 | d = client.bind('uid=unknown,cn=users,dc=test,dc=local', 'secret') 32 | return self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 33 | 34 | def test_bind_fails_no_matching_dn(self): 35 | server, client = self.create_server_and_client([]) 36 | d = client.bind('uid=hugo,cn=users,dc=somewhere-else,dc=local', 'secret') 37 | return self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 38 | 39 | def test_bind_fails_invalid_dn(self): 40 | server, client = self.create_server_and_client([]) 41 | d = client.bind('dn=uid=hugo,cn=users,dc=test,dc=local', 'secret') 42 | return self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 43 | 44 | @defer.inlineCallbacks 45 | def test_passthrough_bind_succeeds(self): 46 | server, client = self.create_server_and_client([pureldap.LDAPBindResponse(resultCode=0)]) 47 | yield client.bind('uid=passthrough,cn=users,dc=test,dc=local', 'some-secret') 48 | server.client.assertSent( 49 | pureldap.LDAPBindRequest(dn='uid=passthrough,cn=users,dc=test,dc=local', auth='some-secret'), 50 | ) 51 | 52 | @defer.inlineCallbacks 53 | def test_reusing_connection_fails1(self): 54 | # Scenario 1: Passthrough Bind, User Bind 55 | server, client = self.create_server_and_client([pureldap.LDAPBindResponse(resultCode=0)]) 56 | yield client.bind('uid=passthrough,cn=users,dc=test,dc=local', 'some-secret') 57 | server.client.assertSent( 58 | pureldap.LDAPBindRequest(dn='uid=passthrough,cn=users,dc=test,dc=local', auth='some-secret'), 59 | ) 60 | d = client.bind('uid=hugo,cn=users,dc=test,dc=local', 'secret') 61 | yield self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 62 | 63 | @defer.inlineCallbacks 64 | def test_reusing_connection_fails2(self): 65 | # Scenario 2: User Bind, Passthrough Bind 66 | server, client = self.create_server_and_client([pureldap.LDAPBindResponse(resultCode=0)]) 67 | yield client.bind('uid=hugo,cn=users,dc=test,dc=local', 'secret') 68 | d = client.bind('uid=passthrough,cn=users,dc=test,dc=local', 'some-secret') 69 | yield self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 70 | 71 | @defer.inlineCallbacks 72 | def test_reusing_connection_fails3(self): 73 | # Scenario 3: Passthrough Bind, Passthrough Bind 74 | server, client = self.create_server_and_client([pureldap.LDAPBindResponse(resultCode=0)]) 75 | yield client.bind('uid=passthrough,cn=users,dc=test,dc=local', 'some-secret') 76 | d = client.bind('uid=passthrough,cn=users,dc=test,dc=local', 'some-secret') 77 | yield self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 78 | server.client.assertSent( 79 | pureldap.LDAPBindRequest(dn='uid=passthrough,cn=users,dc=test,dc=local', auth='some-secret'), 80 | ) 81 | 82 | @defer.inlineCallbacks 83 | def test_reusing_connection_fails4(self): 84 | # Scenario 4: User Bind, User Bind 85 | server, client = self.create_server_and_client([]) 86 | yield client.bind('uid=hugo,cn=users,dc=test,dc=local', 'secret') 87 | d = client.bind('uid=hugo,cn=users,dc=test,dc=local', 'secret') 88 | yield self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 89 | 90 | def test_passthrough_bind_fails(self): 91 | server, client = self.create_server_and_client([pureldap.LDAPBindResponse(resultCode=49)]) 92 | d = client.bind('uid=passthrough,cn=users,dc=test,dc=local', 'some-secret') 93 | return self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 94 | 95 | @defer.inlineCallbacks 96 | def test_passthrough_account_search_fails(self): 97 | server, client = self.create_server_and_client([pureldap.LDAPBindResponse(resultCode=0)]) 98 | yield client.bind('uid=passthrough,cn=users,dc=test,dc=local', 'some-secret') 99 | entry = LDAPEntry(client, 'cn=users,dc=test,dc=local') 100 | d = entry.search('(objectClass=*)', scope=pureldap.LDAP_SCOPE_wholeSubtree) 101 | yield self.assertFailure(d, ldaperrors.LDAPInsufficientAccessRights) 102 | 103 | @defer.inlineCallbacks 104 | def test_health_check_closes_connection_to_backend(self): 105 | server, client = self.create_server_and_client() 106 | server.connectionLost(error.ConnectionDone) 107 | # Trick to ensure that the rest of the test is executed after the 108 | # fake connection to the backend has been established 109 | d = defer.Deferred() 110 | reactor.callLater(0, d.callback, None) 111 | yield d 112 | self.assertIsNone(server.client) 113 | self.assertFalse(server.clientTestDriver.connected) 114 | self.assertEqual(server.queuedRequests, []) 115 | 116 | class TestProxyIgnoringReferences(ProxyTestCase): 117 | privacyidea_credentials = { 118 | 'hugo@default': 'secret' 119 | } 120 | additional_config = { 121 | 'ldap-proxy': { 122 | 'ignore-search-result-references': True, 123 | 'allow-search': True, 124 | } 125 | } 126 | 127 | @defer.inlineCallbacks 128 | def test_ignores_search_result_reference(self): 129 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 130 | server, client = self.create_server_and_client( 131 | [ 132 | pureldap.LDAPBindResponse(resultCode=0) 133 | ], 134 | [ 135 | pureldap.LDAPSearchResultEntry(dn, [('someattr', ['somevalue'])]), 136 | pureldap.LDAPSearchResultReference( 137 | uris=[ 138 | pureldap.LDAPString(b'ldap://test.local') 139 | ] 140 | ), # NOTE: ldaptor does not really support these 141 | pureldap.LDAPSearchResultReference( 142 | uris=[ 143 | pureldap.LDAPString(b'ldap://test.local') 144 | ] 145 | ), 146 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 147 | ] 148 | ) 149 | yield client.bind('uid=passthrough,cn=users,dc=test,dc=local', 'some-secret') 150 | entry = LDAPEntry(client, 'cn=users,dc=test,dc=local') 151 | r = yield entry.search('(objectClass=*)', scope=pureldap.LDAP_SCOPE_wholeSubtree) 152 | self.assertEqual(len(r), 1) 153 | self.assertEqual(r[0].dn, dn) 154 | 155 | class TestProxyReuse(ProxyTestCase): 156 | additional_config = { 157 | 'ldap-proxy': { 158 | 'allow-connection-reuse': True, 159 | 'bind-service-account': True, 160 | } 161 | } 162 | privacyidea_credentials = { 163 | 'hugo@default': 'secret' 164 | } 165 | 166 | @defer.inlineCallbacks 167 | def test_reusing_connection_succeeds1(self): 168 | # Passthrough Bind, User Bind 169 | server, client = self.create_server_and_client( 170 | [ 171 | pureldap.LDAPBindResponse(resultCode=0) 172 | ], 173 | [ 174 | pureldap.LDAPBindResponse(resultCode=0) 175 | ]) 176 | yield client.bind('uid=passthrough,cn=users,dc=test,dc=local', 'some-secret') 177 | yield client.bind('uid=hugo,cn=users,dc=test,dc=local', 'secret') 178 | server.client.assertSent( 179 | pureldap.LDAPBindRequest(dn='uid=passthrough,cn=users,dc=test,dc=local', auth='some-secret'), 180 | pureldap.LDAPBindRequest(dn='uid=service,cn=users,dc=test,dc=local', auth='service-secret'), 181 | ) 182 | 183 | @defer.inlineCallbacks 184 | def test_reusing_connection_succeeds2(self): 185 | # User Bind, User Bind 186 | server, client = self.create_server_and_client( 187 | [ 188 | pureldap.LDAPBindResponse(resultCode=0) 189 | ], 190 | [ 191 | pureldap.LDAPBindResponse(resultCode=0) 192 | ]) 193 | yield client.bind('uid=hugo,cn=users,dc=test,dc=local', 'secret') 194 | yield client.bind('uid=hugo,cn=users,dc=test,dc=local', 'secret') 195 | server.client.assertSent( 196 | pureldap.LDAPBindRequest(dn='uid=service,cn=users,dc=test,dc=local', auth='service-secret'), 197 | pureldap.LDAPBindRequest(dn='uid=service,cn=users,dc=test,dc=local', auth='service-secret'), 198 | ) 199 | 200 | class TestProxyReuseNoServiceBind(ProxyTestCase): 201 | additional_config = { 202 | 'ldap-proxy': { 203 | 'allow-connection-reuse': True, 204 | 'bind-service-account': False, 205 | 'allow-search': True, 206 | } 207 | } 208 | privacyidea_credentials = { 209 | 'hugo@default': 'secret' 210 | } 211 | 212 | @defer.inlineCallbacks 213 | def test_state_reset(self): 214 | # Passthrough Bind, User Bind 215 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 216 | search_response = pureldap.LDAPSearchResultEntry(dn, [('someattr', ['somevalue'])]) 217 | server, client = self.create_server_and_client( 218 | [ 219 | pureldap.LDAPBindResponse(resultCode=0) 220 | ], 221 | [ 222 | search_response, 223 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 224 | ], 225 | [ 226 | pureldap.LDAPBindResponse(resultCode=0) 227 | ] 228 | ) 229 | yield client.bind('uid=passthrough,cn=users,dc=test,dc=local', 'some-secret') 230 | server.client.assertSent( 231 | pureldap.LDAPBindRequest(dn='uid=passthrough,cn=users,dc=test,dc=local', auth='some-secret'), 232 | ) 233 | # perform a search 234 | entry = LDAPEntry(client, dn) 235 | r = yield entry.search('(|(objectClass=*)(objectcLAsS=App-markerSecret))', scope=pureldap.LDAP_SCOPE_baseObject) 236 | # check that the state is correct 237 | self.assertTrue(server.received_bind_request) 238 | self.assertTrue(server.forwarded_passthrough_bind) 239 | self.assertEqual(server.search_response_entries, 0) 240 | self.assertIsNone(server.last_search_response_entry) 241 | yield client.bind(dn, 'secret') 242 | self.assertEqual(len(server.client.sent), 2) 243 | # Check that the bind requests was sent properly 244 | self.assertEqual(server.client.sent[0], 245 | pureldap.LDAPBindRequest(dn='uid=passthrough,cn=users,dc=test,dc=local', auth='some-secret')) 246 | # check that state is properly reset 247 | self.assertTrue(server.received_bind_request) 248 | self.assertFalse(server.forwarded_passthrough_bind) 249 | self.assertEqual(server.search_response_entries, 0) 250 | self.assertIsNone(server.last_search_response_entry) 251 | 252 | class TestProxyForwardAnonymousBind(ProxyTestCase): 253 | additional_config = { 254 | 'ldap-proxy': { 255 | 'forward-anonymous-binds': True, 256 | } 257 | } 258 | 259 | @defer.inlineCallbacks 260 | def test_anonymous_bind_succeeds(self): 261 | server, client = self.create_server_and_client([pureldap.LDAPBindResponse(resultCode=0)]) 262 | yield client.bind('', '') 263 | server.client.assertSent( 264 | pureldap.LDAPBindRequest(dn='', auth=''), 265 | ) 266 | -------------------------------------------------------------------------------- /pi_ldapproxy/test/test_proxy_app_cache.py: -------------------------------------------------------------------------------- 1 | import time 2 | from ldaptor.protocols import pureldap 3 | from ldaptor.protocols.ldap import ldaperrors 4 | from ldaptor.protocols.ldap.ldapsyntax import LDAPEntry 5 | from twisted.internet import defer 6 | 7 | from pi_ldapproxy.test.util import ProxyTestCase 8 | 9 | 10 | class TestProxyUserBind(ProxyTestCase): 11 | privacyidea_credentials = { 12 | 'hugo@realmSecret': 'secret', 13 | 'hugo@realmOfficial': 'password', 14 | } 15 | 16 | additional_config = { 17 | 'ldap-proxy': { 18 | 'bind-service-account': True, 19 | 'allow-search': True, 20 | }, 21 | 'realm-mapping': { 22 | 'strategy': 'app-cache', 23 | 'mappings': { 24 | 'markerSecret': 'realmSecret', 25 | 'markerOfficial': 'realmOfficial', 26 | } 27 | }, 28 | 'app-cache': { 29 | 'enabled': True, 30 | 'timeout': 1, 31 | } 32 | } 33 | 34 | def test_simple_bind_fails(self): 35 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 36 | server, client = self.create_server_and_client() 37 | d = client.bind(dn, 'secret') 38 | return self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 39 | 40 | @defer.inlineCallbacks 41 | def _test_realm_mapping(self, marker, realm, password): 42 | service_dn = 'uid=passthrough,cn=users,dc=test,dc=local' 43 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 44 | server, client = self.create_server_and_client([ 45 | pureldap.LDAPBindResponse(resultCode=0), # for service account 46 | ], [ 47 | pureldap.LDAPSearchResultEntry(dn, [('someattr', ['somevalue'])]), 48 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 49 | ]) 50 | yield client.bind(service_dn, 'service-secret') 51 | # Assert that Proxy<->Backend uses the correct credentials 52 | server.client.assertSent( 53 | pureldap.LDAPBindRequest(dn=service_dn, auth='service-secret'), 54 | ) 55 | # Perform a simple search in the context of the service account 56 | entry = LDAPEntry(client, dn) 57 | r = yield entry.search('(|(objectClass=*)(objectcLAsS=App-%s))' % marker, scope=pureldap.LDAP_SCOPE_baseObject) 58 | # sleep half a second and then try to bind as hugo 59 | time.sleep(0.5) 60 | server2, client2 = self.create_server_and_client([ 61 | pureldap.LDAPBindResponse(resultCode=0), # for service account (successful hugo bind) 62 | ]) 63 | yield client2.bind(dn, password) 64 | self.assertEqual(self.privacyidea.authentication_requests, 65 | [('hugo', realm, password, True)]) 66 | time.sleep(1) # to clean the reactor 67 | 68 | def test_realm_mapping_succeeds1(self): 69 | return self._test_realm_mapping('markerSecret', 'realmSecret', 'secret') 70 | 71 | def test_realm_mapping_succeeds2(self): 72 | return self._test_realm_mapping('markerOfficial', 'realmOfficial', 'password') 73 | 74 | @defer.inlineCallbacks 75 | def test_realm_mapping_fails_wrong_password(self): 76 | marker = 'markerSecret' 77 | realm = 'realmSecret' 78 | password = 'password' # this is the wrong password! 79 | service_dn = 'uid=passthrough,cn=users,dc=test,dc=local' 80 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 81 | server, client = self.create_server_and_client([ 82 | pureldap.LDAPBindResponse(resultCode=0), # for service account 83 | ], [ 84 | pureldap.LDAPSearchResultEntry(dn, [('someattr', ['somevalue'])]), 85 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 86 | ]) 87 | yield client.bind(service_dn, 'service-secret') 88 | # Assert that Proxy<->Backend uses the correct credentials 89 | server.client.assertSent( 90 | pureldap.LDAPBindRequest(dn=service_dn, auth='service-secret'), 91 | ) 92 | # Perform a simple search in the context of the service account 93 | entry = LDAPEntry(client, dn) 94 | r = yield entry.search('(|(objectClass=*)(objectclass=App-%s))' % marker, scope=pureldap.LDAP_SCOPE_baseObject) 95 | # sleep a second and then try to bind as hugo 96 | time.sleep(0.5) 97 | server2, client2 = self.create_server_and_client([ 98 | pureldap.LDAPBindResponse(resultCode=0), # for service account (successful hugo bind) 99 | ]) 100 | d = client2.bind(dn, password) 101 | yield self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 102 | self.assertEqual(self.privacyidea.authentication_requests, 103 | [('hugo', realm, password, False)]) 104 | time.sleep(1) # to clean the reactor 105 | 106 | @defer.inlineCallbacks 107 | def test_realm_mapping_fails_wrong_marker(self): 108 | marker = 'markerUnknown' 109 | password = 'password' 110 | service_dn = 'uid=passthrough,cn=users,dc=test,dc=local' 111 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 112 | server, client = self.create_server_and_client([ 113 | pureldap.LDAPBindResponse(resultCode=0), # for service account 114 | ], [ 115 | pureldap.LDAPSearchResultEntry(dn, [('someattr', ['somevalue'])]), 116 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 117 | ]) 118 | yield client.bind(service_dn, 'service-secret') 119 | # Assert that Proxy<->Backend uses the correct credentials 120 | server.client.assertSent( 121 | pureldap.LDAPBindRequest(dn=service_dn, auth='service-secret'), 122 | ) 123 | # Perform a simple search in the context of the service account 124 | entry = LDAPEntry(client, dn) 125 | r = yield entry.search('(|(objectClass=*)(objectclass=App-%s))' % marker, scope=pureldap.LDAP_SCOPE_baseObject) 126 | # sleep half a second and then try to bind as hugo 127 | time.sleep(0.5) 128 | server2, client2 = self.create_server_and_client([ 129 | pureldap.LDAPBindResponse(resultCode=0), # for service account (successful hugo bind) 130 | ]) 131 | d = client2.bind(dn, password) 132 | yield self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 133 | self.assertEqual(self.privacyidea.authentication_requests, 134 | []) 135 | time.sleep(1) # to clean the reactor 136 | 137 | @defer.inlineCallbacks 138 | def test_realm_mapping_fails_case_sensitive(self): 139 | marker = 'markerSecret' 140 | password = 'secret' 141 | service_dn = 'uid=passthrough,cn=users,dc=test,dc=local' 142 | dn = 'uid=Hugo,cn=users,dc=test,DC=LOCAL' 143 | server, client = self.create_server_and_client([ 144 | pureldap.LDAPBindResponse(resultCode=0), # for service account 145 | ], [ 146 | pureldap.LDAPSearchResultEntry(dn, [('someattr', ['somevalue'])]), 147 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 148 | ]) 149 | yield client.bind(service_dn, 'service-secret') 150 | # Assert that Proxy<->Backend uses the correct credentials 151 | server.client.assertSent( 152 | pureldap.LDAPBindRequest(dn=service_dn, auth='service-secret'), 153 | ) 154 | # Perform a simple search in the context of the service account 155 | entry = LDAPEntry(client, dn) 156 | r = yield entry.search('(|(objectClass=*)(objectclass=App-%s))' % marker, scope=pureldap.LDAP_SCOPE_baseObject) 157 | # sleep half a second and then try to bind as hugo 158 | time.sleep(0.5) 159 | server2, client2 = self.create_server_and_client([ 160 | pureldap.LDAPBindResponse(resultCode=0), # for service account (successful hugo bind) 161 | ]) 162 | d = client2.bind(dn.lower(), password) # this will fail because the DN has differing case 163 | yield self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 164 | self.assertEqual(self.privacyidea.authentication_requests, 165 | []) 166 | time.sleep(1) # to clean the reactor 167 | 168 | @defer.inlineCallbacks 169 | def test_realm_mapping_fails_waiting_too_long(self): 170 | service_dn = 'uid=passthrough,cn=users,dc=test,dc=local' 171 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 172 | marker = 'markerSecret' 173 | password = 'secret' 174 | server, client = self.create_server_and_client([ 175 | pureldap.LDAPBindResponse(resultCode=0), # for service account 176 | ], [ 177 | pureldap.LDAPSearchResultEntry(dn, [('someattr', ['somevalue'])]), 178 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 179 | ]) 180 | yield client.bind(service_dn, 'service-secret') 181 | # Assert that Proxy<->Backend uses the correct credentials 182 | server.client.assertSent( 183 | pureldap.LDAPBindRequest(dn=service_dn, auth='service-secret'), 184 | ) 185 | # Perform a simple search in the context of the service account 186 | entry = LDAPEntry(client, dn) 187 | r = yield entry.search('(|(objectClass=*)(objectclass=App-%s))' % marker, scope=pureldap.LDAP_SCOPE_baseObject) 188 | # sleep very long and then try to bind as hugo 189 | time.sleep(2) 190 | server2, client2 = self.create_server_and_client([ 191 | pureldap.LDAPBindResponse(resultCode=0), # for service account (successful hugo bind) 192 | ]) 193 | d = client2.bind(dn, password) 194 | yield self.assertFailure(d, ldaperrors.LDAPInvalidCredentials) 195 | self.assertEqual(self.privacyidea.authentication_requests, 196 | []) 197 | time.sleep(1) # to clean the reactor 198 | 199 | @defer.inlineCallbacks 200 | def test_realm_mapping_fails_fake_search_by_user(self): 201 | service_dn = 'uid=passthrough,cn=users,dc=test,dc=local' 202 | dn = 'uid=hugo,cn=users,dc=test,dc=local' 203 | server, client = self.create_server_and_client([ 204 | pureldap.LDAPBindResponse(resultCode=0), # for service account 205 | ], [ 206 | pureldap.LDAPSearchResultEntry(dn, [('someattr', ['somevalue'])]), 207 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 208 | ]) 209 | yield client.bind(service_dn, 'service-secret') 210 | # Assert that Proxy<->Backend uses the correct credentials 211 | server.client.assertSent( 212 | pureldap.LDAPBindRequest(dn=service_dn, auth='service-secret'), 213 | ) 214 | # Perform a simple search in the context of the service account 215 | entry = LDAPEntry(client, dn) 216 | r = yield entry.search('(|(objectClass=*)(objectcLAsS=App-markerSecret))', scope=pureldap.LDAP_SCOPE_baseObject) 217 | # sleep half a second and then try to bind as hugo 218 | time.sleep(0.5) 219 | server2, client2 = self.create_server_and_client([ 220 | pureldap.LDAPBindResponse(resultCode=0), # for service account (successful hugo bind) 221 | ], [ 222 | pureldap.LDAPSearchResultEntry(dn, [('someattr', ['somevalue'])]), # hugo's search 223 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 224 | ]) 225 | yield client2.bind(dn, 'secret') 226 | self.assertEqual(self.privacyidea.authentication_requests, 227 | [('hugo', 'realmSecret', 'secret', True)]) 228 | # Perform another search in hugo's context 229 | entry2 = LDAPEntry(client2, dn) 230 | r = yield entry2.search('(|(objectClass=*)(objectcLAsS=App-markerOfficial))', scope=pureldap.LDAP_SCOPE_baseObject) 231 | self.assertTrue(server.factory.app_cache.get_cached_marker(dn) in ('markerSecret', None)) 232 | time.sleep(1) # to clean the reactor 233 | 234 | class TestProxyUserBindCaseInsensitive(ProxyTestCase): 235 | privacyidea_credentials = { 236 | 'hugo@realmSecret': 'secret', 237 | 'hugo@realmOfficial': 'password', 238 | } 239 | 240 | additional_config = { 241 | 'ldap-proxy': { 242 | 'bind-service-account': True, 243 | 'allow-search': True, 244 | }, 245 | 'realm-mapping': { 246 | 'strategy': 'app-cache', 247 | 'mappings': { 248 | 'markerSecret': 'realmSecret', 249 | 'markerOfficial': 'realmOfficial', 250 | }, 251 | }, 252 | 'app-cache': { 253 | 'enabled': True, 254 | 'timeout': 1, 255 | 'case-insensitive': True, 256 | } 257 | } 258 | 259 | @defer.inlineCallbacks 260 | def test_realm_mapping_succeeds_case_sensitive(self): 261 | marker = 'markerSecret' 262 | password = 'secret' 263 | service_dn = 'uid=passthrough,cn=users,dc=test,dc=local' 264 | dn = 'uid=Hugo,cn=users,dc=test,DC=LOCAL' 265 | server, client = self.create_server_and_client([ 266 | pureldap.LDAPBindResponse(resultCode=0), # for service account 267 | ], [ 268 | pureldap.LDAPSearchResultEntry(dn, [('someattr', ['somevalue'])]), 269 | pureldap.LDAPSearchResultDone(ldaperrors.Success.resultCode), 270 | ]) 271 | yield client.bind(service_dn, 'service-secret') 272 | # Assert that Proxy<->Backend uses the correct credentials 273 | server.client.assertSent( 274 | pureldap.LDAPBindRequest(dn=service_dn, auth='service-secret'), 275 | ) 276 | # Perform a simple search in the context of the service account 277 | entry = LDAPEntry(client, dn) 278 | r = yield entry.search('(|(objectClass=*)(objectclass=App-%s))' % marker, scope=pureldap.LDAP_SCOPE_baseObject) 279 | # sleep half a second and then try to bind as hugo 280 | time.sleep(0.5) 281 | server2, client2 = self.create_server_and_client([ 282 | pureldap.LDAPBindResponse(resultCode=0), # for service account (successful hugo bind) 283 | ]) 284 | yield client2.bind(dn.lower(), password) # this will work even though the DN has differing case 285 | self.assertEqual(self.privacyidea.authentication_requests, 286 | [('hugo', 'realmSecret', password, True)]) 287 | time.sleep(1) # to clean the reactor -------------------------------------------------------------------------------- /pi_ldapproxy/proxy.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | import argparse 3 | import json 4 | import sys 5 | import re 6 | import urllib 7 | from io import BytesIO 8 | from functools import partial 9 | 10 | from ldaptor.protocols import pureldap 11 | from ldaptor.protocols.ldap import ldaperrors 12 | from ldaptor.protocols.ldap.ldapclient import LDAPClient 13 | from ldaptor.protocols.ldap.ldapconnector import connectToLDAPEndpoint 14 | from ldaptor.protocols.ldap.proxybase import ProxyBase 15 | from six import ensure_str 16 | from twisted.internet import defer, protocol, reactor 17 | from twisted.logger import Logger 18 | from twisted.internet.ssl import Certificate 19 | from twisted.python.filepath import FilePath 20 | from twisted.web.client import Agent, FileBodyProducer, readBody, BrowserLikePolicyForHTTPS 21 | from twisted.web.http_headers import Headers 22 | 23 | from pi_ldapproxy.bindcache import BindCache 24 | from pi_ldapproxy.config import load_config 25 | from pi_ldapproxy.appcache import AppCache 26 | from pi_ldapproxy.realmmapping import detect_login_preamble, REALM_MAPPING_STRATEGIES, RealmMappingError 27 | from pi_ldapproxy.usermapping import USER_MAPPING_STRATEGIES, UserMappingError 28 | from pi_ldapproxy.util import DisabledVerificationPolicyForHTTPS 29 | 30 | log = Logger() 31 | 32 | class ProxyError(Exception): 33 | pass 34 | 35 | DN_BLACKLIST = list(map(re.compile, ['^dn=uid='])) 36 | VALIDATE_URL_TEMPLATE = '{}validate/check' 37 | 38 | class TwoFactorAuthenticationProxy(ProxyBase): 39 | def __init__(self): 40 | ProxyBase.__init__(self) 41 | # Set the state initially 42 | self.reset_state() 43 | 44 | def _connectedToProxiedServer(self, proto): 45 | """ 46 | Workaround for ldaptor bug #105. In case the application has disconnected before 47 | the connection to the LDAP backend has been established, we want to close the 48 | connection to the LDAP backend. This works around the problem that health checks 49 | may result in leftover sockets. 50 | """ 51 | if not self.connected: 52 | log.info('Client has disconnected already, closing connection to LDAP backend ...') 53 | proto.transport.loseConnection() 54 | self.queuedRequests = [] 55 | else: 56 | ProxyBase._connectedToProxiedServer(self, proto) 57 | 58 | def request_validate(self, url, user, realm, password): 59 | """ 60 | Issue an HTTP request to authenticate an user with a password in a given 61 | realm using the specified privacyIDEA /validate/check endpoint. 62 | 63 | :param url: an HTTP or HTTPS url to the /validate/check endpoint 64 | :param user: username to authenticate 65 | :param realm: realm of the user, empty string for default realm 66 | :param password: password for authentication 67 | :return: A Twisted Deferred which yields a `twisted.web.client.Response` instance or fails. 68 | """ 69 | body = urllib.parse.urlencode({'user': user, 70 | 'realm': realm, 71 | 'pass': password}) 72 | # TODO: Is this really the preferred way to pass a string body? 73 | log.info('Validating user password') 74 | producer = FileBodyProducer(BytesIO(body.encode('ascii'))) 75 | d = self.factory.agent.request(b'POST', 76 | url, 77 | Headers({ 78 | 'Content-Type': ['application/x-www-form-urlencoded'], 79 | 'User-Agent': ['privacyIDEA-LDAP-Proxy'] 80 | }), 81 | producer) 82 | return d 83 | 84 | @defer.inlineCallbacks 85 | def authenticate_bind_request(self, request): 86 | """ 87 | Given a LDAP bind request: 88 | * Check if it is contained in the bind cache. 89 | If yes: Return success and bind the service account. 90 | * If not: resolve the DN and redirect the request to privacyIDEA. 91 | :param request: An `pureldap.LDAPBindRequest` instance. 92 | :return: Deferred that fires a tuple ``(success, message)``, whereas ``success`` denotes whether privacyIDEA 93 | successfully validated the given password. If ``success`` is ``False``, ``message`` contains an error message. 94 | """ 95 | #: This 2-tuple has the following semantics: 96 | #: If the first element is True, authentication has succeeded! The second element then 97 | #: contains the app marker as a string. 98 | #: If the first element is False, authentication has failed. The second element then contains 99 | #: the error message. 100 | result = (False, '') 101 | request.auth = ensure_str(request.auth) 102 | try: 103 | app_marker, realm = yield self.factory.resolve_realm(request.dn) 104 | user = yield self.factory.resolve_user(request.dn) 105 | except UserMappingError: 106 | # User could not be found 107 | log.info('Could not resolve {dn!r} to user', dn=request.dn) 108 | result = (False, 'Invalid user.') 109 | except RealmMappingError as e: 110 | # Realm could not be mapped 111 | log.info('Could not resolve {dn!r} to realm: {message!r}', dn=request.dn, message=e.args) 112 | # TODO: too much information revealed? 113 | result = (False, 'Could not determine realm.') 114 | else: 115 | log.info('Resolved {dn!r} to {user!r}@{realm!r} ({marker!r})', 116 | dn=request.dn, user=user, realm=realm, marker=app_marker) 117 | password = request.auth 118 | if self.factory.is_bind_cached(request.dn, app_marker, request.auth): 119 | log.info('Combination found in bind cache!') 120 | result = (True, app_marker) 121 | else: 122 | response = yield self.request_validate(self.factory.validate_url, 123 | user, 124 | realm, 125 | password) 126 | json_body = yield readBody(response) 127 | if response.code == 200: 128 | body = json.loads(json_body) 129 | if body['result']['status']: 130 | if body['result']['value']: 131 | result = (True, app_marker) 132 | else: 133 | result = (False, 'Failed to authenticate.') 134 | else: 135 | result = (False, 'Failed to authenticate. privacyIDEA error.') 136 | else: 137 | result = (False, 'Failed to authenticate. Wrong HTTP response ({})'.format(response.code)) 138 | # TODO: Is this the right place to bind the service user? 139 | # (check that result[0] is actually True and not just truthy) 140 | if result[0] is True and self.factory.bind_service_account: 141 | log.info('Successful authentication, authenticating as service user ...') 142 | # Reset value in case the connection is re-used 143 | self.forwarded_passthrough_bind = False 144 | yield self.bind_service_account() 145 | defer.returnValue(result) 146 | 147 | def send_bind_response(self, result, request, reply): 148 | """ 149 | Given a bind request, authentication result and a reply function, send a successful or a failed bind response. 150 | :param result: A tuple ``(success, message/app marker)`` 151 | :param request: The corresponding ``LDAPBindRequest`` 152 | :param reply: A function that expects a ``LDAPResult`` object 153 | :return: nothing 154 | """ 155 | success, message = result 156 | if success: 157 | log.info('Sending BindResponse "success"') 158 | app_marker = message 159 | self.factory.finalize_authentication(request.dn, app_marker, request.auth) 160 | reply(pureldap.LDAPBindResponse(ldaperrors.Success.resultCode)) 161 | else: 162 | log.info('Sending BindResponse "invalid credentials": {message}', message=message) 163 | reply(pureldap.LDAPBindResponse(ldaperrors.LDAPInvalidCredentials.resultCode, errorMessage=message)) 164 | 165 | def send_error_bind_response(self, failure, request, reply): 166 | """ 167 | Given a failure and a reply function, log the failure and send a failed bind response. 168 | :param failure: A ``twisted.python.failure.Failure`` object 169 | :param request: The corresponding ``LDAPBindRequest`` 170 | :param reply: A function that expects a ``LDAPResult`` object 171 | :return: 172 | """ 173 | log.failure("Could not bind", failure) 174 | # TODO: Is it right to send LDAPInvalidCredentials here? 175 | self.send_bind_response((False, 'LDAP Proxy failed.'), request, reply) 176 | 177 | @defer.inlineCallbacks 178 | def bind_service_account(self): 179 | """ 180 | :return: A deferred that sends a bind request for the service account at `self.client` 181 | """ 182 | log.info('Binding service account ...') 183 | yield self.client.bind(self.factory.service_account_dn, self.factory.service_account_password) 184 | 185 | def handleProxiedResponse(self, response, request, controls): 186 | """ 187 | Called by `ProxyBase` to handle the response of an incoming request. 188 | :param response: 189 | :param request: 190 | :param controls: 191 | :return: 192 | """ 193 | try: 194 | # Try to detect login preamble 195 | if isinstance(request, pureldap.LDAPSearchRequest): 196 | # If we are sending back a search result entry, we just save it for preamble detection 197 | # and count the total number of search result entries. 198 | if isinstance(response, pureldap.LDAPSearchResultEntry): 199 | self.last_search_response_entry = response 200 | self.search_response_entries += 1 201 | elif isinstance(response, pureldap.LDAPSearchResultDone): 202 | # only check for preambles if we returned exactly one search result entry 203 | # and if this connection was established in the context of a passthrough bind 204 | # (i.e. an app service account) 205 | if self.search_response_entries == 1 and self.forwarded_passthrough_bind: 206 | self.factory.process_search_response(request, self.last_search_response_entry) 207 | # reset counter and storage 208 | self.search_response_entries = 0 209 | self.last_search_response_entry = None 210 | elif isinstance(response, pureldap.LDAPSearchResultReference): 211 | if self.factory.ignore_search_result_references: 212 | log.info('Ignoring LDAP SEARCH result reference ...') 213 | return None 214 | else: 215 | log.warn('Possibly sending an invalid LDAP SEARCH result reference, ' 216 | 'check the ignore-search-result-reference config option for more details.') 217 | except Exception as e: 218 | log.failure("Unhandled error in handleProxiedResponse: {e}", e=e) 219 | raise 220 | return response 221 | 222 | def reset_state(self): 223 | """ 224 | Reset the internal state of the connection to its initial state. 225 | This is used in case a LDAP conneciton is reused, i.e. more than 226 | one bind request is received: 227 | """ 228 | #: Specifies whether we have received a Bind Request at some point 229 | self.received_bind_request = False 230 | #: Specifies whether we forwarded a Bind Request to the LDAP backend because the 231 | #: DN was found in passthrough_binds. 232 | self.forwarded_passthrough_bind = False 233 | #: If we are currently processing a search request, this stores the last entry 234 | #: sent during its response. Otherwise, it is None. 235 | self.last_search_response_entry = None 236 | #: If we are currently processing a search request, this stores the total number of 237 | #: entries sent during its response. 238 | # Why do we have these two attributes here? For preamble detection, we need to make sure 239 | # that the search request returns only one entry. To achieve that, we could store all entries 240 | # in a list. However, this introduces unnecessary space overhead (e.g. if the app queries 241 | # all users). Thus, we only store the last entry and the total entry count. 242 | self.search_response_entries = 0 243 | 244 | def handleBeforeForwardRequest(self, request, controls, reply): 245 | """ 246 | Called by `ProxyBase` to handle an incoming request. 247 | :param request: 248 | :param controls: 249 | :param reply: 250 | :return: 251 | """ 252 | if isinstance(request, pureldap.LDAPBindRequest): 253 | if self.received_bind_request: 254 | # We have already received a bind request in this connection! 255 | if self.factory.allow_connection_reuse: 256 | # We need to reset the state before further processing the request 257 | log.info('Reusing LDAP connection, resetting state ...') 258 | self.reset_state() 259 | else: 260 | log.warn('Rejected a second bind request in the same connection. ' 261 | 'Please check the `allow-connection-reuse` config option.') 262 | self.send_bind_response((False, 'Reusing connections is disabled.'), request, reply) 263 | return None 264 | self.received_bind_request = True 265 | request.dn = ensure_str(request.dn) 266 | if request.dn == '': 267 | if self.factory.forward_anonymous_binds: 268 | return request, controls 269 | else: 270 | self.send_bind_response((False, 'Anonymous binds are not supported.'), request, reply) 271 | return None 272 | elif self.factory.is_dn_blacklisted(request.dn): 273 | self.send_bind_response((False, 'DN is blacklisted.'), request, reply) 274 | return None 275 | elif request.dn in self.factory.passthrough_binds: 276 | log.info('BindRequest for {dn!r}, passing through ...', dn=request.dn) 277 | self.forwarded_passthrough_bind = True 278 | return request, controls 279 | else: 280 | log.info("BindRequest for {dn!r} received ...", dn=request.dn) 281 | d = self.authenticate_bind_request(request) 282 | d.addCallback(self.send_bind_response, request, reply) 283 | d.addErrback(self.send_error_bind_response, request, reply) 284 | return None 285 | elif isinstance(request, pureldap.LDAPSearchRequest): 286 | # If the corresponding config option is not set, search requests are rejected. 287 | if not self.factory.allow_search: 288 | # TODO: Is that the right response? 289 | log.info('Incoming search request, but configuration allows no search.') 290 | reply(pureldap.LDAPSearchResultDone(ldaperrors.LDAPInsufficientAccessRights.resultCode, 291 | errorMessage='LDAP Search disallowed according to the configuration.')) 292 | return None 293 | # Apparently, we can forward the search request. 294 | # Assuming `bind-service-account` is enabled and the privacyIDEA authentication was successful, 295 | # the service account is already authenticated for `self.client`. 296 | return request, controls 297 | elif isinstance(request, pureldap.LDAPUnbindRequest): 298 | # We just forward any Unbind Request, regardless of whether we have sent a Bind Request to 299 | # the LDAP backend earlier. 300 | return request, controls 301 | else: 302 | log.info("{class_!r} received, rejecting.", class_=request.__class__.__name__) 303 | # TODO: Is that the right approach to reject (any) request? 304 | reply(pureldap.LDAPResult(ldaperrors.LDAPInsufficientAccessRights.resultCode, 305 | errorMessage='Rejecting LDAP Search without successful privacyIDEA authentication')) 306 | return None 307 | 308 | class ProxyServerFactory(protocol.ServerFactory): 309 | protocol = TwoFactorAuthenticationProxy 310 | 311 | def __init__(self, config): 312 | # NOTE: ServerFactory.__init__ does not exist? 313 | # Read configuration options. 314 | if config['privacyidea']['verify']: 315 | if config['privacyidea']['certificate']: 316 | certificate_path = config['privacyidea']['certificate'] 317 | certificate = Certificate.loadPEM(FilePath(certificate_path).getContent()) 318 | log.info('privacyIDEA HTTPS certificate will be checked against {certificate!r} from {path!r}', 319 | certificate=certificate, path=certificate_path) 320 | else: 321 | certificate = None 322 | log.info('privacyIDEA HTTPS certificate will be checked against system certificate store') 323 | https_policy = BrowserLikePolicyForHTTPS(certificate) 324 | else: 325 | log.warn('privacyIDEA HTTPS certificate will NOT be checked!') 326 | https_policy = DisabledVerificationPolicyForHTTPS() 327 | self.agent = Agent(reactor, https_policy) 328 | if config['ldap-backend']['use-tls']: 329 | # TODO: This seems to get lost if we use log.info 330 | log.warn('The use-tls config option is deprecated and will be ignored.') 331 | 332 | self.proxied_endpoint_string = config['ldap-backend']['endpoint'] 333 | self.privacyidea_instance = config['privacyidea']['instance'] 334 | # Construct the validate url from the instance location 335 | if self.privacyidea_instance[-1] != '/': 336 | self.privacyidea_instance += '/' 337 | self.validate_url = VALIDATE_URL_TEMPLATE.format(self.privacyidea_instance).encode('ascii') 338 | 339 | self.service_account_dn = config['service-account']['dn'] 340 | self.service_account_password = config['service-account']['password'] 341 | 342 | # We have to make a small workaround for configobj here: An empty config value 343 | # is interpreted as a list with one element, the empty string. 344 | self.passthrough_binds = config['ldap-proxy']['passthrough-binds'] 345 | if len(self.passthrough_binds) == 1 and self.passthrough_binds[0] == '': 346 | self.passthrough_binds = [] 347 | log.info('Passthrough DNs: {binds!r}', binds=self.passthrough_binds) 348 | 349 | self.forward_anonymous_binds = config['ldap-proxy']['forward-anonymous-binds'] 350 | 351 | self.allow_search = config['ldap-proxy']['allow-search'] 352 | self.bind_service_account = config['ldap-proxy']['bind-service-account'] 353 | self.allow_connection_reuse = config['ldap-proxy']['allow-connection-reuse'] 354 | self.ignore_search_result_references = config['ldap-proxy']['ignore-search-result-references'] 355 | 356 | user_mapping_strategy = USER_MAPPING_STRATEGIES[config['user-mapping']['strategy']] 357 | log.info('Using user mapping strategy: {strategy!r}', strategy=user_mapping_strategy) 358 | 359 | self.user_mapper = user_mapping_strategy(self, config['user-mapping']) 360 | 361 | realm_mapping_strategy = REALM_MAPPING_STRATEGIES[config['realm-mapping']['strategy']] 362 | log.info('Using realm mapping strategy: {strategy!r}', strategy=realm_mapping_strategy) 363 | 364 | self.realm_mapper = realm_mapping_strategy(self, config['realm-mapping']) 365 | 366 | enable_bind_cache = config['bind-cache']['enabled'] 367 | if enable_bind_cache: 368 | self.bind_cache = BindCache(config['bind-cache']['timeout']) 369 | else: 370 | self.bind_cache = None 371 | 372 | enable_app_cache = config['app-cache']['enabled'] 373 | if enable_app_cache: 374 | self.app_cache = AppCache(config['app-cache']['timeout'], config['app-cache']['case-insensitive']) 375 | else: 376 | self.app_cache = None 377 | self.app_cache_attribute = config['app-cache']['attribute'] 378 | self.app_cache_value_prefix = config['app-cache']['value-prefix'] 379 | 380 | if config['ldap-backend']['test-connection']: 381 | self.test_connection() 382 | 383 | @defer.inlineCallbacks 384 | def connect_service_account(self): 385 | """ 386 | Make a new connection to the LDAP backend server using the credentials of the service account 387 | :return: A Deferred that fires a `LDAPClient` instance 388 | """ 389 | client = yield connectToLDAPEndpoint(reactor, self.proxied_endpoint_string, LDAPClient) 390 | try: 391 | yield client.bind(self.service_account_dn, self.service_account_password) 392 | except ldaperrors.LDAPException as e: 393 | # Call unbind() here if an exception occurs: Otherwise, Twisted will keep the file open 394 | # and slowly run out of open files. 395 | yield client.unbind() 396 | raise e 397 | defer.returnValue(client) 398 | 399 | def resolve_user(self, dn): 400 | """ 401 | Invoke the user mapper to find the username of the user identified by the DN *dn*. 402 | :param dn: LDAP distinguished name as string 403 | :return: a Deferred firing a string (or raising a UserMappingError) 404 | """ 405 | return self.user_mapper.resolve(dn) 406 | 407 | def resolve_realm(self, dn): 408 | """ 409 | Invoke the realm mapper to find the realm of the user identified by the DN *dn*. 410 | :param dn: LDAP distinguished name as string 411 | :return: a Deferred firing a string (or raising a RealmMappingError) 412 | """ 413 | return self.realm_mapper.resolve(dn) 414 | 415 | def finalize_authentication(self, dn, app_marker, password): 416 | """ 417 | Called when a user was successfully authenticated by privacyIDEA. If the bind cache is enabled, 418 | add the corresponding credentials to the bind cache. 419 | :param dn: Distinguished Name as string 420 | :param app_marker: app marker 421 | :param password: Password as string 422 | """ 423 | if self.bind_cache is not None: 424 | self.bind_cache.add_to_cache(dn, app_marker, password) 425 | 426 | def process_search_response(self, request, response): 427 | """ 428 | Called when ``response`` is sent in response to ``request``. If the app cache is enabled, 429 | ``detect_login_preamble`` is invoked in order to detect a login preamble. If one was detected, 430 | the corresponding entry is added to the app cache. 431 | :param request: LDAPSearchRequest 432 | :param response: LDAPSearchResultEntry or LDAPSearchResultDone 433 | :return: 434 | """ 435 | if self.app_cache is not None: 436 | result = detect_login_preamble(request, 437 | response, 438 | self.app_cache_attribute, 439 | self.app_cache_value_prefix) 440 | if result is not None: 441 | dn, marker = result 442 | self.app_cache.add_to_cache(dn, marker) 443 | 444 | def is_bind_cached(self, dn, app_marker, password): 445 | """ 446 | Check whether the given credentials are found in the bind cache. 447 | If the bind cache is disabled, this always returns False. 448 | :param dn: Distinguished Name as string 449 | :param app_marker: App marker as string 450 | :param password: Password as string 451 | :return: a boolean 452 | """ 453 | if self.bind_cache is not None: 454 | return self.bind_cache.is_cached(dn, app_marker, password) 455 | else: 456 | return False 457 | 458 | def is_dn_blacklisted(self, dn): 459 | """ 460 | Check whether the given distinguished name is part of our blacklist 461 | :param dn: Distinguished Name as string 462 | :return: a boolean 463 | """ 464 | return any(pattern.match(dn) for pattern in DN_BLACKLIST) 465 | 466 | def buildProtocol(self, addr): 467 | """ 468 | called by Twisted for each new incoming connection. 469 | """ 470 | proto = self.protocol() 471 | client_connector = partial( 472 | connectToLDAPEndpoint, 473 | reactor, 474 | self.proxied_endpoint_string, 475 | LDAPClient) 476 | proto.factory = self 477 | proto.clientConnector = client_connector 478 | return proto 479 | 480 | @defer.inlineCallbacks 481 | def test_connection(self): 482 | """ 483 | Connect to the LDAP backend using an anonymous bind and unbind after that. 484 | :return: a Deferred that fires True or False 485 | """ 486 | try: 487 | client = yield self.connect_service_account() 488 | yield client.unbind() 489 | log.info('Successfully tested the connection to the LDAP backend using the service account') 490 | defer.returnValue(True) 491 | except Exception as e: 492 | log.failure('Could not connect to LDAP backend', exception=e) 493 | defer.returnValue(False) 494 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | --------------------------------------------------------------------------------