class UserLookupSet: """ A container for a number of user handlers that provides caching for said handlers. """ def __init__(self, user_lookup: Set[UserLookup], cache_timer: Callable[[], int] = None, cache_max_size: int = 10000, cache_user_expiration: int = 300, cache_is_valid_expiration: int = 3600) -> None: """ Create the handler set. The cache_* parameters are mainly provided for testing purposes. :param user_lookup: the set of user lookup instances to query when looking up user names from tokens or checking that a provided user name is valid. :param cache_timer: the timer used for cache expiration. Defaults to time.time. :param cache_max_size: the maximum size of the token -> user and username -> validity caches. :param cache_user_expiration: the default expiration time for the token -> user cache in seconds. This time can be overridden by a user handler on a per token basis. :param cache_is_valid_expiration: the default expiration time for the username -> validity cache. This time can be overridden by a user handler on a per user basis. """ no_Nones_in_iterable(user_lookup, 'user_lookup') self._lookup = {l.get_authsource_id(): l for l in user_lookup} self._cache_timer = time.time if not cache_timer else cache_timer self._user_cache = LRUCache(timer=self._cache_timer, maxsize=cache_max_size, ttl=cache_user_expiration) self._valid_cache = LRUCache(timer=self._cache_timer, maxsize=cache_max_size, ttl=cache_is_valid_expiration) def _check_authsource_id(self, authsource_id: AuthsourceID) -> None: """ :raises NoSuchAuthsourceError: if there's no handler for the provided authsource. """ not_none(authsource_id, 'authsource_id') if authsource_id not in self._lookup: raise NoSuchAuthsourceError(authsource_id.id) def _calc_ttl(self, epoch, rel): if not rel and not epoch: return None if not rel: return epoch - self._cache_timer() if not epoch: return rel return min(epoch - self._cache_timer(), rel) def get_user(self, authsource_id: AuthsourceID, token: Token) -> Tuple[User, bool]: """ Get a user given the user's token. :param authsource_id: the authsource where the user resides. :param token: the users's token. :raises TypeError: if any of the arguments are None. :raises NoSuchAuthsourceError: if there's no handler for the provided authsource. :raises InvalidTokenError: if the token is invalid. :returns: a tuple of the user and a boolean indicating whether the authsource claims the user is a mapping service system admin. """ not_none(token, 'token') self._check_authsource_id(authsource_id) # None default causes a key error cacheres = self._user_cache.get((authsource_id, token), default=False) if cacheres: return cacheres user, admin, epoch, rel = self._lookup[authsource_id].get_user(token) self._user_cache.set((authsource_id, token), (user, admin), ttl=self._calc_ttl(epoch, rel)) return (user, admin) def is_valid_user(self, user: User) -> bool: """ Check whether a given user exists. :param user: the user to check. :raises NoSuchAuthsourceError: if there's no handler for the user's authsource. """ not_none(user, 'user') self._check_authsource_id(user.authsource_id) # None default causes a key error exists = self._valid_cache.get(user, default=False) if not exists: exists, epoch, rel = self._lookup[ user.authsource_id].is_valid_user(user.username) if exists: self._valid_cache.set(user, True, ttl=self._calc_ttl(epoch, rel)) return exists
class KBaseUserLookup: ''' A client for contacting the KBase authentication server to verify user names. ''' def __init__(self, auth_url: str, auth_token: str, full_admin_roles: List[str] = None, read_admin_roles: List[str] = None, cache_max_size: int = 10000, cache_admin_expiration: int = 300, cache_valid_expiration: int = 3600): ''' Create the client. :param auth_url: The root url of the authentication service. :param auth_token: A valid token for the authentication service. :raises InvalidTokenError: if the token is invalid :param cache_max_size: the maximum size of the token -> admin and username -> validity caches. :param cache_admin_expiration: the default expiration time for the token -> admin cache in seconds. This time can be overridden by a user handler on a per token basis. :param cache_valid_expiration: the default expiration time for the username -> validity cache. This time can be overridden by a user handler on a per user basis. ''' self._url = _not_falsy(auth_url, 'auth_url') if not self._url.endswith('/'): self._url += '/' self._user_url = self._url + 'api/V2/users?list=' self._me_url = self._url + 'api/V2/me' self._token = _not_falsy(auth_token, 'auth_token') self._full_roles = set(full_admin_roles) if full_admin_roles else set() self._read_roles = set(read_admin_roles) if read_admin_roles else set() self._cache_timer = time.time self._admin_cache = LRUCache(timer=self._cache_timer, maxsize=cache_max_size, ttl=cache_admin_expiration) self._valid_cache = LRUCache(timer=self._cache_timer, maxsize=cache_max_size, ttl=cache_valid_expiration) # Auth 0.4.1 needs to be deployed before this will work # r = requests.get(self._url, headers={'Accept': 'application/json'}) # self._check_error(r) # if r.json().get('servicename') != 'Authentication Service': # raise IOError(f'The service at {self._url} does not appear to be the KBase ' + # 'Authentication Service') # could use the server time to adjust for clock skew, probably not worth the trouble # check token is valid r = requests.get(self._user_url, headers={ 'Accept': 'application/json', 'authorization': self._token }) self._check_error(r) # need to test this with a mock. YAGNI for now. # if r.json() != {}: # raise ValueError(f'Invalid auth url, expected empty map, got {r.text}') def _check_error(self, r): if r.status_code != 200: try: j = r.json() except Exception: err = ( 'Non-JSON response from KBase auth server, status code: ' + str(r.status_code)) logging.getLogger(__name__).info('%s, response:\n%s', err, r.text) raise IOError(err) # assume that if we get json then at least this is the auth server and we can # rely on the error structure. if j['error'].get('appcode') == 10020: # Invalid token raise InvalidTokenError( 'KBase auth server reported token is invalid.') if j['error'].get('appcode') == 30010: # Invalid username raise InvalidUserError( 'The KBase auth server is being very assertive about ' + 'one of the usernames being illegal: ' + j['error']['message']) # don't really see any other error codes we need to worry about - maybe disabled? # worry about it later. raise IOError('Error from KBase auth server: ' + j['error']['message']) def invalid_users(self, usernames: Sequence[UserID]) -> List[UserID]: ''' Check whether users exist in the authentication service. :param users: the users to check. :returns: A list of users that have legal usernames but do not exist in the authentication service. :raises InvalidTokenError: if the token has expired :raises InvalidUserError: if any of the user names are illegal user names. ''' if usernames is None: raise ValueError('usernames cannot be None') if not usernames: return [] _no_falsy_in_iterable(usernames, 'usernames') bad_usernames = [ u for u in usernames if not self._valid_cache.get(u.id, default=False) ] if len(bad_usernames) == 0: return [] r = requests.get(self._user_url + ','.join([u.id for u in bad_usernames]), headers={'Authorization': self._token}) self._check_error(r) good_users = r.json() for u in bad_usernames: if u.id in good_users: self._valid_cache.set(u.id, True) return [u for u in bad_usernames if u.id not in good_users] def is_admin(self, token: str) -> Tuple[AdminPermission, str]: ''' Check whether a user is a service administrator. :param token: The user's token. :returns: A tuple consisting of an enum indicating the user's administration permissions, if any, and the username. ''' # TODO CODE should regex the token to check for \n etc., but the SDK has already checked it _not_falsy(token, 'token') admin_cache = self._admin_cache.get(token, default=False) if admin_cache: return admin_cache r = requests.get(self._me_url, headers={'Authorization': token}) self._check_error(r) j = r.json() v = (self._get_role(j['customroles']), j['user']) self._admin_cache.set(token, v) return v def _get_role(self, roles): r = set(roles) if r & self._full_roles: return AdminPermission.FULL if r & self._read_roles: return AdminPermission.READ return AdminPermission.NONE