def _get_form_action(self, soup): LOG.debug('Looking for the form action') form = soup.find('form') if form is None: alohomora.die('Expected form not found, please make sure Duo is set up properly.{}Please check: {}' .format(os.linesep, self.idp_url)) LOG.debug('Found form action %s', form['action']) return form['action']
def to_seconds(tstr): """Takes string in form of 3h/25m/13s/13 and returns integer seconds. No unit specified implies seconds.""" try: val, suffix = re.match("^([0-9]+)([HhMmSs]?)$", tstr).groups() except: alohomora.die("Can't parse duration '%s'" % tstr) scale = {'h': 3600, 'm': 60}.get(suffix.lower(), 1) return int(val) * scale
def main(self): """Run the program.""" if self.options.version: print('Version:', alohomora.__version__) print('Python: ', sys.version.replace('\n', ' ').replace('\r', '')) print('README: ', alohomora.__url__) return # Validate options duration = to_seconds(self._get_config('duration', '1h')) if not DURATION_MIN <= duration <= DURATION_MAX: alohomora.die( "Duration of '%s' not in the range of %s-%s seconds" % (self._get_config('duration', None), DURATION_MIN, DURATION_MAX)) # # Get the user's credentials # username = self._get_config('username', os.getenv("USER")) if not username: alohomora.die("Oops, don't forget to provide a username") password = getpass.getpass() idp_url = self._get_config('idp-url', None) if not idp_url: alohomora.die("Oops, don't forget to provide an idp-url") auth_method = self._get_config('auth-method', None) auth_device = self._get_config('auth-device', None) # # Authenticate the user # provider = alohomora.req.DuoRequestsProvider(idp_url, auth_method) (okay, response) = provider.login_one_factor(username, password) assertion = None if not okay: # we need to 2FA LOG.info('We need to two-factor') (okay, response) = provider.login_two_factor(response, auth_device) if not okay: alohomora.die('Error doing two-factor, sorry.') assertion = response else: LOG.info('One-factor OK') assertion = response awsroles = alohomora.saml.get_roles(assertion) # If I have more than one role, ask the user which one they want, # otherwise just proceed if len(awsroles) == 0: print('You are not authorized for any AWS roles.') sys.exit(0) elif len(awsroles) == 1: role_arn = awsroles[0].split(',')[0] principal_arn = awsroles[0].split(',')[1] elif len(awsroles) > 1: # arn:{{ partition }}:iam::{{ accountid }}:role/{{ role_name }} account_id = self._get_config('account', None) role_name = self._get_config('role-name', None) idp_name = self._get_config('idp-name', DEFAULT_IDP_NAME) # If the user has specified a partition, use it; otherwise, try autodiscovery partition = self._get_config('aws-partition', None) if partition is None: partition = awsroles[0].split(':')[1] if account_id is not None and role_name is not None and idp_name is not None: role_arn = "arn:%s:iam::%s:role/%s" % (partition, account_id, role_name) principal_arn = "arn:%s:iam::%s:saml-provider/%s" % ( partition, account_id, idp_name) else: account_map = {} try: accounts = self.config.options('account_map') for account in accounts: account_map[account] = self.config.get( 'account_map', account) except Exception: pass selectedrole = alohomora._prompt_for_a_thing( "Please choose the role you would like to assume:", awsroles, lambda s: format_role(s.split(',')[0], account_map)) role_arn = selectedrole.split(',')[0] principal_arn = selectedrole.split(',')[1] token = alohomora.keys.get(role_arn, principal_arn, assertion, duration) alohomora.keys.save(token, profile=self._get_config('aws-profile', DEFAULT_AWS_PROFILE))
def login_two_factor(self, response_1fa, auth_device=None): """Log in with the second factor, borrowing first factor data if necessary""" soup_1fa = BeautifulSoup(response_1fa.text, 'html.parser') duo_host = None sig_request = None # post_action = None for iframe in soup_1fa.find_all('iframe'): duo_host = iframe.get('data-host') sig_request = iframe.get('data-sig-request') sigs = sig_request.split(':') duo_sig = sigs[0] app_sig = sigs[1] # Pulling the iframe into the page frame_url = 'https://%s/frame/web/v1/auth?tx=%s&parent=%s&v=2.3' % \ (duo_host, duo_sig, response_1fa.url) LOG.info('Getting Duo iframe') # if the duo integration has an allowed origin list, we must # pass the page URL as a Referer header in addition to using # the `parent` query parameter in the frame URL (response, soup) = self._do_get(frame_url, headers={ 'Referer': response_1fa.url, }) payload = {} for inputtag in soup.find_all('input'): name = inputtag.get('name', '') value = inputtag.get('value', '') # Populate all parameters with the existing value (picks up hidden fields too) payload[name] = value # Post data to emulate the plugin determination LOG.info('Posting plugin information to Duo') (response, soup) = self._do_post(frame_url, data=payload) sid = unquote(urlparse.urlparse(response.request.url).query[4:]) new_action = self._get_form_action(soup) device = self._get_duo_device(soup, auth_device) factor = self._get_auth_factor(soup, device) # Finally send the POST request for an auth to Duo payload = { 'sid': sid, 'device': device.value if (device.name != "Security Key (U2F)" and not device.value.startswith('WA')) else "u2f_token", 'factor': factor.name if (device.name != "Security Key (U2F)" and not device.value.startswith('WA')) else "U2F Token", 'out_of_date': '' } if factor.name == "Passcode": payload['passcode'] = factor.value headers = {'Referer': response.url} (status, _) = self._do_post('https://%s%s' % (duo_host, new_action), data=payload, headers=headers, soup=False) # Response is of form # {"stat": "OK", "response": {"txid": "f95cbacc-151c-43a6-b462-b33420e72633"}} txid = json.loads(status.text)['response']['txid'] LOG.debug("Received transaction ID %s", txid) # Initial call will NOT block (status, _) = self._do_post('https://%s/frame/status' % duo_host, data={ 'sid': sid, 'txid': txid }, soup=False) # text from this will be something like # { # "stat": "OK", # "response": { # "status_code": "pushed", # "status": "Pushed a login request to your device..." # } # } # if U2F enrolled, text from this will be something like # { # "stat": "OK", # "response": { # "status_code": "u2f_sent", # "status": "Use your Security Key to log in.", # "u2f_sign_request": [{"keyHandle": "..."}, {...}] # } # } status_data = json.loads(status.text) LOG.info(str(status_data)) if status_data['stat'] != 'OK': LOG.error("Returned from inital status call: %s", status.text) alohomora.die("Sorry, there was a problem talking to Duo.") print(status_data['response']['status']) allowed = status_data['response']['status_code'] == 'allow' # there should never be a case where `allowed` is True if the user picked Security Key if device.name == "Security Key (U2F)" or device.value.startswith( 'WA'): challenges = [ r for r in status_data['response']['u2f_sign_request'] if self._validate_u2f_request(duo_host, r) ] resp = self._get_u2f_response(challenges) # pull the first challenge's sessionId since they all match # the challenges list should not be empty here, as the device would not be presented # to the user without a corresponding challenge resp['sessionId'] = challenges[0]['sessionId'] # include the session ID as passed to us earlier payload['sid'] = sid # u2f_token and u2f_finish are magic strings here payload['device'] = 'u2f_token' payload['factor'] = 'u2f_finish' # these are a copy/paste from the duo integration's POST data payload['out_of_date'] = None payload['days_to_block'] = 'None' # finally, the response data itself needs to be a JSON string payload['response_data'] = json.dumps(resp) (status, _) = self._do_post('https://%s%s' % (duo_host, new_action), data=payload, headers=headers, soup=False) status_data = json.loads(status.text) # Response is of form # {"stat": "OK", "response": {"txid": "f95cbacc-151c-43a6-b462-b33420e72633"}} txid = json.loads(status.text)['response']['txid'] LOG.debug("Received transaction ID %s", txid) # Initial call will NOT block (status, _) = self._do_post('https://%s/frame/status' % duo_host, data={ 'sid': sid, 'txid': txid }, soup=False) status_data = json.loads(status.text) LOG.info(str(status_data)) if status_data['stat'] != 'OK': LOG.error("Returned from inital status call: %s", status.text) alohomora.die("Sorry, there was a problem talking to Duo.") print(status_data['response']['status']) allowed = status_data['response']['status_code'] == 'allow' if not allowed: alohomora.die( "Sorry, there was a problem with your security key, try again." ) while not allowed: # call again to get status of request # for a push notification, this will hang until the user approves/denies # for a phone call, you need to keep polling until the user approves/denies (status, _) = self._do_post('https://%s/frame/status' % duo_host, data={ 'sid': sid, 'txid': txid }, soup=False) status_data = json.loads(status.text) if status_data['stat'] != 'OK': LOG.error("Returned from second status call: %s", status.text) alohomora.die("Sorry, there was a problem talking to Duo.") if status_data['response']['status_code'] == 'allow': LOG.info("Login allowed!") allowed = True elif status_data['response']['status_code'] == 'deny': LOG.error("Login disallowed: %s", status.text) alohomora.die("The login was blocked!") else: LOG.info("Still waiting... (%s)", status_data['response']['status_code']) LOG.debug(str(status_data)) time.sleep(2) signed_auth = '' if 'result_url' in status_data['response']: # We have to specifically ask Duo for the signed auth string; # this doesn't come for free anymore (postresult, _) = self._do_post( 'https://%s%s' % (duo_host, status_data['response']['result_url']), data={'sid': sid}, soup=False) postresult_data = json.loads(postresult.text) signed_auth = postresult_data['response']['cookie'] elif 'cookie' in status_data['response']: # Leaving this option in here, in case Duo treats different users differently signed_auth = status_data['response']['cookie'] else: raise Exception( "Unable to find signed token from successful Duo auth") payload = { '_eventId_proceed': 'transition', 'sig_response': '%s:%s' % (signed_auth, app_sig) } (response, soup) = self._do_post(response_1fa.url, data=payload) assertion = self._get_assertion(soup) return (True, assertion)
def login_one_factor(self, username, password): self.session = requests.Session() (response, soup) = self._do_get(self.idp_url) payload = {} for inputtag in soup.find_all('input'): name = inputtag.get('name', '') # value = inputtag.get('value', '') if "user" in name.lower(): # Make an educated guess that this is the right field for the username payload[name] = username elif "email" in name.lower(): # Some IdPs also label the username field as 'email' payload[name] = username elif "pass" in name.lower(): # Make an educated guess that this is the right field for the password payload[name] = password else: # Populate the parameter with the existing value (picks up hidden fields as well) # payload[name] = value pass payload['_eventId_proceed'] = '' # Omit the password from the debug output... payload_debugger = {} for key in payload: if "pass" in key.lower(): payload_debugger[key] = '****' else: payload_debugger[key] = payload[key] LOG.debug(payload_debugger) if username not in payload.values(): alohomora.die("Couldn't find right form field for username!") elif password not in payload.values(): alohomora.die("Couldn't find right form field for password!") # Some IdPs don't explicitly set a form action, but if one is set we should # build the idpauthformsubmiturl by combining the scheme and hostname # from the entry url with the form action target # If the action tag doesn't exist, we just stick with the # idpauthformsubmiturl above for inputtag in soup.find_all(re.compile('form', re.IGNORECASE)): action = inputtag.get('action') if action: parsedurl = urlparse.urlparse(self.idp_url) idpauthformsubmiturl = parsedurl.scheme + "://" + parsedurl.netloc + action post_headers = { 'Referer': response.url, 'Content-Type': 'application/x-www-form-urlencoded' } (response, soup) = self._do_post(idpauthformsubmiturl, data=payload, headers=post_headers) # We need to check if the user actually got logged in # See if we have anything classed 'form-error' for tag in soup.find_all( lambda x: x.has_attr('class') and 'form-error' in x['class']): alohomora.die(tag.get_text()) # Look for the SAMLResponse attribute of the input tag (determined by # analyzing the debug print lines above) assertion = '' for inputtag in soup.find_all('input'): if inputtag.get('name') == 'SAMLResponse': # print(inputtag.get('value')) assertion = inputtag.get('value') if assertion != '': return (True, assertion) return (False, response)
def login_two_factor(self, response_1fa): """Log in with the second factor, borrowing first factor data if necessary""" soup_1fa = BeautifulSoup(response_1fa.text, 'html.parser') duo_host = None sig_request = None # post_action = None for iframe in soup_1fa.find_all('iframe'): duo_host = iframe.get('data-host') sig_request = iframe.get('data-sig-request') sigs = sig_request.split(':') duo_sig = sigs[0] app_sig = sigs[1] # Pulling the iframe into the page frame_url = 'https://%s/frame/web/v1/auth?tx=%s&parent=%s&v=2.3' % \ (duo_host, duo_sig, response_1fa.url) LOG.info('Getting Duo iframe') (response, soup) = self._do_get(frame_url) payload = {} for inputtag in soup.find_all('input'): name = inputtag.get('name', '') value = inputtag.get('value', '') # Populate all parameters with the existing value (picks up hidden fields too) payload[name] = value # Post data to emulate the plugin determination LOG.info('Posting plugin information to Duo') (response, soup) = self._do_post(frame_url, data=payload) sid = unquote(urlparse.urlparse(response.request.url).query[4:]) new_action = self._get_form_action(soup) device = self._get_duo_device(soup) factor = self._get_auth_factor(soup, device) # Finally send the POST request for an auth to Duo payload = { 'sid': sid, 'device': device.value, 'factor': factor.name, 'out_of_date': '' } if factor.name == "Passcode": payload['passcode'] = factor.value headers = {'Referer': response.url} (status, _) = self._do_post('https://%s%s' % (duo_host, new_action), data=payload, headers=headers, soup=False) # Response is of form # {"stat": "OK", "response": {"txid": "f95cbacc-151c-43a6-b462-b33420e72633"}} txid = json.loads(status.text)['response']['txid'] LOG.debug("Received transaction ID %s", txid) # Initial call will NOT block (status, _) = self._do_post('https://%s/frame/status' % duo_host, data={ 'sid': sid, 'txid': txid }, soup=False) # text from this will be something like # { # "stat": "OK", # "response": { # "status_code": "pushed", # "status": "Pushed a login request to your device..." # } # } status_data = json.loads(status.text) LOG.info(str(status_data)) if status_data['stat'] != 'OK': LOG.error("Returned from inital status call: %s", status.text) alohomora.die("Sorry, there was a problem talking to Duo.") print(status_data['response']['status']) allowed = status_data['response']['status_code'] == 'allow' while not allowed: # call again to get status of request # for a push notification, this will hang until the user approves/denies # for a phone call, you need to keep polling until the user approves/denies (status, _) = self._do_post('https://%s/frame/status' % duo_host, data={ 'sid': sid, 'txid': txid }, soup=False) status_data = json.loads(status.text) if status_data['stat'] != 'OK': LOG.error("Returned from second status call: %s", status.text) alohomora.die("Sorry, there was a problem talking to Duo.") if status_data['response']['status_code'] == 'allow': LOG.info("Login allowed!") allowed = True elif status_data['response']['status_code'] == 'deny': LOG.error("Login disallowed: %s", status.text) alohomora.die("The login was blocked!") else: LOG.info("Still waiting... (%s)", status_data['response']['status_code']) LOG.debug(str(status_data)) time.sleep(2) signed_auth = '' if 'result_url' in status_data['response']: # We have to specifically ask Duo for the signed auth string; this doesn't come for free anymore (postresult, _) = self._do_post( 'https://%s%s' % (duo_host, status_data['response']['result_url']), data={'sid': sid}, soup=False) postresult_data = json.loads(postresult.text) signed_auth = postresult_data['response']['cookie'] elif 'cookie' in status_data['response']: # Leaving this option in here, in case Duo treats different users differently signed_auth = status_data['response']['cookie'] else: raise Exception( "Unable to find signed token from successful Duo auth") payload = { '_eventId_proceed': 'transition', 'sig_response': '%s:%s' % (signed_auth, app_sig) } (response, soup) = self._do_post(response_1fa.url, data=payload) assertion = self._get_assertion(soup) return (True, assertion)