Skip to content

Commit 75cb130

Browse files
committed
[_499] authenticate native and pam_password schemes using iRODS 4.3+ auth flow
remove redundant scripts new write_pam_irodsA_file function experimental - auth options/write_pam_irodsA related auth_options per connection (in support of writing PAM .irodsA using new auth-fwk) review-related and experimental changes fixing stuff. fix pam free fn import io in free fn irods.auth import at head leads to circular import loop. when importing irods.connection before other modules. pam free fn to write irodsA works time-to-live addl script login pam w/o env check_ssl, proper reading of pw possible "" error if no env. chgs to pam write irodsA w/ raw_server_version name changes for (PAM and native) irodsA creator functions also change vector keep password in mem variable names, doc, refactor corrections fri 2/14 am corrections client_init.py force_legacy_auth setting preliminary test that inline pam_password (ie w/o env files) doesn't write .irodsA
1 parent 0fd84bc commit 75cb130

16 files changed

+607
-61
lines changed

irods/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ def env_filename_from_keyword_args(kwargs):
2929
def derived_auth_filename(env_filename):
3030
if not env_filename:
3131
return ""
32-
default_irods_authentication_file = os.path.join(
33-
os.path.dirname(env_filename), ".irodsA"
32+
default_irods_authentication_file = (
33+
##->https://github.com/irods/python-irodsclient/issues/686
34+
#os.path.join(os.path.dirname(env_filename), ".irodsA")
35+
os.path.expanduser("~/.irods/.irodsA")
3436
)
3537
return os.environ.get(
3638
"IRODS_AUTHENTICATION_FILE", default_irods_authentication_file

irods/account.py

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

3030
self.env_file = env_file
31+
self._auth_file = "" # will be written into by iRODSSession on call to configure( )
3132
tuplify = lambda _: _ if isinstance(_, (list, tuple)) else (_,)
3233
schemes = [_.lower() for _ in tuplify(irods_authentication_scheme)]
3334

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: 139 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,85 @@
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+
NoneType = type(None)
17+
18+
19+
class AuthStorage:
20+
21+
@staticmethod
22+
def get_env_password(filename = None):
23+
options = dict(irods_authentication_file = filename) if filename else {}
24+
return irods.session.iRODSSession.get_irods_password(**options)
25+
26+
@staticmethod
27+
def get_env_password_file():
28+
return irods.session.iRODSSession.get_irods_password_file()
29+
30+
@staticmethod
31+
def set_env_password(unencoded_pw, filename = None):
32+
if filename is None:
33+
filename = AuthStorage.get_env_password_file()
34+
from ..client_init import _open_file_for_protected_contents
35+
with _open_file_for_protected_contents(filename,'w') as irodsA:
36+
irodsA.write(obf.encode(unencoded_pw))
37+
return filename
38+
39+
@staticmethod
40+
def get_temp_pw_storage(conn):
41+
return getattr(conn,'auth_storage',lambda:None)()
42+
43+
@staticmethod
44+
def create_temp_pw_storage(conn):
45+
"""A reference to the value returned by this call should be stored for the duration of the
46+
authentication exchange.
47+
"""
48+
store = getattr(conn,'auth_storage',None)
49+
if store is None:
50+
store = AuthStorage(conn)
51+
# So that the connection object doesn't hold on to password data too long:
52+
conn.auth_storage = weakref.ref(store)
53+
return store
54+
55+
def __init__(self, conn):
56+
self.conn = conn
57+
self.pw = ''
58+
self._auth_file = ''
59+
60+
@property
61+
def auth_file(self):
62+
if self._auth_file is None:
63+
return ''
64+
return self._auth_file or self.conn.account.derived_auth_file
65+
66+
def use_client_auth_file(self, auth_file):
67+
if isinstance(auth_file, (str, NoneType)):
68+
self._auth_file = auth_file
69+
else:
70+
msg = f"Invalid object in {self.__class__}._auth_file"
71+
raise RuntimeError(msg)
72+
73+
def store_pw(self,pw):
74+
if self.auth_file:
75+
self.set_env_password(pw, filename = self.auth_file)
76+
else:
77+
self.pw = pw
78+
79+
def retrieve_pw(self):
80+
if self.auth_file:
81+
return self.get_env_password(filename = self.auth_file)
82+
return self.pw
683

784

885
def load_plugins(subset=set(), _reload=False):
@@ -18,9 +95,66 @@ def load_plugins(subset=set(), _reload=False):
1895
return dir_
1996

2097

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.
98+
class REQUEST_IS_MISSING_KEY(Exception): pass
99+
100+
101+
def throw_if_request_message_is_missing_key( request, required_keys ):
102+
for key in required_keys:
103+
if not key in request:
104+
raise REQUEST_IS_MISSING_KEY(f"key = {key}")
105+
106+
107+
def _auth_api_request(conn, data):
108+
message_body = JSON_Message(data, conn.server_version)
109+
message = iRODSMessage('RODS_API_REQ', msg=message_body,
110+
int_info=api_number['AUTHENTICATION_APN']
111+
)
112+
conn.send(message)
113+
response = conn.recv()
114+
return response.get_json_encoded_struct()
115+
116+
117+
__FLOW_COMPLETE__ = "authentication_flow_complete"
118+
__NEXT_OPERATION__ = "next_operation"
119+
120+
121+
CLIENT_GET_REQUEST_RESULT = 'client_get_request_result'
122+
FORCE_PASSWORD_PROMPT = "force_password_prompt"
123+
STORE_PASSWORD_IN_MEMORY = "store_password_in_memory"
124+
125+
class authentication_base:
126+
127+
def __init__(self, connection, scheme):
128+
self.conn = connection
129+
self.loggedIn = 0
130+
self.scheme = scheme
131+
132+
def call(self, next_operation, request):
133+
logging.info('next operation = %r', next_operation)
134+
old_func = func = next_operation
135+
while isinstance(func, str):
136+
old_func, func = (func, getattr(self, func, None))
137+
func = (func or old_func)
138+
if not func:
139+
raise RuntimeError("client request contains no callable 'next_operation'")
140+
resp = func(request)
141+
logging.info('resp = %r',resp)
142+
return resp
143+
144+
def authenticate_client(self, next_operation = "auth_client_start", initial_request = {}):
145+
146+
to_send = initial_request.copy()
147+
to_send["scheme"] = self.scheme
23148

149+
while True:
150+
resp = self.call(next_operation, to_send)
151+
if self.loggedIn:
152+
break
153+
next_operation = resp.get(__NEXT_OPERATION__)
154+
if next_operation is None:
155+
raise ClientAuthError("next_operation key missing; cannot determine next operation")
156+
if next_operation in (__FLOW_COMPLETE__,""):
157+
raise ClientAuthError(f"authentication flow stopped without success: scheme = {self.scheme}")
158+
to_send = resp
24159

25-
class X:
26-
pass
160+
logging.info("fully authenticated")

irods/auth/native.py

Lines changed: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,135 @@
1-
def login(conn):
2-
conn._login_native()
1+
import base64
2+
import logging
3+
import hashlib
4+
import struct
35

6+
from irods import MAX_PASSWORD_LENGTH
47

5-
# TODO (#499): Here, we could define client & server auth_state classes (ie state machines mimicking the mechanics
6-
# of 4.3+ iCommands/iRods-runtime authentication framework), using this pattern for an inheritance hook.
7-
from . import X as X_base
8+
from . import (__NEXT_OPERATION__, __FLOW_COMPLETE__,
9+
AuthStorage,
10+
authentication_base, _auth_api_request,
11+
throw_if_request_message_is_missing_key)
812

913

10-
class X(X_base):
11-
pass
14+
def login(conn, **extra_opt):
15+
opt = {'user_name': conn.account.proxy_user,
16+
'zone_name': conn.account.proxy_zone}
17+
opt.update(extra_opt)
18+
authenticate_native(conn, req = opt)
19+
20+
21+
_scheme = 'native'
22+
23+
24+
def authenticate_native(conn, req):
25+
26+
logging.info('----------- %s (begin)', _scheme)
27+
28+
native_ClientAuthState(
29+
conn,
30+
scheme = _scheme
31+
).authenticate_client(
32+
# initial_request is called context (or ctx for short) in iRODS core library code.
33+
initial_request = req
34+
)
35+
36+
logging.info('----------- %s (end)', _scheme)
37+
38+
39+
class native_ClientAuthState(authentication_base):
40+
41+
def auth_client_start(self, request):
42+
resp = request.copy()
43+
# user_name and zone_name keys injected by authenticate_client() method
44+
resp[__NEXT_OPERATION__] = self.AUTH_CLIENT_AUTH_REQUEST # native_auth_client_request
45+
return resp
46+
47+
# Client defines. These strings should match instance method names within the class namespace.
48+
AUTH_AGENT_START = 'native_auth_agent_start'
49+
AUTH_CLIENT_AUTH_REQUEST = 'native_auth_client_request'
50+
AUTH_ESTABLISH_CONTEXT = 'native_auth_establish_context'
51+
AUTH_CLIENT_AUTH_RESPONSE = 'native_auth_client_response'
52+
53+
# Server defines.
54+
AUTH_AGENT_AUTH_REQUEST = "auth_agent_auth_request"
55+
AUTH_AGENT_AUTH_RESPONSE = "auth_agent_auth_response"
56+
57+
def native_auth_client_request(self, request):
58+
server_req = request.copy()
59+
server_req[__NEXT_OPERATION__] = self.AUTH_AGENT_AUTH_REQUEST
60+
61+
resp = _auth_api_request(self.conn, server_req)
62+
63+
resp[__NEXT_OPERATION__] = self.AUTH_ESTABLISH_CONTEXT
64+
return resp
65+
66+
def native_auth_establish_context(self, request):
67+
throw_if_request_message_is_missing_key(request,
68+
["user_name", "zone_name", "request_result"])
69+
request = request.copy()
70+
71+
password = ''
72+
depot = AuthStorage.get_temp_pw_storage(self.conn)
73+
if depot:
74+
# The following is how pam_password communicates a server-generated password.
75+
password = depot.retrieve_pw()
76+
77+
if not password:
78+
password = self.conn.account.password or ''
79+
80+
challenge = request["request_result"].encode('utf-8')
81+
self.conn._client_signature = "".join("{:02x}".format(c) for c in challenge[:16])
82+
83+
padded_pwd = struct.pack(
84+
"%ds" % MAX_PASSWORD_LENGTH, password.encode(
85+
'utf-8').strip())
86+
87+
m = hashlib.md5()
88+
m.update(challenge)
89+
m.update(padded_pwd)
90+
91+
encoded_pwd = m.digest()
92+
if b'\x00' in encoded_pwd:
93+
encoded_pwd_array = bytearray(encoded_pwd)
94+
encoded_pwd = bytes(encoded_pwd_array.replace(b'\0', b'\1'))
95+
request['digest'] = base64.encodebytes(encoded_pwd).strip().decode('utf-8')
96+
97+
request[__NEXT_OPERATION__] = self.AUTH_CLIENT_AUTH_RESPONSE
98+
return request
99+
100+
def native_auth_client_response (self,request):
101+
throw_if_request_message_is_missing_key(request,
102+
["user_name", "zone_name", "digest"])
103+
104+
server_req = request.copy()
105+
server_req[__NEXT_OPERATION__] = self.AUTH_AGENT_AUTH_RESPONSE
106+
resp = _auth_api_request(self.conn, server_req)
107+
108+
self.loggedIn = 1;
109+
resp [__NEXT_OPERATION__] = __FLOW_COMPLETE__
110+
return resp
111+
112+
#if __name__ == '__main__':
113+
# from sys import argv
114+
def main(*argv):
115+
116+
User, Zone, Pw = argv[1:4]
117+
118+
import irods.account, irods.pool, irods.connection
119+
120+
account = irods.account.iRODSAccount(
121+
'localhost',1247,
122+
User, Zone,
123+
password = Pw,
124+
irods_authentication_scheme = _scheme
125+
)
126+
127+
pool = irods.pool.Pool(account)
128+
connection = irods.connection.Connection(pool, account, #connect=False #TODO delete
129+
)
130+
131+
authenticate_native(
132+
connection,
133+
req = {'user_name': account.proxy_user,
134+
'zone_name': account.proxy_zone} )
135+

0 commit comments

Comments
 (0)