Skip to content

Commit 62f0066

Browse files
committed
[_499] authenticate native and pam_password schemes using iRODS 4.3+ auth flow
1 parent 65843a7 commit 62f0066

File tree

14 files changed

+553
-64
lines changed

14 files changed

+553
-64
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: 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: 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: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,110 @@
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

0 commit comments

Comments
 (0)