Skip to content

Commit

Permalink
Major refactor
Browse files Browse the repository at this point in the history
* Moved most logic from main.py to keyman.py which can be used more like a module
* Significantly reduced complexity of some sections of code
* Improved maintainability
* Reduced duplication
* Updated README with relevant information for end users
* Fixed or added docstrings throughout
* Pylint fixes throughout
* Updated import orders throughout
* Fixed LOG constant
* Fixed some typos
* Improved logging and messaging if MFA required but factor unsupported
* Added support for Google Auth OTP type
* Bump to v0.3.3
  • Loading branch information
nathan-v committed Apr 27, 2018
1 parent 7725b53 commit 0aec9f2
Show file tree
Hide file tree
Showing 17 changed files with 1,026 additions and 723 deletions.
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[![Apache](https://img.shields.io/badge/license-Apache-blue.svg)](https://github.com/nathan-v/aws_okta_keyman/blob/master/LICENSE.txt) [![PyPI version](https://badge.fury.io/py/aws-okta-keyman.svg)](https://badge.fury.io/py/aws-okta-keyman) [![Python versions](https://img.shields.io/pypi/pyversions/aws-okta-keyman.svg?style=flat-square)](https://pypi.python.org/pypi/aws-okta-keyman/0.2.0)
[![Apache](https://img.shields.io/badge/license-Apache-blue.svg)](https://github.com/nathan-v/aws_okta_keyman/blob/master/LICENSE.txt) [![PyPI version](https://badge.fury.io/py/aws-okta-keyman.svg)](https://badge.fury.io/py/aws-okta-keyman) [![Python versions](https://img.shields.io/pypi/pyversions/aws-okta-keyman.svg?style=flat-square)](https://pypi.python.org/pypi/aws-okta-keyman/0.2.0) [![Downloads](http://pepy.tech/badge/aws-okta-keyman)](http://pepy.tech/count/aws-okta-keyman)

[![CircleCI](https://circleci.com/gh/nathan-v/aws_okta_keyman.svg?style=svg&circle-token=93e91f099440edc9f62378bb3f056af8b0841231)](https://circleci.com/gh/nathan-v/aws_okta_keyman) [![CC GPA](https://codeclimate.com/github/nathan-v/aws_okta_keyman/badges/gpa.svg)](https://codeclimate.com/github/nathan-v/aws_okta_keyman) [![CC Issues](https://codeclimate.com/github/nathan-v/aws_okta_keyman/badges/issue_count.svg)](https://codeclimate.com/github/nathan-v/aws_okta_keyman) [![Coverage Status](https://coveralls.io/repos/github/nathan-v/aws_okta_keyman/badge.svg?branch=master)](https://coveralls.io/github/nathan-v/aws_okta_keyman?branch=master)
[![CircleCI](https://circleci.com/gh/nathan-v/aws_okta_keyman/tree/master.svg?style=svg&circle-token=93e91f099440edc9f62378bb3f056af8b0841231)](https://circleci.com/gh/nathan-v/aws_okta_keyman/tree/master) [![CC GPA](https://codeclimate.com/github/nathan-v/aws_okta_keyman/badges/gpa.svg)](https://codeclimate.com/github/nathan-v/aws_okta_keyman) [![CC Issues](https://codeclimate.com/github/nathan-v/aws_okta_keyman/badges/issue_count.svg)](https://codeclimate.com/github/nathan-v/aws_okta_keyman) [![Coverage Status](https://coveralls.io/repos/github/nathan-v/aws_okta_keyman/badge.svg?branch=master)](https://coveralls.io/github/nathan-v/aws_okta_keyman?branch=master)

# AWS Okta Keyman

Expand Down Expand Up @@ -31,6 +31,13 @@ In the case of Duo Auth a web page is opened (served locally) for the user to
interact with Duo and select their preferred authentication method. Once Duo is
successful the user may close the browser or tab.

### Supported MFA Solutions

* Okta Verify
* Duo Auth
* Okta OTP
* Google Auth OTP

## Multiple AWS Roles

AWS Okta Keyman supports multiple AWS roles when configued. The user is prompted to
Expand Down Expand Up @@ -95,6 +102,15 @@ accounts:

# Usage

## Client Setup

Before you can install this tool you need to have a working Python installation with pip.
If you're not sure if you have this a good place to start would be the [Python Beginner's Guide](https://wiki.python.org/moin/BeginnersGuide/Download) .

Once your Python environment is configured simply run `pip install aws-okta-keyman` to install the tool.

## Running AWS Okta Keyman

For detailed usage instructions, see the `--help` commandline argument.

Typical usage:
Expand Down
1 change: 1 addition & 0 deletions aws_okta_keyman/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
#
# Copyright 2018 Nextdoor.com, Inc
# Copyright 2018 Nathan V
"""Empty init."""
6 changes: 4 additions & 2 deletions aws_okta_keyman/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,17 @@
to AWS to get them.
"""
from __future__ import unicode_literals
from builtins import str

import configparser
import datetime
import logging
import os
from os.path import expanduser
import xml
from builtins import str
from os.path import expanduser

import boto3

from aws_okta_keyman.aws_saml import SamlAssertion

LOG = logging.getLogger(__name__)
Expand Down
47 changes: 27 additions & 20 deletions aws_okta_keyman/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import os

import yaml

from aws_okta_keyman.metadata import __version__

LOG = logging.getLogger(__name__)
Expand Down Expand Up @@ -196,36 +197,37 @@ def optional_args(optional_args):
),
default=False)

def parse_config(self, filename):
"""Parse a configuration file and set the variables from it."""
@staticmethod
def read_yaml(filename, raise_on_error=False):
"""Read a YAML file and optionally raise if anything goes wrong."""
config = {}
try:
if os.path.isfile(filename):
config = yaml.load(open(filename, 'r'))
LOG.debug("YAML loaded config: {}".format(config))
else:
raise IOError("File not found: {}".format(filename))
if raise_on_error:
raise IOError("File not found: {}".format(filename))
except (yaml.parser.ParserError, yaml.scanner.ScannerError):
LOG.error('Error parsing config file; invalid YAML.')
raise
if raise_on_error:
raise
return config

def parse_config(self, filename):
"""Parse a configuration file and set the variables from it."""
config = self.read_yaml(filename, raise_on_error=True)

for key, value in config.items():
if getattr(self, key) is None: # Only overwrite None not args
if not getattr(self, key): # Only overwrite None not args
setattr(self, key, value)

def write_config(self):
"""Use provided arguments and existing config to write an updated
config file.
"""
file_path = os.path.expanduser(self.writepath)
try:
if os.path.isfile(file_path):
config = yaml.load(open(file_path, 'r'))
LOG.debug("YAML loaded config: {}".format(config))
else:
config = {}
except (yaml.parser.ParserError, yaml.scanner.ScannerError):
config = {}
LOG.error('Error parsing config file; invalid YAML.')
config = self.read_yaml(file_path)

args_dict = dict(vars(self))

Expand All @@ -236,8 +238,16 @@ def write_config(self):
if args_dict[key] is not None:
setattr(self, key, args_dict[key])

config = dict(vars(self))
# Remove args we don't want to save to a config file
config_out = self.clean_config_for_write(dict(vars(self)))

LOG.debug("YAML being saved: {}".format(config_out))

with open(file_path, 'w') as outfile:
yaml.safe_dump(config_out, outfile, default_flow_style=False)

@staticmethod
def clean_config_for_write(config):
"""Remove args we don't want to save to a config file."""
ignore = ['name', 'appid', 'argv', 'writepath', 'config', 'debug',
'oktapreview']
for var in ignore:
Expand All @@ -246,7 +256,4 @@ def write_config(self):
if config['accounts'] is None:
del config['accounts']

LOG.debug("YAML being saved: {}".format(config))

with open(file_path, 'w') as outfile:
yaml.safe_dump(config, outfile, default_flow_style=False)
return config
5 changes: 3 additions & 2 deletions aws_okta_keyman/duo.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@
# Copyright 2018 Nathan V
"""All the Duo things."""

from multiprocessing import Process
import sys
import time
from multiprocessing import Process

if sys.version_info[0] < 3: # pragma: no cover
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
else:
else: # pragma: no cover
from http.server import HTTPServer, BaseHTTPRequestHandler


Expand Down
230 changes: 230 additions & 0 deletions aws_okta_keyman/keyman.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
#!/usr/bin/env python

# 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 ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Copyright 2018 Nextdoor.com, Inc
# Copyright 2018 Nathan V
"""This module contains the primary logic of the tool."""
from __future__ import unicode_literals

import getpass
import logging
import sys
import time
from builtins import input

import rainbow_logging_handler
import requests

from aws_okta_keyman import aws, okta
from aws_okta_keyman.config import Config
from aws_okta_keyman.metadata import __desc__, __version__


class Keyman:
"""Main class for the tool."""

def __init__(self, argv):
self.okta_client = None
self.log = self.setup_logging()
self.log.info('{} v{}'.format(__desc__, __version__))
self.config = Config(argv)
try:
self.config.get_config()
except ValueError as err:
self.log.fatal(err)
sys.exit(1)
if self.config.debug:
self.log.setLevel(logging.DEBUG)

def main(self):
"""Execute primary logic path."""
try:
# If there's no appid try to select from accounts in config file
self.handle_appid_selection()

# get user password
password = self.user_password()

# Generate our initial OktaSaml client
self.init_okta(password)

# Authenticate to Okta
self.auth_okta()

# Start the AWS session and loop (if using reup)
self.aws_auth_loop()

except KeyboardInterrupt:
# Allow users to exit cleanly at any time.
print('')
self.log.info('Exiting after keyboard interrupt.')
sys.exit(1)

@staticmethod
def setup_logging():
"""Return back a pretty color-coded logger."""
logger = logging.getLogger()
logger.setLevel(logging.INFO)
handler = rainbow_logging_handler.RainbowLoggingHandler(sys.stdout)
fmt = '%(asctime)-10s (%(levelname)s) %(message)s'
formatter = logging.Formatter(fmt)
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger

@staticmethod
def user_input(text):
"""Wrap input() making testing support of py2 and py3 easier."""
return input(text)

@staticmethod
def user_password():
"""Wrap getpass to simplify testing."""
return getpass.getpass()

def selector_menu(self, options, key, key_name):
"""Show a selection menu from a dict so the user can pick something."""
selection = -1
while selection < 0 or selection > len(options):
for index, option in enumerate(options):
print("[{}] {}: {}".format(index, key_name, option[key]))
selection = int(self.user_input("{} selection: ".format(key_name)))
return selection

def handle_appid_selection(self):
"""If we have no appid specified and we have accounts from a config
file display the options to the user and select one
"""
if self.config.appid is None and self.config.accounts:
msg = 'No app ID provided; select from available AWS accounts'
self.log.warning(msg)
accts = self.config.accounts
acct_selection = self.selector_menu(accts, 'name', 'Account')
self.config.set_appid_from_account_id(acct_selection)
msg = "Using account: {} / {}".format(
accts[acct_selection]["name"], accts[acct_selection]["appid"]
)
self.log.info(msg)

def init_okta(self, password):
"""Initialize the Okta client or exit if the client received an empty
input value
"""
try:
if self.config.oktapreview is True:
self.okta_client = okta.OktaSaml(self.config.org,
self.config.username,
password, oktapreview=True)
else:
self.okta_client = okta.OktaSaml(self.config.org,
self.config.username,
password)

except okta.EmptyInput:
self.log.fatal('Cannot enter a blank string for any input')
sys.exit(1)

def auth_okta(self):
"""Authenticate the Okta client. Prompt for MFA if necessary"""
try:
self.okta_client.auth()
except okta.InvalidPassword:
self.log.fatal('Invalid Username ({user}) or Password'.format(
user=self.config.username
))
sys.exit(1)
except okta.PasscodeRequired as err:
self.log.warning(
"MFA Requirement Detected - Enter your {} code here".format(
err.provider
)
)
verified = False
while not verified:
passcode = self.user_input('MFA Passcode: ')
verified = self.okta_client.validate_mfa(err.fid,
err.state_token,
passcode)
except okta.UnknownError as err:
self.log.fatal("Fatal error: {}".format(err))
sys.exit(1)

def handle_multiple_roles(self, session):
"""If there's more than one role available from AWS present the user
with a list to pick from
"""
self.log.warning('Multiple AWS roles found; please select one')
roles = session.available_roles()
role_selection = self.selector_menu(roles, 'role', 'Role')
session.set_role(role_selection)
session.assume_role()
return role_selection

def start_session(self):
"""Initialize AWS session object."""
assertion = self.okta_client.get_assertion(appid=self.config.appid,
apptype='amazon_aws')
return aws.Session(assertion, profile=self.config.name)

def aws_auth_loop(self):
"""Once we're authenticated with an OktaSaml client object we use that
object to get a fresh SAMLResponse repeatedly and refresh our AWS
Credentials.
"""
session = None
role_selection = None
retries = 0
while True:
# If we have a session and it's valid take a nap
if session and session.is_valid:
self.log.debug('Credentials are still valid, sleeping')
time.sleep(15)
continue

self.log.info('Getting SAML Assertion from {org}'.format(
org=self.config.org))

try:
session = self.start_session()

# If role_selection is set we're in a reup loop. Re-set the
# role on the session to prevent the user being prompted for
# the role again on each subsequent renewal.
if role_selection is not None:
session.set_role(role_selection)

session.assume_role()

except aws.MultipleRoles:
role_selection = self.handle_multiple_roles(session)
except requests.exceptions.ConnectionError:
self.log.warning('Connection error... will retry')
time.sleep(5)
continue
except aws.InvalidSaml:
self.log.error('AWS SAML response invalid. Retrying...')
time.sleep(1)
retries += 1
if retries > 2:
self.log.fatal('SAML failure. Please reauthenticate.')
sys.exit(1)
continue

# If we're not running in re-up mode, once we have the assertion
# and creds, go ahead and quit.
if not self.config.reup:
return

self.log.info('Reup enabled, sleeping...')
time.sleep(5)
Loading

0 comments on commit 0aec9f2

Please sign in to comment.