diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..013caeed --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,69 @@ +version: 2 + +tox_shared_config: &tox_shared_config + steps: + - checkout + - restore_cache: + keys: + - v1-dependencies-{{ checksum "requirements-dev.txt" }} + - v1-dependencies- + - run: + name: install dependencies + command: | + virtualenv venv + . venv/bin/activate + pip install -r requirements-dev.txt + - save_cache: + paths: + - ./venv + key: v1-dependencies-{{ checksum "requirements-dev.txt" }} + - run: + name: run tests + command: | + . venv/bin/activate + tox -e "${CIRCLE_JOB}" + +jobs: + lint: + <<: *tox_shared_config + docker: + - image: circleci/python:3.7 + + py27: + <<: *tox_shared_config + docker: + - image: circleci/python:2.7 + + py36: + <<: *tox_shared_config + docker: + - image: circleci/python:3.6 + + py37: + <<: *tox_shared_config + docker: + - image: circleci/python:3.7 + functional: + <<: *tox_shared_config + docker: + - image: circleci/python:3.7 + +workflows: + version: 2 + hygiene_and_tests: + jobs: + - lint + - py27: + requires: + - lint + - py36: + requires: + - lint + - py37: + requires: + - lint + - functional: + requires: + - py27 + - py36 + - py37 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..28027095 --- /dev/null +++ b/.gitignore @@ -0,0 +1,106 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +.DS_Store \ No newline at end of file diff --git a/AUTHORS.rst b/AUTHORS.rst new file mode 100644 index 00000000..29284e8e --- /dev/null +++ b/AUTHORS.rst @@ -0,0 +1,17 @@ +Tokendito is written and maintained by Cloud Security and Engineering at Dow Jones, and +various contributors: + +Dow Jones Cloud Security and Engineering +``````````````````````` + +- Sydney Sweeney +- Nico Halpern +- Lars Joergensen + +Patches and more +``````````````````````` +- Kuber Kaul +- Steve Stevenson +- Roman Sluzhynskyy +- Scott Rahner +- Basant Singh \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..3f87f60e --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,10 @@ +Copyright 2019 Dow Jones & Co. +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this +file except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF A +NY KIND, either express or implied. See the License for the specific language governi +ng permissions and limitations under the License. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..8c4a0c68 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include MANIFEST.in README.rst requirements.txt requirements-dev.txt \ No newline at end of file diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..3ca61a68 --- /dev/null +++ b/README.rst @@ -0,0 +1,41 @@ +.. image:: https://raw.githubusercontent.com/dowjones/tokendito/master/docs/tokendito.png + :align: center + +Generate temporary AWS credentials via Okta. + +.. image:: https://circleci.com/gh/dowjones/tokendito/tree/master.svg?style=svg + :target: https://circleci.com/gh/dowjones/tokendito/tree/master + +| +| + +.. image:: https://raw.githubusercontent.com/dowjones/tokendito/master/docs/tokendito-scaled.gif + +NOTE: Advanced users may shorten the tokendito interaction to a single command. + +.. _STS: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp.html + +Use tokendito to generate temporary AWS credentials via Okta for programmatic authentication to AWS. Tokendito signs you in to Okta and uses your existing AWS integration to broker your SAML assertion into your AWS accounts, returning STS_ tokens into your local ``~/.aws/credentials`` file. + +Requirements +------------ + +* Python 2.7.10+ +* Your AWS account is federated in Okta + +tokendito is compatible with both python 2 and 3, and can be installed with either pip or pip3. + +Getting started +--------------- + +#. Install (via PyPi): ``pip install tokendito`` + +#. Run ``tokendito --configure``. + +#. Run ``tokendito``. + +Have multiple Okta tiles to switch between? View our `multi-tile guide `_. + +===================================================================================================================================================================================================================================================== +Tips, tricks, troubleshooting, examples, and more docs are `here `_! Also, `contributions are welcome `_! +===================================================================================================================================================================================================================================================== diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS new file mode 100644 index 00000000..412fe9aa --- /dev/null +++ b/docs/CODEOWNERS @@ -0,0 +1,3 @@ +# Global Owners + +@dowjones/tokendito-owners \ No newline at end of file diff --git a/docs/CONTRIBUTING.rst b/docs/CONTRIBUTING.rst new file mode 100644 index 00000000..83755c0a --- /dev/null +++ b/docs/CONTRIBUTING.rst @@ -0,0 +1,29 @@ +============ +Contributing +============ + +Ideally create a topic branch for every separate change you make. Each logical change should be its own separate pull request. For example: + + +#. Fork the repository. +#. Create a branch (\ ``git checkout -b feature/my-new-feature``\ , ``git checkout -b fix/my-fix``\ ) +#. Add and commit your changes to your fork (\ ``git commit -m 'Added some feature'``\ ) +#. Push to your fork (\ ``git push origin feature/my-new-feature``\ ) +#. Create new Pull Request from your fork onto the tokendito repo +#. Assign the PR to a team member. + +See `GitHub Flow `_ + +Git commit messages +^^^^^^^^^^^^^^^^^^^ + + +* Separate subject from body with a blank line +* Limit the subject line to 50 characters +* Capitalize the subject line +* Do not end the subject line with a period +* Use the imperative mood in the subject line +* Wrap the body at 72 characters +* Use the body to explain what and why vs. how + +See best practices on `commit messages `_ diff --git a/docs/README.rst b/docs/README.rst new file mode 100644 index 00000000..3a3b0d15 --- /dev/null +++ b/docs/README.rst @@ -0,0 +1,98 @@ +========== +More Docs! +========== + +.. contents:: Table of contents +.. section-numbering:: + +Additional Usage Reference +-------------------------- + +.. code-block:: sh + + usage: tokendito [-h] [--version] [--configure] [--username USERNAME] + [--password PASSWORD] [--config-file CONFIG_FILE] + [--okta-aws-app-url OKTA_AWS_APP_URL] + [--okta-profile OKTA_PROFILE] [--aws-region AWS_REGION] + [--aws-output AWS_OUTPUT] [--aws-profile AWS_PROFILE] + [--mfa-method MFA_METHOD] [--mfa-response MFA_RESPONSE] + [--role-arn ROLE_ARN] [--output-file OUTPUT_FILE] + [--loglevel {DEBUG,INFO,WARN,ERROR}] + + Gets a STS token to use with the AWS CLI + + optional arguments: + -h, --help show this help message and exit + --version, -v Displays version and exit + --configure, -c Prompt user for configuration parameters + --username USERNAME, -u USERNAME + username to login to Okta. You can also use the + OKTA_USERNAME environment variable. + --password PASSWORD, -p PASSWORD + password to login to Okta. You can also user the + OKTA_PASSWORD environment variable. + --config-file CONFIG_FILE, -C CONFIG_FILE + Use an alternative configuration file + --okta-aws-app-url OKTA_AWS_APP_URL, -ou OKTA_AWS_APP_URL + Okta App URL to use. + --okta-profile OKTA_PROFILE, -op OKTA_PROFILE + Okta configuration profile to use. + --aws-region AWS_REGION, -r AWS_REGION + Sets the AWS region for the profile + --aws-output AWS_OUTPUT, -ao AWS_OUTPUT + Sets the AWS output type for the profile + --aws-profile AWS_PROFILE, -ap AWS_PROFILE + Override AWS profile to save as in the credentials + file. + --mfa-method MFA_METHOD, -mm MFA_METHOD + Sets the MFA method + --mfa-response MFA_RESPONSE, -mr MFA_RESPONSE + Sets the MFA response to a challenge + --role-arn ROLE_ARN, -R ROLE_ARN + Sets the IAM role + --output-file OUTPUT_FILE, -o OUTPUT_FILE + Log output to filename + --loglevel {DEBUG,INFO,WARN,ERROR}, -l {DEBUG,INFO,WARN,ERROR} + [DEBUG|INFO|WARN|ERROR], default loglevel is ERROR. + Note: DEBUG level may display credentials + +To upgrade: +""""""""""" +``pip install tokendito --upgrade`` + + +Installing from github: +""""""""""""""""""""""" + +``pip install git+ssh://git@github.com/dowjones/tokendito.git@`` + +For instance, ``pip install git+ssh://git@github.com/dowjones/tokendito.git@1.0.0`` + +Troubleshooting: +"""""""""""""""" +Validate your environment's AWS configuration profile(s) located at: + +``$HOME/.aws/config`` + +``$HOME/.aws/credentials`` + +``$HOME/.aws/okta_auth`` + + +Multi-tile Guide! +----------------- +If you have multiple AWS-type Okta tiles assigned to you, please update your local `$HOME/.aws/okta_auth `_ with the links to your AWS tiles in Okta. You can get the link to your tile by right clicking on the tile in Okta and selecting "Copy Link URL." +This file supports multiple profiles, in case there is a need to connect with different Okta Orgs and tiles. tokendito can access the profiles by name, by passing in the ``--okta-profile`` parameter. + +ex: +``tokendito --okta-profile my_prod_okta_tile`` + +Without specifying a specific profile, tokendito will look for a default profile within that file. + + +Design & Limitations +-------------------- + +* This tool does not cache and reuse Okta session IDs + +`Pull requests welcome `_! diff --git a/docs/okta_auth.example b/docs/okta_auth.example new file mode 100644 index 00000000..b6ec8e83 --- /dev/null +++ b/docs/okta_auth.example @@ -0,0 +1,11 @@ +[default] +okta_aws_app_url = https://acme.oktapreview.com/home/amazon_aws/b07384d113edec49eaa6/123 +okta_username = jane.doe@acme.com +mfa_method = push + +[my_prod_okta_tile] +okta_aws_app_url = https://acme.okta.com/home/amazon_aws/b07384d113edec49f00d/272?fromHome=true + +[my_dev_okta_tile] +okta_aws_app_url = https://acme.oktapreview.com/home/amazon_aws/b07384d113edec49eaa6/123 +okta_username = jane.doe@acme.com \ No newline at end of file diff --git a/docs/tokendito-scaled.gif b/docs/tokendito-scaled.gif new file mode 100644 index 00000000..ffd8e682 Binary files /dev/null and b/docs/tokendito-scaled.gif differ diff --git a/docs/tokendito.gif b/docs/tokendito.gif new file mode 100644 index 00000000..3442a601 Binary files /dev/null and b/docs/tokendito.gif differ diff --git a/docs/tokendito.png b/docs/tokendito.png new file mode 100644 index 00000000..f291731d Binary files /dev/null and b/docs/tokendito.png differ diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100755 index 00000000..e8ee76cf --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,11 @@ +-r ./requirements.txt +flake8 +flake8-colors +flake8-docstrings +flake8-import-order>=0.9 +pep8-naming +pexpect>=4.6.0 +pylint>=1.8.4 +pydocstyle==3.0.0 +pyroma +tox<3.7.0 diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 00000000..f6f2c62e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +boto3>=1.9.0 +requests>=2.19.0 +configparser>=3.5.0 +future>=0.16.0 +pyOpenSSL>=18.0.0 +beautifulsoup4>=4.6.0 +lxml>=4.3.0 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100755 index 00000000..f12fccd1 --- /dev/null +++ b/setup.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +# vim: set filetype=python ts=4 sw=4 +# -*- coding: utf-8 -*- +"""`tokendito` is in Github: _.""" + +from codecs import open +import os +from os import path +import sys + +from setuptools import find_packages, setup + +here = path.abspath(path.dirname(__file__)) + +with open(path.join(here, 'README.rst'), encoding=sys.stdin.encoding) as f: + long_description = f.read() + +with open('requirements.txt') as f: + required = f.read().splitlines() + +about = {} +with open(os.path.join(here, 'tokendito', '__version__.py'), 'r') as f: + exec(f.read(), about) + +setup( + name='tokendito', + version=about['__version__'], + description=about['__description__'], + long_description=long_description, + long_description_content_type=about['__long_description_content_type__'], + url=about['__url__'], + author=about['__author__'], + author_email=about['__author_email__'], + classifiers=[ + 'License :: OSI Approved :: Apache Software License', + 'Development Status :: 5 - Production/Stable', + 'Operating System :: OS Independent', + 'Natural Language :: English', + 'Environment :: Console', + 'Programming Language :: Python', + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + ], + keywords=['okta', 'aws', 'sts'], + packages=find_packages(exclude=['contrib', 'docs', 'tests', '.tox']), + python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*", + license=about['__license__'], + zip_safe=False, + install_requires=[required], + entry_points={ + 'console_scripts': ['tokendito=tokendito.__main__:main'], + }, + + # $ pip install -e . [dev,test] +) diff --git a/tests/functional_test.py b/tests/functional_test.py new file mode 100644 index 00000000..c5c56543 --- /dev/null +++ b/tests/functional_test.py @@ -0,0 +1,208 @@ +""" +Functional tests for tokendito cli tool. + +Usage: +python tests/functional_test.py --role --mfa + +Requirements: +1) the push mfa option enabled for your Okta user. +2) AWS CLI installed. +""" +from __future__ import (absolute_import, division, + print_function, unicode_literals) + + +import argparse +import logging +from os import path +import re +import subprocess +import sys + +from future import standard_library + + +standard_library.install_aliases() + +logfile = "tokendito_functional_test.log" +logging.basicConfig(filename=logfile, level=logging.DEBUG, + format='%(asctime)s :: %(levelname)s :: %(message)s', + datefmt='%m/%d/%Y %I:%M:%S %p') + + +def setup(): + """Parse command line arguments. + + :return: args parse object + + """ + parser = \ + argparse.ArgumentParser( + description='Functional tests for tokendito.') + parser.add_argument('--role-arn', '-R', required=True, type=helpers.to_unicode, + help='a role arn for an assignment within' + 'your default ~/.okta_auth profile to continue this test.') + parser.add_argument('--mfa-method', '-mm', required=True, type=helpers.to_unicode, + help='Please supply an MFA option to continue this test.') + parser.add_argument('--okta-profile', '-op', type=helpers.to_unicode, + default='default', + help='Okta configuration profile to use.') + args = parser.parse_args() + return args + + +def collect_args(): + """Reconcile args required for this test with args for tool.""" + test_args = setup() + tool_args = helpers.setup() + for arg in test_args.__dict__: + if test_args.__dict__[arg]: + tool_args.__dict__[arg] = test_args.__dict__[arg] + validate_role(tool_args.role_arn) + return tool_args + + +def validate_role(role): + """Validate provided role arn.""" + regex = re.compile('arn:aws:iam::*:') + if len(re.findall(regex, role)) == 0: + print("Error: invalid role ARN syntax.") + exit(1) + + +def get_pip_version(): + """Identify running version of python and select pip version accordingly.""" + pip_version = 'pip ' + if sys.version_info.major == 3: + pip_version = 'pip3 ' + return pip_version + + +def check_tokendito_installed(): + """Uninstall tokendito if it is already installed. + + This ensures you are testing your current state and not a previous version. + """ + pip_version = get_pip_version() + proc = pip_version + 'show tokendito' + list_packages = run_process(proc) + + if list_packages["stdout"]: + print("Deleting a preexisting installation of tokendito.") + proc = pip_version + 'uninstall tokendito --yes' + return run_process(proc) + + +def run_process(proc): + """Spawn a child process, pipe and format stdout to logfile. + + Returns a dict with stdout log, exit status, and command executed. + """ + short_proc = proc.split("--password")[0] + logging.debug("Running proc $ {}".format(short_proc)) + + process = subprocess.Popen( + [proc], stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + op, err = process.communicate() + rc = process.returncode + + if rc == 1 and proc == "pip show tokendito": + logging.info("tokendito installation not found.") + elif rc != 0: + print(rc) + print(proc) + logging.error( + "There was an error running command \n$ {}\n{}".format( + short_proc, err)) + exit(1) + else: + logging.debug(op) + + result = { + "stdout": op, + "exit_status": rc, + "process": short_proc + } + return result + + +def test_pip_install(): + """Uninstall and install tokendito as a python package.""" + repo_root = path.dirname(path.dirname(path.abspath(__file__))) + pip_version = get_pip_version() + proc = pip_version + 'install -e ' + repo_root + return run_process(proc) + + +def test_as_package(arg_string): + """Test tokendito execution as a python package.""" + proc = ('tokendito' + arg_string) + print("Contacting MFA device...") + return run_process(proc) + + +def test_as_script(arg_string): + """Test tokendito execution as a script.""" + proc = ('python tokendito/tokendito.py' + arg_string) + print("Contacting MFA device...") + return run_process(proc) + + +def test_as_module(arg_string): + """Test tokendito execution as a module.""" + proc = ('python -m tokendito' + arg_string) + print("Contacting MFA device...") + return run_process(proc) + + +def test_aws_cli(args): + """Validate that tokendito writes working api keys to the environment.""" + aws_role_name = args.role_arn.split("/")[-1] + proc = 'aws --profile ' + aws_role_name + ' sts get-caller-identity' + return run_process(proc) + + +def calculate_results(test_results): + """Compile test results into passed vs failed.""" + failed_tests = [d for d in test_results if d["exit_status"]] + + if len(failed_tests) == 0: + print("All " + str(len(test_results)) + " tests passed.") + exit(0) + + print(str(len(failed_tests)) + " out of " + + str(len(test_results)) + " tests failed.") + + for test in failed_tests: + print("Test failed: \n" + test["process"].split("--password")[0]) + print(test["stdout"]) + + +def main(): + """Test installation and execution of tokendito tool.""" + tool_args = collect_args() + check_tokendito_installed() + helpers.process_options(tool_args) + + arg_string = (' --role-arn ' + tool_args.role_arn + + ' --mfa-method ' + tool_args.mfa_method + + " --password='{}'".format(settings.okta_password)) + + if "profile" in tool_args: + arg_string += ' --okta-profile={}'.format(tool_args.okta_profile) + + test_results = [] + test_results.append(test_pip_install()) + test_results.append(test_as_module(arg_string)) + test_results.append(test_aws_cli(tool_args)) + test_results.append(test_as_script(arg_string)) + test_results.append(test_aws_cli(tool_args)) + test_results.append(test_as_package(arg_string)) + test_results.append(test_aws_cli(tool_args)) + calculate_results(test_results) + + +if __name__ == '__main__' and __package__ is None: + sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) + from tokendito import helpers, settings + main() diff --git a/tokendito/__init__.py b/tokendito/__init__.py new file mode 100644 index 00000000..551e808e --- /dev/null +++ b/tokendito/__init__.py @@ -0,0 +1,4 @@ +# vim: set filetype=python ts=4 sw=4 +# -*- coding: utf-8 -*- +"""tokendito module initialization.""" +__all__ = [''] diff --git a/tokendito/__main__.py b/tokendito/__main__.py new file mode 100755 index 00000000..94c09f54 --- /dev/null +++ b/tokendito/__main__.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# vim: set filetype=python ts=4 sw=4 +# -*- coding: utf-8 -*- +"""tokendito module entry point.""" + +from __future__ import (absolute_import, division, + print_function, unicode_literals) + +from builtins import (ascii, bytes, chr, dict, filter, hex, input, # noqa: F401 + int, list, map, next, object, oct, open, pow, range, + round, str, super, zip) +import sys + +from future import standard_library +standard_library.install_aliases() + + +def main(): # needed for console script + """Packge entry point.""" + if __package__ is None: + import os.path + path = os.path.dirname(os.path.dirname(__file__)) + sys.path[0:0] = [path] + from tokendito.tool import cli + return cli() + + +if __name__ == '__main__': + try: + sys.exit(main()) + except KeyboardInterrupt: + print('\nInterrupted') + sys.exit(1) diff --git a/tokendito/__version__.py b/tokendito/__version__.py new file mode 100644 index 00000000..1940f8d4 --- /dev/null +++ b/tokendito/__version__.py @@ -0,0 +1,11 @@ +# vim: set filetype=python ts=4 sw=4 +# -*- coding: utf-8 -*- +"""tokendito version.""" +__version__ = '1.0.0' +__title__ = 'tokendito' +__description__ = 'Get AWS STS tokens from Okta SSO' +__long_description_content_type__ = 'text/x-rst' +__url__ = 'https://github.com/dowjones/tokendito' +__author__ = 'tokendito' +__author_email__ = 'tokendito@dowjones.com' +__license__ = 'Apache 2.0' diff --git a/tokendito/aws_helpers.py b/tokendito/aws_helpers.py new file mode 100644 index 00000000..e8c8eaec --- /dev/null +++ b/tokendito/aws_helpers.py @@ -0,0 +1,188 @@ +# vim: set filetype=python ts=4 sw=4 +# -*- coding: utf-8 -*- +"""This module handles the all aws workflow operations. + +Tasks include: +1. Aws Authentication with SAML +2. Updating the AWS Credentials +3. Updating the AWS Config + +""" +from __future__ import (absolute_import, division, + print_function, unicode_literals) + +from builtins import (ascii, bytes, chr, dict, filter, hex, input, # noqa: F401 + int, list, map, next, object, oct, open, pow, range, + round, str, super, zip) +import codecs +import logging +import sys + +import boto3 +from botocore import UNSIGNED +from botocore.client import Config +from botocore.exceptions import ClientError +from future import standard_library +import requests +from tokendito import helpers + + +standard_library.install_aliases() + + +def authenticate_to_roles(secret_session_token, url): + """Authenticate AWS user with saml. + + :param secret_session_token: secret session token + :param url: url of the AWS account + :return: response text + + """ + payload = {'onetimetoken': secret_session_token} + logging.debug( + "Authenticate AWS user with SAML URL [{}:{}]".format(url, payload)) + try: + response = requests.get(url, params=payload) + saml_response_string = response.text + if response.status_code == 400 or response.status_code == 401: + errmsg = 'Invalid Credentials.' + logging.critical("{}\nExiting with code:{}".format( + errmsg, response.status_code)) + sys.exit(2) + elif response.status_code == 404: + errmsg = 'Invalid Okta application URL. Please verify your configuration.' + logging.critical("{}".format(errmsg)) + sys.exit(2) + elif response.status_code >= 500 and response.status_code < 504: + errmsg = 'Unable to establish connection with Okta. Verify Okta Org URL and try again.' + logging.critical("{}\nExiting with code:{}".format( + errmsg, response.status_code)) + sys.exit(2) + elif response.status_code != 200: + logging.critical("Exiting with code:{}".format( + response.status_code)) + logging.debug(saml_response_string) + sys.exit(2) + + except Exception as error: + errmsg = 'Okta auth failed:\n{}'.format(error) + logging.critical(errmsg) + sys.exit(1) + + saml_xml = helpers.validate_saml_response(saml_response_string) + + return saml_response_string, saml_xml + + +def assume_role(role_arn, provider_arn, saml): + """Return AssumeRoleWithSaml API response. + + :param role_arn: IAM role arn to assume + :param provider_arn: ARN of saml-provider resource + :param saml: decoded saml response from okta + :return: AssumeRoleWithSaml API response + + """ + default_error = ('\nUnable to assume role with the following details:\n' + '- Role ARN: {}\n' + '- Error: {}\n') + + encoded_xml = codecs.encode(saml.encode('utf-8'), 'base64') + + # Attempt to assume a role with the following durations: + # 12h, 8h, 6h, 4h, 2h, 1h, 30m, 15m + session_times = [43200, 28800, 21600, 14400, 7200, 3600, 1800, 900, "exit"] + for duration in session_times: + if duration == "exit": + logging.critical(default_error.format( + role_arn, "IAM role session time is not within set: {}".format(session_times[:-1]))) + sys.exit(2) + + assume_role_response = handle_assume_role( + role_arn, provider_arn, encoded_xml, duration, default_error) + if 'Credentials' in assume_role_response: + break + + return assume_role_response + + +def handle_assume_role(role_arn, provider_arn, encoded_xml, duration, default_error): + """Handle assume role with saml. + + :param role_arn: IAM role arn to assume + :param provider_arn: ARN of saml-provider resource + :param saml: decoded saml response from okta + :return: AssumeRoleWithSaml API responses + """ + logging.debug("Attempting session time [{}]".format(duration)) + client = boto3.client('sts', config=Config(signature_version=UNSIGNED)) + try: + assume_role_response = client.assume_role_with_saml(RoleArn=role_arn, + PrincipalArn=provider_arn, + SAMLAssertion=encoded_xml.decode(), + DurationSeconds=duration) + # Client Exceptions + except ClientError as error: + if error.response['Error']['Code'] == 'ValidationError': + logging.info("AssumeRoleWithSaml failed with {} for duration {}".format( + error.response['Error']['Code'], duration)) + assume_role_response = "continue" + elif error.response['Error']['Code'] == 'AccessDenied': + errmsg = 'Error assuming intermediate {} SAML role'.format( + provider_arn) + logging.critical(errmsg) + sys.exit(2) + else: + logging.critical(default_error.format(role_arn, str(error))) + sys.exit(1) + # Service Exceptions + except Exception as error: + logging.critical(default_error.format(role_arn, str(error))) + sys.exit(1) + + return assume_role_response + + +def ensure_keys_work(assume_role_response): + """Validate the temporary AWS credentials. + + :param aws_access_key: AWS access key + :param aws_secret_key: AWS secret key + :param aws_session_token: AWS session token + :return: + + """ + logging.debug("Validate the temporary AWS credentials") + + aws_access_key = assume_role_response['Credentials']['AccessKeyId'] + aws_secret_key = assume_role_response['Credentials']['SecretAccessKey'] + aws_session_token = assume_role_response['Credentials']['SessionToken'] + + try: + client = boto3.client( + 'sts', aws_access_key_id=aws_access_key, + aws_secret_access_key=aws_secret_key, aws_session_token=aws_session_token) + client.get_caller_identity() + except Exception as auth_error: + logging.critical( + "There was an error authenticating your keys with AWS: {}".format( + auth_error)) + sys.exit(1) + + +def select_assumeable_role(saml_response_string, saml): + """Select the role to perform the AssumeRoleWithSaml. + + :param saml_response_string response from Okta with saml data: + :param saml decoded saml response from Okta: + :return AWS AssumeRoleWithSaml response, role name: + """ + roles_and_providers = helpers.extract_arns(saml) + role_arn = helpers.select_role_arn( + list(roles_and_providers.keys()), saml, saml_response_string) + role_name = role_arn.split("/")[-1] + + assume_role_response = assume_role( + role_arn, roles_and_providers[role_arn], saml) + + return assume_role_response, role_name diff --git a/tokendito/helpers.py b/tokendito/helpers.py new file mode 100644 index 00000000..a2834141 --- /dev/null +++ b/tokendito/helpers.py @@ -0,0 +1,584 @@ +# vim: set filetype=python ts=4 sw=4 +# -*- coding: utf-8 -*- +"""Helper module for AWS and Okta configuration, management and data flow.""" +from __future__ import (absolute_import, division, + print_function, unicode_literals) + +import argparse +from builtins import (ascii, bytes, chr, dict, filter, hex, input, # noqa: F401 + int, list, map, next, object, oct, open, pow, range, + round, str, super, zip) +import codecs +import configparser +import getpass +import json +import logging +import os +import re +import sys +from urllib.parse import urlparse + +from botocore import __version__ as __botocore_version__ +from bs4 import BeautifulSoup +from future import standard_library +import requests +from tokendito import settings +from tokendito.__version__ import __version__ + +standard_library.install_aliases() + + +def setup(): + """Parse command line arguments. + + :return: args parse object + """ + parser = argparse.ArgumentParser( + description='Gets a STS token to use with the AWS CLI') + parser.add_argument('--version', '-v', action='version', + version='{}/{} botocore/{}'.format( + parser.prog, __version__, __botocore_version__), + help='Displays version and exit') + parser.add_argument('--configure', '-c', action='store_true', help='Prompt user for ' + 'configuration parameters') + + parser.add_argument('--username', '-u', type=to_unicode, dest='okta_username', + help='username to login to Okta. You can ' + 'also use the OKTA_USERNAME environment variable.') + parser.add_argument('--password', '-p', type=to_unicode, dest='okta_password', + help='password to login to Okta. You ' + 'can also user the OKTA_PASSWORD environment variable.') + + parser.add_argument('--config-file', '-C', type=to_unicode, + help='Use an alternative configuration file') + parser.add_argument('--okta-aws-app-url', '-ou', type=to_unicode, + help='Okta App URL to use.') + parser.add_argument('--okta-profile', '-op', type=to_unicode, + help='Okta configuration profile to use.') + parser.add_argument('--aws-region', '-r', type=to_unicode, + help='Sets the AWS region for the profile') + parser.add_argument('--aws-output', '-ao', type=to_unicode, + help='Sets the AWS output type for the profile') + parser.add_argument('--aws-profile', '-ap', type=to_unicode, + help='Override AWS profile to save as in the credentials file.') + parser.add_argument('--mfa-method', '-mm', type=to_unicode, + help='Sets the MFA method') + parser.add_argument('--mfa-response', '-mr', type=to_unicode, + help='Sets the MFA response to a challenge') + parser.add_argument('--role-arn', '-R', type=to_unicode, + help='Sets the IAM role') + parser.add_argument('--output-file', '-o', type=to_unicode, + help="Log output to filename") + parser.add_argument('--loglevel', '-l', type=lambda s: s.upper(), default='ERROR', + choices=["DEBUG", "INFO", "WARN", "ERROR"], + help='[DEBUG|INFO|WARN|ERROR], default loglevel is ERROR.' + ' Note: DEBUG level may display credentials') + + args = parser.parse_args() + set_logging(args) + logging.debug("Parse command line arguments [{}]".format(args)) + + return args + + +def to_unicode(bytestring): + """Convert a str into a Unicode object. + + The `unicode()` method is only available in Python 2. Python 3 + generates a `NameError`, and the same string is returned unmodified. + + :param bytestring: + :return: unicode-escaped string + """ + unicode_string = bytestring + try: + unicode_string = unicode(bytestring, settings.encoding) + except (NameError, TypeError): + pass + return unicode_string + + +def create_directory(dir_name): + """Create directories on the local machine.""" + if os.path.isdir(dir_name) is False: + try: + os.mkdir(dir_name) + except OSError as error: + logging.error("Cannot continue creating directory \'{}\': {}".format( + settings.config_dir, error.strerror)) + sys.exit(1) + + +def set_okta_user_name(): + """Set okta username in a constant settings variable. + + :return: okta_username + + """ + logging.debug("Set okta username in a constant settings variable.") + + if settings.okta_username == '': + okta_username = input('Username: ') + setattr(settings, 'okta_username', to_unicode(okta_username)) + logging.debug('username set to {} interactively'.format( + settings.okta_username)) + + return settings.okta_username + + +def set_okta_password(): + """Set okta password in a constant settings variable. + + :param args: command line arguments + :return: okta_password + + """ + logging.debug("Set okta password in a constant settings variable.") + + while settings.okta_password == '': + okta_password = getpass.getpass() + setattr(settings, 'okta_password', to_unicode(okta_password)) + + logging.debug('password set interactively') + return settings.okta_password + + +def set_logging(args): + """Set logging level. + + :param args: Arguments provided by a user + :return: + + """ + logger = logging.getLogger() + logger.setLevel(args.loglevel) + log_level_int = getattr(logging, args.loglevel) + + # increment boto logs to not print api keys + logging.getLogger('botocore').setLevel( + log_level_int + 10) + + log_format = ( + '%(levelname)s ' + '[%(filename)s:%(funcName)s():%(lineno)i]: %(message)s' + ) + date_format = '%m/%d/%Y %I:%M:%S %p' + + formatter = logging.Formatter(log_format, date_format) + + if args.output_file: + handler = logging.FileHandler(args.output_file) + else: + handler = logging.StreamHandler() + + handler.setFormatter(formatter) + logger.addHandler(handler) + + +def select_role_arn(role_arns, saml_xml, saml_response_string): + """Select the role user wants to pick. + + :param role_arns: IAM roles ARN list assigned for the user + :param saml_xml: Decoded saml response from Okta + :param saml_response_string: http response from saml assertion to AWS + :return: User input index selected by the user, the arn of selected role + + """ + logging.debug("Select the role user wants to pick [{}]".format(role_arns)) + if settings.role_arn is None: + selected_role = prompt_role_choices( + role_arns, saml_xml, saml_response_string) + elif settings.role_arn in role_arns: + selected_role = settings.role_arn + else: + logging.error( + "User provided rolename does not exist [{}]".format(settings.role_arn)) + sys.exit(2) + + logging.debug("Selected role: [{}]".format(selected_role)) + + return selected_role + + +def prompt_role_choices(role_arns, saml_xml, saml_response_string): + """Ask user to select role. + + :param role_arns: IAM Role list + :return: user input of AWS Role + """ + if len(role_arns) == 1: + account_id = role_arns[0].split(":")[4] + alias_table = {account_id: account_id} + else: + alias_table = get_account_aliases(saml_xml, saml_response_string) + + logging.debug("Ask user to select role") + print("Please select one of the following:\n") + + longest_alias = max([len(d) for d in alias_table.values()]) + longest_index = len(str(len(role_arns))) + sorted_role_arns = sorted(role_arns) + + for (i, arn) in enumerate(sorted_role_arns): + padding_index = longest_index - len(str(i)) + account_alias = alias_table[arn.split(":")[4]] + print('[{}] {}{: <{}} {}'.format( + i, padding_index*' ', account_alias, longest_alias, arn)) + + while True: + user_input = to_unicode(input('-> ')) + + try: + user_input = int(user_input) + except ValueError as error: + print('Invalid input, try again.' + str(error)) + logging.warning("Invalid input [{}]".format(error)) + continue + if user_input in range(0, len(role_arns)): + logging.debug("User selected item {}.".format(user_input)) + break + continue + + selected_role = sorted_role_arns[user_input] + + logging.debug("Selected role [{}]".format(user_input)) + + return selected_role + + +def print_selected_role(profile_name, expiration_time): + """Print details about how to assume role. + + :param profile_name: AWS profile name + :param expiration_time: Credentials expiration time + :return: + + """ + msg = ( + '\nGenerated profile \'{}\' in {}.\n' + '\nUse profile to authenticate to AWS:\n\t' + 'aws --profile \'{}\' sts get-caller-identity' + '\nOR\n\t' + 'export AWS_PROFILE=\'{}\'\n\n' + 'Credentials are valid until {}.' + ).format(profile_name, settings.aws_shared_credentials_file, + profile_name, profile_name, expiration_time) + + return print(msg) + + +def extract_arns(saml): + """Extract arns from SAML decoded xml. + + :param saml: results saml decoded + :return: Principle ARNs, Role ARNs + """ + logging.debug("Decode response string as a SAML decoded value.") + + soup = BeautifulSoup(saml, 'xml') + arns = soup.find_all(text=re.compile('arn:aws:iam::')) + if len(arns) == 0: + logging.error("No IAM roles found in SAML response.") + logging.debug(arns) + sys.exit(2) + + roles_and_providers = {i.split(",")[1]: i.split(",")[0] for i in arns} + + logging.debug("Collected ARNs: {}".format(json.dumps(roles_and_providers))) + + return roles_and_providers + + +def validate_saml_response(html): + """Parse html to validate that saml a saml response was returned.""" + soup = BeautifulSoup(html, "html.parser") + + xml = None + for elem in soup.find_all('input', attrs={'name': 'SAMLResponse'}): + saml_base64 = elem.get('value') + xml = codecs.decode(saml_base64.encode( + 'ascii'), 'base64').decode('utf-8') + + if xml is None: + logging.error("Invalid data detected in SAML response." + " View the response with the DEBUG loglevel.") + logging.debug(html) + sys.exit(1) + + return xml + + +def get_account_aliases(saml_xml, saml_response_string): + """Parse AWS SAML page for account aliases. + + :param saml_xml: Decoded saml response from Okta + :param saml_response_string response from Okta with saml data: + :return: mapping table of account ids to their aliases + """ + soup = BeautifulSoup(saml_response_string, "html.parser") + url = soup.find('form').get('action') + + encoded_xml = codecs.encode(saml_xml.encode('utf-8'), 'base64') + aws_response = None + try: + aws_response = requests.Session().post( + url, data={'SAMLResponse': encoded_xml}) + except Exception as request_error: + logging.error( + "There was an error retrieving the AWS SAML page: \n{}".format(request_error)) + logging.debug(json.dumps(aws_response)) + sys.exit(1) + + if "Account: " not in aws_response.text: + logging.error( + "There were no accounts returned in the AWS SAML page.") + logging.debug(json.dumps(aws_response.text)) + sys.exit(2) + + soup = BeautifulSoup(aws_response.text, "html.parser") + account_names = soup.find_all(text=re.compile('Account:')) + alias_table = {str(i.split(" ")[-1]).strip("()"): i.split(" ")[1] for i in account_names} + + return alias_table + + +def process_init_file(config): + """Process options from a ConfigParser init file. + + :param config: ConfigParser object + :return: None + """ + # Read defaults from config + if 'default' in config.sections(): + for (key, val) in config.items('default'): + logging.debug( + 'Set option {}={} from config default'.format(key, val)) + setattr(settings, key, val) + # Override with local profile config + if settings.okta_profile in config.sections(): + for (key, val) in config.items(settings.okta_profile): + logging.debug('Set option {}={} from {}'.format( + key, val, settings.okta_profile)) + setattr(settings, key, val) + else: + logging.warning('Profile \'{}\' does not exist.'.format(settings.okta_profile)) + + +def process_arguments(args): + """Process command-line arguments. + + :param args: argparse object + :return: None + """ + for (key, val) in vars(args).items(): + if val is not None: + logging.debug( + 'Set option {}={} from command line'.format(key, val)) + setattr(settings, key, val) + + +def process_environment(): + """Process environment variables. + + :return: None + """ + for (key, val) in os.environ.items(): + if key.startswith('OKTA_') or \ + key == 'AWS_CONFIG_FILE' or \ + key == 'AWS_SHARED_CREDENTIALS_FILE': + logging.debug( + 'Set option {}={} from environment'.format(key.lower(), val)) + setattr(settings, key.lower(), val) + + +def user_configuration_input(): + """Obtain user input for the user. + + :return: (okta app url, organization username) + + """ + logging.debug("Obtain user input for the user.") + + all_config_msgs = ['Okta App URL. E.g https://acme.okta.com/home/' + 'amazon_aws/b07384d113edec49eaa6/123: ', + 'Organization username. E.g jane.doe@acme.com: '] + config_details = [] + for config_msg in all_config_msgs: + user_input = to_unicode(input(config_msg)) + config_details.append(user_input) + + return (config_details[0], config_details[1]) + + +def update_configuration(okta_file, profile): + """Update okta configuration file on local system. + + :param okta_file: Default configuration system file + :param profile: profile of the okta user + :return: + """ + logging.debug("Update okta configuration file on local system.") + + config = configparser.RawConfigParser() + + create_directory(settings.config_dir) + + if os.path.isfile(okta_file): + logging.debug("Read Okta config [{} {}]".format(okta_file, profile)) + config.read(okta_file, encoding=settings.encoding) + if not config.has_section(profile): + config.add_section(profile) + logging.debug("Add section to Okta config [{}]".format(profile)) + (app_url, user_name) = user_configuration_input() + + url = urlparse(app_url.strip()) + okta_username = user_name.strip() + + if url.scheme == '' or url.netloc == '' or url.path == '': + sys.exit('Okta Application URL invalid or not found. Please reconfigure.') + + okta_aws_app_url = '{}://{}{}'.format(url.scheme, url.netloc, url.path) + + config.set(profile, 'okta_aws_app_url', okta_aws_app_url) + config.set(profile, 'okta_username', okta_username) + logging.debug("Config Okta [{}]".format(config)) + + with open(okta_file, 'w+', encoding=settings.encoding) as file: + config.write(file) + logging.debug( + "Write new section Okta config [{} {}]".format(okta_file, config)) + + +def set_local_credentials(assume_role_response, role_name, aws_region, aws_output): + """Write to local files to insert credentials. + + :param assume_role_response AWS AssumeRoleWithSaml response: + :param role_name the name of the assumed role, used for local profile: + :param aws_region configured region for aws credential profile: + :param aws output configured datatype for aws cli output: + """ + expiration_time = assume_role_response['Credentials']['Expiration'] + aws_access_key = assume_role_response['Credentials']['AccessKeyId'] + aws_secret_key = assume_role_response['Credentials']['SecretAccessKey'] + aws_session_token = assume_role_response['Credentials']['SessionToken'] + + if settings.aws_profile is not None: + role_name = settings.aws_profile + + update_aws_credentials(role_name, aws_access_key, aws_secret_key, + aws_session_token) + update_aws_config(role_name, aws_output, aws_region) + + print_selected_role(role_name, expiration_time) + + +def update_aws_credentials(profile, aws_access_key, aws_secret_key, aws_session_token): + """Update AWS credentials in ~/.aws/credentials default file. + + :param profile: AWS profile name + :param aws_access_key: AWS access key + :param aws_secret_key: AWS secret access key + :param aws_session_token: Session token + :return: + + """ + cred_file = settings.aws_shared_credentials_file + cred_dir = os.path.dirname(cred_file) + logging.debug("Update AWS credentials in: [{}]".format(cred_file)) + + create_directory(cred_dir) + + config = configparser.RawConfigParser() + if os.path.isfile(cred_file): + config.read(cred_file, encoding=settings.encoding) + if not config.has_section(profile): + config.add_section(profile) + config.set(profile, 'aws_access_key_id', aws_access_key) + config.set(profile, 'aws_secret_access_key', aws_secret_key) + config.set(profile, 'aws_session_token', aws_session_token) + with open(cred_file, 'w+', encoding=settings.encoding) as file: + config.write(file) + + +def update_aws_config(profile, output, region): + """Update AWS config file in ~/.aws/config file. + + :param profile: tokendito profile + :param output: aws output + :param region: aws region + :return: + + """ + config_file = settings.aws_config_file + config_dir = os.path.dirname(config_file) + logging.debug("Update AWS config to file: [{}]".format(config_file)) + + create_directory(config_dir) + + # Prepend the word profile the the profile name + profile = 'profile {}'.format(profile) + config = configparser.RawConfigParser() + if os.path.isfile(config_file): + config.read(config_file, encoding=settings.encoding) + if not config.has_section(profile): + config.add_section(profile) + config.set(profile, 'output', output) + config.set(profile, 'region', region) + + with open(config_file, 'w+', encoding=settings.encoding) as file: + config.write(file) + + +def initialize_okta_credentials(): + """Set Okta credentials. + + :return: Success or error message + + """ + logging.debug("Set Okta credentials.") + set_okta_user_name() + set_okta_password() + + +def process_options(args): + """Collect all user-specific credentials and config params.""" + # Point to the correct profile + if args.okta_profile is not None: + logging.debug('okta_profile={}'.format(settings.okta_profile)) + settings.okta_profile = args.okta_profile + + if args.configure: + update_configuration( + settings.config_file, settings.okta_profile) + sys.exit(0) + + config = configparser.ConfigParser() + config.read(settings.config_file) + + # 1: read init file (if it exists) + process_init_file(config) + # 2: override with args + process_arguments(args) + # 3: override with ENV + process_environment() + + if settings.okta_aws_app_url is None: + logging.error( + "Okta Application URL not found in profile '{}'.\nPlease verify your options" + " or re-run this application with the --configure flag".format(settings.okta_profile)) + sys.exit(2) + # Prepare final Okta and AWS app Url + url = urlparse(settings.okta_aws_app_url) + + if url.scheme == '' or url.netloc == '' or url.path == '': + logging.error("Okta Application URL invalid. Please check your configuration" + " and try again.") + sys.exit(2) + + okta_org = '{}://{}'.format(url.scheme, url.netloc) + okta_aws_app_url = '{}{}'.format(okta_org, url.path) + setattr(settings, 'okta_org', okta_org) + setattr(settings, 'okta_aws_app_url', okta_aws_app_url) + + # Set username and password for Okta Authentication + initialize_okta_credentials() diff --git a/tokendito/okta_helpers.py b/tokendito/okta_helpers.py new file mode 100644 index 00000000..f20bd98c --- /dev/null +++ b/tokendito/okta_helpers.py @@ -0,0 +1,261 @@ +# vim: set filetype=python ts=4 sw=4 +# -*- coding: utf-8 -*- +""" +This module handles the all Okta operations. + +1. Okta authentication +2. Update Okta Config File + +""" +from __future__ import (absolute_import, division, + print_function, unicode_literals) + +from builtins import (ascii, bytes, chr, dict, filter, hex, input, # noqa: F401 + int, list, map, next, object, oct, open, pow, range, + round, str, super, zip) +import json +import logging +import sys +import time + +from future import standard_library +import requests +from tokendito import helpers +from tokendito import settings +standard_library.install_aliases() + + +def okta_verify_api_method(mfa_challenge_url, payload, headers): + """Okta MFA authentication. + + :param mfa_challenge_url: MFA challenge url + :param payload: JSON Payload + :param headers: Headers of the request + :return: Okta authentication response + """ + logging.debug("Okta MFA authentication URL [{}] headers [{}]".format( + mfa_challenge_url, headers)) + + try: + response = json.loads(requests.request('POST', mfa_challenge_url, + data=json.dumps(payload), headers=headers).text) + except Exception as request_error: + logging.error( + "There was an error connecting to Okta: \n{}".format(request_error)) + sys.exit(1) + + if 'errorCode' in response: + error_string = "Exiting due to Okta API error [{}]\n{}".format( + response['errorCode'], response['errorSummary']) + if len(response['errorCauses']) > 0: + error_string += "\n{}".format(json.dumps(response['errorCauses'])) + logging.error(error_string) + sys.exit(1) + + return response + + +def authenticate_user(okta_url, okta_username, okta_password): + """Authenticate user with okta credential. + + :param okta_url: company specific URL of the okta + :param okta_username: okta username + :param okta_password: okta password + :return: MFA session options + + """ + logging.debug( + "Authenticate user with okta credential [{} user {}]".format( + okta_url, okta_username)) + headers = { + 'content-type': 'application/json', + 'accept': 'application/json' + } + payload = prepare_payload( + username=okta_username, password=okta_password) + + primary_auth = okta_verify_api_method( + '{}/api/v1/authn'.format(okta_url), payload, headers) + logging.debug("Authenticate Okta header [{}] ".format(headers)) + + return user_mfa_challenge(headers, primary_auth) + + +def user_mfa_challenge(headers, primary_auth): + """Handle user mfa challenges. + + :param headers: headers what needs to be sent to api + :param primary_auth: primary authentication + :return: Okta MFA Session token after the successful entry of the code + + """ + logging.debug("Handle user MFA challenges") + try: + mfa_options = primary_auth['_embedded']['factors'] + except KeyError: + logging.error("Okta auth failed: " + "Could not retrieve list of MFA methods") + logging.debug("Error parsing response: {}".format( + json.dumps(primary_auth))) + sys.exit(1) + + mfa_setup_statuses = [ + d['status'] for d in mfa_options if 'status' in d and d['status'] != "ACTIVE"] + + if len(mfa_setup_statuses) == len(mfa_options): + logging.error("MFA not configured. " + "Please enable MFA on your account and try again.") + sys.exit(2) + + preset_mfa = settings.mfa_method + available_mfas = [d['factorType'] for d in mfa_options] + if preset_mfa is not None and preset_mfa in available_mfas: + mfa_index = available_mfas.index(settings.mfa_method) + else: + logging.warning( + "No MFA provided or provided MFA does not exist. [{}]".format( + settings.mfa_method)) + mfa_index = select_preferred_mfa_index(mfa_options) + + # time to challenge the mfa option + selected_mfa_option = mfa_options[mfa_index] + logging.debug("Selected MFA is [{}]".format(selected_mfa_option)) + + mfa_challenge_url = selected_mfa_option['_links']['verify']['href'] + payload = prepare_payload(stateToken=primary_auth['stateToken'], + factorType=selected_mfa_option['factorType'], + provider=selected_mfa_option['provider'], + profile=selected_mfa_option['profile']) + okta_verify_api_method(mfa_challenge_url, payload, headers) + logging.debug("mfa_challenge_url [{}] headers [{}]".format( + mfa_challenge_url, headers)) + mfa_verify = user_mfa_options(selected_mfa_option, + headers, mfa_challenge_url, payload, primary_auth) + return mfa_verify['sessionToken'] + + +def user_mfa_options(selected_mfa_option, + headers, mfa_challenge_url, + payload, primary_auth): + """Handle user mfa options. + + :param selected_mfa_option: Selected MFA option (SMS, push, etc) + :param headers: headers + :param mfa_challenge_url: MFA challenge URL + :param payload: payload + :param primary_auth: Primary authentication method + :return: payload data + + """ + logging.debug("Handle user MFA options") + + logging.debug("User MFA options selected: [{}]".format( + selected_mfa_option['factorType'])) + if selected_mfa_option['factorType'] == 'push': + return push_approval(headers, mfa_challenge_url, payload) + + if settings.mfa_response is None: + logging.debug("Getting verification code from user.") + print('Type verification code and press Enter') + settings.mfa_response = helpers.to_unicode(input('-> ')) + + # time to verify the mfa method + payload = prepare_payload( + stateToken=primary_auth['stateToken'], passCode=settings.mfa_response) + mfa_verify = okta_verify_api_method(mfa_challenge_url, payload, headers) + logging.debug("mfa_verify [{}]".format(mfa_verify)) + + return mfa_verify + + +def prepare_payload(**kwargs): + """Prepare payload for the HTTP request header. + + :param kwargs: parameters to get together + :return: payload for the http header + + """ + logging.debug("Prepare payload") + + payload_dict = {} + if kwargs is not None: + for key, value in list(kwargs.items()): + payload_dict[key] = value + + if key != 'password': + logging.debug("Prepare payload [{} {}]".format(key, value)) + + return payload_dict + + +def push_approval(headers, mfa_challenge_url, payload): + """Handle push approval from the user. + + :param headers: HTTP headers sent to API call + :param mfa_challenge_url: MFA challenge url + :param payload: payload which needs to be sent + :return: Session Token if succeeded or terminates if user wait goes 5 min + + """ + logging.debug("Handle push approval from the user [{}] [{}]".format( + headers, mfa_challenge_url)) + + print('Waiting for an approval from device...') + mfa_status = "WAITING" + + while mfa_status == "WAITING": + mfa_verify = okta_verify_api_method( + mfa_challenge_url, payload, headers) + + logging.debug("MFA Response:\n{}".format(json.dumps(mfa_verify))) + + if 'factorResult' in mfa_verify: + mfa_status = mfa_verify['factorResult'] + elif mfa_verify['status'] == 'SUCCESS': + break + else: + logging.error( + "There was an error getting your MFA status.") + logging.debug(mfa_verify) + if 'status' in mfa_verify: + logging.error("Exiting due to error: {}".format( + mfa_verify['status'])) + sys.exit(1) + + if mfa_status == 'REJECTED': + logging.error( + "The Okta Verify push has been denied. Please retry later.") + sys.exit(2) + elif mfa_status == 'TIMEOUT': + logging.error("Device approval window has expired.") + sys.exit(2) + + time.sleep(2) + + return mfa_verify + + +def select_preferred_mfa_index(mfa_options): + """Show all the MFA options to the users. + + :param mfa_options: List of available MFA options + :return: MFA option selected index by the user from the output + """ + logging.debug("Show all the MFA options to the users.") + print('\nSelect your preferred MFA method and press Enter') + for (mfa_counter, mfa_option) in enumerate(mfa_options): + print("[{}] {}".format(mfa_counter, mfa_option['factorType'])) + while True: + user_input = helpers.to_unicode(input('-> ')) + logging.debug("User input [{}]".format(user_input)) + + try: + user_input = int(user_input) + except ValueError as error: + print('Invalid input, try again.\n{}'.format(error)) + continue + if user_input in range(0, len(mfa_options)): + break + print('Invalid choice') + continue + return user_input diff --git a/tokendito/settings.py b/tokendito/settings.py new file mode 100644 index 00000000..f627c147 --- /dev/null +++ b/tokendito/settings.py @@ -0,0 +1,32 @@ +# vim: set filetype=python ts=4 sw=4 +# -*- coding: utf-8 -*- +"""This module is responsible for initialisation of global variables.""" +from __future__ import (absolute_import, division, + print_function, unicode_literals) + +from builtins import (ascii, bytes, chr, dict, filter, hex, input, # noqa: F401 + int, list, map, next, object, oct, open, pow, range, + round, str, super, zip) +from os.path import expanduser +import sys + +from future import standard_library +standard_library.install_aliases() + + +config_dir = expanduser('~') + '/.aws' +config_file = config_dir + '/okta_auth' +aws_config_file = config_dir + '/config' +aws_shared_credentials_file = config_dir + '/credentials' +aws_output = 'json' +aws_profile = None +aws_region = 'us-east-1' +encoding = sys.stdin.encoding +mfa_method = None +mfa_response = None +okta_aws_app_url = None +okta_org = None +okta_password = '' +okta_profile = 'default' +okta_username = '' +role_arn = None diff --git a/tokendito/tokendito.py b/tokendito/tokendito.py new file mode 100755 index 00000000..242097ee --- /dev/null +++ b/tokendito/tokendito.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# vim: set filetype=python ts=4 sw=4 +# -*- coding: utf-8 -*- +"""tokendito cli entry point.""" +from __future__ import (absolute_import, division, + print_function, unicode_literals) + +from builtins import (ascii, bytes, chr, dict, filter, hex, input, # noqa: F401 + int, list, map, next, object, oct, open, pow, range, + round, str, super, zip) +import sys + +from future import standard_library +standard_library.install_aliases() + + +def main(): # needed for console script + """Packge entry point.""" + if __package__ is None: + import os.path + path = os.path.dirname(os.path.dirname(__file__)) + sys.path[0:0] = [path] + from tokendito.tool import cli + return cli() + + +if __name__ == '__main__': + try: + sys.exit(main()) + except KeyboardInterrupt: + print('\nInterrupted') + sys.exit(1) diff --git a/tokendito/tool.py b/tokendito/tool.py new file mode 100644 index 00000000..9c317b80 --- /dev/null +++ b/tokendito/tool.py @@ -0,0 +1,48 @@ +# vim: set filetype=python ts=4 sw=4 +# -*- coding: utf-8 -*- +"""This module retrieves AWS credentials after authenticating with Okta.""" +from __future__ import (absolute_import, division, + print_function, unicode_literals) + +from builtins import (ascii, bytes, chr, dict, filter, hex, input, # noqa: F401 + int, list, map, next, object, oct, open, pow, range, + round, str, super, zip) +import logging + +from future import standard_library +from tokendito import aws_helpers +from tokendito import helpers +from tokendito import okta_helpers +from tokendito import settings + +standard_library.install_aliases() + + +def cli(): + """Tokendito retrieves AWS credentials after authenticating with Okta.""" + # Set some required initial values + args = helpers.setup() + + logging.debug( + "tokendito retrieves AWS credentials after authenticating with Okta." + ) + + # Collect and organize user specific information + helpers.process_options(args) + + # Authenticate okta and AWS also use assumerole to assign the role + logging.debug("Authenticate user with Okta and AWS.") + + secret_session_token = okta_helpers.authenticate_user( + settings.okta_org, settings.okta_username, settings.okta_password) + + saml_response_string, saml_xml = aws_helpers.authenticate_to_roles( + secret_session_token, settings.okta_aws_app_url) + + assume_role_response, role_name = aws_helpers.select_assumeable_role( + saml_response_string, saml_xml) + + aws_helpers.ensure_keys_work(assume_role_response) + + helpers.set_local_credentials(assume_role_response, role_name, + settings.aws_region, settings.aws_output) diff --git a/tox.ini b/tox.ini new file mode 100755 index 00000000..c4611ff0 --- /dev/null +++ b/tox.ini @@ -0,0 +1,33 @@ +[tox] +skipsdist = true +envlist = lint, py27, py36, py37, functional + +[testenv] +deps = -rrequirements.txt +commands = + pip install . + tokendito --version + pip uninstall --yes tokendito + python -m tokendito --version + python tokendito/tokendito.py --version + +[testenv:lint] +deps = -rrequirements-dev.txt +commands = + {[testenv:flake8]commands} + pyroma --min=10 . + +[testenv:flake8] +skip_install = true +commands = flake8 + +[testenv:functional] +commands = python -c 'import sys; sys.exit(0)' + +[flake8] +max-line-length = 100 +max-complexity = 8 +exclude = .git, __pycache__, .tox, venv/ +import-order-style = google +application-import-names = flake8 +format = ${cyan}%(path)s${reset}:${yellow_bold}%(row)d${reset}:${green_bold}%(col)d${reset}: ${red_bold}%(code)s${reset} %(text)s