diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed27ddb --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +*.pyc +*.egg +*.egg-info +*.pyc +*$py.class +*~ +.coverage +.tox/ +nosetests.xml +tutorial.db +build/ +dist/ +bin/ +lib/ +include/ +.idea/ +distribute-*.tar.gz +env*/ diff --git a/CHANGES.txt b/CHANGES.txt new file mode 100644 index 0000000..35a34f3 --- /dev/null +++ b/CHANGES.txt @@ -0,0 +1,4 @@ +0.0 +--- + +- Initial version diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..f03609a --- /dev/null +++ b/README.txt @@ -0,0 +1 @@ +pyramid_ldap README diff --git a/TODO.txt b/TODO.txt new file mode 100644 index 0000000..9dc0592 --- /dev/null +++ b/TODO.txt @@ -0,0 +1,2 @@ +- User object / attribute mappings. + diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..92dedcb --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,2 @@ +_build/ +_themes/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..bb381fc --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,86 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = -W +SPHINXBUILD = sphinx-build +PAPER = + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d _build/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html web pickle htmlhelp latex changes linkcheck + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " pickle to make pickle files (usable by e.g. sphinx-web)" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " changes to make an overview over all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + +clean: + -rm -rf _build/* + +html: + mkdir -p _build/html _build/doctrees + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) _build/html + @echo + @echo "Build finished. The HTML pages are in _build/html." + +text: + mkdir -p _build/text _build/doctrees + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) _build/text + @echo + @echo "Build finished. The HTML pages are in _build/text." + +pickle: + mkdir -p _build/pickle _build/doctrees + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) _build/pickle + @echo + @echo "Build finished; now you can process the pickle files or run" + @echo " sphinx-web _build/pickle" + @echo "to start the sphinx-web server." + +web: pickle + +htmlhelp: + mkdir -p _build/htmlhelp _build/doctrees + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) _build/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in _build/htmlhelp." + +latex: + mkdir -p _build/latex _build/doctrees + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) _build/latex + cp _static/*.png _build/latex + ./convert_images.sh + cp _static/latex-warning.png _build/latex + cp _static/latex-note.png _build/latex + @echo + @echo "Build finished; the LaTeX files are in _build/latex." + @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ + "run these through (pdf)latex." + +changes: + mkdir -p _build/changes _build/doctrees + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) _build/changes + @echo + @echo "The overview file is in _build/changes." + +linkcheck: + mkdir -p _build/linkcheck _build/doctrees + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) _build/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in _build/linkcheck/output.txt." + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) _build/epub + @echo + @echo "Build finished. The epub file is in _build/epub." + diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..48ceb97 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,34 @@ +.. _pyramid_ldap_api: + +:mod:`pyramid_ldap` API +----------------------- + +Configuration +~~~~~~~~~~~~~ + +.. automodule:: pyramid_ldap + +.. autofunction:: ldap_set_login_query + +.. autofunction:: ldap_set_groups_query + +.. autofunction:: ldap_setup + +.. autofunction:: includeme + +Usage +~~~~~ + +.. autofunction:: get_ldap_connector + +.. autoclass:: Connector + :members: + + .. attribute:: manager + + An ``ldappool`` ConnectionManager instance that can be used to perform + arbitrary LDAP queries. See + https://github.com/mozilla-services/ldappool + +.. autofunction:: groupfinder + diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..25caa77 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +# +# pyramid_ldap documentation build configuration file +# +# This file is execfile()d with the current directory set to its containing +# dir. +# +# The contents of this file are pickled, so don't put values in the +# namespace that aren't pickleable (module imports are okay, they're +# removed automatically). +# +# All configuration values have a default value; values that are commented +# out serve to show the default value. + +# If your extensions are in another directory, add it here. If the +# directory is relative to the documentation root, use os.path.abspath to +# make it absolute, like shown here. +#sys.path.append(os.path.abspath('some/directory')) + +import sys, os + +# Add and use Pylons theme +if 'sphinx-build' in ' '.join(sys.argv): # protect against dumb importers + from subprocess import call, Popen, PIPE + + p = Popen('which git', shell=True, stdout=PIPE) + git = p.stdout.read().strip() + cwd = os.getcwd() + _themes = os.path.join(cwd, '_themes') + + if not os.path.isdir(_themes): + call([git, 'clone', 'git://github.com/Pylons/pylons_sphinx_theme.git', + '_themes']) + else: + os.chdir(_themes) + call([git, 'checkout', 'master']) + call([git, 'pull']) + os.chdir(cwd) + + sys.path.append(os.path.abspath('_themes')) + + parent = os.path.dirname(os.path.dirname(__file__)) + sys.path.append(os.path.abspath(parent)) + wd = os.getcwd() + os.chdir(parent) + os.system('%s setup.py test -q' % sys.executable) + os.chdir(wd) + + for item in os.listdir(parent): + if item.endswith('.egg'): + sys.path.append(os.path.join(parent, item)) + +# General configuration +# --------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = [ + 'sphinx.ext.autodoc', + ] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['.templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General substitutions. +project = 'pyramid_ldap' +copyright = '2012, Agendaless Consulting ' + +# The default replacements for |version| and |release|, also used in various +# other places throughout the built documents. +# +# The short X.Y version. +version = '0.9.9.1' +# The full version, including alpha/beta/rc tags. +release = version + +# There are two options for replacing |today|: either, you set today to +# some non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +today_fmt = '%B %d, %Y' + +# List of documents that shouldn't be included in the build. +#unused_docs = [] + +# List of directories, relative to source directories, that shouldn't be +# searched for source files. +#exclude_dirs = [] + +exclude_patterns = ['_themes/README.rst',] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + + +# Options for HTML output +# ----------------------- + +# Add and use Pylons theme +sys.path.append(os.path.abspath('_themes')) +html_theme_path = ['_themes'] +html_theme = 'pyramid' +html_theme_options = dict(github_url='https://github.com/Pylons/pyramid_ldap') + +# The style sheet to use for HTML and HTML Help pages. A file of that name +# must exist either in Sphinx' static/ path, or in one of the custom paths +# given in html_static_path. +# html_style = 'repoze.css' + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as +# html_title. +#html_short_title = None + +# The name of an image file (within the static path) to place at the top of +# the sidebar. +# html_logo = '.static/logo_hi.gif' + +# The name of an image file (within the static path) to use as favicon of +# the docs. This file should be a Windows icon file (.ico) being 16x16 or +# 32x32 pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) +# here, relative to this directory. They are copied after the builtin +# static files, so a file named "default.css" will overwrite the builtin +# "default.css". +#html_static_path = ['.static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page +# bottom, using the given strftime format. +html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_use_modindex = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, the reST sources are included in the HTML build as +# _sources/. +#html_copy_source = True + +# If true, an OpenSearch description file will be output, and all pages +# will contain a tag referring to it. The value of this option must +# be the base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = '' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'atemplatedoc' + + +# Options for LaTeX output +# ------------------------ + +# The paper size ('letter' or 'a4'). +#latex_paper_size = 'letter' + +# The font size ('10pt', '11pt' or '12pt'). +#latex_font_size = '10pt' + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, document class [howto/manual]). +latex_documents = [ + ('index', 'pyramid_ldap.tex', 'pyramid_ldap Documentation', + 'Repoze Developers', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the +# top of the title page. +latex_logo = '.static/logo_hi.gif' + +# For "manual" documents, if this is true, then toplevel headings are +# parts, not chapters. +#latex_use_parts = False + +# Additional stuff for the LaTeX preamble. +#latex_preamble = '' + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_use_modindex = True diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..aa79792 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,134 @@ +pyramid_debugtoolbar +==================== + +Overview +-------- + +:mod:`pyramid_ldap` provides LDAP authentication services to your Pyramid +application. + +.. warning:: This package only works with Pyramid 1.3a9 and better. + +Installation +------------ + +Install using setuptools, e.g. (within a virtualenv):: + + $ easy_install pyramid_ldap + +Setup +----- + +Once :mod:`pyramid_ldap` is installed, you must use the ``config.include`` +mechanism to include it into your Pyramid project's configuration. In your +Pyramid project's ``__init__.py``: + +.. code-block:: python + + config = Configurator(.....) + config.include('pyramid_ldap') + +Alternately, instead of using the Configurator's ``include`` method, you can +activate Pyramid by changing your application's ``.ini`` file, use the +following line: + +.. code-block:: ini + + pyramid.includes = pyramid_ldap + +Once you've included ``pyramid_ldap``, you have to call methods of the +Configurator to tell it about your LDAP server and query particulars. Here's +an example of calling methods to create a fully-configured LDAP setup that +attempts to talk to an Active Directory server: + +.. code-block:: python + + import ldap + + config = Configurator() + + config.include('pyramid_ldap') + + config.ldap_setup( + 'ldap://ldap.example.com', + bind='CN=ldap user,CN=Users,DC=example,DC=com', + passwd='ld@pu5er' + ) + + config.ldap_set_login_query( + base_dn='CN=Users,DC=example,DC=com', + filter_tmpl='(sAMAccountName=%(login)s)', + scope = ldap.SCOPE_ONELEVEL, + ) + + config.ldap_set_groups_query( + base_dn='CN=Users,DC=example,DC=com', + filter_tmpl='(&(objectCategory=group)(member=%(dn)s))', + scope = ldap.SCOPE_SUBTREE, + cache_secs = 600, + ) + +Configurator Methods +-------------------- + +Configuration of ``pyramid_ldap`` is done via the Configurator methods named +``ldap_setup``, ``ldap_set_login_query``, and ``ldap_set_groups_query``. All +three of these methods should be called once (and, ideally, only once) during +the startup phase of your Pyramid application. + +``Configurator.ldap_setup`` + + This Configurator method accepts arguments used to set up an LDAP + connection. After you call it, you will be able to use the + :func:`pyramid_ldap.get_ldap_connector` API from within your application. + It will return a :class:`pyramid_ldap.Connector` instance. See + :func:`pyramid_ldap.ldap_setup` for argument details. + +``Configurator.ldap_set_login_query`` + + This configurator method accepts parameters which tell ``pyramid_ldap`` + how to find a user based on a login. Invoking this method allows the LDAP + connector's ``authenticate`` method to work. See + :func:`pyramid_ldap.ldap_set_login_query` for argument details. + + If ``ldap_set_login_query`` is not called, the + :meth:`pyramid_ldap.Connector.authenticate` method will not work. + +``Configurator.ldap_set_groups_query`` + + This configurator method accepts parameters which tell ``pyramid_ldap`` + how to find groups based on a user DN. Invoking this method allows the + connector's ``user_groups`` method to work. See + :func:`pyramid_ldap.ldap_set_groups_query` for argument details. + + If ``ldap_set_groups_query`` is not called, the + :meth:`pyramid_ldap.Connector.user_groups` method will not work. + +Usage +----- + +See the ``sampleapp`` sample application in this package for usage +information. + +More Information +---------------- + +.. toctree:: + :maxdepth: 1 + + api.rst + +Reporting Bugs / Development Versions +------------------------------------- + +Visit http://github.com/Pylons/pyramid_ldap to download development or +tagged versions. + +Visit http://github.com/Pylons/pyramid_ldap/issues to report bugs. + +Indices and tables +------------------ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/remake b/docs/remake new file mode 100755 index 0000000..b236f29 --- /dev/null +++ b/docs/remake @@ -0,0 +1 @@ +make clean html SPHINXBUILD=../env26/bin/sphinx-build diff --git a/pyramid_ldap/__init__.py b/pyramid_ldap/__init__.py new file mode 100644 index 0000000..41952c4 --- /dev/null +++ b/pyramid_ldap/__init__.py @@ -0,0 +1,290 @@ +import ldap +import logging +import time + +from pyramid.exceptions import ConfigurationError +from pyramid.compat import bytes_ + +from ldappool import ConnectionManager + +logger = logging.getLogger(__name__) + +class _LDAPQuery(object): + """ Represents an LDAP query. Provides rudimentary in-RAM caching of + query results.""" + def __init__(self, base_dn, filter_tmpl, scope, cache_period): + self.base_dn = base_dn + self.filter_tmpl = filter_tmpl + self.scope = scope + self.cache_period = cache_period + self.last_timeslice = 0 + self.cache = {} + + def query_cache(self, cache_key): + result = None + now = time.time() + ts = _timeslice(self.cache_period, now) + + if ts > self.last_timeslice: + logger.debug('dumping cache; now ts: %r, last_ts: %r' % ( + ts, + self.last_timeslice) + ) + self.cache = {} + self.last_timeslice = ts + + result = self.cache.get(cache_key) + + return result + + def execute(self, conn, **kw): + cache_key = ( + bytes_(self.base_dn % kw, 'utf-8'), + self.scope, + bytes_(self.filter_tmpl % kw, 'utf-8') + ) + + logger.debug('searching for %r' % (cache_key,)) + + if self.cache_period: + result = self.query_cache(cache_key) + if result is not None: + logger.debug('result for %r retrieved from cache' % + (cache_key,) + ) + else: + result = conn.search_s(*cache_key) + self.cache[cache_key] = result + else: + result = conn.search_s(*cache_key) + + logger.debug('search result: %r' % (result,)) + + return result + +def _timeslice(period, when=None): + if when is None: # pragma: no cover + when = time.time() + return when - (when % period) + +class Connector(object): + """ Provides API methods for accessing LDAP authentication information.""" + def __init__(self, registry, manager): + self.registry = registry + self.manager = manager + + def authenticate(self, login, password): + """ Given a login name and a password, return a tuple of ``(dn, + attrdict)`` if the matching user if the user exists and his password + is correct. Otherwise return ``None``. + + In a ``(dn, attrdict)`` return value, ``dn`` will be the + distinguished name of the authenticated user. Attrdict will be a + dictionary mapping LDAP user attributes to sequences of values. The + keys and values in the dictionary values provided will be decoded + from UTF-8, recursively, where possible. + + If :meth:`pyramid.config.Configurator.ldap_set_login_query` was not + called, using this function will raise an + :exc:`pyramid.exceptions.ConfiguratorError`.""" + with self.manager.connection() as conn: + search = getattr(self.registry, 'ldap_login_search', None) + if search is None: + raise ConfigurationError( + 'ldap_set_login_query was not called during setup') + + result = search.execute(conn, login=login, password=password) + if len(result) == 1: + login_dn = result[0][0] + else: + return None + try: + with self.manager.connection(login_dn, password) as conn: + # must invoke the __enter__ of this thing for it to connect + return _ldap_decode(result[0]) + except ldap.LDAPError: + logger.debug('Exception in authenticate with login %r' % login, + exc_info=True) + return None + + def user_groups(self, userdn): + """ Given a user DN, return a sequence of LDAP attribute dictionaries + matching the groups of which the DN is a member. If the DN does not + exist, return ``None``. + + In a return value ``[(dn, attrdict), ...]``, ``dn`` will be the + distinguished name of the group. Attrdict will be a dictionary + mapping LDAP group attributes to sequences of values. The keys and + values in the dictionary values provided will be decoded from UTF-8, + recursively, where possible. + + If :meth:`pyramid.config.Configurator.ldap_set_groups_query` was not + called, using this function will raise an + :exc:`pyramid.exceptions.ConfiguratorError` + """ + with self.manager.connection() as conn: + search = getattr(self.registry, 'ldap_groups_search', None) + if search is None: + raise ConfigurationError( + 'set_ldap_groups_search was not called during setup') + try: + result = search.execute(conn, userdn=userdn) + return _ldap_decode(result) + except ldap.LDAPError: + logger.debug( + 'Exception in user_groups with userdn %r' % userdn, + exc_info=True) + return None + +def ldap_set_login_query(config, base_dn, filter_tmpl, + scope=ldap.SCOPE_ONELEVEL, cache_secs=0): + """ Configurator method to set the LDAP login search. ``base_dn`` is the + DN at which to begin the search. ``filter_tmpl`` is a string which can + be used as an LDAP filter: it should contain the replacement value + ``%(login)s``. Scope is any valid LDAP scope value + (e.g. ``ldap.SCOPE_ONELEVEL``). ``cache_secs`` is the number of seconds + to cache login search results; if it is 0, login search results will not + be cached. + + Example:: + + config.set_ldap_login_search( + base_dn='CN=Users,DC=example,DC=com', + filter_tmpl='(sAMAccountName=%(login)s)', + scope=ldap.SCOPE_ONELEVEL, + ) + + The registered search must return one and only one value to be considered + a valid login. + """ + config.registry.ldap_login_search = _LDAPQuery( + base_dn, filter_tmpl, scope, cache_secs) + +def ldap_set_groups_query(config, base_dn, filter_tmpl, + scope=ldap.SCOPE_SUBTREE, cache_secs=600): + """ Configurator method to set the LDAP groups search. ``base_dn`` is + the DN at which to begin the search. ``filter_tmpl`` is a string which + can be used as an LDAP filter: it should contain the replacement value + ``%(userdn)s``. Scope is any valid LDAP scope value + (e.g. ``ldap.SCOPE_SUBTREE``). ``cache_secs`` is the number of seconds + to cache groups search results; if it is 0, groups search results will + not be cached. + + Example:: + + config.set_ldap_groups_search( + base_dn='CN=Users,DC=example,DC=com', + filter_tmpl='(&(objectCategory=group)(member=%(userdn)s))' + scope=ldap.SCOPE_SUBTREE, + ) + + """ + config.registry.ldap_groups_search = _LDAPQuery( + base_dn, filter_tmpl, scope, cache_secs) + +def ldap_setup(config, uri, bind=None, passwd=None, pool_size=10, retry_max=3, + retry_delay=.1, use_tls=False, timeout=-1, use_pool=True): + """ Configurator method to set up an LDAP connection pool. + + - **uri**: ldap server uri **[mandatory]** + - **bind**: default bind that will be used to bind a connector. + **default: None** + - **passwd**: default password that will be used to bind a connector. + **default: None** + - **size**: pool size. **default: 10** + - **retry_max**: number of attempts when a server is down. **default: 3** + - **retry_delay**: delay in seconds before a retry. **default: .1** + - **use_tls**: activate TLS when connecting. **default: False** + - **timeout**: connector timeout. **default: -1** + - **use_pool**: activates the pool. If False, will recreate a connector + each time. **default: True** + """ + manager = ConnectionManager( + uri=uri, bind=bind, passwd=passwd, size=pool_size, + retry_max=retry_max, retry_delay=retry_delay, use_tls=use_tls, + timeout=-1, use_pool=use_pool + ) + def get_connector(request): + registry = request.registry + return Connector(registry, manager) + config.set_request_property(get_connector, 'ldap_connector', reify=True) + +def get_ldap_connector(request): + """ Return the LDAP connector attached to the request. If + :meth:`pyramid.config.Configurator.ldap_setup` was not called, using + this function will raise an :exc:`pyramid.exceptions.ConfigurationError`.""" + connector = getattr(request, 'ldap_connector', None) + if connector is None: + raise ConfigurationError( + 'You must call Configurator.ldap_setup during setup ' + 'to use an ldap connector') + return connector + +def groupfinder(dn, request): + """ A groupfinder implementation useful in conjunction with + out-of-the-box Pyramid authentication policies. It returns the DN of + each group belonging to the user specified by ``dn`` to as a principal in + the list of results; if the user does not exist, it returns None.""" + connector = get_ldap_connector(request) + group_list = connector.user_groups(dn) + if group_list is None: + return None + group_dns = [] + for dn, attrs in group_list: + group_dns.append(dn) + return group_dns + +def _ldap_decode(result): + """ Decode (recursively) strings in the result data structure to Unicode + using the utf-8 encoding """ + return _Decoder().decode(result) + +class _Decoder(object): + """ + Stolen from django-auth-ldap. + + Encodes and decodes strings in a nested structure of lists, tuples, and + dicts. This is helpful when interacting with the Unicode-unaware + python-ldap. + """ + + ldap = ldap + + def __init__(self, encoding='utf-8'): + self.encoding = encoding + + def decode(self, value): + try: + if isinstance(value, str): + value = value.decode(self.encoding) + elif isinstance(value, list): + value = self._decode_list(value) + elif isinstance(value, tuple): + value = tuple(self._decode_list(value)) + elif isinstance(value, dict): + value = self._decode_dict(value) + except UnicodeDecodeError: + pass + + return value + + def _decode_list(self, value): + return [self.decode(v) for v in value] + + def _decode_dict(self, value): + # Attribute dictionaries should be case-insensitive. python-ldap + # defines this, although for some reason, it doesn't appear to use it + # for search results. + decoded = self.ldap.cidict.cidict() + + for k, v in value.iteritems(): + decoded[self.decode(k)] = self.decode(v) + + return decoded + +def includeme(config): + """ Set up Configurator methods for pyramid_ldap """ + config.add_directive('ldap_setup', ldap_setup) + config.add_directive('ldap_set_login_query', ldap_set_login_query) + config.add_directive('ldap_set_groups_query', ldap_set_groups_query) + diff --git a/pyramid_ldap/declarative.py b/pyramid_ldap/declarative.py new file mode 100644 index 0000000..570877e --- /dev/null +++ b/pyramid_ldap/declarative.py @@ -0,0 +1,71 @@ +import ldap +from pyramid.settings import asbool + +def scope_converter(v): + if 'subtree' in v.lower(): + return ldap.SCOPE_SUBTREE + if 'onelevel' in v.lower(): + return ldap.SCOPE_ONELEVEL + if 'base' in v.lower(): + return ldap.SCOPE_BASE + raise ValueError('Unknown scope %s' % v) + +def search_args_from_settings(settings, prefix, default_scope): + def get_setting(name): + return settings.get(prefix + name) + + search_args = [] + + for key, converter, default in ( + ('%sbase_dn' % prefix, None, None), + ('%sfilter_tmpl' % prefix, None, None), + ('%sscope' % prefix, scope_converter, default_scope), + ): + setting = get_setting(key) + if setting is None: + setting = default + if setting is None: + raise ValueError('Must specify %s for an LDAP search' % key) + if converter is not None: + setting = converter(setting) + search_args.append(setting) + return search_args + +def setup_from_settings(config, settings, prefix='pyramid_ldap.'): + + def get_setting(name): + return settings.get(prefix + name) + + pool_args = {} + uri = get_setting('uri') + pool_args['uri'] = uri + if uri is None: + raise ValueError('If you include pyramid_ldap, you must set a ' + '%suri configuration key' % prefix) + for name, converter in ( + ('bind', None), ('passwd', None), ('size', int), + ('retry_max', int), ('retry_delay', float), ('use_tls', asbool), + ('timeout', int), ('use_pool', asbool), + ): + setting = get_setting(name) + if setting is not None: + if converter is not None: + setting = converter(setting) + pool_args[name] = setting + + config.ldap_setup_pool(**pool_args) + login_search_args = search_args_from_settings( + settings, + prefix + 'login_search.', + ldap.SCOPE_BASE + ) + config.ldap_set_login_search(*login_search_args) + groups_search_args = search_args_from_settings( + settings, + prefix + 'groups_search.', + ldap.SCOPE_SUBTREE + ) + config.ldap_set_groups_search(*groups_search_args) + +def includeme(config): + config.add_directive('setup_ldap_from_settings', setup_from_settings) diff --git a/pyramid_ldap/tests.py b/pyramid_ldap/tests.py new file mode 100644 index 0000000..89153cc --- /dev/null +++ b/pyramid_ldap/tests.py @@ -0,0 +1,285 @@ +import contextlib +import unittest +import sys + +from pyramid.compat import ( + text_type, + text_, + ) +from pyramid import testing +from pyramid.exceptions import ConfigurationError + +class Test_includeme(unittest.TestCase): + def _callFUT(self, config): + from pyramid_ldap import includeme + includeme(config) + + def test_it(self): + config = DummyConfig() + self._callFUT(config) + self.assertEqual(config.directives, + ['ldap_setup', 'ldap_set_login_query', + 'ldap_set_groups_query']) + + +class Test__ldap_decode(unittest.TestCase): + def _callFUT(self, val): + from pyramid_ldap import _ldap_decode + return _ldap_decode(val) + + def test_decode_str(self): + result = self._callFUT('abc') + self.assertEqual(type(result), text_type) + self.assertEqual(result, text_('abc')) + + def test_decode_list(self): + result = self._callFUT(['abc', 'def']) + self.assertEqual(type(result), list) + self.assertEqual(result[0], text_('abc')) + self.assertEqual(result[1], text_('def')) + + def test_decode_tuple(self): + result = self._callFUT(('abc', 'def')) + self.assertEqual(type(result), tuple) + self.assertEqual(result[0], text_('abc')) + self.assertEqual(result[1], text_('def')) + + def test_decode_dict(self): + import ldap + result = self._callFUT({'abc':'def'}) + self.assertTrue(isinstance(result, ldap.cidict.cidict)) + self.assertEqual(result[text_('abc')], text_('def')) + + def test_decode_nested(self): + import ldap + result = self._callFUT({'abc':['def', 'jkl']}) + self.assertTrue(isinstance(result, ldap.cidict.cidict)) + self.assertEqual(result[text_('abc')], [text_('def'), text_('jkl')]) + + def test_undecodeable(self): + uid = b'\xdd\xafw:PuUO\x8a#\x17\xaa\xc2\xc7\x8e\xf6' + result = self._callFUT(uid) + self.assertTrue(isinstance(result, bytes)) + +class Test_groupfinder(unittest.TestCase): + def _callFUT(self, dn, request): + from pyramid_ldap import groupfinder + return groupfinder(dn, request) + + def test_no_group_list(self): + request = testing.DummyRequest() + request.ldap_connector = DummyLDAPConnector('dn', None) + result = self._callFUT('dn', request) + self.assertEqual(result, None) + + def test_with_group_list(self): + request = testing.DummyRequest() + request.ldap_connector = DummyLDAPConnector('dn', [('groupdn', None)]) + result = self._callFUT('dn', request) + self.assertEqual(result, ['groupdn']) + +class Test_get_ldap_connector(unittest.TestCase): + def _callFUT(self, request): + from pyramid_ldap import get_ldap_connector + return get_ldap_connector(request) + + def test_no_connector(self): + request = testing.DummyRequest() + self.assertRaises(ConfigurationError, self._callFUT, request) + + def test_with_connector(self): + request = testing.DummyRequest() + request.ldap_connector = True + result = self._callFUT(request) + self.assertEqual(result, True) + +class Test_ldap_setup(unittest.TestCase): + def _callFUT(self, config, uri, **kw): + from pyramid_ldap import ldap_setup + return ldap_setup(config, uri, **kw) + + def test_it_defaults(self): + from pyramid_ldap import Connector + config = DummyConfig() + self._callFUT(config, 'ldap://') + self.assertEqual(config.prop_name, 'ldap_connector') + self.assertEqual(config.prop_reify, True) + request = testing.DummyRequest() + self.assertEqual(config.prop(request).__class__, Connector) + +class Test_ldap_set_groups_query(unittest.TestCase): + def _callFUT(self, config, base_dn, filter_tmpl, **kw): + from pyramid_ldap import ldap_set_groups_query + return ldap_set_groups_query(config, base_dn, filter_tmpl, **kw) + + def test_it_defaults(self): + import ldap + config = DummyConfig() + self._callFUT(config, 'dn', 'tmpl') + self.assertEqual(config.registry.ldap_groups_search.base_dn, 'dn') + self.assertEqual(config.registry.ldap_groups_search.filter_tmpl, 'tmpl') + self.assertEqual(config.registry.ldap_groups_search.scope, + ldap.SCOPE_SUBTREE) + self.assertEqual(config.registry.ldap_groups_search.cache_period, 600) + +class Test_ldap_set_login_query(unittest.TestCase): + def _callFUT(self, config, base_dn, filter_tmpl, **kw): + from pyramid_ldap import ldap_set_login_query + return ldap_set_login_query(config, base_dn, filter_tmpl, **kw) + + def test_it_defaults(self): + import ldap + config = DummyConfig() + self._callFUT(config, 'dn', 'tmpl') + self.assertEqual(config.registry.ldap_login_search.base_dn, 'dn') + self.assertEqual(config.registry.ldap_login_search.filter_tmpl, 'tmpl') + self.assertEqual(config.registry.ldap_login_search.scope, + ldap.SCOPE_ONELEVEL) + self.assertEqual(config.registry.ldap_login_search.cache_period, 0) + +class TestConnector(unittest.TestCase): + def _makeOne(self, registry, manager): + from pyramid_ldap import Connector + return Connector(registry, manager) + + def test_authenticate_no_ldap_login_search(self): + manager = DummyManager() + inst = self._makeOne(None, manager) + self.assertRaises(ConfigurationError, inst.authenticate, None, None) + + def test_authenticate_search_returns_non_one_result(self): + manager = DummyManager() + registry = Dummy() + registry.ldap_login_search = DummySearch([]) + inst = self._makeOne(registry, manager) + self.assertEqual(inst.authenticate(None, None), None) + + def test_authenticate_search_returns_one_result(self): + manager = DummyManager() + registry = Dummy() + registry.ldap_login_search = DummySearch([('a', 'b')]) + inst = self._makeOne(registry, manager) + self.assertEqual(inst.authenticate(None, None), ('a', 'b')) + + def test_authenticate_search_bind_raises(self): + import ldap + manager = DummyManager([None, ldap.LDAPError]) + registry = Dummy() + registry.ldap_login_search = DummySearch([('a', 'b')]) + inst = self._makeOne(registry, manager) + self.assertEqual(inst.authenticate(None, None), None) + + def test_user_groups_no_ldap_groups_search(self): + manager = DummyManager() + inst = self._makeOne(None, manager) + self.assertRaises(ConfigurationError, inst.user_groups, None) + + def test_user_groups_search_returns_result(self): + manager = DummyManager() + registry = Dummy() + registry.ldap_groups_search = DummySearch([('a', 'b')]) + inst = self._makeOne(registry, manager) + self.assertEqual(inst.user_groups(None), [('a', 'b')]) + + def test_user_groups_execute_raises(self): + import ldap + manager = DummyManager() + registry = Dummy() + registry.ldap_groups_search = DummySearch([('a', 'b')], ldap.LDAPError) + inst = self._makeOne(registry, manager) + self.assertEqual(inst.user_groups(None), None) + +class Test_LDAPQuery(unittest.TestCase): + def _makeOne(self, base_dn, filter_tmpl, scope, cache_period): + from pyramid_ldap import _LDAPQuery + return _LDAPQuery(base_dn, filter_tmpl, scope, cache_period) + + def test_query_cache_no_rollover(self): + inst = self._makeOne(None, None, None, 1) + inst.last_timeslice = sys.maxint + inst.cache['foo'] = 'bar' + self.assertEqual(inst.query_cache('foo'), 'bar') + + def test_query_cache_with_rollover(self): + inst = self._makeOne(None, None, None, 1) + inst.cache['foo'] = 'bar' + self.assertEqual(inst.query_cache('foo'), None) + self.assertEqual(inst.cache, {}) + self.assertNotEqual(inst.last_timeslice, 0) + + def test_execute_no_cache_period(self): + inst = self._makeOne('%(login)s', '%(login)s', None, 0) + conn = DummyConnection('abc') + result = inst.execute(conn, login='foo') + self.assertEqual(result, 'abc') + self.assertEqual(conn.arg, ('foo', None, 'foo')) + + def test_execute_with_cache_period_miss(self): + inst = self._makeOne('%(login)s', '%(login)s', None, 1) + conn = DummyConnection('abc') + result = inst.execute(conn, login='foo') + self.assertEqual(result, 'abc') + self.assertEqual(conn.arg, ('foo', None, 'foo')) + + def test_execute_with_cache_period_hit(self): + inst = self._makeOne('%(login)s', '%(login)s', None, 1) + inst.last_timeslice = sys.maxint + inst.cache[('foo', None, 'foo')] = 'def' + conn = DummyConnection('abc') + result = inst.execute(conn, login='foo') + self.assertEqual(result, 'def') + +class DummyLDAPConnector(object): + def __init__(self, dn, group_list): + self.dn = dn + self.group_list = group_list + + def user_groups(self, dn): + return self.group_list + +class Dummy(object): + pass + +class DummyConfig(object): + def __init__(self): + self.registry = Dummy() + self.directives = [] + + def add_directive(self, name, fn): + self.directives.append(name) + + def set_request_property(self, prop, name, reify=False): + self.prop_reify = reify + self.prop_name = name + self.prop = prop + +class DummyManager(object): + def __init__(self, with_errors=()): + self.with_errors = with_errors + @contextlib.contextmanager + def connection(self, username=None, password=None): + yield self + if self.with_errors: + e = self.with_errors.pop(0) + if e is not None: + raise e + +class DummySearch(object): + def __init__(self, result, exc=None): + self.result = result + self.exc = exc + + def execute(self, conn, **kw): + if self.exc is not None: + raise self.exc + self.kw = kw + return self.result + +class DummyConnection(object): + def __init__(self, result): + self.result = result + + def search_s(self, *arg): + self.arg = arg + return self.result + diff --git a/sampleapp.ini b/sampleapp.ini new file mode 100644 index 0000000..6c406de --- /dev/null +++ b/sampleapp.ini @@ -0,0 +1,51 @@ +[app:main] +use = egg:pyramid_ldap#sampleapp +pyramid.reload_templates = true +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.debug_templates = true +pyramid.default_locale_name = en +pyramid.includes = + pyramid_debugtoolbar + +[server:main] +use = egg:waitress#main +host = 0.0.0.0 +port = 6543 + +# Begin logging configuration + +[loggers] +keys = root, starter, pyramid_ldap + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_starter] +level = DEBUG +handlers = +qualname = starter + +[logger_pyramid_ldap] +level = DEBUG +handlers = +qualname = pyramid_ldap + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s + +# End logging configuration diff --git a/sampleapp/__init__.py b/sampleapp/__init__.py new file mode 100644 index 0000000..7284370 --- /dev/null +++ b/sampleapp/__init__.py @@ -0,0 +1,42 @@ +from pyramid.config import Configurator +from pyramid_ldap import groupfinder + +from pyramid.authentication import AuthTktAuthenticationPolicy +from pyramid.authorization import ACLAuthorizationPolicy +from pyramid.security import Allow, Authenticated + +class RootFactory(object): + __acl__ = [(Allow, Authenticated, 'view')] + def __init__(self, request): + pass + +def main(global_config, **settings): + config = Configurator(settings=settings, root_factory=RootFactory) + config.include('pyramid_ldap') + config.set_authentication_policy( + AuthTktAuthenticationPolicy('seekr1t', callback=groupfinder) + ) + config.set_authorization_policy( + ACLAuthorizationPolicy() + ) + config.ldap_setup( + 'ldap://192.168.1.159', + bind='CN=ldap user,CN=Users,DC=example,DC=com', + passwd='ld@pu5er') + config.ldap_set_login_query( + 'CN=Users,DC=example,DC=com', + '(sAMAccountName=%(login)s)', + cache_secs=0, + ) + config.ldap_set_groups_query( + 'CN=Users,DC=example,DC=com', + '(member:1.2.840.113556.1.4.1941:=%(userdn)s)', + #'(&(objectCategory=group)(member=%(dn)s))', + cache_secs=60, + ) + config.add_route('sampleapp.root', '/') + config.add_route('sampleapp.login', '/login') + config.add_route('sampleapp.logout', '/logout') + config.scan('.views') + return config.make_wsgi_app() + diff --git a/sampleapp/templates/login.pt b/sampleapp/templates/login.pt new file mode 100644 index 0000000..3fc0f11 --- /dev/null +++ b/sampleapp/templates/login.pt @@ -0,0 +1,17 @@ + + +Login + + +

Log In

+

+

+ Login: +
+ Password: +
+ + +
+ + diff --git a/sampleapp/views.py b/sampleapp/views.py new file mode 100644 index 0000000..517e6d1 --- /dev/null +++ b/sampleapp/views.py @@ -0,0 +1,49 @@ +import pprint + +from pyramid.view import view_config, forbidden_view_config +from pyramid.response import Response +from pyramid.httpexceptions import HTTPFound +from pyramid.security import remember, forget + +from pyramid_ldap import get_ldap_connector + +class LoggedIn(Exception): + pass + +@view_config(route_name='sampleapp.root', permission='view') +def logged_in(request): + return Response('OK') + +@view_config(route_name='sampleapp.logout') +def logout(request): + headers = forget(request) + return Response('Logged out', headers=headers) + +@view_config(route_name='sampleapp.login', renderer='templates/login.pt') +@forbidden_view_config(renderer='templates/login.pt') +def login(request): + url = request.current_route_url() + login = '' + password = '' + error = '' + + if 'form.submitted' in request.POST: + login = request.POST['login'] + password = request.POST['password'] + connector = get_ldap_connector(request) + data = connector.authenticate(login, password) + if data is not None: + pprint.pprint(data) + dn = data[0] + headers = remember(request, dn) + return HTTPFound('/', headers=headers) + else: + error = 'Invalid credentials' + + return dict( + login_url=url, + login=login, + password=password, + error=error, + ) + diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..4d9b592 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[nosetests] +match = ^test +nocapture = 1 +cover-package = pyramid_ldap +cover-erase = 1 + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c3d13d4 --- /dev/null +++ b/setup.py @@ -0,0 +1,47 @@ +import os + +from setuptools import setup, find_packages + +here = os.path.abspath(os.path.dirname(__file__)) +README = open(os.path.join(here, 'README.txt')).read() +CHANGES = open(os.path.join(here, 'CHANGES.txt')).read() + +requires = [ + 'pyramid>=1.3a9', + 'ldappool', + 'python-ldap', + ] + +sampleapp_requires = [ + 'waitress', + 'pyramid_debugtoolbar', + ] + +setup(name='pyramid_ldap', + version='0.0', + description='pyramid_ldap', + long_description=README + '\n\n' + CHANGES, + classifiers=[ + "Programming Language :: Python", + "Framework :: Pylons", + "Programming Language :: Python :: 2.6", + "Programming Language :: Python :: 2.7", + "Topic :: System :: Systems Administration :: Authentication/Directory :: LDAP", + ], + author='Chris McDonough', + author_email='pylons-discuss@groups.google.com', + url='http://pylonsproject.org', + keywords='web pyramid pylons ldap', + packages=find_packages(), + include_package_data=True, + zip_safe=False, + install_requires=requires, + tests_require=requires, + extras_require = {'sampleapp':sampleapp_requires}, + test_suite="pyramid_ldap", + entry_points = """\ + [paste.app_factory] + sampleapp = sampleapp:main + """, + ) + diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8dbbd0f --- /dev/null +++ b/tox.ini @@ -0,0 +1,18 @@ +[tox] +envlist = + py26,py27,pypy,cover + +[testenv] +commands = + python setup.py test -q + +[testenv:cover] +basepython = + python2.6 +commands = + python setup.py nosetests --with-xunit --with-xcoverage +deps = + nose + coverage==3.4 + nosexcover +