Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Find dn before login user #16

Merged
merged 8 commits into from
Nov 23, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This document describes changes between each past release.
0.3.0 (unreleased)
------------------

- Nothing changed yet.
- Support login from multiple DN from the same LDAP server (#16)


0.2.1 (2016-11-03)
Expand Down
9 changes: 6 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,18 @@ Configuration

multiauth.policy.ldap.use = kinto_ldap.authentication.LDAPBasicAuthAuthenticationPolicy

# kinto.ldap.cache_ttl_seconds = 30
# kinto.ldap.endpoint = ldap://ldap.prod.mozaws.net
# kinto.ldap.fqn = "uid={uid},ou=users,dc=mozilla"
kinto.ldap.cache_ttl_seconds = 30
kinto.ldap.endpoint = ldap://ldap.prod.mozaws.net
# kinto.ldap.bind_dn = uid=read_user,ou=logins,dc=mozilla
# kinto.ldap.bind_password = user_password

If necessary, override default values for authentication policy:

::

# multiauth.policy.ldap.realm = Realm
# kinto.ldap.base_dn = dc=mozilla
# kinto.ldap.filters = (mail={mail})
# kinto.ldap.pool_size = 10
# kinto.ldap.pool_retry_max = 3
# kinto.ldap.pool_retry_delay = .1
Expand Down
3 changes: 1 addition & 2 deletions config/kinto.ini
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ kinto.permission_backend = kinto.core.permission.memory
#
kinto.userid_hmac_secret = 2a2c8d277cf3cf7a9c45e73170763d4cb44c9c04f112d3e5999432a08d1a8840
multiauth.policies = ldap
multiauth.policy.ldap.use = kinto_ldap.authentication.LDAPBasicAuthAuthenticationPolicy
# multiauth.policies = fxa basicauth

# kinto.readonly = false
Expand All @@ -43,8 +44,6 @@ multiauth.policies = ldap
kinto.includes = kinto.plugins.default_bucket kinto_ldap


multiauth.policy.ldap.use = kinto_ldap.authentication.LDAPBasicAuthAuthenticationPolicy

#
# Firefox Accounts configuration.
# These are working FxA credentials for localhost:8888
Expand Down
13 changes: 12 additions & 1 deletion kinto_ldap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@
'LDAPBasicAuthAuthenticationPolicy'),
'ldap.cache_ttl_seconds': 30,
'ldap.endpoint': 'ldap://ldap.db.scl3.mozilla.com',
'ldap.base_dn': 'dc=mozilla',
'ldap.filters': '(mail={mail})',
'ldap.pool_size': 10,
'ldap.pool_retry_max': 3,
'ldap.pool_retry_delay': .1,
'ldap.pool_timeout': 30,
'ldap.fqn': 'mail={mail},o=com,dc=mozilla',
}


Expand All @@ -38,6 +39,16 @@ def includeme(config):
description="Basic Auth user are validated against an LDAP server.",
url="https://github.com/mozilla-services/kinto-ldap")

try:
settings['ldap.filters'].format(mail='test')
except KeyError:
msg = "ldap.filters should take a 'mail' argument only, got: %r" % settings['ldap.filters']
raise ConfigurationError(msg)
else:
if settings['ldap.filters'].format(mail='test') == settings['ldap.filters']:
msg = "ldap.filters should take a 'mail' argument, got: %r" % settings['ldap.filters']
raise ConfigurationError(msg)

# Register heartbeat to ping the LDAP server.
config.registry.heartbeats['ldap'] = ldap_ping

Expand Down
46 changes: 39 additions & 7 deletions kinto_ldap/authentication.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging

from kinto.core import utils
from ldap import INVALID_CREDENTIALS
from ldap import INVALID_CREDENTIALS, SCOPE_SUBTREE
from ldappool import BackendError
from pyramid.authentication import BasicAuthAuthenticationPolicy

Expand All @@ -20,18 +20,45 @@ def user_checker(username, password, request):
settings = request.registry.settings
cache_ttl = settings['ldap.cache_ttl_seconds']
hmac_secret = settings['userid_hmac_secret']
cache_key = utils.hmac_digest(hmac_secret, '%s:%s' % (username, password))
cache_key = utils.hmac_digest(hmac_secret, '{}:{}'.format(username, password))

cache = request.registry.cache
cache_result = cache.get(cache_key)

ldap_fqn = settings['ldap.fqn']
bind_dn = settings.get('ldap.bind_dn')
bind_password = settings.get('ldap.bind_password')

if cache_result is None:
cm = request.registry.ldap_cm

# 0. Generate a search filter by combining the attribute and
# filter provided in the ldap.fqn_filters directive with the
# username passed by the HTTP client.
base_dn = settings['ldap.base_dn']
filters = settings['ldap.filters'].format(mail=username)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this will fail if {mail} is not in the filters setting. Should we raise a ConfigurationError in case if it's not?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a nice way to handle this case, I will do that thanks.


# 1. Search for the user
try:
with cm.connection(bind_dn, bind_password) as conn:
# import pdb; pdb.set_trace()
results = conn.search_s(base_dn, SCOPE_SUBTREE, filters)
except BackendError:
logger.exception("LDAP error")
return None

if len(results) != 1:
# If the search does not return exactly one entry, deny or decline access.
return None

dn, entry = results[0]
user_dn = str(dn)

# 2. Fetch the distinguished name of the entry retrieved from
# the search and attempt to bind to the LDAP server using that
# DN and the password passed by the HTTP client. If the bind
# is unsuccessful, deny or decline access.
try:
with cm.connection(ldap_fqn.format(mail=username), password):
with cm.connection(user_dn, password):
cache.set(cache_key, "1", ttl=cache_ttl)
return []
except BackendError:
Expand Down Expand Up @@ -61,12 +88,17 @@ def unauthenticated_userid(self, request):

def ldap_ping(request):
"""Verify if the LDAP server is ready."""
settings = request.registry.settings
bind_dn = settings.get('ldap.bind_dn')
bind_password = settings.get('ldap.bind_password')
base_dn = settings['ldap.base_dn']
cm = request.registry.ldap_cm
try:
with cm.connection('mail=mail@test,o=com,dc=test', 'password'):
with cm.connection(bind_dn, bind_password) as conn:
# Perform a dumb query
filters = settings['ldap.filters'].format(mail="demo")
conn.search_s(base_dn, SCOPE_SUBTREE, filters)
ldap = True
except INVALID_CREDENTIALS:
ldap = True
except Exception:
logger.exception("Heartbeat Failure")
ldap = False
Expand Down
46 changes: 29 additions & 17 deletions kinto_ldap/tests/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ def setUp(self):
self.request = DummyRequest()
self.request.registry.cache = self.backend
self.request.registry.ldap_cm = mock.MagicMock()
self.conn = mock.MagicMock()
self.conn.search_s.return_value = [("dn", {})]
self.request.registry.ldap_cm.connection.return_value.__enter__.return_value = self.conn
settings = DEFAULT_SETTINGS.copy()
settings['userid_hmac_secret'] = 'abcdef'
settings['ldap.cache_ttl_seconds'] = 0.01
Expand All @@ -37,6 +40,13 @@ def test_returns_none_if_server_is_unreachable(self):
user_id = self.policy.authenticated_userid(self.request)
self.assertIsNone(user_id)

def test_returns_none_if_server_is_unreachable_the_second_time(self):
error = BackendError("unreachable", backend=None)
self.request.registry.ldap_cm.connection \
.return_value.__enter__.side_effect = [self.conn, error]
user_id = self.policy.authenticated_userid(self.request)
self.assertIsNone(user_id)

def test_returns_none_if_authorization_header_is_missing(self):
self.request.headers.pop('Authorization')
user_id = self.policy.authenticated_userid(self.request)
Expand All @@ -53,41 +63,43 @@ def test_returns_none_if_token_is_unknown(self):
self.assertIsNone(user_id)

def test_returns_ldap_userid(self):
mocked = mock.MagicMock()
self.request.registry.ldap_cm.connection \
.return_value.__enter__.return_value = mocked

user_id = self.policy.authenticated_userid(self.request)
self.assertEqual("username", user_id)

def test_auth_verification_uses_cache(self):
mocked = mock.MagicMock()
self.request.registry.ldap_cm.connection \
.return_value.__enter__.return_value = mocked

self.policy.authenticated_userid(self.request)
self.policy.authenticated_userid(self.request)
self.assertEqual(
1, self.request.registry.ldap_cm.connection.call_count)
2, self.request.registry.ldap_cm.connection.call_count)

def test_auth_verification_cache_has_ttl(self):
mocked = mock.MagicMock()
self.request.registry.ldap_cm.connection \
.return_value.__enter__.return_value = mocked

self.policy.authenticated_userid(self.request)
self.assertEqual(
2, self.request.registry.ldap_cm.connection.call_count)
time.sleep(0.02)
self.policy.authenticated_userid(self.request)
self.assertEqual(
2, self.request.registry.ldap_cm.connection.call_count)
4, self.request.registry.ldap_cm.connection.call_count)

def test_returns_none_if_user_password_mismatch(self):
self.request.registry.ldap_cm.connection \
.return_value.__enter__.side_effect = ldap.INVALID_CREDENTIALS()
.return_value.__enter__.side_effect = [self.conn, ldap.INVALID_CREDENTIALS()]
self.assertIsNone(self.policy.authenticated_userid(self.request))

def test_forget_uses_realm(self):
policy = authentication.LDAPBasicAuthAuthenticationPolicy(realm='Who')
headers = policy.forget(self.request)
self.assertEqual(headers[0],
('WWW-Authenticate', 'Basic realm="Who"'))
self.assertEqual(headers[0], ('WWW-Authenticate', 'Basic realm="Who"'))

def test_returns_none_if_multiple_ldap_search_results_matches(self):
self.conn.search_s.return_value = [("dn", {}), ("dn2", {})]
user_id = self.policy.authenticated_userid(self.request)
self.assertIsNone(user_id)

def test_bind_dn_and_bind_password_settings_are_used_if_specified(self):
self.request.registry.settings['ldap.bind_dn'] = "bind_dn"
self.request.registry.settings['ldap.bind_password'] = "bind_password"
self.policy.authenticated_userid(self.request)
self.assertEqual(2, self.request.registry.ldap_cm.connection.call_count)
self.request.registry.ldap_cm.connection.assert_any_call("bind_dn", "bind_password")
self.request.registry.ldap_cm.connection.assert_any_call("dn", "password")
20 changes: 20 additions & 0 deletions kinto_ldap/tests/test_includeme.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,23 @@ def test_connection_manager_is_instantiated_with_settings(self):
size=10,
timeout=30,
uri='ldap://ldap.db.scl3.mozilla.com')

def test_include_fails_if_ldap_filters_contains_multiple_keys(self):
config = testing.setUp()
settings = config.get_settings()
settings['ldap.filters'] = '{uid}{mail}'
kinto.core.initialize(config, '0.0.1')
with self.assertRaises(ConfigurationError) as e:
config.include(includeme)
message = "ldap.filters should take a 'mail' argument only, got: '{uid}{mail}'"
self.assertEqual(str(e.exception), message)

def test_include_fails_if_ldap_filters_is_missing_the_mail_key(self):
config = testing.setUp()
settings = config.get_settings()
settings['ldap.filters'] = 'toto'
kinto.core.initialize(config, '0.0.1')
with self.assertRaises(ConfigurationError) as e:
config.include(includeme)
message = "ldap.filters should take a 'mail' argument, got: 'toto'"
self.assertEqual(str(e.exception), message)
9 changes: 0 additions & 9 deletions kinto_ldap/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import webtest
from kinto.core.utils import random_bytes_hex
from pyramid.config import Configurator
from ldap import INVALID_CREDENTIALS

from kinto_ldap import __version__ as ldap_version

Expand Down Expand Up @@ -99,11 +98,3 @@ def test_heartbeat_returns_true_if_test_credentials_are_valid(self):
resp = self.app.get('/__heartbeat__')
heartbeat = resp.json['ldap']
self.assertTrue(heartbeat)

def test_heartbeat_returns_true_if_credentials_are_invalid(self):
self.app.app.registry.ldap_cm = mock.MagicMock()
self.app.app.registry.ldap_cm.connection \
.return_value.__enter__.side_effect = INVALID_CREDENTIALS
resp = self.app.get('/__heartbeat__')
heartbeat = resp.json['ldap']
self.assertTrue(heartbeat)
3 changes: 3 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ deps =
commands = flake8 kinto_ldap
deps =
flake8

[flake8]
max-line-length = 99