├── setup.cfg ├── tests ├── __init__.py ├── core │ ├── __init__.py │ ├── utils.py │ ├── test_locate.py │ ├── test_creds.py │ └── test_client.py ├── protocol │ ├── __init__.py │ ├── netlogon.bin │ ├── searchresult.bin │ ├── searchrequest.bin │ ├── test_ldap.py │ ├── test_krb5.py │ ├── test_ldapfilter.py │ ├── test_netlogon.py │ └── test_asn1.py ├── conftest.py ├── test.conf.example └── base.py ├── lib └── activedirectory │ ├── core │ ├── __init__.py │ ├── exception.py │ ├── constant.py │ ├── object.py │ ├── creds.py │ └── locate.py │ ├── util │ ├── __init__.py │ ├── misc.py │ ├── log.py │ ├── compat.py │ └── parser.py │ ├── protocol │ ├── __init__.py │ ├── ldapfilter_tab.py │ ├── ldapfilter.py │ ├── ldap.py │ ├── netlogon.py │ ├── krb5.c │ └── asn1.py │ └── __init__.py ├── AUTHORS ├── MANIFEST.in ├── .gitignore ├── header ├── tut ├── tutorial1.py ├── tutorial2.py ├── tutorial3.py ├── tutorial4.py └── tutorial5.py ├── doc ├── index.xml ├── preface.xml ├── Makefile ├── license.xml └── reference.xml ├── gentab.py ├── tox.ini ├── LICENSE ├── README.rst ├── setup.py └── env.py /setup.cfg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/protocol/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/activedirectory/core/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /lib/activedirectory/util/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /lib/activedirectory/protocol/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /tests/protocol/netlogon.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/python-active-directory/HEAD/tests/protocol/netlogon.bin -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .base import Conf 3 | 4 | 5 | @pytest.fixture 6 | def conf(): 7 | return Conf() 8 | -------------------------------------------------------------------------------- /tests/protocol/searchresult.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/python-active-directory/HEAD/tests/protocol/searchresult.bin -------------------------------------------------------------------------------- /tests/protocol/searchrequest.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/python-active-directory/HEAD/tests/protocol/searchrequest.bin -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | The following people have contributed to Python-AD. Collectively they own 2 | the copyright of this software. 3 | 4 | * Geert Jansen : original implementation 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE AUTHORS MANIFEST.in 2 | recursive-include lib *.py *.c *.bin 3 | recursive-include tut *.py 4 | recursive-include doc *.xml Makefile 5 | include setup.py env.py gentab.py 6 | include tests/test.conf.example 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | /build 3 | /doc/html 4 | /ldapfilter_tab.py 5 | .eggs/ 6 | *.egg-info 7 | /lib/*.egg-info 8 | /lib/*.egg 9 | /lib/activedirectory/protocol/*.so 10 | *.pyc 11 | *.log 12 | *.egg 13 | *.db 14 | *.pid 15 | pip-log.txt 16 | .DS_Store 17 | *swp 18 | .python-version 19 | .tox 20 | venv/ 21 | test.conf.atl 22 | -------------------------------------------------------------------------------- /header: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007-2008 by the Python-AD authors. See the 7 | # file "AUTHORS" for a complete overview. 8 | -------------------------------------------------------------------------------- /tut/tutorial1.py: -------------------------------------------------------------------------------- 1 | from activedirectory import Client, Creds, activate 2 | 3 | domain = 'freeadi.org' 4 | user = 'Administrator' 5 | password = 'Pass123' 6 | 7 | creds = Creds(domain) 8 | creds.acquire(user, password) 9 | activate(creds) 10 | 11 | client = Client(domain) 12 | users = client.search('(objectClass=user)') 13 | for dn,attrs in users: 14 | name = attrs['sAMAccountName'][0] 15 | print('-> %s' % name) 16 | -------------------------------------------------------------------------------- /tut/tutorial2.py: -------------------------------------------------------------------------------- 1 | from activedirectory import Client, Creds, activate 2 | 3 | domain = 'freeadi.org' 4 | 5 | creds = Creds(domain) 6 | creds.load() 7 | activate(creds) 8 | 9 | client = Client(domain) 10 | users = client.search('(objectClass=user)', scheme='gc') 11 | for dn,attrs in users: 12 | name = attrs['sAMAccountName'][0] 13 | domain = client.domain_name_from_dn(dn) 14 | print('-> %s (%s)' % (name, domain)) 15 | -------------------------------------------------------------------------------- /lib/activedirectory/core/exception.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | import ldap 10 | 11 | 12 | class Error(Exception): 13 | pass 14 | 15 | 16 | LDAPError = ldap.LDAPError 17 | -------------------------------------------------------------------------------- /tut/tutorial3.py: -------------------------------------------------------------------------------- 1 | from activedirectory import Client, Creds, Locator, activate 2 | 3 | domain = 'freeadi.org' 4 | user = 'Administrator' 5 | password = 'Pass123' 6 | 7 | creds = Creds(domain) 8 | creds.acquire(user, password) 9 | activate(creds) 10 | 11 | locator = Locator() 12 | pdc = locator.locate(domain, role='pdc') 13 | 14 | client = Client(domain) 15 | users = client.search('(objectClass=user)', server=pdc) 16 | for dn,attrs in users: 17 | name = attrs['sAMAccountName'][0] 18 | print('-> %s' % name) 19 | -------------------------------------------------------------------------------- /doc/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | Python-AD reference manual 6 | Geert Jansen 7 | 20072008 Geert Jansen 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /doc/preface.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Foreword 6 | 7 | 8 | This is the reference manual for Python-AD. It is the definitive reference 9 | for the Python-AD API. Other information on Python-AD, such as download 10 | instructions, installation instructions, and a tuturial, can be found on the 11 | Python-AD project 12 | page. 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /lib/activedirectory/core/constant.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | # Network ports 10 | 11 | LDAP_PORT = 389 12 | GC_PORT = 3268 13 | 14 | # AD constants 15 | 16 | AD_USERCTRL_ACCOUNT_DISABLED = 0x2 17 | AD_USERCTRL_NORMAL_ACCOUNT = 0x200 18 | AD_USERCTRL_WORKSTATION_ACCOUNT = 0x1000 19 | AD_USERCTRL_DONT_EXPIRE_PASSWORD = 0x10000 20 | -------------------------------------------------------------------------------- /gentab.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | import os 10 | 11 | # This script generates the PLY parser tables. Note: It needs to be run from 12 | # the top-level python-ad directory! 13 | 14 | from activedirectory.protocol.ldapfilter import Parser as LDAPFilterParser 15 | 16 | os.chdir('lib/activedirectory/protocol') 17 | 18 | parser = LDAPFilterParser() 19 | parser._write_parsetab() 20 | -------------------------------------------------------------------------------- /tut/tutorial4.py: -------------------------------------------------------------------------------- 1 | from activedirectory import Client, Creds, Locator, activate 2 | 3 | domain = 'freeadi.org' 4 | user = 'Administrator' 5 | password = 'Pass123' 6 | 7 | levels = { 8 | '0': 'windows 2000', 9 | '1': 'windows 2003 interim', 10 | '2': 'windows 2003' 11 | } 12 | 13 | creds = Creds(domain) 14 | creds.acquire(user, password) 15 | activate(creds) 16 | 17 | locator = Locator() 18 | server = locator.locate(domain) 19 | 20 | client = Client(domain) 21 | result = client.search(base='', scope='base', server=server) 22 | assert len(result) == 1 23 | dn, attrs = result[0] 24 | level = attrs['forestFunctionality'][0] 25 | level = levels.get(level, 'unknown') 26 | print('Forest functionality level: %s' % level) 27 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | XSLTPROC = xsltproc 10 | XSLTFLAGS = --xinclude 11 | STYLESHEET = /usr/share/sgml/docbook/xsl-stylesheets/xhtml/chunk.xsl 12 | MKDIR = mkdir -p 13 | INPUTS = index.xml preface.xml reference.xml license.xml 14 | 15 | all: html/index.html 16 | 17 | html/index.html: $(INPUTS) 18 | $(MKDIR) html 19 | $(XSLTPROC) $(XSLTFLAGS) --output html/index.html $(STYLESHEET) $< 20 | -------------------------------------------------------------------------------- /lib/activedirectory/util/misc.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | import socket 10 | 11 | 12 | def hostname(): 13 | """Return the host name. 14 | 15 | The host name is defined as the "short" host name. If the hostname as 16 | returned by gethostname() includes a domain, the part until the first 17 | period ('.') is returned. 18 | """ 19 | hostname = socket.gethostname() 20 | if '.' in hostname: 21 | hostname = hostname.split('.')[0] 22 | return hostname 23 | -------------------------------------------------------------------------------- /lib/activedirectory/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | from .core.exception import Error, LDAPError 10 | from .core.constant import (LDAP_PORT, GC_PORT, AD_USERCTRL_ACCOUNT_DISABLED, 11 | AD_USERCTRL_NORMAL_ACCOUNT, 12 | AD_USERCTRL_WORKSTATION_ACCOUNT, 13 | AD_USERCTRL_DONT_EXPIRE_PASSWORD) 14 | from .core.client import Client 15 | from .core.creds import Creds 16 | from .core.locate import Locator 17 | from .core.object import activate 18 | -------------------------------------------------------------------------------- /lib/activedirectory/util/log.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007-2009 by the Python-AD authors. See the 7 | # file "AUTHORS" for a complete overview. 8 | 9 | import sys 10 | import logging 11 | 12 | 13 | def enable_logging(level=None): 14 | """Utility function to enable logging for the "ad" package if no root 15 | logger is configured yet.""" 16 | if level is None: 17 | level = logging.DEBUG 18 | logger = logging.getLogger('ad') 19 | handler = logging.StreamHandler(sys.stdout) 20 | format = '%(levelname)s [%(name)s] %(message)s' 21 | formatter = logging.Formatter(format) 22 | handler.setFormatter(formatter) 23 | logger.addHandler(handler) 24 | logger.setLevel(level) 25 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py3{7,8} 4 | 5 | [testenv] 6 | skipsdist = true 7 | skip_install = true 8 | passenv = PYAD_READONLY_CONFIG PYAD_TEST_CONFIG 9 | commands = 10 | python setup.py build 11 | python setup.py install 12 | pytest tests 13 | deps = 14 | python-ldap>=3.0 15 | dnspython 16 | ply==3.8 17 | pytest 18 | pexpect 19 | 20 | [testenv:pep8] 21 | description = Run PEP8 (flake8) against the lib directory 22 | skipsdist = true 23 | skip_install = true 24 | basepython = python3.7 25 | deps = flake8 26 | commands = flake8 lib/activedirectory 27 | 28 | [testenv:clean] 29 | description = Clean all build and test artifacts 30 | skipsdist = true 31 | skip_install = true 32 | deps = 33 | whitelist_externals = 34 | find 35 | rm 36 | commands = 37 | find {toxinidir} -type f -name "*.pyc" -delete 38 | find {toxinidir} -type d -name "__pycache__" -delete 39 | rm -rf {toxworkdir} {toxinidir}/build {toxinidir}/dist 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2007 the Python-AD authors. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /tut/tutorial5.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from activedirectory import Client, Creds, Locator, activate 3 | from activedirectory import AD_USERCTRL_NORMAL_ACCOUNT, AD_USERCTRL_ACCOUNT_DISABLED 4 | 5 | domain = 'freeadi.org' 6 | user = 'Administrator' 7 | password = 'Pass123' 8 | 9 | if len(sys.argv) != 3: 10 | sys.stderr.write('Usage: useradd \n') 11 | sys.exit(1) 12 | username = sys.argv[1] 13 | userpass = sys.argv[2] 14 | 15 | creds = Creds(domain) 16 | creds.acquire(user, password) 17 | activate(creds) 18 | 19 | client = Client(domain) 20 | result = client.search('(sAMAccountName=%s)' % username) 21 | if len(result) > 0: 22 | sys.stderr.write('Error: user %s already exists\n' % username) 23 | sys.exit(1) 24 | 25 | dn = 'cn=%s,cn=users,%s' % (username, client.dn_from_domain_name(domain)) 26 | attrs = [] 27 | attrs.append(('cn', [username])) 28 | attrs.append(('sAMAccountName', [username])) 29 | princ = '%s@%s' % (username, domain) 30 | attrs.append(('userPrincipalName', [princ])) 31 | ctrl = AD_USERCTRL_NORMAL_ACCOUNT | AD_USERCTRL_ACCOUNT_DISABLED 32 | attrs.append(('userAccountControl', [str(ctrl)])) 33 | attrs.append(('objectClass', ['user'])) 34 | client.add(dn, attrs) 35 | 36 | client.set_password(princ, userpass) 37 | 38 | mods = [] 39 | ctrl = AD_USERCTRL_NORMAL_ACCOUNT 40 | mods.append(('replace', 'userAccountControl', [str(ctrl)])) 41 | client.modify(dn, mods) 42 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Python-Active-Directory 2 | ======================= 3 | 4 | This is Python-AD, an Active Directory client library for Python on UNIX/Linux systems. 5 | 6 | **Note** - version 1.0 added support for Python >= 3.6 and version 2.0 will drop support for Python 2 7 | 8 | Install 9 | ------- 10 | 11 | .. code:: bash 12 | 13 | $ pip install -e git+git@github.com:theatlantic/python-active-directory.git@v1.0.0+atl.2.0#egg=python-active-directory 14 | 15 | 16 | Development 17 | ----------- 18 | 19 | Get the code 20 | ~~~~~~~~~~~~ 21 | 22 | .. code:: bash 23 | 24 | $ git clone git@github.com:theatlantic/python-active-directory.git 25 | $ cd python-active-directory 26 | 27 | 28 | Create virtual environment 29 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 30 | 31 | .. code:: bash 32 | 33 | $ python -mvenv venv 34 | $ . venv/bin/activate 35 | $ pip install -e . 36 | 37 | 38 | Testing 39 | ~~~~~~~ 40 | 41 | Version 1.0 switched to using pytest instead of nose, and added tox configuration 42 | for supporting testing across various supported Python versions. 43 | 44 | .. code:: bash 45 | 46 | $ pip install tox 47 | $ tox 48 | 49 | Special environment variables: 50 | 51 | * ``PYAD_TEST_CONFIG`` - Override the default test configuration file (formerly ``FREEADI_TEST_CONFIG``) 52 | * ``PYAD_READONLY_CONFIG`` - Enable readonly tests, must be in the form of ``username:password@domain.tld`` 53 | 54 | 55 | -------------------------------------------------------------------------------- /tests/protocol/test_ldap.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | """Test suite for activedirectory.util.ldap.""" 9 | 10 | import os.path 11 | from activedirectory.protocol import ldap 12 | 13 | 14 | 15 | def test_encode_real_search_request(conf): 16 | client = ldap.Client() 17 | filter = '(&(DnsDomain=FREEADI.ORG)(Host=magellan)(NtVer=\\06\\00\\00\\00))' 18 | req = client.create_search_request('', filter, ('NetLogon',), 19 | scope=ldap.SCOPE_BASE, msgid=4) 20 | 21 | buf = conf.read_file('protocol/searchrequest.bin') 22 | assert req == buf 23 | 24 | def test_decode_real_search_reply(conf): 25 | client = ldap.Client() 26 | buf = conf.read_file('protocol/searchresult.bin') 27 | reply = client.parse_message_header(buf) 28 | assert reply == (4, 4) 29 | reply = client.parse_search_result(buf) 30 | assert len(reply) == 1 31 | msgid, dn, attrs = reply[0] 32 | assert msgid == 4 33 | assert dn == b'' 34 | 35 | netlogon = conf.read_file('protocol/netlogon.bin') 36 | print(repr(attrs)) 37 | print(repr({ 'netlogon': [netlogon] })) 38 | assert attrs == { b'netlogon': [netlogon] } 39 | -------------------------------------------------------------------------------- /doc/license.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | License 5 | The Python-AD software is goverend by the following license (the 6 | "MIT" license): 7 | 8 | Copyright (c) 2007-2008 the Python-AD authors. 9 | 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | 19 | 20 | The above copyright notice and this permission notice shall be included in 21 | all copies or substantial portions of the Software. 22 | 23 | 24 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 25 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 26 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 27 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 28 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 29 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 30 | SOFTWARE. 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /lib/activedirectory/core/object.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | 10 | def _singleton(cls, *args, **kwargs): 11 | """Return the single instance of a class, creating it if it does not exist.""" 12 | if not hasattr(cls, 'instance') or cls.instance is None: 13 | obj = cls(*args, **kwargs) 14 | cls.instance = obj 15 | return cls.instance 16 | 17 | def instance(cls): 18 | """Return the single instance of a class. The instance needs to exist.""" 19 | if not hasattr(cls, 'instance'): 20 | return None 21 | return cls.instance 22 | 23 | def factory(cls): 24 | """Create an instance of a class, creating it using the system specific 25 | rules.""" 26 | from activedirectory.core.locate import Locator 27 | from activedirectory.core.creds import Creds 28 | if issubclass(cls, Locator): 29 | return _singleton(Locator) 30 | elif issubclass(cls, Creds): 31 | domain = detect_domain() 32 | return Creds(domain) 33 | else: 34 | return cls() 35 | 36 | def activate(obj): 37 | """Activate `obj' to be the active instance of its class.""" 38 | from activedirectory.core.creds import Creds 39 | if isinstance(obj, Creds): 40 | obj._activate_config() 41 | obj._activate_ccache() 42 | type(obj).instance = obj 43 | return obj 44 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | from setuptools import setup, Extension 9 | 10 | 11 | setup( 12 | name='python-active-directory', 13 | version='2.0.1', 14 | description='An Active Directory client library for Python', 15 | long_description=open('README.rst').read(), 16 | long_description_content_type='text/x-rst', 17 | author='Geert Jansen', 18 | author_email='programmers@theatlantic.com', 19 | maintainer='The Atlantic', 20 | maintainer_email='programmers@theatlantic.com', 21 | url='https://github.com/theatlantic/python-active-directory', 22 | license='MIT', 23 | classifiers=[ 24 | 'Development Status :: 5 - Production/Stable', 25 | 'Intended Audience :: Developers', 26 | 'License :: OSI Approved :: MIT License', 27 | 'Programming Language :: Python', 28 | 'Programming Language :: Python :: 3', 29 | 'Programming Language :: Python :: 3 :: Only', 30 | 'Programming Language :: Python :: 3.7', 31 | 'Programming Language :: Python :: 3.8', 32 | ], 33 | package_dir={'': 'lib'}, 34 | packages=[ 35 | 'activedirectory', 36 | 'activedirectory.core', 37 | 'activedirectory.protocol', 38 | 'activedirectory.util' 39 | ], 40 | install_requires=['python-ldap>=3.0', 'dnspython', 'ply>=3.8'], 41 | ext_modules=[Extension( 42 | 'activedirectory.protocol.krb5', 43 | ['lib/activedirectory/protocol/krb5.c'], 44 | libraries=['krb5'] 45 | )], 46 | zip_safe=False, # eggs are the devil. 47 | ) 48 | -------------------------------------------------------------------------------- /env.py: -------------------------------------------------------------------------------- 1 | # vi: ts=4 sw=4 et 2 | # 3 | # env.py: setup environment variabels. 4 | # 5 | # This small utility outputs a bourne shell fragment that sets up the 6 | # PATH and PYTHONPATH environment variables such that Python-AD can be used 7 | # from within its source directory. This is required for the test suite, 8 | # and is helpful for developing. Kudos to the py-lib team for the idea. 9 | # 10 | # This file is part of Python-AD. Python-AD is free software that is made 11 | # available under the MIT license. Consult the file "LICENSE" that is 12 | # distributed together with this file for the exact licensing terms. 13 | # 14 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 15 | # "AUTHORS" for a complete overview. 16 | import os 17 | import os.path 18 | import sys 19 | 20 | 21 | def prepend_path(name, value): 22 | if sys.platform == 'win32': 23 | sep = ';' 24 | else: 25 | sep = ':' 26 | env_path = os.environ.get(name, '') 27 | parts = [ x for x in env_path.split(sep) if x ] 28 | while value in parts: 29 | del parts[parts.index(value)] 30 | parts.insert(0, value) 31 | return setenv(name, sep.join(parts)) 32 | 33 | def setenv(name, value): 34 | shell = os.environ.get('SHELL', '') 35 | comspec = os.environ.get('COMSPEC', '') 36 | if shell.endswith('csh'): 37 | cmd = 'setenv %s "%s"' % (name, value) 38 | elif shell.endswith('sh'): 39 | cmd = '%s="%s"; export %s' % (name, value, name) 40 | elif comspec.endswith('cmd.exe'): 41 | cmd = '@set %s=%s' % (name, value) 42 | else: 43 | assert False, 'Shell not supported.' 44 | return cmd 45 | 46 | 47 | abspath = os.path.abspath(sys.argv[0]) 48 | topdir, fname = os.path.split(abspath) 49 | 50 | bindir = os.path.join(topdir, 'bin') 51 | print(prepend_path('PATH', bindir)) 52 | pythondir = os.path.join(topdir, 'lib') 53 | print(prepend_path('PYTHONPATH', pythondir)) 54 | testconf = os.path.join(topdir, 'test.conf') 55 | print(setenv('FREEADI_TEST_CONFIG', testconf)) 56 | -------------------------------------------------------------------------------- /tests/protocol/test_krb5.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | """Test suite for protocol.krb5.""" 9 | 10 | import os 11 | import stat 12 | import pexpect 13 | 14 | from activedirectory.protocol import krb5 15 | from ..base import assert_raises, Error 16 | 17 | 18 | def test_cc_default(conf): 19 | conf.require(ad_user=True) 20 | domain = conf.domain().upper() 21 | principal = '%s@%s' % (conf.ad_user_account(), domain) 22 | password = conf.ad_user_password() 23 | conf.acquire_credentials(principal, password) 24 | ccache = krb5.cc_default() 25 | ccname, princ, creds = conf.list_credentials(ccache) 26 | assert princ.lower() == principal.lower() 27 | assert len(creds) > 0 28 | assert creds[0] == 'krbtgt/%s@%s' % (domain, domain) 29 | 30 | def test_cc_copy_creds(conf): 31 | conf.require(ad_user=True) 32 | domain = conf.domain().upper() 33 | principal = '%s@%s' % (conf.ad_user_account(), domain) 34 | password = conf.ad_user_password() 35 | conf.acquire_credentials(principal, password) 36 | ccache = krb5.cc_default() 37 | cctmp = conf.tempfile() 38 | assert_raises(Error, conf.list_credentials, cctmp) 39 | krb5.cc_copy_creds(ccache, cctmp) 40 | ccname, princ, creds = conf.list_credentials(cctmp) 41 | assert princ.lower() == principal.lower() 42 | assert len(creds) > 0 43 | assert creds[0] == 'krbtgt/%s@%s' % (domain, domain) 44 | 45 | def test_cc_get_principal(conf): 46 | conf.require(ad_user=True) 47 | domain = conf.domain().upper() 48 | principal = '%s@%s' % (conf.ad_user_account(), domain) 49 | password = conf.ad_user_password() 50 | conf.acquire_credentials(principal, password) 51 | ccache = krb5.cc_default() 52 | princ = krb5.cc_get_principal(ccache) 53 | assert princ.lower() == principal.lower() 54 | -------------------------------------------------------------------------------- /lib/activedirectory/util/compat.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007-2009 by the Python-AD authors. See the 7 | # file "AUTHORS" for a complete overview. 8 | 9 | import ldap 10 | import ldap.dn 11 | 12 | from distutils import version 13 | 14 | # ldap.str2dn has been removed in python-ldap >= 2.3.6. We now need to use 15 | # the version in ldap.dn. 16 | try: 17 | str2dn = ldap.dn.str2dn 18 | except AttributeError: 19 | str2dn = ldap.str2dn 20 | 21 | def disable_reverse_dns(): 22 | # Possibly add in a Kerberos minimum version check as well... 23 | return hasattr(ldap, 'OPT_X_SASL_NOCANON') 24 | 25 | if version.StrictVersion('2.4.0') <= version.StrictVersion(ldap.__version__): 26 | LDAP_CONTROL_PAGED_RESULTS = ldap.CONTROL_PAGEDRESULTS 27 | else: 28 | LDAP_CONTROL_PAGED_RESULTS = ldap.LDAP_CONTROL_PAGE_OID 29 | 30 | 31 | class SimplePagedResultsControl(ldap.controls.SimplePagedResultsControl): 32 | """ 33 | Python LDAP 2.4 and later breaks the API. This is an abstraction class 34 | so that we can handle either. 35 | http://planet.ergo-project.org/blog/jmeeuwen/2011/04/11/python-ldap-module-24-changes 36 | """ 37 | 38 | def __init__(self, page_size=0, cookie=''): 39 | if version.StrictVersion('2.4.0') <= version.StrictVersion(ldap.__version__): 40 | ldap.controls.SimplePagedResultsControl.__init__( 41 | self, 42 | size=page_size, 43 | cookie=cookie 44 | ) 45 | else: 46 | ldap.controls.SimplePagedResultsControl.__init__( 47 | self, 48 | LDAP_CONTROL_PAGED_RESULTS, 49 | critical, 50 | (page_size, '') 51 | ) 52 | 53 | def cookie(self): 54 | if version.StrictVersion('2.4.0') <= version.StrictVersion(ldap.__version__): 55 | return self.cookie 56 | else: 57 | return self.controlValue[1] 58 | 59 | def size(self): 60 | if version.StrictVersion('2.4.0') <= version.StrictVersion(ldap.__version__): 61 | return self.size 62 | else: 63 | return self.controlValue[0] 64 | -------------------------------------------------------------------------------- /lib/activedirectory/protocol/ldapfilter_tab.py: -------------------------------------------------------------------------------- 1 | 2 | # ldapfilter_tab.py 3 | # This file is automatically generated. Do not edit. 4 | _tabversion = '3.8' 5 | 6 | _lr_method = 'LALR' 7 | 8 | _lr_signature = '4F128CEA6E6C348C5E33FD02565B75DC' 9 | 10 | _lr_action_items = {'AND':([2,],[4,]),'APPROX':([5,],[14,]),'RPAREN':([3,7,9,10,11,12,13,18,19,20,21,22,23,24,25,26,27,28,],[11,20,22,23,-1,-5,-8,-14,-6,-4,-7,-3,-2,-9,-13,-12,-10,-11,]),'STRING':([2,14,15,16,17,],[5,25,26,27,28,]),'LTE':([5,],[17,]),'GTE':([5,],[15,]),'EQUALS':([5,],[16,]),'LPAREN':([0,4,6,8,11,13,20,22,23,],[2,2,2,2,-1,2,-4,-3,-2,]),'NOT':([2,],[8,]),'OR':([2,],[6,]),'PRESENT':([5,],[18,]),'$end':([1,11,20,22,23,],[0,-1,-4,-3,-2,]),} 11 | 12 | _lr_action = {} 13 | for _k, _v in _lr_action_items.items(): 14 | for _x,_y in zip(_v[0],_v[1]): 15 | if not _x in _lr_action: _lr_action[_x] = {} 16 | _lr_action[_x][_k] = _y 17 | del _lr_action_items 18 | 19 | _lr_goto_items = {'and':([2,],[3,]),'filterlist':([4,6,13,],[12,19,24,]),'filter':([0,4,6,8,13,],[1,13,13,21,13,]),'item':([2,],[7,]),'not':([2,],[9,]),'or':([2,],[10,]),} 20 | 21 | _lr_goto = {} 22 | for _k, _v in _lr_goto_items.items(): 23 | for _x, _y in zip(_v[0], _v[1]): 24 | if not _x in _lr_goto: _lr_goto[_x] = {} 25 | _lr_goto[_x][_k] = _y 26 | del _lr_goto_items 27 | _lr_productions = [ 28 | ("S' -> filter","S'",1,None,None,None), 29 | ('filter -> LPAREN and RPAREN','filter',3,'p_filter','ldapfilter.py',108), 30 | ('filter -> LPAREN or RPAREN','filter',3,'p_filter','ldapfilter.py',109), 31 | ('filter -> LPAREN not RPAREN','filter',3,'p_filter','ldapfilter.py',110), 32 | ('filter -> LPAREN item RPAREN','filter',3,'p_filter','ldapfilter.py',111), 33 | ('and -> AND filterlist','and',2,'p_and','ldapfilter.py',116), 34 | ('or -> OR filterlist','or',2,'p_or','ldapfilter.py',120), 35 | ('not -> NOT filter','not',2,'p_not','ldapfilter.py',124), 36 | ('filterlist -> filter','filterlist',1,'p_filterlist','ldapfilter.py',128), 37 | ('filterlist -> filter filterlist','filterlist',2,'p_filterlist','ldapfilter.py',129), 38 | ('item -> STRING EQUALS STRING','item',3,'p_item','ldapfilter.py',137), 39 | ('item -> STRING LTE STRING','item',3,'p_item','ldapfilter.py',138), 40 | ('item -> STRING GTE STRING','item',3,'p_item','ldapfilter.py',139), 41 | ('item -> STRING APPROX STRING','item',3,'p_item','ldapfilter.py',140), 42 | ('item -> STRING PRESENT','item',2,'p_item','ldapfilter.py',141), 43 | ] 44 | -------------------------------------------------------------------------------- /tests/core/utils.py: -------------------------------------------------------------------------------- 1 | from activedirectory.core.exception import Error as ADError, LDAPError 2 | from activedirectory.core.constant import ( 3 | AD_USERCTRL_ACCOUNT_DISABLED, 4 | AD_USERCTRL_NORMAL_ACCOUNT 5 | ) 6 | 7 | 8 | def delete_obj(client, dn, server=None): 9 | try: 10 | client.delete(dn, server=server) 11 | except (ADError, LDAPError): 12 | pass 13 | 14 | def delete_user(client, name, server=None): 15 | # Delete any user that may conflict with a newly to be created user 16 | filter = '(|(cn=%s)(sAMAccountName=%s)(userPrincipalName=%s))' % \ 17 | (name, name, '%s@%s' % (name, client.domain().upper())) 18 | result = client.search('(&(objectClass=user)(sAMAccountName=%s))' % name, 19 | server=server) 20 | for res in result: 21 | client.delete(res[0], server=server) 22 | 23 | 24 | def create_user(client, name, server=None): 25 | attrs = [] 26 | attrs.append(('cn', [name])) 27 | attrs.append(('sAMAccountName', [name])) 28 | attrs.append(('userPrincipalName', ['%s@%s' % (name, client.domain().upper())])) 29 | ctrl = AD_USERCTRL_ACCOUNT_DISABLED | AD_USERCTRL_NORMAL_ACCOUNT 30 | attrs.append(('userAccountControl', [str(ctrl)])) 31 | attrs.append(('objectClass', ['user'])) 32 | dn = 'cn=%s,cn=users,%s' % (name, client.dn_from_domain_name(client.domain())) 33 | delete_user(client, name, server=server) 34 | client.add(dn, attrs, server=server) 35 | return dn 36 | 37 | 38 | def create_ou(client, name, server=None): 39 | attrs = [] 40 | attrs.append(('objectClass', ['organizationalUnit'])) 41 | attrs.append(('ou', [name])) 42 | dn = 'ou=%s,%s' % (name, client.dn_from_domain_name(client.domain())) 43 | delete_obj(client, dn, server=server) 44 | client.add(dn, attrs, server=server) 45 | return dn 46 | 47 | def delete_group(client, dn, server=None): 48 | try: 49 | client.delete(dn, server=server) 50 | except (ADError, LDAPError): 51 | pass 52 | 53 | def create_group(client, name, server=None): 54 | attrs = [] 55 | attrs.append(('cn', [name])) 56 | attrs.append(('sAMAccountName', [name])) 57 | attrs.append(('objectClass', ['group'])) 58 | dn = 'cn=%s,cn=Users,%s' % (name, client.dn_from_domain_name(client.domain())) 59 | delete_group(client, dn, server=server) 60 | client.add(dn, attrs, server=server) 61 | return dn 62 | 63 | def add_user_to_group(client, user, group): 64 | mods = [] 65 | mods.append(('delete', 'member', [user])) 66 | try: 67 | client.modify(group, mods) 68 | except (ADError, LDAPError): 69 | pass 70 | mods = [] 71 | mods.append(('add', 'member', [user])) 72 | client.modify(group, mods) 73 | -------------------------------------------------------------------------------- /lib/activedirectory/util/parser.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | import sys 10 | import os.path 11 | 12 | from ply import lex, yacc 13 | 14 | 15 | class Parser(object): 16 | """Wrapper object for PLY lexer/parser.""" 17 | 18 | exception = ValueError 19 | 20 | def _parsetab_name(cls, fullname=True): 21 | """Return a name for PLY's parsetab file.""" 22 | ptname = sys.modules[cls.__module__].__name__ + '_tab' 23 | if not fullname: 24 | ptname = ptname.split('.')[-1] 25 | return ptname 26 | 27 | _parsetab_name = classmethod(_parsetab_name) 28 | 29 | def _write_parsetab(cls): 30 | """Write parser table (distribution purposes).""" 31 | parser = cls() 32 | tabname = cls._parsetab_name(False) 33 | yacc.yacc(module=parser, debug=0, tabmodule=tabname) 34 | 35 | _write_parsetab = classmethod(_write_parsetab) 36 | 37 | def parse(self, input, fname=None): 38 | lexer = lex.lex(object=self) 39 | if hasattr(input, 'read'): 40 | input = input.read() 41 | lexer.input(input) 42 | self.m_input = input 43 | self.m_fname = fname 44 | parser = yacc.yacc(module=self, debug=0, 45 | tabmodule=self._parsetab_name(), 46 | write_tables=0) 47 | parsed = parser.parse(lexer=lexer, tracking=True) 48 | return parsed 49 | 50 | def _position(self, o): 51 | if hasattr(o, 'lineno') and hasattr(o, 'lexpos'): 52 | lineno = o.lineno 53 | lexpos = o.lexpos 54 | pos = self.m_input.rfind('\n', 0, lexpos) 55 | column = lexpos - pos 56 | else: 57 | lineno = None 58 | column = None 59 | return lineno, column 60 | 61 | def t_ANY_error(self, t): 62 | err = self.exception() 63 | msg = 'illegal token' 64 | if self.m_fname: 65 | err.fname = self.m_fname 66 | msg += ' in file %s' % self.m_fname 67 | lineno, column = self._position(t) 68 | if lineno is not None and column is not None: 69 | msg += ' at %d:%d' % (lineno, column) 70 | err.lineno = lineno 71 | err.column = column 72 | err.message = msg 73 | raise err 74 | 75 | def p_error(self, p): 76 | err = self.exception() 77 | msg = 'syntax error' 78 | if self.m_fname: 79 | err.fname = self.m_fname 80 | msg += ' in file %s' % self.m_fname 81 | lineno, column = self._position(p) 82 | if lineno is not None and column is not None: 83 | msg += ' at %d:%d' % (lineno, column) 84 | err.lineno = lineno 85 | err.column = column 86 | err.message = msg 87 | raise err 88 | -------------------------------------------------------------------------------- /tests/core/test_locate.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | import math 10 | import signal 11 | 12 | from activedirectory.core.locate import Locator 13 | from threading import Timer 14 | 15 | 16 | class SRV(object): 17 | """SRV record for Locator testing.""" 18 | 19 | def __init__(self, priority=0, weight=100, target=None, port=None): 20 | self.priority = priority 21 | self.weight = weight 22 | self.target = target 23 | self.port = port 24 | 25 | 26 | class TestLocator(object): 27 | """Test suite for Locator.""" 28 | 29 | def test_simple(self, conf): 30 | conf.require(ad_user=True) 31 | domain = conf.domain() 32 | loc = Locator() 33 | result = loc.locate_many(domain) 34 | assert len(result) > 0 35 | result = loc.locate_many(domain, role='gc') 36 | assert len(result) > 0 37 | result = loc.locate_many(domain, role='pdc') 38 | assert len(result) == 1 39 | 40 | def test_network_failure(self, conf): 41 | conf.require(ad_user=True, local_admin=True, firewall=True) 42 | domain = conf.domain() 43 | loc = Locator() 44 | # Block outgoing DNS and CLDAP traffic and enable it after 3 seconds. 45 | # Locator should be able to handle this. 46 | conf.remove_network_blocks() 47 | conf.block_outgoing_traffic('tcp', 53) 48 | conf.block_outgoing_traffic('udp', 53) 49 | conf.block_outgoing_traffic('udp', 389) 50 | t = Timer(3, conf.remove_network_blocks); t.start() 51 | result = loc.locate_many(domain) 52 | assert len(result) > 0 53 | 54 | def test_order_dns_srv_priority(self): 55 | srv = [ SRV(10), SRV(0), SRV(10), SRV(20), SRV(100), SRV(5) ] 56 | loc = Locator() 57 | result = loc._order_dns_srv(srv) 58 | prio = [ res.priority for res in result ] 59 | sorted = prio[:] 60 | sorted.sort() 61 | assert prio == sorted 62 | 63 | def test_order_dns_srv_weight(self): 64 | n = 10000 65 | w = (100, 50, 25) 66 | sumw = sum(w) 67 | count = {} 68 | for x in w: 69 | count[x] = 0 70 | loc = Locator() 71 | srv = [ SRV(0, x) for x in w ] 72 | for i in range(n): 73 | res = loc._order_dns_srv(srv) 74 | count[res[0].weight] += 1 75 | print(count) 76 | 77 | def stddev(n, p): 78 | # standard deviation of binomial distribution 79 | return math.sqrt(n*p*(1-p)) 80 | 81 | for x in w: 82 | p = float(x)/sumw 83 | # 6 sigma this gives a 1 per 100 million chance of wrongly 84 | # asserting an error here. 85 | assert abs(count[x] - n*p) < 6 * stddev(n, p) 86 | 87 | def test_detect_site(self, conf): 88 | conf.require(ad_user=True) 89 | loc = Locator() 90 | domain = conf.domain() 91 | site = loc._detect_site(domain) 92 | assert site is not None 93 | -------------------------------------------------------------------------------- /tests/test.conf.example: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007-2008 by the Python-AD authors. See the 7 | # file "AUTHORS" for a complete overview. 8 | 9 | [test] 10 | 11 | # The following setting can enable "expensive tests". Expensive tests are tests 12 | # that can take a significant amount of time to complete. 13 | expensive_tests = 1 14 | 15 | # Some test in the Python-AD test suite require an Active Directory. These tests 16 | # are disabled by default and can be enabled below. A great way of doing AD 17 | # tests is to install an AD forest in one or multiple virtual machines on the 18 | # current system, and connect the systems to a virtual network that is shared 19 | # with the host. Be sure to enable DNS services in the forest. Then add a line 20 | # to "/etc/resolv.conf" pointing to a DNS server in the new AD forest. This is 21 | # sufficient to start using AD tests. 22 | 23 | # Set to 1 to enable tests that require read access to an active directory. All 24 | # tests that become enabled by this setting are non-intrusive. 25 | readonly_ad_tests = 0 26 | 27 | # Change to the domain that is to be used for AD tests. 28 | domain = freeadi.org 29 | 30 | # The name of a non-privileges account in AD. 31 | ad_user_account = 32 | 33 | # The password of the non-privileged account in AD. 34 | ad_user_password = 35 | 36 | 37 | # WARNING !!! WARNING !!! WARNING !!! 38 | # 39 | # Python-AD has support for "intrusive tests". These tests require elevated 40 | # privileges on the current system or in the directory, and may change 41 | # system configurations and update or delete data. 42 | # 43 | # All intrusive tests are disabled by default. They can be enabled by 44 | # settings the corresponding variables to "1" and providing the proper 45 | # password in this configuration file. Two types of intrusive tests exist: 46 | # 47 | # - Intrusive local tests. These tests can modify system configuration 48 | # files, update firewall settings and make other changes. The local root 49 | # password is required to run these tests. 50 | # - Intrusive AD tests. These tests can create / change / delete objects in 51 | # Active Directory. The AD administrator password is required to run 52 | # these tests. 53 | # 54 | # Intrusive tests should be in an environment that is not a production 55 | # environment. A good candidate for such an environment would be a set of 56 | # virtualised host running on a developer workstations which are connected 57 | # to a host-only network. See the section above on AD tests on how to set up 58 | # such an environment. 59 | # 60 | # Intrusive tests are written such that after running a set of tests the 61 | # system will be in a state that allows running the tests again, and so on. 62 | # In particular no restore from backup (or restore to snapshot in a virtual 63 | # environment) is required after running the tests. It is not guaranteed 64 | # however that after a running a set of tests the system will be in the same 65 | # state as it was before the tests. This is the reason that intrusive tests 66 | # should never be run in a production environment. 67 | 68 | # Set to 1 to enable tests that require local admin (root) access, 0 to 69 | # disable. 70 | intrusive_local_tests = 0 71 | 72 | # The local admin account 73 | local_admin_account = root 74 | 75 | # The password of the local admin account 76 | local_admin_password = 77 | 78 | # Set to 1 to enable tests that require AD administrator access, 0 to 79 | # disable. 80 | intrusive_ad_tests = 0 81 | 82 | # The AD administrator account 83 | ad_admin_account = Administrator 84 | 85 | # The AD administrator password 86 | ad_admin_password = 87 | -------------------------------------------------------------------------------- /lib/activedirectory/protocol/ldapfilter.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | import re 10 | from activedirectory.util.parser import Parser as PLYParser 11 | 12 | 13 | class Error(Exception): 14 | """LDAP Filter exception""" 15 | 16 | 17 | class AND(object): 18 | 19 | def __init__(self, *terms): 20 | self.terms = terms 21 | 22 | class OR(object): 23 | 24 | def __init__(self, *terms): 25 | self.terms = terms 26 | 27 | class NOT(object): 28 | 29 | def __init__(self, term): 30 | self.term = term 31 | 32 | class EQUALS(object): 33 | 34 | def __init__(self, type, value): 35 | self.type = type 36 | self.value = value 37 | 38 | class LTE(object): 39 | 40 | def __init__(self, type, value): 41 | self.type = type 42 | self.value = value 43 | 44 | class GTE(object): 45 | 46 | def __init__(self, type, value): 47 | self.type = type 48 | self.value = value 49 | 50 | class APPROX(object): 51 | 52 | def __init__(self, type, value): 53 | self.type = type 54 | self.value = value 55 | 56 | class PRESENT(object): 57 | 58 | def __init__(self, type): 59 | self.type = type 60 | 61 | 62 | class Parser(PLYParser): 63 | """A parser for LDAP filters (see RFC2254). 64 | 65 | The parser is pretty complete. Currently lacking are substring matches and 66 | extensible matches. 67 | """ 68 | 69 | exception = Error 70 | tokens = ('LPAREN', 'RPAREN', 'EQUALS', 'AND', 'OR', 'NOT', 71 | 'LTE','GTE', 'APPROX', 'PRESENT', 'STRING') 72 | 73 | t_LPAREN = r'\(' 74 | t_RPAREN = r'\)' 75 | t_EQUALS = r'=' 76 | t_AND = r'&' 77 | t_OR = r'\|' 78 | t_NOT = r'!' 79 | t_LTE = r'<=' 80 | t_GTE = r'>=' 81 | t_APPROX = r'~=' 82 | t_PRESENT = r'=\*' 83 | 84 | re_escape = re.compile(r'\\([0-9a-fA-F]{2})') 85 | 86 | def _unescape(self, value): 87 | """Unescape a hex encoded string.""" 88 | pos = 0 89 | parts = [] 90 | while True: 91 | mobj = self.re_escape.search(value, pos) 92 | if not mobj: 93 | parts.append(value[pos:]) 94 | break 95 | parts.append(value[pos:mobj.start()]) 96 | ch = chr(int(mobj.group(1), 16)) 97 | parts.append(ch) 98 | pos = mobj.end() 99 | result = ''.join(parts) 100 | return result 101 | 102 | def t_STRING(self, t): 103 | r'[^()=&|!<>~*]+' 104 | t.value = self._unescape(t.value) 105 | return t 106 | 107 | def p_filter(self, p): 108 | """filter : LPAREN and RPAREN 109 | | LPAREN or RPAREN 110 | | LPAREN not RPAREN 111 | | LPAREN item RPAREN 112 | """ 113 | p[0] = p[2] 114 | 115 | def p_and(self, p): 116 | 'and : AND filterlist' 117 | p[0] = AND(*p[2]) 118 | 119 | def p_or(self, p): 120 | 'or : OR filterlist' 121 | p[0] = OR(*p[2]) 122 | 123 | def p_not(self, p): 124 | 'not : NOT filter' 125 | p[0] = NOT(p[2]) 126 | 127 | def p_filterlist(self, p): 128 | """filterlist : filter 129 | | filter filterlist 130 | """ 131 | if len(p) == 2: 132 | p[0] = (p[1],) 133 | else: 134 | p[0] = (p[1],) + p[2] 135 | 136 | def p_item(self, p): 137 | """item : STRING EQUALS STRING 138 | | STRING LTE STRING 139 | | STRING GTE STRING 140 | | STRING APPROX STRING 141 | | STRING PRESENT 142 | """ 143 | if p[2] == '=': 144 | p[0] = EQUALS(p[1], p[3]) 145 | elif p[2] == '<=': 146 | p[0] = LTE(p[1], p[3]) 147 | elif p[2] == '>=': 148 | p[0] = GTE(p[1], p[3]) 149 | elif p[2] == '~=': 150 | p[0] = APPROX(p[1], p[3]) 151 | elif p[2] == '=*': 152 | p[0] = PRESENT(p[1]) 153 | -------------------------------------------------------------------------------- /tests/protocol/test_ldapfilter.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | from activedirectory.protocol import ldapfilter 10 | 11 | from ..base import assert_raises 12 | 13 | 14 | class TestLDAPFilterParser(object): 15 | """Test suite for activedirectory.protocol.ldapfilter.""" 16 | 17 | def test_equals(self): 18 | filt = '(type=value)' 19 | parser = ldapfilter.Parser() 20 | res = parser.parse(filt) 21 | assert isinstance(res, ldapfilter.EQUALS) 22 | assert res.type == 'type' 23 | assert res.value == 'value' 24 | 25 | def test_and(self): 26 | filt = '(&(type=value)(type2=value2))' 27 | parser = ldapfilter.Parser() 28 | res = parser.parse(filt) 29 | assert isinstance(res, ldapfilter.AND) 30 | assert len(res.terms) == 2 31 | assert isinstance(res.terms[0], ldapfilter.EQUALS) 32 | assert isinstance(res.terms[1], ldapfilter.EQUALS) 33 | 34 | def test_and_multi_term(self): 35 | filt = '(&(type=value)(type2=value2)(type3=value3))' 36 | parser = ldapfilter.Parser() 37 | res = parser.parse(filt) 38 | assert isinstance(res, ldapfilter.AND) 39 | assert len(res.terms) == 3 40 | assert isinstance(res.terms[0], ldapfilter.EQUALS) 41 | assert isinstance(res.terms[1], ldapfilter.EQUALS) 42 | assert isinstance(res.terms[2], ldapfilter.EQUALS) 43 | 44 | def test_or(self): 45 | filt = '(|(type=value)(type2=value2))' 46 | parser = ldapfilter.Parser() 47 | res = parser.parse(filt) 48 | assert isinstance(res, ldapfilter.OR) 49 | assert len(res.terms) == 2 50 | assert isinstance(res.terms[0], ldapfilter.EQUALS) 51 | assert isinstance(res.terms[1], ldapfilter.EQUALS) 52 | 53 | def test_or_multi_term(self): 54 | filt = '(|(type=value)(type2=value2)(type3=value3))' 55 | parser = ldapfilter.Parser() 56 | res = parser.parse(filt) 57 | assert isinstance(res, ldapfilter.OR) 58 | assert len(res.terms) == 3 59 | assert isinstance(res.terms[0], ldapfilter.EQUALS) 60 | assert isinstance(res.terms[1], ldapfilter.EQUALS) 61 | assert isinstance(res.terms[2], ldapfilter.EQUALS) 62 | 63 | def test_not(self): 64 | filt = '(!(type=value))' 65 | parser = ldapfilter.Parser() 66 | res = parser.parse(filt) 67 | assert isinstance(res, ldapfilter.NOT) 68 | assert isinstance(res.term, ldapfilter.EQUALS) 69 | 70 | def test_lte(self): 71 | filt = '(type<=value)' 72 | parser = ldapfilter.Parser() 73 | res = parser.parse(filt) 74 | assert isinstance(res, ldapfilter.LTE) 75 | assert res.type == 'type' 76 | assert res.value == 'value' 77 | 78 | def test_gte(self): 79 | filt = '(type>=value)' 80 | parser = ldapfilter.Parser() 81 | res = parser.parse(filt) 82 | assert isinstance(res, ldapfilter.GTE) 83 | assert res.type == 'type' 84 | assert res.value == 'value' 85 | 86 | def test_approx(self): 87 | filt = '(type~=value)' 88 | parser = ldapfilter.Parser() 89 | res = parser.parse(filt) 90 | assert isinstance(res, ldapfilter.APPROX) 91 | assert res.type == 'type' 92 | assert res.value == 'value' 93 | 94 | def test_present(self): 95 | filt = '(type=*)' 96 | parser = ldapfilter.Parser() 97 | res = parser.parse(filt) 98 | assert isinstance(res, ldapfilter.PRESENT) 99 | assert res.type == 'type' 100 | 101 | def test_escape(self): 102 | filt = r'(type=\5c\00\2a)' 103 | parser = ldapfilter.Parser() 104 | res = parser.parse(filt) 105 | assert res.value == '\\\x00*' 106 | 107 | def test_error_incomplete_term(self): 108 | parser = ldapfilter.Parser() 109 | filt = '(' 110 | assert_raises(ldapfilter.Error, parser.parse, filt) 111 | filt = '(type' 112 | assert_raises(ldapfilter.Error, parser.parse, filt) 113 | filt = '(type=' 114 | assert_raises(ldapfilter.Error, parser.parse, filt) 115 | filt = '(type=)' 116 | assert_raises(ldapfilter.Error, parser.parse, filt) 117 | 118 | def test_error_not_multi_term(self): 119 | parser = ldapfilter.Parser() 120 | filt = '(!(type=value)(type2=value2))' 121 | assert_raises(ldapfilter.Error, parser.parse, filt) 122 | 123 | def test_error_illegal_operator(self): 124 | parser = ldapfilter.Parser() 125 | filt = '($(type=value)(type2=value2))' 126 | assert_raises(ldapfilter.Error, parser.parse, filt) 127 | 128 | def test_error_illegal_character(self): 129 | parser = ldapfilter.Parser() 130 | filt = '(type=val*e)' 131 | assert_raises(ldapfilter.Error, parser.parse, filt) 132 | -------------------------------------------------------------------------------- /tests/core/test_creds.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | import os 10 | import pexpect 11 | 12 | from activedirectory.core.creds import Creds as ADCreds 13 | from activedirectory.core.object import instance, activate 14 | 15 | 16 | class TestCreds(object): 17 | """Test suite for activedirectory.core.creds.""" 18 | 19 | def test_acquire_password(self, conf): 20 | conf.require(ad_user=True) 21 | domain = conf.domain() 22 | creds = ADCreds(domain) 23 | principal = conf.ad_user_account() 24 | password = conf.ad_user_password() 25 | creds.acquire(principal, password) 26 | principal = '%s@%s' % (principal, domain) 27 | assert creds.principal().lower() == principal.lower() 28 | child = pexpect.spawn('klist') 29 | pattern = '.*krbtgt/%s@%s' % (domain.upper(), domain.upper()) 30 | assert child.expect([pattern]) == 0 31 | 32 | def test_acquire_keytab(self, conf): 33 | conf.require(ad_user=True) 34 | domain = conf.domain() 35 | creds = ADCreds(domain) 36 | principal = conf.ad_user_account() 37 | password = conf.ad_user_password() 38 | creds.acquire(principal, password) 39 | os.environ['PATH'] = '/usr/kerberos/sbin:/usr/kerberos/bin:%s' % \ 40 | os.environ['PATH'] 41 | fullprinc = creds.principal() 42 | child = pexpect.spawn('kvno %s' % fullprinc) 43 | child.expect('kvno =') 44 | kvno = int(child.readline()) 45 | child.expect(pexpect.EOF) 46 | child = pexpect.spawn('ktutil') 47 | child.expect('ktutil:') 48 | child.sendline('addent -password -p %s -k %d -e rc4-hmac' % 49 | (fullprinc, kvno)) 50 | child.expect('Password for.*:') 51 | child.sendline(password) 52 | child.expect('ktutil:') 53 | keytab = conf.tempfile(remove=True) 54 | child.sendline('wkt %s' % keytab) 55 | child.expect('ktutil:') 56 | child.sendline('quit') 57 | child.expect(pexpect.EOF) 58 | creds.release() 59 | creds.acquire(principal, keytab=keytab) 60 | child = pexpect.spawn('klist') 61 | pattern = '.*krbtgt/%s@%s' % (domain.upper(), domain.upper()) 62 | assert child.expect([pattern]) == 0 63 | 64 | def test_load(self, conf): 65 | conf.require(ad_user=True) 66 | domain = conf.domain().upper() 67 | principal = '%s@%s' % (conf.ad_user_account(), domain) 68 | conf.acquire_credentials(principal, conf.ad_user_password()) 69 | creds = ADCreds(domain) 70 | creds.load() 71 | assert creds.principal().lower() == principal.lower() 72 | ccache, princ, creds = conf.list_credentials() 73 | assert princ.lower() == principal.lower() 74 | assert len(creds) > 0 75 | assert creds[0] == 'krbtgt/%s@%s' % (domain, domain) 76 | 77 | def test_acquire_multi(self, conf): 78 | conf.require(ad_user=True) 79 | domain = conf.domain() 80 | principal = conf.ad_user_account() 81 | password = conf.ad_user_password() 82 | creds1 = ADCreds(domain) 83 | creds1.acquire(principal, password) 84 | ccache1 = creds1._ccache_name() 85 | config1 = creds1._config_name() 86 | assert ccache1 == os.environ['KRB5CCNAME'] 87 | assert config1 == os.environ['KRB5_CONFIG'] 88 | creds2 = ADCreds(domain) 89 | creds2.acquire(principal, password) 90 | ccache2 = creds2._ccache_name() 91 | config2 = creds2._config_name() 92 | assert ccache2 == os.environ['KRB5CCNAME'] 93 | assert config2 == os.environ['KRB5_CONFIG'] 94 | assert ccache1 != ccache2 95 | assert config1 != config2 96 | activate(creds1) 97 | assert os.environ['KRB5CCNAME'] == ccache1 98 | assert os.environ['KRB5_CONFIG'] == config1 99 | activate(creds2) 100 | assert os.environ['KRB5CCNAME'] == ccache2 101 | assert os.environ['KRB5_CONFIG'] == config2 102 | 103 | def test_release_multi(self, conf): 104 | conf.require(ad_user=True) 105 | domain = conf.domain() 106 | principal = conf.ad_user_account() 107 | password = conf.ad_user_password() 108 | ccorig = os.environ.get('KRB5CCNAME') 109 | cforig = os.environ.get('KRB5_CONFIG') 110 | creds1 = ADCreds(domain) 111 | creds1.acquire(principal, password) 112 | ccache1 = creds1._ccache_name() 113 | config1 = creds1._config_name() 114 | creds2 = ADCreds(domain) 115 | creds2.acquire(principal, password) 116 | ccache2 = creds2._ccache_name() 117 | config2 = creds2._config_name() 118 | creds1.release() 119 | assert os.environ['KRB5CCNAME'] == ccache2 120 | assert os.environ['KRB5_CONFIG'] == config2 121 | creds2.release() 122 | assert os.environ.get('KRB5CCNAME') == ccorig 123 | assert os.environ.get('KRB5_CONFIG') == cforig 124 | 125 | def test_cleanup_files(self, conf): 126 | conf.require(ad_user=True) 127 | domain = conf.domain() 128 | principal = conf.ad_user_account() 129 | password = conf.ad_user_password() 130 | creds = ADCreds(domain) 131 | creds.acquire(principal, password) 132 | ccache = creds._ccache_name() 133 | config = creds._config_name() 134 | assert os.access(ccache, os.R_OK) 135 | assert os.access(config, os.R_OK) 136 | creds.release() 137 | assert not os.access(ccache, os.R_OK) 138 | assert not os.access(config, os.R_OK) 139 | 140 | def test_cleanup_environment(self, conf): 141 | conf.require(ad_user=True) 142 | domain = conf.domain() 143 | principal = conf.ad_user_account() 144 | password = conf.ad_user_password() 145 | ccorig = os.environ.get('KRB5CCNAME') 146 | cforig = os.environ.get('KRB5_CONFIG') 147 | creds = ADCreds(domain) 148 | creds.acquire(principal, password) 149 | ccache = creds._ccache_name() 150 | config = creds._config_name() 151 | assert ccache != ccorig 152 | assert config != cforig 153 | creds.release() 154 | assert os.environ.get('KRB5CCNAME') == ccorig 155 | assert os.environ.get('KRB5_CONFIG') == cforig 156 | -------------------------------------------------------------------------------- /lib/activedirectory/protocol/ldap.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | from . import asn1, ldapfilter 10 | 11 | 12 | SCOPE_BASE = 0 13 | SCOPE_ONELEVEL = 1 14 | SCOPE_SUBTREE = 2 15 | 16 | DEREF_NEVER = 0 17 | DEREF_IN_SEARCHING = 1 18 | DEREF_FINDING_BASE_OBJ = 2 19 | DEREF_ALWAYS = 3 20 | 21 | 22 | class Error(Exception): 23 | """LDAP Error""" 24 | 25 | 26 | class Client(object): 27 | """LDAP client.""" 28 | 29 | def _encode_filter(self, encoder, filter): 30 | """Encode a parsed LDAP filter using `encoder'.""" 31 | if isinstance(filter, ldapfilter.AND): 32 | encoder.enter(0, asn1.ClassContext) 33 | for term in filter.terms: 34 | self._encode_filter(encoder, term) 35 | encoder.leave() 36 | elif isinstance(filter, ldapfilter.OR): 37 | encoder.enter(1, asn1.ClassContext) 38 | for term in filter.terms: 39 | self._encode_filter(encoder, term) 40 | encoder.leave() 41 | elif isinstance(filter, ldapfilter.NOT): 42 | encoder.enter(2, asn1.ClassContext) 43 | self._encode_filter(encoder, term) 44 | encoder.leave() 45 | elif isinstance(filter, ldapfilter.EQUALS): 46 | encoder.enter(3, asn1.ClassContext) 47 | encoder.write(filter.type) 48 | encoder.write(filter.value) 49 | encoder.leave() 50 | elif isinstance(filter, ldapfilter.LTE): 51 | encoder.enter(5, asn1.ClassContext) 52 | encoder.write(filter.type) 53 | encoder.write(filter.value) 54 | encoder.leave() 55 | elif isinstance(filter, ldapfilter.GTE): 56 | encoder.enter(6, asn1.ClassContext) 57 | encoder.write(filter.type) 58 | encoder.write(filter.value) 59 | encoder.leave() 60 | elif isinstance(filter, ldapfilter.PRESENT): 61 | encoder.enter(7, asn1.ClassContext) 62 | encoder.write(filter.type) 63 | encoder.leave() 64 | elif isinstance(filter, ldapfilter.APPROX): 65 | encoder.enter(8, asn1.ClassContext) 66 | encoder.write(filter.type) 67 | encoder.write(filter.value) 68 | encoder.leave() 69 | 70 | def create_search_request(self, dn, filter=None, attrs=None, scope=None, 71 | sizelimit=None, timelimit=None, deref=None, 72 | typesonly=None, msgid=None): 73 | """Create a search request. This only supports a very simple AND 74 | filter.""" 75 | if filter is None: 76 | filter = '(objectClass=*)' 77 | if attrs is None: 78 | attrs = [] 79 | if scope is None: 80 | scope = SCOPE_SUBTREE 81 | if sizelimit is None: 82 | sizelimit = 0 83 | if timelimit is None: 84 | timelimit = 0 85 | if deref is None: 86 | deref = DEREF_NEVER 87 | if typesonly is None: 88 | typesonly = False 89 | if msgid is None: 90 | msgid = 1 91 | parser = ldapfilter.Parser() 92 | parsed = parser.parse(filter) 93 | encoder = asn1.Encoder() 94 | encoder.start() 95 | encoder.enter(asn1.Sequence) # LDAPMessage 96 | encoder.write(msgid) 97 | encoder.enter(3, asn1.ClassApplication) # SearchRequest 98 | encoder.write(dn) 99 | encoder.write(scope, asn1.Enumerated) 100 | encoder.write(deref, asn1.Enumerated) 101 | encoder.write(sizelimit) 102 | encoder.write(timelimit) 103 | encoder.write(typesonly, asn1.Boolean) 104 | self._encode_filter(encoder, parsed) 105 | encoder.enter(asn1.Sequence) # attributes 106 | for attr in attrs: 107 | encoder.write(attr) 108 | encoder.leave() # end of attributes 109 | encoder.leave() # end of SearchRequest 110 | encoder.leave() # end of LDAPMessage 111 | result = encoder.output() 112 | return result 113 | 114 | def parse_message_header(self, buffer): 115 | """Parse an LDAP header and return the tuple (messageid, 116 | protocolOp).""" 117 | decoder = asn1.Decoder() 118 | decoder.start(buffer) 119 | self._check_tag(decoder.peek(), asn1.Sequence) 120 | decoder.enter() 121 | self._check_tag(decoder.peek(), asn1.Integer) 122 | msgid = decoder.read()[1] 123 | tag = decoder.peek() 124 | self._check_tag(tag, None, asn1.TypeConstructed, asn1.ClassApplication) 125 | op = tag[0] 126 | return (msgid, op) 127 | 128 | def parse_search_result(self, buffer): 129 | """Parse an LDAP search result. 130 | 131 | This function returns a list of search result. Each entry in the list 132 | is a (msgid, dn, attrs) tuple. attrs is a dictionary with LDAP types 133 | as keys and a list of attribute values as its values. 134 | """ 135 | decoder = asn1.Decoder() 136 | decoder.start(buffer) 137 | messages = [] 138 | while True: 139 | tag = decoder.peek() 140 | if tag is None: 141 | break 142 | self._check_tag(tag, asn1.Sequence) 143 | decoder.enter() # enter LDAPMessage 144 | self._check_tag(decoder.peek(), asn1.Integer) 145 | msgid = decoder.read()[1] # messageID 146 | tag = decoder.peek() 147 | self._check_tag(tag, (4,5), asn1.TypeConstructed, asn1.ClassApplication) 148 | if tag[0] == 5: 149 | break 150 | decoder.enter() # SearchResultEntry 151 | self._check_tag(decoder.peek(), asn1.OctetString) 152 | dn = decoder.read()[1] # objectName 153 | self._check_tag(decoder.peek(), asn1.Sequence) 154 | decoder.enter() # enter attributes 155 | attrs = {} 156 | while True: 157 | tag = decoder.peek() 158 | if tag is None: 159 | break 160 | self._check_tag(tag, asn1.Sequence) 161 | decoder.enter() # one attribute 162 | self._check_tag(decoder.peek(), asn1.OctetString) 163 | name = decoder.read()[1] # type 164 | self._check_tag(decoder.peek(), asn1.Set) 165 | decoder.enter() # vals 166 | values = [] 167 | while True: 168 | tag = decoder.peek() 169 | if tag is None: 170 | break 171 | self._check_tag(tag, asn1.OctetString) 172 | values.append(decoder.read()[1]) 173 | attrs[name] = values 174 | decoder.leave() # leave vals 175 | decoder.leave() # leave attribute 176 | decoder.leave() # leave attributes 177 | messages.append((msgid, dn, attrs)) 178 | return messages 179 | 180 | def _check_tag(self, tag, id, typ=None, cls=None): 181 | """Ensure that `tag' matches with `id', `typ' and `syntax'.""" 182 | if cls is None: 183 | cls = asn1.ClassUniversal 184 | if typ is None: 185 | if id in (asn1.Sequence, asn1.Set): 186 | typ = asn1.TypeConstructed 187 | else: 188 | typ = asn1.TypePrimitive 189 | if isinstance(id, tuple): 190 | if tag[0] not in id: 191 | raise Error('LDAP syntax error') 192 | elif id is not None: 193 | if tag[0] != id: 194 | raise Error('LDAP syntax error') 195 | if tag[1] != typ or tag[2] != cls: 196 | raise Error('LDAP syntax error') 197 | -------------------------------------------------------------------------------- /tests/protocol/test_netlogon.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | import os.path 10 | import signal 11 | import dns.resolver 12 | from threading import Timer 13 | 14 | import pytest 15 | from activedirectory.protocol import netlogon 16 | 17 | from ..base import assert_raises 18 | 19 | 20 | def decode_uint32(buffer, offset): 21 | d = netlogon.Decoder() 22 | d.start(buffer) 23 | d._set_offset(offset) 24 | return d._decode_uint32(), d._offset() 25 | 26 | 27 | def decode_rfc1035(buffer, offset): 28 | d = netlogon.Decoder() 29 | d.start(buffer) 30 | d._set_offset(offset) 31 | return d._decode_rfc1035(), d._offset() 32 | 33 | 34 | class TestDecoder(object): 35 | """Test suite for netlogon.Decoder.""" 36 | 37 | def test_uint32_simple(self): 38 | s = b'\x01\x00\x00\x00' 39 | assert decode_uint32(s, 0) == (1, 4) 40 | 41 | def test_uint32_byte_order(self): 42 | s = b'\x00\x01\x00\x00' 43 | assert decode_uint32(s, 0) == (0x100, 4) 44 | s = b'\x00\x00\x01\x00' 45 | assert decode_uint32(s, 0) == (0x10000, 4) 46 | s = b'\x00\x00\x00\x01' 47 | assert decode_uint32(s, 0) == (0x1000000, 4) 48 | 49 | def test_uint32_long(self): 50 | s = b'\x00\x00\x00\xff' 51 | assert decode_uint32(s, 0) == (0xff000000, 4) 52 | s = b'\xff\xff\xff\xff' 53 | assert decode_uint32(s, 0) == (0xffffffff, 4) 54 | 55 | def test_error_uint32_null_input(self): 56 | s = b'' 57 | assert_raises(netlogon.Error, decode_uint32, s, 0) 58 | 59 | def test_error_uint32_short_input(self): 60 | s = b'\x00' 61 | assert_raises(netlogon.Error, decode_uint32, s, 0) 62 | s = b'\x00\x00' 63 | assert_raises(netlogon.Error, decode_uint32, s, 0) 64 | s = b'\x00\x00\x00' 65 | assert_raises(netlogon.Error, decode_uint32, s, 0) 66 | 67 | def test_rfc1035_simple(self): 68 | s = b'\x03foo\x00' 69 | assert decode_rfc1035(s, 0) == (b'foo', 5) 70 | 71 | def test_rfc1035_multi_component(self): 72 | s = b'\x03foo\x03bar\x00' 73 | assert decode_rfc1035(s, 0) == (b'foo.bar', 9) 74 | 75 | def test_rfc1035_pointer(self): 76 | s = b'\x03foo\x00\xc0\x00' 77 | assert decode_rfc1035(s, 5) == (b'foo', 7) 78 | 79 | def test_rfc1035_forward_pointer(self): 80 | s = b'\xc0\x02\x03foo\x00' 81 | assert decode_rfc1035(s, 0) == (b'foo', 2) 82 | 83 | def test_rfc1035_pointer_component(self): 84 | s = b'\x03foo\x00\x03bar\xc0\x00' 85 | assert decode_rfc1035(s, 5) == (b'bar.foo', 11) 86 | 87 | def test_rfc1035_pointer_multi_component(self): 88 | s = b'\x03foo\x03bar\x00\x03baz\xc0\x00' 89 | assert decode_rfc1035(s, 9) == (b'baz.foo.bar', 15) 90 | 91 | def test_rfc1035_pointer_recursive(self): 92 | s = b'\x03foo\x00\x03bar\xc0\x00\x03baz\xc0\x05' 93 | assert decode_rfc1035(s, 11) == (b'baz.bar.foo', 17) 94 | 95 | def test_rfc1035_multi_string(self): 96 | s = b'\x03foo\x00\x03bar\x00' 97 | assert decode_rfc1035(s, 0) == (b'foo', 5) 98 | assert decode_rfc1035(s, 5) == (b'bar', 10) 99 | 100 | def test_rfc1035_null(self): 101 | s = b'\x00' 102 | assert decode_rfc1035(s, 0) == (b'', 1) 103 | 104 | def test_error_rfc1035_null_input(self): 105 | s = b'' 106 | assert_raises(netlogon.Error, decode_rfc1035, s, 0) 107 | 108 | def test_error_rfc1035_missing_tag(self): 109 | s = b'\x03foo' 110 | assert_raises(netlogon.Error, decode_rfc1035, s, 0) 111 | 112 | def test_error_rfc1035_truncated_input(self): 113 | s = b'\x04foo' 114 | assert_raises(netlogon.Error, decode_rfc1035, s, 0) 115 | 116 | def test_error_rfc1035_pointer_overflow(self): 117 | s = b'\xc0\x03' 118 | assert_raises(netlogon.Error, decode_rfc1035, s, 0) 119 | 120 | def test_error_rfc1035_cyclic_pointer(self): 121 | s = b'\xc0\x00' 122 | assert_raises(netlogon.Error, decode_rfc1035, s, 0) 123 | s = b'\x03foo\xc0\x06\x03bar\xc0\x0c\x03baz\xc0\x00' 124 | assert_raises(netlogon.Error, decode_rfc1035, s, 0) 125 | 126 | def test_error_rfc1035_illegal_tags(self): 127 | s = b'\x80' + 0x80 * b'a' + b'\x00' 128 | assert_raises(netlogon.Error, decode_rfc1035, s, 0) 129 | s = b'\x40' + 0x40 * b'a' + b'\x00' 130 | assert_raises(netlogon.Error, decode_rfc1035, s, 0) 131 | 132 | def test_error_rfc1035_half_pointer(self): 133 | s = b'\xc0' 134 | assert_raises(netlogon.Error, decode_rfc1035, s, 0) 135 | 136 | def test_io_byte(self): 137 | d = netlogon.Decoder() 138 | s = b'foo' 139 | d.start(s) 140 | assert d._read_byte() == ord('f') 141 | assert d._read_byte() == ord('o') 142 | assert d._read_byte() == ord('o') 143 | 144 | def test_io_bytes(self): 145 | d = netlogon.Decoder() 146 | s = b'foo' 147 | d.start(s) 148 | assert d._read_bytes(3) == b'foo' 149 | 150 | def test_error_io_byte(self): 151 | d = netlogon.Decoder() 152 | s = b'foo' 153 | d.start(s) 154 | for i in range(3): 155 | d._read_byte() 156 | assert_raises(netlogon.Error, d._read_byte) 157 | 158 | def test_error_io_bytes(self): 159 | d = netlogon.Decoder() 160 | s = b'foo' 161 | d.start(s) 162 | assert_raises(netlogon.Error, d._read_bytes, 4) 163 | 164 | def test_error_io_bounds(self): 165 | d = netlogon.Decoder() 166 | s = b'foo' 167 | d.start(s) 168 | d._set_offset(4) 169 | assert_raises(netlogon.Error, d._read_byte) 170 | assert_raises(netlogon.Error, d._read_bytes, 4) 171 | 172 | def test_error_negative_offset(self): 173 | d = netlogon.Decoder() 174 | s = b'foo' 175 | d.start(s) 176 | assert_raises(netlogon.Error, d._set_offset, -1) 177 | 178 | def test_error_io_type(self): 179 | d = netlogon.Decoder() 180 | assert_raises(netlogon.Error, d.start, 1) 181 | assert_raises(netlogon.Error, d.start, 1) 182 | assert_raises(netlogon.Error, d.start, ()) 183 | assert_raises(netlogon.Error, d.start, []) 184 | assert_raises(netlogon.Error, d.start, {}) 185 | assert_raises(netlogon.Error, d.start, u'test') 186 | 187 | def test_real_packet(self, conf): 188 | buf = conf.read_file('protocol/netlogon.bin') 189 | dec = netlogon.Decoder() 190 | dec.start(buf) 191 | res = dec.parse() 192 | assert res.forest == b'freeadi.org' 193 | assert res.domain == b'freeadi.org' 194 | assert res.client_site == b'Default-First-Site' 195 | assert res.server_site == b'Test-Site' 196 | 197 | def test_error_short_input(self): 198 | buf = b'x' * 24 199 | dec = netlogon.Decoder() 200 | dec.start(buf) 201 | assert_raises(netlogon.Error, dec.parse) 202 | 203 | 204 | class TestClient(object): 205 | """Test suite for netlogon.Client.""" 206 | 207 | def test_simple(self, conf): 208 | conf.require(ad_user=True) 209 | domain = conf.domain() 210 | client = netlogon.Client() 211 | answer = dns.resolver.query('_ldap._tcp.%s' % domain, 'SRV') 212 | addrs = [ (ans.target.to_text(), ans.port) for ans in answer ] 213 | names = [ ans.target.to_text().rstrip('.') for ans in answer ] 214 | for addr in addrs: 215 | client.query(addr, domain) 216 | result = client.call() 217 | assert len(result) == len(addrs) # assume retries are succesful 218 | for res in result: 219 | assert res.type in (23,) 220 | assert res.flags & netlogon.SERVER_LDAP 221 | assert res.flags & netlogon.SERVER_KDC 222 | assert res.flags & netlogon.SERVER_WRITABLE 223 | assert len(res.domain_guid) == 16 224 | assert len(res.forest) > 0 225 | assert res.domain == domain 226 | assert res.hostname in names 227 | assert len(res.netbios_domain) > 0 228 | assert len(res.netbios_hostname) > 0 229 | assert len(res.client_site) > 0 230 | assert len(res.server_site) > 0 231 | assert (res.q_hostname, res.q_port) in addrs 232 | assert res.q_domain.lower() == domain.lower() 233 | assert res.q_timing >= 0.0 234 | 235 | def test_network_failure(self, conf): 236 | conf.require(ad_user=True, local_admin=True, firewall=True) 237 | domain = conf.domain() 238 | client = netlogon.Client() 239 | answer = dns.resolver.query('_ldap._tcp.%s' % domain, 'SRV') 240 | addrs = [ (ans.target.to_text(), ans.port) for ans in answer ] 241 | for addr in addrs: 242 | client.query(addr, domain) 243 | # Block CLDAP traffic and enable it after 3 seconds. Because 244 | # NetlogonClient is retrying, it should be succesfull. 245 | conf.remove_network_blocks() 246 | conf.block_outgoing_traffic('udp', 389) 247 | t = Timer(3, conf.remove_network_blocks); t.start() 248 | result = client.call() 249 | assert len(result) == len(addrs) 250 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | import os 10 | import sys 11 | import os.path 12 | from io import open 13 | import tempfile 14 | 15 | from configparser import ConfigParser 16 | import pexpect 17 | import pytest 18 | 19 | from activedirectory.util.log import enable_logging 20 | 21 | 22 | def assert_raises(error_class, function, *args, **kwargs): 23 | with pytest.raises(error_class): 24 | function(*args, **kwargs) 25 | 26 | 27 | class Error(Exception): 28 | """Test error.""" 29 | 30 | def dedent(self, s): 31 | lines = s.splitlines() 32 | for i in range(len(lines)): 33 | lines[i] = lines[i].lstrip() 34 | if lines and not lines[0]: 35 | lines = lines[1:] 36 | if lines and not lines[-1]: 37 | lines = lines[:-1] 38 | return '\n'.join(lines) + '\n' 39 | 40 | 41 | class Conf(object): 42 | """Base class for Python-AD tests.""" 43 | 44 | def __init__(self): 45 | fname = os.environ.get( 46 | 'PYAD_TEST_CONFIG', 47 | os.path.join(os.path.dirname(__file__), 'test.conf.example') 48 | ) 49 | if fname is None: 50 | raise Error('Python-AD test configuration file not specified.') 51 | if not os.path.exists(fname): 52 | raise Error('Python-AD test configuration file {} does not exist.'.format(fname)) 53 | self.config = ConfigParser() 54 | self.config.read(fname) 55 | self.basedir = os.path.dirname(__file__) 56 | self._iptables = None 57 | self._domain = self.config.get('test', 'domain') 58 | self._tempfiles = [] 59 | enable_logging() 60 | 61 | self.readonly_ad_creds = None 62 | readonly_env = os.environ.get('PYAD_READONLY_CONFIG', None) 63 | if readonly_env: 64 | bits = readonly_env.rsplit('@', 1) 65 | if len(bits) == 2: 66 | creds, domain = bits 67 | bits = creds.split(':', 1) 68 | if len(bits) == 2: 69 | self._domain = domain 70 | self.readonly_ad_creds = bits 71 | elif self.config.getboolean('test', 'readonly_ad_tests'): 72 | self.readonly_ad_creds = [ 73 | config.get('test', 'ad_user_account'), 74 | config.get('test', 'ad_user_password'), 75 | ] 76 | 77 | def teardown(self): 78 | for fname in self._tempfiles: 79 | try: 80 | os.unlink(fname) 81 | except OSError: 82 | pass 83 | self._tempfiles = [] 84 | 85 | def tempfile(self, contents=None, remove=False): 86 | fd, name = tempfile.mkstemp() 87 | if contents: 88 | os.write(fd, dedent(contents)) 89 | elif remove: 90 | os.remove(name) 91 | os.close(fd) 92 | self._tempfiles.append(name) 93 | return name 94 | 95 | def read_file(self, fname): 96 | fname = os.path.join(self.basedir, fname) 97 | with open(fname, 'rb') as fin: 98 | buf = fin.read() 99 | 100 | return buf 101 | 102 | def require(self, ad_user=False, local_admin=False, ad_admin=False, 103 | firewall=False, expensive=False): 104 | if firewall: 105 | local_admin = True 106 | config = self.config 107 | if ad_user and not ( 108 | self.readonly_ad_creds and all(self.readonly_ad_creds) 109 | ): 110 | raise pytest.skip('test disabled by configuration') 111 | if local_admin: 112 | if not config.getboolean('test', 'intrusive_local_tests'): 113 | raise pytest.skip('test disabled by configuration') 114 | if not config.get('test', 'local_admin_account') or \ 115 | not config.get('test', 'local_admin_password'): 116 | raise pytest.skip('intrusive local tests enabled but no user/pw given') 117 | if ad_admin: 118 | if not config.getboolean('test', 'intrusive_ad_tests'): 119 | raise pytest.skip('test disabled by configuration') 120 | if not config.get('test', 'ad_admin_account') or \ 121 | not config.get('test', 'ad_admin_password'): 122 | raise pytest.skip('intrusive ad tests enabled but no user/pw given') 123 | if firewall and not self.iptables_supported: 124 | raise pytest.skip('iptables/conntrack not available') 125 | if expensive and not config.getboolean('test', 'expensive_tests'): 126 | raise pytest.skip('test disabled by configuration') 127 | 128 | def domain(self): 129 | return self._domain 130 | 131 | def ad_user_account(self): 132 | self.require(ad_user=True) 133 | return self.readonly_ad_creds[0] 134 | 135 | def ad_user_password(self): 136 | self.require(ad_user=True) 137 | return self.readonly_ad_creds[1] 138 | 139 | def local_admin_account(self): 140 | self.require(local_admin=True) 141 | return self.config.get('test', 'local_admin_account') 142 | 143 | def local_admin_password(self): 144 | self.require(local_admin=True) 145 | return self.config.get('test', 'local_admin_password') 146 | 147 | def ad_admin_account(self): 148 | self.require(ad_admin=True) 149 | return self.config.get('test', 'ad_admin_account') 150 | 151 | def ad_admin_password(self): 152 | self.require(ad_admin=True) 153 | return self.config.get('test', 'ad_admin_password') 154 | 155 | def execute_as_root(self, command): 156 | self.require(local_admin=True) 157 | child = pexpect.spawn('su -c "%s" %s' % (command, self.local_admin_account())) 158 | child.expect('.*:') 159 | child.sendline(self.local_admin_password()) 160 | child.expect(pexpect.EOF) 161 | assert not child.isalive() 162 | if child.exitstatus != 0: 163 | m = 'Root command exited with status %s' % child.exitstatus 164 | raise Error(m) 165 | return child.before 166 | 167 | def acquire_credentials(self, principal, password, ccache=None): 168 | if ccache is None: 169 | ccache = '' 170 | else: 171 | ccache = '-c %s' % ccache 172 | child = pexpect.spawn('kinit %s %s' % (principal, ccache)) 173 | child.expect(':') 174 | child.sendline(password) 175 | child.expect(pexpect.EOF) 176 | assert not child.isalive() 177 | if child.exitstatus != 0: 178 | m = 'Command kinit exited with status %s' % child.exitstatus 179 | raise Error(m) 180 | 181 | def list_credentials(self, ccache=None): 182 | if ccache is None: 183 | ccache = '' 184 | child = pexpect.spawn('klist %s' % ccache) 185 | try: 186 | child.expect('Ticket cache: ([a-zA-Z0-9_/.:-]+)\r\n') 187 | except pexpect.EOF: 188 | m = 'Command klist exited with status %s' % child.exitstatus 189 | raise Error(m) 190 | ccache = child.match.group(1) 191 | child.expect('Default principal: ([a-zA-Z0-9_/.:@-]+)\r\n') 192 | principal = child.match.group(1) 193 | creds = [] 194 | while True: 195 | i = child.expect(['\r\n', pexpect.EOF, 196 | r'\d\d/\d\d/\d\d \d\d:\d\d:\d\d\s+' \ 197 | r'\d\d/\d\d/\d\d \d\d:\d\d:\d\d\s+' \ 198 | r'([a-zA-Z0-9_/.:@-]+)\r\n']) 199 | if i == 0: 200 | continue 201 | elif i == 1: 202 | break 203 | creds.append(child.match.group(1)) 204 | return ccache, principal, creds 205 | 206 | @property 207 | def iptables_supported(self): 208 | if self._iptables is None: 209 | try: 210 | self.execute_as_root('iptables -L -n') 211 | self.execute_as_root('conntrack -L') 212 | except Error: 213 | self._iptables = False 214 | else: 215 | self._iptables = True 216 | return self._iptables 217 | 218 | def remove_network_blocks(self): 219 | self.require(local_admin=True, firewall=True) 220 | self.execute_as_root('iptables -t nat -F') 221 | self.execute_as_root('conntrack -F') 222 | 223 | def block_outgoing_traffic(self, protocol, port): 224 | """Block outgoing traffic of type `protocol' with destination `port'.""" 225 | self.require(local_admin=True, firewall=True) 226 | # Unfortunately we cannot simply insert a rule like this: -A OUTPUT -m 227 | # udp -p udp--dport 389 -j DROP. If we do this the kernel code will 228 | # be smart and return an error when sending trying to connect or send 229 | # a datagram. In order realistically emulate a network failure we 230 | # instead redirect packets the discard port on localhost. This 231 | # complicates stopping the emulated failure though: merely flushling 232 | # the nat table is not enough. We also need to flush the conntrack 233 | # table that keeps state for NAT'ed connections even after the rule 234 | # that caused the NAT in the first place has been removed. 235 | self.execute_as_root( 236 | 'iptables -t nat -A OUTPUT -m %s -p %s --dport %d ' 237 | '-j DNAT --to-destination 127.0.0.1:9' % (protocol, protocol, port) 238 | ) 239 | -------------------------------------------------------------------------------- /lib/activedirectory/protocol/netlogon.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | import time 10 | import errno 11 | import socket 12 | import select 13 | import random 14 | 15 | from ..util import misc 16 | from . import asn1, ldap 17 | 18 | 19 | SERVER_PDC = 0x1 20 | SERVER_GC = 0x4 21 | SERVER_LDAP = 0x8 22 | SERVER_DS = 0x10 23 | SERVER_KDC = 0x20 24 | SERVER_TIMESERV = 0x40 25 | SERVER_CLOSEST = 0x80 26 | SERVER_WRITABLE = 0x100 27 | SERVER_GOOD_TIMESERV = 0x200 28 | 29 | 30 | class Error(Exception): 31 | """Netlogon error.""" 32 | 33 | 34 | class Reply(object): 35 | """The result of a NetLogon RPC.""" 36 | 37 | def __init__(self, **kwargs): 38 | """Constructor.""" 39 | for key in kwargs: 40 | setattr(self, key, kwargs[key]) 41 | 42 | 43 | class Decoder(object): 44 | """Netlogon decoder.""" 45 | 46 | def start(self, buffer): 47 | """Start decoding `buffer'.""" 48 | self._set_buffer(buffer) 49 | self._set_offset(0) 50 | 51 | def parse(self): 52 | """Parse a netlogon reply.""" 53 | type = self._decode_uint32() 54 | flags = self._decode_uint32() 55 | domain_guid = self._read_bytes(16) 56 | forest = self._decode_rfc1035() 57 | domain = self._decode_rfc1035() 58 | hostname = self._decode_rfc1035() 59 | netbios_domain = self._decode_rfc1035() 60 | netbios_hostname = self._decode_rfc1035() 61 | user = self._decode_rfc1035() 62 | server_site = self._decode_rfc1035() 63 | client_site = self._decode_rfc1035() 64 | return Reply(type=type, flags=flags, domain_guid=domain_guid, 65 | forest=forest, domain=domain, hostname=hostname, 66 | netbios_domain=netbios_domain, 67 | netbios_hostname=netbios_hostname, user=user, 68 | client_site=client_site, server_site=server_site) 69 | 70 | def _decode_rfc1035(self, _pointer=False): 71 | """Decompress an RFC1035 (section 4.1.4) compressed string.""" 72 | result = [] 73 | if _pointer == False: 74 | _pointer = [] 75 | while True: 76 | tag = self._read_byte() 77 | if tag == 0: 78 | break 79 | elif tag & 0xc0 == 0xc0: 80 | byte = self._read_byte() 81 | ptr = ((tag & ~0xc0) << 8) + byte 82 | if ptr in _pointer: 83 | raise Error('Cyclic pointer') 84 | _pointer.append(ptr) 85 | saved, self.m_offset = self.m_offset, ptr 86 | result.append(self._decode_rfc1035(_pointer)) 87 | self.m_offset = saved 88 | break 89 | elif tag & 0xc0: 90 | raise Error('Illegal tag') 91 | else: 92 | s = self._read_bytes(tag) 93 | result.append(s) 94 | result = b'.'.join(result) 95 | return result 96 | 97 | def _try_convert_int(self, value): 98 | """Try to convert `value' to an integer.""" 99 | try: 100 | value = int(value) 101 | except OverflowError: 102 | pass 103 | return value 104 | 105 | def _decode_uint32(self): 106 | """Decode a 32-bit unsigned little endian integer from the current 107 | offset.""" 108 | value = 0 109 | for i in range(4): 110 | byte = self._read_byte() 111 | value |= (byte << i*8) 112 | value = self._try_convert_int(value) 113 | return value 114 | 115 | def _offset(self): 116 | """Return the current offset.""" 117 | return self.m_offset 118 | 119 | def _set_offset(self, offset): 120 | """Set the current decoding offset.""" 121 | if offset < 0: 122 | raise Error('Offset must be positive.') 123 | self.m_offset = offset 124 | 125 | def _buffer(self): 126 | """Return the current buffer.""" 127 | return self.m_buffer 128 | 129 | def _set_buffer(self, buffer): 130 | """Set the current buffer.""" 131 | if not isinstance(buffer, bytes): 132 | raise Error('Buffer must be bytes.') 133 | self.m_buffer = buffer 134 | 135 | def _read_byte(self, offset=None): 136 | """Read a single byte from the input.""" 137 | if offset is None: 138 | offset = self.m_offset 139 | update_offset = True 140 | else: 141 | update_offset = False 142 | if offset >= len(self.m_buffer): 143 | raise Error('Premature end of input.') 144 | byte = self.m_buffer[offset] 145 | if isinstance(byte, str): 146 | byte = ord(byte) 147 | if update_offset: 148 | self.m_offset += 1 149 | return byte 150 | 151 | def _read_bytes(self, count, offset=None): 152 | """Return the next `count' bytes of input. Raise error on 153 | end-of-input.""" 154 | if offset is None: 155 | offset = self.m_offset 156 | update_offset = True 157 | else: 158 | update_offset = False 159 | bytes = self.m_buffer[offset:offset+count] 160 | if len(bytes) != count: 161 | raise Error('Premature end of input.') 162 | if update_offset: 163 | self.m_offset += count 164 | return bytes 165 | 166 | 167 | class Client(object): 168 | """A client for the netlogon service. 169 | 170 | This client can make multiple simultaneous netlogon calls. 171 | """ 172 | 173 | _timeout = 2 174 | _retries = 3 175 | _bufsize = 8192 176 | 177 | def __init__(self): 178 | """Constructor.""" 179 | self.m_socket = None 180 | self.m_queries = {} 181 | self.m_offset = None 182 | 183 | def query(self, addr, domain): 184 | """Add the Netlogon query to `addr' for `domain'.""" 185 | hostname, port = addr 186 | addr = (socket.gethostbyname(hostname), port) 187 | self.m_queries[addr] = [hostname, port, domain, None] 188 | 189 | def call(self, timeout=None, retries=None): 190 | """Wait for results for `timeout' seconds.""" 191 | if timeout is None: 192 | timeout = self._timeout 193 | if retries is None: 194 | retries = self._retries 195 | result = [] 196 | self._create_socket() 197 | for i in range(retries): 198 | if not self.m_queries: 199 | break 200 | self._send_all_requests() 201 | result += self._wait_for_replies(timeout) 202 | self._close_socket() 203 | self.m_queries = {} 204 | return result 205 | 206 | def _create_socket(self): 207 | """Create an UDP socket for `server':`port'.""" 208 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 209 | sock.bind(('', 0)) 210 | self.m_socket = sock 211 | 212 | def _close_socket(self): 213 | """Close the UDP socket.""" 214 | self.m_socket.close() 215 | self.m_socket = None 216 | 217 | def _create_message_id(self): 218 | """Create a new sequence number.""" 219 | if self.m_offset is None: 220 | self.m_offset = random.randint(0, 2**31-1) 221 | msgid = self.m_offset 222 | self.m_offset += 1 223 | if self.m_offset == 2**31-1: 224 | self.m_offset = 0 225 | return msgid 226 | 227 | def _send_all_requests(self): 228 | """Send requests to all hosts.""" 229 | for addr in self.m_queries: 230 | domain = self.m_queries[addr][2] 231 | msgid = self._create_message_id() 232 | self.m_queries[addr][3] = msgid 233 | packet = self._create_netlogon_query(domain, msgid) 234 | self.m_socket.sendto(packet, 0, addr) 235 | 236 | def _wait_for_replies(self, timeout): 237 | """Wait one single timeout on all the sockets.""" 238 | begin = time.time() 239 | end = begin + timeout 240 | replies = [] 241 | while True: 242 | if not self.m_queries: 243 | break 244 | timeleft = end - time.time() 245 | if timeleft <= 0: 246 | break 247 | fds = [ self.m_socket.fileno() ] 248 | try: 249 | result = select.select(fds, [], [], timeleft) 250 | except select.error as err: 251 | error = err.args[0] 252 | if error == errno.EINTR: 253 | continue # interrupted by signal 254 | else: 255 | raise Error(str(err)) # unrecoverable 256 | if not result[0]: 257 | continue # timeout 258 | assert fds == result[0] 259 | while True: 260 | if not self.m_queries: 261 | break 262 | try: 263 | data, addr = self.m_socket.recvfrom(self._bufsize, 264 | socket.MSG_DONTWAIT) 265 | except socket.error as err: 266 | error = err.args[0] 267 | if error == errno.EINTR: 268 | continue # signal interrupt 269 | elif error == errno.EAGAIN: 270 | break # no data available now 271 | else: 272 | raise Error(str(err)) # unrecoverable 273 | try: 274 | hostname, port, domain, msgid = self.m_queries[addr] 275 | except KeyError: 276 | continue # someone sent us an erroneous datagram? 277 | try: 278 | id, opcode = self._parse_message_header(data) 279 | except (asn1.Error, ldap.Error, Error): 280 | continue 281 | if id != msgid: 282 | continue 283 | del self.m_queries[addr] 284 | try: 285 | reply = self._parse_netlogon_reply(data) 286 | except (asn1.Error, ldap.Error, Error): 287 | continue 288 | if not reply: 289 | continue 290 | reply.q_hostname = hostname 291 | reply.q_port = port 292 | reply.q_domain = domain 293 | reply.q_msgid = msgid 294 | reply.q_address = addr 295 | timing = time.time() - begin 296 | reply.q_timing = timing 297 | replies.append(reply) 298 | return replies 299 | 300 | def _create_netlogon_query(self, domain, msgid): 301 | """Create a netlogon query for `domain'.""" 302 | client = ldap.Client() 303 | hostname = misc.hostname() 304 | filter = '(&(DnsDomain=%s)(Host=%s)(NtVer=\\06\\00\\00\\00))' % \ 305 | (domain, hostname) 306 | attrs = ('NetLogon',) 307 | return client.create_search_request('', filter, attrs=attrs, 308 | scope=ldap.SCOPE_BASE, msgid=msgid) 309 | 310 | def _parse_message_header(self, reply): 311 | """Parse an LDAP header and return the messageid and opcode.""" 312 | client = ldap.Client() 313 | msgid, opcode = client.parse_message_header(reply) 314 | return msgid, opcode 315 | 316 | def _parse_netlogon_reply(self, reply): 317 | """Parse a netlogon reply.""" 318 | client = ldap.Client() 319 | messages = client.parse_search_result(reply) 320 | if not messages: 321 | return 322 | msgid, dn, attrs = messages[0] 323 | if not attrs.get(b'netlogon'): 324 | raise Error('No netlogon attribute received.') 325 | data = attrs[b'netlogon'][0] 326 | decoder = Decoder() 327 | decoder.start(data) 328 | result = decoder.parse() 329 | return result 330 | -------------------------------------------------------------------------------- /lib/activedirectory/core/creds.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | import os 10 | import time 11 | import logging 12 | import tempfile 13 | import ldap 14 | 15 | from .object import factory 16 | from .exception import Error 17 | from .locate import Locator 18 | from .locate import KERBEROS_PORT, KPASSWD_PORT 19 | from ..protocol import krb5 20 | from ..util import compat 21 | 22 | 23 | class Creds(object): 24 | """AD credential management.""" 25 | 26 | c_config_stack = {} 27 | c_ccache_stack = {} 28 | 29 | # We only use strong encryption mechanisms so no DES here. RC4 has been 30 | # available since the first AD version in Windows 2000 so there's no 31 | # backward compatibility issue. AES has been available since Windows 2008. 32 | # In case of older Windows version it is not a problem that we include AES 33 | # as Kerberos will correctly detect what encryption type to use. 34 | # 35 | # Note: there's an interoperability issue for computer accounts in Windows 36 | # 2003 and later. In Windows 2003, AD changed its principal2salt() 37 | # function for computer accounts which means it no longer matches the 38 | # implementation in MIT Kerberos. One consequence of this is that keytabs 39 | # created by "ktutil" on Unix will be different from keytabs created by 40 | # "ktpass" on Windows. Keytabs created for computer accounts on Windows 41 | # 2000 still work correctly, and so do keytabs for regular user accounts 42 | # on any Windows version. 43 | # 44 | # If you're authenticating computer accounts with keytabs, there's two 45 | # options. The first is to use the "arcfour-hmac-md5" encryption type 46 | # which is unsalted. The second option is to use "msktutil" to create the 47 | # keytab on the Unix side. 48 | c_enctypes = ('aes256-cts-hmac-sha1-96', 49 | 'aes128-cts-hmac-sha1-96', 50 | 'arcfour-hmac-md5') 51 | c_supported_enctypes = None 52 | 53 | def __init__(self, domain, use_system_config=False): 54 | """Constructor. 55 | 56 | The `domain' parameter specifies the default domain. The default 57 | domain is used when a principal is specified without a domain in the 58 | .acquire() method. 59 | """ 60 | self.m_domain = domain.upper() 61 | self.m_domains = {} 62 | self.m_principal = None 63 | self.m_ccache = None 64 | self.m_config = None 65 | self.m_use_system_config = use_system_config 66 | self.m_config_cleanup = [] 67 | self.m_logger = logging.getLogger('activedirectory.core.creds') 68 | 69 | def __del__(self): 70 | """Destructor. This releases all currently held credentials and cleans 71 | up temporary files.""" 72 | self.release() 73 | 74 | def load(self): 75 | """Load credentials from the OS.""" 76 | ccache = krb5.cc_default() 77 | if not os.access(ccache, os.R_OK): 78 | raise Error('No ccache found') 79 | self.m_principal = krb5.cc_get_principal(ccache) 80 | self._init_ccache() 81 | krb5.cc_copy_creds(ccache, self.m_ccache) 82 | self._activate_ccache() 83 | self._resolve_servers_for_domain(self.m_domain) 84 | 85 | def acquire(self, principal, password=None, keytab=None, server=None): 86 | """Acquire credentials for `principal'. 87 | 88 | The `principal' argument specifies the principal for which to acquire 89 | credentials. If it is not a qualified pricipal in the form of 90 | principal@domain, then the default domain is assumed. 91 | 92 | If `password' is given, it is the password to the principal. 93 | Otherwise, `keytab' specifies the keytab to use. The `keytab' argument 94 | can either specify the absolute path name of a file, or can be None 95 | (the default) which uses the system-specific default keytab. 96 | """ 97 | if '@' in principal: 98 | principal, domain = principal.split('@') 99 | domain = domain.upper() 100 | else: 101 | domain = self.m_domain 102 | principal = '%s@%s' % (principal, domain) 103 | self._init_ccache() 104 | self._activate_ccache() 105 | if not self.m_use_system_config: 106 | if server is None: 107 | self._resolve_servers_for_domain(domain) 108 | else: 109 | self._set_servers_for_domain(domain, [server]) 110 | try: 111 | if password is not None: 112 | krb5.get_init_creds_password(principal, password) 113 | else: 114 | krb5.get_init_creds_keytab(principal, keytab) 115 | except krb5.Error as err: 116 | raise Error(str(err)) 117 | self.m_principal = principal 118 | 119 | def release(self): 120 | """Release all credentials.""" 121 | self._release_ccache() 122 | self._release_config() 123 | self.m_principal = None 124 | 125 | def principal(self): 126 | """Return the current principal.""" 127 | return self.m_principal 128 | 129 | def _ccache_name(self): 130 | """Return the Kerberos credential cache file name.""" 131 | return self.m_ccache 132 | 133 | def _config_name(self): 134 | """Return the Kerberos config file name.""" 135 | return self.m_config 136 | 137 | def _init_ccache(self): 138 | """Initialize Kerberos ccache.""" 139 | if self.m_ccache: 140 | return 141 | fd, fname = tempfile.mkstemp() 142 | os.close(fd) 143 | self.m_ccache = fname 144 | 145 | def _activate_ccache(self): 146 | """Active our private credential cache.""" 147 | assert self.m_ccache is not None 148 | orig = self._environ('KRB5CCNAME') 149 | if orig != self.m_ccache: 150 | self._set_environ('KRB5CCNAME', self.m_ccache) 151 | self.c_ccache_stack[self.m_ccache] = (True, orig) 152 | 153 | def _release_ccache(self): 154 | """Release the current Kerberos configuration.""" 155 | if not self.m_ccache: 156 | return 157 | # Things are complicated by the fact that multiple instances of this 158 | # class my exist. Therefore we need to keep track whether we have set 159 | # the current $KRB5CCNAME or someone else. If it is ourselves we are 160 | # fine, but if not we need to mark that the class who replaced our 161 | # value should not point back to us when that class releases its 162 | # credentials because we are releasing those credentials now. 163 | assert self.m_ccache in self.c_ccache_stack 164 | active, orig = self.c_ccache_stack[self.m_ccache] 165 | assert active 166 | ccache = self._environ('KRB5CCNAME') 167 | if ccache == self.m_ccache: 168 | while True: 169 | active, orig = self.c_ccache_stack[ccache] 170 | del self.c_ccache_stack[ccache] 171 | if orig not in self.c_ccache_stack or \ 172 | self.c_ccache_stack[orig][0]: 173 | self._set_environ('KRB5CCNAME', orig) 174 | break 175 | ccache = orig 176 | else: 177 | self.c_ccache_stack[self.m_ccache] = (False, orig) 178 | try: 179 | os.remove(self.m_ccache) 180 | except OSError: 181 | pass 182 | self.m_ccache = None 183 | 184 | def _resolve_servers_for_domain(self, domain, force=False): 185 | """Resolve domain controllers for a domain.""" 186 | if self.m_use_system_config: 187 | return 188 | if domain in self.m_domains and not force: 189 | return 190 | locator = factory(Locator) 191 | result = locator.locate_many(domain) 192 | if not result: 193 | m = 'No suitable domain controllers found for %s' % domain 194 | raise Error(m) 195 | self.m_domains[domain] = list(result) 196 | # Re-init every time 197 | self._init_config() 198 | self._write_config() 199 | self._activate_config() 200 | 201 | def _set_servers_for_domain(self, domain, servers): 202 | """Set the servers to use for `domain'.""" 203 | if self.m_use_system_config: 204 | return 205 | self.m_domains[domain] = servers 206 | self._init_config() 207 | self._write_config() 208 | self._activate_config() 209 | 210 | def _init_config(self): 211 | """Initialize Kerberos config.""" 212 | if self.m_config and self.m_config in self.c_config_stack: 213 | # Delete current config and create one under a new file name. This 214 | # seems to be required by the Kerberos libraries that do not 215 | # reload the configuration file otherwise. 216 | active, orig = self.c_config_stack[self.m_config] 217 | self.c_config_stack[self.m_config] = (False, orig) 218 | # unlink this config file on activate of new config 219 | self.m_config_cleanup.append(self.m_config) 220 | fd, fname = tempfile.mkstemp() 221 | os.close(fd) 222 | self.m_config = fname 223 | 224 | def _supported_enctypes(self): 225 | """Return a string of supported encryption types.""" 226 | if self.c_supported_enctypes is not None: 227 | return self.c_supported_enctypes 228 | result = [] 229 | for enctype in self.c_enctypes: 230 | if krb5.c_valid_enctype(enctype): 231 | result.append(enctype) 232 | self.c_supported_enctypes = result 233 | self.m_logger.info('using encryption types: %s' % ' '.join(result)) 234 | return result 235 | 236 | def _write_config(self): 237 | """Write the Kerberos configuration file.""" 238 | assert self.m_config is not None 239 | ftmp = '%s.%d-tmp' % (self.m_config, os.getpid()) 240 | fout = open(ftmp, 'w') 241 | enctypes = ' '.join(self._supported_enctypes()) 242 | try: 243 | fout.write('# krb5.conf generated by Python-AD at %s\n' % 244 | time.asctime()) 245 | fout.write('[libdefaults]\n') 246 | fout.write(' default_realm = %s\n' % self.m_domain) 247 | fout.write(' dns_lookup_kdc = false\n') 248 | fout.write(' default_tgs_enctypes = %s\n' % enctypes) 249 | fout.write(' default_tkt_enctypes = %s\n' % enctypes) 250 | if compat.disable_reverse_dns(): 251 | fout.write(' rdns = no\n') 252 | fout.write('[realms]\n') 253 | for domain in self.m_domains: 254 | fout.write(' %s = {\n' % domain) 255 | for server in self.m_domains[domain]: 256 | fout.write(' kdc = %s:%d\n' % (server, KERBEROS_PORT)) 257 | fout.write(' kpasswd_server = %s:%d\n' 258 | % (server, KPASSWD_PORT)) 259 | fout.write(' }\n') 260 | fout.close() 261 | os.rename(ftmp, self.m_config) 262 | finally: 263 | try: 264 | os.remove(ftmp) 265 | except OSError: 266 | pass 267 | 268 | def _activate_config(self): 269 | """Activate the Kerberos config.""" 270 | if self.m_use_system_config: 271 | return 272 | assert self.m_config is not None 273 | orig = self._environ('KRB5_CONFIG') 274 | if orig != self.m_config: 275 | self._set_environ('KRB5_CONFIG', self.m_config) 276 | self.c_config_stack[self.m_config] = (True, orig) 277 | for fname in self.m_config_cleanup: 278 | try: 279 | os.remove(fname) 280 | except OSError: 281 | pass 282 | self.m_config_cleanup = [] 283 | 284 | def _release_config(self): 285 | """Release the current Kerberos configuration.""" 286 | if self.m_use_system_config or not self.m_config: 287 | return 288 | # See the comments with _release_ccache(). 289 | assert self.m_config in self.c_config_stack 290 | active, orig = self.c_config_stack[self.m_config] 291 | assert active 292 | config = self._environ('KRB5_CONFIG') 293 | if config == self.m_config: 294 | while True: 295 | active, orig = self.c_config_stack[config] 296 | del self.c_config_stack[config] 297 | if orig not in self.c_config_stack or \ 298 | self.c_config_stack[orig][0]: 299 | self._set_environ('KRB5_CONFIG', orig) 300 | break 301 | config = orig 302 | else: 303 | self.c_config_stack[self.m_config] = (False, orig) 304 | try: 305 | os.remove(self.m_config) 306 | except OSError: 307 | pass 308 | self.m_config = None 309 | 310 | def _environ(self, name): 311 | """Return an environment variable or None in case it doesn't exist.""" 312 | return os.environ.get(name) 313 | 314 | def _set_environ(self, name, value): 315 | """Set or delete an environment variable.""" 316 | if value is None: 317 | try: 318 | del os.environ[name] 319 | except KeyError: 320 | pass 321 | else: 322 | os.environ[name] = value 323 | -------------------------------------------------------------------------------- /tests/core/test_client.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | import pytest 10 | 11 | from activedirectory.core.object import activate 12 | from activedirectory.core.client import Client 13 | from activedirectory.core.locate import Locator 14 | from activedirectory.core.constant import AD_USERCTRL_NORMAL_ACCOUNT 15 | from activedirectory.core.creds import Creds 16 | from activedirectory.core.exception import Error as ADError 17 | 18 | from ..base import assert_raises 19 | from . import utils 20 | 21 | 22 | class TestADClient(object): 23 | """Test suite for ADClient""" 24 | 25 | def test_search(self, conf): 26 | pytest.skip('test disabled: hanging') 27 | conf.require(ad_user=True) 28 | domain = conf.domain() 29 | creds = Creds(domain) 30 | creds.acquire(conf.ad_user_account(), conf.ad_user_password()) 31 | activate(creds) 32 | client = Client(domain) 33 | result = client.search('(objectClass=user)') 34 | assert len(result) > 1 35 | 36 | def test_add(self, conf): 37 | conf.require(ad_admin=True) 38 | domain = conf.domain() 39 | creds = Creds(domain) 40 | creds.acquire(conf.ad_admin_account(), conf.ad_admin_password()) 41 | activate(creds) 42 | client = Client(domain) 43 | user = utils.create_user(client, 'test-usr') 44 | delete_obj(client, user) 45 | 46 | def test_delete(self, conf): 47 | conf.require(ad_admin=True) 48 | domain = conf.domain() 49 | creds = Creds(domain) 50 | creds.acquire(conf.ad_admin_account(), conf.ad_admin_password()) 51 | activate(creds) 52 | client = Client(domain) 53 | dn = utils.create_user(client, 'test-usr') 54 | client.delete(dn) 55 | 56 | def test_modify(self, conf): 57 | conf.require(ad_admin=True) 58 | domain = conf.domain() 59 | creds = Creds(domain) 60 | creds.acquire(conf.ad_admin_account(), conf.ad_admin_password()) 61 | activate(creds) 62 | client = Client(domain) 63 | user = utils.create_user(client, 'test-usr') 64 | mods = [] 65 | mods.append(('replace', 'sAMAccountName', ['test-usr-2'])) 66 | client.modify(user, mods) 67 | delete_obj(client, user) 68 | 69 | def test_modrdn(self, conf): 70 | conf.require(ad_admin=True) 71 | domain = conf.domain() 72 | creds = Creds(domain) 73 | creds.acquire(conf.ad_admin_account(), conf.ad_admin_password()) 74 | activate(creds) 75 | client = Client(domain) 76 | result = client.search('(&(objectClass=user)(sAMAccountName=test-usr))') 77 | if result: 78 | client.delete(result[0][0]) 79 | user = utils.create_user(client, 'test-usr') 80 | client.modrdn(user, 'cn=test-usr2') 81 | result = client.search('(&(objectClass=user)(cn=test-usr2))') 82 | assert len(result) == 1 83 | 84 | def test_rename(self, conf): 85 | conf.require(ad_admin=True) 86 | domain = conf.domain() 87 | creds = Creds(domain) 88 | creds.acquire(conf.ad_admin_account(), conf.ad_admin_password()) 89 | activate(creds) 90 | client = Client(domain) 91 | result = client.search('(&(objectClass=user)(sAMAccountName=test-usr))') 92 | if result: 93 | client.delete(result[0][0]) 94 | user = utils.create_user(client, 'test-usr') 95 | client.rename(user, 'cn=test-usr2') 96 | result = client.search('(&(objectClass=user)(cn=test-usr2))') 97 | assert len(result) == 1 98 | user = result[0][0] 99 | ou = utils.create_ou(client, 'test-ou') 100 | client.rename(user, 'cn=test-usr', ou) 101 | newdn = 'cn=test-usr,%s' % ou 102 | result = client.search('(&(objectClass=user)(cn=test-usr))') 103 | assert len(result) == 1 104 | assert result[0][0].lower() == newdn.lower() 105 | 106 | def test_forest(self, conf): 107 | conf.require(ad_user=True) 108 | domain = conf.domain() 109 | creds = Creds(domain) 110 | creds.acquire(conf.ad_user_account(), conf.ad_user_password()) 111 | activate(creds) 112 | client = Client(domain) 113 | forest = client.forest() 114 | assert forest 115 | assert forest.isupper() 116 | 117 | def test_domains(self, conf): 118 | conf.require(ad_user=True) 119 | domain = conf.domain() 120 | creds = Creds(domain) 121 | creds.acquire(conf.ad_user_account(), conf.ad_user_password()) 122 | activate(creds) 123 | client = Client(domain) 124 | domains = client.domains() 125 | for domain in domains: 126 | assert domain 127 | assert domain.isupper() 128 | 129 | def test_naming_contexts(self, conf): 130 | conf.require(ad_user=True) 131 | domain = conf.domain() 132 | creds = Creds(domain) 133 | creds.acquire(conf.ad_user_account(), conf.ad_user_password()) 134 | activate(creds) 135 | client = Client(domain) 136 | naming_contexts = client.naming_contexts() 137 | assert len(naming_contexts) >= 3 138 | 139 | def test_search_all_domains(self, conf): 140 | conf.require(ad_user=True) 141 | domain = conf.domain() 142 | creds = Creds(domain) 143 | creds.acquire(conf.ad_user_account(), conf.ad_user_password()) 144 | activate(creds) 145 | client = Client(domain) 146 | domains = client.domains() 147 | for domain in domains: 148 | base = client.dn_from_domain_name(domain) 149 | result = client.search('(objectClass=*)', base=base, scope='base') 150 | assert len(result) == 1 151 | 152 | def test_search_schema(self, conf): 153 | conf.require(ad_user=True) 154 | domain = conf.domain() 155 | creds = Creds(domain) 156 | creds.acquire(conf.ad_user_account(), conf.ad_user_password()) 157 | activate(creds) 158 | client = Client(domain) 159 | base = client.schema_base() 160 | result = client.search('(objectClass=*)', base=base, scope='base') 161 | assert len(result) == 1 162 | 163 | def test_search_configuration(self, conf): 164 | conf.require(ad_user=True) 165 | domain = conf.domain() 166 | creds = Creds(domain) 167 | creds.acquire(conf.ad_user_account(), conf.ad_user_password()) 168 | activate(creds) 169 | client = Client(domain) 170 | base = client.configuration_base() 171 | result = client.search('(objectClass=*)', base=base, scope='base') 172 | assert len(result) == 1 173 | 174 | def test_incremental_retrieval_of_multivalued_attributes(self, conf): 175 | conf.require(ad_admin=True, expensive=True) 176 | domain = conf.domain() 177 | creds = Creds(domain) 178 | creds.acquire(conf.ad_admin_account(), conf.ad_admin_password()) 179 | activate(creds) 180 | client = Client(domain) 181 | user = utils.create_user(client, 'test-usr') 182 | groups = [] 183 | for i in range(2000): 184 | group = utils.create_group(client, 'test-grp-%04d' % i) 185 | utils.add_user_to_group(client, user, group) 186 | groups.append(group) 187 | result = client.search('(sAMAccountName=test-usr)') 188 | assert len(result) == 1 189 | dn, attrs = result[0] 190 | assert 'memberOf' in attrs 191 | assert len(attrs['memberOf']) == 2000 192 | delete_obj(client, user) 193 | for group in groups: 194 | utils.delete_group(client, group) 195 | 196 | def test_paged_results(self, conf): 197 | conf.require(ad_admin=True, expensive=True) 198 | domain = conf.domain() 199 | creds = Creds(domain) 200 | creds.acquire(conf.ad_admin_account(), conf.ad_admin_password()) 201 | activate(creds) 202 | client = Client(domain) 203 | users = [] 204 | for i in range(2000): 205 | user = utils.create_user(client, 'test-usr-%04d' % i) 206 | users.append(user) 207 | result = client.search('(cn=test-usr-*)') 208 | assert len(result) == 2000 209 | for user in users: 210 | delete_obj(client, user) 211 | 212 | def test_search_rootdse(self, conf): 213 | conf.require(ad_user=True) 214 | domain = conf.domain() 215 | creds = Creds(domain) 216 | creds.acquire(conf.ad_user_account(), conf.ad_user_password()) 217 | activate(creds) 218 | locator = Locator() 219 | server = locator.locate(domain) 220 | client = Client(domain) 221 | result = client.search(base='', scope='base', server=server) 222 | assert len(result) == 1 223 | dns, attrs = result[0] 224 | assert 'supportedControl' in attrs 225 | assert 'supportedSASLMechanisms' in attrs 226 | 227 | def test_search_server(self, conf): 228 | conf.require(ad_user=True) 229 | domain = conf.domain() 230 | creds = Creds(domain) 231 | creds.acquire(conf.ad_user_account(), conf.ad_user_password()) 232 | activate(creds) 233 | locator = Locator() 234 | server = locator.locate(domain) 235 | client = Client(domain) 236 | result = client.search('(objectClass=user)', server=server) 237 | assert len(result) > 1 238 | 239 | def test_search_gc(self, conf): 240 | conf.require(ad_user=True) 241 | domain = conf.domain() 242 | creds = Creds(domain) 243 | creds.acquire(conf.ad_user_account(), conf.ad_user_password()) 244 | activate(creds) 245 | client = Client(domain) 246 | result = client.search('(objectClass=user)', scheme='gc') 247 | assert len(result) > 1 248 | for res in result: 249 | dn, attrs = res 250 | # accountExpires is always set, but is not a GC attribute 251 | assert 'accountExpires' not in attrs 252 | 253 | def test_set_password(self, conf): 254 | conf.require(ad_admin=True) 255 | domain = conf.domain() 256 | creds = Creds(domain) 257 | creds.acquire(conf.ad_admin_account(), conf.ad_admin_password()) 258 | activate(creds) 259 | client = Client(domain) 260 | user = utils.create_user(client, 'test-usr-1') 261 | principal = 'test-usr-1@%s' % domain 262 | client.set_password(principal, 'Pass123') 263 | mods = [] 264 | ctrl = AD_USERCTRL_NORMAL_ACCOUNT 265 | mods.append(('replace', 'userAccountControl', [str(ctrl)])) 266 | client.modify(user, mods) 267 | creds = Creds(domain) 268 | creds.acquire('test-usr-1', 'Pass123') 269 | assert_raises(ADError, creds.acquire, 'test-usr-1', 'Pass321') 270 | delete_obj(client, user) 271 | 272 | def test_set_password_target_pdc(self, conf): 273 | conf.require(ad_admin=True) 274 | domain = conf.domain() 275 | creds = Creds(domain) 276 | creds.acquire(conf.ad_admin_account(), conf.ad_admin_password()) 277 | activate(creds) 278 | client = Client(domain) 279 | locator = Locator() 280 | pdc = locator.locate(domain, role='pdc') 281 | user = utils.create_user(client, 'test-usr-2', server=pdc) 282 | principal = 'test-usr-2@%s' % domain 283 | client.set_password(principal, 'Pass123', server=pdc) 284 | mods = [] 285 | ctrl = AD_USERCTRL_NORMAL_ACCOUNT 286 | mods.append(('replace', 'userAccountControl', [str(ctrl)])) 287 | client.modify(user, mods, server=pdc) 288 | creds = Creds(domain) 289 | creds.acquire('test-usr-2', 'Pass123', server=pdc) 290 | assert_raises(ADError, creds.acquire, 'test-usr-2','Pass321', server=pdc) 291 | delete_obj(client, user, server=pdc) 292 | 293 | def test_change_password(self, conf): 294 | conf.require(ad_admin=True) 295 | domain = conf.domain() 296 | creds = Creds(domain) 297 | creds.acquire(conf.ad_admin_account(), conf.ad_admin_password()) 298 | activate(creds) 299 | client = Client(domain) 300 | user = utils.create_user(client, 'test-usr-3') 301 | principal = 'test-usr-3@%s' % domain 302 | client.set_password(principal, 'Pass123') 303 | mods = [] 304 | ctrl = AD_USERCTRL_NORMAL_ACCOUNT 305 | mods.append(('replace', 'userAccountControl', [str(ctrl)])) 306 | mods.append(('replace', 'pwdLastSet', ['0'])) 307 | client.modify(user, mods) 308 | client.change_password(principal, 'Pass123', 'Pass456') 309 | creds = Creds(domain) 310 | creds.acquire('test-usr-3', 'Pass456') 311 | assert_raises(ADError, creds.acquire, 'test-usr-3', 'Pass321') 312 | delete_obj(client, user) 313 | 314 | def test_change_password_target_pdc(self, conf): 315 | conf.require(ad_admin=True) 316 | domain = conf.domain() 317 | creds = Creds(domain) 318 | creds.acquire(conf.ad_admin_account(), conf.ad_admin_password()) 319 | activate(creds) 320 | client = Client(domain) 321 | locator = Locator() 322 | pdc = locator.locate(domain, role='pdc') 323 | user = utils.create_user(client, 'test-usr-4', server=pdc) 324 | principal = 'test-usr-4@%s' % domain 325 | client.set_password(principal, 'Pass123', server=pdc) 326 | mods = [] 327 | ctrl = AD_USERCTRL_NORMAL_ACCOUNT 328 | mods.append(('replace', 'userAccountControl', [str(ctrl)])) 329 | mods.append(('replace', 'pwdLastSet', ['0'])) 330 | client.modify(user, mods, server=pdc) 331 | client.change_password(principal, 'Pass123', 'Pass456', server=pdc) 332 | creds = Creds(domain) 333 | creds.acquire('test-usr-4', 'Pass456', server=pdc) 334 | assert_raises(ADError, creds.acquire, 'test-usr-4', 'Pass321', server=pdc) 335 | delete_obj(client, user, server=pdc) 336 | -------------------------------------------------------------------------------- /lib/activedirectory/core/locate.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007 by the Python-AD authors. See the file 7 | # "AUTHORS" for a complete overview. 8 | 9 | import time 10 | import random 11 | import logging 12 | 13 | import ldap 14 | import dns.resolver 15 | import dns.reversename 16 | import dns.exception 17 | 18 | from ..protocol import netlogon 19 | from ..protocol.netlogon import Client as NetlogonClient 20 | from .exception import Error as ADError 21 | from ..util import compat 22 | 23 | 24 | LDAP_PORT = 389 25 | KERBEROS_PORT = 88 26 | KPASSWD_PORT = 464 27 | 28 | 29 | def ensure_str(s): 30 | """ 31 | Coerce *s* to str 32 | """ 33 | if isinstance(s, bytes): 34 | return s.decode('utf-8', 'strict') 35 | elif isinstance(s, str): 36 | return s 37 | else: 38 | raise TypeError("not expecting type '%s'" % type(s)) 39 | 40 | 41 | class Locator(object): 42 | """Locate domain controllers. 43 | 44 | The function of is class is to locate, select, and order domain 45 | controllers for a given domain. 46 | 47 | The default selection mechanism discards domain controllers that do not 48 | have a proper reverse DNS name set. These domain controllers are not 49 | usable with SASL/GSSAPI that uses hostname canonicalisation based on 50 | reverse DNS. 51 | 52 | The default ordering mechanism has two different policies: 53 | 54 | - For local domain controllers we order on the priority and weight that 55 | has been configured for the SRV records. 56 | - For remote domain controllers we order on timing only and we ignore 57 | priority and weight. This may or may not be what you want. From my 58 | experience, priorities and weights are often not set up at all. In this 59 | situation is is preferable to use timing information to order domain 60 | controllers. 61 | 62 | Both policies (selection and ordering) can be changed by subclassing this 63 | class. 64 | """ 65 | 66 | _maxservers = 3 67 | _timeout = 300 # cache entries for 5 minutes 68 | 69 | def __init__(self, site=None, resolver=None, resolve_hostnames=False): 70 | """Constructor.""" 71 | self.m_site = site 72 | self.m_site_detected = False 73 | self.m_logger = logging.getLogger('activedirectory.core.locate') 74 | self.m_cache = {} 75 | self.m_timeout = self._timeout 76 | self.m_resolver = resolver or dns.resolver.get_default_resolver() 77 | self.m_resolve_hostnames = resolve_hostnames 78 | 79 | def locate(self, domain, role=None): 80 | """Locate one domain controller.""" 81 | servers = self.locate_many(domain, role, maxservers=1) 82 | if not servers: 83 | m = 'Could not locate domain controller' 84 | raise ADError(m) 85 | return servers[0] 86 | 87 | def locate_many(self, domain, role=None, maxservers=None): 88 | """Locate a list of up to `maxservers' of domain controllers.""" 89 | result = self.locate_many_ex(domain, role, maxservers) 90 | result = [ ensure_str(r.hostname) for r in result ] 91 | return result 92 | 93 | def locate_many_ex(self, domain, role=None, maxservers=None): 94 | """Like locate_many(), but returns a list of netlogon.Reply objects 95 | instead.""" 96 | if role is None: 97 | role = 'dc' 98 | if maxservers is None: 99 | maxservers = self._maxservers 100 | if role not in ('dc', 'gc', 'pdc'): 101 | raise ValueError('Role should be one of "dc", "gc" or "pdc".') 102 | if role == 'pdc': 103 | maxservers = 1 104 | domain = domain.upper() 105 | self.m_logger.debug('locating domain controllers for %s (role %s)' % 106 | (domain, role)) 107 | key = (domain, role) 108 | if key in self.m_cache: 109 | stamp, nrequested, servers = self.m_cache[key] 110 | now = time.time() 111 | if now - stamp < self._timeout and nrequested >= maxservers: 112 | self.m_logger.debug('domain controllers found in cache') 113 | return servers 114 | self.m_logger.debug('domain controllers not in cache, going to network') 115 | servers = [] 116 | candidates = [] 117 | if self.m_site is None and not self.m_site_detected: 118 | self.m_site = self._detect_site(domain) 119 | self.m_site_detected = True 120 | if self.m_site and role != 'pdc': 121 | query = '_ldap._tcp.%s._sites.%s._msdcs.%s' % \ 122 | (self.m_site, role, domain.lower()) 123 | answer = self._dns_query(query, 'SRV') 124 | candidates += self._order_dns_srv(answer) 125 | query = '_ldap._tcp.%s._msdcs.%s' % (role, domain.lower()) 126 | answer = self._dns_query(query, 'SRV') 127 | candidates += self._order_dns_srv(answer) 128 | addresses = self._extract_addresses_from_srv(candidates) 129 | addresses = self._remove_duplicates(addresses) 130 | replies = [] 131 | netlogon = NetlogonClient() 132 | for i in range(0, len(addresses), maxservers): 133 | for addr in addresses[i:i+maxservers]: 134 | addr = (addr[0], LDAP_PORT) # in case we queried for GC 135 | netlogon.query(addr, domain) 136 | replies += netlogon.call() 137 | if self._sufficient_domain_controllers(replies, role, maxservers): 138 | break 139 | servers = self._select_domain_controllers(replies, role, maxservers, 140 | addresses) 141 | self.m_logger.debug('found %d domain controllers' % len(servers)) 142 | 143 | if self.m_resolve_hostnames: 144 | for srv in servers: 145 | hostname = srv.hostname.decode('utf-8') 146 | try: 147 | address = self._dns_query(hostname, 'A')[0].address 148 | except IndexError: 149 | continue 150 | else: 151 | srv.hostname = address.encode('utf-8') 152 | 153 | now = time.time() 154 | self.m_cache[key] = (now, maxservers, servers) 155 | return servers 156 | 157 | def check_domain_controller(self, server, domain, role): 158 | """Ensure that `server' is a domain controller for `domain' and has 159 | role `role'. 160 | """ 161 | addr = (server, LDAP_PORT) 162 | client = NetlogonClient() 163 | client.query(addr, domain.upper()) 164 | result = client.call() 165 | if len(result) != 1: 166 | return False 167 | reply = result[0] 168 | result = self._check_domain_controller(reply, role) 169 | return result 170 | 171 | def _dns_query(self, query, type): 172 | """Perform a DNS query.""" 173 | self.m_logger.debug('DNS query %s type %s' % (query, type)) 174 | try: 175 | answer = self.m_resolver.query(query, type) 176 | except dns.exception.DNSException as err: 177 | answer = [] 178 | self.m_logger.error('DNS query error: %s' % (str(err) or err.__doc__)) 179 | else: 180 | self.m_logger.debug('DNS query returned %d results' % len(answer)) 181 | return answer 182 | 183 | def _detect_site(self, domain): 184 | """Detect our site using the netlogon protocol.""" 185 | self.m_logger.debug('detecting site') 186 | query = '_ldap._tcp.%s' % domain.lower() 187 | answer = self._dns_query(query, 'SRV') 188 | servers = self._order_dns_srv(answer) 189 | addresses = self._extract_addresses_from_srv(servers) 190 | replies = [] 191 | netlogon = NetlogonClient() 192 | for i in range(0, len(addresses), 3): 193 | for addr in addresses[i:i+3]: 194 | self.m_logger.debug('NetLogon query to %s' % addr[0]) 195 | netlogon.query(addr, domain) 196 | replies += netlogon.call() 197 | self.m_logger.debug('%d replies' % len(replies)) 198 | if len(replies) >= 3: 199 | break 200 | if not replies: 201 | self.m_logger.error('could not detect site') 202 | return 203 | sites = {} 204 | for reply in replies: 205 | try: 206 | sites[reply.client_site] += 1 207 | except KeyError: 208 | sites[reply.client_site] = 1 209 | sites = [ (value, key) for key,value in sites.items() ] 210 | sites.sort() 211 | self.m_logger.debug('site detected as %s' % sites[-1][1]) 212 | return ensure_str(sites[0][1]) 213 | 214 | def _order_dns_srv(self, answer): 215 | """Order the results of a DNS SRV query.""" 216 | answer = list(answer) 217 | answer.sort(key=lambda x: x.priority) 218 | result = [] 219 | for i in range(len(answer)): 220 | if i == 0: 221 | low = i 222 | prio = answer[i].priority 223 | if i > 0 and answer[i].priority != prio: 224 | result += self._srv_weighted_shuffle(answer[low:i]) 225 | low = i 226 | prio = answer[i].priority 227 | elif i == len(answer)-1: 228 | result += self._srv_weighted_shuffle(answer[low:]) 229 | return result 230 | 231 | def _srv_weighted_shuffle(self, answer): 232 | """Do a weighted shuffle on the SRV query result `result'.""" 233 | result = [] 234 | for i in range(len(answer)): 235 | total = 0 236 | cumulative = [] 237 | for j in range(len(answer)): 238 | total += answer[j].weight 239 | cumulative.append((total, j)) 240 | rnd = random.randrange(0, total) 241 | for j in range(len(answer)): 242 | if rnd < cumulative[j][0]: 243 | k = cumulative[j][1] 244 | result.append(answer[k]) 245 | del answer[k] 246 | break 247 | return result 248 | 249 | def _extract_addresses_from_srv(self, answer): 250 | """Extract IP addresses from a DNS SRV query answer.""" 251 | result = [ (a.target.to_text(), a.port) for a in answer ] 252 | return result 253 | 254 | def _remove_duplicates(self, servers): 255 | """Remove duplicates for `servers', keeping the order.""" 256 | dict = {} 257 | result = [] 258 | for srv in servers: 259 | if srv not in dict: 260 | result.append(srv) 261 | dict[srv] = True 262 | return result 263 | 264 | def _check_domain_controller(self, reply, role): 265 | """Check that `server' is a domain controller for `domain' and has 266 | role `role'. 267 | """ 268 | self.m_logger.debug('Checking controller %s for domain %s role %s' % 269 | (reply.q_hostname, reply.q_domain, role)) 270 | answer = self._dns_query(reply.q_hostname, 'A') 271 | if len(answer) != 1: 272 | self.m_logger.error('Forward DNS returned %d entries (need 1)' % 273 | len(answer)) 274 | return False 275 | address = answer[0].address 276 | if not compat.disable_reverse_dns(): 277 | revname = dns.reversename.from_address(address) 278 | answer = self._dns_query(revname, 'PTR') 279 | if len(answer) != 1: 280 | self.m_logger.error('Reverse DNS returned %d entries (need 1)' 281 | % len(answer)) 282 | return False 283 | hostname = answer[0].target.to_text() 284 | answer = self._dns_query(hostname, 'A') 285 | if len(answer) != 1: 286 | self.m_logger.error('Second fwd DNS returned %d entries (need 1)' 287 | % len(answer)) 288 | return False 289 | if answer[0].address != address: 290 | self.m_logger.error('Second forward DNS does not match first') 291 | return False 292 | if role == 'gc' and not (reply.flags & netlogon.SERVER_GC) or \ 293 | role == 'pdc' and not (reply.flags & netlogon.SERVER_PDC) or \ 294 | role == 'dc' and not (reply.flags & netlogon.SERVER_LDAP): 295 | self.m_logger.error('Role does not match') 296 | return False 297 | if reply.q_domain.lower() != ensure_str(reply.domain).lower(): 298 | self.m_logger.error('Domain does not match') 299 | return False 300 | self.m_logger.debug('Controller is OK') 301 | return True 302 | 303 | def _sufficient_domain_controllers(self, replies, role, maxservers): 304 | """Return True if there are sufficient domain controllers in `replies' 305 | to satisfy `maxservers'.""" 306 | total = 0 307 | for reply in replies: 308 | if not hasattr(reply, 'checked'): 309 | checked = self._check_domain_controller(reply, role) 310 | reply.checked = checked 311 | if reply.checked: 312 | total += 1 313 | return total >= maxservers 314 | 315 | def _select_domain_controllers(self, replies, role, maxservers, addresses): 316 | """Select up to `maxservers' domain controllers from `replies'. The 317 | `addresses' argument is the ordered list of addresses from DNS SRV 318 | resolution. It can be used to obtain SRV ordering information. 319 | """ 320 | local = [] 321 | remote = [] 322 | for reply in replies: 323 | assert hasattr(reply, 'checked') 324 | if not reply.checked: 325 | continue 326 | if self.m_site.lower() == ensure_str(reply.server_site).lower(): 327 | local.append(reply) 328 | else: 329 | remote.append(reply) 330 | local.sort(key=lambda a: addresses.index((a.q_hostname, a.q_port))) 331 | remote.sort(key=lambda a: a.q_timing) 332 | self.m_logger.debug('Local DCs: %s' % ', '.join(['%s:%s' % 333 | (x.q_hostname, x.q_port) for x in local])) 334 | self.m_logger.debug('Remote DCs: %s' % ', '.join(['%s:%s' % 335 | (x.q_hostname, x.q_port) for x in remote])) 336 | result = local + remote 337 | result = result[:maxservers] 338 | self.m_logger.debug('Selected DCs: %s' % ', '.join(['%s:%s' % 339 | (x.q_hostname, x.q_port) for x in result])) 340 | return result 341 | -------------------------------------------------------------------------------- /lib/activedirectory/protocol/krb5.c: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of FreeADI. FreeADI is free software that is made 3 | * available under the MIT license. Consult the file "LICENSE" that is 4 | * distributed together with this file for the exact licensing terms. 5 | * 6 | * FreeADI is copyright (c) 2007 by the FreeADI authors. See the file 7 | * "AUTHORS" for a complete overview. 8 | */ 9 | 10 | #include 11 | #include 12 | 13 | 14 | static PyObject *k5_error; 15 | 16 | #define RETURN_ON_ERROR(message, code) \ 17 | do if (code != 0) \ 18 | { \ 19 | const char *error; \ 20 | error = krb5_get_error_message(ctx, code); \ 21 | PyErr_Format(k5_error, "%s: %s", message, error); \ 22 | krb5_free_error_message(ctx, error); \ 23 | return NULL; \ 24 | } while (0) 25 | 26 | 27 | static PyObject * 28 | k5_get_init_creds_password(PyObject *self, PyObject *args) 29 | { 30 | char *name, *password; 31 | krb5_context ctx; 32 | krb5_error_code code; 33 | krb5_ccache ccache; 34 | krb5_principal principal; 35 | krb5_get_init_creds_opt options; 36 | krb5_creds creds; 37 | 38 | if (!PyArg_ParseTuple(args, "ss", &name, &password)) 39 | return NULL; 40 | 41 | /* Initialize parameters. */ 42 | code = krb5_init_context(&ctx); 43 | RETURN_ON_ERROR("krb5_init_context()", code); 44 | code = krb5_parse_name(ctx, name, &principal); 45 | RETURN_ON_ERROR("krb5_parse_name()", code); 46 | krb5_get_init_creds_opt_init(&options); 47 | memset(&creds, 0, sizeof (creds)); 48 | 49 | /* Get the credentials. */ 50 | code = krb5_get_init_creds_password(ctx, &creds, principal, password, 51 | NULL, NULL, 0, NULL, &options); 52 | RETURN_ON_ERROR("krb5_get_init_creds_password()", code); 53 | 54 | /* Store the credential in the credential cache. */ 55 | code = krb5_cc_default(ctx, &ccache); 56 | RETURN_ON_ERROR("krb5_cc_default()", code); 57 | code = krb5_cc_initialize(ctx, ccache, principal); 58 | RETURN_ON_ERROR("krb5_cc_initialize()", code); 59 | code = krb5_cc_store_cred(ctx, ccache, &creds); 60 | RETURN_ON_ERROR("krb5_cc_store_creds()", code); 61 | krb5_cc_close(ctx, ccache); 62 | 63 | Py_INCREF(Py_None); 64 | return Py_None; 65 | } 66 | 67 | 68 | static PyObject * 69 | k5_get_init_creds_keytab(PyObject *self, PyObject *args) 70 | { 71 | char *name, *ktname; 72 | krb5_context ctx; 73 | krb5_error_code code; 74 | krb5_keytab keytab; 75 | krb5_ccache ccache; 76 | krb5_principal principal; 77 | krb5_get_init_creds_opt options; 78 | krb5_creds creds; 79 | 80 | if (!PyArg_ParseTuple(args, "sz", &name, &ktname)) 81 | return NULL; 82 | 83 | /* Initialize parameters. */ 84 | code = krb5_init_context(&ctx); 85 | RETURN_ON_ERROR("krb5_init_context()", code); 86 | code = krb5_parse_name(ctx, name, &principal); 87 | RETURN_ON_ERROR("krb5_parse_name()", code); 88 | krb5_get_init_creds_opt_init(&options); 89 | memset(&creds, 0, sizeof (creds)); 90 | 91 | /* Resolve keytab */ 92 | if (ktname) 93 | { 94 | code = krb5_kt_resolve(ctx, ktname, &keytab); 95 | RETURN_ON_ERROR("krb5_kt_resolve()", code); 96 | } else 97 | { 98 | code = krb5_kt_default(ctx, &keytab); 99 | RETURN_ON_ERROR("krb5_kt_resolve()", code); 100 | } 101 | 102 | /* Get the credentials. */ 103 | code = krb5_get_init_creds_keytab(ctx, &creds, principal, 104 | keytab, 0, NULL, &options); 105 | RETURN_ON_ERROR("krb5_get_init_creds_keytab()", code); 106 | 107 | /* Store the credential in the credential cache. */ 108 | code = krb5_cc_default(ctx, &ccache); 109 | RETURN_ON_ERROR("krb5_cc_default()", code); 110 | code = krb5_cc_initialize(ctx, ccache, principal); 111 | RETURN_ON_ERROR("krb5_cc_initialize()", code); 112 | code = krb5_cc_store_cred(ctx, ccache, &creds); 113 | RETURN_ON_ERROR("krb5_cc_store_creds()", code); 114 | krb5_cc_close(ctx, ccache); 115 | 116 | Py_INCREF(Py_None); 117 | return Py_None; 118 | } 119 | 120 | 121 | static void 122 | _k5_set_password_error(krb5_data *result_code_string, krb5_data *result_string) 123 | { 124 | char *p1, *p2; 125 | 126 | p1 = malloc(result_code_string->length+1); 127 | if (p1 == NULL) 128 | { 129 | PyErr_NoMemory(); 130 | return; 131 | } 132 | if (result_code_string->data) 133 | { 134 | strncpy(p1, result_code_string->data, result_code_string->length); 135 | } 136 | p1[result_code_string->length] = '\000'; 137 | 138 | p2 = malloc(result_string->length+1); 139 | if (p1 == NULL) 140 | { 141 | PyErr_NoMemory(); 142 | return; 143 | } 144 | if (result_string->data) 145 | { 146 | strncpy(p1, result_string->data, result_string->length); 147 | } 148 | p2[result_string->length] = '\000'; 149 | 150 | PyErr_Format(k5_error, "%s%s%s", p1, (*p1 && *p2) ? ": " : "", p2); 151 | 152 | free(p1); 153 | free(p2); 154 | } 155 | 156 | 157 | static PyObject * 158 | k5_set_password(PyObject *self, PyObject *args) 159 | { 160 | int result_code; 161 | char *name, *newpass; 162 | krb5_context ctx; 163 | krb5_error_code code; 164 | krb5_principal principal; 165 | krb5_data result_code_string, result_string; 166 | krb5_ccache ccache; 167 | 168 | if (!PyArg_ParseTuple(args, "ss", &name, &newpass)) 169 | return NULL; 170 | 171 | /* Initialize parameters. */ 172 | code = krb5_init_context(&ctx); 173 | RETURN_ON_ERROR("krb5_init_context()", code); 174 | code = krb5_parse_name(ctx, name, &principal); 175 | RETURN_ON_ERROR("krb5_parse_name()", code); 176 | 177 | /* Get credentials */ 178 | code = krb5_cc_default(ctx, &ccache); 179 | RETURN_ON_ERROR("krb5_cc_default()", code); 180 | 181 | /* Set password */ 182 | code = krb5_set_password_using_ccache(ctx, ccache, newpass, principal, 183 | &result_code, &result_code_string, 184 | &result_string); 185 | RETURN_ON_ERROR("krb5_set_password_using_ccache()", code); 186 | 187 | /* Any other error? */ 188 | if (result_code != 0) 189 | { 190 | _k5_set_password_error(&result_code_string, &result_string); 191 | return NULL; 192 | } 193 | 194 | /* Free up results. */ 195 | if (result_code_string.data != NULL) 196 | free(result_code_string.data); 197 | if (result_string.data != NULL) 198 | free(result_string.data); 199 | 200 | Py_INCREF(Py_None); 201 | return Py_None; 202 | } 203 | 204 | 205 | static PyObject * 206 | k5_change_password(PyObject *self, PyObject *args) 207 | { 208 | int result_code; 209 | char *name, *oldpass, *newpass; 210 | krb5_context ctx; 211 | krb5_error_code code; 212 | krb5_principal principal; 213 | krb5_get_init_creds_opt options; 214 | krb5_creds creds; 215 | krb5_data result_code_string, result_string; 216 | 217 | if (!PyArg_ParseTuple(args, "sss", &name, &oldpass, &newpass)) 218 | return NULL; 219 | 220 | /* Initialize parameters. */ 221 | code = krb5_init_context(&ctx); 222 | RETURN_ON_ERROR("krb5_init_context()", code); 223 | code = krb5_parse_name(ctx, name, &principal); 224 | RETURN_ON_ERROR("krb5_parse_name()", code); 225 | 226 | /* Get credentials using the password. */ 227 | krb5_get_init_creds_opt_init(&options); 228 | krb5_get_init_creds_opt_set_tkt_life(&options, 5*60); 229 | krb5_get_init_creds_opt_set_renew_life(&options, 0); 230 | krb5_get_init_creds_opt_set_forwardable(&options, 0); 231 | krb5_get_init_creds_opt_set_proxiable(&options, 0); 232 | memset(&creds, 0, sizeof (creds)); 233 | code = krb5_get_init_creds_password(ctx, &creds, principal, oldpass, 234 | NULL, NULL, 0, "kadmin/changepw", 235 | &options); 236 | RETURN_ON_ERROR("krb5_get_init_creds_password()", code); 237 | 238 | code = krb5_change_password(ctx, &creds, newpass, &result_code, 239 | &result_code_string, &result_string); 240 | RETURN_ON_ERROR("krb5_change_password()", code); 241 | 242 | /* Any other error? */ 243 | if (result_code != 0) 244 | { 245 | _k5_set_password_error(&result_code_string, &result_string); 246 | return NULL; 247 | } 248 | 249 | /* Free up results. */ 250 | if (result_code_string.data != NULL) 251 | free(result_code_string.data); 252 | if (result_string.data != NULL) 253 | free(result_string.data); 254 | 255 | Py_INCREF(Py_None); 256 | return Py_None; 257 | } 258 | 259 | 260 | static PyObject * 261 | k5_cc_default(PyObject *self, PyObject *args) 262 | { 263 | krb5_context ctx; 264 | krb5_error_code code; 265 | krb5_ccache ccache; 266 | const char *name; 267 | PyObject *ret; 268 | 269 | code = krb5_init_context(&ctx); 270 | RETURN_ON_ERROR("krb5_init_context()", code); 271 | code = krb5_cc_default(ctx, &ccache); 272 | RETURN_ON_ERROR("krb5_cc_default()", code); 273 | name = krb5_cc_get_name(ctx, ccache); 274 | if (name == NULL) 275 | { 276 | PyErr_Format(k5_error, "krb5_cc_default() returned NULL"); 277 | return NULL; 278 | } 279 | 280 | ret = PyUnicode_FromString(name); 281 | 282 | if (ret == NULL) 283 | return ret; 284 | 285 | code = krb5_cc_close(ctx, ccache); 286 | RETURN_ON_ERROR("krb5_cc_close()", code); 287 | krb5_free_context(ctx); 288 | 289 | return ret; 290 | } 291 | 292 | static PyObject * 293 | k5_cc_copy_creds(PyObject *self, PyObject *args) 294 | { 295 | krb5_context ctx; 296 | char *namein, *nameout; 297 | krb5_error_code code; 298 | krb5_ccache ccin, ccout; 299 | krb5_principal principal; 300 | 301 | if (!PyArg_ParseTuple( args, "ss", &namein, &nameout)) 302 | return NULL; 303 | 304 | code = krb5_init_context(&ctx); 305 | RETURN_ON_ERROR("krb5_init_context()", code); 306 | code = krb5_cc_resolve(ctx, namein, &ccin); 307 | RETURN_ON_ERROR("krb5_cc_resolve()", code); 308 | code = krb5_cc_get_principal(ctx, ccin, &principal); 309 | RETURN_ON_ERROR("krb5_cc_get_principal()", code); 310 | 311 | code = krb5_cc_resolve(ctx, nameout, &ccout); 312 | RETURN_ON_ERROR("krb5_cc_resolve()", code); 313 | code = krb5_cc_initialize(ctx, ccout, principal); 314 | RETURN_ON_ERROR("krb5_cc_get_initialize()", code); 315 | code = krb5_cc_copy_creds(ctx, ccin, ccout); 316 | RETURN_ON_ERROR("krb5_cc_copy_creds()", code); 317 | 318 | code = krb5_cc_close(ctx, ccin); 319 | RETURN_ON_ERROR("krb5_cc_close()", code); 320 | code = krb5_cc_close(ctx, ccout); 321 | RETURN_ON_ERROR("krb5_cc_close()", code); 322 | krb5_free_principal(ctx, principal); 323 | krb5_free_context(ctx); 324 | 325 | Py_INCREF(Py_None); 326 | return Py_None; 327 | } 328 | 329 | 330 | static PyObject * 331 | k5_cc_get_principal(PyObject *self, PyObject *args) 332 | { 333 | krb5_context ctx; 334 | char *ccname, *name; 335 | krb5_error_code code; 336 | krb5_ccache ccache; 337 | krb5_principal principal; 338 | PyObject *ret; 339 | 340 | if (!PyArg_ParseTuple( args, "s", &ccname)) 341 | return NULL; 342 | 343 | code = krb5_init_context(&ctx); 344 | RETURN_ON_ERROR("krb5_init_context()", code); 345 | code = krb5_cc_resolve(ctx, ccname, &ccache); 346 | RETURN_ON_ERROR("krb5_cc_resolve()", code); 347 | code = krb5_cc_get_principal(ctx, ccache, &principal); 348 | RETURN_ON_ERROR("krb5_cc_get_principal()", code); 349 | code = krb5_unparse_name(ctx, principal, &name); 350 | RETURN_ON_ERROR("krb5_unparse_name()", code); 351 | 352 | ret = PyUnicode_FromString(name); 353 | 354 | if (ret == NULL) 355 | return ret; 356 | 357 | code = krb5_cc_close(ctx, ccache); 358 | RETURN_ON_ERROR("krb5_cc_close()", code); 359 | krb5_free_unparsed_name(ctx, name); 360 | krb5_free_principal(ctx, principal); 361 | krb5_free_context(ctx); 362 | 363 | return ret; 364 | } 365 | 366 | 367 | static PyObject * 368 | k5_c_valid_enctype(PyObject *self, PyObject *args) 369 | { 370 | char *name; 371 | krb5_context ctx; 372 | krb5_enctype type; 373 | krb5_error_code code; 374 | krb5_boolean valid; 375 | PyObject *ret; 376 | 377 | if (!PyArg_ParseTuple( args, "s", &name)) 378 | return NULL; 379 | 380 | code = krb5_init_context(&ctx); 381 | RETURN_ON_ERROR("krb5_init_context()", code); 382 | code = krb5_string_to_enctype(name, &type); 383 | RETURN_ON_ERROR("krb5_string_to_enctype()", code); 384 | valid = krb5_c_valid_enctype(type); 385 | ret = PyBool_FromLong((long) valid); 386 | krb5_free_context(ctx); 387 | 388 | return ret; 389 | } 390 | 391 | struct module_state { 392 | PyObject *error; 393 | }; 394 | 395 | #define GETSTATE(m) ((struct module_state*)PyModule_GetState(m)) 396 | 397 | 398 | static PyMethodDef k5_methods[] = 399 | { 400 | { "get_init_creds_password", 401 | (PyCFunction) k5_get_init_creds_password, METH_VARARGS }, 402 | { "get_init_creds_keytab", 403 | (PyCFunction) k5_get_init_creds_keytab, METH_VARARGS }, 404 | { "set_password", 405 | (PyCFunction) k5_set_password, METH_VARARGS }, 406 | { "change_password", 407 | (PyCFunction) k5_change_password, METH_VARARGS }, 408 | { "cc_default", 409 | (PyCFunction) k5_cc_default, METH_VARARGS }, 410 | { "cc_copy_creds", 411 | (PyCFunction) k5_cc_copy_creds, METH_VARARGS }, 412 | { "cc_get_principal", 413 | (PyCFunction) k5_cc_get_principal, METH_VARARGS }, 414 | { "c_valid_enctype", 415 | (PyCFunction) k5_c_valid_enctype, METH_VARARGS }, 416 | { NULL, NULL } 417 | }; 418 | 419 | 420 | static int k5_traverse(PyObject *m, visitproc visit, void *arg) { 421 | Py_VISIT(GETSTATE(m)->error); 422 | return 0; 423 | } 424 | 425 | static int k5_clear(PyObject *m) { 426 | Py_CLEAR(GETSTATE(m)->error); 427 | return 0; 428 | } 429 | 430 | 431 | static struct PyModuleDef moduledef = { 432 | PyModuleDef_HEAD_INIT, 433 | "krb5", 434 | NULL, 435 | sizeof(struct module_state), 436 | k5_methods, 437 | NULL, 438 | k5_traverse, 439 | k5_clear, 440 | NULL 441 | }; 442 | 443 | #define INITERROR return NULL 444 | 445 | PyMODINIT_FUNC 446 | PyInit_krb5(void) 447 | { 448 | PyObject *module, *dict; 449 | 450 | #if !defined(__APPLE__) || !defined(__MACH__) 451 | initialize_krb5_error_table(); 452 | #endif 453 | 454 | module = PyModule_Create(&moduledef); 455 | 456 | if (module == NULL) 457 | INITERROR; 458 | 459 | dict = PyModule_GetDict(module); 460 | 461 | struct module_state *st = GETSTATE(module); 462 | k5_error = st->error = PyErr_NewException("freeadi.protocol.krb5.Error", NULL, NULL); 463 | 464 | PyDict_SetItemString(dict, "Error", st->error); 465 | 466 | return module; 467 | 468 | } 469 | -------------------------------------------------------------------------------- /lib/activedirectory/protocol/asn1.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007-2008 by the Python-AD authors. See the 7 | # file "AUTHORS" for a complete overview. 8 | 9 | import re 10 | import struct 11 | 12 | Boolean = 0x01 13 | Integer = 0x02 14 | OctetString = 0x04 15 | Null = 0x05 16 | ObjectIdentifier = 0x06 17 | Enumerated = 0x0a 18 | Sequence = 0x10 19 | Set = 0x11 20 | 21 | TypeConstructed = 0x20 22 | TypePrimitive = 0x00 23 | 24 | ClassUniversal = 0x00 25 | ClassApplication = 0x40 26 | ClassContext = 0x80 27 | ClassPrivate = 0xc0 28 | 29 | int2byte = struct.Struct(">B").pack 30 | 31 | 32 | class Error(Exception): 33 | """ASN1 error""" 34 | 35 | 36 | class Encoder(object): 37 | """A ASN.1 encoder. Uses DER encoding.""" 38 | 39 | def __init__(self): 40 | """Constructor.""" 41 | self.m_stack = None 42 | 43 | def start(self): 44 | """Start encoding.""" 45 | self.m_stack = [[]] 46 | 47 | def enter(self, nr, cls=None): 48 | """Start a constructed data value.""" 49 | if self.m_stack is None: 50 | raise Error('Encoder not initialized. Call start() first.') 51 | if cls is None: 52 | cls = ClassUniversal 53 | self._emit_tag(nr, TypeConstructed, cls) 54 | self.m_stack.append([]) 55 | 56 | def leave(self): 57 | """Finish a constructed data value.""" 58 | if self.m_stack is None: 59 | raise Error('Encoder not initialized. Call start() first.') 60 | if len(self.m_stack) == 1: 61 | raise Error('Tag stack is empty.') 62 | value = b''.join(self.m_stack[-1]) 63 | del self.m_stack[-1] 64 | self._emit_length(len(value)) 65 | self._emit(value) 66 | 67 | def write(self, value, nr=None, typ=None, cls=None): 68 | """Write a primitive data value.""" 69 | if self.m_stack is None: 70 | raise Error('Encoder not initialized. Call start() first.') 71 | if nr is None: 72 | if isinstance(value, int): 73 | nr = Integer 74 | elif isinstance(value, str): 75 | nr = OctetString 76 | if isinstance(value, str): 77 | value = value.encode('utf-8') 78 | elif value is None: 79 | nr = Null 80 | if typ is None: 81 | typ = TypePrimitive 82 | if cls is None: 83 | cls = ClassUniversal 84 | value = self._encode_value(nr, value) 85 | self._emit_tag(nr, typ, cls) 86 | self._emit_length(len(value)) 87 | self._emit(value) 88 | 89 | def output(self): 90 | """Return the encoded output.""" 91 | if self.m_stack is None: 92 | raise Error('Encoder not initialized. Call start() first.') 93 | if len(self.m_stack) != 1: 94 | raise Error('Stack is not empty.') 95 | output = b''.join(self.m_stack[0]) 96 | return output 97 | 98 | def _emit_tag(self, nr, typ, cls): 99 | """Emit a tag.""" 100 | if nr < 31: 101 | self._emit_tag_short(nr, typ, cls) 102 | else: 103 | self._emit_tag_long(nr, typ, cls) 104 | 105 | def _emit_tag_short(self, nr, typ, cls): 106 | """Emit a short (< 31 bytes) tag.""" 107 | assert nr < 31 108 | self._emit(int2byte(nr | typ | cls)) 109 | 110 | def _emit_tag_long(self, nr, typ, cls): 111 | """Emit a long (>= 31 bytes) tag.""" 112 | head = int2byte(typ | cls | 0x1f) 113 | self._emit(head) 114 | values = [] 115 | values.append((nr & 0x7f)) 116 | nr >>= 7 117 | while nr: 118 | values.append((nr & 0x7f) | 0x80) 119 | nr >>= 7 120 | values.reverse() 121 | values = list(map(int2byte, values)) 122 | for val in values: 123 | self._emit(val) 124 | 125 | def _emit_length(self, length): 126 | """Emit length octects.""" 127 | if length < 128: 128 | self._emit_length_short(length) 129 | else: 130 | self._emit_length_long(length) 131 | 132 | def _emit_length_short(self, length): 133 | """Emit the short length form (< 128 octets).""" 134 | assert length < 128 135 | self._emit(int2byte(length)) 136 | 137 | def _emit_length_long(self, length): 138 | """Emit the long length form (>= 128 octets).""" 139 | values = [] 140 | while length: 141 | values.append(length & 0xff) 142 | length >>= 8 143 | values.reverse() 144 | values = list(map(int2byte, values)) 145 | # really for correctness as this should not happen anytime soon 146 | assert len(values) < 127 147 | head = int2byte(0x80 | len(values)) 148 | self._emit(head) 149 | for val in values: 150 | self._emit(val) 151 | 152 | def _emit(self, s): 153 | """Emit raw bytes.""" 154 | assert isinstance(s, bytes) 155 | self.m_stack[-1].append(s) 156 | 157 | def _encode_value(self, nr, value): 158 | """Encode a value.""" 159 | if nr in (Integer, Enumerated): 160 | value = self._encode_integer(value) 161 | elif nr == OctetString: 162 | value = self._encode_octet_string(value) 163 | elif nr == Boolean: 164 | value = self._encode_boolean(value) 165 | elif nr == Null: 166 | value = self._encode_null() 167 | elif nr == ObjectIdentifier: 168 | value = self._encode_object_identifier(value) 169 | return value 170 | 171 | def _encode_boolean(self, value): 172 | """Encode a boolean.""" 173 | return value and b'\xff' or b'\x00' 174 | 175 | def _encode_integer(self, value): 176 | """Encode an integer.""" 177 | if value < 0: 178 | value = -value 179 | negative = True 180 | limit = 0x80 181 | else: 182 | negative = False 183 | limit = 0x7f 184 | values = [] 185 | while value > limit: 186 | values.append(value & 0xff) 187 | value >>= 8 188 | values.append(value & 0xff) 189 | if negative: 190 | # create two's complement 191 | for i in range(len(values)): 192 | values[i] = 0xff - values[i] 193 | for i in range(len(values)): 194 | values[i] += 1 195 | if values[i] <= 0xff: 196 | break 197 | assert i != len(values)-1 198 | values[i] = 0x00 199 | values.reverse() 200 | values = list(map(int2byte, values)) 201 | return b''.join(values) 202 | 203 | def _encode_octet_string(self, value): 204 | """Encode an octetstring.""" 205 | # Use the primitive encoding 206 | return value 207 | 208 | def _encode_null(self): 209 | """Encode a Null value.""" 210 | return b'' 211 | 212 | _re_oid = re.compile(r'^[0-9]+(\.[0-9]+)+$') 213 | 214 | def _encode_object_identifier(self, oid): 215 | """Encode an object identifier.""" 216 | if not self._re_oid.match(oid): 217 | raise Error('Illegal object identifier') 218 | cmps = list(map(int, oid.split('.'))) 219 | if cmps[0] > 39 or cmps[1] > 39: 220 | raise Error('Illegal object identifier') 221 | cmps = [40 * cmps[0] + cmps[1]] + cmps[2:] 222 | cmps.reverse() 223 | result = [] 224 | for cmp in cmps: 225 | result.append(cmp & 0x7f) 226 | while cmp > 0x7f: 227 | cmp >>= 7 228 | result.append(0x80 | (cmp & 0x7f)) 229 | result.reverse() 230 | result = list(map(int2byte, result)) 231 | return b''.join(result) 232 | 233 | 234 | class Decoder(object): 235 | """A ASN.1 decoder. Understands BER (and DER which is a subset).""" 236 | 237 | def __init__(self): 238 | """Constructor.""" 239 | self.m_stack = None 240 | self.m_tag = None 241 | 242 | def start(self, data): 243 | """Start processing `data'.""" 244 | if not isinstance(data, bytes): 245 | raise Error('Expecting bytes instance.') 246 | self.m_stack = [[0, data]] 247 | self.m_tag = None 248 | 249 | def peek(self): 250 | """Return the value of the next tag without moving to the next 251 | TLV record.""" 252 | if self.m_stack is None: 253 | raise Error('No input selected. Call start() first.') 254 | if self._end_of_input(): 255 | return None 256 | if self.m_tag is None: 257 | self.m_tag = self._read_tag() 258 | return self.m_tag 259 | 260 | def read(self): 261 | """Read a simple value and move to the next TLV record.""" 262 | if self.m_stack is None: 263 | raise Error('No input selected. Call start() first.') 264 | if self._end_of_input(): 265 | return None 266 | tag = self.peek() 267 | length = self._read_length() 268 | value = self._read_value(tag[0], length) 269 | self.m_tag = None 270 | return (tag, value) 271 | 272 | def eof(self): 273 | """Return True if we are end of input.""" 274 | return self._end_of_input() 275 | 276 | def enter(self): 277 | """Enter a constructed tag.""" 278 | if self.m_stack is None: 279 | raise Error('No input selected. Call start() first.') 280 | nr, typ, cls = self.peek() 281 | if typ != TypeConstructed: 282 | raise Error('Cannot enter a non-constructed tag.') 283 | length = self._read_length() 284 | bytes = self._read_bytes(length) 285 | self.m_stack.append([0, bytes]) 286 | self.m_tag = None 287 | 288 | def leave(self): 289 | """Leave the last entered constructed tag.""" 290 | if self.m_stack is None: 291 | raise Error('No input selected. Call start() first.') 292 | if len(self.m_stack) == 1: 293 | raise Error('Tag stack is empty.') 294 | del self.m_stack[-1] 295 | self.m_tag = None 296 | 297 | def _decode_boolean(self, bytes): 298 | """Decode a boolean value.""" 299 | if len(bytes) != 1: 300 | raise Error('ASN1 syntax error') 301 | byte = bytes[0] 302 | if isinstance(byte, str): 303 | byte = ord(byte) 304 | return (byte != 0) 305 | 306 | def _read_tag(self): 307 | """Read a tag from the input.""" 308 | byte = self._read_byte() 309 | cls = byte & 0xc0 310 | typ = byte & 0x20 311 | nr = byte & 0x1f 312 | if nr == 0x1f: 313 | nr = 0 314 | while True: 315 | byte = self._read_byte() 316 | nr = (nr << 7) | (byte & 0x7f) 317 | if not byte & 0x80: 318 | break 319 | return (nr, typ, cls) 320 | 321 | def _read_length(self): 322 | """Read a length from the input.""" 323 | byte = self._read_byte() 324 | if byte & 0x80: 325 | count = byte & 0x7f 326 | if count == 0x7f: 327 | raise Error('ASN1 syntax error') 328 | bytes = self._read_bytes(count) 329 | bytes = [ b for b in bytes ] 330 | length = 0 331 | for byte in bytes: 332 | if isinstance(byte, str): 333 | byte = ord(byte) 334 | length = (length << 8) | byte 335 | try: 336 | length = int(length) 337 | except OverflowError: 338 | pass 339 | else: 340 | length = byte 341 | return length 342 | 343 | def _read_value(self, nr, length): 344 | """Read a value from the input.""" 345 | bytes = self._read_bytes(length) 346 | if nr == Boolean: 347 | value = self._decode_boolean(bytes) 348 | elif nr in (Integer, Enumerated): 349 | value = self._decode_integer(bytes) 350 | elif nr == OctetString: 351 | value = self._decode_octet_string(bytes) 352 | elif nr == Null: 353 | value = self._decode_null(bytes) 354 | elif nr == ObjectIdentifier: 355 | value = self._decode_object_identifier(bytes) 356 | else: 357 | value = bytes 358 | return value 359 | 360 | def _read_byte(self): 361 | """Return the next input byte, or raise an error on end-of-input.""" 362 | index, input = self.m_stack[-1] 363 | try: 364 | byte = input[index] 365 | except IndexError: 366 | raise Error('Premature end of input.') 367 | self.m_stack[-1][0] += 1 368 | if isinstance(byte, str): 369 | byte = ord(byte) 370 | return byte 371 | 372 | def _read_bytes(self, count): 373 | """Return the next `count' bytes of input. Raise error on 374 | end-of-input.""" 375 | index, input = self.m_stack[-1] 376 | bytes = input[index:index+count] 377 | if len(bytes) != count: 378 | raise Error('Premature end of input.') 379 | self.m_stack[-1][0] += count 380 | return bytes 381 | 382 | def _end_of_input(self): 383 | """Return True if we are at the end of input.""" 384 | index, input = self.m_stack[-1] 385 | assert not index > len(input) 386 | return index == len(input) 387 | 388 | def _decode_integer(self, bytes): 389 | """Decode an integer value.""" 390 | values = [b for b in bytes] 391 | 392 | # check if the integer is normalized 393 | if len(values) > 1 and \ 394 | (values[0] == 0xff and values[1] & 0x80 or 395 | values[0] == 0x00 and not (values[1] & 0x80)): 396 | raise Error('ASN1 syntax error') 397 | negative = values[0] & 0x80 398 | if negative: 399 | # make positive by taking two's complement 400 | for i in range(len(values)): 401 | values[i] = 0xff - values[i] 402 | for i in range(len(values)-1, -1, -1): 403 | values[i] += 1 404 | if values[i] <= 0xff: 405 | break 406 | assert i > 0 407 | values[i] = 0x00 408 | value = 0 409 | for val in values: 410 | value = (value << 8) | val 411 | if negative: 412 | value = -value 413 | try: 414 | value = int(value) 415 | except OverflowError: 416 | pass 417 | return value 418 | 419 | def _decode_octet_string(self, bytes): 420 | """Decode an octet string.""" 421 | return bytes 422 | 423 | def _decode_null(self, bytes): 424 | """Decode a Null value.""" 425 | if len(bytes) != 0: 426 | raise Error('ASN1 syntax error') 427 | return None 428 | 429 | def _decode_object_identifier(self, bytes): 430 | """Decode an object identifier.""" 431 | result = [] 432 | value = 0 433 | for i in range(len(bytes)): 434 | byte = bytes[i] 435 | if isinstance(byte, str): 436 | byte = ord(byte) 437 | if value == 0 and byte == 0x80: 438 | raise Error('ASN1 syntax error') 439 | value = (value << 7) | (byte & 0x7f) 440 | if not byte & 0x80: 441 | result.append(value) 442 | value = 0 443 | if len(result) == 0 or result[0] > 1599: 444 | raise Error('ASN1 syntax error') 445 | result = [result[0] // 40, result[0] % 40] + result[1:] 446 | result = [str(r).encode('utf-8') for r in result] 447 | return b'.'.join(result) 448 | -------------------------------------------------------------------------------- /tests/protocol/test_asn1.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Python-AD. Python-AD is free software that is made 3 | # available under the MIT license. Consult the file "LICENSE" that is 4 | # distributed together with this file for the exact licensing terms. 5 | # 6 | # Python-AD is copyright (c) 2007-2008 by the Python-AD authors. See the 7 | # file "AUTHORS" for a complete overview. 8 | 9 | from activedirectory.protocol import asn1 10 | 11 | from ..base import assert_raises 12 | 13 | 14 | class TestEncoder(object): 15 | """Test suite for ASN1 Encoder.""" 16 | 17 | def test_boolean(self): 18 | enc = asn1.Encoder() 19 | enc.start() 20 | enc.write(True, asn1.Boolean) 21 | res = enc.output() 22 | assert res == b'\x01\x01\xff' 23 | 24 | def test_integer(self): 25 | enc = asn1.Encoder() 26 | enc.start() 27 | enc.write(1) 28 | res = enc.output() 29 | assert res == b'\x02\x01\x01' 30 | 31 | def test_long_integer(self): 32 | enc = asn1.Encoder() 33 | enc.start() 34 | enc.write(0x0102030405060708090a0b0c0d0e0f) 35 | res = enc.output() 36 | assert res == b'\x02\x0f\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f' 37 | 38 | def test_negative_integer(self): 39 | enc = asn1.Encoder() 40 | enc.start() 41 | enc.write(-1) 42 | res = enc.output() 43 | assert res == b'\x02\x01\xff' 44 | 45 | def test_long_negative_integer(self): 46 | enc = asn1.Encoder() 47 | enc.start() 48 | enc.write(-0x0102030405060708090a0b0c0d0e0f) 49 | res = enc.output() 50 | assert res == b'\x02\x0f\xfe\xfd\xfc\xfb\xfa\xf9\xf8\xf7\xf6\xf5\xf4\xf3\xf2\xf1\xf1' 51 | 52 | def test_twos_complement_boundaries(self): 53 | enc = asn1.Encoder() 54 | enc.start() 55 | enc.write(127) 56 | res = enc.output() 57 | assert res == b'\x02\x01\x7f' 58 | enc.start() 59 | enc.write(128) 60 | res = enc.output() 61 | assert res == b'\x02\x02\x00\x80' 62 | enc.start() 63 | enc.write(-128) 64 | res = enc.output() 65 | assert res == b'\x02\x01\x80' 66 | enc.start() 67 | enc.write(-129) 68 | res = enc.output() 69 | assert res == b'\x02\x02\xff\x7f' 70 | 71 | def test_octet_string(self): 72 | enc = asn1.Encoder() 73 | enc.start() 74 | enc.write('foo') 75 | res = enc.output() 76 | assert res == b'\x04\x03foo' 77 | 78 | def test_null(self): 79 | enc = asn1.Encoder() 80 | enc.start() 81 | enc.write(None) 82 | res = enc.output() 83 | assert res == b'\x05\x00' 84 | 85 | def test_object_identifier(self): 86 | enc = asn1.Encoder() 87 | enc.start() 88 | enc.write('1.2.3', asn1.ObjectIdentifier) 89 | res = enc.output() 90 | assert res == b'\x06\x02\x2a\x03' 91 | 92 | def test_long_object_identifier(self): 93 | enc = asn1.Encoder() 94 | enc.start() 95 | enc.write('39.2.3', asn1.ObjectIdentifier) 96 | res = enc.output() 97 | assert res == b'\x06\x03\x8c\x1a\x03' 98 | enc.start() 99 | enc.write('1.39.3', asn1.ObjectIdentifier) 100 | res = enc.output() 101 | assert res == b'\x06\x02\x4f\x03' 102 | enc.start() 103 | enc.write('1.2.300000', asn1.ObjectIdentifier) 104 | res = enc.output() 105 | assert res == b'\x06\x04\x2a\x92\xa7\x60' 106 | 107 | def test_real_object_identifier(self): 108 | enc = asn1.Encoder() 109 | enc.start() 110 | enc.write('1.2.840.113554.1.2.1.1', asn1.ObjectIdentifier) 111 | res = enc.output() 112 | assert res == b'\x06\x0a\x2a\x86\x48\x86\xf7\x12\x01\x02\x01\x01' 113 | 114 | def test_enumerated(self): 115 | enc = asn1.Encoder() 116 | enc.start() 117 | enc.write(1, asn1.Enumerated) 118 | res = enc.output() 119 | assert res == b'\x0a\x01\x01' 120 | 121 | def test_sequence(self): 122 | enc = asn1.Encoder() 123 | enc.start() 124 | enc.enter(asn1.Sequence) 125 | enc.write(1) 126 | enc.write('foo') 127 | enc.leave() 128 | res = enc.output() 129 | assert res == b'\x30\x08\x02\x01\x01\x04\x03foo' 130 | 131 | def test_sequence_of(self): 132 | enc = asn1.Encoder() 133 | enc.start() 134 | enc.enter(asn1.Sequence) 135 | enc.write(1) 136 | enc.write(2) 137 | enc.leave() 138 | res = enc.output() 139 | assert res == b'\x30\x06\x02\x01\x01\x02\x01\x02' 140 | 141 | def test_set(self): 142 | enc = asn1.Encoder() 143 | enc.start() 144 | enc.enter(asn1.Set) 145 | enc.write(1) 146 | enc.write('foo') 147 | enc.leave() 148 | res = enc.output() 149 | assert res == b'\x31\x08\x02\x01\x01\x04\x03foo' 150 | 151 | def test_set_of(self): 152 | enc = asn1.Encoder() 153 | enc.start() 154 | enc.enter(asn1.Set) 155 | enc.write(1) 156 | enc.write(2) 157 | enc.leave() 158 | res = enc.output() 159 | assert res == b'\x31\x06\x02\x01\x01\x02\x01\x02' 160 | 161 | def test_context(self): 162 | enc = asn1.Encoder() 163 | enc.start() 164 | enc.enter(1, asn1.ClassContext) 165 | enc.write(1) 166 | enc.leave() 167 | res = enc.output() 168 | assert res == b'\xa1\x03\x02\x01\x01' 169 | 170 | def test_application(self): 171 | enc = asn1.Encoder() 172 | enc.start() 173 | enc.enter(1, asn1.ClassApplication) 174 | enc.write(1) 175 | enc.leave() 176 | res = enc.output() 177 | assert res == b'\x61\x03\x02\x01\x01' 178 | 179 | def test_private(self): 180 | enc = asn1.Encoder() 181 | enc.start() 182 | enc.enter(1, asn1.ClassPrivate) 183 | enc.write(1) 184 | enc.leave() 185 | res = enc.output() 186 | assert res == b'\xe1\x03\x02\x01\x01' 187 | 188 | def test_long_tag_id(self): 189 | enc = asn1.Encoder() 190 | enc.start() 191 | enc.enter(0xffff) 192 | enc.write(1) 193 | enc.leave() 194 | res = enc.output() 195 | assert res == b'\x3f\x83\xff\x7f\x03\x02\x01\x01' 196 | 197 | def test_long_tag_length(self): 198 | enc = asn1.Encoder() 199 | enc.start() 200 | enc.write('x' * 0xffff) 201 | res = enc.output() 202 | assert res == b'\x04\x82\xff\xff' + b'x' * 0xffff 203 | 204 | def test_error_init(self): 205 | enc = asn1.Encoder() 206 | assert_raises(asn1.Error, enc.enter, asn1.Sequence) 207 | assert_raises(asn1.Error, enc.leave) 208 | assert_raises(asn1.Error, enc.write, 1) 209 | assert_raises(asn1.Error, enc.output) 210 | 211 | def test_error_stack(self): 212 | enc = asn1.Encoder() 213 | enc.start() 214 | assert_raises(asn1.Error, enc.leave) 215 | enc.enter(asn1.Sequence) 216 | assert_raises(asn1.Error, enc.output) 217 | enc.leave() 218 | assert_raises(asn1.Error, enc.leave) 219 | 220 | def test_error_object_identifier(self): 221 | enc = asn1.Encoder() 222 | enc.start() 223 | assert_raises(asn1.Error, enc.write, '1', asn1.ObjectIdentifier) 224 | assert_raises(asn1.Error, enc.write, '40.2.3', asn1.ObjectIdentifier) 225 | assert_raises(asn1.Error, enc.write, '1.40.3', asn1.ObjectIdentifier) 226 | assert_raises(asn1.Error, enc.write, '1.2.3.', asn1.ObjectIdentifier) 227 | assert_raises(asn1.Error, enc.write, '.1.2.3', asn1.ObjectIdentifier) 228 | assert_raises(asn1.Error, enc.write, 'foo', asn1.ObjectIdentifier) 229 | assert_raises(asn1.Error, enc.write, 'foo.bar', asn1.ObjectIdentifier) 230 | 231 | 232 | class TestDecoder(object): 233 | """Test suite for ASN1 Decoder.""" 234 | 235 | def test_boolean(self): 236 | buf = b'\x01\x01\xff' 237 | dec = asn1.Decoder() 238 | dec.start(buf) 239 | tag = dec.peek() 240 | assert tag == (asn1.Boolean, asn1.TypePrimitive, asn1.ClassUniversal) 241 | tag, val = dec.read() 242 | assert isinstance(val, int) 243 | assert val == True 244 | buf = b'\x01\x01\x01' 245 | dec.start(buf) 246 | tag, val = dec.read() 247 | assert isinstance(val, int) 248 | assert val == True 249 | buf = b'\x01\x01\x00' 250 | dec.start(buf) 251 | tag, val = dec.read() 252 | assert isinstance(val, int) 253 | assert val == False 254 | 255 | def test_integer(self): 256 | buf = b'\x02\x01\x01' 257 | dec = asn1.Decoder() 258 | dec.start(buf) 259 | tag = dec.peek() 260 | assert tag == (asn1.Integer, asn1.TypePrimitive, asn1.ClassUniversal) 261 | tag, val = dec.read() 262 | assert isinstance(val, int) 263 | assert val == 1 264 | 265 | def test_long_integer(self): 266 | buf = b'\x02\x0f\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f' 267 | dec = asn1.Decoder() 268 | dec.start(buf) 269 | tag, val = dec.read() 270 | assert val == 0x0102030405060708090a0b0c0d0e0f 271 | 272 | def test_negative_integer(self): 273 | buf = b'\x02\x01\xff' 274 | dec = asn1.Decoder() 275 | dec.start(buf) 276 | tag, val = dec.read() 277 | assert val == -1 278 | 279 | def test_long_negative_integer(self): 280 | buf = b'\x02\x0f\xfe\xfd\xfc\xfb\xfa\xf9\xf8\xf7\xf6\xf5\xf4\xf3\xf2\xf1\xf1' 281 | dec = asn1.Decoder() 282 | dec.start(buf) 283 | tag, val = dec.read() 284 | assert val == -0x0102030405060708090a0b0c0d0e0f 285 | 286 | def test_twos_complement_boundaries(self): 287 | buf = b'\x02\x01\x7f' 288 | dec = asn1.Decoder() 289 | dec.start(buf) 290 | tag, val = dec.read() 291 | assert val == 127 292 | buf = b'\x02\x02\x00\x80' 293 | dec.start(buf) 294 | tag, val = dec.read() 295 | assert val == 128 296 | buf = b'\x02\x01\x80' 297 | dec.start(buf) 298 | tag, val = dec.read() 299 | assert val == -128 300 | buf = b'\x02\x02\xff\x7f' 301 | dec.start(buf) 302 | tag, val = dec.read() 303 | assert val == -129 304 | 305 | def test_octet_string(self): 306 | buf = b'\x04\x03foo' 307 | dec = asn1.Decoder() 308 | dec.start(buf) 309 | tag = dec.peek() 310 | assert tag == (asn1.OctetString, asn1.TypePrimitive, asn1.ClassUniversal) 311 | tag, val = dec.read() 312 | assert isinstance(val, bytes) 313 | assert val == b'foo' 314 | 315 | def test_null(self): 316 | buf = b'\x05\x00' 317 | dec = asn1.Decoder() 318 | dec.start(buf) 319 | tag = dec.peek() 320 | assert tag == (asn1.Null, asn1.TypePrimitive, asn1.ClassUniversal) 321 | tag, val = dec.read() 322 | assert val is None 323 | 324 | def test_object_identifier(self): 325 | dec = asn1.Decoder() 326 | buf = b'\x06\x02\x2a\x03' 327 | dec.start(buf) 328 | tag = dec.peek() 329 | assert tag == (asn1.ObjectIdentifier, asn1.TypePrimitive, 330 | asn1.ClassUniversal) 331 | tag, val = dec.read() 332 | assert val == b'1.2.3' 333 | 334 | def test_long_object_identifier(self): 335 | dec = asn1.Decoder() 336 | buf = b'\x06\x03\x8c\x1a\x03' 337 | dec.start(buf) 338 | tag, val = dec.read() 339 | assert val == b'39.2.3' 340 | buf = b'\x06\x02\x4f\x03' 341 | dec.start(buf) 342 | tag, val = dec.read() 343 | assert val == b'1.39.3' 344 | buf = b'\x06\x04\x2a\x92\xa7\x60' 345 | dec.start(buf) 346 | tag, val = dec.read() 347 | assert val == b'1.2.300000' 348 | 349 | def test_real_object_identifier(self): 350 | dec = asn1.Decoder() 351 | buf = b'\x06\x0a\x2a\x86\x48\x86\xf7\x12\x01\x02\x01\x01' 352 | dec.start(buf) 353 | tag, val = dec.read() 354 | assert val == b'1.2.840.113554.1.2.1.1' 355 | 356 | def test_enumerated(self): 357 | buf = b'\x0a\x01\x01' 358 | dec = asn1.Decoder() 359 | dec.start(buf) 360 | tag = dec.peek() 361 | assert tag == (asn1.Enumerated, asn1.TypePrimitive, asn1.ClassUniversal) 362 | tag, val = dec.read() 363 | assert isinstance(val, int) 364 | assert val == 1 365 | 366 | def test_sequence(self): 367 | buf = b'\x30\x08\x02\x01\x01\x04\x03foo' 368 | dec = asn1.Decoder() 369 | dec.start(buf) 370 | tag = dec.peek() 371 | assert tag == (asn1.Sequence, asn1.TypeConstructed, asn1.ClassUniversal) 372 | dec.enter() 373 | tag, val = dec.read() 374 | assert val == 1 375 | tag, val = dec.read() 376 | assert val == b'foo' 377 | 378 | def test_sequence_of(self): 379 | buf = b'\x30\x06\x02\x01\x01\x02\x01\x02' 380 | dec = asn1.Decoder() 381 | dec.start(buf) 382 | tag = dec.peek() 383 | assert tag == (asn1.Sequence, asn1.TypeConstructed, asn1.ClassUniversal) 384 | dec.enter() 385 | tag, val = dec.read() 386 | assert val == 1 387 | tag, val = dec.read() 388 | assert val == 2 389 | 390 | def test_set(self): 391 | buf = b'\x31\x08\x02\x01\x01\x04\x03foo' 392 | dec = asn1.Decoder() 393 | dec.start(buf) 394 | tag = dec.peek() 395 | assert tag == (asn1.Set, asn1.TypeConstructed, asn1.ClassUniversal) 396 | dec.enter() 397 | tag, val = dec.read() 398 | assert val == 1 399 | tag, val = dec.read() 400 | assert val == b'foo' 401 | 402 | def test_set_of(self): 403 | buf = b'\x31\x06\x02\x01\x01\x02\x01\x02' 404 | dec = asn1.Decoder() 405 | dec.start(buf) 406 | tag = dec.peek() 407 | assert tag == (asn1.Set, asn1.TypeConstructed, asn1.ClassUniversal) 408 | dec.enter() 409 | tag, val = dec.read() 410 | assert val == 1 411 | tag, val = dec.read() 412 | assert val == 2 413 | 414 | def test_context(self): 415 | buf = b'\xa1\x03\x02\x01\x01' 416 | dec = asn1.Decoder() 417 | dec.start(buf) 418 | tag = dec.peek() 419 | assert tag == (1, asn1.TypeConstructed, asn1.ClassContext) 420 | dec.enter() 421 | tag, val = dec.read() 422 | assert val == 1 423 | 424 | def test_application(self): 425 | buf = b'\x61\x03\x02\x01\x01' 426 | dec = asn1.Decoder() 427 | dec.start(buf) 428 | tag = dec.peek() 429 | assert tag == (1, asn1.TypeConstructed, asn1.ClassApplication) 430 | dec.enter() 431 | tag, val = dec.read() 432 | assert val == 1 433 | 434 | def test_private(self): 435 | buf = b'\xe1\x03\x02\x01\x01' 436 | dec = asn1.Decoder() 437 | dec.start(buf) 438 | tag = dec.peek() 439 | assert tag == (1, asn1.TypeConstructed, asn1.ClassPrivate) 440 | dec.enter() 441 | tag, val = dec.read() 442 | assert val == 1 443 | 444 | def test_long_tag_id(self): 445 | buf = b'\x3f\x83\xff\x7f\x03\x02\x01\x01' 446 | dec = asn1.Decoder() 447 | dec.start(buf) 448 | tag = dec.peek() 449 | assert tag == (0xffff, asn1.TypeConstructed, asn1.ClassUniversal) 450 | dec.enter() 451 | tag, val = dec.read() 452 | assert val == 1 453 | 454 | def test_long_tag_length(self): 455 | buf = b'\x04\x82\xff\xff' + b'x' * 0xffff 456 | dec = asn1.Decoder() 457 | dec.start(buf) 458 | tag, val = dec.read() 459 | assert val == b'x' * 0xffff 460 | 461 | def test_read_multiple(self): 462 | buf = b'\x02\x01\x01\x02\x01\x02' 463 | dec = asn1.Decoder() 464 | dec.start(buf) 465 | tag, val = dec.read() 466 | assert val == 1 467 | tag, val = dec.read() 468 | assert val == 2 469 | assert dec.eof() 470 | 471 | def test_skip_primitive(self): 472 | buf = b'\x02\x01\x01\x02\x01\x02' 473 | dec = asn1.Decoder() 474 | dec.start(buf) 475 | dec.read() 476 | tag, val = dec.read() 477 | assert val == 2 478 | assert dec.eof() 479 | 480 | def test_skip_constructed(self): 481 | buf = b'\x30\x06\x02\x01\x01\x02\x01\x02\x02\x01\x03' 482 | dec = asn1.Decoder() 483 | dec.start(buf) 484 | dec.read() 485 | tag, val = dec.read() 486 | assert val == 3 487 | assert dec.eof() 488 | 489 | def test_error_init(self): 490 | dec = asn1.Decoder() 491 | assert_raises(asn1.Error, dec.peek) 492 | assert_raises(asn1.Error, dec.read) 493 | assert_raises(asn1.Error, dec.enter) 494 | assert_raises(asn1.Error, dec.leave) 495 | 496 | def test_error_stack(self): 497 | buf = b'\x30\x08\x02\x01\x01\x04\x03foo' 498 | dec = asn1.Decoder() 499 | dec.start(buf) 500 | assert_raises(asn1.Error, dec.leave) 501 | dec.enter() 502 | dec.leave() 503 | assert_raises(asn1.Error, dec.leave) 504 | 505 | def test_no_input(self): 506 | dec = asn1.Decoder() 507 | dec.start(b'') 508 | tag = dec.peek() 509 | assert tag is None 510 | 511 | def test_error_missing_tag_bytes(self): 512 | buf = b'\x3f' 513 | dec = asn1.Decoder() 514 | dec.start(buf) 515 | assert_raises(asn1.Error, dec.peek) 516 | buf = b'\x3f\x83' 517 | dec.start(buf) 518 | assert_raises(asn1.Error, dec.peek) 519 | 520 | def test_error_no_length_bytes(self): 521 | buf = b'\x02' 522 | dec = asn1.Decoder() 523 | dec.start(buf) 524 | assert_raises(asn1.Error, dec.read) 525 | 526 | def test_error_missing_length_bytes(self): 527 | buf = b'\x04\x82\xff' 528 | dec = asn1.Decoder() 529 | dec.start(buf) 530 | assert_raises(asn1.Error, dec.read) 531 | 532 | def test_error_too_many_length_bytes(self): 533 | buf = b'\x04\xff' + b'\xff' * 0x7f 534 | dec = asn1.Decoder() 535 | dec.start(buf) 536 | assert_raises(asn1.Error, dec.read) 537 | 538 | def test_error_no_value_bytes(self): 539 | buf = b'\x02\x01' 540 | dec = asn1.Decoder() 541 | dec.start(buf) 542 | assert_raises(asn1.Error, dec.read) 543 | 544 | def test_error_missing_value_bytes(self): 545 | buf = b'\x02\x02\x01' 546 | dec = asn1.Decoder() 547 | dec.start(buf) 548 | assert_raises(asn1.Error, dec.read) 549 | 550 | def test_error_non_normalized_positive_integer(self): 551 | buf = b'\x02\x02\x00\x01' 552 | dec = asn1.Decoder() 553 | dec.start(buf) 554 | assert_raises(asn1.Error, dec.read) 555 | 556 | def test_error_non_normalized_negative_integer(self): 557 | buf = b'\x02\x02\xff\x80' 558 | dec = asn1.Decoder() 559 | dec.start(buf) 560 | assert_raises(asn1.Error, dec.read) 561 | 562 | def test_error_non_normalised_object_identifier(self): 563 | buf = b'\x06\x02\x80\x01' 564 | dec = asn1.Decoder() 565 | dec.start(buf) 566 | assert_raises(asn1.Error, dec.read) 567 | 568 | def test_error_object_identifier_with_too_large_first_component(self): 569 | buf = b'\x06\x02\x8c\x40' 570 | dec = asn1.Decoder() 571 | dec.start(buf) 572 | assert_raises(asn1.Error, dec.read) 573 | -------------------------------------------------------------------------------- /doc/reference.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | API Reference 6 | 7 |
8 | 9 | Credential Management 10 | 11 | 12 | Most Active Directory operations require authentication. The default 13 | authentication mechanism in AD is Kerberos. Unfortunately, it has always 14 | been difficult to use Kerberos authentication with AD. It required for 15 | example to have a properly configured /etc/krb5.conf 16 | listing the realms you want to use and the Kerbers servers for those realms. 17 | 18 | 19 | 20 | Fortunately Python-AD simplifies AD credential management significantly. It 21 | does not require any system configuration and all functionality is embedded 22 | in a single class named Creds. This class is 23 | available from the Python package ad 24 | 25 | 26 | 27 | from activedirectory import Creds 28 | 29 | 30 | 31 | The constructor for the Creds class takes one 32 | argument: domain. This parameter specifies the 33 | default AD domain for credential management. 34 | 35 | 36 | 37 | class Creds(object): 38 | """Credential management.""" 39 | 40 | def __init__(self, domain): 41 | """Constructor.""" 42 | 43 | 44 | 45 | The domain parameter is used as a default when 46 | principals are used without domain name. Credentials in any other domains 47 | can be acquired as long as the domain is part of the same AD forest. 48 | 49 | 50 | 51 | The Creds class has the following methods: 52 | 53 | 54 | 55 | 56 | def acquire(self, principal, password=None, keytab=None, server=None): 57 | """Acquire credentials.""" 58 | 59 | 60 | 61 | The acquire() function acquires credentials for 62 | principal principal. The principal can either a 63 | unqualified principal, in which case the default domain is assumed, or a 64 | principal in the form of user@domain in which case 65 | domain must be a domain in the same AD forest as the 66 | default domain. The password contains the password to 67 | the principal. If no password is given, a keytab will be used. This keytab 68 | is can be specified by the keytab parameter. If this 69 | parameter is not present either, the default system keytab is used. The 70 | server argument overrides the default Kerberos server 71 | to acquire credentials from. If this argument is not given, a suitable 72 | Kerberos server is autodetected. 73 | 74 | 75 | 76 | 77 | def load(self): 78 | """Load credentials from the OS.""" 79 | 80 | 81 | 82 | The function load() loads credentials from the default 83 | operating system credentials store. It raises an exception in case no 84 | credentials are available. 85 | 86 | 87 | 88 | 89 | def principal(self): 90 | """Return the current principal.""" 91 | 92 | 93 | 94 | The principal() method returns the current principal, 95 | i.e. the principal that was last succesfully used in a call to 96 | acquire(). 97 | 98 | 99 | 100 | 101 | def release(self): 102 | """Release all credentials.""" 103 | 104 | 105 | 106 | The release() method releases the currently held 107 | credentials. It is called automatically from the 108 | Creds destructor. 109 | 110 | 111 | 112 | Once credentials are acquired with acquire(), they need 113 | to be activated before they can be used. This is required because 114 | credentials are a process-global resource and they need to be installed in a 115 | place where other classes in Python-AD will be able to find them. Credential 116 | activation is done by calling a function called 117 | activate() as follows: 118 | 119 | 120 | 121 | from activedirectory import activate 122 | activate(creds) 123 | 124 | 125 | 126 | The code fragment above will make the creds object the 127 | globally active credentials. The activate() can be 128 | called multiple times on different objects. The semantics are that the 129 | object on which activate() was called last will be the 130 | active credentials. If active credentials are released by calling the 131 | release() method of a Creds 132 | instance, then the credentials that were previously active are activated. If 133 | no previously activated credentials exist, there will be no credentials 134 | available after this call. 135 | 136 | 137 |
138 | 139 |
140 | 141 | AD Client Interface 142 | 143 | 144 | The actual operations on Active Directory are grouped in a single class 145 | called Client. This class is available from the 146 | ad package. 147 | 148 | 149 | 150 | from activedirectory import Client 151 | 152 | 153 | 154 | The constructor to the Client class takes one 155 | argument: the domain. 156 | 157 | 158 | 159 | class Client(object): 160 | """AD client interface.""" 161 | 162 | def __init__(self, domain): 163 | """Constructor.""" 164 | 165 | 166 | 167 | The domain parameter sets the default domain for the 168 | Client class. As with the 169 | Creds class, the requirement to specify the domain 170 | does not mean that operations through the client are limited to the 171 | specified domain: all operations are available to any domain that is part of 172 | the same forest. 173 | 174 | 175 | 176 | Instances of the Client class will try to access the 177 | globally installed Creds instance. Therefore you must 178 | ensure that credentials are available before you use any functionality in 179 | this class. 180 | 181 | 182 | 183 | The following methods are provided by the Client 184 | class: 185 | 186 | 187 | 188 | 189 | def domain_name_from_dn(self, dn): 190 | """Given a DN, return a domain.""" 191 | 192 | 193 | 194 | The function domain_name_from_dn() is a utility 195 | function that, given an LDAP distinguished name, will return the Active 196 | Directory domain name. For example, if the dn 197 | parameter is set to cn=users,dc=freeadi,dc=org, the 198 | return value of this method will be FREEADI.ORG. 199 | 200 | 201 | 202 | 203 | def dn_from_domain_name(self, name): 204 | """Given a domain name, return a DN.""" 205 | 206 | 207 | 208 | The function dn_from_domain_name() is the inverse of 209 | domain_name_from_dn(). For example, if 210 | name is set to LINUX.FREEADI.ORG, 211 | the return value of this method will be 212 | dc=linux,dc=freeadi,dc=org. 213 | 214 | 215 | 216 | 217 | def domain(self): 218 | """Return the domain name of the current domain.""" 219 | 220 | 221 | 222 | This method returns the current default domain. 223 | 224 | 225 | 226 | 227 | def domain_base(self): 228 | """Return the base DN of the domain.""" 229 | 230 | 231 | 232 | The method domain_base() returns the LDAP base DN for 233 | the curent domain. If client is an instance of 234 | Client, then this is equivalent to 235 | client.dn_from_domain_name(client.domain()). 236 | 237 | 238 | 239 | def forest(self): 240 | """Return the domain name of the forest root.""" 241 | 242 | 243 | 244 | This method returns the root of the forest for the current domain. In case 245 | the current domain is a child domain, this will return something different 246 | than the domain() method. 247 | 248 | 249 | 250 | def forest_base(self): 251 | """Return the base DN of the forest root.""" 252 | 253 | 254 | 255 | The forest_base() method returns the LDAP base DN for 256 | the forest. This function is equivalent to 257 | client.dn_from_domain_name(client.forest()). 258 | 259 | 260 | 261 | def schema_base(self): 262 | """Return the base DN of the schema naming_context.""" 263 | 264 | 265 | 266 | This method returns the LDAP base DN for the schema naming context for the 267 | Active Directory forest. 268 | 269 | 270 | 271 | def configuration_base(self): 272 | """Return the base DN of the configuration naming_context.""" 273 | 274 | 275 | 276 | This method returns the LDAP base DN for the configuration namign context 277 | for the Active Directory forest. 278 | 279 | 280 | 281 | def naming_contexts(self): 282 | """Return a list of all naming_contexts.""" 283 | 284 | 285 | 286 | The naming_contexts() returns a list of all naming 287 | contexts in the forest. The term naming context is the Microsoft name for a 288 | directory partition. Each naming context is present on one or multiple 289 | domain controllers, and each domain controller normally has multiple naming 290 | contexts. 291 | 292 | 293 | 294 | def domains(self): 295 | """Return a list of all domains in the forest.""" 296 | 297 | 298 | 299 | This function returns a list of all domains that are present in the forest. 300 | 301 | 302 | 303 | def close(self): 304 | """Close any active LDAP connection.""" 305 | 306 | 307 | 308 | The close() closes all currently open connections to 309 | the Active Directory. 310 | 311 | 312 | 313 | def search(self, filter=None, base=None, scope=None, attrs=None, 314 | server=None, scheme=None): 315 | """Search the Active Directory.""" 316 | 317 | 318 | 319 | The search() method is probably the most important 320 | function in Python-AD. It searches the Active Directory and returns a list 321 | of objects that match the query. The filter argument 322 | specifies the LDAP search filter. If it is absent, the default filter is 323 | (objectClass=*). The base 324 | arguments gives the LDAP search base. This search base must either be blank 325 | ('') in which case the rootDSE is searched, or it must be 326 | within one of the naming contexts of the forest. The 327 | scope parameter must be a string of the value 328 | 'base', 'onelevel' or 329 | 'subtree'. For compatibility reasons, the 330 | SCOPE_* constants as defined by Python-LDAP are accepted 331 | as well. The attrs parameter specifies the attributes 332 | to request. If it is given, it must be a list of strings. If it is not 333 | specified, then the default is to request all attributes. The 334 | server parameter is another optional parameter that 335 | specifies the server to bind to. Normally, Python-AD operates in 336 | serverless binding mode. In this mode, a suitable 337 | domain controller is selected automatically. In some situation this may not 338 | be desidered however and for these situations the 339 | server can be used. Finally the 340 | scheme parameter specifies the search scheme. It must 341 | be one of 'ldap' (the default) or 'gc' 342 | to search the global catalog. 343 | 344 | 345 | 346 | The return value of search() is a list of 2-tuples. 347 | Each tuple consists of a distinguished name and a dictionary of attributes. 348 | The dictionary has string keys (the attribute names) and a list of strings 349 | as it values (the attribute values). 350 | 351 | 352 | 353 | def add(self, dn, attrs, server=None): 354 | """Add a new object to Active Directory.""" 355 | 356 | 357 | 358 | The add() function adds an object to the directory. The 359 | parameter dn specifies the distinguished name of the 360 | attribute to be added. The parameter attrs specifies 361 | the attributes of the object. It must be a list of 2-tuples, with the first 362 | tuple entry the attribute name, and the second tuple entry a list of strings 363 | containing the attribute values. The server parameter 364 | can be used to override the default binding behaviour and has the same 365 | meaning as for search(). 366 | 367 | 368 | 369 | def modify(self, dn, mods, server=None): 370 | """Modify an LDAP object.""" 371 | 372 | 373 | 374 | The modify() method modifies an existing object in the 375 | directory. The dn parameter specifies the 376 | distinguished name of the object modify, while mods 377 | specifies the modifications. The latter must be a list of 3-tuples. Each 378 | tuple consists of the modify operation (one of 'add', 379 | 'replace' or 'delete' to add, replace 380 | or delete an attribute value respectively -- the Python-LDAP 381 | MOD_* constants are supported for compatibility), the 382 | attribute name, and the attribute value. The latter must be a list of 383 | strings. The server parameter can be used to override 384 | the default binding behaviour and has the same meaning as for 385 | search(). 386 | 387 | 388 | 389 | def delete(self, dn, server=None): 390 | """Delete the LDAP object referenced by `dn'.""" 391 | 392 | 393 | 394 | The delete() method removes an object from the 395 | directory. The dn parameter specifies the 396 | distinguished name of the object to remove. The server 397 | parameter can be used to override the default binding behaviour and has the 398 | same meaning as for search(). 399 | 400 | 401 | 402 | def modrdn(self, dn, newrdn, delold=True, server=None): 403 | """Change the RDN of an object in Active Direcotry.""" 404 | 405 | 406 | 407 | The modrdn() method modifies the relative distinuished 408 | name of the LDAP object with distinguished name dn to 409 | the value specified by the newrdn, which must be in 410 | the form of "attr=value". The 411 | delold specifies whether the old RDN value is 412 | retained or not. The server parameter has the same 413 | meaning as for search(). 414 | 415 | 416 | 417 | def rename(self, dn, newrdn, newsuperior=None, delold=True, server=None): 418 | """Change the RDN of an object in Active Directory, and optionally 419 | move it to a new part of the tree.""" 420 | 421 | 422 | 423 | The rename() method is like 424 | modrdn() but is also allows the object to be moved to a 425 | new place in the directory by means of the 426 | newsuperior parameter. 427 | 428 | 429 | 430 | def set_password(self, principal, password, server=None): 431 | """Set the password of `principal' to `password'.""" 432 | 433 | 434 | 435 | This function sets the password for principal to 436 | password. The server parameter 437 | can again be used to override the default server binding as for the 438 | search() method. 439 | 440 | 441 | 442 | def change_password(self, principal, oldpass, newpass, server=None): 443 | """Change the password of `principal' to `password'.""" 444 | 445 | 446 | 447 | This function changes the password for principal 448 | principal. The old password must be given in 449 | oldpass and the new password in 450 | newpass. 451 | 452 | 453 |
454 | 455 |
456 | 457 | Resource Location 458 | 459 | 460 | Resource location is an problem that is normally handled transparently by 461 | Python-AD. In the default situation, domain controllers are looked up 462 | automatically by a global instance of the Locator 463 | class. It is possible however to use this class directly. This is most 464 | useful when targetting a specific domain controller with the 465 | server argument that many methods of the 466 | Client class accept. 467 | 468 | 469 | 470 | The Locator class is available from the 471 | ad package: 472 | 473 | 474 | from activedirectory import Locator 475 | 476 | 477 | 478 | The constructor takes one optional parameter: the site. 479 | 480 | 481 | 482 | class Locator(object): 483 | """Locate domain controllers. 484 | 485 | def __init__(self, site=None): 486 | """Constructor.""" 487 | 488 | 489 | 490 | The site specifies the AD site the current system is 491 | in. Normally this value is autodetected, but in some situations you may want 492 | to override this. 493 | 494 | 495 | 496 | The Locator class defines the following methods: 497 | 498 | 499 | 500 | def locate(self, domain, role=None): 501 | """Locate one domain controller.""" 502 | 503 | 504 | 505 | The locate() method locates one domain controller for 506 | the domain domain. The optional parameter 507 | role, if given, specifies the desired role of the 508 | domain controller. This can be one of 'dc', 509 | 'gc' or 'pdc' for a normal domain 510 | controller, a global catalog, or the domain controller with the primary 511 | domain controller emulator role respectively. The default is to locate an 512 | ordinary domain controller. The return value of this method is a string 513 | containing the host name of the domain controller. If no domain controller 514 | is found, an exception is raised. 515 | 516 | 517 | 518 | def locate_many(self, domain, role=None, maxservers=None): 519 | """Locate a list of up to `maxservers' of domain controllers.""" 520 | 521 | 522 | 523 | This method locates up to maxservers domain 524 | controllers. The domain and 525 | role parameters are as for 526 | locate(). The return value is a list of strings 527 | containing the host names of the selected domain controllers. If no domain 528 | controllers are found, an empty list is returned. 529 | 530 | 531 |
532 | 533 |
534 | 535 | Error Handling 536 | 537 | Two exceptions are defined for error handling. It is recommended to import 538 | the exception as per the code fragment below: 539 | 540 | 541 | from activedirectory import Error as ADError 542 | from activedirectory import LDAPError 543 | 544 | 545 | The ADError exception is raised for all errors that 546 | are encountered by Python-AD. The LDAPError exception 547 | is imported from Python-LDAP and is raised when an exception is raised by 548 | that module. Therefore, to ensure that all exceptions are caught, you always 549 | need to capture both exceptions, as illustrated by the example below: 550 | 551 | 552 | client = Client(domain) 553 | try: 554 | client.add(dn, attrs) 555 | except (ADError, LDAPError): 556 | pass 557 | 558 | 559 |
560 | 561 |
562 | --------------------------------------------------------------------------------