From 55b2da9ccf71ba32db6196ef58cada050b6a0e76 Mon Sep 17 00:00:00 2001 From: d-w-moore Date: Sat, 3 Feb 2024 01:07:36 -0500 Subject: [PATCH] [#517,#596,#621] generate .irodsA for pam_password and native authentication. This commit introduces iinit-like capability to generate the .irodsA file, when not previously existing, for the pam_password authentication scheme. Also, free functions are introduced which create the .irodsA file from a cleartext password value in the native and pam_password authentication schemes. --- README.md | 51 +++++++++++++ irods/__init__.py | 16 +++++ irods/account.py | 12 +++- irods/client_init.py | 42 +++++++++++ irods/connection.py | 11 ++- irods/session.py | 17 +++-- irods/test/PRC_issue_362.bats | 2 +- irods/test/helpers.py | 10 +-- .../test001_pam_password_expiration.bats | 2 +- ...te_native_credentials_to_secrets_file.bats | 47 ++++++++++++ ...write_pam_credentials_to_secrets_file.bats | 38 ++++++++++ ...word_internal_secrets_file_generation.bats | 71 +++++++++++++++++++ 12 files changed, 296 insertions(+), 23 deletions(-) create mode 100644 irods/client_init.py create mode 100755 irods/test/scripts/test002_write_native_credentials_to_secrets_file.bats create mode 100755 irods/test/scripts/test003_write_pam_credentials_to_secrets_file.bats create mode 100755 irods/test/scripts/test004_prc_pam_password_internal_secrets_file_generation.bats diff --git a/README.md b/README.md index 7b260b75..e380fdba 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,47 @@ the `encryption_*` and `ssl_*` options directly to the constructor as keyword arguments, even though it is required when they are placed in the environment file. +Creating PAM or Native Credentials File (.irodsA) +------------------------------------------------- + +Two free functions exist for creating encoded authentication files: +``` +irods.client_init.write_native_credentials_to_secrets_file +irods.client_init.write_pam_credentials_to_secrets_file +``` + +Each takes a cleartext password and writes an appropriately processed version of it +into an .irodsA (secrets) file in the login environment. + +Note, in the `pam_password` case, this involves sending the cleartext password +to the server (SSL should thus be enabled!) and then writing the scrambled token that +returns from the transaction. + +If an .irodsA file exists already, it will be overwritten. + +Examples: +For the `native` authentication scheme, we can use the currently set iRODS password to create .irodsA file from Python thus: + +```python +import irods.client_init as iinit +iinit.write_native_credentials_to_secrets_file(irods_password) +``` + +For the `pam_password` authentication scheme, we must first ensure an `irods_environment.json` file exists in the +client environment (necessary for establishing SSL/TLS connection parameters as well as obtaining a PAM token from the server after connecting) +and then make the call to write .irodsA using the Bash commands: + +```bash +$ cat > ~/.irods/irods_environment.json << EOF +{ + "irods_user_name":"rods", + "irods_host":"server-hostname", + ... [all other connection settings, including SSL parameters, needed for communication with iRODS] ... +} +EOF +$ python -c "import irods.client_init as iinit; iinit.write_pam_credentials_to_secrets_file(pam_cleartext_password)" +``` + PAM logins ---------- @@ -171,6 +212,16 @@ iCommands. Caveat for iRODS 4.3+: when upgrading from 4.2, the "irods_authentication_scheme" setting must be changed from "pam" to "pam_password" in `~/.irods/irods_environment.json` for all file-based client environments. +To use the PRC PAM login credentials update function for the client login environment, we can set these two configuration variables: + +``` +legacy_auth.pam.password_for_auto_renew "my_pam_password" +legacy_auth.pam.store_password_to_environment True +``` + +Optionally, the `legacy_auth.pam.time_to_live_in_hours` may also be set to determine the time-to-live for the new password. +Leaving it at the default value defers this decision to the server. + Maintaining a connection ------------------------ diff --git a/irods/__init__.py b/irods/__init__.py index ed213f9a..875257d3 100644 --- a/irods/__init__.py +++ b/irods/__init__.py @@ -3,6 +3,22 @@ import logging import os +def env_filename_from_keyword_args(kwargs): + try: + env_file = kwargs.pop('irods_env_file') + except KeyError: + try: + env_file = os.environ['IRODS_ENVIRONMENT_FILE'] + except KeyError: + env_file = os.path.expanduser('~/.irods/irods_environment.json') + return env_file + +def derived_auth_filename(env_filename): + if not env_filename: + return '' + default_irods_authentication_file = os.path.join(os.path.dirname(env_filename),'.irodsA') + return os.environ.get('IRODS_AUTHENTICATION_FILE', default_irods_authentication_file) + # This has no effect if basicConfig() was previously called. logging.basicConfig() diff --git a/irods/account.py b/irods/account.py index 9c96cb48..3f8564fb 100644 --- a/irods/account.py +++ b/irods/account.py @@ -1,15 +1,25 @@ +from irods import derived_auth_filename + class iRODSAccount(object): + @property + def derived_auth_file(self): + return derived_auth_filename(self.env_file) + def __init__(self, irods_host, irods_port, irods_user_name, irods_zone_name, irods_authentication_scheme='native', password=None, client_user=None, - server_dn=None, client_zone=None, **kwargs): + server_dn=None, client_zone=None, + env_file = '', + **kwargs): + # Allowed overrides when cloning sessions. (Currently hostname only.) for k,v in kwargs.pop('_overrides',{}).items(): if k =='irods_host': irods_host = v + self.env_file = env_file tuplify = lambda _: _ if isinstance(_,(list,tuple)) else (_,) schemes = [_.lower() for _ in tuplify(irods_authentication_scheme)] diff --git a/irods/client_init.py b/irods/client_init.py new file mode 100644 index 00000000..f7d9a14f --- /dev/null +++ b/irods/client_init.py @@ -0,0 +1,42 @@ +from irods import (env_filename_from_keyword_args, derived_auth_filename) +import irods.client_configuration as cfg +import irods.password_obfuscation as obf +import irods.helpers as h +import getpass +import os +import sys + +def write_native_credentials_to_secrets_file(password, **kw): + env_file = env_filename_from_keyword_args(kw) + auth_file = derived_auth_filename(env_file) + old_mask = None + try: + old_mask = os.umask(0o77) + open(auth_file,'w').write(obf.encode(password)) + finally: + if old_mask is not None: + os.umask(old_mask) + + return True + +def write_pam_credentials_to_secrets_file( password ,**kw): + s = h.make_session() + s.pool.account.password = password + with cfg.loadlines( [dict(setting='legacy_auth.pam.password_for_auto_renew',value=None), + dict(setting='legacy_auth.pam.store_password_to_environment',value=False)] ): + to_encode = s.pam_pw_negotiated + if to_encode: + open(s.pool.account.derived_auth_file,'w').write(obf.encode(to_encode[0])) + return True + return False + +if __name__ == '__main__': + vector = { + 'pam_password': write_pam_credentials_to_secrets_file, + 'native': write_native_credentials_to_secrets_file + } + + if sys.argv[1] in vector: + vector[sys.argv[1]](getpass.getpass(prompt=f'{sys.argv[1]} password: ')) + else: + print('did not recognize authentication scheme argument',file = sys.stderr) diff --git a/irods/connection.py b/irods/connection.py index a40c712c..7b70c62a 100644 --- a/irods/connection.py +++ b/irods/connection.py @@ -461,16 +461,14 @@ def _login_pam(self): import irods.client_configuration as cfg inline_password = (self.account.authentication_scheme == self.account._original_authentication_scheme) - # By default, let server determine the TTL. - time_to_live_in_hours = 0 + time_to_live_in_hours = cfg.legacy_auth.pam.time_to_live_in_hours # For certain characters in the pam password, if they need escaping with '\' then do so. new_pam_password = PAM_PW_ESC_PATTERN.sub(lambda m: '\\'+m.group(1), self.account.password) - if not inline_password: + if not inline_password and cfg.legacy_auth.pam.password_for_auto_renew is not None: # Login using PAM password from .irodsA try: self._login_native() except (ex.CAT_PASSWORD_EXPIRED, ex.CAT_INVALID_USER, ex.CAT_INVALID_AUTHENTICATION) as exc: - time_to_live_in_hours = cfg.legacy_auth.pam.time_to_live_in_hours if cfg.legacy_auth.pam.password_for_auto_renew: new_pam_password = cfg.legacy_auth.pam.password_for_auto_renew # Fall through and retry the native login later, after creating a new PAM password @@ -532,8 +530,9 @@ def _login_pam(self): self._login_native(password = auth_out.result_) # Store new password in .irodsA if requested. - if self.account._auth_file and cfg.legacy_auth.pam.store_password_to_environment: - with open(self.account._auth_file,'w') as f: + auth_file = (self.account._auth_file or self.account.derived_auth_file) + if auth_file and cfg.legacy_auth.pam.store_password_to_environment: + with open(auth_file,'w') as f: f.write(obf.encode(auth_out.result_)) logger.debug('new PAM pw write succeeded') diff --git a/irods/session.py b/irods/session.py index 6bee2c31..a1ab1573 100644 --- a/irods/session.py +++ b/irods/session.py @@ -210,10 +210,9 @@ def cleanup(self, new_host = ''): self.__configured = self.configure(**self.do_configure) def _configure_account(self, **kwargs): - + env_file = None try: env_file = kwargs['irods_env_file'] - except KeyError: # For backward compatibility for key in ['host', 'port', 'authentication_scheme']: @@ -232,6 +231,9 @@ def _configure_account(self, **kwargs): # Update with new keywords arguments only creds.update((key, value) for key, value in kwargs.items() if key not in creds) + if env_file: + creds['env_file'] = env_file + # Get auth scheme try: auth_scheme = creds['irods_authentication_scheme'] @@ -259,10 +261,13 @@ def _configure_account(self, **kwargs): missing_file_path = [] error_args = [] pw = creds['password'] = self.get_irods_password(session_ = self, file_path_if_not_found = missing_file_path, **creds) - if not pw and creds.get('irods_user_name') != 'anonymous': - if missing_file_path: - error_args += ["Authentication file not found at {!r}".format(missing_file_path[0])] - raise NonAnonymousLoginWithoutPassword(*error_args) + # For native authentication, a missing password should be flagged as an error for non-anonymous logins. + # However, the pam_password case has its own internal checks. + if auth_scheme.lower() not in PAM_AUTH_SCHEMES: + if not pw and creds.get('irods_user_name') != 'anonymous': + if missing_file_path: + error_args += ["Authentication file not found at {!r}".format(missing_file_path[0])] + raise NonAnonymousLoginWithoutPassword(*error_args) return iRODSAccount(**creds) diff --git a/irods/test/PRC_issue_362.bats b/irods/test/PRC_issue_362.bats index 1516de36..c3a1ef80 100644 --- a/irods/test/PRC_issue_362.bats +++ b/irods/test/PRC_issue_362.bats @@ -1,7 +1,7 @@ # The tests in this BATS module must be run as a (passwordless) sudo-enabled user. # It is also required that the python irodsclient be installed under irods' ~/.local environment. -. $BATS_TEST_DIRNAME/scripts/funcs +. $BATS_TEST_DIRNAME/scripts/test_support_functions setup() { diff --git a/irods/test/helpers.py b/irods/test/helpers.py index 3e9dda71..99256ab4 100644 --- a/irods/test/helpers.py +++ b/irods/test/helpers.py @@ -21,6 +21,7 @@ from irods.session import iRODSSession from irods.message import (iRODSMessage, IRODS_VERSION) from irods.password_obfuscation import encode +from irods import env_filename_from_keyword_args from six.moves import range class iRODSUserLogins(object): @@ -148,7 +149,6 @@ def recast(k): os.chmod(auth,0o600) return (config, auth) - # Create a connection for test, based on ~/.irods environment by default. def make_session(test_server_version = True, **kwargs): @@ -166,13 +166,7 @@ def make_session(test_server_version = True, **kwargs): **kwargs: Keyword arguments. Fed directly to the iRODSSession constructor. """ - try: - env_file = kwargs.pop('irods_env_file') - except KeyError: - try: - env_file = os.environ['IRODS_ENVIRONMENT_FILE'] - except KeyError: - env_file = os.path.expanduser('~/.irods/irods_environment.json') + env_file = env_filename_from_keyword_args( kwargs ) session = iRODSSession( irods_env_file = env_file, **kwargs ) if test_server_version: connected_version = session.server_version[:3] diff --git a/irods/test/pam.bats/test001_pam_password_expiration.bats b/irods/test/pam.bats/test001_pam_password_expiration.bats index 7bb98718..3e29100e 100644 --- a/irods/test/pam.bats/test001_pam_password_expiration.bats +++ b/irods/test/pam.bats/test001_pam_password_expiration.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats -. "$BATS_TEST_DIRNAME"/funcs +. "$BATS_TEST_DIRNAME"/test_support_functions PYTHON=python3 # Setup/prerequisites are same as for login_auth_test. diff --git a/irods/test/scripts/test002_write_native_credentials_to_secrets_file.bats b/irods/test/scripts/test002_write_native_credentials_to_secrets_file.bats new file mode 100755 index 00000000..dc1837d2 --- /dev/null +++ b/irods/test/scripts/test002_write_native_credentials_to_secrets_file.bats @@ -0,0 +1,47 @@ +#!/usr/bin/env bats +# +# Test creation of .irodsA for iRODS native authentication using the free function, +# irods.client_init.write_pam_credentials_to_secrets_file + +. "$BATS_TEST_DIRNAME"/test_support_functions +PYTHON=python3 + +# Setup/prerequisites are same as for login_auth_test. +# Run as ubuntu user with sudo; python_irodsclient must be installed (in either ~/.local or a virtualenv) +# + +ALICES_OLD_PAM_PASSWD="test123" +ALICES_NEW_PAM_PASSWD="new_pass" + +setup() +{ + setup_pam_login_for_alice "$ALICES_OLD_PAM_PASSWD" +} + +teardown() +{ + finalize_pam_login_for_alice + test_specific_cleanup +} + +@test create_secrets_file { + + # Old .irodsA is already created, so we delete it and alter the pam password. + sudo chpasswd <<<"alice:$ALICES_NEW_PAM_PASSWD" + rm -f ~/.irods/.irodsA + $PYTHON -c "import irods.client_init; irods.client_init.write_pam_credentials_to_secrets_file('$ALICES_NEW_PAM_PASSWD')" + + # Define the core Python to be run, basically a minimal code block ensuring that we can authenticate to iRODS + # without an exception being raised. + + local SCRIPT=" +import irods.test.helpers as h +ses = h.make_session() +ses.collections.get(h.home_collection(ses)) +print ('env_auth_scheme=%s' % ses.pool.account._original_authentication_scheme) +" + OUTPUT=$($PYTHON -c "$SCRIPT") + # Assert passing value + [ $OUTPUT = "env_auth_scheme=pam_password" ] + +} diff --git a/irods/test/scripts/test003_write_pam_credentials_to_secrets_file.bats b/irods/test/scripts/test003_write_pam_credentials_to_secrets_file.bats new file mode 100755 index 00000000..4686755f --- /dev/null +++ b/irods/test/scripts/test003_write_pam_credentials_to_secrets_file.bats @@ -0,0 +1,38 @@ +#!/usr/bin/env bats +# +# Test creation of .irodsA for iRODS pam_password authentication using the free function, +# irods.client_init.write_native_credentials_to_secrets_file + +. "$BATS_TEST_DIRNAME"/test_support_functions +PYTHON=python3 + +# Setup/prerequisites are same as for login_auth_test. +# Run as ubuntu user with sudo; python_irodsclient must be installed (in either ~/.local or a virtualenv) +# + +@test create_irods_secrets_file { + + rm -fr ~/.irods + mkdir ~/.irods + cat > ~/.irods/irods_environment.json <<-EOF + { "irods_host":"$(hostname)", + "irods_port":1247, + "irods_user_name":"rods", + "irods_zone_name":"tempZone" + } + EOF + $PYTHON -c "import irods.client_init; irods.client_init.write_native_credentials_to_secrets_file('rods')" + + # Define the core Python to be run, basically a minimal code block ensuring that we can authenticate to iRODS + # without an exception being raised. + + local SCRIPT=" +import irods.test.helpers as h +ses = h.make_session() +ses.collections.get(h.home_collection(ses)) +print ('env_auth_scheme=%s' % ses.pool.account._original_authentication_scheme) +" + OUTPUT=$($PYTHON -c "$SCRIPT") + # Assert passing value + [ $OUTPUT = "env_auth_scheme=native" ] +} diff --git a/irods/test/scripts/test004_prc_pam_password_internal_secrets_file_generation.bats b/irods/test/scripts/test004_prc_pam_password_internal_secrets_file_generation.bats new file mode 100755 index 00000000..a5cff593 --- /dev/null +++ b/irods/test/scripts/test004_prc_pam_password_internal_secrets_file_generation.bats @@ -0,0 +1,71 @@ +#!/usr/bin/env bats +# +# Test creation of .irodsA for iRODS pam_password authentication, this time purely internal to the PRC +# library code. + +. "$BATS_TEST_DIRNAME"/test_support_functions +PYTHON=python3 + +# Setup/prerequisites are same as for login_auth_test. +# Run as ubuntu user with sudo; python_irodsclient must be installed (in either ~/.local or a virtualenv) +# + +ALICES_PAM_PASSWORD=test123 + +setup() +{ + export SKIP_IINIT_FOR_PASSWORD=1 + setup_pam_login_for_alice "$ALICES_PAM_PASSWORD" + unset SKIP_IINIT_FOR_PASSWORD +} + +teardown() +{ +: +# finalize_pam_login_for_alice +# test_specific_cleanup +} + +@test f001 { + + AUTH_FILE=~/.irods/.irodsA + + # Test assertion: No pre-existing authentication file. + ! [ -e $AUTH_FILE ] + + local SCRIPT=" +import irods.test.helpers as h +ses = h.make_session() +ses.collections.get(h.home_collection(ses)) +print ('env_auth_scheme=%s' % ses.pool.account._original_authentication_scheme) +" + + # First invocation. PRC will both authenticate with pam_password, and write the generated secrets to the auth file, + OUTPUT=$($PYTHON -c "import irods.client_configuration as cfg +cfg.legacy_auth.pam.password_for_auto_renew = '$ALICES_PAM_PASSWORD' +cfg.legacy_auth.pam.time_to_live_in_hours = 1 +cfg.legacy_auth.pam.store_password_to_environment = True +$SCRIPT") + + SECRETS_0=$(cat $AUTH_FILE) + STAT_0=$(stat -c%y $AUTH_FILE) + + sleep 1.1 + + # Second invocation. PRC will use previously generated secrets from the auth file generated in the first invocation. + OUTPUT=$($PYTHON -c "import irods.client_configuration as cfg +#cfg.legacy_auth.pam.password_for_auto_renew = '$ALICES_PAM_PASSWORD' +cfg.legacy_auth.pam.time_to_live_in_hours = 1 +cfg.legacy_auth.pam.store_password_to_environment = True +$SCRIPT") + + SECRETS_1=$(cat $AUTH_FILE) + STAT_1=$(stat -c%y $AUTH_FILE) + + # Test assertion: authentication file is the same, before and after, with identical modification date and contents. + [ "$STAT_1" = "$STAT_0" ] + [ "$SECRETS_0" = "$SECRETS_1" ] + + # Test assertion: authentication method is pam_password + [ $OUTPUT = "env_auth_scheme=pam_password" ] +}