diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..8b21f1c --- /dev/null +++ b/.coveragerc @@ -0,0 +1,16 @@ +[run] +parallel = True +branch = False +omit = + ldapauthenticator/tests/* + +[report] +exclude_lines = + if self.debug: + pragma: no cover + raise NotImplementedError + if __name__ == .__main__.: +ignore_errors = True +omit = + ldapauthenticator/tests/* + */site-packages/* diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..6884725 --- /dev/null +++ b/.flake8 @@ -0,0 +1,22 @@ +[flake8] +# Ignore style and complexity +# E: style errors +# W: style warnings +# C: complexity +# F401: module imported but unused +# F403: import * +# F811: redefinition of unused `name` from line `N` +# F841: local variable assigned but never used +# E402: module level import not at top of file +# I100: Import statements are in the wrong order +# I101: Imported names are in the wrong order. Should be +ignore = E, C, W, F401, F403, F811, F841, E402, I100, I101, D400 +builtins = c, get_config +exclude = + .cache, + .github, + onbuild, + scripts, + share, + tools, + setup.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ae6a999 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: +- repo: https://github.com/asottile/reorder_python_imports + rev: v1.3.5 + hooks: + - id: reorder-python-imports + language_version: python3.6 +- repo: https://github.com/ambv/black + rev: 18.9b0 + hooks: + - id: black +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.1.0 + hooks: + - id: end-of-file-fixer + - id: check-json + - id: check-yaml + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: requirements-txt-fixer + - id: flake8 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..fddfb56 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,59 @@ +dist: xenial +language: python +sudo: false +cache: + - pip +python: + - 3.7 + - 3.6 +env: + global: + - ASYNC_TEST_TIMEOUT=15 + - LDAP_HOST=127.0.0.1 +services: + - docker + +# installing dependencies +before_install: + - set -e +install: + - pip install --upgrade pip + - pip install --upgrade --pre -r dev-requirements.txt . + - pip freeze + - | + # start LDAP server + if [[ -z "$TEST" ]]; then + ci/docker-ldap.sh + fi + +# running tests +script: + - | + # run tests + if [[ -z "$TEST" ]]; then + pytest -v --maxfail=2 --cov=ldapauthenticator ldapauthenticator/tests + fi + - | + # run autoformat + if [[ "$TEST" == "lint" ]]; then + pre-commit run --all-files + fi +after_success: + - codecov +after_failure: + - | + # point to auto-lint-fix + if [[ "$TEST" == "lint" ]]; then + echo "You can install pre-commit hooks to automatically run formatting" + echo "on each commit with:" + echo " pre-commit install" + echo "or you can run by hand on staged files with" + echo " pre-commit run" + echo "or after-the-fact on already committed files with" + echo " pre-commit run --all-files" + fi +matrix: + fast_finish: true + include: + - python: 3.6 + env: TEST=lint diff --git a/README.md b/README.md index 3f56d6c..f43132e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # ldapauthenticator +[![Build Status](https://travis-ci.com/jupyterhub/ldapauthenticator.svg?branch=master)](https://travis-ci.com/jupyterhub/ldapauthenticator) + Simple LDAP Authenticator Plugin for JupyterHub ## Installation ## @@ -11,7 +13,7 @@ pip install jupyterhub-ldapauthenticator ``` ...or using conda with: ``` -conda install -c conda-forge jupyterhub-ldapauthenticator +conda install -c conda-forge jupyterhub-ldapauthenticator ``` @@ -219,4 +221,3 @@ JupyterHub create local accounts using the LDAPAuthenticator. Issue [#19](https://github.com/jupyterhub/ldapauthenticator/issues/19) provides additional discussion on local user creation. - diff --git a/ci/docker-ldap.sh b/ci/docker-ldap.sh new file mode 100755 index 0000000..7c140f4 --- /dev/null +++ b/ci/docker-ldap.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# source this file to setup LDAP +# for local testing (as similar as possible to docker) + +set -e + +NAME="hub-test-ldap" +DOCKER_RUN="docker run -d --name $NAME" +RUN_ARGS="-p 389:389 -p 636:636 rroemhild/test-openldap" + +docker rm -f "$NAME" 2>/dev/null || true + +$DOCKER_RUN $RUN_ARGS diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..f225f7a --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,13 @@ +beautifulsoup4 +codecov +coverage +cryptography +html5lib # needed for beautifulsoup +mock +notebook +pre-commit +pytest-asyncio +pytest-cov +pytest>=3.3 +requests-mock +virtualenv diff --git a/ldapauthenticator/ldapauthenticator.py b/ldapauthenticator/ldapauthenticator.py index 754d541..f53da58 100644 --- a/ldapauthenticator/ldapauthenticator.py +++ b/ldapauthenticator/ldapauthenticator.py @@ -1,10 +1,14 @@ import re -from jupyterhub.auth import Authenticator import ldap3 +from jupyterhub.auth import Authenticator from ldap3.utils.conv import escape_filter_chars from tornado import gen -from traitlets import Unicode, Int, Bool, List, Union +from traitlets import Bool +from traitlets import Int +from traitlets import List +from traitlets import Unicode +from traitlets import Union class LDAPAuthenticator(Authenticator): @@ -14,7 +18,7 @@ class LDAPAuthenticator(Authenticator): Address of the LDAP server to contact. Could be an IP address or hostname. - """ + """, ) server_port = Int( config=True, @@ -22,7 +26,7 @@ class LDAPAuthenticator(Authenticator): Port on which to contact the LDAP server. Defaults to `636` if `use_ssl` is set, `389` otherwise. - """ + """, ) def _server_port_default(self): @@ -38,7 +42,7 @@ def _server_port_default(self): Use SSL to communicate with the LDAP server. Deprecated in version 3 of LDAP. Your LDAP server must be configured to support this, however. - """ + """, ) bind_dn_template = Union( @@ -62,7 +66,7 @@ def _server_port_default(self): uid={username},ou=people,dc=wikimedia,dc=org, uid={username},ou=Developers,dc=wikimedia,dc=org ] - """ + """, ) allowed_groups = List( @@ -79,13 +83,13 @@ def _server_port_default(self): Set to an empty list or None to allow all users that have an LDAP account to log in, without performing any group membership checks. - """ + """, ) # FIXME: Use something other than this? THIS IS LAME, akin to websites restricting things you # can use in usernames / passwords to protect from SQL injection! valid_username_regex = Unicode( - r'^[a-z][.a-z0-9_-]*$', + r"^[a-z][.a-z0-9_-]*$", config=True, help=""" Regex for validating usernames - those that do not match this regex will be rejected. @@ -96,7 +100,7 @@ def _server_port_default(self): the implications of allowing additional characters in usernames and what that means for LDAP injection issues. See https://www.owasp.org/index.php/LDAP_injection for an overview of LDAP injection. - """ + """, ) lookup_dn = Bool( @@ -109,7 +113,7 @@ def _server_port_default(self): However, in some installations, the user's DN does not contain the username, and hence needs to be looked up. You can set this to True and then use `user_search_base` and `user_attribute` to accomplish this. - """ + """, ) user_search_base = Unicode( @@ -134,7 +138,7 @@ def _server_port_default(self): c.LDAPAuthenticator.user_attribute = 'sAMAccountName' c.LDAPAuthenticator.lookup_dn_user_dn_attribute = 'cn' ``` - """ + """, ) user_attribute = Unicode( @@ -148,16 +152,16 @@ def _server_port_default(self): For most LDAP servers, this is uid. For Active Directory, it is sAMAccountName. - """ + """, ) lookup_dn_search_filter = Unicode( config=True, - default_value='({login_attr}={login})', + default_value="({login_attr}={login})", allow_none=True, help=""" How to query LDAP for user name lookup, if `lookup_dn` is set to True. - """ + """, ) lookup_dn_search_user = Unicode( @@ -168,7 +172,7 @@ def _server_port_default(self): Technical account for user lookup, if `lookup_dn` is set to True. If both lookup_dn_search_user and lookup_dn_search_password are None, then anonymous LDAP query will be done. - """ + """, ) lookup_dn_search_password = Unicode( @@ -177,7 +181,7 @@ def _server_port_default(self): allow_none=True, help=""" Technical account for user lookup, if `lookup_dn` is set to True. - """ + """, ) lookup_dn_user_dn_attribute = Unicode( @@ -190,7 +194,7 @@ def _server_port_default(self): See `user_search_base` for info on how this attribute is used. For most LDAP servers, this is username. For Active Directory, it is cn. - """ + """, ) escape_userdn = Bool( @@ -201,18 +205,14 @@ def _server_port_default(self): On some LDAP servers, when userdn contains chars like '(', ')', '\' authentication may fail when those chars are not escaped. - """ + """, ) search_filter = Unicode( - config=True, - help="LDAP3 Search Filter whose results are allowed access" + config=True, help="LDAP3 Search Filter whose results are allowed access" ) - attributes = List( - config=True, - help="List of attributes to be searched" - ) + attributes = List(config=True, help="List of attributes to be searched") use_lookup_dn_username = Bool( True, @@ -221,7 +221,7 @@ def _server_port_default(self): If set to true uses the `lookup_dn_user_dn_attribute` attribute as username instead of the supplied one. This can be useful in an heterogeneous environment, when supplying a UNIX username to authenticate against AD. - """ + """, ) def resolve_username(self, username_supplied_by_user): @@ -229,30 +229,32 @@ def resolve_username(self, username_supplied_by_user): if self.escape_userdn: search_dn = escape_filter_chars(search_dn) conn = self.get_connection( - userdn=search_dn, - password=self.lookup_dn_search_password, + userdn=search_dn, password=self.lookup_dn_search_password ) is_bound = conn.bind() if not is_bound: msg = "Failed to connect to LDAP server with search user '{search_dn}'" - self.log.warn(msg.format(search_dn=search_dn)) + self.log.warning(msg.format(search_dn=search_dn)) return None search_filter = self.lookup_dn_search_filter.format( - login_attr=self.user_attribute, - login=username_supplied_by_user, + login_attr=self.user_attribute, login=username_supplied_by_user + ) + msg = "\n".join( + [ + "Looking up user with:", + " search_base = '{search_base}'", + " search_filter = '{search_filter}'", + " attributes = '{attributes}'", + ] + ) + self.log.debug( + msg.format( + search_base=self.user_search_base, + search_filter=search_filter, + attributes=self.user_attribute, + ) ) - msg = '\n'.join([ - "Looking up user with:", - " search_base = '{search_base}'", - " search_filter = '{search_filter}'", - " attributes = '{attributes}'", - ]) - self.log.debug(msg.format( - search_base=self.user_search_base, - search_filter=search_filter, - attributes=self.user_attribute, - )) conn.search( search_base=self.user_search_base, search_scope=ldap3.SUBTREE, @@ -260,62 +262,59 @@ def resolve_username(self, username_supplied_by_user): attributes=[self.lookup_dn_user_dn_attribute], ) response = conn.response - if len(response) == 0 or 'attributes' not in response[0].keys(): + if len(response) == 0 or "attributes" not in response[0].keys(): msg = ( "No entry found for user '{username}' " "when looking up attribute '{attribute}'" ) - self.log.warn(msg.format( - username=username_supplied_by_user, - attribute=self.user_attribute, - )) + self.log.warning( + msg.format( + username=username_supplied_by_user, attribute=self.user_attribute + ) + ) return None - return conn.response[0]['attributes'][self.lookup_dn_user_dn_attribute] + return conn.response[0]["attributes"][self.lookup_dn_user_dn_attribute] def get_connection(self, userdn, password): server = ldap3.Server( - self.server_address, - port=self.server_port, - use_ssl=self.use_ssl + self.server_address, port=self.server_port, use_ssl=self.use_ssl ) auto_bind = ( - self.use_ssl - and ldap3.AUTO_BIND_TLS_BEFORE_BIND - or ldap3.AUTO_BIND_NO_TLS + self.use_ssl and ldap3.AUTO_BIND_TLS_BEFORE_BIND or ldap3.AUTO_BIND_NO_TLS ) conn = ldap3.Connection( - server, - user=userdn, - password=password, - auto_bind=auto_bind, + server, user=userdn, password=password, auto_bind=auto_bind ) return conn @gen.coroutine def authenticate(self, handler, data): - username = data['username'] - password = data['password'] + username = data["username"] + password = data["password"] # Protect against invalid usernames as well as LDAP injection attacks if not re.match(self.valid_username_regex, username): - self.log.warn( - 'username:%s Illegal characters in username, must match regex %s', - username, self.valid_username_regex + self.log.warning( + "username:%s Illegal characters in username, must match regex %s", + username, + self.valid_username_regex, ) return None # No empty passwords! - if password is None or password.strip() == '': - self.log.warn('username:%s Login denied for blank password', username) + if password is None or password.strip() == "": + self.log.warning("username:%s Login denied for blank password", username) return None if self.lookup_dn: username = self.resolve_username(username) if not username: return None + if isinstance(username, list): + username = username[0] if self.lookup_dn: - if str(self.lookup_dn_user_dn_attribute).upper() == 'CN': + if str(self.lookup_dn_user_dn_attribute).upper() == "CN": # Only escape commas if the lookup attribute is CN username = re.subn(r"([^\\]),", r"\1\,", username)[0] @@ -327,55 +326,49 @@ def authenticate(self, handler, data): is_bound = False for dn in bind_dn_template: if not dn: - self.log.warn("Ignoring blank 'bind_dn_template' entry!") + self.log.warning("Ignoring blank 'bind_dn_template' entry!") continue userdn = dn.format(username=username) if self.escape_userdn: userdn = escape_filter_chars(userdn) - msg = 'Attempting to bind {username} with {userdn}' + msg = "Attempting to bind {username} with {userdn}" self.log.debug(msg.format(username=username, userdn=userdn)) msg = "Status of user bind {username} with {userdn} : {is_bound}" try: conn = self.get_connection(userdn, password) except ldap3.core.exceptions.LDAPBindError as exc: is_bound = False - msg += '\n{exc_type}: {exc_msg}'.format( + msg += "\n{exc_type}: {exc_msg}".format( exc_type=exc.__class__.__name__, - exc_msg=exc.args[0] if exc.args else '' + exc_msg=exc.args[0] if exc.args else "", ) else: is_bound = conn.bind() - msg = msg.format( - username=username, - userdn=userdn, - is_bound=is_bound - ) + msg = msg.format(username=username, userdn=userdn, is_bound=is_bound) self.log.debug(msg) if is_bound: break if not is_bound: msg = "Invalid password for user '{username}'" - self.log.warn(msg.format(username=username)) + self.log.warning(msg.format(username=username)) return None if self.search_filter: search_filter = self.search_filter.format( - userattr=self.user_attribute, - username=username, + userattr=self.user_attribute, username=username ) conn.search( search_base=self.user_search_base, search_scope=ldap3.SUBTREE, search_filter=search_filter, - attributes=self.attributes + attributes=self.attributes, ) n_users = len(conn.response) if n_users == 0: msg = "User with '{userattr}={username}' not found in directory" - self.log.warn(msg.format( - userattr=self.user_attribute, - username=username) + self.log.warning( + msg.format(userattr=self.user_attribute, username=username) ) return None if n_users > 1: @@ -383,65 +376,63 @@ def authenticate(self, handler, data): "Duplicate users found! " "{n_users} users found with '{userattr}={username}'" ) - self.log.warn(msg.format( - userattr=self.user_attribute, - username=username, - n_users=n_users) + self.log.warning( + msg.format( + userattr=self.user_attribute, username=username, n_users=n_users + ) ) return None if self.allowed_groups: - self.log.debug('username:%s Using dn %s', username, userdn) + self.log.debug("username:%s Using dn %s", username, userdn) found = False for group in self.allowed_groups: group_filter = ( - '(|' - '(member={userdn})' - '(uniqueMember={userdn})' - '(memberUid={uid})' - ')' + "(|" + "(member={userdn})" + "(uniqueMember={userdn})" + "(memberUid={uid})" + ")" ) - group_filter = group_filter.format( - userdn=userdn, - uid=username - ) - group_attributes = ['member', 'uniqueMember', 'memberUid'] + group_filter = group_filter.format(userdn=userdn, uid=username) + group_attributes = ["member", "uniqueMember", "memberUid"] found = conn.search( group, search_scope=ldap3.BASE, search_filter=group_filter, - attributes=group_attributes + attributes=group_attributes, ) if found: break if not found: # If we reach here, then none of the groups matched - msg = 'username:{username} User not in any of the allowed groups' - self.log.warn(msg.format(username=username)) + msg = "username:{username} User not in any of the allowed groups" + self.log.warning(msg.format(username=username)) return None if self.use_lookup_dn_username: return username else: - return data['username'] + return data["username"] if __name__ == "__main__": import getpass + c = LDAPAuthenticator() c.server_address = "ldap.organisation.org" c.server_port = 636 c.bind_dn_template = "uid={username},ou=people,dc=organisation,dc=org" - c.user_attribute = 'uid' - c.user_search_base = 'ou=people,dc=organisation,dc=org' - c.attributes = ['uid','cn','mail','ou','o'] + c.user_attribute = "uid" + c.user_search_base = "ou=people,dc=organisation,dc=org" + c.attributes = ["uid", "cn", "mail", "ou", "o"] # The following is an example of a search_filter which is build on LDAP AND and OR operations # here in this example as a combination of the LDAP attributes 'ou', 'mail' and 'uid' - sf = "(&(o={o})(ou={ou}))".format(o='yourOrganisation',ou='yourOrganisationalUnit') - sf += "(&(o={o})(mail={mail}))".format(o='yourOrganisation',mail='yourMailAddress') + sf = "(&(o={o})(ou={ou}))".format(o="yourOrganisation", ou="yourOrganisationalUnit") + sf += "(&(o={o})(mail={mail}))".format(o="yourOrganisation", mail="yourMailAddress") c.search_filter = "(&({{userattr}}={{username}})(|{}))".format(sf) - username = input('Username: ') + username = input("Username: ") passwd = getpass.getpass() - data = dict(username=username,password=passwd) - rs=c.authenticate(None,data) + data = dict(username=username, password=passwd) + rs = c.authenticate(None, data) print(rs.result()) diff --git a/ldapauthenticator/tests/__init__.py b/ldapauthenticator/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ldapauthenticator/tests/conftest.py b/ldapauthenticator/tests/conftest.py new file mode 100644 index 0000000..545744f --- /dev/null +++ b/ldapauthenticator/tests/conftest.py @@ -0,0 +1,37 @@ +import inspect +import os + +import pytest + +from ..ldapauthenticator import LDAPAuthenticator + + +def pytest_collection_modifyitems(items): + """add asyncio marker to all async tests""" + for item in items: + if inspect.iscoroutinefunction(item.obj): + item.add_marker("asyncio") + if hasattr(inspect, "isasyncgenfunction"): + # double-check that we aren't mixing yield and async def + assert not inspect.isasyncgenfunction(item.obj) + + +@pytest.fixture(scope="session") +def authenticator(): + authenticator = LDAPAuthenticator() + authenticator.server_address = os.environ.get("LDAP_HOST", "localhost") + authenticator.lookup_dn = True + authenticator.bind_dn_template = "cn={username},ou=people,dc=planetexpress,dc=com" + authenticator.user_search_base = "ou=people,dc=planetexpress,dc=com" + authenticator.user_attribute = "uid" + authenticator.lookup_dn_user_dn_attribute = "cn" + authenticator.escape_userdn = True + authenticator.attributes = ["uid", "cn", "mail", "ou"] + authenticator.use_lookup_dn_username = False + + authenticator.allowed_groups = [ + "cn=admin_staff,ou=people,dc=planetexpress,dc=com", + "cn=ship_crew,ou=people,dc=planetexpress,dc=com", + ] + + return authenticator diff --git a/ldapauthenticator/tests/test_ldapauthenticator.py b/ldapauthenticator/tests/test_ldapauthenticator.py new file mode 100644 index 0000000..ae0fed5 --- /dev/null +++ b/ldapauthenticator/tests/test_ldapauthenticator.py @@ -0,0 +1,92 @@ +# Inspired by https://github.com/jupyterhub/jupyterhub/blob/master/jupyterhub/tests/test_auth.py + + +async def test_ldap_auth_allowed(authenticator): + # proper username and password in allowed group + authorized = await authenticator.get_authenticated_user( + None, {"username": "fry", "password": "fry"} + ) + assert authorized["name"] == "fry" + + +async def test_ldap_auth_disallowed(authenticator): + # invalid username + authorized = await authenticator.get_authenticated_user( + None, {"username": "3fry/", "password": "raw"} + ) + assert authorized is None + + # incorrect password + authorized = await authenticator.get_authenticated_user( + None, {"username": "fry", "password": "raw"} + ) + assert authorized is None + + # blank password + authorized = await authenticator.get_authenticated_user( + None, {"username": "fry", "password": ""} + ) + assert authorized is None + + # nonexistant username + authorized = await authenticator.get_authenticated_user( + None, {"username": "flexo", "password": "imposter"} + ) + assert authorized is None + + # proper username and password but not in allowed group + authorized = await authenticator.get_authenticated_user( + None, {"username": "zoidberg", "password": "zoidberg"} + ) + assert authorized is None + + +async def test_ldap_auth_blank_template(authenticator): + authenticator.bind_dn_template = [authenticator.bind_dn_template, ""] + + # proper username and password in allowed group + authorized = await authenticator.get_authenticated_user( + None, {"username": "fry", "password": "fry"} + ) + assert authorized["name"] == "fry" + + +async def test_ldap_auth_ssl(authenticator): + authenticator.use_ssl = True + authenticator.server_port = 636 + + # proper username and password in allowed group + authorized = await authenticator.get_authenticated_user( + None, {"username": "fry", "password": "fry"} + ) + assert authorized["name"] == "fry" + + +async def test_ldap_auth_use_lookup_dn(authenticator): + authenticator.use_lookup_dn_username = True + + # proper username and password in allowed group + authorized = await authenticator.get_authenticated_user( + None, {"username": "fry", "password": "fry"} + ) + assert authorized["name"] == "philip j. fry" + authenticator.use_lookup_dn_username = False + + +async def test_ldap_auth_search_filter(authenticator): + authenticator.allowed_groups = [] + authenticator.search_filter = ( + "(&(objectClass=inetOrgPerson)(ou= Delivering Crew)(cn={username}))" + ) + + # proper username and password in allowed group + authorized = await authenticator.get_authenticated_user( + None, {"username": "fry", "password": "fry"} + ) + assert authorized["name"] == "fry" + + # proper username and password but not in search filter + authorized = await authenticator.get_authenticated_user( + None, {"username": "zoidberg", "password": "zoidberg"} + ) + assert authorized is None diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..9dffb00 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,11 @@ +[pytest] +# pytest 3.10 has broken minversion checks, +# so we have to disable this until pytest 3.11 +# minversion = 3.3 + +python_files = test_*.py +markers = + group: mark as a test for groups + services: mark as a services test + user: mark as a test for a user + slow: mark a test as slow diff --git a/setup.py b/setup.py index 649d092..208b7a6 100644 --- a/setup.py +++ b/setup.py @@ -1,26 +1,21 @@ from setuptools import setup -version = '1.2.2' +version = "1.2.2" -with open("./ldapauthenticator/__init__.py", 'a') as f: +with open("./ldapauthenticator/__init__.py", "a") as f: f.write("\n__version__ = '{}'\n".format(version)) setup( - name='jupyterhub-ldapauthenticator', + name="jupyterhub-ldapauthenticator", version=version, - description='LDAP Authenticator for JupyterHub', - url='https://github.com/jupyterhub/ldapauthenticator', - author='Yuvi Panda', - author_email='yuvipanda@riseup.net', - license='3 Clause BSD', - packages=['ldapauthenticator'], - install_requires=[ - 'jupyterhub', - 'ldap3', - 'tornado', - 'traitlets', - ] + description="LDAP Authenticator for JupyterHub", + url="https://github.com/jupyterhub/ldapauthenticator", + author="Yuvi Panda", + author_email="yuvipanda@riseup.net", + license="3 Clause BSD", + packages=["ldapauthenticator"], + install_requires=["jupyterhub", "ldap3", "tornado", "traitlets"], )