3030from ..exceptions import LoginError
3131from ..utils import commons
3232from ..utils .process_asyncs import async_to_sync
33- from ..query import QueryWithLogin
33+ from ..query import BaseQuery , QueryWithLogin
3434from .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,101 @@ def _gen_sql(payload):
212212 return sql + where
213213
214214
215+ class AlmaAuth (BaseQuery ):
216+ """Authentication session information for passing credentials to an OIDC instance
217+
218+ Assumes an OIDC system like Keycloak with a preconfigured client app called "oidc" to validate against.
219+ This does not use Tokens in the traditional OIDC sense, but rather uses the Keycloak specific endpoint
220+ to validate a username and password. Passwords are then kept in a Python keyring.
221+ """
222+
223+ _CLIENT_ID = 'oidc'
224+ _GRANT_TYPE = 'password'
225+ _INVALID_PASSWORD_MESSAGE = 'Invalid user credentials'
226+ _REALM_ENDPOINT = '/auth/realms/ALMA'
227+ _LOGIN_ENDPOINT = f'{ _REALM_ENDPOINT } /protocol/openid-connect/token'
228+ _VERIFY_WELL_KNOWN_ENDPOINT = f'{ _REALM_ENDPOINT } /.well-known/openid-configuration'
229+
230+ def __init__ (self ):
231+ super ().__init__ ()
232+ self ._auth_hosts = auth_urls
233+ self ._auth_host = None
234+
235+ @property
236+ def auth_hosts (self ):
237+ return self ._auth_hosts
238+
239+ @auth_hosts .setter
240+ def auth_hosts (self , auth_hosts ):
241+ """
242+ Set the available hosts to check for login endpoints.
243+
244+ Parameters
245+ ----------
246+ auth_hosts : array
247+ Available hosts name. Checking each one until one returns a 200 for
248+ the well-known endpoint.
249+ """
250+ if auth_hosts is None :
251+ raise LoginError ('Valid authentication hosts cannot be None' )
252+ else :
253+ self ._auth_hosts = auth_hosts
254+
255+ def get_valid_host (self ):
256+ if self ._auth_host is None :
257+ for auth_url in self ._auth_hosts :
258+ # set session cookies (they do not get set otherwise)
259+ url_to_check = f'https://{ auth_url } { self ._VERIFY_WELL_KNOWN_ENDPOINT } '
260+ response = self ._request ("HEAD" , url_to_check , cache = False )
261+
262+ if response .status_code == 200 :
263+ self ._auth_host = auth_url
264+ log .debug (f'Set auth host to { self ._auth_host } ' )
265+ break
266+
267+ if self ._auth_host is None :
268+ raise LoginError (f'No useable hosts to login to: { self ._auth_hosts } ' )
269+ else :
270+ return self ._auth_host
271+
272+ def login (self , username , password ):
273+ """
274+ Authenticate to one of the configured hosts.
275+
276+ Parameters
277+ ----------
278+ username : str
279+ The username to authenticate with
280+ password : str
281+ The user's password
282+ """
283+ data = {
284+ 'username' : username ,
285+ 'password' : password ,
286+ 'grant_type' : self ._GRANT_TYPE ,
287+ 'client_id' : self ._CLIENT_ID
288+ }
289+
290+ login_url = f'https://{ self .get_valid_host ()} { self ._LOGIN_ENDPOINT } '
291+ log .info (f'Authenticating { username } on { login_url } .' )
292+ login_response = self ._request ('POST' , login_url , data = data , cache = False )
293+ json_auth = login_response .json ()
294+
295+ if 'error' in json_auth :
296+ log .debug (f'{ json_auth } ' )
297+ error_message = json_auth ['error_description' ]
298+ if self ._INVALID_PASSWORD_MESSAGE not in error_message :
299+ raise LoginError ("Could not log in to ALMA authorization portal: "
300+ f"{ self .get_valid_host ()} Message from server: { error_message } " )
301+ else :
302+ raise LoginError (error_message )
303+ elif 'access_token' not in json_auth :
304+ raise LoginError ("Could not log in to any of the known ALMA authorization portals: \n "
305+ f"No error from server, but missing access token from host: { self .get_valid_host ()} " )
306+ else :
307+ log .info (f'Successfully logged in to { self ._auth_host } ' )
308+
309+
215310@async_to_sync
216311class AlmaClass (QueryWithLogin ):
217312
@@ -228,6 +323,11 @@ def __init__(self):
228323 self ._sia_url = None
229324 self ._tap_url = None
230325 self ._datalink_url = None
326+ self ._auth = AlmaAuth ()
327+
328+ @property
329+ def auth (self ):
330+ return self ._auth
231331
232332 @property
233333 def datalink (self ):
@@ -875,11 +975,7 @@ def _get_auth_info(self, username, *, store_password=False,
875975 else :
876976 username = self .USERNAME
877977
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" )
978+ auth_url = self .auth .get_valid_host ()
883979
884980 # Get password from keyring or prompt
885981 password , password_from_keyring = self ._get_password (
@@ -909,69 +1005,16 @@ def _login(self, username=None, store_password=False,
9091005 on the keyring. Default is False.
9101006 """
9111007
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
1008+ self .auth .auth_hosts = auth_urls
9391009
9401010 username , password = self ._get_auth_info (username = username ,
9411011 store_password = store_password ,
9421012 reenter_password = reenter_password )
9431013
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!" )
1014+ self .auth .login (username , password )
1015+ self .USERNAME = username
9731016
974- return authenticated
1017+ return True
9751018
9761019 def get_cycle0_uid_contents (self , uid ):
9771020 """
0 commit comments