diff --git a/README.md b/README.md index f1d28ad0..078585ad 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ A configuration wizard will prompt you to enter the necessary configuration para - token:hardware - OTP using hardware like Yubikey - call - OTP via Voice call - sms - OTP via SMS message + - email - OTP via email - web - DUO uses localhost webbrowser to support push|call|passcode - passcode - DUO uses `OKTA_MFA_CODE` or `--mfa-code` if set, or prompts user for passcode(OTP). diff --git a/gimme_aws_creds/config.py b/gimme_aws_creds/config.py index 31d1f93d..9e43eba9 100644 --- a/gimme_aws_creds/config.py +++ b/gimme_aws_creds/config.py @@ -514,6 +514,7 @@ def _get_preferred_mfa_type(self, default_entry): - token:hardware - OTP using hardware like Yubikey - call - OTP via Voice call - sms - OTP via SMS message + - email - OTP via email message - web - DUO uses localhost webbrowser to support push|call|passcode - passcode - DUO uses `OKTA_MFA_CODE` or `--mfa-code` if set, or prompts user for passcode(OTP). """ diff --git a/gimme_aws_creds/okta_classic.py b/gimme_aws_creds/okta_classic.py index 17d12d2c..f7db77de 100644 --- a/gimme_aws_creds/okta_classic.py +++ b/gimme_aws_creds/okta_classic.py @@ -389,6 +389,25 @@ def _login_send_sms(self, state_token, factor): if 'sessionToken' in response_data: return {'stateToken': None, 'sessionToken': response_data['sessionToken'], 'apiResponse': response_data} + def _login_send_email(self, state_token, factor): + """ Send email message for second factor authentication""" + response = self._http_client.post( + factor['_links']['verify']['href'], + params={'rememberDevice': self._remember_device}, + json={'stateToken': state_token}, + headers=self._get_headers(), + verify=self._verify_ssl_certs + ) + response.raise_for_status() + + self.ui.info("A verification code has been sent to " + factor['profile']['email']) + response_data = response.json() + + if 'stateToken' in response_data: + return {'stateToken': response_data['stateToken'], 'apiResponse': response_data} + if 'sessionToken' in response_data: + return {'stateToken': None, 'sessionToken': response_data['sessionToken'], 'apiResponse': response_data} + def _login_send_call(self, state_token, factor): """ Send Voice call for second factor authentication""" response = self._http_client.post( @@ -554,6 +573,8 @@ def _login_multi_factor(self, state_token, login_data): return self._login_send_sms(state_token, factor) elif factor['factorType'] == 'call': return self._login_send_call(state_token, factor) + elif factor['factorType'] == 'email': + return self._login_send_email(state_token, factor) elif factor['factorType'] == 'token:software:totp': return self._login_input_mfa_challenge(state_token, factor['_links']['verify']['href']) elif factor['factorType'] == 'token': @@ -834,6 +855,8 @@ def _build_factor_name(self, factor): return "Okta Verify App: " + factor['profile']['deviceType'] + ": " + factor['profile']['name'] elif factor['factorType'] == 'sms': return factor['factorType'] + ": " + factor['profile']['phoneNumber'] + elif factor['factorType'] == 'email': + return factor['factorType'] + ": " + factor['profile']['email'] elif factor['factorType'] == 'call': return factor['factorType'] + ": " + factor['profile']['phoneNumber'] elif factor['factorType'] == 'token:software:totp': diff --git a/tests/test_okta_classic_client.py b/tests/test_okta_classic_client.py index 496790ec..969916ec 100644 --- a/tests/test_okta_classic_client.py +++ b/tests/test_okta_classic_client.py @@ -61,6 +61,26 @@ def setUp(self): } } + self.email_factor = { + "id": "ema9hmdk2qvhjOQQ30h7", + "factorType": "email", + "provider": "OKTA", + "vendorName": "OKTA", + "profile": { + "email": "example@example.com" + }, + "_links": { + "verify": { + "href": "https://example.okta.com/api/v1/authn/factors/ema9hmdk2qvhjOQQ30h7/verify", + "hints": { + "allow": [ + "POST" + ] + } + } + } + } + self.push_factor = { "id": "opf9ei43pbAgb2qgc0h7", "factorType": "push", @@ -194,7 +214,7 @@ def setUp(self): """ - self.factor_list = [self.sms_factor, self.push_factor, self.totp_factor, self.webauthn_factor] + self.factor_list = [self.sms_factor, self.push_factor, self.totp_factor, self.webauthn_factor, self.email_factor] def setUp_client(self, okta_org_url, verify_ssl_certs): client = OktaClassicClient(ui.default, okta_org_url, verify_ssl_certs) @@ -423,6 +443,97 @@ def test_login_send_sms(self): result = self.client._login_send_sms(self.state_token, self.sms_factor) self.assertEqual(result, {'stateToken': self.state_token, 'apiResponse': verify_response}) + @responses.activate + def test_login_send_email(self): + """Test that email messages can be requested for MFA""" + + verify_response = { + "stateToken": "00Wf8xZJ79mSoTYnJqXbvRegT8QB1EX1IBVk1TU7KI", + "type": "SESSION_STEP_UP", + "expiresAt": "2017-06-15T15:06:10.000Z", + "status": "MFA_CHALLENGE", + "_embedded": { + "user": { + "id": "00u8cakq7vQwtK7sR0h7", + "profile": { + "login": "Jane.Doe@example.com", + "firstName": "Jane", + "lastName": "Doe", + "locale": "en", + "timeZone": "America/Los_Angeles" + } + }, + "factor": { + "id": "ema9hmdk2qvhjOQQ30h7", + "factorType": "email", + "provider": "OKTA", + "vendorName": "OKTA", + "profile": { + "email": "example@example.com" + } + }, + "policy": { + "allowRememberDevice": False, + "rememberDeviceLifetimeInMinutes": 0, + "rememberDeviceByDefault": False + }, + "target": { + "type": "APP", + "name": "gimmecredsserver", + "label": "Gimme-Creds-Server (Dev)", + "_links": { + "logo": { + "name": "medium", + "href": "https://op1static.oktacdn.com/bc/globalFileStoreRecord?id=gfsatgifysE8NG37F0h7", + "type": "image/png" + } + } + } + }, + "_links": { + "next": { + "name": "verify", + "href": "https://example.okta.com/api/v1/authn/factors/ema9hmdk2qvhjOQQ30h7/verify", + "hints": { + "allow": [ + "POST" + ] + } + }, + "cancel": { + "href": "https://example.okta.com/api/v1/authn/cancel", + "hints": { + "allow": [ + "POST" + ] + } + }, + "prev": { + "href": "https://example.okta.com/api/v1/authn/previous", + "hints": { + "allow": [ + "POST" + ] + } + }, + "resend": [ + { + "name": "sms", + "href": "https://example.okta.com/api/v1/authn/factors/ema9hmdk2qvhjOQQ30h7/verify/resend", + "hints": { + "allow": [ + "POST" + ] + } + } + ] + } + } + + responses.add(responses.POST, 'https://example.okta.com/api/v1/authn/factors/ema9hmdk2qvhjOQQ30h7/verify', status=200, body=json.dumps(verify_response)) + result = self.client._login_send_email(self.state_token, self.email_factor) + self.assertEqual(result, {'stateToken': self.state_token, 'apiResponse': verify_response}) + @responses.activate def test_login_send_push(self): """Test that Okta Verify can be used for MFA""" @@ -996,6 +1107,12 @@ def test_choose_factor_totp(self, mock_input): result = self.client._choose_factor(self.factor_list) self.assertEqual(result, self.totp_factor) + @patch('builtins.input', return_value='4') + def test_choose_factor_totp(self, mock_input): + """ Test selecting email as a MFA""" + result = self.client._choose_factor(self.factor_list) + self.assertEqual(result, self.email_factor) + @patch('builtins.input', return_value='12') def test_choose_bad_factor_totp(self, mock_input): """ Test selecting an invalid MFA factor""" @@ -1019,6 +1136,11 @@ def test_build_factor_name_sms(self): result = self.client._build_factor_name(self.sms_factor) self.assertEqual(result, "sms: +1 XXX-XXX-1234") + def test_build_factor_name_email(self): + """ Test building a display name for email""" + result = self.client._build_factor_name(self.email_factor) + self.assertEqual(result, "email: example@example.com") + def test_build_factor_name_push(self): """ Test building a display name for push""" result = self.client._build_factor_name(self.push_factor)