From 0365f71c00be9da15f9cf5a098339d9b4a795cac Mon Sep 17 00:00:00 2001 From: blitzmann Date: Sat, 12 May 2018 00:46:26 -0400 Subject: [PATCH] Move over ESI functionality to be completely separate from esipy --- eos/saveddata/ssocharacter.py | 4 + gui/esiFittings.py | 3 +- requirements.txt | 1 - service/esi.py | 245 +++++++++++++++++++++------------- service/esi_security_proxy.py | 222 ------------------------------ 5 files changed, 156 insertions(+), 319 deletions(-) delete mode 100644 service/esi_security_proxy.py diff --git a/eos/saveddata/ssocharacter.py b/eos/saveddata/ssocharacter.py index 9ffed8d46..352e3724a 100644 --- a/eos/saveddata/ssocharacter.py +++ b/eos/saveddata/ssocharacter.py @@ -50,6 +50,10 @@ class SsoCharacter(object): ).total_seconds() } + def is_token_expired(self): + if self.accessTokenExpires is None: + return True + return datetime.datetime.now() >= self.accessTokenExpires def __repr__(self): return "SsoCharacter(ID={}, name={}, client={}) at {}".format( diff --git a/gui/esiFittings.py b/gui/esiFittings.py index 92fd89b83..2f53a5819 100644 --- a/gui/esiFittings.py +++ b/gui/esiFittings.py @@ -16,8 +16,7 @@ import gui.globalEvents as GE from logbook import Logger import calendar -from service.esi import Esi -from esipy.exceptions import APIException +from service.esi import Esi, APIException from service.port import ESIExportException pyfalog = Logger(__name__) diff --git a/requirements.txt b/requirements.txt index c55887d95..a938d0217 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,6 @@ matplotlib >= 2.0.0 python-dateutil requests >= 2.0.0 sqlalchemy >= 1.0.5 -esipy == 0.3.4 cryptography diskcache markdown2 diff --git a/service/esi.py b/service/esi.py index 943f2cbbc..adbf7e774 100644 --- a/service/esi.py +++ b/service/esi.py @@ -19,35 +19,53 @@ import gui.globalEvents as GE from service.server import StoppableHTTPServer, AuthHandler from service.settings import EsiSettings -from .esi_security_proxy import EsiSecurityProxy -from esipy import EsiClient, EsiApp -from esipy.cache import FileCache - import wx +from requests import Session pyfalog = Logger(__name__) -cache_path = os.path.join(config.savePath, config.ESI_CACHE) - -from esipy.events import AFTER_TOKEN_REFRESH - from urllib.parse import urlencode -if not os.path.exists(cache_path): - os.mkdir(cache_path) +# todo: reimplement Caching for calls +# from esipy.cache import FileCache +# file_cache = FileCache(cache_path) +# cache_path = os.path.join(config.savePath, config.ESI_CACHE) +# +# if not os.path.exists(cache_path): +# os.mkdir(cache_path) +# -file_cache = FileCache(cache_path) - - -sso_url = "https://www.pyfa.io" # "https://login.eveonline.com" for actual login +sso_url = "https://www.pyfa.io" # "https://login.eveonline.com" for actual login esi_url = "https://esi.tech.ccp.is" oauth_authorize = '%s/oauth/authorize' % sso_url oauth_token = '%s/oauth/token' % sso_url -class EsiException(Exception): - pass +class APIException(Exception): + """ Exception for SSO related errors """ + + def __init__(self, url, code, json_response): + self.url = url + self.status_code = code + self.response = json_response + super(APIException, self).__init__(str(self)) + + def __str__(self): + if 'error' in self.response: + return 'HTTP Error %s: %s' % (self.status_code, + self.response['error']) + elif 'message' in self.response: + return 'HTTP Error %s: %s' % (self.status_code, + self.response['message']) + return 'HTTP Error %s' % (self.status_code) + + +class ESIEndpoints(Enum): + CHAR = "/v4/characters/{character_id}/" + CHAR_SKILLS = "/v4/characters/{character_id}/skills/" # prepend https://esi.evetech.net/ + CHAR_FITTINGS = "/v1/characters/{character_id}/fittings/" + CHAR_DEL_FIT = "/v1/characters/{character_id}/fittings/{fitting_id}/" class Servers(Enum): TQ = 0 @@ -60,31 +78,8 @@ class LoginMethod(Enum): class Esi(object): - esiapp = None - esi_v1 = None - esi_v4 = None - - _initializing = None - _instance = None - @classmethod - def initEsiApp(cls): - if cls._initializing is None: - cls._initializing = True - cls.esiapp = EsiApp(cache=file_cache, cache_time=None, cache_prefix='pyfa{0}-esipy-'.format(config.version)) - cls.esi_v1 = cls.esiapp.get_v1_swagger - cls.esi_v4 = cls.esiapp.get_v4_swagger - cls._initializing = False - - @classmethod - def genEsiClient(cls, security=None): - return EsiClient( - security=EsiSecurityProxy(sso_url=config.ESI_AUTH_PROXY) if security is None else security, - cache=file_cache, - headers={'User-Agent': 'pyfa esipy'} - ) - @classmethod def getInstance(cls): if cls._instance is None: @@ -93,18 +88,8 @@ class Esi(object): return cls._instance def __init__(self): - try: - Esi.initEsiApp() - except Exception as e: - # todo: this is a stop-gap for #1546. figure out a better way of handling esi service failing. - pyfalog.error(e) - wx.MessageBox("The ESI module failed to initialize. This can sometimes happen on first load on a slower connection. Please try again.") - return - self.settings = EsiSettings.getInstance() - AFTER_TOKEN_REFRESH.add_receiver(self.tokenUpdate) - # these will be set when needed self.httpd = None self.state = None @@ -112,17 +97,31 @@ class Esi(object): self.implicitCharacter = None - # The database cache does not seem to be working for some reason. Use - # this as a temporary measure - self.charCache = {} - # need these here to post events import gui.mainFrame # put this here to avoid loop self.mainFrame = gui.mainFrame.MainFrame.getInstance() - def tokenUpdate(self, **kwargs): - print(kwargs) - pass + if sso_url is None or sso_url == "": + raise AttributeError("sso_url cannot be None or empty " + "without app parameter") + + self.oauth_authorize = '%s/oauth/authorize' % sso_url + self.oauth_token = '%s/oauth/token' % sso_url + + # use ESI url for verify, since it's better for caching + if esi_url is None or esi_url == "": + raise AttributeError("esi_url cannot be None or empty") + self.oauth_verify = '%s/verify/' % esi_url + + + # session request stuff + self._session = Session() + self._session.headers.update({ + 'Accept': 'application/json', + 'User-Agent': ( + 'pyfa v{}'.format(config.version) + ) + }) def delSsoCharacter(self, id): char = eos.db.getSsoCharacter(id, config.getClientSecret()) @@ -151,46 +150,36 @@ class Esi(object): eos.db.commit() return char + def getSkills(self, id): char = self.getSsoCharacter(id) - op = Esi.esi_v4.op['get_characters_character_id_skills'](character_id=char.characterID) - resp = self.check_response(char.esi_client.request(op)) - return resp.data + resp = self.get(char, ESIEndpoints.CHAR_SKILLS, character_id=char.characterID) + # resp = self.check_response(char.esi_client.request(op)) + return resp.json() def getSecStatus(self, id): char = self.getSsoCharacter(id) - op = Esi.esi_v4.op['get_characters_character_id'](character_id=char.characterID) - resp = self.check_response(char.esi_client.request(op)) - return resp.data + resp = self.get(char, ESIEndpoints.CHAR, character_id=char.characterID) + return resp.json() def getFittings(self, id): char = self.getSsoCharacter(id) - op = Esi.esi_v1.op['get_characters_character_id_fittings'](character_id=char.characterID) - resp = self.check_response(char.esi_client.request(op)) - return resp.data + resp = self.get(char, ESIEndpoints.CHAR_FITTINGS, character_id=char.characterID) + return resp.json() def postFitting(self, id, json_str): # @todo: new fitting ID can be recovered from resp.data, char = self.getSsoCharacter(id) - op = Esi.esi_v1.op['post_characters_character_id_fittings']( - character_id=char.characterID, - fitting=json.loads(json_str) - ) - resp = self.check_response(char.esi_client.request(op)) - return resp.data + resp = self.post(char, ESIEndpoints.CHAR_FITTINGS, json_str, character_id=char.characterID) + return resp.json() def delFitting(self, id, fittingID): char = self.getSsoCharacter(id) - op = Esi.esi_v1.op['delete_characters_character_id_fittings_fitting_id']( - character_id=char.characterID, - fitting_id=fittingID - ) - resp = self.check_response(char.esi_client.request(op)) - return resp.data + self.delete(char, ESIEndpoints.CHAR_DEL_FIT, character_id=char.characterID, fitting_id=fittingID) def check_response(self, resp): - if resp.status >= 400: - raise EsiException(resp.status) + # if resp.status >= 400: + # raise EsiException(resp.status) return resp @staticmethod @@ -211,10 +200,6 @@ class Esi(object): if 'refresh_token' in tokenResponse: char.refreshToken = config.cipher.encrypt(tokenResponse['refresh_token'].encode()) - # remove, no longer need? - if char.esi_client is not None: - char.esi_client.security.update_token(tokenResponse) - def login(self): serverAddr = None if self.settings.get('loginMode') == LoginMethod.SERVER: @@ -263,31 +248,72 @@ class Esi(object): return 'http://localhost:{}'.format(port) + def get_oauth_header(self, token): + """ Return the Bearer Authorization header required in oauth calls + + :return: a dict with the authorization header + """ + return {'Authorization': 'Bearer %s' % token} + + def get_refresh_token_params(self, refreshToken): + """ Return the param object for the post() call to get the access_token + from the refresh_token + + :param code: the refresh token + :return: a dict with the url, params and header + """ + if refreshToken is None: + raise AttributeError('No refresh token is defined.') + + return { + 'data': { + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + }, + 'url': self.oauth_token, + } + + def refresh(self, ssoChar): + request_data = self.get_refresh_token_params(config.cipher.decrypt(ssoChar.refreshToken).decode()) + res = self._session.post(**request_data) + if res.status_code != 200: + raise APIException( + request_data['url'], + res.status_code, + res.json() + ) + json_res = res.json() + self.update_token(ssoChar, json_res) + return json_res + def handleLogin(self, ssoInfo): auth_response = json.loads(base64.b64decode(ssoInfo)) - # We need to preload the ESI Security object beforehand with the auth response so that we can use verify to - # get character information - # init the security object - esisecurity = EsiSecurityProxy(sso_url=config.ESI_AUTH_PROXY) - - esisecurity.update_token(auth_response) - - # we get the character information - cdata = esisecurity.verify() + res = self._session.get( + self.oauth_verify, + headers=self.get_oauth_header(auth_response['access_token']) + ) + if res.status_code != 200: + raise APIException( + self.oauth_verify, + res.status_code, + res.json() + ) + cdata = res.json() print(cdata) currentCharacter = self.getSsoCharacter(cdata['CharacterName']) if currentCharacter is None: currentCharacter = SsoCharacter(cdata['CharacterID'], cdata['CharacterName'], config.getClientSecret()) - currentCharacter.esi_client = Esi.genEsiClient(esisecurity) - Esi.update_token(currentCharacter, auth_response) # this also sets the esi security token + Esi.update_token(currentCharacter, auth_response) eos.db.save(currentCharacter) wx.PostEvent(self.mainFrame, GE.SsoLogin(character=currentCharacter)) + # get (endpoint, char, data?) + def handleServerLogin(self, message): if not message: raise Exception("Could not parse out querystring parameters.") @@ -299,3 +325,34 @@ class Esi(object): pyfalog.debug("Handling SSO login with: {0}", message) self.handleLogin(message['SSOInfo'][0]) + + def __before_request(self, ssoChar): + if ssoChar.is_token_expired(): + json_response = self.refresh(ssoChar) + # AFTER_TOKEN_REFRESH.send(**json_response) + + if ssoChar.accessToken is not None: + self._session.headers.update(self.get_oauth_header(ssoChar.accessToken)) + + def get(self, ssoChar, endpoint, *args, **kwargs): + self.__before_request(ssoChar) + endpoint = endpoint.format(**kwargs) + return self._session.get("{}{}".format(esi_url, endpoint)) + + # check for warnings, also status > 400 + + + def post(self, ssoChar, endpoint, json, *args, **kwargs): + self.__before_request(ssoChar) + endpoint = endpoint.format(**kwargs) + return self._session.post("{}{}".format(esi_url, endpoint), data=json) + + # check for warnings, also status > 400 + + def delete(self, ssoChar, endpoint, *args, **kwargs): + self.__before_request(ssoChar) + endpoint = endpoint.format(**kwargs) + return self._session.delete("{}{}".format(esi_url, endpoint)) + + # check for warnings, also status > 400 + diff --git a/service/esi_security_proxy.py b/service/esi_security_proxy.py deleted file mode 100644 index 277eb3aa1..000000000 --- a/service/esi_security_proxy.py +++ /dev/null @@ -1,222 +0,0 @@ -# -*- encoding: utf-8 -*- -""" EsiPy Security Proxy - An ESI Security class that directs authentication towards a third-party service. -Client key/secret not needed. -""" - -from __future__ import absolute_import - -import base64 -import logging -import time - -from requests import Session -from requests.utils import quote -from six.moves.urllib.parse import urlparse -from urllib.parse import urlencode - -from esipy.events import AFTER_TOKEN_REFRESH -from esipy.exceptions import APIException -LOGGER = logging.getLogger(__name__) - - -class EsiSecurityProxy(object): - """ Contains all the OAuth2 knowledge for ESI use. - Based on pyswagger Security object, to be used with pyswagger BaseClient - implementation. - """ - - def __init__( - self, - **kwargs): - """ Init the ESI Security Object - - :param sso_url: the default sso URL used when no "app" is provided - :param esi_url: the default esi URL used for verify endpoint - :param app: (optionnal) the pyswagger app object - :param security_name: (optionnal) the name of the object holding the - informations in the securityDefinitions, used to check authed endpoint - """ - - app = kwargs.pop('app', None) - sso_url = kwargs.pop('sso_url', "https://login.eveonline.com") - esi_url = kwargs.pop('esi_url', "https://esi.tech.ccp.is") - - self.security_name = kwargs.pop('security_name', 'evesso') - - # we provide app object, so we don't use sso_url - if app is not None: - # check if the security_name exists in the securityDefinition - security = app.root.securityDefinitions.get( - self.security_name, - None - ) - if security is None: - raise NameError( - "%s is not defined in the securityDefinitions" % - self.security_name - ) - - self.oauth_authorize = security.authorizationUrl - - # some URL we still need to "manually" define... sadly - # we parse the authUrl so we don't care if it's TQ or SISI. - # https://github.com/ccpgames/esi-issues/issues/92 - parsed_uri = urlparse(security.authorizationUrl) - self.oauth_token = '%s://%s/oauth/token' % ( - parsed_uri.scheme, - parsed_uri.netloc - ) - - # no app object is provided, so we use direct URLs - else: - if sso_url is None or sso_url == "": - raise AttributeError("sso_url cannot be None or empty " - "without app parameter") - - self.oauth_authorize = '%s/oauth/authorize' % sso_url - self.oauth_token = '%s/oauth/token' % sso_url - - # use ESI url for verify, since it's better for caching - if esi_url is None or esi_url == "": - raise AttributeError("esi_url cannot be None or empty") - self.oauth_verify = '%s/verify/' % esi_url - - # session request stuff - self._session = Session() - self._session.headers.update({ - 'Accept': 'application/json', - 'User-Agent': ( - 'EsiPy/Security/ - ' - 'https://github.com/Kyria/EsiPy' - ) - }) - - # token data - self.refresh_token = None - self.access_token = None - self.token_expiry = None - - def __get_oauth_header(self): - """ Return the Bearer Authorization header required in oauth calls - - :return: a dict with the authorization header - """ - return {'Authorization': 'Bearer %s' % self.access_token} - - def __make_token_request_parameters(self, params): - """ Return the token uri from the securityDefinition - - :param params: the data given to the request - :return: the oauth/token uri - """ - request_params = { - 'data': params, - 'url': self.oauth_token, - } - - return request_params - - def get_refresh_token_params(self): - """ Return the param object for the post() call to get the access_token - from the refresh_token - - :param code: the refresh token - :return: a dict with the url, params and header - """ - if self.refresh_token is None: - raise AttributeError('No refresh token is defined.') - - return self.__make_token_request_parameters( - { - 'grant_type': 'refresh_token', - 'refresh_token': self.refresh_token, - } - ) - - def update_token(self, response_json): - """ Update access_token, refresh_token and token_expiry from the - response body. - The response must be converted to a json object before being passed as - a parameter - - :param response_json: the response body to use. - """ - self.access_token = response_json['access_token'] - self.token_expiry = int(time.time()) + response_json['expires_in'] - - if 'refresh_token' in response_json: - self.refresh_token = response_json['refresh_token'] - - def is_token_expired(self, offset=0): - """ Return true if the token is expired. - - The offset can be used to change the expiry time: - - positive value decrease the time (sooner) - - negative value increase the time (later) - If the expiry is not set, always return True. This case allow the users - to define a security object, only knowing the refresh_token and get - a new access_token / expiry_time without errors. - - :param offset: the expiry offset (in seconds) [default: 0] - :return: boolean true if expired, else false. - """ - if self.token_expiry is None: - return True - return int(time.time()) >= (self.token_expiry - offset) - - def refresh(self): - """ Update the auth data (tokens) using the refresh token in auth. - """ - request_data = self.get_refresh_token_params() - res = self._session.post(**request_data) - if res.status_code != 200: - raise APIException( - request_data['url'], - res.status_code, - res.json() - ) - json_res = res.json() - self.update_token(json_res) - return json_res - - def verify(self): - """ Make a get call to the oauth/verify endpoint to get the user data - - :return: the json with the data. - """ - res = self._session.get( - self.oauth_verify, - headers=self.__get_oauth_header() - ) - if res.status_code != 200: - raise APIException( - self.oauth_verify, - res.status_code, - res.json() - ) - return res.json() - - def __call__(self, request): - """ Check if the request need security header and apply them. - Required for pyswagger.core.BaseClient.request(). - - :param request: the pyswagger request object to check - :return: the updated request. - """ - if not request._security: - return request - - if self.is_token_expired(): - json_response = self.refresh() - AFTER_TOKEN_REFRESH.send(**json_response) - - for security in request._security: - if self.security_name not in security: - LOGGER.warning( - "Missing Securities: [%s]" % ", ".join(security.keys()) - ) - continue - if self.access_token is not None: - request._p['header'].update(self.__get_oauth_header()) - - return request