From 473433a203813f822b5e2874f1673825d6740b7f Mon Sep 17 00:00:00 2001 From: Lucio Anderlini Date: Mon, 10 Jun 2024 10:57:37 +0000 Subject: [PATCH 1/5] drafted support for bearer token --- elog/logbook.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/elog/logbook.py b/elog/logbook.py index a3b557a..bdd701f 100644 --- a/elog/logbook.py +++ b/elog/logbook.py @@ -15,7 +15,7 @@ class Logbook(object): """ def __init__(self, hostname, logbook='', port=None, user=None, password=None, subdir='', use_ssl=True, - encrypt_pwd=True): + encrypt_pwd=True, bearer_token=None): """ :param hostname: elog server hostname. If whole url is specified here, it will be parsed and arguments: "logbook, port, subdir, use_ssl" will be overwritten by parsed values. @@ -29,6 +29,8 @@ def __init__(self, hostname, logbook='', port=None, user=None, password=None, su :param encrypt_pwd: To avoid exposing password in the code, this flag can be set to False and password will then be handled as it is (user needs to provide sha256 encrypted password with salt= '' and rounds=5000) + :param bearer_token: bearer token (if authentication needed) Access Token as obtained by an OAuth2 issuer. + If callable, the token is retrieved calling this function each time is needed. :return: """ hostname = hostname.strip() @@ -105,6 +107,10 @@ def __init__(self, hostname, logbook='', port=None, user=None, password=None, su self.logbook = logbook self._user = user self._password = _handle_pswd(password, encrypt_pwd) + self._bearer_token_arg = bearer_token + + def _bearer_token(self): + return self._bearer_token_arg() if callable(self._bearer_token_arg) else self._bearer_token_arg def post(self, message, msg_id=None, reply=False, attributes=None, attachments=None, suppress_email_notification=False, encoding=None, timeout=None, **kwargs): @@ -330,6 +336,8 @@ def read(self, msg_id, timeout=None): request_headers = dict() if self._user or self._password: request_headers['Cookie'] = self._make_user_and_pswd_cookie() + if self._bearer_token: + request_headers['Authorization'] = 'Bearer ' + self._bearer_token try: self._check_if_message_on_server(msg_id) # raises exceptions if no message or no response from server @@ -390,11 +398,15 @@ def delete_attachment(self, msg_id, text, attributes, attachment_id, timeout=Non if self._password: attributes['upwd'] = self._password + request_headers = dict() + if self._bearer_token: + request_headers['Authorization'] = 'Bearer ' + self._bearer_token + just_text = list() just_text.append(('Text', ('', text.encode('iso-8859-1')))) try: response = requests.post(self._url, data=attributes, verify=False, allow_redirects=False, - files=just_text) + headers=request_headers, files=just_text) except requests.Timeout as e: # Catch here a timeout o the post request. # Raise the logbook excetion and let the user handle it @@ -431,6 +443,8 @@ def delete(self, msg_id, timeout=None): request_headers = dict() if self._user or self._password: request_headers['Cookie'] = self._make_user_and_pswd_cookie() + if self._bearer_token: + request_headers['Authorization'] = 'Bearer ' + self._bearer_token try: self._check_if_message_on_server(msg_id) # check if something to delete @@ -467,6 +481,8 @@ def search(self, search_term, n_results=20, scope="subtext", timeout=None): request_headers = dict() if self._user or self._password: request_headers['Cookie'] = self._make_user_and_pswd_cookie() + if self._bearer_token: + request_headers['Authorization'] = 'Bearer ' + self._bearer_token # Putting n_results = 0 crashes the elog. also in the web-gui. n_results = 1 if n_results < 1 else n_results @@ -524,6 +540,8 @@ def get_message_ids(self, timeout=None): request_headers = dict() if self._user or self._password: request_headers['Cookie'] = self._make_user_and_pswd_cookie() + if self._bearer_token: + request_headers['Authorization'] = 'Bearer ' + self._bearer_token try: response = requests.get(self._url + 'page', headers=request_headers, @@ -557,6 +575,8 @@ def download_attachment(self, url, timeout=None): request_headers = dict() if self._user or self._password: request_headers['Cookie'] = self._make_user_and_pswd_cookie() + if self._bearer_token: + request_headers['Authorization'] = 'Bearer ' + self._bearer_token try: response = requests.get(url, headers=request_headers, allow_redirects=False, @@ -585,6 +605,8 @@ def _check_if_message_on_server(self, msg_id, timeout=None): request_headers = dict() if self._user or self._password: request_headers['Cookie'] = self._make_user_and_pswd_cookie() + if self._bearer_token: + request_headers['Authorization'] = 'Bearer ' + self._bearer_token try: response = requests.get(self._url + str(msg_id), headers=request_headers, allow_redirects=False, verify=False, timeout=timeout) From eb212380f782f1744786ffe6ca3772f2b75dd542 Mon Sep 17 00:00:00 2001 From: Lucio Anderlini Date: Wed, 12 Jun 2024 22:12:48 +0000 Subject: [PATCH 2/5] Added support for Bearer tokens --- elog/logbook.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/elog/logbook.py b/elog/logbook.py index bdd701f..362d4ed 100644 --- a/elog/logbook.py +++ b/elog/logbook.py @@ -109,6 +109,7 @@ def __init__(self, hostname, logbook='', port=None, user=None, password=None, su self._password = _handle_pswd(password, encrypt_pwd) self._bearer_token_arg = bearer_token + @property def _bearer_token(self): return self._bearer_token_arg() if callable(self._bearer_token_arg) else self._bearer_token_arg @@ -855,6 +856,7 @@ def _validate_response(response): raise LogbookMessageRejected('Rejected because of unknown error.') # Other unknown errors + print (response.status_code, response.reason, response.text) raise LogbookMessageRejected('Rejected because of unknown error.') else: location = response.headers.get('Location') From 858844c7c350ef4bec9fefd95992ea69e077258e Mon Sep 17 00:00:00 2001 From: Lucio Anderlini Date: Wed, 12 Jun 2024 22:30:12 +0000 Subject: [PATCH 3/5] Added documentation for bearer token support in constructor --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 7f4678f..af50e4e 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,19 @@ logbook.delete(23) __Note:__ Due to the way elog implements delete this function is only supported on english logbooks. +### JWT-based authentication +If the access to the elog is protected by an OAuth2 layer, it is possible to add the token (or a callable returning +the token) when opening the connection to the ELOG server + +```python +logbook = elog.open( + 'https://elog.example.com', 'my_logbook', user='my_username', bearer_token=open("/path/to/my/token").read() +) +``` + +This includes the Access token in the request headers of the following queries to the ELOG. + + # Installation The Elog module and only depends on the `passlib` and `requests` library used for password encryption and http(s) communication. It is packed as [anaconda package](https://anaconda.org/paulscherrerinstitute/elog) and can be installed as follows: From 8b732a35afff83c26e4d8e49d91880418d9b09c2 Mon Sep 17 00:00:00 2001 From: Lucio Anderlini Date: Mon, 1 Jul 2024 17:21:45 +0000 Subject: [PATCH 4/5] added flag to verify ssl and added authentication to logbook.post --- elog/logbook.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/elog/logbook.py b/elog/logbook.py index 362d4ed..9e6a5e6 100644 --- a/elog/logbook.py +++ b/elog/logbook.py @@ -15,7 +15,7 @@ class Logbook(object): """ def __init__(self, hostname, logbook='', port=None, user=None, password=None, subdir='', use_ssl=True, - encrypt_pwd=True, bearer_token=None): + encrypt_pwd=True, bearer_token=None, verify_ssl=False): """ :param hostname: elog server hostname. If whole url is specified here, it will be parsed and arguments: "logbook, port, subdir, use_ssl" will be overwritten by parsed values. @@ -31,6 +31,7 @@ def __init__(self, hostname, logbook='', port=None, user=None, password=None, su salt= '' and rounds=5000) :param bearer_token: bearer token (if authentication needed) Access Token as obtained by an OAuth2 issuer. If callable, the token is retrieved calling this function each time is needed. + :param verify_ssl: verify the SSL certificate when connecting through HTTPS. Has no effect with HTTP. :return: """ hostname = hostname.strip() @@ -44,6 +45,7 @@ def __init__(self, hostname, logbook='', port=None, user=None, password=None, su url_scheme = parsed_url.scheme if url_scheme == 'http': use_ssl = False + verify_ssl = False elif url_scheme == 'https': use_ssl = True @@ -54,6 +56,7 @@ def __init__(self, hostname, logbook='', port=None, user=None, password=None, su url_scheme = 'https' else: url_scheme = 'http' + verify_ssl = False # ---- handle port ----- # 1) by default use port defined in the url @@ -108,6 +111,7 @@ def __init__(self, hostname, logbook='', port=None, user=None, password=None, su self._user = user self._password = _handle_pswd(password, encrypt_pwd) self._bearer_token_arg = bearer_token + self._verify_ssl = verify_ssl @property def _bearer_token(self): @@ -292,8 +296,15 @@ def post(self, message, msg_id=None, reply=False, attributes=None, attachments=N attributes_to_edit = _encode_values(attributes_to_edit) try: + request_headers = dict() + if self._user or self._password: + request_headers['Cookie'] = self._make_user_and_pswd_cookie() + if self._bearer_token: + request_headers['Authorization'] = 'Bearer ' + self._bearer_token + response = requests.post(self._url, data=attributes_to_edit, files=new_attachment_list, - allow_redirects=False, verify=False, timeout=timeout) + headers=request_headers, allow_redirects=False, verify=self._verify_ssl, + timeout=timeout) # Validate response. Any problems will raise an Exception. resp_message, resp_headers, resp_msg_id = _validate_response(response) @@ -343,7 +354,7 @@ def read(self, msg_id, timeout=None): try: self._check_if_message_on_server(msg_id) # raises exceptions if no message or no response from server response = requests.get(self._url + str(msg_id) + '?cmd=download', headers=request_headers, - allow_redirects=False, verify=False, timeout=timeout) + allow_redirects=False, verify=self._verify_ssl, timeout=timeout) # Validate response. If problems Exception will be thrown. resp_message, resp_headers, resp_msg_id = _validate_response(response) @@ -406,7 +417,7 @@ def delete_attachment(self, msg_id, text, attributes, attachment_id, timeout=Non just_text = list() just_text.append(('Text', ('', text.encode('iso-8859-1')))) try: - response = requests.post(self._url, data=attributes, verify=False, allow_redirects=False, + response = requests.post(self._url, data=attributes, verify=self._verify_ssl, allow_redirects=False, headers=request_headers, files=just_text) except requests.Timeout as e: # Catch here a timeout o the post request. @@ -451,7 +462,7 @@ def delete(self, msg_id, timeout=None): self._check_if_message_on_server(msg_id) # check if something to delete response = requests.get(self._url + str(msg_id) + '?cmd=Delete&confirm=Yes', headers=request_headers, - allow_redirects=False, verify=False, timeout=timeout) + allow_redirects=False, verify=self._verify_ssl, timeout=timeout) _validate_response(response) # raises exception if any other error identified @@ -507,7 +518,7 @@ def search(self, search_term, n_results=20, scope="subtext", timeout=None): try: response = requests.get(self._url, params=params, headers=request_headers, - allow_redirects=False, verify=False, timeout=timeout) + allow_redirects=False, verify=self._verify_ssl, timeout=timeout) # Validate response. If problems Exception will be thrown. _validate_response(response) @@ -546,7 +557,7 @@ def get_message_ids(self, timeout=None): try: response = requests.get(self._url + 'page', headers=request_headers, - allow_redirects=False, verify=False, timeout=timeout) + allow_redirects=False, verify=self._verify_ssl, timeout=timeout) # Validate response. If problems Exception will be thrown. _validate_response(response) @@ -581,7 +592,7 @@ def download_attachment(self, url, timeout=None): try: response = requests.get(url, headers=request_headers, allow_redirects=False, - verify=False, timeout=timeout) + verify=self._verify_ssl, timeout=timeout) # If there is no message code 200 will be returned (OK) and _validate_response will not recognise it # but there will be some error in the html code. resp_message, resp_headers, resp_msg_id = _validate_response(response) @@ -610,7 +621,7 @@ def _check_if_message_on_server(self, msg_id, timeout=None): request_headers['Authorization'] = 'Bearer ' + self._bearer_token try: response = requests.get(self._url + str(msg_id), headers=request_headers, allow_redirects=False, - verify=False, timeout=timeout) + verify=self._verify_ssl, timeout=timeout) # If there is no message code 200 will be returned (OK) and _validate_response will not recognise it # but there will be some error in the html code. From 34b9f046569791b7296cb8bcc3e88786e7381ccf Mon Sep 17 00:00:00 2001 From: Lucio Anderlini Date: Mon, 1 Jul 2024 17:44:13 +0000 Subject: [PATCH 5/5] added message_id retrival --- elog/logbook.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/elog/logbook.py b/elog/logbook.py index 9e6a5e6..4cac377 100644 --- a/elog/logbook.py +++ b/elog/logbook.py @@ -885,6 +885,15 @@ def _validate_response(response): # it was not possible to get the msg_id. # this may happen when deleting the last entry of a logbook msg_id = None + + if msg_id is None: + try: + msg_id = int(re.findall("Location: [^\r]*/([0-9]+)\r", response.text)[0]) + except ValueError: + msg_id = None + except IndexError: + msg_id = None + if b'type=password' in response.content or b'type="password"' in response.content: # Not too smart to check this way, but no other indication of this kind of error.