python-irodsclient icon indicating copy to clipboard operation
python-irodsclient copied to clipboard

enable client to create PAM credentials auth_file

Open d-w-moore opened this issue 1 year ago • 9 comments

As of v2.0.0, the Python client is capable of renewing the PAM credentials in the client environment secrets file (.irodsA), in the event of e.g. an expired time-to-live or even if it simply did not previously exist

d-w-moore avatar Feb 10 '24 05:02 d-w-moore

Also interested in this change.

IanVermes avatar Feb 20 '24 20:02 IanVermes

The way this issue is framed, it deals with PAM credentials in particular ... so I'm wondering if the community interest exists (or should apply) to the native login case too. If so, we'd need another configuration option to store the native password - to enable PRC's automatic generation of .irodsA for that case as well. @IanVermes @trel any opinion?

d-w-moore avatar May 03 '24 07:05 d-w-moore

you're proposing a configuration option for each login type? rather than having one that both would honor?

can you propose a name for this one (or two) configuration option(s)?

can we get away with having no new user-facing configuration options?

trel avatar May 03 '24 13:05 trel

I was hoping for an official, Pythonic way of emulating the IRODs program iinit when used with a PAM configuration. Ie. mimicking it. If there was official support from this python-irodsclient that would be nice.

Here is how I have replicated the functionality of iinit: ideally I would have liked my iinit_via_python function to call _persist_irods_auth_file with minimal complexity.

However to be able to create a valid .irodsA file I need to read and persist the iRODSSession.pam_pw_negotiated attribute value to be able to use it in later function calls. Unfortunately that attribute on the iRODSSession object is not stable and will be masked or null (see _freeze_global_pam_pw_negotiated function below). I resort to using a global variable (yes - I agree this is horrible and hope to replace it).

Despite this, the .irodsA file this code generates is compliant with the itools my colleagues and team use; which was the goal.

I'm using python-irodsclient = "1.1.5"

import typing as t
import warnings
...
from irods.exception import iRODSException
from irods import password_obfuscation
from irods.session import iRODSSession
...

...
__GLOBAL_PAM_PW_NEGOTIATED = None
...

def iinit_via_python(
    password: str,
    irods_env_file: t.Union[str, Path],
    ssl_ca_certificate_file: t.Union[str, Path],
):
    """
    Execute the iinit command via the irods-python library.
    """

    # Cast the iRODS environment file and SSL CA certificate file to strings.
    irods_env_file = str(irods_env_file)
    ssl_ca_certificate_file = str(ssl_ca_certificate_file)

    # NB: An explict ssl certificate file kwarg will override the value in the
    # irods_env_file
    with get_irods_session(
        password=password,
        irods_env_file=irods_env_file,
        ssl_ca_certificate_file=ssl_ca_certificate_file,
    ) as session:
        token_list = session.pam_pw_negotiated
        logging.info(f"Received iRODS auth token")

        # Compute flags - global variable set when session is created by context
        use_normal_token = isinstance(token_list, list) and len(token_list) > 0
        use_global_token = (
            __GLOBAL_PAM_PW_NEGOTIATED is not None
            and isinstance(__GLOBAL_PAM_PW_NEGOTIATED, list)
            and len(__GLOBAL_PAM_PW_NEGOTIATED) > 0
        )
        # Find and process the token
        if use_normal_token:
            token = token_list[0]
        elif not use_normal_token and use_global_token:
            logging.info(
                "iRODS session pam_pw_negotiated was reset, "
                "using frozen value from global variable"
            )
            token = __GLOBAL_PAM_PW_NEGOTIATED[0]
        else:
            raise ValueError("No token returned from iRODS.")
        encoded_password = password_obfuscation.encode(token)
        _persist_irods_auth_file(encoded_password)


@contextmanager
def get_irods_session(
    password: t.Optional[str] = None,
    irods_env_file: t.Optional[t.Union[str, Path]] = None,
    ssl_ca_certificate_file: t.Optional[t.Union[str, Path]] = None,
) -> t.Generator[iRODSSession, None, None]:
    """
    Get an iRODS session object within a managed context. If the iRODS environment file or SSL CA
    certificate file are not provided, their default locations will be used. If
    the files are not found, an error will be raised.

    Raises:
        MyAppError: If the iRODS session is not authenticated and connected.

    Optional parameters:

    - password: The iRODS password, if absent the session will try to
        authenticate with an existing token.
    - irods_env_file: The path to the iRODS environment file, e.g.
      ~/.irods/irods_environment.json. If absent, the default location will be
      used.
    - ssl_ca_certificate_file: The path to the SSL CA certificate file used. If
      absent, the value in the irods_env_file will be used, or falling back to
      the default location.

    Usage:
    >>> with get_irods_session() as session:
    ...     # Perform operations with the session
    ...     pass
    """
    irods_env_file = _resolve_irods_env_file(irods_env_file)
    ssl_ca_certificate_file = _resolve_ssl_ca_certificate_file(
        irods_env_file, ssl_ca_certificate_file
    )

    # Prepare the session keyword arguments.
    kwargs = {
        "irods_env_file": str(irods_env_file),
        "ssl_context": get_ssl_context(),
        "ssl_ca_certificate_file": str(ssl_ca_certificate_file),
    }
    if password is not None:
        kwargs["password"] = password

    # Create the iRODS session and check if it is connected.
    try:
        session = iRODSSession(**kwargs)
        try:
            _freeze_global_pam_pw_negotiated(session)
            yield session
        finally:
            session.cleanup()
    except iRODSException as err:
        ... # custom handling
        raise
    except Exception as err:
        ... # no special handling
        raise
    return


def _freeze_global_pam_pw_negotiated(session: iRODSSession):
    """
    Why does this exist? Aren't globals horrible? Yes, they are but we have challenges.

    If you perform ANY query on the session object, the pam_pw_negotiated attribute will be reset and empty. So we want
    to create a global variable that will store the value of pam_pw_negotiated so that we can use it later.
    """
    warnings.warn("Not threadsafe! Encapsulate 'iinit_via_python' logic as a class to avoid global var to manage 'pam_pw_negotiated' or see if upstream solved problem", RuntimeWarning)
    global __GLOBAL_PAM_PW_NEGOTIATED
    __GLOBAL_PAM_PW_NEGOTIATED = session.pam_pw_negotiated
    return


def _persist_irods_auth_file(encoded_password: str) -> None:
    # Default iRODs home directory and authentication file paths.
    irods_auth_file = _resolve_irods_auth_file()  # An expanded user path equivalent of ~/.irods/.irodsA
    irods_dir = irods_auth_file.parent

    # Create the iRODS home directory if it does not exist.
    if not irods_dir.exists():
        logging.info(f"Creating iRODS home directory {str(irods_dir)}")
        irods_dir.mkdir(parents=True, exist_ok=True, mode=0o700)

    # Write the token to the iRODS authentication file or raise an error.
    logging.info(f"Persisting iRODS encoded auth token to {str(irods_auth_file)}")
    irods_auth_file.unlink(missing_ok=True)
    with open(irods_auth_file, "w") as auth_file:
        auth_file.write(encoded_password)
    os.chmod(irods_auth_file, 0o600)
    return

IanVermes avatar May 04 '24 16:05 IanVermes

You mention

I'm using python-irodsclient = "1.1.5"

Can you confirm this is still necessary with python-irodsclient = "2.0.1"?

trel avatar May 05 '24 17:05 trel

you're proposing a configuration option for each login type? rather than having one that both would honor?

can you propose a name for this one (or two) configuration option(s)?

can we get away with having no new user-facing configuration options?

This is possibly just a half-baked idea of mine. It's perhaps better to go with a library and/or command that is explicitly iinit-like, which some (notably @IanVermes) have expressed a wish for. (See below). There might be a way for routine login attempts to call into that library from the client's login attempts, and that is the part that would require storing cleartext passwords as user-facing configuration. Although Ian has apparently figured out how to avoid that for PAM! (Assuming we're not covering the case of a PAM password the user has modified in the database since the last session).

d-w-moore avatar May 06 '24 21:05 d-w-moore

@trel Sorry for the delay in answering. I tried and the above approach works in python-irodsclient = "2.0.1" and python-irodsclient = "1.1.5"

@d-w-moore

Assuming we're not covering the case of a PAM password the user has modified in the database since the last session

Is this different to what one would expect from iinit with a PAM strategy? If the user's password has changed, authentication with irods tools or the python-irods client would still be attempted with the .irodsA file, even if its stale. I'd hope the server would reject the connection attempt as the token in the .irodsA file could not be cryptographically matched against a new password... but this is only a guess.

IanVermes avatar May 14 '24 11:05 IanVermes

Is this different to what one would expect from iinit with a PAM strategy?

If the user's password has changed, authentication with irods tools or the python-irods client would still be attempted with the .irodsA file, even if its stale. I'd hope the server would reject the connection attempt as the token in the .irodsA file could not be cryptographically matched against a new password... but this is only a guess.

Yes, solid security is certainly the rule : ) . Or the aim - my attempts at uninterrupted client access via PAM (ie . not requiring a user to re-enter a new password the way that iinit/icommands would if the old PAM password is expired) have thus far involved storing the PAM user's cleartext password in a configuration variable. The security of that depends on protecting a ~/.python_irodsclient containing a PAM password from being seen by other users. Whereas with your solution, the password doesn't need to be stored anywhere, only the server token given as a challenge response to the cleartext password.

d-w-moore avatar May 14 '24 12:05 d-w-moore

This can wait for 2.1.1 ... its testing is contingent on #502, which I've also noted should have a milestone of 2.1.1

d-w-moore avatar Jun 14 '24 13:06 d-w-moore

We realize this might be a bit delayed, but wondering whether there are any plans to integrate the pam_interactive option into the PRC soon?

mstfdkmn avatar Nov 04 '24 13:11 mstfdkmn

Yes, the plan is to add support for pam_interactive to the PRC. We do not have a specific date for that, but the goal is to add it sooner rather than later.

korydraughn avatar Nov 04 '24 14:11 korydraughn

let's create an issue for it - and link it from here.

trel avatar Nov 04 '24 14:11 trel

Created #653

korydraughn avatar Nov 04 '24 14:11 korydraughn