def __send_push(self, factor, state_token): ''' Send push re: Okta Verify ''' url = factor['_links']['verify']['href'] headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Cache-Control': '"no-cache' } payload = { 'stateToken': state_token } response_data = None response = requests.post(url, data=json.dumps(payload), headers=headers) if response.status_code == requests.codes.ok: # pylint: disable=E1101 response_data = response.json() else: response.raise_for_status() Common.echo(message='Push notification sent; waiting for your response', new_line=False) status = response_data['status'] if status == 'MFA_CHALLENGE': if 'factorResult' in response_data and response_data['factorResult'] == 'WAITING': return self.__check_push_result( state_token=state_token, push_response=response_data )
def choose_idp_role_tuple(self): ''' Determine the role options the user can choose from ''' idp_role_tuples = self.__discover_role_idp_tuples() # throw an error if no roles are provided # (defensive coding only - this should not be impossible) if not idp_role_tuples: Common.dump_err( message='No AWS Role was assigned to this application!', exit_code=4, verbose=self.verbose) # use the one prvided if there is only one if len(idp_role_tuples) == 1: role_arn = idp_role_tuples[0][2] if self.role_preference and role_arn != self.role_preference: Common.echo( message='Your cofigured role was not found; using {role}'. format(role=role_arn)) else: Common.echo(message='Using the configured role {role}'.format( role=role_arn)) return idp_role_tuples[0] # use the configured role if it matches one from the the SAML assertion for tup in idp_role_tuples: if tup[2] == self.role_preference: return tup # make the user choose return self.__choose_tuple(idp_role_tuples=idp_role_tuples)
def generate_creds(self, role): """ :param role: the AWS role the user wants to assume :type role: AwsRole """ client = boto3.client('sts') # Try for a 12 hour session. If it fails, try for shorter periods assumed_role_credentials = None durations = [43200, 14400, 3600] for duration in durations: try: assumed_role_credentials = client.assume_role_with_saml( RoleArn=role.role_arn, PrincipalArn=role.idp_arn, SAMLAssertion=self.saml_assertion, DurationSeconds=duration) if duration == 3600: Common.echo(message='YOUR SESSION WILL ONLY LAST ONE HOUR') break except ClientError as e: # If we get a validation error and we have shorter durations to try, try a shorter duration if e.response['Error'][ 'Code'] != 'ValidationError' or duration == durations[ -1]: raise self.clokta_config.apply_credentials( credentials=assumed_role_credentials) self.bash_file = self.__write_sourceable_file( credentials=assumed_role_credentials) self.docker_file = self.__write_dockerenv_file( credentials=assumed_role_credentials)
def __check_push_result(self, state_token, push_response): ''' Wait for push response acknowledgement ''' url = push_response['_links']['next']['href'] headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Cache-Control': '"no-cache' } payload = { 'stateToken': state_token } wait_for = 60 timeout = time.time() + wait_for response_data = None while True: Common.echo(message='.', new_line=False) response = requests.post(url, data=json.dumps(payload), headers=headers) if response.status_code == requests.codes.ok: # pylint: disable=E1101 response_data = response.json() else: response.raise_for_status() if 'sessionToken' in response_data or time.time() > timeout: Common.echo(message='Session confirmed') break time.sleep(3) if response_data: return response_data['sessionToken'] else: msg = 'Timeout expired ({} seconds)'.format(wait_for) Common.dump_err(message=msg, exit_code=3)
def assume_role(self): ''' entry point for the cli tool ''' profile_mgr = ProfileManager(profile_name=self.profile, verbose=self.verbose) configuration = profile_mgr.initialize_configuration() profile_mgr.update_configuration(profile_configuration=configuration) session_token = self.__okta_session_token( configuration=configuration ) if self.verbose: Common.dump_verbose(message='Okta session token: {}'.format(session_token)) saml_assertion = self.__saml_assertion_aws( session_token=session_token, configuration=configuration ) idp_and_role_chooser = RoleChooser( saml_assertion=saml_assertion, role_preference=configuration.get('okta_aws_role_to_assume'), verbose=self.verbose ) idp_role_tuple = idp_and_role_chooser.choose_idp_role_tuple() client = boto3.client('sts') # Try for a 12 hour session. If it fails, try for a 1 hour session try: assumed_role_credentials = client.assume_role_with_saml( RoleArn=idp_role_tuple[2], PrincipalArn=idp_role_tuple[1], SAMLAssertion=saml_assertion, DurationSeconds=43200 ) except ClientError as e: if e.response['Error']['Code'] == 'ValidationError': assumed_role_credentials = client.assume_role_with_saml( RoleArn=idp_role_tuple[2], PrincipalArn=idp_role_tuple[1], SAMLAssertion=saml_assertion, DurationSeconds=3600 ) Common.echo(message='YOUR SESSION WILL ONLY LAST ONE HOUR') else: raise profile_mgr.apply_credentials(credentials=assumed_role_credentials, echo_message=True) bash_file = profile_mgr.write_sourceable_file(credentials=assumed_role_credentials) docker_file = profile_mgr.write_dockerenv_file(credentials=assumed_role_credentials) Common.echo( message='AWS keys generated. To use, run "export AWS_PROFILE={prof}"\nor use generated files {file1} with docker compose or {file2} with shell scripts'.format( prof=self.profile, file1=docker_file, file2=bash_file ) )
def dump_account_numbers(cls, clokta_config_file): clokta_cfg_file = configparser.ConfigParser() clokta_cfg_file.read(os.path.expanduser(clokta_config_file)) section_names = clokta_cfg_file.sections() for section_name in section_names: if clokta_cfg_file.has_option(section=section_name, option='aws_account_number'): acct_num = clokta_cfg_file.get(section=section_name, option='aws_account_number') if acct_num: Common.echo("{name} = {number}".format(name=section_name, number=acct_num))
def __wait_for_push_result(self, state_token, push_response): """ A request was sent to Okta querying the status of a push. Process the response, pull the session token from it, and store in self.session_token :param state_token: a token received from Okta identifying this authentication attempt session :type state_token: str :param push_response: the HTTP response from the HTTP request :type push_response: json :return: SUCCESS if response indicates SUCCESS. INPUT_ERROR if timed out. """ url = push_response['_links']['next']['href'] headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Cache-Control': '"no-cache' } payload = {'stateToken': state_token} wait_for = 60 timeout = time.time() + wait_for response_data = None while True: Common.echo(message='.', new_line=False) response = requests.post(url, data=json.dumps(payload), headers=headers) if response.status_code == requests.codes.ok: # pylint: disable=E1101 response_data = response.json() else: response.raise_for_status() if 'sessionToken' in response_data or time.time() > timeout: break time.sleep(3) if response_data and 'sessionToken' in response_data: Common.echo(message='Session confirmed') self.session_token = response_data['sessionToken'] return OktaInitiator.Result.SUCCESS else: msg = 'Timeout expired ({} seconds)'.format(wait_for) Common.dump_err(message=msg) return OktaInitiator.Result.INPUT_ERROR
def __choose_tuple(self, idp_role_tuples): ''' Give the user a choice from the intersection of configured and supported factors ''' index = 1 for tup in idp_role_tuples: slashIndex = tup[2].find('/') shortName = tup[2][slashIndex + 1:] if slashIndex >= 0 else tup[2] msg = '{index} - {prompt}'.format(index=tup[0], prompt=shortName) Common.echo(message=msg, bold=True) index += 1 raw_choice = None try: raw_choice = click.prompt('Choose a Role ARN to use', type=int) choice = raw_choice - 1 except ValueError: Common.echo(message='Please select a valid option: you chose: {}'. format(raw_choice)) return self.__choose_tuple(idp_role_tuples=idp_role_tuples) if len(idp_role_tuples) > choice >= 0: pass else: Common.echo(message='Please select a valid option: you chose: {}'. format(raw_choice)) return self.__choose_tuple(idp_role_tuples=idp_role_tuples) chosen_option = idp_role_tuples[choice] if self.verbose: Common.dump_verbose(message='Using chosen Role {role} & IDP {idp}'. format(role=tup[2], idp=tup[1])) return chosen_option
def choose_supported_factor(self): ''' Give the user a choice from the intersection of configured and supported factors ''' index = 1 for opt in self.option_factors: msg = '{index} - {prompt}'.format(index=index, prompt=opt['prompt']) Common.echo(message=msg, bold=True) index += 1 raw_choice = None try: raw_choice = click.prompt('Choose a MFA type to use', type=int) choice = raw_choice - 1 except ValueError: Common.echo(message='Please select a valid option: you chose: {}'.format(raw_choice)) return self.choose_supported_factor() if len(self.option_factors) > choice >= 0: pass else: Common.echo(message='Please select a valid option: you chose: {}'.format(raw_choice)) return self.choose_supported_factor() chosen_option = self.option_factors[choice] matching_okta_factor = [ fact for fact in self.okta_factors if fact['provider'] == chosen_option['provider'] and fact['factorType'] == chosen_option['factor_type'] ] if self.verbose: Common.dump_verbose(message='Using chosen factor: {}'.format(chosen_option['prompt'])) return matching_okta_factor[0]
def __do_mfa_with_push(self, factor, state_token): """ Send push re: Okta Verify and wait for response. If succesful, session token will be stored in self.session_token :param factor: mfa information :type factor: dict :param state_token: token used in MFA back and forth :type: str :return: SUCCESS if push reported success. INPUT_ERROR if user never responded. Any other possibilities will result in an exception :rtype: OktaInitiator.Result """ url = factor['_links']['verify']['href'] headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Cache-Control': '"no-cache' } payload = {'stateToken': state_token} response_data = None response = requests.post(url, data=json.dumps(payload), headers=headers) if response.status_code == requests.codes.ok: # pylint: disable=E1101 response_data = response.json() else: response.raise_for_status() Common.echo( message='Push notification sent; waiting for your response', new_line=False) status = response_data['status'] if status == 'MFA_CHALLENGE': if 'factorResult' in response_data and response_data[ 'factorResult'] == 'WAITING': return self.__wait_for_push_result(state_token=state_token, push_response=response_data)
def __prompt_for_role(self, with_set_default_option): """ Give the user a choice from the intersection of configured and supported factors :param with_set_default_option: if True will add an option for setting a default role :type with_set_default_option: bool :return: a tuple of what role was chosen and whether it is the new default :rtype: AwsRole, bool """ index = 1 for role in self.possible_roles: msg = '{index} - {prompt}'.format(index=index, prompt=role.role_name) Common.echo(message=msg, bold=True) index += 1 if with_set_default_option: Common.echo('{index} - set a default role'.format(index=index)) raw_choice = None try: raw_choice = click.prompt(text='Choose a Role ARN to use', type=int, err=Common.to_std_error()) choice = raw_choice - 1 except ValueError: Common.echo(message='Please select a valid option: you chose: {}'. format(raw_choice)) return self.__prompt_for_role() if choice == len(self.possible_roles): # They want to set a default. Prompt again (just without the set-default option) # and return that chosen role and that it's the new default chosen_option, _ = self.__prompt_for_role( with_set_default_option=False) return chosen_option, True if len(self.possible_roles) > choice >= 0: pass else: Common.echo(message='Please select a valid option: you chose: {}'. format(raw_choice)) return self.__prompt_for_role( with_set_default_option=with_set_default_option) chosen_option = self.possible_roles[choice] if Common.is_debug(): Common.dump_out( message='Using chosen Role {role} & IDP {idp}'.format( role=chosen_option.role_arn, idp=chosen_option.idp_arn)) return chosen_option, False
def write_dockerenv_file(self, credentials): ''' Generates a Docker .env file that can be used with docker compose to inject into the environment. ''' creds = credentials['Credentials'] output_file_name = '{dir}/{profile}.env'.format( dir=os.path.dirname(self.config_location), profile=self.profile_name ) lines = [ 'AWS_ACCESS_KEY_ID={}\n'.format(creds['AccessKeyId']), 'AWS_SECRET_ACCESS_KEY={}\n'.format(creds['SecretAccessKey']) ] if 'SessionToken' in creds: lines.append('AWS_SESSION_TOKEN={}\n'.format(creds['SessionToken'])) with open(output_file_name, mode='w') as file_handle: file_handle.writelines(lines) Common.echo( message='AWS keys saved to {loc} for use with docker compose'.format( loc=output_file_name ) )
def write_sourceable_file(self, credentials): ''' Generates a shell script to source in order to apply credentials to the shell environment. ''' creds = credentials['Credentials'] output_file_name = '{dir}/{profile}.sh'.format( dir=os.path.dirname(self.config_location), profile=self.profile_name ) lines = [ 'export AWS_ACCESS_KEY_ID={}\n'.format(creds['AccessKeyId']), 'export AWS_SECRET_ACCESS_KEY={}\n'.format(creds['SecretAccessKey']) ] if 'SessionToken' in creds: lines.append('export AWS_SESSION_TOKEN={}\n'.format(creds['SessionToken'])) with open(output_file_name, mode='w') as file_handle: file_handle.writelines(lines) Common.echo( message='AWS keys saved to {loc}. To use, `source {loc}`'.format( loc=output_file_name ) )
def choose_role(self): """ Look for a default role defined and, if not, prompt the user for one Allow the user to also specify the role is the default to be used from now on :return: a tuple of the chosen role and whether it is the new default :rtype: AwsRole, bool """ # throw an error if no roles are provided # (defensive coding only - this should not be possible) if not self.possible_roles: Common.dump_err( message='No AWS Role was assigned to this application!') raise ValueError( 'Unexpected configuration - No AWS role assigned to Okta login.' ) # use the one provided if there is only one if len(self.possible_roles) == 1: role = self.possible_roles[0] if self.role_preference and role.role_arn != self.role_preference: Common.dump_err( message= 'Your cofigured role "{notfound}" was not found; using "{found}" role' .format(notfound=self.role_preference, found=role.role_name)) elif Common.is_debug(): Common.echo(message="Using default role '{role}'".format( role=role.role_arn)) return role, False # use the configured role if it matches one from the the SAML assertion for role in self.possible_roles: if role.role_arn == self.role_preference: message = "Using default role '{}'".format(role.role_name) extra_message = '. Run "clokta --no-default-role" to override.' if Common.get_output_format() == Common.long_out: Common.echo(message + extra_message) else: Common.echo(message) return role, True # make the user choose return self.__prompt_for_role(with_set_default_option=True)
def output_instructions(self, docker_file, bash_file): if Common.get_output_format() == Common.quiet_out: Common.echo( message='export AWS_PROFILE={}'.format(self.profile), always_stdout=True ) elif Common.get_output_format() == Common.long_out: Common.echo( message='\nAWS keys generated.\nTo use with docker-compose include\n' + '\tenv_file:\n\t - {}\n'.format(docker_file) + 'To use with shell scripts include\n\tsource {}\n'.format(bash_file) + 'to use in the current interactive shell run\n\texport AWS_PROFILE={}\n'.format(self.profile) ) else: Common.echo( message='Add the "-i" flag for how to use credentials and override defaults or just run:\n\n' + 'export AWS_PROFILE={}\n'.format(self.profile) )
def assume_role(self): ''' entry point for the cli tool ''' profile_mgr = ProfileManager(profile_name=self.profile, verbose=self.verbose) configuration = profile_mgr.initialize_configuration() profile_mgr.update_configuration(profile_configuration=configuration) # Need a directory to store intermediate files. Use the same directory that clokta configuration # is kept in self.data_dir = os.path.dirname(profile_mgr.config_location) saml_assertion = self.__saml_assertion_aws(session_token=None, configuration=configuration) if not saml_assertion: if 'okta_password' not in configuration or not configuration[ 'okta_password']: configuration['okta_password'] = profile_mgr.prompt_for( 'okta_password') session_token = self.__okta_session_token( configuration=configuration) if self.verbose: Common.dump_verbose( message='Okta session token: {}'.format(session_token)) saml_assertion = self.__saml_assertion_aws( session_token=session_token, configuration=configuration) idp_and_role_chooser = RoleChooser( saml_assertion=saml_assertion, role_preference=configuration.get('okta_aws_role_to_assume'), verbose=self.verbose) idp_role_tuple = idp_and_role_chooser.choose_idp_role_tuple() client = boto3.client('sts') # Try for a 12 hour session. If it fails, try for shorter periods durations = [43200, 14400, 3600] for duration in durations: try: assumed_role_credentials = client.assume_role_with_saml( RoleArn=idp_role_tuple[2], PrincipalArn=idp_role_tuple[1], SAMLAssertion=saml_assertion, DurationSeconds=duration) if duration == 3600: Common.echo(message='YOUR SESSION WILL ONLY LAST ONE HOUR') break except ClientError as e: # If we get a validation error and we have shorter durations to try, try a shorter duration if e.response['Error'][ 'Code'] != 'ValidationError' or duration == durations[ -1]: raise profile_mgr.apply_credentials(credentials=assumed_role_credentials, echo_message=True) bash_file = profile_mgr.write_sourceable_file( credentials=assumed_role_credentials) docker_file = profile_mgr.write_dockerenv_file( credentials=assumed_role_credentials) Common.echo( message= 'AWS keys generated. To use, run "export AWS_PROFILE={prof}"\nor use files {file1} with docker compose or {file2} with shell scripts' .format(prof=self.profile, file1=docker_file, file2=bash_file))