diff --git a/dummy b/dummy new file mode 100644 index 000000000..e69de29bb diff --git a/irods/auth/pam_interactive.py b/irods/auth/pam_interactive.py index 9b53ed068..ac638df8c 100644 --- a/irods/auth/pam_interactive.py +++ b/irods/auth/pam_interactive.py @@ -38,6 +38,7 @@ _logger = logging.getLogger(__name__) +_logger.debug("hello from paminteractive") def login(conn, **extra_opt): """The entry point for the pam_interactive authentication scheme.""" @@ -72,12 +73,17 @@ def auth_client_start(self, request): resp['user_name'] = self.conn.account.proxy_user resp['zone_name'] = self.conn.account.proxy_zone + #TODO check handling of FORCE_PASSWORD_PROMPT - + # This is close to what the C++ plugin (client-side) does # If not forcing a prompt, check for existing credentials (.irodsA) to attempt native auth directly if not resp.get(FORCE_PASSWORD_PROMPT, False): if self.conn.account.password and self.conn.account.derived_auth_file: resp[__NEXT_OPERATION__] = PERFORM_NATIVE_AUTH return resp + # TODO + # iRODS4j removes the passworda property from the response object + # Otherwise, begin the full interactive flow resp[__NEXT_OPERATION__] = AUTH_CLIENT_AUTH_REQUEST return resp @@ -199,6 +205,7 @@ def authenticated(self, request): if not self.depot: raise RuntimeError("auth storage object was either not set, or allowed to expire prematurely.") + # TODO: review (iRODS4j doesn't do this). if request.get(STORE_PASSWORD_IN_MEMORY): self.depot.use_client_auth_file(None) @@ -230,7 +237,7 @@ def native_auth(self, request): def next(self, request): prompt = request.get("msg", {}).get("prompt", "") if prompt: - _logger.info("Server prompt: %s", prompt) + _logger.debug("Server prompt: %s", prompt) server_req = request.copy() self._patch_state(server_req) diff --git a/irods/test/scripts/files_for_test012/pam_clear_token.c b/irods/test/scripts/files_for_test012/pam_clear_token.c new file mode 100644 index 000000000..8590287ce --- /dev/null +++ b/irods/test/scripts/files_for_test012/pam_clear_token.c @@ -0,0 +1,36 @@ +/* +To build, you need the PAM development library. Once installed, +run the following: + + gcc -fPIC -fno-stack-protector -o pam_clear_token.o -c main.c + gcc -shared -o pam_clear_token.so pam_clear_token.o +*/ + +#include +#include +#include + +#include + +PAM_EXTERN int pam_sm_authenticate(pam_handle_t* pamh, int flags, int argc, const char** argv) +{ + (void) flags; + (void) argc; + (void) argv; + + // Clear the current auth token. + pam_set_item(pamh, PAM_AUTHTOK, NULL); + pam_set_item(pamh, PAM_OLDAUTHTOK, NULL); + + return PAM_SUCCESS; +} + +PAM_EXTERN int pam_sm_setcred(pam_handle_t* pamh, int flags, int argc, const char** argv) +{ + (void) pamh; + (void) flags; + (void) argc; + (void) argv; + + return PAM_SUCCESS; +} diff --git a/irods/test/scripts/files_for_test012/pam_interactive b/irods/test/scripts/files_for_test012/pam_interactive new file mode 100644 index 000000000..40a3a8b6e --- /dev/null +++ b/irods/test/scripts/files_for_test012/pam_interactive @@ -0,0 +1,17 @@ +# This file is for testing PAM authentication with iRODS +# using the pam_interactive authentication scheme. + +# Prompt for the first password, from /etc/shadow. +auth required pam_unix.so + +# This is a custom PAM module that clears the success token. Without +# this, the "auth" lines which follow are skipped. +auth required /t012/pam_clear_token.so + +# Prompt for the second password, from the user database file created +# earlier. The use of "crypt=crypt" is required for this to work. It +# tells the module that the passwords are encrypted. +auth required pam_userdb.so db=/t012/pam_userdb crypt=crypt + +# Do the normal user account stuff. +account required pam_unix.so diff --git a/irods/test/scripts/files_for_test012/pam_password b/irods/test/scripts/files_for_test012/pam_password new file mode 100644 index 000000000..44d4f19ff --- /dev/null +++ b/irods/test/scripts/files_for_test012/pam_password @@ -0,0 +1,7 @@ +# This file is for testing PAM authentication with iRODS +# using the pam_password authentication scheme. + +auth required pam_env.so +auth sufficient pam_unix.so +auth requisite pam_succeed_if.so uid >= 500 quiet +auth required pam_deny.so diff --git a/irods/test/scripts/test011_pam_interactive.bats b/irods/test/scripts/test011_pam_interactive.bats new file mode 100755 index 000000000..a721dcbeb --- /dev/null +++ b/irods/test/scripts/test011_pam_interactive.bats @@ -0,0 +1,46 @@ +#!/usr/bin/env bats + +# 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/test_support_functions + +setup() { + [ -f /tmp/test011_flag ] || { + rm -fr ~/.irods + /prc/test_harness/utility/iinit.py host localhost \ + port 1247 \ + zone tempZone \ + user rods \ + password rods \ + + ## Because iRODS 5+ negotiates for SSL automatically: + CLIENT_JSON=~/.irods/irods_environment.json + jq '.irods_client_server_policy="CS_NEG_REFUSE"' >$CLIENT_JSON.$$ <$CLIENT_JSON && \ + mv $CLIENT_JSON.$$ $CLIENT_JSON + + sudo apt install -y irods-auth-plugin-pam-interactive-{client,server} + + setup_pam_login_for_user "rods" alice + + # Tests require only the irods_environment.json + rm -f ~/.irods/.irodsA + + ## Switch over to scheme to be tested. + jq '.irods_authentication_scheme="pam_interactive"' >$CLIENT_JSON.$$ <$CLIENT_JSON && \ + mv $CLIENT_JSON.$$ $CLIENT_JSON + } + touch /tmp/test011_flag +} + +original_test_suite() +{ + local USER="alice" + local PASSWORD="rods" + sudo chpasswd <<<"$USER:$PASSWORD" + python -m unittest irods.test.pam_interactive_test_must_run_manually +} + +@test "original_pam_interactive_tests" { + original_test_suite +} diff --git a/irods/test/scripts/test012_pam_interactive_multistep.bats b/irods/test/scripts/test012_pam_interactive_multistep.bats new file mode 100755 index 000000000..0f4de2f28 --- /dev/null +++ b/irods/test/scripts/test012_pam_interactive_multistep.bats @@ -0,0 +1,152 @@ +#!/usr/bin/env bats + +# 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. + +SKIP_IINIT_FOR_PASSWORD=yes + +. $BATS_TEST_DIRNAME/test_support_functions + +export TESTUSER="john" +export FIRST_PASSWORD="=i;r@o\\d&s" # somerods +export SECOND_PASSWORD="otherrods" +export CLIENT_AUTH_ERROR_EXITCODE=123 + +ssl_hash() { + openssl passwd -6 "$1" +} + +setup() { + [ -f /tmp/test012_flag ] || { + rm -fr ~/.irods + /prc/test_harness/utility/iinit.py host localhost \ + port 1247 \ + zone tempZone \ + user rods \ + password rods \ + + sudo apt update + sudo apt install -y db-util libpam0g-dev jq + + ## Because iRODS 5+ negotiates for SSL automatically: + CLIENT_JSON=~/.irods/irods_environment.json + jq '.irods_client_server_policy="CS_NEG_REFUSE"' >$CLIENT_JSON.$$ <$CLIENT_JSON && \ + mv $CLIENT_JSON.$$ $CLIENT_JSON + + sudo apt install -y irods-auth-plugin-pam-interactive-{client,server} + SERVER_CONFIG=server_config.json + + sudo -s <<-EOF + jq '.plugin_configuration.authentication.pam_interactive = { + "pam_stack_name": "pam_interactive" + }' <"/etc/irods/${SERVER_CONFIG}" >"/tmp/${SERVER_CONFIG}" + cp -rp "/etc/irods/${SERVER_CONFIG}"{,.orig} + mv -f "/tmp/${SERVER_CONFIG}" "/etc/irods/${SERVER_CONFIG}" + EOF + waitsrv() { + while true; do + sleep 5 + ils >& /dev/null && break + done + } + + { sudo kill -HUP `sudo cat /tmp/irods.pid` && waitsrv; } || { + echo "Couldn't properly bounce server after configuration change."; exit 1; } + + setup_pam_login_for_user "${FIRST_PASSWORD}" $TESTUSER + sudo cp $BATS_TEST_DIRNAME/files_for_test012/pam_password /etc/pam.d/irods + sudo cp $BATS_TEST_DIRNAME/files_for_test012/pam_interactive /etc/pam.d/ + sudo mkdir /t012 && sudo gcc -o /t012/pam_clear_token.so -fno-stack-protector -shared -fPIC $BATS_TEST_DIRNAME/files_for_test012/pam_clear_token.c + + # Tests require only the irods_environment.json + rm -f ~/.irods/.irodsA + + ## Switch over to scheme to be tested. + jq '.irods_authentication_scheme="pam_interactive"' >$CLIENT_JSON.$$ <$CLIENT_JSON && \ + mv $CLIENT_JSON.$$ $CLIENT_JSON + } + touch /tmp/test012_flag +} + +encode_2nd_password() { + db_file=/t012/pam_userdb.db + sudo db_load -T -t hash "$db_file" <<<"${TESTUSER}"$'\n'"$(ssl_hash ${1})" + sudo chown root:root "$db_file" + sudo chmod 600 "$db_file" +} + +SCRIPT=" +import getpass +import os + +import irods +from irods.auth import ClientAuthError +from unittest.mock import patch + +def getpass_new_callable(answers=()): + class iterate_answers: + def __init__(self,answers = answers): + self.answers = answers + self.count = 0 + def __call__(self,*_): + count = self.count + self.count += 1 + ans = self.answers[count] + print ('*** giving answer:', ans) + return ans + return lambda : iterate_answers() + +home = None + +pw_count = 0 + +with patch( + 'getpass.getpass', + new_callable=getpass_new_callable(answers=[os.environ['FIRST_PASSWORD'],os.environ['SECOND_PASSWORD']]) +) as m: + try: + sess = irods.helpers.make_session(test_server_version=False) + sess.set_auth_option_for_scheme('pam_interactive', irods.auth.FORCE_PASSWORD_PROMPT, True) + home = sess.collections.get(f'/{sess.zone}/home/{sess.username}') + except ClientAuthError as exc: + # Note: The write to stdout, and the specific exit code, are necessary for the test assertions. + # in test "pam_interactive_test_multistep_with_incorrect_2nd_password" below. + print(f'ERROR: {exc!r}') + exit(int(os.environ['CLIENT_AUTH_ERROR_EXITCODE'])) + finally: + pw_count = m.count + +# Assert both passwords were prompted for. +if pw_count < 2: + print(f'************************ {pw_count = } < 2') + exit(3) + +# Assert home is defined, ie a session was successfully created and used to retrieve a collection object +if home is None: + exit(2) + +username = os.environ['TESTUSER'] + +# Assert home contains the expected username. +if not home.path.endswith(f'/{username}'): + exit(1) +" + +@test "pam_interactive_test_multistep_with_incorrect_2nd_password" { + + # We are using a deliberately munged password. + encode_2nd_password "_${SECOND_PASSWORD}" + local STATUS="" + OUTPUT=$(python -c "$SCRIPT" 2>&1) || STATUS=$? + + # Here, we assert the process's exit and output conform to expectation. We want to + # enforce that the stdout output stream contains the thrown exception name ("ClientAuthError") + # as well as that the process exits with a particular error status. + [ $STATUS = $CLIENT_AUTH_ERROR_EXITCODE ] + [[ $OUTPUT =~ ClientAuthError ]] +} + +@test "pam_interactive_test_multistep_with_correct_2nd_password" { + encode_2nd_password "${SECOND_PASSWORD}" + python -c "$SCRIPT" +} diff --git a/irods/test/scripts/test_support_functions b/irods/test/scripts/test_support_functions index a7e40bfe4..875e53e7c 100644 --- a/irods/test/scripts/test_support_functions +++ b/irods/test/scripts/test_support_functions @@ -120,7 +120,7 @@ _begin_pam_environment_and_password() { echo "$ENV" > ~/.irods/irods_environment.json if [ -n "$1" -a -z "$SKIP_IINIT_FOR_PASSWORD" ]; then - iinit <<<"$1" 2>/tmp/iinit_as_alice.log + iinit ${IINIT_TTL:+--ttl $IINIT_TTL}<<<"$1" 2>/tmp/iinit_as_alice.log fi } diff --git a/pyproject.toml b/pyproject.toml index abd88a899..e07af1717 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -152,7 +152,7 @@ select = [ # flake8-tidy-imports "TID", # flake8-todos - "TD", +#"TD", # flake8-type-checking "TC", # flake8-unused-arguments diff --git a/test_harness/single_node/test_script_parameters b/test_harness/single_node/test_script_parameters index 4b94d58e6..f7b977296 100644 --- a/test_harness/single_node/test_script_parameters +++ b/test_harness/single_node/test_script_parameters @@ -21,6 +21,8 @@ declare -A wrappers=( [test008_prc_write_irodsA_utility_in_native_mode.bats]=../login_auth_test.sh [test009_test_special_characters_in_pam_passwords_auth_framework.bats]=../login_auth_test.sh [test010_issue_362_rogue_chars_in_pam_password.bats]=../login_auth_test.sh + [test011_pam_interactive.bats]=../login_auth_test.sh + [test012_pam_interactive_multistep.bats]=../login_auth_test.sh ) # keys for Image and User refer to the basename after resolution to a wrapper if one is used