Skip to content
This repository was archived by the owner on Jul 1, 2024. It is now read-only.

Customizable forms field names #31

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ disable the cache by specifying ``--no-cache``.

Additionally, you can show logs by specifying ``-v`` or ``--verbose``.

Forms-based authentication can be customized: names of fields in form in ADFS
tend to change. You can specify field names that are present in HTML form:

* ``--form-username-field`` - name of field for username.
* ``--form-password-field`` - name of field for password.

For older Active Directory those fields are
``ctl00$ContentPlaceHolder1$UsernameTextBox``
and ``ctl00$ContentPlaceHolder1$PasswordTextBox``.
In newer AD, you can change it to ``UserName`` and ``Password`` using above
options.

To configure this provider, you need create a profile using the
``credential_process`` config variable. See the `AWS CLI Config docs`_
for more details on this config option.
Expand Down
2 changes: 1 addition & 1 deletion awsprocesscreds/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging

__version__ = '0.0.2'
__version__ = '0.0.3'


class NullHandler(logging.Handler):
Expand Down
18 changes: 17 additions & 1 deletion awsprocesscreds/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ def saml(argv=None, prompter=getpass.getpass, client_creator=None,
'local file cache.'
)
)
parser.add_argument(
'--form-username-field',
default="ctl00$ContentPlaceHolder1$UsernameTextBox", help=(
'(ADFS only) - name of the form field for user name supplied '
'by ADFS. Adjust according to your ADFS version.'
)
)
parser.add_argument(
'--form-password-field',
default="ctl00$ContentPlaceHolder1$PasswordTextBox", help=(
'(ADFS only) - name of the form field for password supplied '
'by ADFS. Adjust according to your ADFS version.'
)
)
parser.add_argument(
'-v', '--verbose', action='store_true', help=('Enables verbose mode.')
)
Expand Down Expand Up @@ -73,7 +87,9 @@ def saml(argv=None, prompter=getpass.getpass, client_creator=None,
'saml_endpoint': args.endpoint,
'saml_authentication_type': 'form',
'saml_username': args.username,
'role_arn': args.role_arn
'role_arn': args.role_arn,
'form_username_field': args.form_username_field,
'form_password_field': args.form_password_field
},
password_prompter=prompter,
cache=cache
Expand Down
36 changes: 23 additions & 13 deletions awsprocesscreds/saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,6 @@ def retrieve_saml_assertion(self, config):


class GenericFormsBasedAuthenticator(SAMLAuthenticator):
USERNAME_FIELD = 'username'
PASSWORD_FIELD = 'password'

_ERROR_BAD_RESPONSE = (
'Received a non-200 response (%s) when making a request to: %s'
Expand All @@ -67,7 +65,8 @@ class GenericFormsBasedAuthenticator(SAMLAuthenticator):
'Could not find login form from: %s'
)
_ERROR_MISSING_FORM_FIELD = (
'Error parsing HTML form, could not find the form field: "%s"'
'Error parsing HTML form, could not find the form field: "%s" '
'Available fields: %s.'
)
_ERROR_LOGIN_FAILED_NON_200 = (
'Login failed, received non 200 response: %s'
Expand All @@ -80,6 +79,10 @@ class GenericFormsBasedAuthenticator(SAMLAuthenticator):
'Missing required config value for SAML: "%s"'
)

_PASSWORD_PROMPT = (
'Password for %s: '
)

def __init__(self, password_prompter, requests_session=None):
"""Retrieve SAML assertion using form based auth.

Expand Down Expand Up @@ -120,6 +123,8 @@ def retrieve_saml_assertion(self, config):

* saml_endpoint
* saml_username
* form_username_field
* form_password_field

:raises SAMLError: Raised when we are unable to retrieve a
SAML assertion.
Expand All @@ -142,7 +147,8 @@ def retrieve_saml_assertion(self, config):
return self._extract_saml_assertion_from_response(response)

def _validate_config_values(self, config):
for required in ['saml_endpoint', 'saml_username']:
for required in ['saml_endpoint', 'saml_username',
'form_username_field', 'form_password_field']:
if required not in config:
raise SAMLError(self._ERROR_MISSING_CONFIG % required)

Expand Down Expand Up @@ -175,14 +181,20 @@ def _parse_form_from_html(self, html):

def _fill_in_form_values(self, config, form_data):
username = config['saml_username']
if self.USERNAME_FIELD not in form_data:
raise SAMLError(
self._ERROR_MISSING_FORM_FIELD % self.USERNAME_FIELD)
username_field = config['form_username_field']
password_field = config['form_password_field']

if username_field not in form_data:
raise SAMLError(self._ERROR_MISSING_FORM_FIELD %
(username_field, ", ".join(form_data.keys())))
else:
form_data[self.USERNAME_FIELD] = username
if self.PASSWORD_FIELD in form_data:
form_data[self.PASSWORD_FIELD] = self._password_prompter(
"Password: ")
form_data[username_field] = username

# In special cases, password_field is not present in the form
# for example when user is remembered.
if password_field in form_data:
form_data[password_field] = self._password_prompter(
self._PASSWORD_PROMPT % username)

def _send_form_post(self, login_url, form_data):
response = self._requests_session.post(
Expand Down Expand Up @@ -255,8 +267,6 @@ def is_suitable(self, config):


class ADFSFormsBasedAuthenticator(GenericFormsBasedAuthenticator):
USERNAME_FIELD = 'ctl00$ContentPlaceHolder1$UsernameTextBox'
PASSWORD_FIELD = 'ctl00$ContentPlaceHolder1$PasswordTextBox'

def is_suitable(self, config):
return (config.get('saml_authentication_type') == 'form' and
Expand Down
4 changes: 3 additions & 1 deletion tests/functional/test_saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,9 @@ def test_prompter_only_called_once(client_creator, prompter, assertion,
'saml_provider': 'okta',
'saml_endpoint': 'https://example.com/',
'saml_username': 'monty',
'role_arn': 'arn:aws:iam::123456789012:role/monty'
'role_arn': 'arn:aws:iam::123456789012:role/monty',
'form_username_field': 'username',
'form_password_field': 'password',
}
fetcher = SAMLCredentialFetcher(
client_creator=client_creator,
Expand Down
36 changes: 34 additions & 2 deletions tests/unit/test_saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ def generic_config():
'saml_authentication_type': 'form',
'saml_username': 'monty',
'role_arn': 'arn:aws:iam::123456789012:role/monty',
'form_username_field': 'username',
'form_password_field': 'password',
}


Expand All @@ -55,6 +57,8 @@ def okta_config():
'saml_authentication_type': 'form',
'saml_username': 'monty',
'saml_provider': 'okta',
'form_username_field': 'username',
'form_password_field': 'password',
}


Expand All @@ -65,6 +69,8 @@ def adfs_config():
'saml_authentication_type': 'form',
'saml_username': 'monty',
'saml_provider': 'adfs',
'form_username_field': 'ctl00$ContentPlaceHolder1$UsernameTextBox',
'form_password_field': 'ctl00$ContentPlaceHolder1$PasswordTextBox',
}


Expand Down Expand Up @@ -124,6 +130,8 @@ def test_config_missing_username(self, generic_auth):
config = {
'saml_endpoint': 'https://example.com',
'saml_authentication_type': 'form',
'form_username_field': 'username',
'form_password_field': 'password',
}
with pytest.raises(SAMLError, match='Missing required'):
generic_auth.retrieve_saml_assertion(config)
Expand All @@ -132,6 +140,28 @@ def test_config_missing_endpoint(self, generic_auth):
config = {
'saml_username': 'monty',
'saml_authentication_type': 'form',
'form_username_field': 'username',
'form_password_field': 'password',
}
with pytest.raises(SAMLError, match='Missing required'):
generic_auth.retrieve_saml_assertion(config)

def test_config_missing_form_username_field(self, generic_auth):
config = {
'saml_username': 'monty',
'saml_authentication_type': 'form',
'saml_endpoint': 'https://example.com',
'form_password_field': 'password',
}
with pytest.raises(SAMLError, match='Missing required'):
generic_auth.retrieve_saml_assertion(config)

def test_config_missing_form_password_field(self, generic_auth):
config = {
'saml_username': 'monty',
'saml_authentication_type': 'form',
'saml_endpoint': 'https://example.com',
'form_username_field': 'username',
}
with pytest.raises(SAMLError, match='Missing required'):
generic_auth.retrieve_saml_assertion(config)
Expand All @@ -150,6 +180,8 @@ def test_non_https_url(self, generic_auth, mock_requests_session,
'saml_endpoint': 'http://example.com',
'saml_authentication_type': 'form',
'saml_username': 'monty',
'form_username_field': 'username',
'form_password_field': 'password',
}
# The error is raised after the call to get the form, but before the
# call to submit it.
Expand Down Expand Up @@ -590,7 +622,7 @@ def test_cache_key_is_windows_safe(self, fetcher, cache,
retrieve.return_value = saml_assertion
fetcher.fetch_credentials()

cache_key = '0cebd512540a4f5fe2edce26319cf1cf3138684f'
cache_key = 'af7a32316c966f76d660f9610c0ec56d91bb2f03'
assert cache_key in cache

def test_datetime_cache_is_always_serialized(self, fetcher, cache,
Expand All @@ -615,7 +647,7 @@ def test_datetime_cache_is_always_serialized(self, fetcher, cache,
retrieve.return_value = saml_assertion
fetcher.fetch_credentials()

cache_key = '0cebd512540a4f5fe2edce26319cf1cf3138684f'
cache_key = 'af7a32316c966f76d660f9610c0ec56d91bb2f03'
cache_expiration = cache[cache_key]['Credentials']['Expiration']
assert not isinstance(cache_expiration, datetime)
assert cache_expiration == expiration.isoformat()
Expand Down