Repository: jirutka/ssh-ldap-pubkey Branch: master Commit: 7ba9cebd22d7 Files: 23 Total size: 42.3 KB Directory structure: gitextract_5m6ot60j/ ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ └── ci.yml ├── .gitignore ├── CHANGELOG.adoc ├── LICENSE ├── MANIFEST.in ├── README.md ├── bin/ │ ├── ssh-ldap-pubkey │ └── ssh-ldap-pubkey-wrapper ├── etc/ │ ├── ldap.conf │ └── openssh-lpk.schema ├── requirements.txt ├── setup.cfg ├── setup.py ├── ssh_ldap_pubkey/ │ ├── __init__.py │ ├── config.py │ └── exceptions.py └── tests/ ├── __init__.py ├── config_test.py ├── fixtures/ │ ├── invalid_ssh_keys │ └── valid_ssh_keys └── functions_test.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # http://editorconfig.org/ root = true [*] charset = utf-8 end_of_line = lf indent_style = space indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true [*.schema] indent_style = tab [bin/ssh-ldap-pubkey-wrapper] indent_style = tab [*.yml] indent_size = 2 ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: jirutka ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: - push - pull_request jobs: test: name: Test on Python ${{ matrix.python }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: python: - '3.6' - '3.7' - '3.8' - '3.9' - '3.10' steps: - uses: actions/checkout@v3 - name: Install Python ${{ matrix.python }} uses: actions/setup-python@v3 with: python-version: ${{ matrix.python }} - name: Install system dependencies run: | sudo apt update -qq sudo apt install -q libldap-dev libsasl2-dev - name: Install project requirements run: | pip install -U -r requirements.txt python3 setup.py install - name: Run tests run: py.test --cov=ssh_ldap_pubkey --cov-report term -vv - name: Run linter run: pycodestyle publish: name: Publish to PyPI if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' needs: [test] runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v3 with: python-version: '3.10' - name: Build source tarball run: python3 setup.py sdist - name: Publish package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized files __pycache__/ *.py[cod] # Distribution / packaging env/ build/ dist/ /MANIFEST *.egg-info/ *.egg # pytest .cache/ # Coverage report .coverage ================================================ FILE: CHANGELOG.adoc ================================================ = Changelog :repo-uri: https://github.com/jirutka/ssh-ldap-pubkey :issues: {repo-uri}/issues :pulls: {repo-uri}/pull :tags: {repo-uri}/releases/tag == unreleased == link:{tags}/v1.4.0[1.4.0] (2022-05-19) * Replace deprecated pyldap with python-ldap. * Drop support for Python 2. * Replace `base64.decodestring()`, which has been removed in Python 3.9, with `base64.decodebytes()`. ({issues}/49[#49]) == link:{tags}/v1.3.3[1.3.3] (2020-05-15) * Fix wrapper script to be compatible with Busybox `logger(1)`. ({issues}/43[#43]) * Fix `tls_reqcert` value of never to be accepted when defined. (PR {pulls}/47[#47]) == link:{tags}/v1.3.2[1.3.2] (2019-08-21) * Fix broken keys listing due to over-wrapping of search filter. ({issues}/34[#34]) == link:{tags}/v1.3.1[1.3.1] (2019-04-27) * Fix handling of complex LDAP filters. (PR {pulls}/33[#33]) * Retire Python 3.3 support (due to pyldap). (PR {pulls}/33[#33]) == link:{tags}/v1.3.0[1.3.0] (2018-03-02) * Add support for SASL GSSAPI (Kerberos) authentication. (PR {pulls}/27[#27]) * Allow to disable LDAP referrals using option `referrals`. == link:{tags}/v1.2.0[1.2.0] (2017-02-24) * Make pubkey class and attribute configurable. (PR {pulls}/21[#21]) == link:{tags}/v1.1.1[1.1.1] (2017-01-04) * Fix parsing of `uri` from config file. ({issues}/20[#20]) == link:{tags}/v1.1.0[1.1.0] (2016-12-28) * Add support for multiple LDAP servers in ldap.conf. * Allow to pass multiple `--uri` options. == link:{tags}/v1.0.0[1.0.0] (2016-10-01) * Refactor code-base, split it into a module and CLI script. * Add support for `TLS_REQCERT` option. (PR {pulls}/11[#12]) * Add support for StartTLS. (PR {pulls}/14[#14]) * Replace python-ldap with pyldap. * Make it compatible with Python 3. ({issues}/15[#15]) * Change sshPublicKey in ldapPublicKey objectclass to be optional. == link:{tags}/v0.4.1[0.4.1] (2015-10-08) * Catch `ldap.INSUFFICIENT_ACCESS` exception when adding/removing key to/from LDAP and print error message. (PR {pulls}/9[#9]) == link:{tags}/v0.4.0[0.4.0] (2015-02-07) * Add option `-D` to specify the bind DN. ({issues}/7[#7]) == link:{tags}/v0.3.3[0.3.3] (2015-02-07) * Fix keys count in the wrapper script to return 0 instead of 1 when no key is found. (PR {pulls}/8[#8]) == link:{tags}/v0.3.2[0.3.2] (2014-12-14) * Remove unnecessary absolute path in wrapper script. ({issues}/6[#6]) == link:{tags}/v0.3.1[0.3.1] (2014-09-16) * Log all info and warn messages to stderr instead of stdout. == link:{tags}/v0.3.0[0.3.0] (2014-09-16) * Print warnings to stderr, not stdout. * Change script option `-h` to `-H` to avoid confusion with help. == link:{tags}/v0.2.3[0.2.3] (2014-09-02) * Display username in the password prompt. (PR {pulls}/3[#3]) * Fix SSH key validation to accept keys without a comment part. * Treat keys in config file as case-insensitive (always convert them to lowercase). == link:{tags}/v0.2.2[0.2.2] (2014-04-23) * Add basic validation of SSH public key format. * Check if public key doesn’t already exist in the user’s entry before adding it. == link:{tags}/v0.2.1[0.2.1] (2014-04-22) * Handle tls_cacertdir configuration directive. == link:{tags}/v0.2[0.2] (2014-04-21) * First public release. ================================================ FILE: LICENSE ================================================ The MIT License Copyright 2014-present Jakub Jirutka . Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: MANIFEST.in ================================================ include README.md include CHANGELOG.adoc include LICENSE include MANIFEST.in include etc/ldap.conf include etc/openssh-lpk.schema ================================================ FILE: README.md ================================================ OpenSSH / LDAP public keys ========================== [![Build Status](https://github.com/jirutka/ssh-ldap-pubkey/workflows/CI/badge.svg)](https://github.com/jirutka/ssh-ldap-pubkey/actions?query=workflow%3A%22CI%22) [![Code Climate](https://codeclimate.com/github/jirutka/ssh-ldap-pubkey/badges/gpa.svg)](https://codeclimate.com/github/jirutka/ssh-ldap-pubkey) [![version](https://img.shields.io/pypi/v/ssh-ldap-pubkey.svg?style=flat)](https://pypi.python.org/pypi/ssh-ldap-pubkey) This project provides an utility to manage SSH public keys stored in LDAP and also a script for OpenSSH server to load authorized keys from LDAP. Why? ---- When you have dozen of servers it becomes difficult to manage your authorized keys. You have to copy all your public keys to `~/.ssh/authorized_keys` on every server you want to login to. And what if you someday change your keys? It’s a good practice to use some kind of a centralized user management, usually an LDAP server. There you have user’s login, uid, e-mail, … and password. What if we could also store public SSH keys on LDAP server? With this utility it’s easy as pie. Alternatives ------------ If you need just a lightweight utility for OpenSSH server to load authorized keys from LDAP, then you can use [ssh-getkey-ldap](https://github.com/jirutka/ssh-getkey-ldap) written in Lua or [this one](https://gist.github.com/jirutka/b15c31b2739a4f3eab63) written in POSIX shell (but it requires `ldapsearch` utility and may not work well on some systems). Requirements ------------ * Python 3.6+ * [python-ldap] 3.x * [docopt] 0.6.x You can install both Python modules from PyPI. python-ldap requires additional system dependencies – OpenLDAP. Refer to [Stack Overflow](http://stackoverflow.com/q/4768446/240963) for distribution-specific information. Installation ------------ ### PyPI: pip install ssh-ldap-pubkey ### Alpine Linux apk add ssh-ldap-pubkey Note: The package is currently in the (official) _community_ repository; make sure that you have community in `/etc/apk/repositories`. Usage ----- List SSH public keys stored in LDAP for the current user: ssh-ldap-pubkey list List SSH public keys stored in LDAP for the specified user: ssh-ldap-pubkey list -u flynn Add the specified SSH public key for the current user to LDAP: ssh-ldap-pubkey add ~/.ssh/id_rsa.pub Remove SSH public key(s) of the current user that matches the specified pattern: ssh-ldap-pubkey del flynn@grid Specify LDAP URI and base DN on command line instead of configuration file: ssh-ldap-pubkey list -b ou=People,dc=encom,dc=com -H ldaps://encom.com -u flynn As the LDAP manager, add SSH public key to LDAP for the specified user: ssh-ldap-pubkey add -D cn=Manager,dc=encom,dc=com -u flynn ~/.ssh/id_rsa.pub Show help for other options: ssh-ldap-pubkey --help Configuration ------------- Configuration is read from /etc/ldap.conf — file used by LDAP nameservice switch library and the LDAP PAM module. An example file is included in [etc/ldap.conf][ldap.conf]. The following subset of parameters are used: * **uri** ... URI(s) of the LDAP server(s) to connect to, separated by a space. The URI scheme may be ldap, or ldaps. Default is `ldap://localhost`. * **nss_base_passwd** ... distinguished name (DN) of the search base. * **base** ... distinguished name (DN) of the search base. Used when *nss_base_passwd* is not set. * **scope** ... search scope; _sub_, _one_, or _base_ (default is _sub_). * **referrals** ... should client automatically follow referrals returned by LDAP servers (default is _on_)? * **pam_filter** ... filter to use when searching for the user’s entry, additional to the login attribute value assertion (`pam_login_attribute=`). Default is _objectclass=posixAccount_. * **pam_login_attribute** ... the user ID attribute (default is _uid_). * **ldap_version** ... LDAP version to use (default is 3). * **sasl** ... enable SASL and specify mechanism to use (currently only GSSAPI is supported). * **binddn** ... distinguished name (DN) to bind when reading the user’s entry (default is to bind anonymously). * **bindpw** ... credentials to bind with when reading the user’s entry (default is none). * **ssl** ... LDAP SSL/TLS method; _off_, _on_, or _start_tls_. If you use LDAP over SSL (i.e. URI `ldaps://`), leave this empty. * **timelimit** ... search time limit in seconds (default is 10). * **bind_timelimit** ... bind/connect time limit in seconds (default is 10). If multiple URIs are specified in _uri_, then the next one is tried after this timeout. * **tls_cacertdir** ... path of the directory with CA certificates for LDAP server certificate verification. * **pubkey_class** ... objectClass that should be added/removed to/from the user’s entry when adding/removing first/last public key and the *pubkey_attr* is mandatory for this class. This is needed for the original openssh-lpk.schema (not for the one in this repository). Default is `ldapPublicKey`. * **pubkey_attr** ... name of LDAP attribute used for SSH public keys (default is `sshPublicKey`). The only required parameter is *nss_base_passwd* or _base_, others have sensitive defaults. You might want to define _uri_ parameter as well. These parameters can be also defined/overriden with `--bind` and `--uri` options on command line. For more information about these parameters refer to ldap.conf man page. Set up OpenSSH server -------------------- To configure OpenSSH server to fetch users’ authorized keys from LDAP server: 1. Make sure that you have installed **ssh-ldap-pubkey** and **ssh-ldap-pubkey-wrapper** in `/usr/bin` with owner `root` and mode `0755`. 2. Add these two lines to /etc/ssh/sshd_config: AuthorizedKeysCommand /usr/bin/ssh-ldap-pubkey-wrapper AuthorizedKeysCommandUser nobody 3. Restart sshd and check log file if there’s no problem. Note: This method is supported by OpenSSH since version 6.2-p1 (or 5.3 onRedHat). If you have an older version and can’t upgrade, for whatever weird reason, use [openssh-lpk] patch instead. Set up LDAP server ------------------ Just add the [openssh-lpk.schema] to your LDAP server, **or** add an attribute named `sshPublicKey` to any existing schema which is already defined in people entries. That’s all. Note: Presumably, you’ve already set up your LDAP server for centralized unix users management, i.e. you have the [NIS schema](http://www.zytrax.com/books/ldap/ape/nis.html) and users in LDAP. License ------- This project is licensed under [MIT license](http://opensource.org/licenses/MIT). [python-ldap]: https://pypi.python.org/pypi/python-ldap/ [docopt]: https://pypi.python.org/pypi/docopt/ [ebuild]: https://github.com/cvut/gentoo-overlay/tree/master/sys-auth/ssh-ldap-pubkey [cvut-overlay]: https://github.com/cvut/gentoo-overlay [openssh-lpk]: http://code.google.com/p/openssh-lpk/ [ldap.conf]: https://github.com/jirutka/ssh-ldap-pubkey/blob/master/etc/ldap.conf [openssh-lpk.schema]: https://github.com/jirutka/ssh-ldap-pubkey/blob/master/etc/openssh-lpk.schema ================================================ FILE: bin/ssh-ldap-pubkey ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- """ ssh-ldap-pubkey - Utility to manage SSH public keys stored in LDAP. Usage: ssh-ldap-pubkey list [-H URI...] [options] ssh-ldap-pubkey add [-H URI...] [options] FILE ssh-ldap-pubkey del [-H URI...] [options] PATTERN ssh-ldap-pubkey --help - Read public key from stdin. FILE Path to the public key file to add. PATTERN Pattern that specifies public key(s) to delete, i.e. a complete key or just a part of it. Options: -b DN --base=DN Base DN where to search for the users' entry. If not provided, then it's read from the config file. -c FILE --conf=FILE Path of the ldap.conf (default is /etc/ldap.conf). The ldap.conf is not required when at least --base is provided. -D DN --binddn=DN DN to bind with instead of the user's DN. -H URI... --uri=URI... URI of the LDAP server to connect; loaded from the config file by default. If not defined even there, then it defaults to ldap://localhost. -q --quiet Be quiet. -u LOGIN --user=LOGIN Login of the user to bind as and change public key(s) (default is the current user). -v --version Show version information. -h --help Show this message. """ from __future__ import print_function import sys from docopt import docopt from getpass import getpass, getuser from os import access, R_OK from ssh_ldap_pubkey import LdapSSH, Error, keyname from ssh_ldap_pubkey.config import LdapConfig from ssh_ldap_pubkey import __versionstr__ DEFAULT_CONFIG_PATH = '/etc/ldap.conf' quiet = False def read_file(path): """Read file and return its content with stripped newlines. This is foolproof against some users that are unable to copy keys properly. """ with open(path, 'r') as f: return ''.join(f.readlines()).strip() def read_stdin(): """Read from standard input and strip newlines. This is foolproof against some users that are unable to copy keys properly. """ return ''.join(sys.stdin.readlines()).strip() def halt(msg, code=1): """Print error message to stderr and exit with the specified code.""" print('Error: ' + msg, file=sys.stderr) exit(code) def info(msg, *args): """Print message to stderr unless we're quiet.""" if not quiet: print(msg % args, file=sys.stderr) def main(**kwargs): global quiet quiet = kwargs['--quiet'] confpath = kwargs['--conf'] or DEFAULT_CONFIG_PATH login = kwargs['--user'] or getuser() passw = None if not access(confpath, R_OK): info("Notice: Could not read config: %s; running with defaults.", confpath) confpath = None conf = LdapConfig(confpath) if kwargs['--uri']: conf.uris = kwargs['--uri'] if kwargs['--base']: conf.base = kwargs['--base'] # prompt for password if kwargs['--binddn']: conf.bind_dn = kwargs['--binddn'] conf.bind_pass = getpass("Enter LDAP password for '%s': " % conf.bind_dn) elif kwargs['add'] or kwargs['del']: passw = getpass("Enter login (LDAP) password for user '%s': " % login) ldapssh = LdapSSH(conf) try: ldapssh.connect() if kwargs['add']: filesrc = kwargs['FILE'] and kwargs['FILE'] != '-' pubkey = read_file(kwargs['FILE']) if filesrc else read_stdin() ldapssh.add_pubkey(login, passw, pubkey) info("Key has been stored: %s", keyname(pubkey)) elif kwargs['del']: keys = ldapssh.find_and_remove_pubkeys(login, passw, kwargs['PATTERN']) if keys: info('Deleted keys:') print('\n'.join(keys)) else: info('No keys found to delete.') else: # list keys = ldapssh.find_pubkeys(login) if keys: print('\n'.join(keys)) else: info('No public keys found.') except Error as e: halt(str(e), e.code) except IOError as e: halt("%s: %s" % (e.strerror, e.filename), 1) finally: ldapssh.close() if __name__ == '__main__': kwargs = docopt(__doc__, version="ssh-ldap-pubkey %s" % __versionstr__) main(**kwargs) ================================================ FILE: bin/ssh-ldap-pubkey-wrapper ================================================ #!/bin/sh # # Wrapper script for ssh-ldap-pubkey to be used as AuthorizedKeysCommand # in OpenSSHd. set -eu SSH_USER="$1" KEYS="$(ssh-ldap-pubkey list -q -u "$SSH_USER")" KEYS_COUNT="$(printf '%s\n' "$KEYS" | grep '^ssh' | wc -l)" logger -t sshd -p info \ "Loaded ${KEYS_COUNT} SSH public key(s) from LDAP for user: ${SSH_USER}" printf '%s\n' "$KEYS" ================================================ FILE: etc/ldap.conf ================================================ # /etc/ldap.conf # # This is the configuration file for OpenSSH LDAP Public Keys (ssh-ldap-pubkey). # # This file actually uses a subset of directives from configuration file of the # LDAP nameservice switch library and the LDAP PAM module, so the same file can # be used for all these services. # # Specifies the URI(s) of the LDAP server(s) to connect to. The URI scheme may # be ldap, or ldaps, specifying LDAP over TCP or SSL respectively. A port # number can be specified; the default port number for the selected protocol # is used if omitted. uri ldap://localhost # The distinguished name of the search base. base dc=example,dc=org # The LDAP version to use. Default is 3 if supported by client library. #ldap_version 3 # Enable SASL and specify mechanism to use (currently supported: GSSAPI). #sasl GSSAPI # The distinguished name to bind to the server with. # Default is to bind anonymously. #binddn cn=proxyuser,dc=example,dc=org # The credentials to bind with. Default is no credential. #bindpw secret # The search scope; sub, one, or base. scope one # Specifies if the client should automatically follow referrals returned # by LDAP servers. This must be typically disabled for Active Directory. # Default is "on". referrals off # Search timelimit in seconds (0 for indefinite). timelimit 5 # Bind/connect timelimit (0 for indefinite). bind_timelimit 5 # The filter to use when retrieving user information, additional to the login # attribute value assertion (pam_login_attribute=). #pam_filter objectclass=posixAccount # The user ID attribute (defaults to 'uid'). #pam_login_attribute uid # RFC2307bis naming contexts # Syntax is: # nss_base_XXX base?scope?filter # where scope is {base,one,sub} and filter is a filter to be &'d with the # default filter. nss_base_passwd ou=People,dc=example,dc=org?one # CA certificates for server certificate verification. tls_cacertdir /etc/ssl/certs # Name of LDAP attribute used for SSH public keys. pubkey_attr sshPublicKey ================================================ FILE: etc/openssh-lpk.schema ================================================ # # LDAP Public Key Patch schema for use with openssh-ldappubkey # Author: Eric AUGE # # Based on the proposal of : Mark Ruijter # # octetString SYNTAX attributetype ( 1.3.6.1.4.1.24552.500.1.1.1.13 NAME 'sshPublicKey' DESC 'OpenSSH Public key' EQUALITY octetStringMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 ) # printableString SYNTAX yes|no objectclass ( 1.3.6.1.4.1.24552.500.1.1.2.0 NAME 'ldapPublicKey' SUP top AUXILIARY DESC 'OpenSSH LPK objectclass' MUST uid MAY sshPublicKey ) ================================================ FILE: requirements.txt ================================================ pycodestyle pytest pytest-cov pytest-describe pytest-mock ================================================ FILE: setup.cfg ================================================ [pycodestyle] max-line-length = 100 # E241 and E302 should be excluded only in tests, but I don't know how to configure it. ignore = E701,E731,E241,E302 exclude = .git/,build/,dist/,docs/,setup.py [tools:pytest] describe_prefixes = describe_ context_ ================================================ FILE: setup.py ================================================ #!/usr/bin/env python3 import sys from setuptools import setup setup( name='ssh-ldap-pubkey', version='1.4.0', url='https://github.com/jirutka/ssh-ldap-pubkey', description='Utility to manage SSH public keys stored in LDAP.', long_description=open('README.md', 'r').read(), long_description_content_type='text/markdown', author='Jakub Jirutka', author_email='jakub@jirutka.cz', license='MIT', packages=['ssh_ldap_pubkey'], scripts=['bin/ssh-ldap-pubkey', 'bin/ssh-ldap-pubkey-wrapper'], install_requires=[ 'docopt>=0.6.2,<0.7.0', 'python-ldap>=3.0.0,<4' ], classifiers=[ 'Environment :: Console', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: POSIX', 'Programming Language :: Python :: 3', 'Topic :: System', 'Topic :: Utilities' ] ) ================================================ FILE: ssh_ldap_pubkey/__init__.py ================================================ # -*- coding: utf-8 -*- import base64 import ldap import struct import sys from .exceptions import * VERSION = (1, 4, 0) __version__ = VERSION __versionstr__ = '.'.join(map(str, VERSION)) BAD_REQCERT_WARNING = u''' !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! WARNING: You've choosen to ignore TLS errors such as invalid certificate. !! !! This is a VERY BAD thing, never ever use this in production! ᕦ(ò_óˇ)ᕤ !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! ''' def keyname(pubkey): return pubkey.split()[-1] def is_valid_openssh_pubkey(pubkey): """Check if the given string is a valid OpenSSH public key. This function is based on http://stackoverflow.com/a/2494645/2217862. Arguments: pubkey (str): The string to validate. Returns: bool: `True` if the given string is a valid key, `False` otherwise. """ try: key_type, data64 = map(_encode, pubkey.split()[0:2]) except (ValueError, AttributeError): return False try: data = base64.decodebytes(data64) except base64.binascii.Error: return False int_len = 4 str_len = struct.unpack('>I', data[:int_len])[0] if data[int_len:(int_len + str_len)] != key_type: return False return True def _decode(input): return input.decode('utf8') def _encode(input): return input.encode('utf8') class LdapSSH(object): def __init__(self, conf): """Initialize new LdapSSH instance. Arguments: conf (LdapConfig): The LDAP configuration. """ self.conf = conf self._conn = None def connect(self): """Connect to the LDAP server. This method must be called before any other methods of this object. Raises: ConfigError: If Base DN or LDAP URI is missing in the config. LDAPConnectionError: If can't connect to the LDAP server. ldap.LDAPError: """ conf = self.conf if not conf.uris or not conf.base: raise ConfigError('Base DN and LDAP URI(s) must be provided.', 1) if conf.tls_require_cert is not None: if conf.tls_require_cert not in [ldap.OPT_X_TLS_DEMAND, ldap.OPT_X_TLS_HARD]: print(BAD_REQCERT_WARNING, file=sys.stderr) # this is a global option! ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, conf.tls_require_cert) if conf.cacert_dir: # this is a global option! ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, conf.cacert_dir) if not conf.referrals: # this is a global option! ldap.set_option(ldap.OPT_REFERRALS, 0) # NOTE: The uri argument is passed directly to the underlying openldap # library that allows multiple URIs separated by a space for failover. self._conn = conn = ldap.initialize(' '.join(conf.uris)) try: conn.protocol_version = conf.ldap_version conn.network_timeout = conf.bind_timeout conn.timeout = conf.search_timeout if conf.sasl == 'GSSAPI': self._bind_sasl_gssapi() return if conf.ssl == 'start_tls' and conf.ldap_version >= 3: conn.start_tls_s() if conf.bind_dn and conf.bind_pass: self._bind(conf.bind_dn, conf.bind_pass) except ldap.SERVER_DOWN: raise LDAPConnectionError('Can\'t contact LDAP server.', 3) def close(self): """Unbind from the LDAP server.""" self._conn and self._conn.unbind_s() def add_pubkey(self, login, password, pubkey): """Add SSH public key to the user with the given ``login``. Arguments: login (str): Login of the user to add the ``pubkey``. password (Optional[str]): The user's password to bind with, or None to not (re)bind with the user's credentials. pubkey (str): The public key to add. Raises: InvalidPubKeyError: If the ``pubkey`` is invalid. PubKeyAlreadyExistsError: If the user already has the given ``pubkey``. UserEntryNotFoundError: If the ``login`` is not found. ConfigError: If LDAP server doesn't define schema for the attribute specified in the config. InsufficientAccessError: If the bind user doesn't have rights to add the pubkey. ldap.LDAPError: """ conf = self.conf if not is_valid_openssh_pubkey(pubkey): raise InvalidPubKeyError('Invalid key, not in OpenSSH Public Key format.', 1) dn = self.find_dn_by_login(login) if password: self._bind(dn, password) if self._has_pubkey(dn, pubkey): raise PubKeyAlreadyExistsError( "Public key %s already exists." % keyname(pubkey), 1) modlist = [(ldap.MOD_ADD, conf.pubkey_attr, _encode(pubkey))] try: self._conn.modify_s(dn, modlist) except ldap.OBJECT_CLASS_VIOLATION: modlist += [(ldap.MOD_ADD, 'objectClass', _encode(conf.pubkey_class))] self._conn.modify_s(dn, modlist) except ldap.UNDEFINED_TYPE: raise ConfigError( "LDAP server doesn't define schema for attribute: %s" % conf.pubkey_attr, 1) except ldap.INSUFFICIENT_ACCESS: raise InsufficientAccessError("No rights to add key for %s " % dn, 2) def find_and_remove_pubkeys(self, login, password, pattern): """Find and remove public keys of the user with the ``login`` that maches the ``pattern``. Arguments: login (str): Login of the user to add the ``pubkey``. password (Optional[str]): The user's password to bind with, or None to not (re)bind with the user's credentials. pattern (str): The pattern specifying public keys to be removed. Raises: UserEntryNotFoundError: If the ``login`` is not found. NoPubKeyFoundError: If no public key matching the ``pattern`` is found. InsufficientAccessError: If the bind user doesn't have rights to add the pubkey. ldap.LDAPError: Returns: List[str]: A list of removed public keys. """ dn = self.find_dn_by_login(login) if password: self._bind(dn, password) pubkeys = [key for key in self._find_pubkeys(dn) if pattern in key] for key in pubkeys: self._remove_pubkey(dn, key) return pubkeys def find_pubkeys(self, login): """Return public keys of the user with the given ``login``. Arguments: login (str): The login name of the user. Returns: List[str]: A list of public keys. Raises: UserEntryNotFoundError: If the ``login`` is not found. ldap.LDAPError: """ return self._find_pubkeys(self.find_dn_by_login(login)) def find_dn_by_login(self, login): """Returns Distinguished Name (DN) of the user with the given ``login``. Arguments: login (str): The login name of the user to find. Returns: str: User's DN. Raises: UserEntryNotFoundError: If the ``login`` is not found. ldap.LDAPError: """ conf = self.conf filter_s = conf.filter # RFC4515 requires filters to be wrapped with parenthesis '(' and ')'. # Over-wrapped filters are invalid! e.g. '((uid=x))' # # OpenLDAP permits simple filters to omit parenthesis entirely: # e.g. 'uid=x' is automatically treated as '(uid=x)' # # The OpenLDAP behavior is taken as a given in many uses, which can # lead to bad assumptions merging filters, because over-wrapped filters # ARE still rejected. # # To cope with these cases, only wrap the incoming filter in # parenthesis if it does NOT already have them. if filter_s[0] != '(': filter_s = '(%s)' % filter_s filter_s = "(&%s(%s=%s))" % (filter_s, conf.login_attr, login) result = self._conn.search_s(conf.base, conf.scope, filter_s, ['dn']) if not result: raise UserEntryNotFoundError("No user with login '%s' found." % login, 2) return result[0][0] def _bind(self, dn, password): try: self._conn.simple_bind_s(dn, password) except ldap.INVALID_CREDENTIALS: raise InvalidCredentialsError("Invalid credentials for %s." % dn, 2) def _bind_sasl_gssapi(self): self._conn.sasl_interactive_bind_s('', ldap.sasl.sasl({}, 'GSSAPI')) def _find_pubkeys(self, dn): conf = self.conf result = self._conn.search_s( dn, ldap.SCOPE_BASE, attrlist=[conf.pubkey_attr]) return map(_decode, result[0][1].get(conf.pubkey_attr, [])) def _has_pubkey(self, dn, pubkey): current = self._find_pubkeys(dn) is_same_key = lambda k1, k2: k1.split()[1] == k2.split()[1] return any(key for key in current if is_same_key(key, pubkey)) def _remove_pubkey(self, dn, pubkey): conf = self.conf modlist = [(ldap.MOD_DELETE, conf.pubkey_attr, _encode(pubkey))] try: self._conn.modify_s(dn, modlist) except ldap.OBJECT_CLASS_VIOLATION: modlist += [(ldap.MOD_DELETE, 'objectClass', _encode(conf.pubkey_class))] self._conn.modify_s(dn, modlist) except ldap.NO_SUCH_ATTRIBUTE: raise NoPubKeyFoundError("No such public key exists: %s." % keyname(pubkey), 1) except ldap.INSUFFICIENT_ACCESS: raise InsufficientAccessError("No rights to remove key for %s " % dn, 2) ================================================ FILE: ssh_ldap_pubkey/config.py ================================================ # -*- coding: utf-8 -*- import ldap import re DEFAULT_FILTER = 'objectclass=posixAccount' DEFAULT_HOST = 'localhost' DEFAULT_LOGIN_ATTR = 'uid' DEFAULT_PORT = 389 DEFAULT_PUBKEY_ATTR = 'sshPublicKey' DEFAULT_PUBKEY_CLASS = 'ldapPublicKey' DEFAULT_REFERRALS = 'on' DEFAULT_SCOPE = 'sub' DEFAULT_TIMEOUT = 10 def parse_config(content): """Parse configuration options into a dict. Blank lines are ignored. Lines beginning with a hash mark (`#`) are comments, and ignored. Valid lines are made of an option's name (a sequence of non-blanks), followed by a value. The value starts with the first non-blank character after the option's name, and terminates at the end of the line, or at the last sequence of blanks before the end of the line. Option names are case-insensitive, and converted to lower-case. Arguments: content (str): The content of a configuration file to parse. Returns: dict: Parsed options. """ return { match.group(1).lower(): match.group(2).strip() for match in ( re.match(r'^(\w+)\s+([^#]+)', line) for line in content.splitlines() ) if match } def parse_config_file(path): """Same as :func:`parse_config`, but read options from a file. Arguments: path (str): Path of the file to read and parse. Returns: dict: Parsed options. """ with open(path, 'r') as f: return parse_config(f.read()) def parse_bool(value): """Parse string that represents a boolean value. Arguments: value (str): The value to parse. Returns: bool: True if the value is "on", "true", or "yes", False otherwise. """ return (value or '').lower() in ('on', 'true', 'yes') def parse_tls_reqcert_opt(value): """Convert `tls_reqcert` option to ldap's `OPT_X_TLS_*` constant.""" return { 'never': ldap.OPT_X_TLS_NEVER, 'allow': ldap.OPT_X_TLS_ALLOW, 'try': ldap.OPT_X_TLS_TRY, 'demand': ldap.OPT_X_TLS_DEMAND, 'hard': ldap.OPT_X_TLS_HARD }[value.lower()] if value else None def parse_scope_opt(value): """Convert `scope` option to ldap's `SCOPE_*` constant.""" return { 'base': ldap.SCOPE_BASE, 'one': ldap.SCOPE_ONELEVEL, 'sub': ldap.SCOPE_SUBTREE }[value.lower()] if value else None class LdapConfig(object): def __init__(self, path): """Initialize new LdapConfig with options parsed from config file on the ``path``. Arguments: path (Optional[path]): Path to the config file to read and parse. If not provided, then empty config is initialized. """ conf = parse_config_file(path) if path else {} if 'uri' in conf: self.uris = conf['uri'].split() else: host = conf.get('host', DEFAULT_HOST) port = conf.get('port', DEFAULT_PORT) self.uris = ["ldap://%s:%s" % (host, port)] self.base = conf.get('nss_base_passwd', '').split('?')[0] or conf.get('base', None) self.bind_dn = conf.get('binddn', None) self.bind_pass = conf.get('bindpw', None) self.bind_timeout = int(conf.get('bind_timelimit', DEFAULT_TIMEOUT)) self.cacert_dir = conf.get('tls_cacertdir', None) self.filter = conf.get('pam_filter', DEFAULT_FILTER) self.ldap_version = int(conf.get('ldap_version', ldap.VERSION3)) self.login_attr = conf.get('pam_login_attribute', DEFAULT_LOGIN_ATTR) self.pubkey_attr = conf.get('pubkey_attr', DEFAULT_PUBKEY_ATTR) self.pubkey_class = conf.get('pubkey_class', DEFAULT_PUBKEY_CLASS) self.referrals = parse_bool(conf.get('referrals', DEFAULT_REFERRALS)) self.sasl = conf.get('sasl', None) self.scope = parse_scope_opt(conf.get('scope', DEFAULT_SCOPE)) self.search_timeout = int(conf.get('timelimit', DEFAULT_TIMEOUT)) self.ssl = conf.get('ssl', None) self.tls_require_cert = parse_tls_reqcert_opt(conf.get('tls_reqcert')) @property def uri(self): # for backward compatibility with <1.1.0 return self.uris[0] if self.uris else None @uri.setter def uri(self, uri): # for backward compatibility with <1.1.0 self.uris = [uri] if uri else None def __str__(self): return str(self.__dict__) ================================================ FILE: ssh_ldap_pubkey/exceptions.py ================================================ # -*- coding: utf-8 -*- class Error(Exception): def __init__(self, msg, code=1): self.msg = msg self.code = code def __str__(self): return self.msg class ConfigError(Error): pass class InsufficientAccessError(Error): pass class InvalidCredentialsError(Error): pass class InvalidPubKeyError(Error): pass class LDAPConnectionError(Error): pass class NoPubKeyFoundError(Error): pass class PubKeyAlreadyExistsError(Error): pass class UserEntryNotFoundError(Error): pass ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/config_test.py ================================================ # -*- coding: utf-8 -*- from inspect import cleandoc from io import StringIO from pytest import mark, raises from ssh_ldap_pubkey.config import * from textwrap import dedent def describe_parse_config(): def parses_key_value_separated_by_whitespace(): content = dedent('''\ uri ldap://localhost base dc=example, dc=org ldap_version\t\t3 ''') expected = { 'uri': 'ldap://localhost', 'base': 'dc=example, dc=org', 'ldap_version': '3' } assert parse_config(content) == expected def strips_trailing_whitespaces(): content = dedent('''\ scope base\t timelimit 3\t\t ''') expected = { 'scope': 'base', 'timelimit': '3' } assert parse_config(content) == expected def ignores_comments(): content = dedent('''\ # The search scope; sub, one, or base. scope one #timelimit 5 ''') expected = { 'scope': 'one' } assert parse_config(content) == expected def converts_keys_to_lowercase(): content = dedent('''\ ScoPe base BASE DC=Example,DC=org ''') expected = { 'scope': 'base', 'base': 'DC=Example,DC=org' } assert parse_config(content) == expected def describe_parse_config_file(): def reads_file_and_calls_parse_config(mocker): # This removes the leading whitespace in this file for the test. content = cleandoc(u''' scope one timelimit 3 pam_filter (objectclass=posixAccount) nss_reconnect_tries 2 # number of times to double the sleep time trailing_space_after_variable foobar ''') # noqa open_mock = mocker.patch('builtins.open', return_value=StringIO(initial_value=content)) result = { 'scope': 'one', 'timelimit': '3', 'pam_filter': '(objectclass=posixAccount)', 'nss_reconnect_tries': '2', 'trailing_space_after_variable': 'foobar', } parse_config_mock = mocker.patch('ssh_ldap_pubkey.config.parse_config', return_value=result) assert parse_config_file('/etc/ldap.conf') == result open_mock.assert_called_with('/etc/ldap.conf', 'r') parse_config_mock.assert_called_with(content) def describe_parse_bool(): @mark.parametrize('value', ['on', 'true', 'yes', 'ON', 'TrUe']) def returns_True_when_given_truthy_value(value): assert parse_bool(value) @mark.parametrize('value', ['off', 'false', 'no', 'NO', 'fooo']) def returns_False_when_given_non_truthy_value(value): assert not parse_bool(value) def returns_False_when_given_None(): assert not parse_bool(None) def describe_parse_tls_reqcert_opt(): @mark.parametrize('value, expected', [ ('never', ldap.OPT_X_TLS_NEVER), ('allow', ldap.OPT_X_TLS_ALLOW), ('try', ldap.OPT_X_TLS_TRY), ('demand', ldap.OPT_X_TLS_DEMAND), ('hard', ldap.OPT_X_TLS_HARD), ]) def returns_ldap_OPT_X_TLS_constant_for_valid_value(value, expected): assert parse_tls_reqcert_opt(value) == expected assert parse_tls_reqcert_opt(value.upper()) == expected @mark.parametrize('value', [None, '']) def returns_None_when_given_falsy(value): assert parse_tls_reqcert_opt(value) is None def raises_KeyError_for_invalud_value(): with raises(KeyError): parse_tls_reqcert_opt('whatever') def describe_parse_scope_opt(): @mark.parametrize('value, expected', [ ('base', ldap.SCOPE_BASE), ('one', ldap.SCOPE_ONELEVEL), ('sub', ldap.SCOPE_SUBTREE) ]) def returns_ldap_SCOPE_constant_for_valid_value(value, expected): assert parse_scope_opt(value) == expected assert parse_scope_opt(value.upper()) == expected @mark.parametrize('value', [None, '']) def returns_None_when_given_falsy(value): assert parse_scope_opt(value) is None def raises_KeyError_for_invalud_value(): with raises(KeyError): parse_scope_opt('whatever') ================================================ FILE: tests/fixtures/invalid_ssh_keys ================================================ ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAYQCkNCKyyUPvwwefhEpnD/Khuowbv+13Cv3shWY3ck3j+hbHLqjCGUB66/igg0Atf3giU7sZOdPrSN6xdX43+k6Gn73yzWFJkWyImKzgHW9uVrU7vo3puailY+yC6RpwNME= flynn@encom.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAYQCkNCKyyUPvwwefhEpnD/Khuowbv+13Cv3shWY3ck3j+hbHLqjCGUB66/igg0Atf3giU7sZOdPrSN6xdX43+k6Gn73yzWFJkWyImKzgHW9uVrU7vo3puailY+yC6RpwNM ssh-dss AAAAB3NzaC1yc2EAAAADAQABAAAAYQCkNCKyyUPvwwefhEpnD/Khuowbv+13Cv3shWY3ck3j+hbHLqjCGUB66/igg0Atf3giU7sZOdPrSN6xdX43+k6Gn73yzWFJkWyImKzgHW9uVrU7vo3puailY+yC6RpwNME= ================================================ FILE: tests/fixtures/valid_ssh_keys ================================================ ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAYQCkNCKyyUPvwwefhEpnD/Khuowbv+13Cv3shWY3ck3j+hbHLqjCGUB66/igg0Atf3giU7sZOdPrSN6xdX43+k6Gn73yzWFJkWyImKzgHW9uVrU7vo3puailY+yC6RpwNME= flynn@encom.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAYQCkNCKyyUPvwwefhEpnD/Khuowbv+13Cv3shWY3ck3j+hbHLqjCGUB66/igg0Atf3giU7sZOdPrSN6xdX43+k6Gn73yzWFJkWyImKzgHW9uVrU7vo3puailY+yC6RpwNME= ssh-dss AAAAB3NzaC1kc3MAAACBAKMfNb7OtqUc5vje5UMvm5r1javDreL+EvACyU7N/BqO+FnqYx6RWQgp0mgDW5b+eOVBBwCfqn41TKHoXIlOsD3Rci7REBA87jmRbgQnZLSaT6BgVFWtEMI8SXG3EsmC5fXKHfX8j6W6f0Tuqeof4eaFW2RC6pYN3rvVGCvJ68FXAAAAFQDbmXw8U17CkJDvFOZ8KqehrahunQAAAIAKg/NxJVqLTJOM6qp7zI0WOiStotzclC5899w3zqJ446P/jLD5nILpnhbnUHnGmw53Yz6endWKHInsUurODPRZcjgOOd2lG5/RViegrI81YsQtczzIRNU86xL/28UkzzAy1IMeJWiH2vSrLLzx37YKZFH4MBjPhlsFtEIfHeR3dQAAAIEAlAMlPaHMIMEP+4IQP3qUhcZhI8UIYlSag2DqRjPry9jRL6uaYUYZAPC82Bvfi83TqJEhowqIBDFib4HDrgp9BJyTGfDZsHjRcLP15FYQdqIg8Fdo2ptsWGJa6EdFmj8RDIyaHsQ4a/JtnHUu+nC7DMYvTHliWl55UUVCSJzAyzs= ================================================ FILE: tests/functions_test.py ================================================ # -*- coding: utf-8 -*- from pytest import mark from os import path from ssh_ldap_pubkey import is_valid_openssh_pubkey FIXTURES_DIR = path.dirname(__file__) + '/fixtures' def describe_is_valid_openssh_pubkey(): @mark.parametrize('key', read_fixtures('valid_ssh_keys')) def returns_true_when_given_valid_pubkey(key): assert is_valid_openssh_pubkey(key) @mark.parametrize('key', read_fixtures('invalid_ssh_keys')) def returns_false_when_given_invalid_pubkey(key): assert not is_valid_openssh_pubkey(key) def read_fixtures(filename): with open(path.join(FIXTURES_DIR, filename)) as f: return f.readlines()