Skip to content

Commit bee8294

Browse files
d-w-moorealanking
authored andcommitted
[#499] authenticate native and pam_password schemes using iRODS 4.3+ auth flow
1 parent cf4bc64 commit bee8294

27 files changed

+1084
-168
lines changed

README.md

Lines changed: 70 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -129,51 +129,90 @@ the `encryption_*` and `ssl_*` options
129129
directly to the constructor as keyword arguments, even though it is
130130
required when they are placed in the environment file.
131131

132-
Creating PAM or Native Credentials File (.irodsA)
133-
-------------------------------------------------
132+
Creating a PAM or Native Authentication File
133+
--------------------------------------------
134134

135-
Two free functions exist for creating encoded authentication files:
135+
The following free functions may be used to create the authentication secrets files (called
136+
`.irodsA` per the convention of iRODS's iCommands):
137+
- `irods.client_init.write_native_irodsA_file`
138+
- `irods.client_init.write_pam_irodsA_file`
139+
140+
These functions can roughly be described as duplicating the "authentication" functionality of `iinit`,
141+
provided that a valid `irods_environment.json` has already been created.
142+
143+
Each of the above functions can take a cleartext password and write an appropriately encoded
144+
version of it into an authentication file in the appropriate location. That location is
145+
`~/.irods/.irodsA` unless the environment variable IRODS_AUTHENTICATION_FILE has been set
146+
in the command shell to dictate an alternative file path.
147+
148+
As an example, here we write a native `.irodsA` file using the first of the two functions. We
149+
provide the one required argument, a password string which is entered interactively at the
150+
terminal.
151+
152+
```bash
153+
$ echo '{ "irods_user_name":"rods",
154+
... # other parameters as needed
155+
}'> ~/.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 -> '))"
136158
```
137-
irods.client_init.write_native_credentials_to_secrets_file
138-
irods.client_init.write_pam_credentials_to_secrets_file
159+
160+
By default, when an `.irodsA` file already exists, it will be overwritten. If however the
161+
`overwrite` parameter is set to `False`, an exception of type `irods.client_init.irodsA_already_exists`
162+
is raised to warn of any older `.irodsA` file that might otherwise have been overwritten.
163+
164+
Equivalently to the above, we can issue the following command.
165+
166+
```bash
167+
$ prc_write_irodsA.py native <<<"${MY_CURRENT_IRODS_PASSWORD}"
139168
```
140169

141-
Each takes a cleartext password and writes an appropriately processed version of it
142-
into an .irodsA (secrets) file in the login environment.
170+
The redirect may of course be left off, in which case the user is prompted for the iRODS password
171+
and echo of the keyboard input will be suppressed, in the style of `iinit`. Regardless of
172+
which technique is used, 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.
143176

144-
Examples:
145-
For the `native` authentication scheme, we can use the currently set iRODS password to create the .irodsA file directly:
177+
Thus, for `pam_password` authentication to work well, we should first ensure, when setting up the
178+
client environment, to include within `irods_environment.json` the appropriate SSL/TLS connection
179+
parameters. In a pinch, `iinit` can be used to verify this prerequisite is fulfilled,
180+
as its invocation would then create a valid `.irodsA` from merely prompting the user for their PAM password.
181+
182+
Once again, this can also be done using the free function directly:
146183

147184
```python
148-
import irods.client_init as iinit
149-
iinit.write_native_credentials_to_secrets_file(irods_password)
185+
irods.client_init.write_pam_irodsA_file(getpass.getpass('Enter current PAM password -> '))
150186
```
151187

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.
188+
or from the Bash command shell:
155189

156-
If an .irodsA file exists already, it will be overwritten by default; however, if these functions'
157-
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.
190+
```bash
191+
$ prc_write_irodsA.py pam_password <<<"${MY_CURRENT_PAM_PASSWORD}"
192+
```
159193

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:
194+
As a final note, in the `pam_password` scheme, the default SSL requirement can be disabled.
195+
**Warning:** Disabling the SSL requirement may cause user passwords to be sent over the network
196+
in the clear. This should only be done for purposes of testing. Here's how to do it:
163197

164-
```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)"
198+
```python
199+
from irods.auth.pam_password import ENSURE_SSL_IS_ACTIVE
200+
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', ENSURE_SSL_IS_ACTIVE, False)
206+
207+
# Do something with the session:
208+
home = session.collections.get('/tempZone/home/alice')
173209
```
174210

175-
PAM logins
176-
----------
211+
Note, however, 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: 183 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,115 @@
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 one of the following:
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+
@staticmethod
38+
def get_env_password(filename=None):
39+
options = dict(irods_authentication_file=filename) if filename else {}
40+
return irods.session.iRODSSession.get_irods_password(**options)
41+
42+
@staticmethod
43+
def get_env_password_file():
44+
return irods.session.iRODSSession.get_irods_password_file()
45+
46+
@staticmethod
47+
def set_env_password(unencoded_pw, filename=None):
48+
if filename is None:
49+
filename = AuthStorage.get_env_password_file()
50+
from ..client_init import _open_file_for_protected_contents
51+
52+
with _open_file_for_protected_contents(filename, "w") as irodsA:
53+
irodsA.write(obf.encode(unencoded_pw))
54+
return filename
55+
56+
@staticmethod
57+
def get_temp_pw_storage(conn):
58+
"""Fetch the AuthStorage instance associated with this connection object."""
59+
return getattr(conn, "auth_storage", lambda: None)()
60+
61+
@staticmethod
62+
def create_temp_pw_storage(conn):
63+
"""Creates an AuthStorage instance to be cached and associated with this connection object.
64+
65+
Called multiple times for the same connection, it will return the cached instance.
66+
67+
The value returned by this call should be stored by the caller into an appropriately scoped
68+
variable to ensure the AuthStorage instance endures for the desired lifetime -- that is,
69+
for however long we wish to keep the password information around. This is because the
70+
connection object only maintains a weak reference to said instance.
71+
"""
72+
73+
# resolve the weakly referenced AuthStorage obj for the connection if there is one.
74+
weakref_to_store = getattr(conn, "auth_storage", None)
75+
store = weakref_to_store and weakref_to_store()
76+
77+
# In absence of a persistent AuthStorage object, create one.
78+
if store is None:
79+
store = AuthStorage(conn)
80+
# So that the connection object doesn't hold on to password data too long:
81+
conn.auth_storage = weakref.ref(store)
82+
return store
83+
84+
def __init__(self, conn):
85+
self.conn = conn
86+
self.pw = ""
87+
self._auth_file = ""
88+
89+
@property
90+
def auth_file(self):
91+
if self._auth_file is None:
92+
return ""
93+
return self._auth_file or self.conn.account.derived_auth_file
94+
95+
def use_client_auth_file(self, auth_file):
96+
"""Set to None to completely suppress use of an .irodsA auth file."""
97+
if isinstance(auth_file, (str, _NoneType)):
98+
self._auth_file = auth_file
99+
else:
100+
msg = f"Invalid object in {self.__class__}._auth_file"
101+
raise RuntimeError(msg)
102+
103+
def store_pw(self, pw):
104+
if self.auth_file:
105+
self.set_env_password(pw, filename=self.auth_file)
106+
else:
107+
self.pw = pw
108+
109+
def retrieve_pw(self):
110+
if self.auth_file:
111+
return self.get_env_password(filename=self.auth_file)
112+
return self.pw
6113

7114

8115
def load_plugins(subset=set(), _reload=False):
@@ -18,9 +125,81 @@ def load_plugins(subset=set(), _reload=False):
18125
return dir_
19126

20127

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.
128+
class REQUEST_IS_MISSING_KEY(Exception):
129+
pass
23130

24131

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

0 commit comments

Comments
 (0)