├── .prettierrc.toml ├── requirements.txt ├── pyproject.toml ├── ci └── environment.yml ├── .github └── workflows │ ├── linting.yaml │ └── CI.yaml ├── sshauthenticator ├── __init__.py └── auth.py ├── setup.cfg ├── README.md ├── .pre-commit-config.yaml ├── LICENSE ├── setup.py └── .gitignore /.prettierrc.toml: -------------------------------------------------------------------------------- 1 | tabWidth = 2 2 | semi = false 3 | singleQuote = true 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fabric 2 | jupyterhub 3 | tornado 4 | traitlets 5 | cryptography 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 100 3 | target-version = ['py37'] 4 | skip-string-normalization = true 5 | -------------------------------------------------------------------------------- /ci/environment.yml: -------------------------------------------------------------------------------- 1 | name: ssh-auth-dev 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - cryptography 6 | - fabric 7 | - jupyterhub 8 | - tornado 9 | - traitlets 10 | -------------------------------------------------------------------------------- /.github/workflows/linting.yaml: -------------------------------------------------------------------------------- 1 | name: linting 2 | 3 | on: 4 | push: 5 | branches: '*' 6 | pull_request: 7 | branches: '*' 8 | 9 | jobs: 10 | linting: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-python@v2 15 | - uses: pre-commit/action@v2.0.0 16 | -------------------------------------------------------------------------------- /sshauthenticator/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from pkg_resources import DistributionNotFound, get_distribution 3 | 4 | from sshauthenticator.auth import SSHAuthenticator 5 | 6 | try: 7 | __version__ = get_distribution(__name__).version 8 | except DistributionNotFound: 9 | # package is not installed 10 | __version__ = '0.0.0' 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | ignore = 4 | max-line-length = 100 5 | max-complexity = 18 6 | select = B,C,E,F,W,T4,B9 7 | 8 | [isort] 9 | known_first_party=intake_esm 10 | known_third_party=cryptography,fabric,jupyterhub,pkg_resources,setuptools,traitlets 11 | multi_line_output=3 12 | include_trailing_comma=True 13 | force_grid_wrap=0 14 | combine_as_imports=True 15 | line_length=100 16 | skip= 17 | setup.py 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sshauthenticator 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/andersy005/sshauthenticator/CI?logo=github&style=for-the-badge)](https://github.com/andersy005/sshauthenticator/actions?query=workflow%3ACI) 4 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/andersy005/sshauthenticator/linting?label=linting&logo=github&style=for-the-badge)](https://github.com/andersy005/sshauthenticator/actions?query=workflow%3Alinting) 5 | 6 | **Table of Contents** 7 | 8 | - [sshauthenticator](#sshauthenticator) 9 | 10 | SSH Authenticator Plugin for JupyterHub 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.2.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-docstring-first 8 | - id: check-json 9 | - id: check-yaml 10 | - id: double-quote-string-fixer 11 | 12 | - repo: https://github.com/ambv/black 13 | rev: 20.8b1 14 | hooks: 15 | - id: black 16 | 17 | - repo: https://github.com/keewis/blackdoc 18 | rev: v0.1.1 19 | hooks: 20 | - id: blackdoc 21 | 22 | - repo: https://gitlab.com/pycqa/flake8 23 | rev: 3.8.3 24 | hooks: 25 | - id: flake8 26 | 27 | - repo: https://github.com/asottile/seed-isort-config 28 | rev: v2.2.0 29 | hooks: 30 | - id: seed-isort-config 31 | - repo: https://github.com/pre-commit/mirrors-isort 32 | rev: v5.4.2 33 | hooks: 34 | - id: isort 35 | 36 | - repo: https://github.com/prettier/prettier 37 | rev: 2.1.1 38 | hooks: 39 | - id: prettier 40 | -------------------------------------------------------------------------------- /.github/workflows/CI.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - '*' 6 | pull_request: 7 | branches: 8 | - '*' 9 | schedule: 10 | - cron: '0 0 * * *' # Daily “At 00:00” 11 | workflow_dispatch: # allows you to trigger manually 12 | 13 | jobs: 14 | build: 15 | name: python-${{ matrix.python-version }} 16 | runs-on: ubuntu-latest 17 | defaults: 18 | run: 19 | shell: bash -l {0} 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | python-version: ['3.6', '3.7', '3.8'] 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: goanpeca/setup-miniconda@v1 27 | with: 28 | activate-environment: ssh-auth-dev # Defined in ci/environment.yml 29 | auto-update-conda: false 30 | python-version: ${{ matrix.python-version }} 31 | environment-file: ci/environment.yml 32 | use-only-tar-bz2: true # IMPORTANT: This needs to be set for caching to work properly! 33 | - name: Set up conda environment 34 | run: | 35 | python -m pip install -e . 36 | conda list 37 | 38 | # - name: Run Tests 39 | # run: | 40 | # python -m pytest -n 4 --cov=./ --cov-report=xml --verbose 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Anderson Banihirwe 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """The setup script.""" 4 | 5 | from setuptools import find_packages, setup 6 | 7 | with open('requirements.txt') as f: 8 | requirements = f.read().strip().split('\n') 9 | 10 | with open('README.md') as f: 11 | long_description = f.read() 12 | 13 | setup( 14 | maintainer='Anderson Banihirwe', 15 | maintainer_email='', 16 | python_requires='>=3.6', 17 | classifiers=[ 18 | 'Development Status :: 2 - Pre-Alpha', 19 | 'License :: OSI Approved :: Apache Software License', 20 | 'Natural Language :: English', 21 | 'Programming Language :: Python :: 3', 22 | 'Programming Language :: Python :: 3.6', 23 | 'Programming Language :: Python :: 3.7', 24 | 'Programming Language :: Python :: 3.8', 25 | 'Topic :: Scientific/Engineering', 26 | 'Operating System :: OS Independent', 27 | 'Intended Audience :: Science/Research', 28 | ], 29 | description='SSH Authenticator Plugin for JupyterHub', 30 | install_requires=requirements, 31 | license='Apache Software License 2.0', 32 | long_description_content_type='text/markdown', 33 | long_description=long_description, 34 | include_package_data=True, 35 | keywords='jupyterhub, authenticator', 36 | name='jupyterhub-sshauthenticator', 37 | packages=find_packages(), 38 | entry_points={}, 39 | url='https://github.com/andersy005/jupyterhub-sshauthenticator', 40 | project_urls={ 41 | 'Documentation': 'https://github.com/andersy005/jupyterhub-sshauthenticator', 42 | 'Source': 'https://github.com/andersy005/jupyterhub-sshauthenticator', 43 | 'Tracker': 'https://github.com/andersy005/jupyterhub-sshauthenticator/issues', 44 | }, 45 | zip_safe=False, 46 | ) 47 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /sshauthenticator/auth.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import fabric 5 | from cryptography.hazmat.backends import default_backend 6 | from cryptography.hazmat.primitives import serialization 7 | from cryptography.hazmat.primitives.asymmetric import rsa 8 | from jupyterhub.auth import Authenticator 9 | from traitlets import Int, Unicode 10 | 11 | 12 | class SSHAuthenticator(Authenticator): 13 | server_address = Unicode(help='Address of SSH server to contact').tag(config=True) 14 | server_port = Int( 15 | help='Port on which to contact SSH server.', 16 | ).tag(config=True) 17 | key_path = Unicode('/tmp/', help='The path for identity files').tag(config=True) 18 | 19 | async def authenticate(self, handler, data): 20 | """Authenticate with SSH, and copy public key to remote host if login is successful. 21 | Return None otherwise. 22 | """ 23 | username = data['username'] 24 | password = data['password'] 25 | key_path = Path(self.key_path) 26 | key_path.mkdir(parents=True, exist_ok=True) 27 | 28 | session = fabric.Connection( 29 | self.server_address, user=username, connect_kwargs={'password': password} 30 | ) 31 | try: 32 | session.open() 33 | key = rsa.generate_private_key( 34 | backend=default_backend(), public_exponent=65537, key_size=2048 35 | ) 36 | private_key = key.private_bytes( 37 | serialization.Encoding.PEM, 38 | serialization.PrivateFormat.TraditionalOpenSSL, 39 | serialization.NoEncryption(), 40 | ).decode('utf-8') 41 | public_key = ( 42 | key.public_key() 43 | .public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH) 44 | .decode('utf-8') 45 | ) 46 | 47 | keys = [ 48 | (public_key, (key_path / f'{username}_jhub.pub.key').expanduser()), 49 | (private_key, (key_path / f'{username}_jhub.key').expanduser()), 50 | ] 51 | self._write_keys(keys) 52 | # Copy generated public key to remote host 53 | session.run('mkdir -p ~/.ssh && touch ~/.ssh/authorized_keys') 54 | session.put(keys[0][-1], '.ssh/') 55 | p = f'cat ~/.ssh/{keys[0][-1].name}' 56 | session.run(f'{p} >> ~/.ssh/authorized_keys') 57 | 58 | except Exception as exc: 59 | message = f'SSH Authentication failed for user `{username}` with error: {exc}' 60 | if handler is not None: 61 | message = ( 62 | f'SSH Authentication failed for' f' {username}@{handler.request.remote_ip}' 63 | ) 64 | self.log.warning(message) 65 | return None 66 | 67 | else: 68 | return username 69 | 70 | def _write_keys(self, keys): 71 | for key, file_path in keys: 72 | with open(file_path, 'w') as f: 73 | f.write(f'{key}\n') 74 | os.chmod(file_path, 0o600) 75 | --------------------------------------------------------------------------------