From b6a1c4b30818f2f040159b22cb08faf25d96baa6 Mon Sep 17 00:00:00 2001 From: blitzmann Date: Sun, 13 May 2018 22:33:58 -0400 Subject: [PATCH] Add support for using own client details (messy code, but it works!) --- config.py | 1 - gui/esiFittings.py | 3 +- gui/mainFrame.py | 5 +- service/esi.py | 29 +++++--- service/esiAccess.py | 173 ++++++++++++++++++++++++++++++------------- 5 files changed, 147 insertions(+), 64 deletions(-) diff --git a/config.py b/config.py index 5da7854bc..0ff71c37b 100644 --- a/config.py +++ b/config.py @@ -42,7 +42,6 @@ logging_setup = None cipher = None clientHash = None -ESI_AUTH_PROXY = "https://www.pyfa.io" # "http://localhost:5015" ESI_CACHE = 'esi_cache' LOGLEVEL_MAP = { diff --git a/gui/esiFittings.py b/gui/esiFittings.py index 0ac8e90db..98944fde1 100644 --- a/gui/esiFittings.py +++ b/gui/esiFittings.py @@ -123,6 +123,7 @@ class EveFittings(wx.Frame): ESIExceptionHandler(self, ex) except Exception as ex: del waitDialog + raise ex def importFitting(self, event): selection = self.fitView.fitSelection @@ -159,7 +160,7 @@ class EveFittings(wx.Frame): class ESIExceptionHandler(object): # todo: make this a generate excetpion handler for all calls def __init__(self, parentWindow, ex): - if ex.response['error'].startswith('Token is not valid'): + if ex.response['error'].startswith('Token is not valid') or ex.response['error'] == 'invalid_token': # todo: this seems messy, figure out a better response dlg = wx.MessageDialog(parentWindow, "There was an error validating characters' SSO token. Please try " "logging into the character again to reset the token.", "Invalid Token", diff --git a/gui/mainFrame.py b/gui/mainFrame.py index c9e5fa09c..4a8a6d1a1 100644 --- a/gui/mainFrame.py +++ b/gui/mainFrame.py @@ -69,6 +69,7 @@ from service.settings import SettingsProvider from service.fit import Fit from service.character import Character from service.update import Update +from service.esiAccess import SsoMode # import this to access override setting from eos.modifiedAttributeDict import ModifiedAttributeDict @@ -241,12 +242,12 @@ class MainFrame(wx.Frame): self.Bind(GE.EVT_SSO_LOGGING_IN, self.ShowSsoLogin) def ShowSsoLogin(self, event): - if getattr(event, "login_mode", LoginMethod.SERVER) == LoginMethod.MANUAL: + if getattr(event, "login_mode", LoginMethod.SERVER) == LoginMethod.MANUAL and getattr(event, "sso_mode", SsoMode.AUTO) == SsoMode.AUTO: dlg = SsoLogin(self) if dlg.ShowModal() == wx.ID_OK: sEsi = Esi.getInstance() # todo: verify that this is a correct SSO Info block - sEsi.handleLogin(dlg.ssoInfoCtrl.Value.strip()) + sEsi.handleLogin({'SSOInfo': [dlg.ssoInfoCtrl.Value.strip()]}) def ShowUpdateBox(self, release, version): dlg = UpdateDialog(self, release, version) diff --git a/service/esi.py b/service/esi.py index 78c80e04a..aafb60044 100644 --- a/service/esi.py +++ b/service/esi.py @@ -11,7 +11,7 @@ import webbrowser import eos.db from eos.enum import Enum from eos.saveddata.ssocharacter import SsoCharacter -from service.esiAccess import APIException +from service.esiAccess import APIException, SsoMode import gui.globalEvents as GE from service.server import StoppableHTTPServer, AuthHandler from service.settings import EsiSettings @@ -39,9 +39,10 @@ class Esi(EsiAccess): return cls._instance def __init__(self): - super().__init__() self.settings = EsiSettings.getInstance() + super().__init__() + # these will be set when needed self.httpd = None self.state = None @@ -105,18 +106,19 @@ class Esi(EsiAccess): def login(self): serverAddr = None - if self.settings.get('loginMode') == LoginMethod.SERVER: - serverAddr = self.startServer() + # always start the local server if user is using client details. Otherwise, start only if they choose to do so. + if self.settings.get('ssoMode') == SsoMode.CUSTOM or self.settings.get('loginMode') == LoginMethod.SERVER: + serverAddr = self.startServer(6461 if self.settings.get('ssoMode') == SsoMode.CUSTOM else 0) # random port, or if it's custom application, use a defined port uri = self.getLoginURI(serverAddr) webbrowser.open(uri) - wx.PostEvent(self.mainFrame, GE.SsoLoggingIn(login_mode=self.settings.get('loginMode'))) + wx.PostEvent(self.mainFrame, GE.SsoLoggingIn(sso_mode=self.settings.get('ssoMode'), login_mode=self.settings.get('loginMode'))) def stopServer(self): pyfalog.debug("Stopping Server") self.httpd.stop() self.httpd = None - def startServer(self): # todo: break this out into two functions: starting the server, and getting the URI + def startServer(self, port): # todo: break this out into two functions: starting the server, and getting the URI pyfalog.debug("Starting server") # we need this to ensure that the previous get_request finishes, and then the socket will close @@ -124,7 +126,7 @@ class Esi(EsiAccess): self.stopServer() time.sleep(1) - self.httpd = StoppableHTTPServer(('localhost', 0), AuthHandler) + self.httpd = StoppableHTTPServer(('localhost', port), AuthHandler) port = self.httpd.socket.getsockname()[1] self.serverThread = threading.Thread(target=self.httpd.serve, args=(self.handleServerLogin,)) self.serverThread.name = "SsoCallbackServer" @@ -133,8 +135,15 @@ class Esi(EsiAccess): return 'http://localhost:{}'.format(port) - def handleLogin(self, ssoInfo): - auth_response = json.loads(base64.b64decode(ssoInfo)) + def handleLogin(self, message): + + # we already have authenticated stuff for the auto mode + if (self.settings.get('ssoMode') == SsoMode.AUTO): + ssoInfo = message['SSOInfo'][0] + auth_response = json.loads(base64.b64decode(ssoInfo)) + else: + # otherwise, we need to fetch the information + auth_response = self.auth(message['code'][0]) res = self._session.get( self.oauth_verify, @@ -171,5 +180,5 @@ class Esi(EsiAccess): pyfalog.debug("Handling SSO login with: {0}", message) - self.handleLogin(message['SSOInfo'][0]) + self.handleLogin(message) diff --git a/service/esiAccess.py b/service/esiAccess.py index 864ca397d..7636249a4 100644 --- a/service/esiAccess.py +++ b/service/esiAccess.py @@ -1,16 +1,27 @@ +''' + +A lot of the inspiration (and straight up code copying!) for this class comes from EsiPy +Much of the credit goes to the maintainer of that package, Kyria . The reasoning for no +longer using EsiPy was due to it's reliance on pyswagger, which has caused a bit of a headache in how it operates on a +low level. + +Eventually I'll rewrite this to be a bit cleaner and a bit more generic, but for now, it works! + +''' + # noinspection PyPackageRequirements from logbook import Logger import uuid import time import config +import base64 import datetime from eos.enum import Enum -from eos.saveddata.ssocharacter import SsoCharacter from service.settings import EsiSettings from requests import Session -from urllib.parse import urlencode +from urllib.parse import urlencode, quote pyfalog = Logger(__name__) @@ -23,12 +34,17 @@ pyfalog = Logger(__name__) # os.mkdir(cache_path) # -# todo: move these over to getters that automatically determine which endpoint we use. -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 +scopes = [ + 'esi-skills.read_skills.v1', + 'esi-fittings.read_fittings.v1', + 'esi-fittings.write_fittings.v1' +] + + +class SsoMode(Enum): + AUTO = 0 + CUSTOM = 1 class APIException(Exception): @@ -57,27 +73,10 @@ class ESIEndpoints(Enum): CHAR_DEL_FIT = "/v1/characters/{character_id}/fittings/{fitting_id}/" -# class Servers(Enum): -# TQ = 0 -# SISI = 1 - class EsiAccess(object): def __init__(self): - if sso_url is None or sso_url == "": - raise AttributeError("sso_url cannot be None or empty " - "without app parameter") - self.settings = EsiSettings.getInstance() - 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({ @@ -87,6 +86,28 @@ class EsiAccess(object): ) }) + @property + def sso_url(self): + if (self.settings.get("ssoMode") == SsoMode.CUSTOM): + return "https://login.eveonline.com" + return "https://www.pyfa.io" + + @property + def esi_url(self): + return "https://esi.tech.ccp.is" + + @property + def oauth_verify(self): + return '%s/verify/' % self.esi_url + + @property + def oauth_authorize(self): + return '%s/oauth/authorize' % self.sso_url + + @property + def oauth_token(self): + return '%s/oauth/token' % self.sso_url + def getSkills(self, char): return self.get(char, ESIEndpoints.CHAR_SKILLS, character_id=char.characterID) @@ -114,20 +135,30 @@ class EsiAccess(object): def getLoginURI(self, redirect=None): self.state = str(uuid.uuid4()) - args = { - 'state': self.state, - 'pyfa_version': config.version, - 'login_method': self.settings.get('loginMode'), - 'client_hash': config.getClientSecret() - } + if (self.settings.get("ssoMode") == SsoMode.AUTO): + args = { + 'state': self.state, + 'pyfa_version': config.version, + 'login_method': self.settings.get('loginMode'), + 'client_hash': config.getClientSecret() + } - if redirect is not None: - args['redirect'] = redirect + if redirect is not None: + args['redirect'] = redirect - return '%s?%s' % ( - oauth_authorize, - urlencode(args) - ) + return '%s?%s' % ( + self.oauth_authorize, + urlencode(args) + ) + else: + return '%s?response_type=%s&redirect_uri=%s&client_id=%s%s%s' % ( + self.oauth_authorize, + 'code', + quote('http://localhost:6461', safe=''), + self.settings.get('clientID'), + '&scope=%s' % '+'.join(scopes) if scopes else '', + '&state=%s' % self.state + ) def get_oauth_header(self, token): """ Return the Bearer Authorization header required in oauth calls @@ -146,14 +177,62 @@ class EsiAccess(object): if refreshToken is None: raise AttributeError('No refresh token is defined.') - return { - 'data': { - 'grant_type': 'refresh_token', - 'refresh_token': refreshToken, - }, + data = { + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + } + + if self.settings.get('ssoMode') == SsoMode.AUTO: + # data is all we really need, the rest is handled automatically by pyfa.io + return { + 'data': data, + 'url': self.oauth_token, + } + + # otherwise, we need to make the token with the client keys + return self.__make_token_request_parameters(data) + + def __get_token_auth_header(self): + """ Return the Basic Authorization header required to get the tokens + + :return: a dict with the headers + """ + # encode/decode for py2/py3 compatibility + auth_b64 = "%s:%s" % (self.settings.get('clientID'), self.settings.get('clientSecret')) + auth_b64 = base64.b64encode(auth_b64.encode('latin-1')) + auth_b64 = auth_b64.decode('latin-1') + + return {'Authorization': 'Basic %s' % auth_b64} + + def __make_token_request_parameters(self, params): + request_params = { + 'headers': self.__get_token_auth_header(), + 'data': params, 'url': self.oauth_token, } + return request_params + + def get_access_token_request_params(self, code): + return self.__make_token_request_parameters( + { + 'grant_type': 'authorization_code', + 'code': code, + } + ) + + def auth(self, code): + request_data = self.get_access_token_request_params(code) + res = self._session.post(**request_data) + if res.status_code != 200: + raise Exception( + request_data['url'], + res.status_code, + res.json() + ) + json_res = res.json() + return json_res + def refresh(self, ssoChar): request_data = self.get_refresh_token_params(config.cipher.decrypt(ssoChar.refreshToken).decode()) res = self._session.post(**request_data) @@ -191,21 +270,15 @@ class EsiAccess(object): def get(self, ssoChar, endpoint, *args, **kwargs): self._before_request(ssoChar) endpoint = endpoint.format(**kwargs) - return self._after_request(self._session.get("{}{}".format(esi_url, endpoint))) - - # check for warnings, also status > 400 + return self._after_request(self._session.get("{}{}".format(self.esi_url, endpoint))) def post(self, ssoChar, endpoint, json, *args, **kwargs): self._before_request(ssoChar) endpoint = endpoint.format(**kwargs) - return self._after_request(self._session.post("{}{}".format(esi_url, endpoint), data=json)) - - # check for warnings, also status > 400 + return self._after_request(self._session.post("{}{}".format(self.esi_url, endpoint), data=json)) def delete(self, ssoChar, endpoint, *args, **kwargs): self._before_request(ssoChar) endpoint = endpoint.format(**kwargs) - return self._after_request(self._session.delete("{}{}".format(esi_url, endpoint))) - - # check for warnings, also status > 400 + return self._after_request(self._session.delete("{}{}".format(self.esi_url, endpoint)))