diff --git a/config.py b/config.py index a59f99ea2..08d641195 100644 --- a/config.py +++ b/config.py @@ -43,7 +43,9 @@ experimentalFeatures = None version = None language = None +API_CLIENT_ID = '095d8cd841ac40b581330919b49fe746' ESI_CACHE = 'esi_cache' +SSO_CALLBACK = 'https://pyfa-org.github.io/Pyfa/callback' LOGLEVEL_MAP = { "critical": CRITICAL, diff --git a/gui/builtinPreferenceViews/pyfaEsiPreferences.py b/gui/builtinPreferenceViews/pyfaEsiPreferences.py index 9f81d65f9..1356fec16 100644 --- a/gui/builtinPreferenceViews/pyfaEsiPreferences.py +++ b/gui/builtinPreferenceViews/pyfaEsiPreferences.py @@ -37,118 +37,19 @@ class PFEsiPref(PreferenceView): rbSizer = wx.BoxSizer(wx.HORIZONTAL) self.rbMode = wx.RadioBox(panel, -1, _t("Login Authentication Method"), wx.DefaultPosition, wx.DefaultSize, [_t('Local Server'), _t('Manual')], 1, wx.RA_SPECIFY_COLS) - self.rbMode.SetItemToolTip(0, _t("This options starts a local webserver that the web application will call back to" + self.rbMode.SetItemToolTip(0, _t("This option starts a local webserver that EVE SSO Server will call back to" " with information about the character login.")) - self.rbMode.SetItemToolTip(1, _t("This option prompts users to copy and paste information from the web application " - "to allow for character login. Use this if having issues with the local server.")) - - self.rbSsoMode = wx.RadioBox(panel, -1, _t("SSO Mode"), wx.DefaultPosition, wx.DefaultSize, - [_t('pyfa.io'), _t('Custom application')], 1, wx.RA_SPECIFY_COLS) - self.rbSsoMode.SetItemToolTip(0, _t("This options routes SSO Logins through pyfa.io, allowing you to easily login " - "without any configuration. When in doubt, use this option.")) - self.rbSsoMode.SetItemToolTip(1, _t("This option goes through EVE SSO directly, but requires more configuration. Use " - "this if pyfa.io is blocked for some reason, or if you do not wish to route data throguh pyfa.io.")) + self.rbMode.SetItemToolTip(1, _t("This option prompts users to copy and paste information to allow for" + " character login. Use this if having issues with the local server.")) self.rbMode.SetSelection(self.settings.get('loginMode')) - self.rbSsoMode.SetSelection(self.settings.get('ssoMode')) - rbSizer.Add(self.rbSsoMode, 1, wx.ALL, 5) rbSizer.Add(self.rbMode, 1, wx.TOP | wx.RIGHT, 5) self.rbMode.Bind(wx.EVT_RADIOBOX, self.OnModeChange) - self.rbSsoMode.Bind(wx.EVT_RADIOBOX, self.OnSSOChange) mainSizer.Add(rbSizer, 1, wx.ALL | wx.EXPAND, 0) - detailsTitle = wx.StaticText(panel, wx.ID_ANY, _t("Custom Application"), wx.DefaultPosition, wx.DefaultSize, 0) - detailsTitle.Wrap(-1) - detailsTitle.SetFont(wx.Font(12, 70, 90, 90, False, wx.EmptyString)) - - mainSizer.Add(detailsTitle, 0, wx.ALL, 5) - mainSizer.Add(wx.StaticLine(panel, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.LI_HORIZONTAL), 0, - wx.EXPAND, 5) - - fgAddrSizer = wx.FlexGridSizer(2, 2, 0, 0) - fgAddrSizer.AddGrowableCol(1) - fgAddrSizer.SetFlexibleDirection(wx.BOTH) - fgAddrSizer.SetNonFlexibleGrowMode(wx.FLEX_GROWMODE_SPECIFIED) - - self.stSetID = wx.StaticText(panel, wx.ID_ANY, _t("Client ID:"), wx.DefaultPosition, wx.DefaultSize, 0) - self.stSetID.Wrap(-1) - fgAddrSizer.Add(self.stSetID, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5) - - self.inputClientID = wx.TextCtrl(panel, wx.ID_ANY, self.settings.get('clientID'), wx.DefaultPosition, - wx.DefaultSize, 0) - - fgAddrSizer.Add(self.inputClientID, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL | wx.EXPAND, 5) - - self.stSetSecret = wx.StaticText(panel, wx.ID_ANY, _t("Client Secret:"), wx.DefaultPosition, wx.DefaultSize, 0) - self.stSetSecret.Wrap(-1) - - fgAddrSizer.Add(self.stSetSecret, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5) - - self.inputClientSecret = wx.TextCtrl(panel, wx.ID_ANY, self.settings.get('clientSecret'), wx.DefaultPosition, - wx.DefaultSize, 0) - - fgAddrSizer.Add(self.inputClientSecret, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL | wx.EXPAND, 5) - - self.inputClientID.Bind(wx.EVT_TEXT, self.OnClientDetailChange) - self.inputClientSecret.Bind(wx.EVT_TEXT, self.OnClientDetailChange) - - mainSizer.Add(fgAddrSizer, 0, wx.EXPAND, 5) - - # self.stTimout = wx.StaticText(panel, wx.ID_ANY, "Timeout (seconds):", wx.DefaultPosition, wx.DefaultSize, 0) - # self.stTimout.Wrap(-1) - # - # timeoutSizer.Add(self.stTimout, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5) - - # self.intTimeout = IntCtrl(panel, max=300000, limited=True, value=self.settings.get('timeout')) - # timeoutSizer.Add(self.intTimeout, 0, wx.ALL, 5) - # self.intTimeout.Bind(wx.lib.intctrl.EVT_INT, self.OnTimeoutChange) - # - # mainSizer.Add(timeoutSizer, 0, wx.ALL | wx.EXPAND, 0) - - # detailsTitle = wx.StaticText(panel, wx.ID_ANY, "CREST client details", wx.DefaultPosition, wx.DefaultSize, 0) - # detailsTitle.Wrap(-1) - # detailsTitle.SetFont(wx.Font(12, 70, 90, 90, False, wx.EmptyString)) - # - # mainSizer.Add(detailsTitle, 0, wx.ALL, 5) - # mainSizer.Add(wx.StaticLine(panel, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.LI_HORIZONTAL), 0, - # wx.EXPAND, 5) - - # fgAddrSizer = wx.FlexGridSizer(2, 2, 0, 0) - # fgAddrSizer.AddGrowableCol(1) - # fgAddrSizer.SetFlexibleDirection(wx.BOTH) - # fgAddrSizer.SetNonFlexibleGrowMode(wx.FLEX_GROWMODE_SPECIFIED) - # - # self.stSetID = wx.StaticText(panel, wx.ID_ANY, "Client ID:", wx.DefaultPosition, wx.DefaultSize, 0) - # self.stSetID.Wrap(-1) - # fgAddrSizer.Add(self.stSetID, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5) - # - # self.inputClientID = wx.TextCtrl(panel, wx.ID_ANY, self.settings.get('clientID'), wx.DefaultPosition, - # wx.DefaultSize, 0) - # - # fgAddrSizer.Add(self.inputClientID, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL | wx.EXPAND, 5) - # - # self.stSetSecret = wx.StaticText(panel, wx.ID_ANY, "Client Secret:", wx.DefaultPosition, wx.DefaultSize, 0) - # self.stSetSecret.Wrap(-1) - # - # fgAddrSizer.Add(self.stSetSecret, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5) - # - # self.inputClientSecret = wx.TextCtrl(panel, wx.ID_ANY, self.settings.get('clientSecret'), wx.DefaultPosition, - # wx.DefaultSize, 0) - # - # fgAddrSizer.Add(self.inputClientSecret, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL | wx.EXPAND, 5) - # - # self.btnApply = wx.Button(panel, wx.ID_ANY, "Save Client Settings", wx.DefaultPosition, wx.DefaultSize, 0) - # self.btnApply.Bind(wx.EVT_BUTTON, self.OnBtnApply) - # - # mainSizer.Add(fgAddrSizer, 0, wx.EXPAND, 5) - # mainSizer.Add(self.btnApply, 0, wx.ALIGN_RIGHT, 5) - - # self.ToggleProxySettings(self.settings.get('loginMode')) - - self.ToggleSSOMode(self.settings.get('ssoMode')) panel.SetSizer(mainSizer) panel.Layout() @@ -158,32 +59,6 @@ class PFEsiPref(PreferenceView): def OnModeChange(self, event): self.settings.set('loginMode', event.GetInt()) - def OnSSOChange(self, event): - self.settings.set('ssoMode', event.GetInt()) - self.ToggleSSOMode(event.GetInt()) - - def ToggleSSOMode(self, mode): - if mode: - self.stSetID.Enable() - self.inputClientID.Enable() - self.stSetSecret.Enable() - self.inputClientSecret.Enable() - self.rbMode.Disable() - else: - self.stSetID.Disable() - self.inputClientID.Disable() - self.stSetSecret.Disable() - self.inputClientSecret.Disable() - self.rbMode.Enable() - - def OnClientDetailChange(self, evt): - self.settings.set('clientID', self.inputClientID.GetValue().strip()) - self.settings.set('clientSecret', self.inputClientSecret.GetValue().strip()) - - # sEsi = Esi.getInstance() - # sEsi.delAllCharacters() - # - def getImage(self): return BitmapLoader.getBitmap("eve", "gui") diff --git a/gui/characterEditor.py b/gui/characterEditor.py index 20130075a..9f268556d 100644 --- a/gui/characterEditor.py +++ b/gui/characterEditor.py @@ -42,6 +42,7 @@ from gui.contextMenu import ContextMenu from gui.utils.clipboard import fromClipboard, toClipboard from service.character import Character from service.esi import Esi +from service.esiAccess import APIException from service.fit import Fit from service.market import Market @@ -888,14 +889,7 @@ class APIView(wx.Panel): def fetchCallback(e=None): if e: pyfalog.warn("Error fetching skill information for character for __fetchCallback") - exc_type, exc_value, exc_trace = e - if config.debug: - exc_value = ''.join(traceback.format_exception(exc_type, exc_value, exc_trace)) - pyfalog.warn(exc_value) - - wx.MessageBox( - _t("Error fetching skill information"), - _t("Error"), wx.ICON_ERROR | wx.STAY_ON_TOP) + SkillFetchExceptionHandler(e) else: wx.MessageBox( _t("Successfully fetched skills"), _t("Success"), wx.ICON_INFORMATION | wx.STAY_ON_TOP) @@ -926,3 +920,24 @@ class SecStatusDialog(wx.Dialog): self.Layout() self.Center(wx.BOTH) + + +class SkillFetchExceptionHandler: + def __init__(self, e): + from gui.esiFittings import ESIExceptionHandler + exc_type, exc_value, exc_trace = e + if config.debug: + exc_value = ''.join(traceback.format_exception(exc_type, exc_value, exc_trace)) + pyfalog.warn(exc_value) + + try: + try: + raise exc_value + except APIException as ex: + pyfalog.error(ex) + ESIExceptionHandler(ex) + except Exception as ex: + pyfalog.error(ex) + wx.MessageBox( + _t("Error fetching skill information"), + _t("Error"), wx.ICON_ERROR | wx.STAY_ON_TOP) diff --git a/gui/characterSelection.py b/gui/characterSelection.py index d403fa8bf..a248d7345 100644 --- a/gui/characterSelection.py +++ b/gui/characterSelection.py @@ -163,15 +163,9 @@ class CharacterSelection(wx.Panel): if e is None: self.refreshCharacterList() else: + from gui.characterEditor import SkillFetchExceptionHandler pyfalog.warn("Error fetching skill information for character for refreshAPICallback") - exc_type, exc_value, exc_trace = e - if config.debug: - exc_value = ''.join(traceback.format_exception(exc_type, exc_value, exc_trace)) - pyfalog.warn(exc_value) - - wx.MessageBox( - _t("Error fetching skill information"), - _t("Error"), wx.ICON_ERROR | wx.STAY_ON_TOP) + SkillFetchExceptionHandler(e) def charChanged(self, event): fitID = self.mainFrame.getActiveFit() diff --git a/gui/esiFittings.py b/gui/esiFittings.py index 70bfe92e4..0f9950880 100644 --- a/gui/esiFittings.py +++ b/gui/esiFittings.py @@ -9,6 +9,7 @@ import config import gui.globalEvents as GE from eos.db import getItem from eos.saveddata.cargo import Cargo +import gui.mainFrame from gui.auxWindow import AuxiliaryFrame from gui.display import Display from gui.characterEditor import APIView @@ -132,7 +133,7 @@ class EveFittings(AuxiliaryFrame): except APIException as ex: # Can't do this in a finally because then it obscures the message dialog del waitDialog # noqa: F821 - ESIExceptionHandler(self, ex) + ESIExceptionHandler(ex) except (KeyboardInterrupt, SystemExit): raise except Exception as ex: @@ -149,6 +150,7 @@ class EveFittings(AuxiliaryFrame): self.mainFrame._openAfterImport(fits) def deleteFitting(self, event): + self.statusbar.SetStatusText("") sEsi = Esi.getInstance() selection = self.fitView.fitSelection if not selection: @@ -164,16 +166,26 @@ class EveFittings(AuxiliaryFrame): if activeChar is None: return try: - sEsi.delFitting(activeChar, data['fitting_id']) - # repopulate the fitting list - self.fitTree.populateSkillTree(self.fittings) - self.fitView.update([]) + try: + sEsi.delFitting(activeChar, data['fitting_id']) + # repopulate the fitting list + self.fitTree.populateSkillTree(self.fittings) + self.fitView.update([]) + except APIException as ex: + pyfalog.error(ex) + self.statusbar.SetStatusText("Failed to delete fit: ESI error {} received - {}".format(ex.status_code, ex.response["error"])) + try: + ESIExceptionHandler(ex) + except: + # don't need to do anything - we should already have error code in the status + pass except requests.exceptions.ConnectionError: msg = _t("Connection error, please check your internet connection") pyfalog.error(msg) self.statusbar.SetStatusText(msg) def deleteAllFittings(self, event): + self.statusbar.SetStatusText("") sEsi = Esi.getInstance() activeChar = self.getActiveCharacter() if activeChar is None: @@ -186,20 +198,30 @@ class EveFittings(AuxiliaryFrame): ) as dlg: if dlg.ShowModal() == wx.ID_YES: try: - for fit in self.fittings: - sEsi.delFitting(activeChar, fit['fitting_id']) - anyDeleted = True + try: + for fit in self.fittings: + sEsi.delFitting(activeChar, fit['fitting_id']) + anyDeleted = True + except APIException as ex: + pyfalog.error(ex) + if anyDeleted: + msg = "Some fits were not deleted: ESI error {} received - {}".format(ex.status_code, + ex.response["error"]) + else: + msg = "Failed to delete fits: ESI error {} received - {}".format(ex.status_code, + ex.response["error"]) + pyfalog.error(msg) + self.statusbar.SetStatusText(msg) + try: + ESIExceptionHandler(ex) + except: + # don't need to do anything - we should already have error code in the status + pass except requests.exceptions.ConnectionError: msg = "Connection error, please check your internet connection" pyfalog.error(msg) self.statusbar.SetStatusText(msg) - except APIException as ex: - if anyDeleted: - msg = "Some fits were not deleted: ESI error {} received".format(ex.status_code) - else: - msg = "Failed to delete fits: ESI error {} received".format(ex.status_code) - pyfalog.error(msg) - self.statusbar.SetStatusText(msg) + # repopulate the fitting list self.fitTree.populateSkillTree(self.fittings) self.fitView.update([]) @@ -222,15 +244,33 @@ class ESIServerExceptionHandler: class ESIExceptionHandler: # todo: make this a generate excetpion handler for all calls - def __init__(self, parentWindow, ex): - if ex.response['error'].startswith('Token is not valid') or ex.response['error'] == 'invalid_token': # todo: this seems messy, figure out a better response + def __init__(self, ex): + # raise ex + if ex.response['error'].startswith('Token is not valid') \ + or ex.response['error'] == 'invalid_token' \ + or ex.response['error'] == 'invalid_grant': # todo: this seems messy, figure out a better response pyfalog.error(ex) with wx.MessageDialog( - parentWindow, + gui.mainFrame.MainFrame.getInstance(), _t("There was an error validating characters' SSO token. Please try " "logging into the character again to reset the token."), _t("Invalid Token"), - wx.OK | wx.ICON_ERROR + wx.OK | wx.ICON_ERROR | wx.CANCEL + ) as dlg: + dlg.SetOKLabel("Manage ESI Characters") + ret = dlg.ShowModal() + if ret == wx.ID_OK: + SsoCharacterMgmt.openOne(parent=gui.mainFrame.MainFrame.getInstance()) + # todo: spawn manage esi characters + pass + elif ex.response['error'].startswith('Timeout contacting'): + pyfalog.error(ex) + with wx.MessageDialog( + gui.mainFrame.MainFrame.getInstance(), + "HTTP %s: %s\n\n" % (ex.status_code, ex.response['error']) + + _t("The server took too long to response. Please try again in a moment."), + _t("Timeout"), + wx.OK | wx.ICON_ERROR ) as dlg: dlg.ShowModal() else: @@ -335,9 +375,9 @@ class ExportToEve(AuxiliaryFrame): pyfalog.warning(msg) self.statusbar.SetStatusText(msg, 1) return - res = sEsi.postFitting(activeChar, data) try: + res = sEsi.postFitting(activeChar, data) res.raise_for_status() self.statusbar.SetStatusText("", 0) self.statusbar.SetStatusText(res.reason, 1) @@ -346,19 +386,19 @@ class ExportToEve(AuxiliaryFrame): pyfalog.error(msg) self.statusbar.SetStatusText(_t("ERROR"), 0) self.statusbar.SetStatusText(msg, 1) - except ESIExportException as ex: + except APIException as ex: pyfalog.error(ex) self.statusbar.SetStatusText(_t("ERROR"), 0) - self.statusbar.SetStatusText("{} - {}".format(res.status_code, res.reason), 1) - except APIException as ex: + self.statusbar.SetStatusText("HTTP {} - {}".format(ex.status_code, ex.response["error"]), 1) try: - ESIExceptionHandler(self, ex) - except (KeyboardInterrupt, SystemExit): - raise - except Exception as ex: - self.statusbar.SetStatusText(_t("ERROR"), 0) - self.statusbar.SetStatusText("{} - {}".format(res.status_code, res.reason), 1) - pyfalog.error(ex) + ESIExceptionHandler(ex) + except: + # don't need to do anything - we should already get the error in ex.response + pass + except Exception as ex: + self.statusbar.SetStatusText(_t("ERROR"), 0) + self.statusbar.SetStatusText("Unknown error", 1) + pyfalog.error(ex) class SsoCharacterMgmt(AuxiliaryFrame): diff --git a/gui/ssoLogin.py b/gui/ssoLogin.py index 07b342780..28f283cd8 100644 --- a/gui/ssoLogin.py +++ b/gui/ssoLogin.py @@ -38,7 +38,7 @@ class SsoLogin(wx.Dialog): from service.esi import Esi self.sEsi = Esi.getInstance() - uri = self.sEsi.getLoginURI(None) + uri = self.sEsi.get_login_uri(None) webbrowser.open(uri) @@ -53,7 +53,7 @@ class SsoLoginServer(wx.Dialog): self.sEsi = Esi.getInstance() serverAddr = self.sEsi.startServer(port) - uri = self.sEsi.getLoginURI(serverAddr) + uri = self.sEsi.get_login_uri(serverAddr) bSizer1 = wx.BoxSizer(wx.VERTICAL) self.mainFrame.Bind(GE.EVT_SSO_LOGIN, self.OnLogin) diff --git a/requirements.txt b/requirements.txt index ff8797eb6..4a95eb125 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,5 @@ packaging >= 16.8 roman >= 2.0.0 beautifulsoup4 >= 4.6.0 pyyaml >= 5.1 +python-jose==3.0.1 +requests-cache==0.8.1 \ No newline at end of file diff --git a/service/esi.py b/service/esi.py index 4251c89ce..3fb38ac03 100644 --- a/service/esi.py +++ b/service/esi.py @@ -11,7 +11,7 @@ import webbrowser import eos.db from service.const import EsiLoginMethod, EsiSsoMode from eos.saveddata.ssocharacter import SsoCharacter -from service.esiAccess import APIException +from service.esiAccess import APIException, GenericSsoError import gui.globalEvents as GE from gui.ssoLogin import SsoLogin, SsoLoginServer from service.server import StoppableHTTPServer, AuthHandler @@ -102,13 +102,14 @@ class Esi(EsiAccess): def login(self): # 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') == EsiSsoMode.CUSTOM or self.settings.get('loginMode') == EsiLoginMethod.SERVER: - with gui.ssoLogin.SsoLoginServer(6461 if self.settings.get('ssoMode') == EsiSsoMode.CUSTOM else 0) as dlg: + if self.settings.get('loginMode') == EsiLoginMethod.SERVER: + with gui.ssoLogin.SsoLoginServer(0) as dlg: dlg.ShowModal() else: with gui.ssoLogin.SsoLogin() as dlg: if dlg.ShowModal() == wx.ID_OK: - self.handleLogin({'SSOInfo': [dlg.ssoInfoCtrl.Value.strip()]}) + message = json.loads(base64.b64decode(dlg.ssoInfoCtrl.Value.strip())) + self.handleLogin(message) def stopServer(self): pyfalog.debug("Stopping Server") @@ -134,31 +135,16 @@ class Esi(EsiAccess): return 'http://localhost:{}'.format(port) def handleLogin(self, message): + auth_response, data = self.auth(message['code']) - # we already have authenticated stuff for the auto mode - if self.settings.get('ssoMode') == EsiSsoMode.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, - 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() - - currentCharacter = self.getSsoCharacter(cdata['CharacterName']) + currentCharacter = self.getSsoCharacter(data['name']) + sub_split = data["sub"].split(":") + if (len(sub_split) != 3): + raise GenericSsoError("JWT sub does not contain the expected data. Contents: %s" % data["sub"]) + cid = sub_split[-1] if currentCharacter is None: - currentCharacter = SsoCharacter(cdata['CharacterID'], cdata['CharacterName'], config.getClientSecret()) + currentCharacter = SsoCharacter(cid, data['name'], config.getClientSecret()) Esi.update_token(currentCharacter, auth_response) @@ -169,11 +155,17 @@ class Esi(EsiAccess): def handleServerLogin(self, message): if not message: - raise Exception("Could not parse out querystring parameters.") + raise GenericSsoError("Could not parse out querystring parameters.") - if message['state'][0] != self.state: + try: + state_enc = message['state'] + state = json.loads(base64.b64decode(state_enc))['state'] + except Exception: + raise GenericSsoError("There was a problem decoding state parameter.") + + if state != self.state: pyfalog.warn("OAUTH state mismatch") - raise Exception("OAUTH State Mismatch.") + raise GenericSsoError("OAUTH State Mismatch.") pyfalog.debug("Handling SSO login with: {0}", message) diff --git a/service/esiAccess.py b/service/esiAccess.py index a79321177..0a53c6de5 100644 --- a/service/esiAccess.py +++ b/service/esiAccess.py @@ -1,49 +1,49 @@ -""" - -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 collections import namedtuple + from logbook import Logger import uuid import time import config import base64 - +import secrets +import hashlib +import json +from jose import jwt +from jose.exceptions import ExpiredSignatureError, JWTError, JWTClaimsError +import os import datetime from service.const import EsiSsoMode, EsiEndpoints from service.settings import EsiSettings, NetworkSettings +from datetime import timedelta +from requests_cache import CachedSession + from requests import Session -from urllib.parse import urlencode, quote +from urllib.parse import urlencode pyfalog = Logger(__name__) -# 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) -# - - scopes = [ 'esi-skills.read_skills.v1', 'esi-fittings.read_fittings.v1', 'esi-fittings.write_fittings.v1' ] +ApiBase = namedtuple('ApiBase', ['sso', 'esi']) +supported_servers = { + "Tranquility": ApiBase("login.eveonline.com", "esi.evetech.net"), + "Singularity": ApiBase("sisilogin.testeveonline.com", "esi.evetech.net"), + "Serenity": ApiBase("login.evepc.163.com", "esi.evepc.163.com") +} + +class GenericSsoError(Exception): + """ Exception used for generic SSO errors that aren't directly related to an API call + """ + pass class APIException(Exception): - """ Exception for SSO related errors """ + """ Exception for API related errors """ def __init__(self, url, code, json_response): self.url = url @@ -51,10 +51,11 @@ class APIException(Exception): self.response = json_response super(APIException, self).__init__(str(self)) + def __str__(self): - if 'error' in self.response: + if 'error_description' in self.response: return 'HTTP Error %s: %s' % (self.status_code, - self.response['error']) + self.response['error_description']) elif 'message' in self.response: return 'HTTP Error %s: %s' % (self.status_code, self.response['message']) @@ -64,6 +65,7 @@ class APIException(Exception): class EsiAccess: def __init__(self): self.settings = EsiSettings.getInstance() + self.server_base: ApiBase = supported_servers[self.settings.get("server")] # session request stuff self._session = Session() @@ -76,46 +78,45 @@ class EsiAccess: self._session.headers.update(self._basicHeaders) self._session.proxies = NetworkSettings.getInstance().getProxySettingsInRequestsFormat() + # Set up cached session. This is only used for SSO meta data for now, but can be expanded to actually handle + # various ESI caching (using ETag, for example) in the future + cached_session = CachedSession( + os.path.join(config.savePath, config.ESI_CACHE), + backend="sqlite", + cache_control=True, # Use Cache-Control headers for expiration, if available + expire_after=timedelta(days=1), # Otherwise expire responses after one day + stale_if_error=True, # In case of request errors, use stale cache data if possible + ) + cached_session.headers.update(self._basicHeaders) + cached_session.proxies = NetworkSettings.getInstance().getProxySettingsInRequestsFormat() + + meta_call = cached_session.get("https://%s/.well-known/oauth-authorization-server" % self.server_base.sso) + meta_call.raise_for_status() + self.server_meta = meta_call.json() + + jwks_call = cached_session.get(self.server_meta["jwks_uri"]) + jwks_call.raise_for_status() + self.jwks = jwks_call.json() + @property def sso_url(self): - if self.settings.get("ssoMode") == EsiSsoMode.CUSTOM: - return "https://login.eveonline.com" - return "https://www.pyfa.io" + return 'https://%s/v2' % self.server_base.sso @property def esi_url(self): - return "https://esi.evetech.net" - - @property - def oauth_verify(self): - return '%s/verify/' % self.esi_url + return 'https://%s' % self.server_base.esi @property def oauth_authorize(self): - return '%s/oauth/authorize' % self.sso_url + return self.server_meta["authorization_endpoint"] @property def oauth_token(self): - return '%s/oauth/token' % self.sso_url + return self.server_meta["token_endpoint"] - def getDynamicItem(self, typeID, itemID): - return self.get(None, EsiEndpoints.DYNAMIC_ITEM.value, type_id=typeID, item_id=itemID) - - def getSkills(self, char): - return self.get(char, EsiEndpoints.CHAR_SKILLS.value, character_id=char.characterID) - - def getSecStatus(self, char): - return self.get(char, EsiEndpoints.CHAR.value, character_id=char.characterID) - - def getFittings(self, char): - return self.get(char, EsiEndpoints.CHAR_FITTINGS.value, character_id=char.characterID) - - def postFitting(self, char, json_str): - # @todo: new fitting ID can be recovered from resp.data, - return self.post(char, EsiEndpoints.CHAR_FITTINGS.value, json_str, character_id=char.characterID) - - def delFitting(self, char, fittingID): - return self.delete(char, EsiEndpoints.CHAR_DEL_FIT.value, character_id=char.characterID, fitting_id=fittingID) + @property + def client_id(self): + return self.settings.get('clientID') or config.API_CLIENT_ID @staticmethod def update_token(char, tokenResponse): @@ -125,33 +126,36 @@ class EsiAccess: if 'refresh_token' in tokenResponse: char.refreshToken = config.cipher.encrypt(tokenResponse['refresh_token'].encode()) - def getLoginURI(self, redirect=None): + def get_login_uri(self, redirect=None): self.state = str(uuid.uuid4()) - if self.settings.get("ssoMode") == EsiSsoMode.AUTO: - args = { - 'state': self.state, - 'pyfa_version': config.version, - 'login_method': self.settings.get('loginMode'), - 'client_hash': config.getClientSecret() - } + # Generate the PKCE code challenge + self.code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)) + m = hashlib.sha256() + m.update(self.code_verifier) + d = m.digest() + code_challenge = base64.urlsafe_b64encode(d).decode().replace("=", "") - if redirect is not None: - args['redirect'] = redirect + state_arg = { + 'mode': self.settings.get('loginMode'), + 'redirect': redirect, + 'state': self.state + } - 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 - ) + args = { + 'response_type': 'code', + 'redirect_uri': config.SSO_CALLBACK, + 'client_id': self.client_id, + 'scope': ' '.join(scopes), + 'code_challenge': code_challenge, + 'code_challenge_method': 'S256', + 'state': base64.b64encode(bytes(json.dumps(state_arg), 'utf-8')) + } + + return '%s?%s' % ( + self.oauth_authorize, + urlencode(args) + ) def get_oauth_header(self, token): """ Return the Bearer Authorization header required in oauth calls @@ -160,85 +164,87 @@ class EsiAccess: """ 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.') - - data = { - 'grant_type': 'refresh_token', - 'refresh_token': refreshToken, - } - - if self.settings.get('ssoMode') == EsiSsoMode.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() - ) + values = { + 'grant_type': 'authorization_code', + 'code': code, + 'client_id': self.client_id, + "code_verifier": self.code_verifier + } + + res = self.token_call(values) json_res = res.json() - return json_res + + decoded_jwt = self.validate_eve_jwt(json_res['access_token']) + return json_res, decoded_jwt 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() - ) + # todo: properly handle invalid refresh token + values = { + "grant_type": "refresh_token", + "refresh_token": config.cipher.decrypt(ssoChar.refreshToken).decode(), + "client_id": self.client_id, + } + + res = self.token_call(values) json_res = res.json() self.update_token(ssoChar, json_res) return json_res + def token_call(self, values): + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Host": self.server_base.sso, + } + + res = self._session.post( + self.server_meta["token_endpoint"], + data=values, + headers=headers, + ) + + if res.status_code != 200: + raise APIException( + self.server_meta["token_endpoint"], + res.status_code, + res.json() + ) + + return res + + def validate_eve_jwt(self, jwt_token): + """Validate a JWT token retrieved from the EVE SSO. + Args: + jwt_token: A JWT token originating from the EVE SSO + Returns + dict: The contents of the validated JWT token if there are no + validation errors + """ + + try: + jwk_sets = self.jwks["keys"] + except KeyError as e: + raise GenericSsoError("Something went wrong when retrieving the JWK set. The returned " + "payload did not have the expected key {}. \nPayload returned " + "from the SSO looks like: {}".format(e, self.jwks)) + + jwk_set = next((item for item in jwk_sets if item["alg"] == "RS256")) + + try: + return jwt.decode( + jwt_token, + jwk_set, + algorithms=jwk_set["alg"], + issuer=[self.server_base.sso, "https://%s" % self.server_base.sso] + ) + except ExpiredSignatureError as e: + raise GenericSsoError("The JWT token has expired: {}".format(str(e))) + except JWTError as e: + raise GenericSsoError("The JWT signature was invalid: {}".format(str(e))) + except JWTClaimsError as e: + raise GenericSsoError("The issuer claim was not from login.eveonline.com or " + "https://login.eveonline.com: {}".format(str(e))) + def _before_request(self, ssoChar): self._session.headers.clear() self._session.headers.update(self._basicHeaders) @@ -279,3 +285,24 @@ class EsiAccess: self._before_request(ssoChar) endpoint = endpoint.format(**kwargs) return self._after_request(self._session.delete("{}{}".format(self.esi_url, endpoint))) + + # todo: move these off to another class which extends this one. This class should only handle the low level + # authentication and + def getDynamicItem(self, typeID, itemID): + return self.get(None, EsiEndpoints.DYNAMIC_ITEM.value, type_id=typeID, item_id=itemID) + + def getSkills(self, char): + return self.get(char, EsiEndpoints.CHAR_SKILLS.value, character_id=char.characterID) + + def getSecStatus(self, char): + return self.get(char, EsiEndpoints.CHAR.value, character_id=char.characterID) + + def getFittings(self, char): + return self.get(char, EsiEndpoints.CHAR_FITTINGS.value, character_id=char.characterID) + + def postFitting(self, char, json_str): + # @todo: new fitting ID can be recovered from resp.data, + return self.post(char, EsiEndpoints.CHAR_FITTINGS.value, json_str, character_id=char.characterID) + + def delFitting(self, char, fittingID): + return self.delete(char, EsiEndpoints.CHAR_DEL_FIT.value, character_id=char.characterID, fitting_id=fittingID) \ No newline at end of file diff --git a/service/server.py b/service/server.py index 8ec1ca607..fcf09b2ca 100644 --- a/service/server.py +++ b/service/server.py @@ -4,103 +4,54 @@ import socket import threading from logbook import Logger import socketserver +import json +import traceback + +from service.esiAccess import APIException, GenericSsoError pyfalog = Logger(__name__) -# noinspection PyPep8 -HTML = ''' - - - - - - pyfa Local Server - - - - - - -
-

pyfa

- {0} -
- - - - -''' - - # https://github.com/fuzzysteve/CREST-Market-Downloader/ class AuthHandler(http.server.BaseHTTPRequestHandler): + def do_OPTIONS(self): + self.send_response(200) + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() + def do_GET(self): if self.path == "/favicon.ico": return parsed_path = urllib.parse.urlparse(self.path) - parts = urllib.parse.parse_qs(parsed_path.query) - msg = "" - - step2 = 'step' in parts - + parts = {k: ";".join(v) for k, v in urllib.parse.parse_qs(parsed_path.query).items()} + is_success = False try: - if step2: - self.server.callback(parts) - pyfalog.info("Successfully logged into EVE.") - msg = "If you see this message then it means you should be logged into EVE SSO. You may close this window and return to the application." - else: - # For implicit mode, we have to serve up the page which will take the hash and redirect using a querystring - pyfalog.info("Processing response from EVE Online.") - msg = "Processing response from EVE Online" + self.server.callback(parts) + pyfalog.info("Successfully logged into EVE.") + is_success = True + self.send_response(200) + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() except (KeyboardInterrupt, SystemExit): raise + except (GenericSsoError, APIException) as ex: + pyfalog.error("Error logging into EVE") + pyfalog.error(ex) + self.send_response(400) + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() + self.wfile.write(str.encode(str(ex))) except Exception as ex: pyfalog.error("Error logging into EVE") pyfalog.error(ex) - msg = "

Error

\n

{}

".format(ex.message) - finally: - self.send_response(200) + self.send_response(500) + self.send_header('Access-Control-Allow-Origin', '*') self.end_headers() - self.wfile.write(str.encode(HTML.format(msg))) + self.wfile.write(str.encode(str(''.join(traceback.format_tb(ex.__traceback__))))) - if step2: - # Only stop once if we've received something in the querystring + # send error + + if is_success: self.server.stop() def log_message(self, format, *args): diff --git a/service/settings.py b/service/settings.py index ccd5dd155..64435cd4d 100644 --- a/service/settings.py +++ b/service/settings.py @@ -374,6 +374,7 @@ class EsiSettings: "clientID": "", "clientSecret": "", "timeout": 60, + "server": "Tranquility", "exportCharges": True} self.settings = SettingsProvider.getInstance().getSettings(