Skip to content

Commit 6eed9cf

Browse files
committed
[_499] authenticate native and pam_password schemes using iRODS 4.3+ auth flow
1 parent c557988 commit 6eed9cf

27 files changed

+1080
-165
lines changed

README.md

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -132,48 +132,87 @@ required when they are placed in the environment file.
132132
Creating PAM or Native Credentials File (.irodsA)
133133
-------------------------------------------------
134134

135-
Two free functions exist for creating encoded authentication files:
135+
Two free functions exist which allow the user to create encoded authentication files
136+
for use in the client's iRODS login environment:
136137
```
137-
irods.client_init.write_native_credentials_to_secrets_file
138-
irods.client_init.write_pam_credentials_to_secrets_file
138+
irods.client_init.write_native_irodsA_file
139+
irods.client_init.write_pam_irodsA_file
139140
```
140141

142+
These functions can roughly be described as duplicating the function of iinit (from iCommands),
143+
once a valid irods_environment.json has already been created.
144+
141145
Each takes a cleartext password and writes an appropriately processed version of it
142-
into an .irodsA (secrets) file in the login environment.
146+
into an .irodsA "password" or "secrets" in the appropriate location.
143147

144-
Examples:
145-
For the `native` authentication scheme, we can use the currently set iRODS password to create the .irodsA file directly:
148+
That location is ~/.irods/.irodsA) unless IRODS_AUTHENTICATION_FILE has
149+
been set with an alternate file path in the OS environment.
146150

147-
```python
148-
import irods.client_init as iinit
149-
iinit.write_native_credentials_to_secrets_file(irods_password)
150-
```
151+
As an example, for the `native` authentication scheme, it is simple to create the
152+
.irodsA file directly:
151153

152-
Note, in the `pam_password` case, this involves sending the cleartext password
153-
to the server (SSL must be enabled!) and then writing the scrambled token that
154-
is returned from the transaction.
154+
```bash
155+
$ echo '{ "irods_user_name":"rods", ... }'> ~/.irods/irods_environment.json
156+
$ python -c "import irods.client_init, getpass
157+
irods.client_init.write_native_irodsA_file(getpass.getpass('Enter iRODS password -> '))"
158+
```
155159

156-
If an .irodsA file exists already, it will be overwritten by default; however, if these functions'
160+
If an .irodsA file already exists, it will be overwritten by default; however, if these functions'
157161
overwrite parameter is set to `False`, an exception of type `irods.client_init.irodsA_already_exists`
158-
will be raised to indicate the older .irodsA file is present.
162+
will be raised to warn of an older .irodsA file that might otherwise be overwritten.
159163

160-
For the `pam_password` authentication scheme, we must first ensure an `irods_environment.json` file exists in the
161-
client environment (necessary for establishing SSL/TLS connection parameters as well as obtaining a PAM token from the server after connecting)
162-
and then make the call to write .irodsA using the Bash commands:
164+
Equivalently, we can issue the following command.
163165

164166
```bash
165-
$ cat > ~/.irods/irods_environment.json << EOF
166-
{
167-
"irods_user_name":"rods",
168-
"irods_host":"server-hostname",
169-
... [all other connection settings, including SSL parameters, needed for communication with iRODS] ...
170-
}
171-
EOF
172-
$ python -c "import irods.client_init as iinit; iinit.write_pam_credentials_to_secrets_file(pam_cleartext_password)"
167+
$ prc_write_irodsA.py native <<<"${MY_CURRENT_IRODS_PASSWORD}"
173168
```
174169

175-
PAM logins
176-
----------
170+
The redirect may be left off, in which case the user is prompted for the iRODS password
171+
and echo of the keyboard input will be suppressed. (Regardless which technique is used,
172+
no password will be visible on the terminal during or after input.)
173+
174+
For the `pam_password` scheme, typically SSL/TLS must first be enabled to avoid sending data related
175+
to the password - or even sending the raw password itself - over a network connection in the clear.
176+
177+
Thus, for `pam_password` authentication to work well, we should first ensure when setting up the
178+
client environment that the `irods_environment.json` file includes the appropriate
179+
SSL/TLS connection parameters. If present, `iinit` can be used to verify this condition is fulfilled,
180+
as of course its invocation would create a valid .irodsA from merely prompting the user for their
181+
PAM password
182+
183+
But if we wish to use the Python client for this purpose instead, we can run:
184+
185+
```python
186+
irods.client_init.write_pam_irodsA_file(getpass.getpass('Enter current PAM password -> '))
187+
```
188+
189+
Or from the Bash command shell, we simply run:
190+
191+
```bash
192+
$ prc_write_irodsA.py pam_password <<<"${MY_CURRENT_PAM_PASSWORD}"
193+
```
194+
195+
again leaving out the redirection if password prompting is preferable.
196+
197+
As a final note, in the "pam_password" scheme the default SSL requirement can be disabled (for purposes
198+
of testing only):
199+
200+
```python
201+
session = irods.session.iRODSSession(host = "localhost", port = 1247,
202+
user = "alice", password = "test123", zone="tempZone",
203+
authentication_scheme = "pam_password")
204+
205+
session.set_auth_option_for_scheme('pam_password', irods.auth.pam_password.ENSURE_SSL_IS_ACTIVE, False)
206+
207+
# Do something with the session:
208+
home = session.collections.get('/tempZone/home/alice')
209+
```
210+
211+
Note however that in future releases of iRODS it is possible that extra SSL checking could be
212+
implemented server-side, at which point, the above code could not be guaranteed to work.
213+
214+
Legacy (iRODS 4.2-compatible) PAM authentication
215+
------------------------------------------------
177216

178217
Since v2.0.0, the Python iRODS Client is able to authenticate via PAM using the same file-based client environment as the
179218
iCommands.

irods/account.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ def __init__(
2828
irods_host = v
2929

3030
self.env_file = env_file
31+
32+
# The '_auth_file' attribute will be written in the call to iRODSSession.configure,
33+
# if an .irodsA file from the client environment is used to load password information.
34+
self._auth_file = ""
35+
3136
tuplify = lambda _: _ if isinstance(_, (list, tuple)) else (_,)
3237
schemes = [_.lower() for _ in tuplify(irods_authentication_scheme)]
3338

irods/api_number.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,5 @@
179179
"REPLICA_CLOSE_APN": 20004,
180180
"TOUCH_APN": 20007,
181181
"AUTH_PLUG_REQ_AN": 1201,
182+
"AUTHENTICATION_APN": 110000,
182183
}

irods/auth/__init__.py

Lines changed: 184 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,116 @@
1+
import importlib
2+
import logging
3+
import weakref
4+
from irods.api_number import api_number
5+
from irods.message import iRODSMessage, JSON_Message
6+
import irods.password_obfuscation as obf
7+
import irods.session
8+
9+
110
__all__ = ["pam_password", "native"]
211

12+
313
AUTH_PLUGIN_PACKAGE = "irods.auth"
414

5-
import importlib
15+
16+
# Python3 does not have types.NoneType
17+
_NoneType = type(None)
18+
19+
20+
class AuthStorage:
21+
"""A class that facilitates flexible means password storage.
22+
23+
Using an instance of this class, passwords may either be
24+
25+
- directly placed in a member attribute (pw), or
26+
27+
- they may be written to / read from a specified file path in encoded
28+
form, usually in an .irodsA file intended for iRODS client authentication.
29+
30+
Most typical of this class's utility is the transfer of password information from
31+
the pam_password to the native authentication flow. In this usage, whether the
32+
password is stored in RAM or in the filesystem depends on whether it was read
33+
originally as a function parameter or from an authentication file, respectively,
34+
when the session was created.
35+
36+
"""
37+
38+
@staticmethod
39+
def get_env_password(filename=None):
40+
options = dict(irods_authentication_file=filename) if filename else {}
41+
return irods.session.iRODSSession.get_irods_password(**options)
42+
43+
@staticmethod
44+
def get_env_password_file():
45+
return irods.session.iRODSSession.get_irods_password_file()
46+
47+
@staticmethod
48+
def set_env_password(unencoded_pw, filename=None):
49+
if filename is None:
50+
filename = AuthStorage.get_env_password_file()
51+
from ..client_init import _open_file_for_protected_contents
52+
53+
with _open_file_for_protected_contents(filename, "w") as irodsA:
54+
irodsA.write(obf.encode(unencoded_pw))
55+
return filename
56+
57+
@staticmethod
58+
def get_temp_pw_storage(conn):
59+
"""Fetch the AuthStorage instance associated with this connection object."""
60+
return getattr(conn, "auth_storage", lambda: None)()
61+
62+
@staticmethod
63+
def create_temp_pw_storage(conn):
64+
"""Creates an AuthStorage instance to be cached and associated with this connection object.
65+
66+
Called multiple times for the same connection, it will return the cached instance.
67+
68+
The value returned by this call should be stored by the caller into an appropriately scoped
69+
variable to ensure the AuthStorage instance endures for the desired lifetime -- that is,
70+
for however long we wish to keep the password information around. This is because the
71+
connection object only maintains a weak reference to said instance.
72+
"""
73+
74+
# resolve the weakly referenced AuthStorage obj for the connection if there is one.
75+
weakref_to_store = getattr(conn, "auth_storage", None)
76+
store = weakref_to_store and weakref_to_store()
77+
78+
# In absence of a persistent AuthStorage object, create one.
79+
if store is None:
80+
store = AuthStorage(conn)
81+
# So that the connection object doesn't hold on to password data too long:
82+
conn.auth_storage = weakref.ref(store)
83+
return store
84+
85+
def __init__(self, conn):
86+
self.conn = conn
87+
self.pw = ""
88+
self._auth_file = ""
89+
90+
@property
91+
def auth_file(self):
92+
if self._auth_file is None:
93+
return ""
94+
return self._auth_file or self.conn.account.derived_auth_file
95+
96+
def use_client_auth_file(self, auth_file):
97+
"""Set to None to completely suppress use of an .irodsA auth file."""
98+
if isinstance(auth_file, (str, _NoneType)):
99+
self._auth_file = auth_file
100+
else:
101+
msg = f"Invalid object in {self.__class__}._auth_file"
102+
raise RuntimeError(msg)
103+
104+
def store_pw(self, pw):
105+
if self.auth_file:
106+
self.set_env_password(pw, filename=self.auth_file)
107+
else:
108+
self.pw = pw
109+
110+
def retrieve_pw(self):
111+
if self.auth_file:
112+
return self.get_env_password(filename=self.auth_file)
113+
return self.pw
6114

7115

8116
def load_plugins(subset=set(), _reload=False):
@@ -18,9 +126,81 @@ def load_plugins(subset=set(), _reload=False):
18126
return dir_
19127

20128

21-
# TODO(#499): X models a class which we could define here as a base for various server or client state machines
22-
# as appropriate for the various authentication types.
129+
class REQUEST_IS_MISSING_KEY(Exception):
130+
pass
23131

24132

25-
class X:
133+
class ClientAuthError(Exception):
26134
pass
135+
136+
137+
def throw_if_request_message_is_missing_key(request, required_keys):
138+
for key in required_keys:
139+
if not key in request:
140+
raise REQUEST_IS_MISSING_KEY(f"key = {key}")
141+
142+
143+
def _auth_api_request(conn, data):
144+
message_body = JSON_Message(data, conn.server_version)
145+
message = iRODSMessage(
146+
"RODS_API_REQ", msg=message_body, int_info=api_number["AUTHENTICATION_APN"]
147+
)
148+
conn.send(message)
149+
response = conn.recv()
150+
return response.get_json_encoded_struct()
151+
152+
153+
__FLOW_COMPLETE__ = "authentication_flow_complete"
154+
__NEXT_OPERATION__ = "next_operation"
155+
156+
157+
CLIENT_GET_REQUEST_RESULT = "client_get_request_result"
158+
FORCE_PASSWORD_PROMPT = "force_password_prompt"
159+
STORE_PASSWORD_IN_MEMORY = "store_password_in_memory"
160+
161+
162+
class authentication_base:
163+
164+
def __init__(self, connection, scheme):
165+
self.conn = connection
166+
self.loggedIn = 0
167+
self.scheme = scheme
168+
169+
def call(self, next_operation, request):
170+
logging.info("next operation = %r", next_operation)
171+
old_func = func = next_operation
172+
# One level of indirection should be sufficient to get a callable method.
173+
if not callable(func):
174+
old_func, func = (func, getattr(self, func, None))
175+
func = func or old_func
176+
if not callable(func):
177+
raise RuntimeError("client request contains no callable 'next_operation'")
178+
resp = func(request)
179+
logging.info("resp = %r", resp)
180+
return resp
181+
182+
def authenticate_client(
183+
self, next_operation="auth_client_start", initial_request=()
184+
):
185+
if not isinstance(initial_request, dict):
186+
initial_request = dict(initial_request)
187+
188+
to_send = initial_request.copy()
189+
to_send["scheme"] = self.scheme
190+
191+
while True:
192+
resp = self.call(next_operation, to_send)
193+
if self.loggedIn:
194+
break
195+
next_operation = resp.get(__NEXT_OPERATION__)
196+
if next_operation is None:
197+
raise ClientAuthError(
198+
"next_operation key missing; cannot determine next operation"
199+
)
200+
if next_operation in (__FLOW_COMPLETE__, ""):
201+
raise ClientAuthError(
202+
f"authentication flow stopped without success: scheme = {self.scheme}"
203+
)
204+
to_send = resp
205+
206+
logging.info("fully authenticated")

0 commit comments

Comments
 (0)