Skip to content

Commit 8bfce9a

Browse files
at88mphbsipocz
authored andcommitted
Fixes #2670
Added auth handling to login to Keycloak.
1 parent cfc7383 commit 8bfce9a

File tree

2 files changed

+102
-68
lines changed

2 files changed

+102
-68
lines changed

astroquery/alma/core.py

Lines changed: 99 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from ..exceptions import LoginError
3131
from ..utils import commons
3232
from ..utils.process_asyncs import async_to_sync
33-
from ..query import QueryWithLogin
33+
from ..query import BaseQuery, QueryWithLogin
3434
from .tapsql import _gen_pos_sql, _gen_str_sql, _gen_numeric_sql,\
3535
_gen_band_list_sql, _gen_datetime_sql, _gen_pol_sql, _gen_pub_sql,\
3636
_gen_science_sql, _gen_spec_res_sql, ALMA_DATE_FORMAT
@@ -212,6 +212,92 @@ def _gen_sql(payload):
212212
return sql + where
213213

214214

215+
#
216+
# Authentication session information for connecting to an OIDC instance. Assumes an OIDC system like Keycloak
217+
# with a preconfigured client called "oidc".
218+
#
219+
class AlmaAuth(BaseQuery):
220+
_CLIENT_ID = 'oidc'
221+
_GRANT_TYPE = 'password'
222+
_INVALID_PASSWORD_MESSAGE = 'Invalid user credentials'
223+
_REALM_ENDPOINT = '/auth/realms/ALMA'
224+
_LOGIN_ENDPOINT = f'{_REALM_ENDPOINT}/protocol/openid-connect/token'
225+
_VERIFY_WELL_KNOWN_ENDPOINT = f'{_REALM_ENDPOINT}/.well-known/openid-configuration'
226+
227+
def __init__(self):
228+
super().__init__()
229+
self._auth_hosts = auth_urls
230+
self._auth_host = None
231+
232+
233+
def set_auth_hosts(self, auth_hosts):
234+
"""
235+
Set the available hosts to check for login endpoints.
236+
237+
Parameters
238+
----------
239+
auth_hosts : array
240+
Available hosts name. Checking each one until one returns a 200 for
241+
the well-known endpoint.
242+
"""
243+
self._auth_hosts = auth_hosts
244+
245+
@property
246+
def host(self):
247+
if self._auth_host is None:
248+
for auth_url in self._auth_hosts:
249+
# set session cookies (they do not get set otherwise)
250+
url_to_check = f'https://{auth_url}{self._VERIFY_WELL_KNOWN_ENDPOINT}'
251+
response = self._request("HEAD", url_to_check, cache=False)
252+
253+
if response.status_code == 200:
254+
self._auth_host = auth_url
255+
log.debug(f'Set auth host to {self._auth_host}')
256+
break
257+
258+
if self._auth_host is None:
259+
raise LoginError(f'No useable hosts to login to: {self._auth_hosts}')
260+
else:
261+
return self._auth_host
262+
263+
def login(self, username, password):
264+
"""
265+
Authenticate to one of the configured hosts.
266+
267+
Parameters
268+
----------
269+
username : str
270+
The username to authenticate with
271+
password : str
272+
The user's password
273+
"""
274+
data = {
275+
'username': username,
276+
'password': password,
277+
'grant_type': self._GRANT_TYPE,
278+
'client_id': self._CLIENT_ID
279+
}
280+
281+
login_url = f'https://{self.host}{self._LOGIN_ENDPOINT}'
282+
log.info(f'Authenticating {username} on {login_url}.')
283+
login_response = self._request('POST', login_url, data=data, cache=False)
284+
json_auth = login_response.json()
285+
286+
if 'error' in json_auth:
287+
log.debug(f'{json_auth}')
288+
error_message = json_auth['error_description']
289+
if self._INVALID_PASSWORD_MESSAGE not in error_message:
290+
raise LoginError("Could not log in to ALMA authorization portal: "
291+
f"{self.host} Message from server: {error_message}")
292+
else:
293+
log.error(error_message)
294+
elif 'access_token' not in json_auth:
295+
raise LoginError("Could not log in to any of the known ALMA authorization portals: \n"
296+
f"No error from server, but missing access token from host: {self.host}")
297+
else:
298+
log.info(f'Successfully logged in to {self._auth_host}')
299+
300+
215301
@async_to_sync
216302
class AlmaClass(QueryWithLogin):
217303

@@ -228,6 +314,11 @@ def __init__(self):
228314
self._sia_url = None
229315
self._tap_url = None
230316
self._datalink_url = None
317+
self._auth = AlmaAuth()
318+
319+
@property
320+
def auth(self):
321+
return self._auth
231322

232323
@property
233324
def datalink(self):
@@ -875,11 +966,7 @@ def _get_auth_info(self, username, *, store_password=False,
875966
else:
876967
username = self.USERNAME
877968

878-
if hasattr(self, '_auth_url'):
879-
auth_url = self._auth_url
880-
else:
881-
raise LoginError("Login with .login() to acquire the appropriate"
882-
" login URL")
969+
auth_url = self.auth.host
883970

884971
# Get password from keyring or prompt
885972
password, password_from_keyring = self._get_password(
@@ -909,69 +996,16 @@ def _login(self, username=None, store_password=False,
909996
on the keyring. Default is False.
910997
"""
911998

912-
success = False
913-
for auth_url in auth_urls:
914-
# set session cookies (they do not get set otherwise)
915-
cookiesetpage = self._request("GET",
916-
urljoin(self._get_dataarchive_url(),
917-
'rh/forceAuthentication'),
918-
cache=False)
919-
self._login_cookiepage = cookiesetpage
920-
cookiesetpage.raise_for_status()
921-
922-
if (auth_url+'/cas/login' in cookiesetpage.request.url):
923-
# we've hit a target, we're good
924-
success = True
925-
break
926-
if not success:
927-
raise LoginError("Could not log in to any of the known ALMA "
928-
"authorization portals: {0}".format(auth_urls))
929-
930-
# Check if already logged in
931-
loginpage = self._request("GET", "https://{auth_url}/cas/login".format(auth_url=auth_url),
932-
cache=False)
933-
root = BeautifulSoup(loginpage.content, 'html5lib')
934-
if root.find('div', class_='success'):
935-
log.info("Already logged in.")
936-
return True
937-
938-
self._auth_url = auth_url
939-
999+
self.auth.set_auth_hosts(auth_urls)
1000+
9401001
username, password = self._get_auth_info(username=username,
9411002
store_password=store_password,
9421003
reenter_password=reenter_password)
1004+
1005+
self.auth.login(username, password)
1006+
self.USERNAME = username
9431007

944-
# Authenticate
945-
log.info("Authenticating {0} on {1} ...".format(username, auth_url))
946-
# Do not cache pieces of the login process
947-
data = {kw: root.find('input', {'name': kw})['value']
948-
for kw in ('execution', '_eventId')}
949-
data['username'] = username
950-
data['password'] = password
951-
data['submit'] = 'LOGIN'
952-
953-
login_response = self._request("POST", "https://{0}/cas/login".format(auth_url),
954-
params={'service': self._get_dataarchive_url()},
955-
data=data,
956-
cache=False)
957-
958-
# save the login response for debugging purposes
959-
self._login_response = login_response
960-
# do not expose password back to user
961-
del data['password']
962-
# but save the parameters for debug purposes
963-
self._login_parameters = data
964-
965-
authenticated = ('You have successfully logged in' in
966-
login_response.text)
967-
968-
if authenticated:
969-
log.info("Authentication successful!")
970-
self.USERNAME = username
971-
else:
972-
log.exception("Authentication failed!")
973-
974-
return authenticated
1008+
return True
9751009

9761010
def get_cycle0_uid_contents(self, uid):
9771011
"""

docs/alma/alma.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ Authentication
8080
==============
8181
8282
Users can log in to acquire proprietary data products. Login is performed
83-
via the ALMA CAS (central authentication server).
83+
via the ALMA OIDC (OpenID Connect) service, Keycloak.
8484
8585
.. doctest-skip::
8686
@@ -97,11 +97,11 @@ via the ALMA CAS (central authentication server).
9797
ICONDOR, enter your ALMA password:
9898
<BLANKLINE>
9999
Authenticating ICONDOR on asa.alma.cl...
100-
Authentication successful!
100+
Successfully logged in to asa.alma.cl
101101
>>> # After the first login, your password has been stored
102102
>>> alma.login("ICONDOR")
103103
Authenticating ICONDOR on asa.alma.cl...
104-
Authentication successful!
104+
Successfully logged in to asa.alma.cl
105105
106106
Your password will be stored by the `keyring
107107
<https://pypi.python.org/pypi/keyring>`_ module.

0 commit comments

Comments
 (0)