Skip to content

Add pam_interactive authentication flow #752

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion irods/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import irods.session


__all__ = ["pam_password", "native"]
__all__ = ["pam_interactive", "pam_password", "native"]


AUTH_PLUGIN_PACKAGE = "irods.auth"
Expand Down
256 changes: 256 additions & 0 deletions irods/auth/pam_interactive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
from . import (
__NEXT_OPERATION__,
__FLOW_COMPLETE__,
authentication_base,
_auth_api_request,
FORCE_PASSWORD_PROMPT,
throw_if_request_message_is_missing_key,
AuthStorage,
STORE_PASSWORD_IN_MEMORY,
CLIENT_GET_REQUEST_RESULT
)
from .native import _authenticate_native

import getpass
import sys
import logging
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could alphabetize. Not critical

import jsonpointer
import jsonpatch

# Constants defining the states and operations for the pam_interactive authentication flow
AUTH_CLIENT_AUTH_REQUEST = "pam_auth_client_request"
AUTH_CLIENT_AUTH_RESPONSE = "pam_auth_response"
PERFORM_RUNNING = "running"
PERFORM_READY = "ready"
PERFORM_NEXT = "next"
PERFORM_RESPONSE = "response"
PERFORM_WAITING = "waiting"
PERFORM_WAITING_PW = "waiting_pw"
PERFORM_ERROR = "error"
PERFORM_TIMEOUT = "timeout"
PERFORM_AUTHENTICATED = "authenticated"
PERFORM_NOT_AUTHENTICATED = "not_authenticated"
PAM_INTERACTIVE_SCHEME = "pam_interactive"
PERFORM_NATIVE_AUTH = "native_auth"

AUTH_AGENT_AUTH_REQUEST = "auth_agent_auth_request"
AUTH_AGENT_AUTH_RESPONSE = "auth_agent_auth_response"

_logger = logging.getLogger(__name__)

def login(conn, **extra_opt):
"""The entry point for the pam_interactive authentication scheme."""

# The AuthStorage object holds the token generated by the server for the native auth step
depot = AuthStorage.create_temp_pw_storage(conn)

auth_client_object = _pam_interactive_ClientAuthState(conn, depot, scheme=PAM_INTERACTIVE_SCHEME)
auth_client_object.authenticate_client(
initial_request=extra_opt
)

class _pam_interactive_ClientAuthState(authentication_base):
def __init__(self, conn, depot, *_, **_kw):
super().__init__(conn, *_, **_kw)
self.depot = depot
self._list_for_request_result_return = None

def auth_client_start(self, request):
self._list_for_request_result_return = request.pop(CLIENT_GET_REQUEST_RESULT, None)

resp = request.copy()

resp["pstate"] = resp.get("pstate", {})
resp["pdirty"] = resp.get("pdirty", False)
Comment on lines +63 to +64
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my sake being yet unfamiliar with but still a potential maintainer of the pam_interactive strategy and client plugin, some of the sections of code could be adorned with explanatory comments esp. where they involve server interactions and the associated key-strings for the exchange, as these seem to be external and not self-explanatory within this body of code. This could mean using web links to other explanations, to save on repetition.


resp['user_name'] = self.conn.account.proxy_user
resp['zone_name'] = self.conn.account.proxy_zone

# 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

# Otherwise, begin the full interactive flow
resp[__NEXT_OPERATION__] = AUTH_CLIENT_AUTH_REQUEST
return resp

def pam_auth_client_request(self, request):
server_req = request.copy()
server_req[__NEXT_OPERATION__] = AUTH_AGENT_AUTH_REQUEST

resp = _auth_api_request(self.conn, server_req)
resp[__NEXT_OPERATION__] = AUTH_CLIENT_AUTH_RESPONSE

return resp

def pam_auth_response(self, request):
throw_if_request_message_is_missing_key(request, ["user_name", "zone_name"])

server_req = request.copy()
server_req[__NEXT_OPERATION__] = AUTH_AGENT_AUTH_RESPONSE

resp = _auth_api_request(self.conn, server_req)

return resp

def _get_default_value(self, request):
"""Looks for a default value in pstate based on a path from the server."""

default_path = request.get("msg", {}).get("default_path", "")
if default_path:
try:
return str(jsonpointer.resolve_pointer(request.get("pstate", {}), default_path))
except jsonpointer.JsonPointerException:
pass
return ""

def _patch_state(self, req):
"""Applies server patch instructions to the client's pstate."""

patch_ops = req.get("msg", {}).get("patch")
if not patch_ops:
return

resp = req.get("resp", "")

# If the patch operation is an add or replace without a value, use the response value (following json patch RFC)
for op in patch_ops:
if op.get("op") in ["add", "replace"] and "value" not in op:
op["value"] = resp

req["pstate"] = jsonpatch.apply_patch(req.get("pstate", {}), patch_ops)
req["pdirty"] = True

del req["msg"]["patch"]

def _retrieve_entry(self, req):
"""Checks if the server is asking for a value already stored in pstate."""

if "retrieve" not in req.get("msg", {}):
return False

retr_path = req.get("msg", {}).get("retrieve", "")
if retr_path:
try:
req["resp"] = str(jsonpointer.resolve_pointer(req.get("pstate", {}), retr_path))
return True
except jsonpointer.JsonPointerException:
pass
req["resp"] = ""
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What, if anything, does the server do when it finds the response is empty? Just trying to understand the overall process here. Again, this could be a possible subject to add to the already existing comment.

return True

def _get_input(self, server_req, is_password=False, prompt_label="Input: "):
"""Handles input from the user, either as a password or regular input."""

# If the server asks for a value we already have, send it without prompting
if self._retrieve_entry(server_req):
self._patch_state(server_req)
server_req[__NEXT_OPERATION__] = AUTH_AGENT_AUTH_RESPONSE
return _auth_api_request(self.conn, server_req)

prompt = server_req.get("msg", {}).get("prompt", prompt_label)
default_value = self._get_default_value(server_req)

display_prompt = prompt
if default_value:
if is_password:
display_prompt += " [******] "
else:
display_prompt += f" [{default_value}] "

if is_password:
user_input = getpass.getpass(display_prompt)
else:
sys.stdout.write(display_prompt)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually stderr is chosen for outputting prompts, rather than stdout

sys.stdout.flush()
user_input = sys.stdin.readline().strip()

server_req["resp"] = user_input or default_value

self._patch_state(server_req)
server_req[__NEXT_OPERATION__] = AUTH_AGENT_AUTH_RESPONSE

return _auth_api_request(self.conn, server_req)

def waiting(self, request):
"""Handles interactive input requests from the server."""
server_req = request.copy()
return self._get_input(server_req, is_password=False)

def waiting_pw(self, request):
"""Handles the case where a password is specifically requested."""
server_req = request.copy()
return self._get_input(server_req, is_password=True, prompt_label="Password: ")

def authenticated(self, request):
throw_if_request_message_is_missing_key(request, ["request_result"])
pw = request["request_result"] # The password token returned by the server

if not self.depot:
raise RuntimeError("auth storage object was either not set, or allowed to expire prematurely.")

if request.get(STORE_PASSWORD_IN_MEMORY):
self.depot.use_client_auth_file(None)

self.depot.store_pw(pw)

# Return the password token to the caller if requested
if isinstance(self._list_for_request_result_return, list):
self._list_for_request_result_return[:] = (pw,)

resp = request.copy()
resp[__NEXT_OPERATION__] = PERFORM_NATIVE_AUTH

return resp

def native_auth(self, request):
resp = request.copy()

# The native auth function will use the depot to retrieve the password token
_authenticate_native(self.conn, request)

resp[__NEXT_OPERATION__] = __FLOW_COMPLETE__
self.loggedIn = 1
return resp

# Pass through and failure states

def next(self, request):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, as a beginner who hasn't worked with the pam_interactive plugin before, I'd be interested in a comment maintaining that part played in the overall function of server/client interaction by such methods. Plus, I'm a little confused at trying to parse the existing comment, "pass through and failure states."

prompt = request.get("msg", {}).get("prompt", "")
if prompt:
_logger.info("Server prompt: %s", prompt)

server_req = request.copy()
self._patch_state(server_req)
server_req[__NEXT_OPERATION__] = AUTH_AGENT_AUTH_RESPONSE

resp = _auth_api_request(self.conn, server_req)

return resp

def running(self, request):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming we arrive at such states as "running" and "waiting[_pw]" as a direct result of the server altering NEXT_OPERATION field. Please correct if wrong, or further adorn with comments to help the user/maintainer.

return self.next(request)

def ready(self, request):
return self.next(request)

def response(self, request):
return self.next(request)

def _auth_failure(self, request, message):
_logger.error(message)
resp = request.copy()
resp[__NEXT_OPERATION__] = __FLOW_COMPLETE__
self.loggedIn = 0
return resp

def error(self, request):
return self._auth_failure(request, "Authentication error.")

def timeout(self, request):
return self._auth_failure(request, "Authentication timed out.")

def not_authenticated(self, request):
return self._auth_failure(request, "Authentication failed possibly due to incorrect credentials.")
32 changes: 32 additions & 0 deletions irods/client_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,35 @@ def write_pam_credentials_to_secrets_file(password, overwrite=True, ttl="", **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)

def write_pam_interactive_irodsA_file(overwrite=True, ttl="", **kw):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. Let's be sure to test this routine works as expected, too. Feel free to ping me about the writing of the tests as well.

"""Write credentials to an .irodsA file for PAM interactive authentication."""
import irods.auth

ses = kw.pop("_session", None) or h.make_session(**kw)

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)

ses.set_auth_option_for_scheme(
"pam_interactive", irods.auth.FORCE_PASSWORD_PROMPT, True
)

if ttl:
ses.set_auth_option_for_scheme(
"pam_interactive", "time_to_live_in_hours", ttl
)

ses.set_auth_option_for_scheme(
"pam_interactive", irods.auth.STORE_PASSWORD_IN_MEMORY, True
)

L = []
ses.set_auth_option_for_scheme(
"pam_interactive", irods.auth.CLIENT_GET_REQUEST_RESULT, L
)

with ses.pool.get_connection() as _:
_write_encoded_auth_value(auth_file, L[0], overwrite)
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
install_requires=[
"PrettyTable>=0.7.2",
"defusedxml",
"jsonpointer",
"jsonpatch",
],
extras_require={"tests": ["unittest-xml-reporting"]}, # for xmlrunner
scripts=["irods/prc_write_irodsA.py"],
Expand Down