-
Notifications
You must be signed in to change notification settings - Fork 75
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
base: main
Are you sure you want to change the base?
Changes from all commits
d790d44
bca661e
fed6f4d
03d4b05
5ca2b0e
e2bc712
d8376d2
4213af0
ff945bd
83ff5db
f308a50
caccff6
1c838d5
45436d5
72f41b1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"] = "" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Usually |
||
sys.stdout.flush() | ||
user_input = sys.stdin.readline().strip() | ||
korydraughn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.") |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could alphabetize. Not critical