├── .gitignore ├── LICENSE ├── README.rst ├── easyad.py ├── requirements.txt ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # PyCharm project settings 92 | .idea 93 | 94 | # Test creds 95 | test*.py 96 | example*.py 97 | config.py 98 | 99 | # Certificates 100 | *.cer 101 | *.crt 102 | 103 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | easyad 2 | ====== 3 | 4 | A simple Python module for common Active Directory authentication and lookup tasks 5 | 6 | :: 7 | 8 | Copyright 2016 Sean Whalen 9 | 10 | Licensed under the Apache License, Version 2.0 (the "License"); 11 | you may not use this file except in compliance with the License. 12 | You may obtain a copy of the License at 13 | 14 | http://www.apache.org/licenses/LICENSE-2.0 15 | 16 | Unless required by applicable law or agreed to in writing, software 17 | distributed under the License is distributed on an "AS IS" BASIS, 18 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 19 | See the License for the specific language governing permissions and 20 | limitations under the License. 21 | 22 | Why? 23 | ---- 24 | 25 | Most LDAP solutions for Python and/or Flask focus in being generic LDAP 26 | interfaces. It's up to the developer to understand and work around the 27 | quirks of Active Directory. This module aims to reduce the complexity 28 | and development time for Python-powered applications that securely 29 | interface with Active Directory. 30 | 31 | Features 32 | -------- 33 | 34 | - Python 2 and 3 support 35 | - Unicode support 36 | - Authenticate user credentials via direct bind 37 | - Quickly test if a user is a member of a group, including nested groups 38 | - Query user and group attributes 39 | - Simple user and group search 40 | - Get all groups that a user is a member of, including nested groups 41 | - Get a list of all group member users, including from nested groups 42 | - Options to automatically convert binary data into base64 for JSON-safe 43 | output 44 | 45 | 46 | Installing 47 | ---------- 48 | 49 | First, install the system dependencies 50 | 51 | :: 52 | 53 | $ sudo apt-get install libsasl2-dev python3-dev python3-pip libldap2-dev libssl-dev 54 | 55 | Then 56 | 57 | :: 58 | 59 | $ sudo pip3 install -U easyad 60 | 61 | Example uses 62 | ------------ 63 | 64 | :: 65 | 66 | from __future__ import unicode_literals, print_function 67 | 68 | from getpass import getpass 69 | from json import dumps 70 | 71 | from easyad import EasyAD 72 | 73 | # Workaround to make input() return a string in Python 2 like it does in Python 3 74 | # It's 2016...you should really be using Python 3 75 | try: 76 | input = raw_input 77 | except NameError: 78 | pass 79 | 80 | # Set up configuration. You could also use a Flask app.config 81 | config = dict(AD_SERVER="ad.example.net", 82 | AD_DOMAIN="example.net", 83 | AD_CA_CERT_FILE="myrootca.crt") 84 | 85 | # Initialize all the things! 86 | ad = EasyAD(config) 87 | 88 | # Authenticate a user 89 | username = input("Username: ") 90 | password = getpass("Password: ") 91 | 92 | local_admin_group_name = "LocalAdministrators" 93 | 94 | user = ad.authenticate_user(username, password, json_safe=True) 95 | 96 | if user: 97 | # Successful login! Let's print your details as JSON 98 | print(dumps(user, sort_keys=True, indent=2, ensure_ascii=False)) 99 | 100 | # Lets find out if you are a member of the "LocalAdministrators" group 101 | print(ad.user_is_member_of_group(user, local_admin_group_name)) 102 | else: 103 | print("Those credentials are invalid. Please try again.") 104 | exit(-1) 105 | 106 | # You can also add service account credentials to the config to do lookups without 107 | # passing in the credentials on every call 108 | ad.config["AD_BIND_USERNAME"] = "SA-ADLookup" 109 | ad.config["AD_BIND_PASSWORD"] = "12345LuggageAmazing" 110 | 111 | user = ad.get_user("maurice.moss", json_safe=True) 112 | print(dumps(user, sort_keys=True, indent=2, ensure_ascii=False)) 113 | 114 | group = ad.get_group("helpdesk", json_safe=True) 115 | print(dumps(user, sort_keys=True, indent=2, ensure_ascii=False)) 116 | 117 | print("Is Jen a manager?") 118 | print(ad.user_is_member_of_group("jen.barber", "Managers")) 119 | 120 | # The calls below can be taxing on an AD server, especially when used frequently. 121 | # If you just need to check if a user is a member of a group use 122 | # EasyAD.user_is_member_of_group(). It is *much* faster. 123 | 124 | # I wonder who all is in the "LocalAdministrators" group? Let's run a 125 | # query that will search in nested groups. 126 | print(dumps(ad.get_all_users_in_group(local_admin_group_name, json_safe=True))) 127 | 128 | # Let's see all of the groups that Moss in in, including nested groups 129 | print(dumps(ad.get_all_user_groups(user), indent=2, ensure_ascii=False)) 130 | 131 | easyad methods 132 | -------------- 133 | 134 | convert_ad_timestamp(timestamp, json_safe=False) 135 | 136 | :: 137 | 138 | Converts a LDAP timestamp to a datetime or a human-readable string 139 | 140 | Args: 141 | timestamp: the LDAP timestamp 142 | json_safe: If true, return a a human-readable string instead of a datetime 143 | 144 | Returns: 145 | A datetime or a human-readable string 146 | 147 | 148 | enhance_user(user, json_safe=False) 149 | 150 | :: 151 | 152 | Adds computed attributes to AD user results 153 | 154 | Args: 155 | user: A dictionary of user attributes 156 | json_safe: If true, converts binary data into base64, 157 | And datetimes into human-readable strings 158 | 159 | Returns: 160 | An enhanced dictionary of user attributes 161 | 162 | process_ldap_results(results, json_safe=False) 163 | 164 | :: 165 | 166 | Converts LDAP search results from bytes to a dictionary of UTF-8 where possible 167 | 168 | Args: 169 | results: LDAP search results 170 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 171 | 172 | Returns: 173 | A list of processed LDAP result dictionaries. 174 | 175 | easyad.ADConnection 176 | ------------------- 177 | 178 | :: 179 | 180 | A LDAP configuration abstraction class 181 | 182 | Attributes: 183 | config: The configuration dictionary 184 | ad:The LDAP interface instance 185 | 186 | 187 | ADConnection.__init__(self, config) 188 | 189 | :: 190 | 191 | 192 | Initializes an ADConnection object 193 | 194 | Args: 195 | config: A dictionary of configuration settings 196 | Required: 197 | AD_SERVER: The hostname of the Active Directory Server 198 | Optional: 199 | AD_REQUIRE_TLS: Require a TLS connection. True by default. 200 | AD_CA_CERT_FILE: The path to the root CA certificate file 201 | AD_PAGE_SIZE: Overrides the default page size of 1000 202 | AD_OPTIONS: A dictionary of other python-ldap options 203 | 204 | 205 | ADConnection.bind(self, credentials=None) 206 | 207 | :: 208 | 209 | Attempts to bind to the Active Directory server 210 | 211 | Args: 212 | credentials: A optional dictionary of the username and password to use. 213 | If credentials are not passed, the credentials from the initial EasyAD configuration are used. 214 | 215 | Returns: 216 | True if the bind was successful 217 | 218 | Raises: 219 | ldap.LDAP_ERROR 220 | 221 | ADConnection.unbind(self) 222 | 223 | :: 224 | 225 | Unbind from the Active Directory server 226 | 227 | easyad.EasyAD 228 | ------------- 229 | 230 | :: 231 | 232 | A high-level class for interacting with Active Directory 233 | 234 | Attributes: 235 | user_attributes: A default list of attributes to return from a user query 236 | group_attributes: A default list of attributes to return from a user query 237 | 238 | EasyAD.__init__(self, config) 239 | 240 | :: 241 | 242 | Initializes an EasyAD object 243 | 244 | Args: 245 | config: A dictionary of configuration settings 246 | Required: 247 | AD_SERVER: the hostname of the Active Directory Server 248 | AD_DOMAIN: The domain to bind to, in TLD format 249 | Optional: 250 | AD_REQUIRE_TLS: Require a TLS connection. True by default. 251 | AD_CA_CERT_FILE: the path to the root CA certificate file 252 | AD_BASE_DN: Overrides the base distinguished name. Derived from AD_DOMAIN by default. 253 | 254 | 255 | EasyAD.authenticate_user(self, username, password, base=None, attributes=None, json_safe=False) 256 | 257 | :: 258 | 259 | Test if the given credentials are valid 260 | 261 | Args: 262 | username: The username 263 | password: The password 264 | base: Optionally overrides the base object DN 265 | attributes: A list of user attributes to return 266 | json_safe: Convert binary data to base64 and datetimes to human-readable strings 267 | 268 | Returns: 269 | A dictionary of user attributes if successful, or False if it failed 270 | 271 | Raises: 272 | ldap.LDAP_ERROR 273 | 274 | EasyAD.get_all_user_groups(self, user, base=None, credentials=None, json_safe=False) 275 | 276 | :: 277 | 278 | Returns a list of all group DNs that a user is a member of, including nested groups 279 | 280 | Args: 281 | user: A username, distinguishedName, or a dictionary containing a distinguishedName 282 | base: Overrides the configured base object dn 283 | credentials: An optional dictionary of the username and password to use 284 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 285 | 286 | Returns: 287 | A list of group DNs that the user is a member of, including nested groups 288 | 289 | Raises: 290 | ldap.LDAP_ERROR 291 | 292 | Notes: 293 | This call can be taxing on an AD server, especially when used frequently. 294 | If you just need to check if a user is a member of a group, 295 | use EasyAD.user_is_member_of_group(). It is *much* faster. 296 | 297 | 298 | EasyAD.get_all_users_in_group(self, group, base=None, credentials=None, json_safe=False) 299 | 300 | :: 301 | 302 | Returns a list of all user DNs that are members of a given group, including from nested groups 303 | 304 | Args: 305 | group: A group name, cn, or dn 306 | base: Overrides the configured base object dn 307 | credentials: An optional dictionary of the username and password to use 308 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 309 | 310 | Returns: 311 | A list of all user DNs that are members of a given group, including users from nested groups 312 | 313 | Raises: 314 | ldap.LDAP_ERROR 315 | 316 | Notes: 317 | This call can be taxing on an AD server, especially when used frequently. 318 | If you just need to check if a user is a member of a group, 319 | use EasyAD.user_is_member_of_group(). It is *much* faster. 320 | 321 | 322 | EasyAD.get_group(self, group_string, base=None, credentials=None, attributes=None, json_safe=False) 323 | 324 | :: 325 | 326 | Searches for a unique group object and returns its attributes 327 | 328 | Args: 329 | group_string: A group name, cn, or dn 330 | base: Optionally override the base object dn 331 | credentials: A optional dictionary of the username and password to use. 332 | If credentials are not passed, the credentials from the initial EasyAD configuration are used. 333 | attributes: An optional list of attributes to return. Otherwise uses self.group_attributes. 334 | To return all attributes, pass an empty list. 335 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 336 | 337 | Returns: 338 | A dictionary of group attributes 339 | 340 | Raises: 341 | ValueError: Query returned no or multiple results 342 | ldap.LDAP_ERROR: An LDAP error occurred 343 | 344 | 345 | EasyAD.get_user(self, user_string, json_safe=False, credentials=None, attributes=None) 346 | 347 | :: 348 | 349 | Searches for a unique user object and returns its attributes 350 | 351 | Args: 352 | user_string: A userPrincipalName, sAMAccountName, or distinguishedName 353 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 354 | credentials: A optional dictionary of the username and password to use. 355 | If credentials are not passed, the credentials from the initial EasyAD configuration are used. 356 | attributes: An optional list of attributes to return. Otherwise uses self.user_attributes. 357 | To return all attributes, pass an empty list. 358 | 359 | Returns: 360 | A dictionary of user attributes 361 | 362 | Raises: 363 | ValueError: query returned no or multiple results 364 | 365 | 366 | EasyAD.resolve_group_dn(self, group, base=None, credentials=None, json_safe=False) 367 | 368 | :: 369 | 370 | Returns a group's DN when given a principalAccountName, sAMAccountName, email, or DN 371 | 372 | Args: 373 | group: A group name, CN, or DN, or a dictionary containing a DN 374 | base: Optionally overrides the base object DN 375 | credentials: An optional dictionary of the username and password to use 376 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 377 | 378 | Returns: 379 | The groups's DN 380 | 381 | Raises: 382 | ldap.LDAP_ERROR 383 | 384 | EasyAD.resolve_user_dn(self, user, base=None, credentials=None, json_safe=False) 385 | 386 | :: 387 | 388 | Returns a user's DN when given a principalAccountName, sAMAccountName, email, or DN 389 | 390 | Args: 391 | user: A principalAccountName, sAMAccountName, email, DN, or a dictionary containing a DN 392 | base: Optionally overrides the base object DN 393 | credentials: An optional dictionary of the username and password to use 394 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 395 | 396 | Returns: 397 | The user's DN 398 | 399 | Raises: 400 | ldap.LDAP_ERROR 401 | 402 | search(self, base=None, scope=ldap.SCOPE_SUBTREE, filter_string="(objectClass=*)", credentials=None, 403 | attributes=None, json_safe=False, page_size=None) 404 | 405 | :: 406 | 407 | 408 | Run a search of the Active Directory server, and get the results 409 | 410 | Args: 411 | base: Optionally override the DN of the base object 412 | scope: Optional scope setting, subtree by default. 413 | filter_string: Optional custom filter string 414 | credentials: Optionally override the bind credentials 415 | attributes: A list of attributes to return. If none are specified, all attributes are returned 416 | json_safe: If true, convert binary data to base64, and datetimes to human-readable strings 417 | page_size: Optionally override the number of results to return per LDAP page 418 | 419 | Returns: 420 | Results as a list of dictionaries 421 | 422 | Raises: 423 | ldap.LDAP_ERROR 424 | 425 | Notes: 426 | Setting a small number of search_attributes and return_attributes reduces server load and bandwidth 427 | respectively 428 | 429 | 430 | search_for_groups(self, group_string, base=None, search_attributes=None, return_attributes=None, 431 | credentials=None, json_safe=False) 432 | 433 | :: 434 | 435 | Returns matching group objects as a list of dictionaries 436 | 437 | Args: 438 | group_string: The substring to search for 439 | base: Optionally override the base object's DN 440 | search_attributes: The attributes to search through, with binary data removed 441 | easyad.EasyAD.group_attributes by default 442 | return_attributes: A list of attributes to return. easyad.EasyAD.group_attributes by default 443 | credentials: Optionally override the bind credentials 444 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 445 | 446 | Returns: 447 | Results as a list of dictionaries 448 | 449 | Raises: 450 | ldap.LDAP_ERROR 451 | 452 | Notes: 453 | Setting a small number of search_attributes and return_attributes reduces server load and bandwidth 454 | respectively 455 | 456 | search_for_users(self, user_string, base=None, search_attributes=None, return_attributes=None, credentials=None, 457 | json_safe=False) 458 | 459 | :: 460 | 461 | Returns matching user objects as a list of dictionaries 462 | 463 | Args: 464 | user_string: The substring to search for 465 | base: Optionally override the base object's DN 466 | search_attributes: The attributes to search through, with binary data removed 467 | easyad.EasyAD.user_attributes by default 468 | return_attributes: A list of attributes to return. easyad.EasyAD.user_attributes by default 469 | credentials: Optionally override the bind credentials 470 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 471 | 472 | Returns: 473 | Results as a list of dictionaries 474 | 475 | Raises: 476 | ldap.LDAP_ERROR 477 | 478 | Notes: 479 | Setting a small number of search_attributes and return_attributes reduces server load and bandwidth 480 | respectively 481 | 482 | 483 | EasyAD.user_is_member_of_group(self, user, group, base=None, credentials=None) 484 | 485 | :: 486 | 487 | Tests if a given user is a member of the given group 488 | 489 | Args: 490 | user: A principalAccountName, sAMAccountName, email, or DN 491 | group: A group name, cn, or dn 492 | base: An optional dictionary of the username and password to use 493 | credentials: An optional dictionary of the username and password to use 494 | 495 | Raises: 496 | ldap.LDAP_ERROR 497 | 498 | Returns: 499 | A boolean that indicates if the given user is a member of the given group 500 | 501 | -------------------------------------------------------------------------------- /easyad.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | A simple Python module for common Active Directory authentication and lookup tasks 5 | """ 6 | 7 | from __future__ import unicode_literals, print_function 8 | 9 | from sys import stderr 10 | from base64 import b64encode 11 | from datetime import datetime, timedelta 12 | 13 | import ldap 14 | from ldap.controls import SimplePagedResultsControl 15 | from ldap.filter import escape_filter_chars 16 | 17 | """Copyright 2016 Sean Whalen 18 | 19 | Licensed under the Apache License, Version 2.0 (the "License"); 20 | you may not use this file except in compliance with the License. 21 | You may obtain a copy of the License at 22 | 23 | http://www.apache.org/licenses/LICENSE-2.0 24 | 25 | Unless required by applicable law or agreed to in writing, software 26 | distributed under the License is distributed on an "AS IS" BASIS, 27 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 28 | See the License for the specific language governing permissions and 29 | limitations under the License.""" 30 | 31 | 32 | __version__ = "1.0.9" 33 | 34 | 35 | # Python 2 & 3 support hack 36 | try: 37 | unicode 38 | except NameError: 39 | unicode = str 40 | 41 | exchange_mailbox_values = { 42 | 1: "User Mailbox", 43 | 2: "Linked Mailbox", 44 | 4: "Shared Mailbox", 45 | 8: "Legacy Mailbox", 46 | 16: "Room Mailbox", 47 | 32: "Equipment Mailbox", 48 | 8192: "System Attendant Mailbox", 49 | 16384: "Mailbox Database Mailbox", 50 | 2147483648: "Remote User Mailbox", 51 | 8589934592: "Remote Room Mailbox", 52 | 17173869184: "Remote Equipment Mailbox", 53 | 34359738368: "Remote Shared Mailbox" 54 | } 55 | 56 | remote_exchange_mailbox_values = { 57 | 2147483648: "Remote User Mailbox", 58 | 8589934592: "Remote Room Mailbox", 59 | 17173869184: "Remote Equipment Mailbox", 60 | 34359738368: "Remote Shared Mailbox" 61 | } 62 | 63 | 64 | def _create_controls(pagesize): 65 | """Create an LDAP control with a page size of "pagesize".""" 66 | # Initialize the LDAP controls for paging. Note that we pass '' 67 | # for the cookie because on first iteration, it starts out empty. 68 | return SimplePagedResultsControl(criticality=True, size=pagesize, cookie="") 69 | 70 | 71 | def _get_page_controls(serverctrls): 72 | """Lookup an LDAP paged control object from the returned controls.""" 73 | # Look through the returned controls and find the page controls. 74 | # This will also have our returned cookie which we need to make 75 | # the next search request. 76 | for control in serverctrls: 77 | if control.controlType == SimplePagedResultsControl.controlType: 78 | return control 79 | 80 | 81 | def convert_ad_timestamp(timestamp, json_safe=False, str_format="%x %X"): 82 | """ 83 | Converts a LDAP timestamp to a datetime or a human-readable string 84 | Args: 85 | timestamp: the LDAP timestamp 86 | json_safe: If true, return a a human-readable string instead of a datetime 87 | str_format: The string format to use if json_safe is true 88 | 89 | Returns: 90 | A datetime or a human-readable string 91 | """ 92 | try: 93 | timestamp = int(timestamp) 94 | if timestamp == 0: 95 | return None 96 | epoch_start = datetime(year=1601, month=1, day=1) 97 | seconds_since_epoch = timestamp / 10 ** 7 98 | converted_timestamp = epoch_start + timedelta(seconds=seconds_since_epoch) 99 | 100 | except ValueError: 101 | converted_timestamp = datetime.strptime(timestamp.split(".")[0], "%Y%m%d%H%M%S") 102 | 103 | if json_safe: 104 | converted_timestamp = converted_timestamp.strftime(str_format) 105 | 106 | return converted_timestamp 107 | 108 | 109 | def _get_last_logon(timestamp, json_safe=False): 110 | """ 111 | Converts a LastLogonTimestamp to a datetime or human-readable format 112 | Args: 113 | timestamp: The timestamp from a lastLogonTimestamp user attribute 114 | json_safe: If true, always return a string 115 | 116 | Returns: 117 | A datetime or string showing the user's last login, or the string "<=14", since 118 | lastLogonTimestamp is not accurate withing 14 days 119 | """ 120 | timestamp = convert_ad_timestamp(timestamp, json_safe=False) 121 | if timestamp is None: 122 | return -1 123 | delta = datetime.now() - timestamp 124 | days = delta.days 125 | 126 | # LastLogonTimestamp is not accurate beyond 14 days 127 | if days <= 14: 128 | timestamp = "<= 14 days" 129 | elif json_safe: 130 | timestamp.strftime("%x %X") 131 | 132 | return timestamp 133 | 134 | 135 | def process_ldap_results(results, json_safe=False): 136 | """ 137 | Converts LDAP search results from bytes to a dictionary of UTF-8 where possible 138 | 139 | Args: 140 | results: LDAP search results 141 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 142 | 143 | Returns: 144 | A list of processed LDAP result dictionaries. 145 | """ 146 | 147 | for i in range(len(results)): 148 | if isinstance(results[i], tuple): 149 | results[i] = results[i][1] 150 | results = [result for result in results if isinstance(result, dict)] 151 | for ldap_object in results: 152 | for attribute in ldap_object.keys(): 153 | # pyldap returns all attributes as bytes. Yuk! 154 | for i in range(len(ldap_object[attribute])): 155 | if isinstance(ldap_object[attribute][i], bytes): 156 | try: 157 | ldap_object[attribute][i] = ldap_object[attribute][i].decode("UTF-8") 158 | except ValueError: 159 | if json_safe: 160 | ldap_object[attribute][i] = b64encode(ldap_object[attribute][i]).decode("UTF-8") 161 | if len(ldap_object[attribute]) == 1: 162 | ldap_object[attribute] = ldap_object[attribute][0] 163 | 164 | return results 165 | 166 | 167 | def _listify(x): 168 | """ 169 | Some servers send back inconsistently-typed fields, e.g. a single 170 | string or a list of strings depending on the number of entries. 171 | This function returns a list version of x if x is a non-string 172 | iterable, otherwise returns a list with x as its only element, 173 | which allows us to coerce such values into their type. 174 | """ 175 | return [x] if isinstance(x, (type(b''), type(''))) else list(x) 176 | 177 | 178 | def enhance_user(user, json_safe=False): 179 | """ 180 | Adds computed attributes to AD user results 181 | Args: 182 | user: A dictionary of user attributes 183 | json_safe: If true, converts binary data into base64, 184 | And datetimes into human-readable strings 185 | 186 | Returns: 187 | An enhanced dictionary of user attributes 188 | """ 189 | if "memberOf" in user.keys(): 190 | user["memberOf"] = sorted(_listify(user["memberOf"]), key=lambda dn: dn.lower()) 191 | if "showInAddressBook" in user.keys(): 192 | user["showInAddressBook"] = sorted(user["showInAddressBook"], key=lambda dn: dn.lower()) 193 | if "lastLogonTimestamp" in user.keys(): 194 | user["lastLogonTimestamp"] = _get_last_logon(user["lastLogonTimestamp"]) 195 | if "lockoutTime" in user.keys(): 196 | user["lockoutTime"] = convert_ad_timestamp(user["lockoutTime"], json_safe=json_safe) 197 | if "pwdLastSet" in user.keys(): 198 | user["pwdLastSet"] = convert_ad_timestamp(user["pwdLastSet"], json_safe=json_safe) 199 | if "userAccountControl" in user.keys(): 200 | user["userAccountControl"] = int(user["userAccountControl"]) 201 | user["disabled"] = user["userAccountControl"] & 2 != 0 202 | user["passwordExpired"] = user["userAccountControl"] & 8388608 != 0 203 | user["passwordNeverExpires"] = user["userAccountControl"] & 65536 != 0 204 | user["smartcardRequired"] = user["userAccountControl"] & 262144 != 0 205 | if "whenCreated" in user.keys(): 206 | user["whenCreated"] = convert_ad_timestamp(user["whenCreated"], json_safe=json_safe) 207 | if "msExchRecipientTypeDetails" in user.keys(): 208 | user["msExchRecipientTypeDetails"] = int(user["msExchRecipientTypeDetails"]) 209 | user["remoteExchangeMailbox"] = user["msExchRecipientTypeDetails"] in remote_exchange_mailbox_values 210 | user["exchangeMailbox"] = user["msExchRecipientTypeDetails"] in exchange_mailbox_values.keys() 211 | if user["exchangeMailbox"]: 212 | user["exchangeMailboxType"] = exchange_mailbox_values[user["msExchRecipientTypeDetails"]] 213 | 214 | return user 215 | 216 | 217 | class ADConnection(object): 218 | """ 219 | A LDAP configuration abstraction 220 | 221 | Attributes: 222 | config: The configuration dictionary 223 | ad: The LDAP interface instance 224 | """ 225 | def __init__(self, config): 226 | """ 227 | Initializes an ADConnection object 228 | 229 | Args: 230 | config: A dictionary of configuration settings 231 | Required: 232 | AD_SERVER: The hostname of the Active Directory Server 233 | Optional: 234 | AD_REQUIRE_TLS: Require a TLS connection. True by default. 235 | AD_CA_CERT_FILE: The path to the root CA certificate file 236 | AD_PAGE_SIZE: Overrides the default page size of 1000 237 | AD_OPTIONS: A dictionary of other python-ldap options 238 | """ 239 | self.config = config 240 | ad_server_url = "ldap://{0}".format(self.config["AD_SERVER"]) 241 | ad = ldap.initialize(ad_server_url) 242 | ad.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION3) 243 | ad.set_option(ldap.OPT_REFERRALS, 0) 244 | 245 | if "AD_CA_CERT_FILE" in self.config and self.config["AD_CA_CERT_FILE"]: 246 | ad.set_option(ldap.OPT_X_TLS_CACERTFILE, self.config["AD_CA_CERT_FILE"]) # The root CA certificate 247 | if "AD_REQUIRE_TLS" in self.config and not self.config["AD_REQUIRE_TLS"]: 248 | ad.set_option(ldap.OPT_X_TLS_DEMAND, 0) 249 | else: 250 | ad.set_option(ldap.OPT_X_TLS_DEMAND, 1) # Force TLS by default 251 | if "AD_PAGE_SIZE" not in self.config: 252 | self.config["AD_PAGE_SIZE"] = 1000 253 | if "AD_OPTIONS" in config and isinstance(config["AD_OPTIONS"], dict): 254 | options = config["AD_OPTIONS"] 255 | for key in options.keys(): 256 | ad.set_option(key, options[key]) 257 | 258 | if ad.get_option(ldap.OPT_X_TLS_DEMAND): 259 | ad.set_option(ldap.OPT_X_TLS_NEWCTX, 0) 260 | 261 | self.ad = ad 262 | 263 | def bind(self, credentials=None): 264 | """ 265 | Attempts to bind to the Active Directory server 266 | 267 | Args: 268 | credentials: A optional dictionary of the username and password to use. 269 | If credentials are not passed, the credentials from the initial EasyAD configuration are used. 270 | 271 | Returns: 272 | True if the bind was successful 273 | 274 | Raises: 275 | ldap.LDAP_ERROR 276 | """ 277 | if credentials is None or "username" not in credentials or "password" not in credentials: 278 | if "AD_BIND_USERNAME" not in self.config or self.config["AD_BIND_USERNAME"] is None: 279 | raise ValueError("AD_BIND_USERNAME must be set") 280 | if "AD_BIND_PASSWORD" not in self.config or self.config["AD_BIND_PASSWORD"] is None: 281 | raise ValueError("AD_BIND_PASSWORD must be set") 282 | 283 | credentials = dict() 284 | credentials["username"] = self.config["AD_BIND_USERNAME"] 285 | credentials["password"] = self.config["AD_BIND_PASSWORD"] 286 | 287 | username = credentials["username"].split("\\")[-1] 288 | if "@" not in username and "cn=" not in username.lower(): 289 | username = "{0}@{1}".format(username, self.config["AD_DOMAIN"]) 290 | 291 | password = credentials["password"] 292 | 293 | if self.ad.get_option(ldap.OPT_X_TLS_DEMAND): 294 | self.ad.start_tls_s() 295 | 296 | self.ad.bind_s(username, password) 297 | return True 298 | 299 | def unbind(self): 300 | """ 301 | Unbind from the Active Directory server 302 | """ 303 | self.ad.unbind() 304 | 305 | 306 | class EasyAD(object): 307 | """ 308 | A high-level class for interacting with Active Directory 309 | 310 | Attributes: 311 | user_attributes: A default list of attributes to return from a user query 312 | group_attributes: A default list of attributes to return from a user query 313 | """ 314 | 315 | user_attributes = [ 316 | "businessCategory", 317 | "businessSegment", 318 | "businessSegmentDescription", 319 | "businessUnitDescription", 320 | "c", 321 | "cn", 322 | "co", 323 | "comment", 324 | "company", 325 | "costCenter", 326 | "countryCode", 327 | "department", 328 | "departmentNumber", 329 | "description", 330 | "displayName", 331 | "distinguishedName", 332 | "employeeClass", 333 | "employeeNumber", 334 | "employeeStatus", 335 | "employeeType", 336 | "enterpriseBusinessUnitDescription", 337 | "givenName", 338 | "hireDate", 339 | "homeDirectory", 340 | "homeDrive", 341 | "iamFullName", 342 | "ipPhone", 343 | "jobFamilyDescription", 344 | "jobFunctionDescription", 345 | "jobTrack", 346 | "l", 347 | "LastLogonTimestamp", 348 | "lockoutTime", 349 | "mail", 350 | "mailNickname", 351 | "manager", 352 | "memberOf", 353 | "msExchRecipientTypeDetails", 354 | "phonebookVisibility", 355 | "physicalDeliveryOfficeName", 356 | "postalCode", 357 | "prefFirstName", 358 | "proxyAddresses", 359 | "pwdLastSet", 360 | "rehireDate", 361 | "roomNumber", 362 | "sAMAccountName", 363 | "scriptPath", 364 | "showInAddressBook", 365 | "siteCode", 366 | "siteName", 367 | "sn", 368 | "st", 369 | "streetAddress", 370 | "telephoneNumber", 371 | "thumbnailPhoto", 372 | "title", 373 | "uid", 374 | "userAccountControl", 375 | "userPrincipalName", 376 | "whenCreated" 377 | ] 378 | 379 | group_attributes = [ 380 | "cn", 381 | "distinguishedName", 382 | "managedBy", 383 | "member", 384 | "name" 385 | ] 386 | 387 | # Another python 2 support hack 388 | user_attributes = list(map(lambda x: str(x), user_attributes)) 389 | group_attributes = list(map(lambda x: str(x), group_attributes)) 390 | 391 | def __init__(self, config): 392 | """ 393 | Initializes an EasyAD object 394 | 395 | Args: 396 | config: A dictionary of configuration settings 397 | Required: 398 | AD_SERVER: The hostname of the Active Directory Server 399 | AD_DOMAIN: The domain to bind to, in TLD format 400 | Optional: 401 | AD_REQUIRE_TLS: Require a TLS connection. True by default. 402 | AD_CA_CERT_FILE: The path to the root CA certificate file 403 | AD_BASE_DN: Overrides the base distinguished name. Derived from AD_DOMAIN by default. 404 | AD_PAGE_SIZE: Overrides the default page size of 1000 405 | AD_OPTIONS: A dictionary of other python-ldap options 406 | """ 407 | self.config = config 408 | base_dn = "" 409 | for part in self.config["AD_DOMAIN"].split("."): 410 | base_dn += "dc={0},".format(part) 411 | base_dn = base_dn.rstrip(",") 412 | if "AD_BASE_DN" not in self.config.keys() or self.config["AD_BASE_DN"] is None: 413 | self.config["AD_BASE_DN"] = base_dn 414 | self.user_attributes = EasyAD.user_attributes 415 | self.group_attributes = EasyAD.group_attributes 416 | 417 | def search(self, base=None, scope=ldap.SCOPE_SUBTREE, filter_string="(objectClass=*)", credentials=None, 418 | attributes=None, json_safe=False, page_size=None): 419 | """ 420 | Run a search of the Active Directory server, and get the results 421 | 422 | Args: 423 | base: Optionally override the DN of the base object 424 | scope: Optional scope setting, subtree by default. 425 | filter_string: Optional custom filter string 426 | credentials: Optionally override the bind credentials 427 | attributes: A list of attributes to return. If none are specified, all attributes are returned 428 | json_safe: If true, convert binary data to base64, and datetimes to human-readable strings 429 | page_size: Optionally override the number of results to return per LDAP page 430 | 431 | Returns: 432 | Results as a list of dictionaries 433 | 434 | Raises: 435 | ldap.LDAP_ERROR 436 | 437 | Notes: 438 | Setting a small number of search_attributes and return_attributes reduces server load and bandwidth 439 | respectively 440 | """ 441 | 442 | connection = ADConnection(self.config) 443 | results = [] 444 | first_pass = True 445 | 446 | if base is None: 447 | base = self.config["AD_BASE_DN"] 448 | 449 | if page_size is None: 450 | page_size = self.config["AD_PAGE_SIZE"] 451 | 452 | # Create the page control to work from 453 | pg_ctrl = SimplePagedResultsControl(criticality=True, size=page_size, cookie='') 454 | 455 | try: 456 | connection.bind(credentials) 457 | 458 | while first_pass or pg_ctrl.cookie: 459 | first_pass = False 460 | msgid = connection.ad.search_ext(base, 461 | scope=scope, 462 | filterstr=filter_string, 463 | attrlist=attributes, 464 | serverctrls=[pg_ctrl]) 465 | 466 | rtype, rdata, rmsgid, serverctrls = connection.ad.result3(msgid) 467 | results += process_ldap_results(rdata, json_safe=json_safe) 468 | 469 | pctrls = _get_page_controls(serverctrls) 470 | if pctrls is None: 471 | print("Warning: Server ignores RFC 2696 control", file=stderr) 472 | break 473 | 474 | # Update the cookie 475 | pg_ctrl.cookie = serverctrls[0].cookie 476 | 477 | finally: 478 | connection.unbind() 479 | 480 | return results 481 | 482 | def get_user(self, user_string, base=None, credentials=None, attributes=None, json_safe=False): 483 | """ 484 | Searches for a unique user object and returns its attributes 485 | 486 | Args: 487 | user_string: A userPrincipalName, sAMAccountName, uid, email address, or distinguishedName 488 | base: Optionally override the base dn 489 | credentials: A optional dictionary of the username and password to use. 490 | If credentials are not passed, the credentials from the initial EasyAD configuration are used. 491 | attributes: An optional list of attributes to return. Otherwise uses self.user_attributes. 492 | To return all attributes, pass an empty list. 493 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 494 | 495 | Returns: 496 | A dictionary of user attributes 497 | 498 | Raises: 499 | ValueError: Query returned no or multiple results 500 | 501 | Raises: 502 | ldap.LDAP_ERROR 503 | """ 504 | if base is None: 505 | base = self.config["AD_BASE_DN"] 506 | 507 | if attributes is None: 508 | attributes = self.user_attributes.copy() 509 | 510 | filter_string = "(&(objectClass=user)(|(userPrincipalName={0})(sAMAccountName={0})(uid={0})(mail={0})" \ 511 | "(distinguishedName={0})(proxyAddresses=SMTP:{0})))".format(escape_filter_chars(user_string)) 512 | 513 | results = self.search(base=base, 514 | filter_string=filter_string, 515 | credentials=credentials, 516 | attributes=attributes, 517 | json_safe=json_safe) 518 | 519 | if len(results) == 0: 520 | raise ValueError("No such user") 521 | elif len(results) > 1: 522 | raise ValueError("The query returned more than one result") 523 | 524 | return enhance_user(results[0], json_safe=json_safe) 525 | 526 | def authenticate_user(self, username, password, base=None, attributes=None, json_safe=False): 527 | """ 528 | Test if the given credentials are valid 529 | 530 | Args: 531 | username: The username 532 | password: The password 533 | base: Optionally overrides the base object DN 534 | attributes: A list of user attributes to return 535 | json_safe: Convert binary data to base64 and datetimes to human-readable strings 536 | 537 | Returns: 538 | A dictionary of user attributes if successful, or False if it failed 539 | 540 | Raises: 541 | ldap.LDAP_ERROR 542 | """ 543 | credentials = dict(username=username, password=password) 544 | try: 545 | user = self.get_user(username, 546 | credentials=credentials, 547 | base=base, 548 | attributes=attributes, 549 | json_safe=json_safe) 550 | return user 551 | except ldap.INVALID_CREDENTIALS: 552 | return False 553 | 554 | def get_group(self, group_string, base=None, credentials=None, attributes=None, json_safe=False): 555 | """ 556 | Searches for a unique group object and returns its attributes 557 | 558 | Args: 559 | group_string: A group name, cn, or dn 560 | base: Optionally override the base object dn 561 | credentials: A optional dictionary of the username and password to use. 562 | If credentials are not passed, the credentials from the initial EasyAD configuration are used. 563 | attributes: An optional list of attributes to return. Otherwise uses self.group_attributes. 564 | To return all attributes, pass an empty list. 565 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 566 | 567 | Returns: 568 | A dictionary of group attributes 569 | 570 | Raises: 571 | ValueError: Query returned no or multiple results 572 | ldap.LDAP_ERROR: An LDAP error occurred 573 | """ 574 | if base is None: 575 | base = self.config["AD_BASE_DN"] 576 | 577 | if attributes is None: 578 | attributes = self.group_attributes.copy() 579 | 580 | group_filter = "(&(objectClass=Group)(|(cn={0})(distinguishedName={0})))".format( 581 | escape_filter_chars(group_string)) 582 | 583 | results = self.search(base=base, 584 | filter_string=group_filter, 585 | credentials=credentials, 586 | attributes=attributes, 587 | json_safe=json_safe) 588 | 589 | if len(results) == 0: 590 | raise ValueError("No such group") 591 | elif len(results) > 1: 592 | raise ValueError("The query returned more than one result") 593 | 594 | group = results[0] 595 | if "member" in group.keys(): 596 | group["member"] = sorted(group["member"], key=lambda dn: dn.lower()) 597 | 598 | return group 599 | 600 | def resolve_user_dn(self, user, base=None, credentials=None, json_safe=False): 601 | """ 602 | Returns a user's DN when given a principalAccountName, sAMAccountName, email, or DN 603 | 604 | Args: 605 | user: A principalAccountName, sAMAccountName, email, DN, or a dictionary containing a DN 606 | base: Optionally overrides the base object DN 607 | credentials: An optional dictionary of the username and password to use 608 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 609 | 610 | Returns: 611 | The user's DN 612 | 613 | Raises: 614 | ldap.LDAP_ERROR 615 | """ 616 | if isinstance(user, dict): 617 | user = user["distinguishedName"] 618 | elif isinstance(user, str) or isinstance(user, unicode): 619 | if not user.lower().startswith("cn="): 620 | user = self.get_user(user, 621 | base=base, 622 | credentials=credentials, 623 | attributes=["distinguishedName"], 624 | json_safe=json_safe)["distinguishedName"] 625 | else: 626 | raise ValueError("User passed as an unsupported data type") 627 | return user 628 | 629 | def resolve_group_dn(self, group, base=None, credentials=None, json_safe=False): 630 | """ 631 | Returns a group's DN when given a principalAccountName, sAMAccountName, email, or DN 632 | 633 | Args: 634 | group: A group name, CN, or DN, or a dictionary containing a DN 635 | base: Optionally overrides the base object DN 636 | credentials: An optional dictionary of the username and password to use 637 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 638 | 639 | Returns: 640 | The groups's DN 641 | 642 | Raises: 643 | ldap.LDAP_ERROR 644 | """ 645 | if isinstance(group, dict): 646 | group = group["distinguishedName"] 647 | elif isinstance(group, str) or isinstance(group, unicode): 648 | if not group.lower().startswith("cn="): 649 | group = self.get_group(group, 650 | base=base, 651 | credentials=credentials, 652 | attributes=["distinguishedName"], 653 | json_safe=json_safe)["distinguishedName"] 654 | else: 655 | raise ValueError("Group passed as an unsupported data type") 656 | return group 657 | 658 | def get_all_user_groups(self, user, base=None, credentials=None, json_safe=False): 659 | """ 660 | Returns a list of all group DNs that a user is a member of, including nested groups 661 | 662 | Args: 663 | user: A username, distinguishedName, or a dictionary containing a distinguishedName 664 | base: Overrides the configured base object dn 665 | credentials: An optional dictionary of the username and password to use 666 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 667 | 668 | Returns: 669 | A list of group DNs that the user is a member of, including nested groups 670 | 671 | Raises: 672 | ldap.LDAP_ERROR 673 | 674 | Notes: 675 | This call can be taxing on an AD server, especially when used frequently. 676 | If you just need to check if a user is a member of a group, 677 | use EasyAD.user_is_member_of_group(). It is *much* faster. 678 | """ 679 | user_dn = self.resolve_user_dn(user) 680 | filter_string = "(member:1.2.840.113556.1.4.1941:={0})".format(escape_filter_chars(user_dn)) 681 | 682 | results = self.search(base=base, 683 | filter_string=filter_string, 684 | credentials=credentials, 685 | json_safe=json_safe) 686 | 687 | return sorted(list(map(lambda x: x["distinguishedName"], results)), key=lambda s: s.lower()) 688 | 689 | def get_all_users_in_group(self, group, base=None, credentials=None, json_safe=False): 690 | """ 691 | Returns a list of all user DNs that are members of a given group, including from nested groups 692 | 693 | Args: 694 | group: A group name, cn, or dn 695 | base: Overrides the configured base object dn 696 | credentials: An optional dictionary of the username and password to use 697 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 698 | 699 | Returns: 700 | A list of all user DNs that are members of a given group, including users from nested groups 701 | 702 | Raises: 703 | ldap.LDAP_ERROR 704 | 705 | Notes: 706 | This call can be taxing on an AD server, especially when used frequently. 707 | If you just need to check if a user is a member of a group, 708 | use EasyAD.user_is_member_of_group(). It is *much* faster. 709 | """ 710 | group = self.resolve_group_dn(group) 711 | if base is None: 712 | base = self.config["AD_BASE_DN"] 713 | filter_string = "(&(objectClass=user)(memberof:1.2.840.113556.1.4.1941:={0}))".format( 714 | escape_filter_chars(group)) 715 | 716 | results = self.search(base=base, 717 | scope=ldap.SCOPE_SUBTREE, 718 | filter_string=filter_string, 719 | attributes=["distinguishedName"], 720 | credentials=credentials, 721 | json_safe=json_safe) 722 | 723 | return sorted(list(map(lambda x: x["distinguishedName"], 724 | process_ldap_results(results, json_safe=json_safe))), key=lambda s: s.lower()) 725 | 726 | def user_is_member_of_group(self, user, group, base=None, credentials=None, json_safe=False): 727 | """ 728 | Tests if a given user is a member of the given group 729 | 730 | Args: 731 | user: A principalAccountName, sAMAccountName, email, or DN 732 | group: A group name, cn, or dn 733 | base: An optional dictionary of the username and password to use 734 | credentials: An optional dictionary of the username and password to use 735 | 736 | Raises: 737 | ldap.LDAP_ERROR 738 | 739 | Returns: 740 | A boolean that indicates if the given user is a member of the given group 741 | """ 742 | user = self.resolve_user_dn(user, base=base, credentials=credentials, json_safe=json_safe) 743 | group = self.resolve_group_dn(group, base=base, credentials=credentials, json_safe=json_safe) 744 | return len(self.get_all_users_in_group(group, base=user, credentials=credentials, json_safe=json_safe)) > 0 745 | 746 | def search_for_users(self, user_string, base=None, search_attributes=None, return_attributes=None, credentials=None, 747 | json_safe=False): 748 | """ 749 | Returns matching user objects as a list of dictionaries 750 | 751 | Args: 752 | user_string: The substring to search for 753 | base: Optionally override the base object's DN 754 | search_attributes: The attributes to search through, with binary data removed 755 | easyad.EasyAD.user_attributes by default 756 | return_attributes: A list of attributes to return. easyad.EasyAD.user_attributes by default 757 | credentials: Optionally override the bind credentials 758 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 759 | 760 | Returns: 761 | Results as a list of dictionaries 762 | 763 | Raises: 764 | ldap.LDAP_ERROR 765 | 766 | Notes: 767 | Setting a small number of search_attributes and return_attributes reduces server load and bandwidth 768 | respectively 769 | 770 | """ 771 | if search_attributes is None: 772 | search_attributes = EasyAD.user_attributes.copy() 773 | if "memberOf" in search_attributes: 774 | search_attributes.remove("memberOf") 775 | if "thumbnailPhoto" in search_attributes: 776 | search_attributes.remove("thumbnailPhoto") 777 | 778 | if return_attributes is None: 779 | return_attributes = EasyAD.user_attributes.copy() 780 | 781 | generated_attributes = ["disabled", "passwordExpired", "passwordNeverExpires", "smartcardRequired"] 782 | for attribute in generated_attributes: 783 | if attribute in return_attributes: 784 | if "userAccountControl" not in return_attributes: 785 | return_attributes.append("userAccountControl") 786 | break 787 | 788 | filter_string = "" 789 | for attribute in search_attributes: 790 | filter_string += "({0}=*{1}*)".format(attribute, escape_filter_chars(user_string)) 791 | 792 | filter_string = "(&(objectClass=User)(|{0}))".format(filter_string) 793 | 794 | results = self.search(base=base, 795 | filter_string=filter_string, 796 | attributes=return_attributes, 797 | credentials=credentials, 798 | json_safe=json_safe) 799 | 800 | results = list(map(lambda user: enhance_user(user, json_safe=json_safe), results)) 801 | 802 | return results 803 | 804 | def search_for_groups(self, group_string, base=None, search_attributes=None, return_attributes=None, 805 | credentials=None, json_safe=False): 806 | """ 807 | Returns matching group objects as a list of dictionaries 808 | Args: 809 | group_string: The substring to search for 810 | base: Optionally override the base object's DN 811 | search_attributes: The attributes to search through, with binary data removed 812 | easyad.EasyAD.group_attributes by default 813 | return_attributes: A list of attributes to return. easyad.EasyAD.group_attributes by default 814 | credentials: Optionally override the bind credentials 815 | json_safe: If true, convert binary data to base64 and datetimes to human-readable strings 816 | 817 | Returns: 818 | Results as a list of dictionaries 819 | 820 | Raises: 821 | ldap.LDAP_ERROR 822 | 823 | Notes: 824 | Setting a small number of search_attributes and return_attributes reduces server load and bandwidth 825 | respectively 826 | 827 | """ 828 | if search_attributes is None: 829 | search_attributes = EasyAD.group_attributes.copy() 830 | if "member" in search_attributes: 831 | search_attributes.remove("member") 832 | 833 | if return_attributes is None: 834 | return_attributes = EasyAD.group_attributes.copy() 835 | 836 | filter_string = "" 837 | for attribute in search_attributes: 838 | filter_string += "({0}=*{1}*)".format(attribute, escape_filter_chars(group_string)) 839 | 840 | filter_string = "(&(objectClass=Group)(|{0}))".format(filter_string) 841 | 842 | results = self.search(base=base, 843 | filter_string=filter_string, 844 | attributes=return_attributes, 845 | credentials=credentials, 846 | json_safe=json_safe) 847 | 848 | return results 849 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyldap 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | # This flag says that the code is written to work on both Python 2 and Python 3 | # 3. If at all possible, it is good practice to do this. If you cannot, you 4 | # will need to generate wheels for each Python version that you support. 5 | universal=1 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """A setuptools based setup module. 5 | See: 6 | https://packaging.python.org/en/latest/distributing.html 7 | https://github.com/pypa/sampleproject 8 | """ 9 | 10 | from __future__ import absolute_import 11 | 12 | # Always prefer setuptools over distutils 13 | from setuptools import setup 14 | # To use a consistent encoding 15 | from codecs import open 16 | from os import path 17 | 18 | here = path.abspath(path.dirname(__file__)) 19 | 20 | # Get the long description from the README file 21 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 22 | long_description = f.read() 23 | 24 | setup( 25 | name='easyad', 26 | 27 | # Versions should comply with PEP440. For a discussion on single-sourcing 28 | # the version across setup.py and the project code, see 29 | # https://packaging.python.org/en/latest/single_source_version.html 30 | version="1.1.0", 31 | 32 | description="A simple Python module for common Active Directory authentication and lookup tasks", 33 | long_description=long_description, 34 | 35 | # The project's main homepage. 36 | url='https://github.com/seanthegeek/easyad', 37 | 38 | # Author details 39 | author='Sean Whalen', 40 | author_email='whalenster@gmail.com', 41 | 42 | # Choose your license 43 | license='Apache 2.0', 44 | 45 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 46 | classifiers=[ 47 | # How mature is this project? Common values are 48 | # 3 - Alpha 49 | # 4 - Beta 50 | # 5 - Production/Stable 51 | 'Development Status :: 5 - Production/Stable', 52 | 53 | # Indicate who your project is intended for 54 | 'Topic :: Security', 55 | 'Intended Audience :: Developers', 56 | 'Intended Audience :: System Administrators', 57 | 'Operating System :: OS Independent', 58 | 59 | 60 | # Pick your license as you wish (should match "license" above) 61 | 'License :: OSI Approved :: Apache Software License', 62 | 63 | # Specify the Python versions you support here. In particular, ensure 64 | # that you indicate whether you support Python 2, Python 3 or both. 65 | 'Programming Language :: Python :: 2', 66 | 'Programming Language :: Python :: 2.6', 67 | 'Programming Language :: Python :: 2.7', 68 | 'Programming Language :: Python :: 3', 69 | 'Programming Language :: Python :: 3.3', 70 | 'Programming Language :: Python :: 3.4', 71 | 'Programming Language :: Python :: 3.5', 72 | ], 73 | 74 | # What does your project relate to? 75 | keywords='ActiveDirectory, WindowsServer, authentication, LDAP', 76 | 77 | # You can just specify the packages manually here if your project is 78 | # simple. Or you can use find_packages(). 79 | # packages=find_packages(exclude=['contrib', 'docs', 'tests']), 80 | 81 | 82 | # Alternatively, if you want to distribute just a my_module.py, uncomment 83 | # this: 84 | py_modules=["easyad"], 85 | 86 | # List run-time dependencies here. These will be installed by pip when 87 | # your project is installed. For an analysis of "install_requires" vs pip's 88 | # requirements files see: 89 | # https://packaging.python.org/en/latest/requirements.html 90 | install_requires=['pyldap'], 91 | ) 92 | --------------------------------------------------------------------------------