def get_auth_code_from_callback(self, request): """ This function processes the callback from the OAuth2 provider server; in particular, it gets the authorization code out of the request and checks the state parameter as well, if applicable. :param request: the request object made by the 3rd party OAuth2 provider server. :return: """ # first, check for the state parameter and, if passed, compare it to the state in the session logger.debug( f"top of get_auth_code_from_callback; request.args: {request.args}; request: {request}" ) req_state = request.args.get('state') if req_state: state = session.get('state') if not state == req_state: logger.error( f"ERROR! state stored in the session ({state}) did not match the state passed in" f"the callback ({req_state}") raise errors.ServiceConfigError( "Error processing provider callback -- state mismatch.") req_code = request.args.get('code') if not req_code: logger.error( f"ERROR! did not receive an authorization code in the callback." ) raise errors.ServiceConfigError( "Error processing provider callback -- code missing.") self.authorization_code = req_code
def get_token_using_auth_code(self): """ Exchange the authorization code for an access token from the provider server. :return: """ logger.debug("top of get_token_using_auth_code") # todo -- it is possible these body parameters will need to change for different oauth2 servers # note -- this function is not called by CII because it does non-standard OAuth. body = { "client_id": self.client_id, "client_secret": self.client_key, "code": self.authorization_code, "redirect_uri": self.callback_url } logger.debug( f"making POST to token url {self.oauth2_token_url}...; body: {body}" ) try: rsp = requests.post(self.oauth2_token_url, data=body, headers={'Accept': 'application/json'}) except Exception as e: logger.error( f"Got exception from POST request to OAuth server attempting to exchange the" f"authorization code for a token. Debug data:" f"request body: {body}" f"exception: {e}") raise errors.ServiceConfigError( "Error requesting access token. Contact server administrator.") logger.debug( f"successfully made POST to token url {self.oauth2_token_url}; rsp: {rsp};" f"rsp.content: {rsp.content}") # todo -- it is possible different provider servers will not pass JSON try: self.access_token = rsp.json().get('access_token') except Exception as e: logger.error( f"Got exception trying to process response from POST request to exchange the" f"authorization code for a token. Debug data:" f"request body: {body};" f"response: {rsp}" f"exception: {e}") raise errors.ServiceConfigError( "Error parsing access token. Contact server administrator.") logger.debug(f"successfully got access_token: {self.access_token}") return self.access_token
def get_config(self, tenant_id): """ Returns the config for a specific tenant from the cache. :param tenant_id: :return: """ logger.debug(f"top of get_config for tenant: {tenant_id}") tries = 0 # first, check if the cache is older than the configured max cache lifetime. if datetime.datetime.now() > self.last_update + self.cache_lifetime: self.load_tenant_config_cache() # if we just reloaded the cache, we don't need the check below tries = 1 while tries < 2: for t in self.tenant_config_models: if t.tenant_id == tenant_id: return t # the first pass through, if we didn't find the tenant_id, reload the cache and try again if tries==0: self.load_tenant_config_cache() tries = 1 continue tries = 2 raise errors.ServiceConfigError(f"tenant id {tenant_id} not found in tenant configurations.")
def __init__(self, tenant_id, is_local_development=False): # the tenant id for this OAuth2 provider self.tenant_id = tenant_id # the custom tenant config for this tenant self.tenant_config = tenant_configs_cache.get_config( tenant_id=tenant_id) # the type of OAuth2 provider, such as github self.ext_type = tenant_configs_cache.get_custom_oa2_extension_type( tenant_id) # the actual custom_idp_configuration object, as a python dictionary self.custom_idp_config_dict = json.loads( self.tenant_config.custom_idp_configuration) # whether or not this authenticator is running in local development mode (i.e., on localhost) self.is_local_development = is_local_development # validate that this tenant should be using the OAuth2 extension module. if not self.ext_type: raise errors.ResourceError( f"Tenant {tenant_id} not configured for a custom OAuth2 extension." ) tenant_base_url = t.tenant_cache.get_tenant_config(tenant_id).base_url if self.is_local_development: self.callback_url = f'http://localhost:5000/v3/oauth2/extensions/oa2/callback' else: self.callback_url = f'{tenant_base_url}/v3/oauth2/extensions/oa2/callback' # These attributes get computed later, as a result of the OAuth flow ---------- self.authorization_code = None self.access_token = None self.username = None # Custom configs for each provider --------------- if self.ext_type == 'github': # github calls the client_key the "client_secret" self.client_id = self.custom_idp_config_dict.get('github').get( 'client_id') self.client_key = self.custom_idp_config_dict.get('github').get( 'client_secret') # initial redirect URL; used to start the oauth flow and log in the user self.identity_redirect_url = 'https://github.com/login/oauth/authorize' # URL to use to exchange the code for an qccess token self.oauth2_token_url = 'https://github.com/login/oauth/access_token' elif self.ext_type == 'cii': # we configure the CII redirect URL directly in the config because there are different CII environments. self.identity_redirect_url = self.custom_idp_config_dict.get( 'cii').get('login_url') if not self.identity_redirect_url: raise errors.ServiceConfigError( f"Missing required cii config, identity_redirect_url. " f"Config: {self.custom_idp_config_dict}") self.jwt_decode_key = self.custom_idp_config_dict.get('cii').get( 'jwt_decode_key') if not self.jwt_decode_key: raise errors.ServiceConfigError( f"Missing required cii config, jwt_decode_key. " f"Config: {self.custom_idp_config_dict}") self.check_jwt_signature = self.custom_idp_config_dict.get( 'check_jwt_signature') # note that CII does not implement standard OAuth2; they do not require a client id and key and they do not # create an authorization code to be exchanged for a token. self.client_id = 'not_used' self.client_key = 'not_used' # # NOTE: each provider type must implement this check # elif self.ext_type == 'google' # ... else: logger.error( f"ERROR! OAuth2ProviderExtension constructor not implemented for OAuth2 provider " f"extension {self.ext_type}.") raise errors.ServiceConfigError( f"Error processing callback URL: extension type {self.ext_type} not " f"supported.")
def get_user_from_token(self): """ Determines the username for the user once an access token has been obtained. :return: """ logger.debug("top of get_user_from_token") # todo -- each OAuth2 provider will have a different mechanism for determining the user's identity if self.ext_type == 'github': user_info_url = 'https://api.github.com/user' headers = { 'Authorization': f'token {self.access_token}', 'Accept': 'application/vnd.github.v3+json' } try: rsp = requests.get(user_info_url, headers=headers) except Exception as e: logger.error( f"Got exception from request to look up user's identity with github. Debug data:" f"exception: {e}") raise errors.ServiceConfigError( "Error determining user identity. Contact server administrator." ) if not rsp.status_code == 200: logger.error( "Did not get 200 from request to look up user's identity with github. Debug data:" f"status code: {rsp.status_code};" f"rsp content: {rsp.content}") raise errors.ServiceConfigError( "Error determining user identity. Contact server administrator." ) username = rsp.json().get('login') if not username: logger.error( f"username was none after processing the github response. Debug data:" f"response: {rsp}") raise errors.ServiceConfigError( "Error determining user identity: username was empty. " "Contact server administrator.") self.username = f'{username}@github.com' logger.debug( f"Successfully determined user's identity: {self.username}") return self.username elif self.ext_type == 'cii': # the CII token is a JWT; we only need to decode it and get the username out of the payload. # todo -- we should verify the signature if that is working... try: claims = jwt.decode(self.access_token, self.jwt_decode_key, verify=self.check_jwt_signature) except Exception as e: msg = f"got exception trying to decode the CII jwt; exception: {e}" logger.error(msg) raise errors.ResourceError( f"Unable to decode third-party JWT. Contact system administrator." f"(Debug message:{msg})") self.username = claims.get('username') if not self.username: msg = f"Did not get a username from the CII jwt; full claims: {claims}" logger.error(msg) raise errors.ResourceError( f"Unable to determine username from third-party JWT. Contact system " f"administrator." f"(Debug message:{msg})") logger.debug( f"Successfully determined user's identity: {self.username}") return self.username # elif self.ext_type == 'google': # ... else: logger.error( f"ERROR! OAuth2ProviderExtension.get_user_from_token not implemented for OAuth2 provider " f"extension {self.ext_type}.") raise errors.ServiceConfigError( f"Error determining user identity: extension type {self.ext_type} not " f"supported.")
def authentication(): """ Entry point for checking authentication for all requests to the authenticator. :return: """ # The authenticator uses different authentication methods for different endpoints. For example, the service # APIs such as clients and profiles use pure JWT authentication, while the OAuth endpoints use Basic Authentication # with OAuth client credentials. logger.debug(f"base_url: {request.base_url}; url_rule: {request.url_rule}") if not hasattr(request, 'url_rule') or not hasattr( request.url_rule, 'rule') or not request.url_rule.rule: raise common_errors.ResourceError( "The endpoint and HTTP method combination " "are not available from this service.") # the metadata endpoint is publicly available if '/v3/oauth2/.well-known/' in request.url_rule.rule: logger.debug( ".well-known endpoint; request is allowed to be made unauthenticated." ) auth.resolve_tenant_id_for_request() return True # only the authenticator's own service token and tenant admins for the tenant can retrieve or modify the tenant # config if '/v3/oauth2/admin' in request.url_rule.rule: logger.debug( "admin endpoint; checking for authentictor service token or tenant admin role..." ) # admin endpoints always require tapis token auth auth.authentication() # we'll need to use the request's tenant_id, so make sure it is resolved now auth.resolve_tenant_id_for_request() # first, make sure this request is for a tenant served by this authenticator if g.request_tenant_id not in conf.tenants: raise common_errors.PermissionsError( f"The request is for a tenant ({g.request_tenant_id}) that is not " f"served by this authenticator.") # we only want to honor tokens from THIS authenticator; i.e., not some other authenticator. therefore, we need # to check that the tenant_id associated with the token (g.tenant_id) is the same as THIS authenticator's tenant # id; if g.username == conf.service_name and g.tenant_id == conf.service_tenant_id: logger.info( f"allowing admin request because username was {conf.service_name} " f"and tenant was {conf.service_tenant_id}") return True logger.debug( f"request token does not represent THIS authenticator: token username: {g.username};" f" request tenant: {g.tenant_id}. Now checking for tenant admin..." ) # all other service accounts are not allowed to update authenticator if g.account_type == 'service': raise common_errors.PermissionsError( "Not authorized -- service accounts are not allowed to access the" "authenticator admin endpoints.") # sanity check -- the request tenant id should be the same as the token tenant id in the remaining cases because # they are all user tokens if not g.request_tenant_id == g.tenant_id: logger.error( f"program error -- g.request_tenant_id: {g.request_tenant_id} not equal to " f"g.tenant_id: {g.tenant_id} even though account type was user!" ) raise common_errors.ServiceConfigError( f"Unexpected program error checking permissions. The tenant id of" f"the request ({g.request_tenant_id}) did not match the tenant id " f"of the access token ({g.tenant_id}). Please contact server " f"administrators.") # check SK for tenant admin -- try: rsp = t.sk.isAdmin(tenant=g.tenant_id, user=g.username) except Exception as e: logger.error( f"Got exception trying to check tenant admin role for tenant: {g.tenant_id} " f"and user: {g.username}; exception: {e}") raise common_errors.PermissionsError( "Could not check tenant admin role with SK; this role is required for " "accessing the authenticator admin endpoints.") try: if rsp.isAuthorized: logger.info( f"user {g.username} had tenant admin role for tenant {g.tenant_id}; allowing request." ) return True else: logger.info( f"user {g.username} DID NOT have tenant admin role for tenant {g.tenant_id}; " f"NOT allowing request.") raise common_errors.PermissionsError( "Permission denied -- Tenant admin role required for accessing " "the authenticator admin endpoints.") except Exception as e: logger.error( f"got exception trying to check isAuthorized property from isAdmin() call to SK." f"username: {g.username}; tenant: {g.tenant_id}; rsp: {rsp}; e: {e}" ) logger.info( f"user {g.username} DID NOT have tenant admin role for tenant {g.tenant_id}; " f"NOT allowing request.") raise common_errors.PermissionsError( "Permission denied -- Tenant admin role required for accessing the " "authenticator admin endpoints.") # no credentials required on the authorize, login and oa2 extenion pages if '/v3/oauth2/authorize' in request.url_rule.rule or '/v3/oauth2/login' in request.url_rule.rule \ or '/oauth2/extensions' in request.url_rule.rule: # always resolve the request tenant id based on the URL: logger.debug( "authorize, login or oa2 extension page. Resolving tenant_id") auth.resolve_tenant_id_for_request() try: logger.debug(f"request_tenant_id: {g.request_tenant_id}") except AttributeError: raise common_errors.BaseTapisError( "Unable to resolve tenant_id for request.") return True # the profiles endpoints always use standard Tapis Token auth - if '/v3/oauth2/profiles' in request.url_rule.rule or \ '/v3/oauth2/userinfo' in request.url_rule.rule: auth.authentication() # always resolve the request tenant id based on the URL: auth.resolve_tenant_id_for_request() return True # the clients endpoints need to accept both standard Tapis Token auth and basic auth, if '/v3/oauth2/clients' in request.url_rule.rule: # first check for basic auth header: parts = get_basic_auth_parts() if parts: logger.debug("oauth2 clients page, with basic auth header.") # do basic auth against the ldap # always resolve the request tenant id based on the URL: auth.resolve_tenant_id_for_request() try: logger.debug(f"request_tenant_id: {g.request_tenant_id}") except AttributeError: raise common_errors.BaseTapisError( "Unable to resolve tenant_id for request.") check_username_password(parts['tenant_id'], parts['username'], parts['password']) return True else: logger.debug("oauth2 clients page, no basic auth header.") # check for a Tapis token auth.authentication() # always resolve the request tenant id based on the URL: auth.resolve_tenant_id_for_request() try: logger.debug(f"request_tenant_id: {g.request_tenant_id}") except AttributeError: raise common_errors.BaseTapisError( "Unable to resolve tenant_id for request.") return True if '/v3/oauth2/tokens' in request.url_rule.rule: logger.debug("oauth2 tokens URL") # the tokens endpoint uses basic auth with the client; logic handled in the controller. # however, it does # require the request tenant id: # first, check if an X-Tapis-Token header appears in the request. We do not honor JWT authentication for # generating new tokens, but we also don't want to fail for an expired token. So, we remove the token header # if it if 'X-Tapis-Token' in request.headers: logger.debug("Got an X-Tapis-Token header.") try: auth.add_headers() auth.validate_request_token() except: # we need to set the token claims because the resolve_tenant_id_for_request method depends on it: g.token_claims = {} # now, resolve the tenant_id try: auth.resolve_tenant_id_for_request() except: # we need to catch and swallow permissions errors having to do with an invalid JWT; if the JWT is invalid, # its claims (including its tenant claim) will be ignored, but then resolve_tenant_id_for_request() will # throw an error because the None tenant_id claim will not match the tenant_id of the URL. pass try: logger.debug(f"request_tenant_id: {g.request_tenant_id}") except AttributeError: raise common_errors.BaseTapisError( "Unable to resolve tenant_id for request.") return True if '/v3/oauth2/logout' in request.url_rule.rule \ or '/v3/oauth2/login' in request.url_rule.rule \ or '/v3/oauth2/tenant' in request.url_rule.rule \ or '/v3/oauth2/webapp' in request.url_rule.rule \ or '/v3/oauth2/portal-login' in request.url_rule.rule: # or '/v3/oauth2/webapp/callback' in request.url_rule.rule \ # or '/v3/oauth2/webapp/token-display' in request.url_rule.rule \ logger.debug("call is for some token webapp page.") auth.resolve_tenant_id_for_request() try: logger.debug(f"request_tenant_id: {g.request_tenant_id}") except AttributeError: raise common_errors.BaseTapisError( "Unable to resolve tenant_id for request.") # make sure this tenant allows the token web app config = tenant_configs_cache.get_config(g.request_tenant_id) logger.debug(f"got tenant config: {config.serialize}") if not config.use_token_webapp: logger.info( f"tenant {g.request_tenant_id} not configured for the token webapp. Raising error" ) raise common_errors.PermissionsError( "This tenant is not configured to use the Token Webapp.") return True
def validate_config(): """ Validates the config provided in the config.json file. """ print(f"top of validate_config; found this config: {conf}") # we must be running as the Tokens API or this is not going to work. if not conf.service_name == 'tokens': raise errors.ServiceConfigError( f"Invalid config: conf.service_name must be 'tokens' not {conf.service_name}." f"This program must run as the Tokens API to be able to interact with SK." ) # this keys management program leverages the Tokens API code for various calls if conf.use_sk: raise errors.ServiceConfigError( f"Invalid config: conf.use_sk must be False so that the tokens code running for " f"this program does not try to retrieve the private keys from the SK at" f"start up (they may not exist yet).") if not conf.dev_jwt_private_key: raise errors.ServiceConfigError( f"Invalid config: conf.dev_jwt_private_key required and must be the site admin " f"tenant private key.") # check that all tenant id's are valid for tn in conf.tenants: found = False for valid_tenant in t.tenant_cache.tenants: if valid_tenant.tenant_id == tn: found = True # check that all tenants in the list are owned by the site_id if not valid_tenant.site_id == conf.site_id: raise errors.ServiceConfigError( f"Invalid tenant '{tn}' configured: tenant owned by {valid_tenant.site_id}, " f"not owned by the configured site ({conf.site_id}.)") if not found: raise errors.ServiceConfigError( f"Invalid tenant {tn} configured: tenant not found; available " f"tenants: {valid_tenants}") if conf.running_at_primary_site: # first check for all required configs: if not hasattr(conf, 'update_associate_site'): raise errors.ServiceConfigError( "running_at_primary_site was 'true' so update_associate_site (t/f) config " "required.") if conf.update_associate_site: # when updating an associate site at the primary site, the associate site id is also required: if not hasattr(conf, 'associate_site_id'): raise errors.ServiceConfigError( "running_at_primary_site was 'true' and 'update_associate_site' was " "true so associate_site_id config required.") # check that there is a directory for each tenant in the list of tenants for tn in conf.tenants: tn_dir_path = os.path.join(DATA_DIR, tn) if not os.path.isdir(tn_dir_path): raise errors.ServiceConfigError( f"Did not find data directory for tenant {tn}" f"Expected a directory at {tn_dir_path}.") # check for the existence of a public key file pub_key_path = os.path.join(DATA_DIR, tn, 'pub.key') if not os.path.isfile(pub_key_path): raise errors.ServiceConfigError( f"Did not find public key for tenant {tn}" f"Expected a public key file at {pub_key_path}.") # check that we can talk to the sk and that the tokens user has the tenant_definition_updater role try: has_role = t.sk.hasRole(roleName='tenant_definition_updater', user='******', tenant=conf.service_tenant_id) except Exception as e: raise errors.ServiceConfigError( f"Got an exception checking that tokens has the tenant_definition_updater role;" f"exception: {e}") if not has_role.isAuthorized: raise errors.ServiceConfigError( f"Got FALSE checking that tokens has the tenant_definition_updater role;" f"has_role: {has_role}") print("tokens user has necessary role.")