From 0971f3d82b817c91522cdfdb3d40882956290893 Mon Sep 17 00:00:00 2001 From: d-w-moore Date: Tue, 11 Mar 2025 04:03:45 -0400 Subject: [PATCH 1/3] [#686] independent computation of irodsA path --- irods/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/irods/__init__.py b/irods/__init__.py index fdb15cc45..ab37e5b94 100644 --- a/irods/__init__.py +++ b/irods/__init__.py @@ -29,9 +29,7 @@ def env_filename_from_keyword_args(kwargs): def derived_auth_filename(env_filename): if not env_filename: return "" - default_irods_authentication_file = os.path.join( - os.path.dirname(env_filename), ".irodsA" - ) + default_irods_authentication_file = os.path.expanduser("~/.irods/.irodsA") return os.environ.get( "IRODS_AUTHENTICATION_FILE", default_irods_authentication_file ) From 53405a88f59eecaf6f0e3cfe26e45258a0711873 Mon Sep 17 00:00:00 2001 From: d-w-moore Date: Wed, 12 Feb 2025 10:07:10 -0500 Subject: [PATCH 2/3] [#688] implement server_version_without_auth This is a method of the iRODSSession object that returns the connected iRODS server version without having to authenticate first. --- irods/connection.py | 2 ++ irods/helpers/__init__.py | 20 ++++++++++++++++++++ irods/keywords.py | 3 +-- irods/pool.py | 12 ++++++++++++ irods/session.py | 33 ++++++++++++++++++++++++++++++++- irods/test/connection_test.py | 33 ++++++++++++++++++++++++++++----- irods/test/helpers.py | 28 +++++++--------------------- 7 files changed, 102 insertions(+), 29 deletions(-) diff --git a/irods/connection.py b/irods/connection.py index 9d1e1198f..66467760a 100644 --- a/irods/connection.py +++ b/irods/connection.py @@ -83,6 +83,8 @@ def __init__(self, pool, account): self._disconnected = False scheme = self.account._original_authentication_scheme + if self.pool and not self.pool._need_auth: + return # These variables are just useful diagnostics. The login_XYZ() methods should fail by # raising exceptions if they encounter authentication errors. diff --git a/irods/helpers/__init__.py b/irods/helpers/__init__.py index 60535c3bb..c9dc5f461 100644 --- a/irods/helpers/__init__.py +++ b/irods/helpers/__init__.py @@ -36,3 +36,23 @@ def xml_mode(s): yield finally: ET(None) + + +class _unlikely_value: + pass + + +@contextlib.contextmanager +def temporarily_assign_attribute( + target, attr, value, not_set_indicator=_unlikely_value() +): + save = not_set_indicator + try: + save = getattr(target, attr, not_set_indicator) + setattr(target, attr, value) + yield + finally: + if save != not_set_indicator: + setattr(target, attr, save) + else: + delattr(target, attr) diff --git a/irods/keywords.py b/irods/keywords.py index c29d10cb7..f6a5e7d9a 100644 --- a/irods/keywords.py +++ b/irods/keywords.py @@ -1,5 +1,4 @@ -"""From rodsKeyWdDef.hpp -""" +"""From rodsKeyWdDef.hpp""" ALL_KW = "all" # operation done on all replicas # COPIES_KW = "copies" # the number of copies # diff --git a/irods/pool.py b/irods/pool.py index d4260befb..63b0f9321 100644 --- a/irods/pool.py +++ b/irods/pool.py @@ -1,3 +1,4 @@ +import contextlib import datetime import logging import threading @@ -57,6 +58,7 @@ def __init__( or application_name or DEFAULT_APPLICATION_NAME ) + self._need_auth = True if connection_refresh_time > 0: self.refresh_connection = True @@ -65,6 +67,16 @@ def __init__( self.refresh_connection = False self.connection_refresh_time = None + @contextlib.contextmanager + def no_auto_authenticate(self): + import irods.helpers + + with irods.helpers.temporarily_assign_attribute(self, "_need_auth", False): + yield self + + def set_session_ref(self, session): + self.session_ref = weakref.ref(session) if session is not None else lambda: None + @property def _conn(self): return getattr(self._thread_local, "_conn", None) diff --git a/irods/session.py b/irods/session.py index bb88f54ac..ef9324206 100644 --- a/irods/session.py +++ b/irods/session.py @@ -1,5 +1,6 @@ import ast import atexit +import contextlib import copy import errno import json @@ -355,10 +356,40 @@ def port(self): @property def server_version(self): + return self._server_version() + + def server_version_without_auth(self): + """Returns the same version tuple as iRODSSession's server_version property, but + does not require successful authentication. + """ + from irods.connection import Connection + + with self.clone().pool.no_auto_authenticate() as pool: + return Connection(pool, pool.account).server_version + + GET_SERVER_VERSION_WITHOUT_AUTH = staticmethod( + lambda s: s.server_version_without_auth() + ) + + def _server_version(self, version_func=None): + """The server version can be retrieved by the usage: + + session._server_version() + + with conditional substitution by another version by use of the environment variable: + + PYTHON_IRODSCLIENT_REPORTED_SERVER_VERSION. + + Also: if iRODSServer.GET_SERVER_VERSION_WITHOUT_AUTH is passed in version_func, the true server + version can be accessed without first going through authentication. + Example: + ses = irods.helpers.make_session() + vsn = ses._server_version( ses.GET_SERVER_VERSION_WITHOUT_AUTH ) + """ reported_vsn = os.environ.get("PYTHON_IRODSCLIENT_REPORTED_SERVER_VERSION", "") if reported_vsn: return tuple(ast.literal_eval(reported_vsn)) - return self.__server_version() + return self.__server_version() if version_func is None else version_func(self) def __server_version(self): try: diff --git a/irods/test/connection_test.py b/irods/test/connection_test.py index 6bb990827..6d785ab9e 100644 --- a/irods/test/connection_test.py +++ b/irods/test/connection_test.py @@ -1,16 +1,16 @@ #! /usr/bin/env python +import numbers import os import sys import tempfile import unittest -from irods.exception import NetworkException from irods import MAXIMUM_CONNECTION_TIMEOUT +from irods.exception import NetworkException, CAT_INVALID_AUTHENTICATION +import irods.session import irods.test.helpers as helpers -from irods.test.helpers import ( - server_side_sleep, - temporarily_assign_attribute as temp_setter, -) +from irods.test.helpers import server_side_sleep +from irods.helpers import temporarily_assign_attribute as temp_setter class TestConnections(unittest.TestCase): @@ -34,6 +34,29 @@ def test_connection_destructor(self): self.assertTrue(conn._disconnected) conn.release(destroy=True) + def test_server_version_without_authentication__issue_688(self): + sess = self.sess + + # Make a session object that cannot authenticate. + non_authenticating_session = irods.session.iRODSSession( + host=sess.host, + port=sess.port, + user=sess.username, + zone=sess.zone, + # No password. + ) + + # Test server_version_without_auth method returns a value. + version_tup = non_authenticating_session.server_version_without_auth() + + # Test returned value is non-empty "version" tuple, i.e. holds only integer values. + self.assertGreater(len(version_tup), 0) + self.assertFalse(any(not isinstance(_, numbers.Integral) for _ in version_tup)) + + # Test that the older server_version property fails for the unauthenticated session object. + with self.assertRaises(CAT_INVALID_AUTHENTICATION): + _ = non_authenticating_session.server_version + def test_failed_connection(self): # Make sure no connections are cached in self.sess.pool.idle to be grabbed by get_connection(). # (Necessary after #418 fix; make_session() can probe server_version, which then leaves an idle conn.) diff --git a/irods/test/helpers.py b/irods/test/helpers.py index bbffde8a8..da61db330 100644 --- a/irods/test/helpers.py +++ b/irods/test/helpers.py @@ -172,6 +172,12 @@ def recast(k): return (config, auth) +def get_server_version_for_test(session, curtail_length): + return session._server_version(session.GET_SERVER_VERSION_WITHOUT_AUTH)[ + :curtail_length + ] + + # Create a connection for test, based on ~/.irods environment by default. @@ -193,7 +199,7 @@ def make_session(test_server_version=True, **kwargs): 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] + connected_version = get_server_version_for_test(session, curtail_length=3) advertised_version = IRODS_VERSION[:3] if connected_version > advertised_version: msg = ( @@ -430,26 +436,6 @@ def enableLogging(logger, handlerType, args, level_=logging.INFO): logger.removeHandler(h) -class _unlikely_value: - pass - - -@contextlib.contextmanager -def temporarily_assign_attribute( - target, attr, value, not_set_indicator=_unlikely_value() -): - save = not_set_indicator - try: - save = getattr(target, attr, not_set_indicator) - setattr(target, attr, value) - yield - finally: - if save != not_set_indicator: - setattr(target, attr, save) - else: - delattr(target, attr) - - # Implement a server-side wait that ensures no TCP communication from server end for a given interval. # Useful to test the effect of socket inactivity on a client. See python-irodsclient issue #569 def server_side_sleep(session, seconds): From 2f30578436d429aa29be18123afec3c4eae7371b Mon Sep 17 00:00:00 2001 From: d-w-moore Date: Sun, 9 Mar 2025 17:27:28 -0400 Subject: [PATCH 3/3] [#499] authenticate native and pam_password schemes using iRODS 4.3+ auth flow --- README.md | 101 +++++++--- irods/account.py | 5 + irods/api_number.py | 1 + irods/auth/__init__.py | 187 +++++++++++++++++- irods/auth/native.py | 129 +++++++++++- irods/auth/pam.py | 19 +- irods/auth/pam_password.py | 175 +++++++++++++++- irods/client_configuration/__init__.py | 3 +- irods/client_init.py | 84 ++++---- irods/connection.py | 76 ++++--- irods/message/__init__.py | 1 + irods/pool.py | 2 +- irods/prc_write_irodsA.py | 54 +++++ irods/session.py | 48 ++++- irods/test/connection_test.py | 19 ++ .../test/login_auth_test_must_run_manually.py | 90 ++++++--- irods/test/scripts/README.md | 17 ++ .../test001_pam_password_expiration.bats | 5 +- ...te_native_credentials_to_secrets_file.bats | 4 +- ...write_pam_credentials_to_secrets_file.bats | 6 +- ...word_internal_secrets_file_generation.bats | 5 +- ..._special_characters_in_pam_passwords.bats} | 6 +- ...t006_connection_timeout_on_ssl_socket.bats | 2 +- ...07_pam_features_in_new_auth_framework.bats | 111 +++++++++++ ...c_write_irodsA_utility_in_native_mode.bats | 56 ++++++ ...cters_in_pam_passwords_auth_framework.bats | 45 +++++ setup.py | 1 + 27 files changed, 1084 insertions(+), 168 deletions(-) create mode 100644 irods/prc_write_irodsA.py create mode 100644 irods/test/scripts/README.md rename irods/test/scripts/{test005_test_special_characters_in_passwords.bats => test005_test_special_characters_in_pam_passwords.bats} (94%) create mode 100755 irods/test/scripts/test007_pam_features_in_new_auth_framework.bats create mode 100755 irods/test/scripts/test008_prc_write_irodsA_utility_in_native_mode.bats create mode 100755 irods/test/scripts/test009_test_special_characters_in_pam_passwords_auth_framework.bats diff --git a/README.md b/README.md index 520376c14..fcfc92781 100644 --- a/README.md +++ b/README.md @@ -129,51 +129,90 @@ 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) -------------------------------------------------- +Creating a PAM or Native Authentication File +-------------------------------------------- -Two free functions exist for creating encoded authentication files: +The following free functions may be used to create the authentication secrets files (called +`.irodsA` per the convention of iRODS's iCommands): + - `irods.client_init.write_native_irodsA_file` + - `irods.client_init.write_pam_irodsA_file` + +These functions can roughly be described as duplicating the "authentication" functionality of `iinit`, +provided that a valid `irods_environment.json` has already been created. + +Each of the above functions can take a cleartext password and write an appropriately encoded +version of it into an authentication file in the appropriate location. That location is +`~/.irods/.irodsA` unless the environment variable IRODS_AUTHENTICATION_FILE has been set +in the command shell to dictate an alternative file path. + +As an example, here we write a native `.irodsA` file using the first of the two functions. We +provide the one required argument, a password string which is entered interactively at the +terminal. + +```bash +$ echo '{ "irods_user_name":"rods", + ... # other parameters as needed + }'> ~/.irods/irods_environment.json +$ python -c "import irods.client_init, getpass +irods.client_init.write_native_irodsA_file(getpass.getpass('Enter iRODS password -> '))" ``` -irods.client_init.write_native_credentials_to_secrets_file -irods.client_init.write_pam_credentials_to_secrets_file + +By default, when an `.irodsA` file already exists, it will be overwritten. If however the +`overwrite` parameter is set to `False`, an exception of type `irods.client_init.irodsA_already_exists` +is raised to warn of any older `.irodsA` file that might otherwise have been overwritten. + +Equivalently to the above, we can issue the following command. + +```bash +$ prc_write_irodsA.py native <<<"${MY_CURRENT_IRODS_PASSWORD}" ``` -Each takes a cleartext password and writes an appropriately processed version of it -into an .irodsA (secrets) file in the login environment. +The redirect may of course be left off, in which case the user is prompted for the iRODS password +and echo of the keyboard input will be suppressed, in the style of `iinit`. Regardless of +which technique is used, no password will be visible on the terminal during or after input. + +For the `pam_password` scheme, typically SSL/TLS must first be enabled to avoid sending data related +to the password - or even sending the raw password itself - over a network connection in the clear. -Examples: -For the `native` authentication scheme, we can use the currently set iRODS password to create the .irodsA file directly: +Thus, for `pam_password` authentication to work well, we should first ensure, when setting up the +client environment, to include within `irods_environment.json` the appropriate SSL/TLS connection +parameters. In a pinch, `iinit` can be used to verify this prerequisite is fulfilled, +as its invocation would then create a valid `.irodsA` from merely prompting the user for their PAM password. + +Once again, this can also be done using the free function directly: ```python -import irods.client_init as iinit -iinit.write_native_credentials_to_secrets_file(irods_password) +irods.client_init.write_pam_irodsA_file(getpass.getpass('Enter current PAM password -> ')) ``` -Note, in the `pam_password` case, this involves sending the cleartext password -to the server (SSL must be enabled!) and then writing the scrambled token that -is returned from the transaction. +or from the Bash command shell: -If an .irodsA file exists already, it will be overwritten by default; however, if these functions' -overwrite parameter is set to `False`, an exception of type `irods.client_init.irodsA_already_exists` -will be raised to indicate the older .irodsA file is present. +```bash +$ prc_write_irodsA.py pam_password <<<"${MY_CURRENT_PAM_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: +As a final note, in the `pam_password` scheme, the default SSL requirement can be disabled. +**Warning:** Disabling the SSL requirement may cause user passwords to be sent over the network +in the clear. This should only be done for purposes of testing. Here's how to do it: -```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)" +```python +from irods.auth.pam_password import ENSURE_SSL_IS_ACTIVE + +session = irods.session.iRODSSession(host = "localhost", port = 1247, + user = "alice", password = "test123", zone="tempZone", + authentication_scheme = "pam_password") + +session.set_auth_option_for_scheme('pam_password', ENSURE_SSL_IS_ACTIVE, False) + +# Do something with the session: +home = session.collections.get('/tempZone/home/alice') ``` -PAM logins ----------- +Note, however, in future releases of iRODS it is possible that extra SSL checking could be +implemented server-side, at which point the above code could not be guaranteed to work. + +Legacy (iRODS 4.2-compatible) PAM authentication +------------------------------------------------ Since v2.0.0, the Python iRODS Client is able to authenticate via PAM using the same file-based client environment as the iCommands. diff --git a/irods/account.py b/irods/account.py index 42696c7cf..67fcf29ae 100644 --- a/irods/account.py +++ b/irods/account.py @@ -28,6 +28,11 @@ def __init__( irods_host = v self.env_file = env_file + + # The '_auth_file' attribute will be written in the call to iRODSSession.configure, + # if an .irodsA file from the client environment is used to load password information. + self._auth_file = "" + tuplify = lambda _: _ if isinstance(_, (list, tuple)) else (_,) schemes = [_.lower() for _ in tuplify(irods_authentication_scheme)] diff --git a/irods/api_number.py b/irods/api_number.py index a957cf1d9..fe614ffce 100644 --- a/irods/api_number.py +++ b/irods/api_number.py @@ -179,4 +179,5 @@ "REPLICA_CLOSE_APN": 20004, "TOUCH_APN": 20007, "AUTH_PLUG_REQ_AN": 1201, + "AUTHENTICATION_APN": 110000, } diff --git a/irods/auth/__init__.py b/irods/auth/__init__.py index 33ed3507c..35998ee5f 100644 --- a/irods/auth/__init__.py +++ b/irods/auth/__init__.py @@ -1,8 +1,115 @@ +import importlib +import logging +import weakref +from irods.api_number import api_number +from irods.message import iRODSMessage, JSON_Message +import irods.password_obfuscation as obf +import irods.session + + __all__ = ["pam_password", "native"] + AUTH_PLUGIN_PACKAGE = "irods.auth" -import importlib + +# Python3 does not have types.NoneType +_NoneType = type(None) + + +class AuthStorage: + """A class that facilitates flexible means password storage. + + Using an instance of this class, passwords may either be one of the following: + + - directly placed in a member attribute (pw), or + + - they may be written to / read from a specified file path in encoded + form, usually in an .irodsA file intended for iRODS client authentication. + + Most typical of this class's utility is the transfer of password information from + the pam_password to the native authentication flow. In this usage, whether the + password is stored in RAM or in the filesystem depends on whether it was read + originally as a function parameter or from an authentication file, respectively, + when the session was created. + """ + + @staticmethod + def get_env_password(filename=None): + options = dict(irods_authentication_file=filename) if filename else {} + return irods.session.iRODSSession.get_irods_password(**options) + + @staticmethod + def get_env_password_file(): + return irods.session.iRODSSession.get_irods_password_file() + + @staticmethod + def set_env_password(unencoded_pw, filename=None): + if filename is None: + filename = AuthStorage.get_env_password_file() + from ..client_init import _open_file_for_protected_contents + + with _open_file_for_protected_contents(filename, "w") as irodsA: + irodsA.write(obf.encode(unencoded_pw)) + return filename + + @staticmethod + def get_temp_pw_storage(conn): + """Fetch the AuthStorage instance associated with this connection object.""" + return getattr(conn, "auth_storage", lambda: None)() + + @staticmethod + def create_temp_pw_storage(conn): + """Creates an AuthStorage instance to be cached and associated with this connection object. + + Called multiple times for the same connection, it will return the cached instance. + + The value returned by this call should be stored by the caller into an appropriately scoped + variable to ensure the AuthStorage instance endures for the desired lifetime -- that is, + for however long we wish to keep the password information around. This is because the + connection object only maintains a weak reference to said instance. + """ + + # resolve the weakly referenced AuthStorage obj for the connection if there is one. + weakref_to_store = getattr(conn, "auth_storage", None) + store = weakref_to_store and weakref_to_store() + + # In absence of a persistent AuthStorage object, create one. + if store is None: + store = AuthStorage(conn) + # So that the connection object doesn't hold on to password data too long: + conn.auth_storage = weakref.ref(store) + return store + + def __init__(self, conn): + self.conn = conn + self.pw = "" + self._auth_file = "" + + @property + def auth_file(self): + if self._auth_file is None: + return "" + return self._auth_file or self.conn.account.derived_auth_file + + def use_client_auth_file(self, auth_file): + """Set to None to completely suppress use of an .irodsA auth file.""" + if isinstance(auth_file, (str, _NoneType)): + self._auth_file = auth_file + else: + msg = f"Invalid object in {self.__class__}._auth_file" + raise RuntimeError(msg) + + def store_pw(self, pw): + if self.auth_file: + self.set_env_password(pw, filename=self.auth_file) + else: + self.pw = pw + + def retrieve_pw(self): + if self.auth_file: + return self.get_env_password(filename=self.auth_file) + return self.pw def load_plugins(subset=set(), _reload=False): @@ -18,9 +125,81 @@ def load_plugins(subset=set(), _reload=False): return dir_ -# TODO(#499): X models a class which we could define here as a base for various server or client state machines -# as appropriate for the various authentication types. +class REQUEST_IS_MISSING_KEY(Exception): + pass -class X: +class ClientAuthError(Exception): pass + + +def throw_if_request_message_is_missing_key(request, required_keys): + for key in required_keys: + if not key in request: + raise REQUEST_IS_MISSING_KEY(f"key = {key}") + + +def _auth_api_request(conn, data): + message_body = JSON_Message(data, conn.server_version) + message = iRODSMessage( + "RODS_API_REQ", msg=message_body, int_info=api_number["AUTHENTICATION_APN"] + ) + conn.send(message) + response = conn.recv() + return response.get_json_encoded_struct() + + +__FLOW_COMPLETE__ = "authentication_flow_complete" +__NEXT_OPERATION__ = "next_operation" + + +CLIENT_GET_REQUEST_RESULT = "client_get_request_result" +FORCE_PASSWORD_PROMPT = "force_password_prompt" +STORE_PASSWORD_IN_MEMORY = "store_password_in_memory" + + +class authentication_base: + + def __init__(self, connection, scheme): + self.conn = connection + self.loggedIn = 0 + self.scheme = scheme + + def call(self, next_operation, request): + logging.info("next operation = %r", next_operation) + old_func = func = next_operation + # One level of indirection should be sufficient to get a callable method. + if not callable(func): + old_func, func = (func, getattr(self, func, None)) + func = func or old_func + if not callable(func): + raise RuntimeError("client request contains no callable 'next_operation'") + resp = func(request) + logging.info("resp = %r", resp) + return resp + + def authenticate_client( + self, next_operation="auth_client_start", initial_request=() + ): + if not isinstance(initial_request, dict): + initial_request = dict(initial_request) + + to_send = initial_request.copy() + to_send["scheme"] = self.scheme + + while True: + resp = self.call(next_operation, to_send) + if self.loggedIn: + break + next_operation = resp.get(__NEXT_OPERATION__) + if next_operation is None: + raise ClientAuthError( + "next_operation key missing; cannot determine next operation" + ) + if next_operation in (__FLOW_COMPLETE__, ""): + raise ClientAuthError( + f"authentication flow stopped without success: scheme = {self.scheme}" + ) + to_send = resp + + logging.info("fully authenticated") diff --git a/irods/auth/native.py b/irods/auth/native.py index 1b02b12b9..d4c2bf175 100644 --- a/irods/auth/native.py +++ b/irods/auth/native.py @@ -1,11 +1,126 @@ -def login(conn): - conn._login_native() +import base64 +import logging +import hashlib +import struct +from irods import MAX_PASSWORD_LENGTH -# TODO (#499): Here, we could define client & server auth_state classes (ie state machines mimicking the mechanics -# of 4.3+ iCommands/iRods-runtime authentication framework), using this pattern for an inheritance hook. -from . import X as X_base +from . import ( + __NEXT_OPERATION__, + __FLOW_COMPLETE__, + AuthStorage, + authentication_base, + _auth_api_request, + throw_if_request_message_is_missing_key, +) -class X(X_base): - pass +def login(conn, **extra_opt): + """When the Python iRODS client loads this (or any) plugin for authenticating a connection object, + login is the hook function that gets called. + """ + opt = {"user_name": conn.account.proxy_user, "zone_name": conn.account.proxy_zone} + opt.update(extra_opt) + _authenticate_native(conn, req=opt) + + +_scheme = "native" + + +_logger = logging.getLogger(__name__) + + +def _authenticate_native(conn, req): + """The implementation for the client side of a native scheme authentication flow. + It is called by login(), the external-facing hook. + Other client auth plugins should at least roughly follow this pattern. + """ + _logger.debug("----------- %s (begin)", _scheme) + + _native_ClientAuthState(conn, scheme=_scheme).authenticate_client( + # initial_request is called context (or ctx for short) in iRODS core library code. + initial_request=req + ) + + _logger.debug("----------- %s (end)", _scheme) + + +class _native_ClientAuthState(authentication_base): + """A class containing the specific methods needed to implement a native scheme authentication flow.""" + + # Client defines. These strings should match instance method names within the class namespace. + AUTH_AGENT_START = "native_auth_agent_start" + AUTH_CLIENT_AUTH_REQUEST = "native_auth_client_request" + AUTH_ESTABLISH_CONTEXT = "native_auth_establish_context" + AUTH_CLIENT_AUTH_RESPONSE = "native_auth_client_response" + + # Server defines. + AUTH_AGENT_AUTH_REQUEST = "auth_agent_auth_request" + AUTH_AGENT_AUTH_RESPONSE = "auth_agent_auth_response" + + def auth_client_start(self, request): + resp = request.copy() + # user_name and zone_name keys injected by authenticate_client() method + resp[__NEXT_OPERATION__] = ( + self.AUTH_CLIENT_AUTH_REQUEST + ) # native_auth_client_request + return resp + + def native_auth_client_request(self, request): + server_req = request.copy() + server_req[__NEXT_OPERATION__] = self.AUTH_AGENT_AUTH_REQUEST + + resp = _auth_api_request(self.conn, server_req) + + resp[__NEXT_OPERATION__] = self.AUTH_ESTABLISH_CONTEXT + return resp + + def native_auth_establish_context(self, request): + throw_if_request_message_is_missing_key( + request, ["user_name", "zone_name", "request_result"] + ) + request = request.copy() + + password = "" + depot = AuthStorage.get_temp_pw_storage(self.conn) + if depot: + # The following is how pam_password communicates a server-generated password. + password = depot.retrieve_pw() + + if not password: + password = self.conn.account.password or "" + + challenge = request["request_result"].encode("utf-8") + self.conn._client_signature = "".join( + "{:02x}".format(c) for c in challenge[:16] + ) + + padded_pwd = struct.pack( + "%ds" % MAX_PASSWORD_LENGTH, password.encode("utf-8").strip() + ) + + m = hashlib.md5() + m.update(challenge) + m.update(padded_pwd) + + encoded_pwd = m.digest() + if b"\x00" in encoded_pwd: + encoded_pwd_array = bytearray(encoded_pwd) + encoded_pwd = bytes(encoded_pwd_array.replace(b"\0", b"\1")) + request["digest"] = base64.encodebytes(encoded_pwd).strip().decode("utf-8") + + request[__NEXT_OPERATION__] = self.AUTH_CLIENT_AUTH_RESPONSE + return request + + def native_auth_client_response(self, request): + throw_if_request_message_is_missing_key( + request, ["user_name", "zone_name", "digest"] + ) + + server_req = request.copy() + server_req[__NEXT_OPERATION__] = self.AUTH_AGENT_AUTH_RESPONSE + resp = _auth_api_request(self.conn, server_req) + + self.loggedIn = 1 + resp[__NEXT_OPERATION__] = __FLOW_COMPLETE__ + return resp diff --git a/irods/auth/pam.py b/irods/auth/pam.py index 652f0063b..2c65eb211 100644 --- a/irods/auth/pam.py +++ b/irods/auth/pam.py @@ -2,13 +2,12 @@ class PamLoginException(Exception): pass -def login(conn): - if conn.server_version >= (4, 3): - raise PamLoginException( - 'PAM logins in iRODS 4.3+ require a scheme of "pam_password"' - ) - conn._login_pam() - - -# Pattern for when you need to import from sibling plugins: -from .native import login as native_login +def login(conn, **opts): + msg = ( + "In iRODS 4.3+, PAM logins use the new authentication plugin framework by default, which " + "requires the authentication scheme be set to 'pam_password' rather than simply 'pam'. " + "Users may choose legacy authentication " + "by setting legacy_auth.force_legacy_auth to True in the client configuration; however, be advised " + "that the legacy code path will be removed in a future release of iRODS." + ) + raise PamLoginException(msg) diff --git a/irods/auth/pam_password.py b/irods/auth/pam_password.py index 59e722f0e..263a72059 100644 --- a/irods/auth/pam_password.py +++ b/irods/auth/pam_password.py @@ -1,9 +1,172 @@ -def login(conn): - conn._login_pam() +import getpass +import logging +import ssl +import sys +from . import ( + __NEXT_OPERATION__, + __FLOW_COMPLETE__, + AuthStorage, + authentication_base, + _auth_api_request, + throw_if_request_message_is_missing_key, + STORE_PASSWORD_IN_MEMORY, + CLIENT_GET_REQUEST_RESULT, + FORCE_PASSWORD_PROMPT, +) +from .native import _authenticate_native -# # in the future we might need cross-plugin calls: -# native_login(conn) # see below for import +AUTH_TTL_KEY = "a_ttl" -# Pattern for when you need to import from sibling plugins: -from .native import login as native_login + +def login(conn, **extra_opt): + context_opt = { + "user_name": conn.account.proxy_user, + "zone_name": conn.account.proxy_zone, + } + context_opt.update(extra_opt) + _authenticate_pam_password(conn, req=context_opt) + + +_scheme = "pam_password" + + +_logger = logging.getLogger(__name__) + + +def _authenticate_pam_password(conn, req): + """The implementation for the client side of a pam_password scheme authentication flow. + It is called by login(), the external-facing hook. + Follow the pattern set in the original iRODS (native) plugin. + """ + _logger.debug("----------- %s (begin)", _scheme) + + # Next, create and persist a "depot" object over the whole of the authentication + # exchange with the iRODS server. + # + # This is done as a means of sending password information to the native phase + # of authentication (with an appropriate token generated by the server as a + # native password input). This must be done in a way that preserves the + # current environment (by refraining from writing to .irodsA) in the event + # that the authentication is happening without the iCommands-like practice of + # using client env/auth files. + + _ = AuthStorage.create_temp_pw_storage(conn) + + _pam_password_ClientAuthState(conn, scheme=_scheme).authenticate_client( + initial_request=req + ) + + _logger.debug("----------- %s (end)", _scheme) + + +def _get_pam_password_from_stdin( + file_like_object=None, prompt="Enter your current PAM password: " +): + try: + if file_like_object: + if not getattr(file_like_object, "readline", None): + msg = ( + "The file_like_object, if provided, must have a 'readline' method." + ) + raise RuntimeError(msg) + sys.stdin = file_like_object + if sys.stdin.isatty(): + return getpass.getpass(prompt) + else: + return sys.stdin.readline().strip() + finally: + sys.stdin = sys.__stdin__ + + +AUTH_PASSWORD_KEY = "a_pw" +ENSURE_SSL_IS_ACTIVE = "ensure_ssl_is_active" + + +class _pam_password_ClientAuthState(authentication_base): + + # Client define + AUTH_CLIENT_AUTH_REQUEST = "pam_password_auth_client_request" + + # Server define + AUTH_AGENT_AUTH_REQUEST = "auth_agent_auth_request" + + def __init__(self, *_, **_kw): + super().__init__(*_, **_kw) + self.check_ssl = True + self._list_for_request_result_return = None + + def auth_client_start(self, request): + + # This list reference is popped and cached for the purpose of returning the request_result value + # to the caller upon request. + self._list_for_request_result_return = request.pop( + CLIENT_GET_REQUEST_RESULT, False + ) + + ensure_ssl = request.pop(ENSURE_SSL_IS_ACTIVE, None) + if ensure_ssl is not None: + self.check_ssl = ensure_ssl + + if self.check_ssl: + if not isinstance(self.conn.socket, ssl.SSLSocket): + msg = "pam_password auth scheme requires secure communications (TLS/SSL) with the server." + raise RuntimeError(msg) + + resp = request.copy() + + password_input_obj = resp.pop(FORCE_PASSWORD_PROMPT, None) + + if password_input_obj: + if isinstance(password_input_obj, (int, bool)): + password_input_obj = None + # Like with the C++ plugin, we offer the user a chance to enter a password. + resp[AUTH_PASSWORD_KEY] = _get_pam_password_from_stdin( + file_like_object=password_input_obj + ) + else: + # Password from .irodsA in environment. + if self.conn.account._auth_file: + resp[__NEXT_OPERATION__] = self.perform_native_auth + return resp + + # Password in cleartext form, fed in via iRODSSession constructor parameter. + resp[AUTH_PASSWORD_KEY] = self.conn.account.password or "" + + resp[__NEXT_OPERATION__] = self.AUTH_CLIENT_AUTH_REQUEST + return resp + + def pam_password_auth_client_request(self, request): + server_req = request.copy() + server_req[__NEXT_OPERATION__] = self.AUTH_AGENT_AUTH_REQUEST + + resp = _auth_api_request(self.conn, server_req) + throw_if_request_message_is_missing_key(resp, {"request_result"}) + + depot = AuthStorage.get_temp_pw_storage(self.conn) + if depot: + if resp.get(STORE_PASSWORD_IN_MEMORY, None): + # Prevent use of an .irodsA to store an encoded password. + depot.use_client_auth_file(None) + depot.store_pw(resp["request_result"]) + else: + msg = "auth storage object was either not set, or allowed to expire prematurely." + raise RuntimeError(msg) + + if isinstance(self._list_for_request_result_return, list): + self._list_for_request_result_return[:] = (resp["request_result"],) + + resp[__NEXT_OPERATION__] = self.perform_native_auth + return resp + + def pam_password_auth_client_perform_native_auth(self, request): + resp = request.copy() + resp.pop(AUTH_PASSWORD_KEY, None) + + _authenticate_native(self.conn, request) + + resp["next_operation"] = __FLOW_COMPLETE__ + self.loggedIn = 1 + return resp + + perform_native_auth = pam_password_auth_client_perform_native_auth diff --git a/irods/client_configuration/__init__.py b/irods/client_configuration/__init__.py index 4b2d3edb6..e866dd772 100644 --- a/irods/client_configuration/__init__.py +++ b/irods/client_configuration/__init__.py @@ -102,7 +102,7 @@ def __init__(self): class LegacyAuth(iRODSConfiguration): - __slots__ = ("pam",) + __slots__ = ("pam", "force_legacy_auth") class Pam(iRODSConfiguration): __slots__ = ( @@ -122,6 +122,7 @@ def __init__(self): def __init__(self): self.pam = self.Pam() + self.force_legacy_auth = False legacy_auth = LegacyAuth() diff --git a/irods/client_init.py b/irods/client_init.py index 22f7581a3..5d44a074a 100755 --- a/irods/client_init.py +++ b/irods/client_init.py @@ -1,10 +1,7 @@ #!/usr/bin/env python3 import contextlib -import getpass import os -import sys -import textwrap from irods import env_filename_from_keyword_args, derived_auth_filename import irods.client_configuration as cfg @@ -41,7 +38,7 @@ def _write_encoded_auth_value(auth_file, encode_input, overwrite): irodsA.write(obf.encode(encode_input)) -def write_native_credentials_to_secrets_file(password, overwrite=True, **kw): +def write_native_irodsA_file(password, overwrite=True, **kw): """Write the credentials to an .irodsA file that will enable logging in with native authentication using the given cleartext password. @@ -53,20 +50,64 @@ def write_native_credentials_to_secrets_file(password, overwrite=True, **kw): _write_encoded_auth_value(auth_file, password, overwrite) -def write_pam_credentials_to_secrets_file(password, overwrite=True, **kw): +# Reverse compatibility. +write_native_credentials_to_secrets_file = write_native_irodsA_file + + +def write_pam_irodsA_file(password, overwrite=True, ttl="", **kw): + """Facility for writing authentication secrets file. Designed to be useable for iRODS 4.3(+) and 4.2(-).""" + import irods.auth.pam_password + from irods.session import iRODSSession + import io + + ses = kw.pop("_session", None) or h.make_session(**kw) + if ( + ses._server_version(iRODSSession.GET_SERVER_VERSION_WITHOUT_AUTH) < (4, 3) + or cfg.legacy_auth.force_legacy_auth + ): + return write_pam_credentials_to_secrets_file( + password, overwrite=overwrite, ttl=ttl, _session=ses + ) + + auth_file = ses.pool.account.derived_auth_file + if not auth_file: + msg = "Auth file could not be written because no iRODS client environment was found." + raise RuntimeError(msg) + if ttl: + ses.set_auth_option_for_scheme( + "pam_password", irods.auth.pam_password.AUTH_TTL_KEY, ttl + ) + ses.set_auth_option_for_scheme( + "pam_password", irods.auth.FORCE_PASSWORD_PROMPT, io.StringIO(password) + ) + ses.set_auth_option_for_scheme( + "pam_password", irods.auth.STORE_PASSWORD_IN_MEMORY, True + ) + L = [] + ses.set_auth_option_for_scheme( + "pam_password", irods.auth.CLIENT_GET_REQUEST_RESULT, L + ) + with ses.pool.get_connection() as _: + _write_encoded_auth_value(auth_file, L[0], overwrite) + + +def write_pam_credentials_to_secrets_file(password, overwrite=True, ttl="", **kw): """Write the credentials to an .irodsA file that will enable logging in with PAM authentication using the given cleartext password. If overwrite is False, irodsA_already_exists will be raised if an .irodsA is found at the expected path. + + This function should not be used in iRODS 4.3+ unless we are forcing the use of legacy PAM authentication. """ - s = h.make_session() + s = kw.pop("_session", None) or h.make_session(**kw) s.pool.account.password = password to_encode = [] 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), + dict(setting="legacy_auth.pam.time_to_live_in_hours", value=ttl), ] ): to_encode = s.pam_pw_negotiated @@ -74,34 +115,3 @@ def write_pam_credentials_to_secrets_file(password, overwrite=True, **kw): raise RuntimeError(f"Password token was not passed from server.") auth_file = s.pool.account.derived_auth_file _write_encoded_auth_value(auth_file, to_encode[0], overwrite) - - -if __name__ == "__main__": - extra_help = textwrap.dedent( - """ - This Python module also functions as a script to produce a "secrets" (i.e. encoded password) file. - Similar to iinit in this capacity, if the environment - and where applicable, the PAM - configuration for both system and user - is already set up in every other regard, this program - will generate the secrets file with appropriate permissions and in the normal location, usually: - - ~/.irods/.irodsA - - The user will be interactively prompted to enter their cleartext password. - """ - ) - - vector = { - "pam_password": write_pam_credentials_to_secrets_file, - "native": write_native_credentials_to_secrets_file, - } - - if len(sys.argv) != 2: - print("{}\nUsage: {} AUTH_SCHEME".format(extra_help, sys.argv[0])) - print(" AUTH_SCHEME:") - for x in vector: - print(" {}".format(x)) - sys.exit(1) - elif 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 66467760a..437f024db 100644 --- a/irods/connection.py +++ b/irods/connection.py @@ -6,7 +6,6 @@ import ssl import datetime import errno -import irods.auth import irods.password_obfuscation as obf from irods import MAX_NAME_LEN from ast import literal_eval as safe_eval @@ -78,45 +77,59 @@ def __init__(self, pool, account): self.pool = pool self.socket = None self.account = account + self.auth_options = {} self._client_signature = None self._server_version = self._connect() self._disconnected = False - scheme = self.account._original_authentication_scheme if self.pool and not self.pool._need_auth: return - # These variables are just useful diagnostics. The login_XYZ() methods should fail by - # raising exceptions if they encounter authentication errors. - auth_module = auth_type = "" - - if self.server_version >= (4, 3, 0): - auth_module = None - # use client side "plugin" module: irods.auth. - irods.auth.load_plugins(subset=[scheme]) - auth_module = getattr(irods.auth, scheme, None) - if auth_module: - auth_module.login(self) - auth_type = auth_module.__name__ - else: - # use legacy (iRODS pre-4.3 style) authentication - auth_type = scheme - if scheme == NATIVE_AUTH_SCHEME: - self._login_native() - elif scheme == GSI_AUTH_SCHEME: - self.client_ctx = None - self._login_gsi() - elif scheme == PAM_AUTH_SCHEME: - self._login_pam() - else: - auth_type = None + try: + scheme = self.account._original_authentication_scheme + + ses = self.pool.session_ref() + if ses: + ses.resolve_auth_options(scheme, conn=self) + + # These variables are just useful diagnostics. The login_XYZ() methods should fail by + # raising exceptions if they encounter authentication errors. + auth_module = auth_type = "" - if not auth_type: - msg = f"Authentication failed: scheme = {scheme!r}, auth_type = {auth_type!r}, auth_module = {auth_module!r}, " - raise ValueError(msg) + import irods.client_configuration as cfg - self.create_time = datetime.datetime.now() - self.last_used_time = self.create_time + if ( + self.server_version >= (4, 3, 0) + and not cfg.legacy_auth.force_legacy_auth + ): + import irods.auth + + auth_module = None + # use client side "plugin" module: irods.auth. + irods.auth.load_plugins(subset=[scheme]) + auth_module = getattr(irods.auth, scheme, None) + if auth_module: + auth_module.login(self, **self.auth_options) + auth_type = auth_module.__name__ + else: + # use legacy (iRODS pre-4.3 style) authentication + auth_type = scheme + if scheme == NATIVE_AUTH_SCHEME: + self._login_native() + elif scheme == GSI_AUTH_SCHEME: + self.client_ctx = None + self._login_gsi() + elif scheme in PAM_AUTH_SCHEMES: + self._login_pam() + else: + auth_type = None + + if not auth_type: + msg = f"Authentication failed: scheme = {scheme!r}, auth_type = {auth_type!r}, auth_module = {auth_module!r}, " + raise ValueError(msg) + finally: + self.create_time = datetime.datetime.now() + self.last_used_time = self.create_time @property def server_version(self): @@ -686,6 +699,7 @@ def _login_native(self, password=None): ) self.send(pwd_request) self.recv() + logger.info("Native authorization validated (in legacy auth).") def write_file(self, desc, string): message_body = OpenedDataObjRequest( diff --git a/irods/message/__init__.py b/irods/message/__init__.py index 7a8321854..d3c1bceff 100644 --- a/irods/message/__init__.py +++ b/irods/message/__init__.py @@ -772,6 +772,7 @@ class MetadataRequest(Message): def __init__(self, *args, **metadata_opts): super(MetadataRequest, self).__init__() + # Python3 does not have types.NoneType NoneType = type(None) def field_name(i): diff --git a/irods/pool.py b/irods/pool.py index 63b0f9321..3d58e6f41 100644 --- a/irods/pool.py +++ b/irods/pool.py @@ -46,7 +46,7 @@ def __init__( 'application_name' specifies the application name as it should appear in an 'ips' listing. """ - self.session_ref = weakref.ref(session) if session is not None else lambda: None + self.set_session_ref(session) self._thread_local = threading.local() self.account = account self._lock = threading.RLock() diff --git a/irods/prc_write_irodsA.py b/irods/prc_write_irodsA.py new file mode 100644 index 000000000..4cb2b6b72 --- /dev/null +++ b/irods/prc_write_irodsA.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 + +import getopt +import textwrap +import sys + +from irods.auth.pam_password import _get_pam_password_from_stdin as get_password +from irods.client_init import write_pam_irodsA_file, write_native_irodsA_file + +if __name__ == "__main__": + extra_help = textwrap.dedent( + """ + This Python module also functions as a script to produce a "secrets" (i.e. encoded password) file. + Similar to iinit in this capacity, if the environment - and where applicable, the PAM + configuration for both system and user - is already set up in every other regard, this program + will generate the secrets file with appropriate permissions and in the normal location, usually: + + ~/.irods/.irodsA + + The user will be interactively prompted to enter their cleartext password. + """ + ) + + vector = {"pam_password": write_pam_irodsA_file, "native": write_native_irodsA_file} + opts, args = getopt.getopt(sys.argv[1:], "hi:", ["ttl=", "help"]) + optD = dict(opts) + help_selected = {*optD} & {"-h", "--help"} + if len(args) != 1 or help_selected: + print( + "{}\nUsage: {} [-i STREAM| -h | --help | --ttl HOURS] AUTH_SCHEME".format( + extra_help, sys.argv[0] + ) + ) + print(" Choices for AUTH_SCHEME are:") + for x in vector: + print(" {}".format(x)) + print( + " STREAM is the name of a file containing a password. Alternatively, a hyphen('-') is used to\n" + " indicate that the password may be read from stdin." + ) + sys.exit(0 if help_selected else 1) + scheme = args[0] + if scheme in vector: + options = {} + inp_stream = optD.get("-i", None) + if "--ttl" in optD: + options["ttl"] = optD["--ttl"] + pw = get_password( + sys.stdin if inp_stream in ("-", None) else open(inp_stream, "r"), + prompt=f"Enter current password for scheme {scheme!r}: ", + ) + vector[scheme](pw, **options) + else: + print("did not recognize authentication scheme argument", file=sys.stderr) diff --git a/irods/session.py b/irods/session.py index ef9324206..64cceb86c 100644 --- a/irods/session.py +++ b/irods/session.py @@ -1,6 +1,5 @@ import ast import atexit -import contextlib import copy import errno import json @@ -9,11 +8,13 @@ import os import threading import weakref +import irods.auth from irods.query import Query from irods.genquery2 import GenQuery2 from irods.pool import Pool from irods.account import iRODSAccount from irods.api_number import api_number +import irods.client_configuration as client_config from irods.manager.collection_manager import CollectionManager from irods.manager.data_object_manager import DataObjectManager from irods.manager.metadata_manager import MetadataManager @@ -179,6 +180,13 @@ def __init__(self, configure=True, auto_cleanup=True, **kwargs): self.ticket__ = "" # A mapping for each connection - holds whether the session's assigned ticket has been applied. self.ticket_applied = weakref.WeakKeyDictionary() + + self.auth_options_by_scheme = { + "pam_password": { + irods.auth.CLIENT_GET_REQUEST_RESULT: (lambda sess, conn: []) + } + } + if auto_cleanup: _weakly_reference(self) @@ -195,6 +203,18 @@ def __del__(self): if self.pool is not None: self.cleanup() + def resolve_auth_options(self, scheme, conn): + for key, value in self.auth_options_by_scheme.setdefault(scheme, {}).items(): + if callable(value): + value = value(self, conn) + conn.auth_options[key] = value + + def set_auth_option_for_scheme(self, scheme, key, value_or_factory_function): + entry = self.auth_options_by_scheme.setdefault(scheme, {}) + old_key = entry.get(key) + entry[key] = value_or_factory_function + return old_key + def clone(self, **kwargs): other = copy.copy(self) other.pool = None @@ -411,12 +431,26 @@ def client_hints(self): @property def pam_pw_negotiated(self): - self.pool.account.store_pw = [] - conn = self.pool.get_connection() - pw = getattr(self.pool.account, "store_pw", []) - delattr(self.pool.account, "store_pw") - conn.release() - return pw + old_setting = _dummy = object() + try: + self.pool.account.store_pw = box = [] + if ( + self.server_version_without_auth() >= (4, 3) + and not client_config.legacy_auth.force_legacy_auth + ): + old_setting = self.set_auth_option_for_scheme( + "pam_password", irods.auth.CLIENT_GET_REQUEST_RESULT, box + ) + conn = self.pool.get_connection() + pw = getattr(self.pool.account, "store_pw", []) + delattr(self.pool.account, "store_pw") + conn.release() + return pw + finally: + if old_setting is not _dummy: + self.set_auth_option_for_scheme( + "pam_password", irods.auth.CLIENT_GET_REQUEST_RESULT, old_setting + ) @property def default_resource(self): diff --git a/irods/test/connection_test.py b/irods/test/connection_test.py index 6d785ab9e..35f71f052 100644 --- a/irods/test/connection_test.py +++ b/irods/test/connection_test.py @@ -1,7 +1,10 @@ #! /usr/bin/env python +import io +import logging import numbers import os +import re import sys import tempfile import unittest @@ -234,6 +237,22 @@ def test_assigning_session_connection_timeout__issue_569(self): sess, old_timeout ) + def test_legacy_auth_used_with_force_legacy_auth_configuration__issue_499(self): + import irods.client_configuration as config + + with config.loadlines( + entries=[dict(setting="legacy_auth.force_legacy_auth", value=True)] + ): + stream = io.StringIO() + logger = logging.getLogger("irods.connection") + with helpers.enableLogging( + logger, logging.StreamHandler, (stream,), level_=logging.INFO + ): + with temp_setter(logger, "propagate", False): + helpers.make_session().collections.get("/") + regex = re.compile("^.*Native auth.*(in legacy auth).*$", re.MULTILINE) + self.assertTrue(regex.search(stream.getvalue())) + if __name__ == "__main__": # let the tests find the parent irods lib diff --git a/irods/test/login_auth_test_must_run_manually.py b/irods/test/login_auth_test_must_run_manually.py index b02d8351e..b02ad1b6c 100644 --- a/irods/test/login_auth_test_must_run_manually.py +++ b/irods/test/login_auth_test_must_run_manually.py @@ -101,8 +101,32 @@ def client_env_keys_from_admin_env(user_name, auth_scheme=""): return cli_env +# For testing only! +# Note pam_password_in_plaintext* functions are for test only as they will allow transmitting passwords on a potentially +# interceptible, unencrypted channel. + + @contextlib.contextmanager -def pam_password_in_plaintext(allow=True): +def pam_password_in_plaintext_4_3(allow=True): + import irods.helpers + from irods.auth.pam_password import ENSURE_SSL_IS_ACTIVE + + # We'll temporarily replace the original iRODSSession constructor with a new version which changes pam_password options + # to allow pam password SSL + old_init = iRODSSession.__init__ + + def new_init(self, *arg, **kw): + old_init(self, *arg, **kw) + self.set_auth_option_for_scheme( + "pam_password", ENSURE_SSL_IS_ACTIVE, not (allow) + ) + + with irods.helpers.temporarily_assign_attribute(iRODSSession, "__init__", new_init): + yield + + +@contextlib.contextmanager +def pam_password_in_plaintext_4_2(allow=True): saved = bool(Connection.DISALLOWING_PAM_PLAINTEXT) try: Connection.DISALLOWING_PAM_PLAINTEXT = not (allow) @@ -111,6 +135,16 @@ def pam_password_in_plaintext(allow=True): Connection.DISALLOWING_PAM_PLAINTEXT = saved +@contextlib.contextmanager +def pam_password_in_plaintext(allow=True, nop=False): + if nop: + yield + return + with pam_password_in_plaintext_4_2(allow=allow): + with pam_password_in_plaintext_4_3(allow=allow): + yield + + class TestLogins(unittest.TestCase): """ Ideally, these tests should move into CI, but that would require the server @@ -213,8 +247,10 @@ def create_env_dirs(self): @classmethod def setUpClass(cls): + import irods.client_configuration as cfg + cls.admin = helpers.make_session() - if cls.admin.server_version >= (4, 3): + if cls.admin.server_version >= (4, 3) and not cfg.legacy_auth.force_legacy_auth: cls.PAM_SCHEME_STRING = cls.user_auth_envs[".irods.pam"]["AUTH"] = ( "pam_password" ) @@ -292,12 +328,15 @@ def tst0( json_file_update( env_file, keys_to_delete=remove, **cli_env_extras ) - session = iRODSSession(irods_env_file=env_file) - with open(env_file) as f: - out = json.load(f) - self.validate_session(session, verbose=verbosity, ssl=ssl_opt) - session.cleanup() - out["ARGS"] = "no" + with pam_password_in_plaintext(nop=ssl_opt): + session = iRODSSession(irods_env_file=env_file) + with open(env_file) as f: + out = json.load(f) + self.validate_session( + session, verbose=verbosity, ssl=ssl_opt + ) + session.cleanup() + out["ARGS"] = "no" else: session_options = {} if auth_opt: @@ -316,18 +355,19 @@ def tst0( lookup = self.user_auth_envs[ ".irods." + ("native" if not (_auth_opt) else _auth_opt) ] - session = iRODSSession( - host=gethostname(), - user=lookup["USER"], - zone="tempZone", - password=lookup["PASSWORD"], - port=1247, - **session_options - ) - out = session_options - self.validate_session(session, verbose=verbosity, ssl=ssl_opt) - session.cleanup() - out["ARGS"] = "yes" + with pam_password_in_plaintext(nop=ssl_opt): + session = iRODSSession( + host=gethostname(), + user=lookup["USER"], + zone="tempZone", + password=lookup["PASSWORD"], + port=1247, + **session_options + ) + out = session_options + self.validate_session(session, verbose=verbosity, ssl=ssl_opt) + session.cleanup() + out["ARGS"] = "yes" if verbosity == "": print("--- ssl:", ssl_opt, "/ auth:", repr(auth_opt), "/ env:", env_opt) @@ -339,6 +379,8 @@ def tst0( ) print("---") + return session + # == test defaulting to 'native' def test_01(self): @@ -374,12 +416,14 @@ def test_5(self): def test_6(self): try: - self.tst0(ssl_opt=False, auth_opt="pam", env_opt=False) + ses = self.tst0(ssl_opt=False, auth_opt="pam", env_opt=False) except PlainTextPAMPasswordError: pass else: - # -- no exception raised - self.fail("PlainTextPAMPasswordError should have been raised") + # -- no exception raised (this is expected behavior in 4.3+ with the new authentication framework, + # but for 4.2 and previous, we expect the PlainTextPAMPasswordError to be raised. + if ses.server_version_without_auth() < (4, 3): + self.fail("PlainTextPAMPasswordError should have been raised") def test_7(self): self.tst0(ssl_opt=True, auth_opt="pam", env_opt=True) diff --git a/irods/test/scripts/README.md b/irods/test/scripts/README.md new file mode 100644 index 000000000..0de4707f2 --- /dev/null +++ b/irods/test/scripts/README.md @@ -0,0 +1,17 @@ +Tests to be run with docker container and local iRODS server. +------------------------------------------------------------- + +These test scripts are meant to be run atop the docker test container +now implemented in a development branch but slated for release in a later version +of python-irodsclient. (See issue #502.) + +Each BATS script is designed such that a "main" function is executed to assert +the relevant test outcomes. In effect, this is realized because BATS fails the +test if any individual command in the function fails. + +The BATS tests are designed to be run by the recipe below: + +``` + cd /irods/test/scripts + ../harness/docker_container_driver.sh +``` diff --git a/irods/test/scripts/test001_pam_password_expiration.bats b/irods/test/scripts/test001_pam_password_expiration.bats index cbe62fe5e..71da0c153 100755 --- a/irods/test/scripts/test001_pam_password_expiration.bats +++ b/irods/test/scripts/test001_pam_password_expiration.bats @@ -20,7 +20,7 @@ teardown() test_specific_cleanup } -@test f001 { +@test main { # Define the core Python to be run, basically a minimal code block ensuring that we can authenticate to iRODS # without an exception being raised. @@ -44,9 +44,10 @@ print ('env_auth_scheme=%s' % ses.pool.account._original_authentication_scheme) OUTPUT=$($PYTHON -c "$SCRIPT" 2>&1 >/dev/null || true) grep 'CAT_PASSWORD_EXPIRED' <<<"$OUTPUT" - # Test that the $SCRIPT, when run with proper settings, can successfully reset the password. + # Test that the $SCRIPT, when run in legacy auth mode and with proper settings, can successfully reset the password. OUTPUT=$($PYTHON -c "import irods.client_configuration as cfg +cfg.legacy_auth.force_legacy_auth = True 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 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 index c30f3aa80..cf539853f 100755 --- a/irods/test/scripts/test002_write_native_credentials_to_secrets_file.bats +++ b/irods/test/scripts/test002_write_native_credentials_to_secrets_file.bats @@ -10,7 +10,7 @@ PYTHON=python3 # Run as ubuntu user with sudo; python_irodsclient must be installed (in either ~/.local or a virtualenv) # -@test create_irods_secrets_file { +@test main { rm -fr ~/.irods mkdir ~/.irods @@ -21,7 +21,7 @@ PYTHON=python3 "irods_zone_name":"tempZone" } EOF - $PYTHON -c "import irods.client_init; irods.client_init.write_native_credentials_to_secrets_file('rods')" + $PYTHON -c "import irods.client_init; irods.client_init.write_native_irodsA_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. 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 index 35171ca20..acf9c9594 100755 --- a/irods/test/scripts/test003_write_pam_credentials_to_secrets_file.bats +++ b/irods/test/scripts/test003_write_pam_credentials_to_secrets_file.bats @@ -24,7 +24,7 @@ teardown() test_specific_cleanup } -@test create_secrets_file { +@test main { auth_file=~/.irods/.irodsA CONTENTS1=$(cat $auth_file) @@ -33,7 +33,7 @@ teardown() sudo chpasswd <<<"alice:$ALICES_NEW_PAM_PASSWD" OUTPUT=$($PYTHON -c "import irods.client_init; try: - irods.client_init.write_pam_credentials_to_secrets_file('$ALICES_NEW_PAM_PASSWD', overwrite = False) + irods.client_init.write_pam_irodsA_file('$ALICES_NEW_PAM_PASSWD', overwrite = False) except irods.client_init.irodsA_already_exists: print ('CANNOT OVERWRITE') ") @@ -43,7 +43,7 @@ except irods.client_init.irodsA_already_exists: [ -n "$CONTENTS1" -a "$CONTENTS1" = "$CONTENTS2" ] # Now delete the already existing irodsA and repeat without negating overwrite. - $PYTHON -c "import irods.client_init; irods.client_init.write_pam_credentials_to_secrets_file('$ALICES_NEW_PAM_PASSWD')" + $PYTHON -c "import irods.client_init; irods.client_init.write_pam_irodsA_file('$ALICES_NEW_PAM_PASSWD')" CONTENTS3=$(cat $auth_file) [ "$CONTENTS2" != "$CONTENTS3" ] 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 index c04687ffd..7bac8129e 100755 --- 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 @@ -26,7 +26,10 @@ teardown() test_specific_cleanup } -@test f001 { +@test main { + + echo legacy_auth.force_legacy_auth True > ~/.python_irodsclient + export PYTHON_IRODSCLIENT_CONFIGURATION_PATH="" local AUTH_FILE=~/.irods/.irodsA diff --git a/irods/test/scripts/test005_test_special_characters_in_passwords.bats b/irods/test/scripts/test005_test_special_characters_in_pam_passwords.bats similarity index 94% rename from irods/test/scripts/test005_test_special_characters_in_passwords.bats rename to irods/test/scripts/test005_test_special_characters_in_pam_passwords.bats index 5b03eb3a0..bc86d2b00 100755 --- a/irods/test/scripts/test005_test_special_characters_in_passwords.bats +++ b/irods/test/scripts/test005_test_special_characters_in_pam_passwords.bats @@ -23,7 +23,11 @@ teardown() test_specific_cleanup } -@test create_secrets_file { +@test main { + + echo legacy_auth.force_legacy_auth True > ~/.python_irodsclient + export PYTHON_IRODSCLIENT_CONFIGURATION_PATH="" + # Old .irodsA is already created, so we delete it and alter the pam password. sudo chpasswd <<<"alice:$ALICES_NEW_PAM_PASSWD" local logfile i diff --git a/irods/test/scripts/test006_connection_timeout_on_ssl_socket.bats b/irods/test/scripts/test006_connection_timeout_on_ssl_socket.bats index 7718dcaa2..349e5bdd5 100755 --- a/irods/test/scripts/test006_connection_timeout_on_ssl_socket.bats +++ b/irods/test/scripts/test006_connection_timeout_on_ssl_socket.bats @@ -24,7 +24,7 @@ teardown() test_specific_cleanup } -@test f001 { +@test main { # Create and put into iRODS a large file which will take a significant fraction of a # second (>1e-5 on any CPU + Network combination) to checksum. diff --git a/irods/test/scripts/test007_pam_features_in_new_auth_framework.bats b/irods/test/scripts/test007_pam_features_in_new_auth_framework.bats new file mode 100755 index 000000000..d3b634f98 --- /dev/null +++ b/irods/test/scripts/test007_pam_features_in_new_auth_framework.bats @@ -0,0 +1,111 @@ +#!/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_ORIGINAL_PAM_PASSWORD=test123 +ALICES_NEW_PASSWORD=test_1234 + +setup() +{ + export SKIP_IINIT_FOR_PASSWORD=1 + setup_pam_login_for_alice "$ALICES_ORIGINAL_PAM_PASSWORD" + unset SKIP_IINIT_FOR_PASSWORD +} + +teardown() +{ + finalize_pam_login_for_alice + test_specific_cleanup +} + +@test main { + + local AUTH_FILE=~/.irods/.irodsA + + # Test assertion: No pre-existing authentication file. + [ ! -e $AUTH_FILE ] + + python -c "import sys, irods.client_init; \ + pw=sys.stdin.readline().strip(); irods.client_init.write_pam_irodsA_file(pw)" \ + <<<"$ALICES_ORIGINAL_PAM_PASSWORD" + [ -e $AUTH_FILE ] + + # === test we can log in. + + local SCRIPT=" +import irods.test.helpers as h +ses = h.make_session() +try: + ses.collections.get(h.home_collection(ses)) +except: + print('authenticate error.') + exit(120) +print ('env_auth_scheme=%s' % ses.pool.account._original_authentication_scheme) +" + # Test Python script authenticates. + OUTPUT=$($PYTHON -c "$SCRIPT") + [[ $OUTPUT = "env_auth_scheme=pam"* ]] + + mc1=$(mtime_and_content $AUTH_FILE) + sleep 2 + + # === test we can log in with direct way, not relying on client environment. + SCRIPT_DIRECT="import irods.helpers, irods.session +SSL_OPTIONS = { + 'irods_client_server_policy': 'CS_NEG_REQUIRE', + 'irods_client_server_negotiation': 'request_server_negotiation', + 'irods_ssl_ca_certificate_file': '/etc/irods/ssl/irods.crt', + 'irods_ssl_verify_server': 'cert', + 'irods_encryption_key_size': 16, + 'irods_encryption_salt_size': 8, + 'irods_encryption_num_hash_rounds': 16, + 'irods_encryption_algorithm': 'AES-256-CBC' +} +ses = irods.session.iRODSSession(user = 'alice', password = '$ALICES_ORIGINAL_PAM_PASSWORD', + host = 'localhost', port = 1247, zone = 'tempZone', + authentication_scheme = 'pam_password', **SSL_OPTIONS) +ses.collections.get(irods.helpers.home_collection(ses)) +print ('env_auth_scheme=%s' % ses.pool.account._original_authentication_scheme)" + + OUTPUT=$($PYTHON -c "$SCRIPT_DIRECT") + [[ $OUTPUT = "env_auth_scheme=pam"* ]] + + # Ensure .irodsA has not been touched by authentication with inline parameter-passing. + mc2=$(mtime_and_content $AUTH_FILE) + + [ "$mc1" == "$mc2" ] + + # Set a new password for alice and use prc_write_irodsA script to alter with chosen + # time-to-live setting of 2000 seconds. + sudo chpasswd <<< "alice:$ALICES_NEW_PASSWORD" + prc_write_irodsA.py -i - --ttl=1 pam_password <<<"$ALICES_NEW_PASSWORD" + + # Check we're able to login with the new password and correspondingly new .irodsA + OUTPUT=$($PYTHON -c "$SCRIPT") + [[ $OUTPUT = "env_auth_scheme=pam"* ]] + + age_out_pam_password alice 2000 + + OUTPUT=$($PYTHON -c "$SCRIPT") + [[ $OUTPUT = "env_auth_scheme=pam"* ]] + + # Check that pam password expires after the specified interval is exhausted. + age_out_pam_password alice 2000 + if ! ils 2>/tmp/stderr ; then grep CAT_PASSWORD_EXPIRED /tmp/stderr ; fi + + # -- Test --ttl option and prc_write_irodsA.py in the pam_password scheme. + prc_write_irodsA.py --ttl=120 pam_password <<<"$ALICES_NEW_PASSWORD" + age_out_pam_password alice $((119*3600)) + OUTPUT=$($PYTHON -c "$SCRIPT") + [[ $OUTPUT = "env_auth_scheme=pam"* ]] + age_out_pam_password alice $((2*3600)) + OUTPUT=$($PYTHON -c "$SCRIPT" 2>&1 || :) +} diff --git a/irods/test/scripts/test008_prc_write_irodsA_utility_in_native_mode.bats b/irods/test/scripts/test008_prc_write_irodsA_utility_in_native_mode.bats new file mode 100755 index 000000000..23aecd8ce --- /dev/null +++ b/irods/test/scripts/test008_prc_write_irodsA_utility_in_native_mode.bats @@ -0,0 +1,56 @@ +#!/usr/bin/env bats +# +# Test creation of .irodsA for iRODS native 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_irodsA_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" ] + + # New starting point with no .irodsA, assert iCommands not working + rm -fr ~/.irods/.irodsA + if ! ils 2>/tmp/stderr ; then + true + else + echo 2>/tmp/stderr "ils should fail when no .irodsA is present" + exit 2 + fi + + # Write another .irodsA + prc_write_irodsA.py native <<<"rods" + + # Verify new .irodsA for both iCommands and PRC use. + ils >/tmp/stdout + OUTPUT=$($PYTHON -c "$SCRIPT") + [ $OUTPUT = "env_auth_scheme=native" ] + +} diff --git a/irods/test/scripts/test009_test_special_characters_in_pam_passwords_auth_framework.bats b/irods/test/scripts/test009_test_special_characters_in_pam_passwords_auth_framework.bats new file mode 100755 index 000000000..d33f6adc3 --- /dev/null +++ b/irods/test/scripts/test009_test_special_characters_in_pam_passwords_auth_framework.bats @@ -0,0 +1,45 @@ +#!/usr/bin/env bats + +# Test creation and use of PAM password authentication info in .irodsA when the password itself contains +# special characters (those known to have created issues in the past). + +. "$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_NEW_PAM_PASSWD="new_&@;=_pass" + +setup() +{ + export SKIP_IINIT_FOR_PASSWORD=1 + setup_pam_login_for_alice "$ALICES_OLD_PAM_PASSWD" + unset SKIP_IINIT_FOR_PASSWORD +} + +teardown() +{ + finalize_pam_login_for_alice + test_specific_cleanup +} + +@test main { + irods_server_version ge 4.3.0 || { + skip "Requires at least iRODS server 4.3.0" + return + } + # Old .irodsA is already created, so we delete it and alter the pam password. + sudo chpasswd <<<"alice:$ALICES_NEW_PAM_PASSWD" + prc_write_irodsA.py pam_password <<<"$ALICES_NEW_PAM_PASSWD" + + 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/setup.py b/setup.py index 6f095d71e..3b6ec5a47 100644 --- a/setup.py +++ b/setup.py @@ -46,4 +46,5 @@ "defusedxml", ], extras_require={"tests": ["unittest-xml-reporting"]}, # for xmlrunner + scripts=["irods/prc_write_irodsA.py"], )