Skip to content

Commit

Permalink
Merge pull request #25 from jmhale/develop
Browse files Browse the repository at this point in the history
Merge develop into v0.2.0
  • Loading branch information
jmhale committed Feb 11, 2018
2 parents 401720c + 1853dfb commit 4602e2c
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 66 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
# Changelog

## [0.2.0] TBD
## [0.2.0] 2018-02-11
### Added:
- Ability to store MFA factor choice in `~/.okta-aws`. (#3)
- Flag to output the version.
- Ability to store AWS Role choice in `~/.okta-aws`. (#4)
- Ability to pass in TOTP token as a command-line argument. (#13)
- Support for MFA push notifications. Thanks Justin! (#10)
- Support for caching credentials to use in other sessions. Thanks Justin! (#6, #7)

### Fixed:
- Issue #14. Fixed a bug where okta-awscli wasn't connecting to the STS API endpoint in us-gov-west-1 when trying to obtain credential for GovCloud.
- Improved sorting in the app list to be more consistent. Thanks Justin!
- Cleaned up README to improve clarity. Thanks Justin!

## [0.1.5] 2017-11-15
### Fixed:
Expand Down
35 changes: 25 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
# okta_awscli
# okta_awscli - Retrieve AWS credentials from Okta

Authenticates a user against Okta and then uses the resulting SAML assertion to retrieve temporary STS credentials from AWS.

This project is largely inspired by https://github.com/nimbusscale/okta_aws_login, but instead uses a purely API-driven approach, instead of parsing HTML during the authentication phase.

Parsing the HTML is still required to get the SAML assertion, after authentication is complete. However, since we only need to look for the SAML assertion in a single, predictable tag, `<input name="SAMLResponse"...`, the results are a lot more stable across any changes that Okta may make to their interface.

*okta_awscli supports MFA if it is enabled for the entire Okta tenant.*
*MFA that is required "per app", is not supported.*

Installation:
- `pip install okta-awscli`
## Installation

Usage:
- `pip install okta-awscli`
- Configure okta-awscli via the `~/.okta-aws` file with the following parameters:

- First, create a `~/.okta-aws` file, with the following parameters:
```
[default]
base-url = <your_okta_org>.okta.com
Expand All @@ -26,13 +23,29 @@ password = <your_okta_password> # Only save your password if you know what you a
factor = <your_preferred_mfa_factor> # Current choices are: GOOGLE or OKTA
```

Note: Multiple Okta profiles are supported, but if none are specified, then `default` will be used.
## Supported Features

- Tenant wide MFA support
- Okta Verify [Play Store](https://play.google.com/store/apps/details?id=com.okta.android.auth) | [App Store](https://itunes.apple.com/us/app/okta-verify/id490179405)
- Okta Verify Push Support
- Google Authenticator [Play Store](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2) | [App Store](https://itunes.apple.com/us/app/google-authenticator/id388497605)


## Unsupported Features

- `okta-awscli --profile <aws_profile> <awscli action> <awscli arguments>`
- Per application MFA support


## Usage

`okta-awscli --profile <aws_profile> <awscli action> <awscli arguments>`
- Follow the prompts to enter MFA information (if required) and choose your AWS app and IAM role.
- Subsequent executions will first check if the STS credentials are still valid and skip Okta authentication if so.
- Multiple Okta profiles are supported, but if none are specified, then `default` will be used.


### Example

Example:
`okta-awscli --profile my-aws-account iam list-users`

If no awscli commands are provided, then okta-awscli will simply output STS credentials to your credentials file, or console, depending on how `--profile` is set.
Expand All @@ -42,4 +55,6 @@ Optional flags:
- `--force` Ignores result of STS credentials validation and gets new credentials from AWS. Used in conjunction with `--profile`.
- `--verbose` More verbose output.
- `--debug` Very verbose output. Useful for debugging.
- `--cache` Cache the acquired credentials to ~/.okta-credentials.cache (only if --profile is unspecified)
- `--okta-profile` Use a Okta profile, other than `default` in `.okta-aws`. Useful for multiple Okta tenants.
- `--token` or `-t` Pass in the TOTP token from your authenticator
17 changes: 9 additions & 8 deletions oktaawscli/aws_auth.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
""" AWS authentication """
#pylint: disable=C0325
# pylint: disable=C0325
import os
import base64
import xml.etree.ElementTree as ET
from collections import namedtuple
from ConfigParser import RawConfigParser
from configparser import RawConfigParser
import boto3
from botocore.exceptions import ClientError


class AwsAuth(object):
""" Methods to support AWS authentication using STS """

Expand Down Expand Up @@ -45,13 +46,13 @@ def choose_aws_role(self, assertion):
for index, role in enumerate(roles):
role_name = role.role_arn.split('/')[1]

## Return the role as soon as it matches the saved role
## Proceed to user choice if it's not found.
# Return the role as soon as it matches the saved role
# Proceed to user choice if it's not found.
if self.role:
if role_name == self.role:
self.logger.info("Using predefined role: %s" % self.role)
return roles[index]
role_list.append("%d: %s" % (index+1, role_name))
role_list.append("%d: %s" % (index + 1, role_name))

if self.role:
self.logger.info("Predefined role, %s, not found in the list of roles assigned to you."
Expand All @@ -61,14 +62,14 @@ def choose_aws_role(self, assertion):
for index, role_name in enumerate(role_list):
print(role_name)

role_choice = input('Please select the AWS role: ')-1
role_choice = input('Please select the AWS role: ') - 1
return roles[role_choice]

@staticmethod
def get_sts_token(role_arn, principal_arn, assertion):
""" Gets a token from AWS STS """

## Connect to the GovCloud STS endpoint if a GovCloud ARN is found.
# Connect to the GovCloud STS endpoint if a GovCloud ARN is found.
arn_region = principal_arn.split(':')[1]
if arn_region == 'aws-us-gov':
sts = boto3.client('sts', region_name='us-gov-west-1')
Expand All @@ -91,7 +92,7 @@ def check_sts_token(self, profile):
parser.read(self.creds_file)

if not os.path.exists(self.creds_dir):
self.logger.info("AWS credentials path does not exit. Not checking.")
self.logger.info("AWS credentials path does not exist. Not checking.")
return False

elif not os.path.isfile(self.creds_file):
Expand Down
86 changes: 63 additions & 23 deletions oktaawscli/okta_auth.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
""" Handles auth to Okta and returns SAML assertion """
#pylint: disable=C0325
# pylint: disable=C0325
import sys
import os
from ConfigParser import RawConfigParser
import time
from configparser import RawConfigParser
from getpass import getpass
from bs4 import BeautifulSoup as bs
import requests


class OktaAuth(object):
""" Handles auth to Okta and returns SAML assertion """
def __init__(self, okta_profile, verbose, logger):
def __init__(self, okta_profile, verbose, logger, totp_token):
home_dir = os.path.expanduser('~')
okta_config = home_dir + '/.okta-aws'
parser = RawConfigParser()
parser.read(okta_config)
profile = okta_profile
self.totp_token = totp_token
self.logger = logger
self.factor = ""
if parser.has_option(profile, 'base-url'):
Expand Down Expand Up @@ -46,7 +49,7 @@ def primary_auth(self):
"username": self.username,
"password": self.password
}
resp = requests.post(self.base_url+'/api/v1/authn', json=auth_data)
resp = requests.post(self.base_url + '/api/v1/authn', json=auth_data)
resp_json = resp.json()
if 'status' in resp_json:
if resp_json['status'] == 'MFA_REQUIRED':
Expand All @@ -66,14 +69,24 @@ def primary_auth(self):

def verify_mfa(self, factors_list, state_token):
""" Performs MFA auth against Okta """

supported_factor_types = ["token:software:totp", "push"]
supported_factors = []
for factor in factors_list:
if factor['factorType'] == "token:software:totp":
if factor['factorType'] in supported_factor_types:
supported_factors.append(factor)

if supported_factors == 1:
session_token = self.verify_single_factor(supported_factors[0]['id'], state_token)
elif supported_factors > 0:
else:
self.logger.info("Unsupported factorType: %s" %
(factor['factorType'],))

supported_factors = sorted(supported_factors,
key=lambda factor: (
factor['provider'],
factor['factorType']))
if len(supported_factors) == 1:
session_token = self.verify_single_factor(
supported_factors[0]['id'], state_token)
elif len(supported_factors) > 0:
if not self.factor:
print("Registered MFA factors:")
for index, factor in enumerate(supported_factors):
Expand All @@ -93,66 +106,93 @@ def verify_mfa(self, factors_list, state_token):
if self.factor:
if self.factor == factor_provider:
factor_choice = index
self.logger.info("Using pre-selected factor choice from ~/.okta-aws")
self.logger.info("Using pre-selected factor choice \
from ~/.okta-aws")
break
else:
print("%d: %s" % (index+1, factor_name))
print("%d: %s" % (index + 1, factor_name))
if not self.factor:
factor_choice = input('Please select the MFA factor: ')
self.logger.info("Performing secondary authentication using: %s" %
supported_factors[factor_choice]['provider'])
session_token = self.verify_single_factor(supported_factors[factor_choice-1]['id'],
session_token = self.verify_single_factor(supported_factors[factor_choice-1],
state_token)
else:
print("MFA required, but no supported factors enrolled! Exiting.")
exit(1)
return session_token

def verify_single_factor(self, factor_id, state_token):
def verify_single_factor(self, factor, state_token):
""" Verifies a single MFA factor """
factor_answer = raw_input('Enter MFA token: ')
req_data = {
"stateToken": state_token,
"answer": factor_answer
"stateToken": state_token
}
post_url = "%s/api/v1/authn/factors/%s/verify" % (self.base_url, factor_id)

if factor['factorType'] == 'token:software:totp':
if self.totp_token:
self.logger.debug("Using TOTP token from command line arg")
req_data['answer'] = self.totp_token
else:
req_data['answer'] = raw_input('Enter MFA token: ')
post_url = factor['_links']['verify']['href']
resp = requests.post(post_url, json=req_data)
resp_json = resp.json()
if 'status' in resp_json:
if resp_json['status'] == "SUCCESS":
return resp_json['sessionToken']
elif resp_json['status'] == "MFA_CHALLENGE":
print "Waiting for push verification..."
while True:
resp = requests.post(
resp_json['_links']['next']['href'], json=req_data)
resp_json = resp.json()
if resp_json['status'] == 'SUCCESS':
return resp_json['sessionToken']
elif resp_json['factorResult'] == 'TIMEOUT':
print "Verification timed out"
exit(1)
elif resp_json['factorResult'] == 'REJECTED':
print "Verification was rejected"
exit(1)
else:
time.sleep(0.5)
elif resp.status_code != 200:
self.logger.error(resp_json['errorSummary'])
exit(1)
else:
self.logger.error(resp_json)
exit(1)


def get_session(self, session_token):
""" Gets a session cookie from a session token """
data = {"sessionToken": session_token}
resp = requests.post(self.base_url+'/api/v1/sessions', json=data).json()
resp = requests.post(
self.base_url + '/api/v1/sessions', json=data).json()
return resp['id']

def get_apps(self, session_id):
""" Gets apps for the user """
sid = "sid=%s" % session_id
headers = {'Cookie': sid}
resp = requests.get(self.base_url+'/api/v1/users/me/appLinks', headers=headers).json()
resp = requests.get(
self.base_url + '/api/v1/users/me/appLinks',
headers=headers).json()
aws_apps = []
for app in resp:
if app['appName'] == "amazon_aws":
aws_apps.append(app)
if not aws_apps:
self.logger.error("No AWS apps are available for your user. Exiting.")
self.logger.error("No AWS apps are available for your user. \
Exiting.")
sys.exit(1)

aws_apps = sorted(aws_apps, key=lambda app: app['sortOrder'])
print("Available apps:")
for index, app in enumerate(aws_apps):
app_name = app['label']
print("%d: %s" % (index+1, app_name))
print("%d: %s" % (index + 1, app_name))

app_choice = input('Please select AWS app: ')-1
app_choice = input('Please select AWS app: ') - 1
return aws_apps[app_choice]['label'], aws_apps[app_choice]['linkUrl']

def get_saml_assertion(self, html):
Expand Down
Loading

0 comments on commit 4602e2c

Please sign in to comment.