From 028fb42e183235f3a20f27233689f88a7ff0aea4 Mon Sep 17 00:00:00 2001 From: blitzmann Date: Sat, 16 Oct 2021 12:46:25 -0400 Subject: [PATCH 01/15] Starting some tweaks on SSO --- gh_pages/callback | 0 service/esiAccess.py | 33 ++++++++++++++++++++++++--------- 2 files changed, 24 insertions(+), 9 deletions(-) create mode 100644 gh_pages/callback diff --git a/gh_pages/callback b/gh_pages/callback new file mode 100644 index 000000000..e69de29bb diff --git a/service/esiAccess.py b/service/esiAccess.py index a79321177..4d6ed5c16 100644 --- a/service/esiAccess.py +++ b/service/esiAccess.py @@ -15,6 +15,8 @@ import uuid import time import config import base64 +import secrets +import hashlib import datetime from service.const import EsiSsoMode, EsiEndpoints @@ -78,9 +80,9 @@ class EsiAccess: @property def sso_url(self): - if self.settings.get("ssoMode") == EsiSsoMode.CUSTOM: - return "https://login.eveonline.com" - return "https://www.pyfa.io" + # if self.settings.get("ssoMode") == EsiSsoMode.CUSTOM: + return "https://login.eveonline.com/v2" + # return "https://www.pyfa.io" @property def esi_url(self): @@ -129,13 +131,26 @@ class EsiAccess: 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 + code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)) + m = hashlib.sha256() + m.update(code_verifier) + d = m.digest() + code_challenge = base64.urlsafe_b64encode(d).decode().replace("=", "") + + args = { + # 'pyfa_version': config.version, + # 'login_method': self.settings.get('loginMode'), # todo: encode this into the state + # 'client_hash': config.getClientSecret(), + 'response_type': 'code', + 'redirect_uri': 'http://localhost:6465', + 'client_id': '095d8cd841ac40b581330919b49fe746', # pyfa PKCE app # TODO: move this to some central config location, not hardcoded + 'scope': ' '.join(scopes), + 'code_challenge': code_challenge, + 'code_challenge_method': 'S256', + 'state': self.state, + } if redirect is not None: args['redirect'] = redirect From 1874cbe0c55613da4a64ba86517b6ca6651a5eb4 Mon Sep 17 00:00:00 2001 From: blitzmann Date: Sat, 16 Oct 2021 16:52:23 -0400 Subject: [PATCH 02/15] Work on the server to handle the new structure --- gh_pages/callback | 0 service/esiAccess.py | 13 ++++++++----- service/server.py | 33 +++++++++++++++------------------ 3 files changed, 23 insertions(+), 23 deletions(-) delete mode 100644 gh_pages/callback diff --git a/gh_pages/callback b/gh_pages/callback deleted file mode 100644 index e69de29bb..000000000 diff --git a/service/esiAccess.py b/service/esiAccess.py index 4d6ed5c16..b24ca898c 100644 --- a/service/esiAccess.py +++ b/service/esiAccess.py @@ -17,6 +17,7 @@ import config import base64 import secrets import hashlib +import json import datetime from service.const import EsiSsoMode, EsiEndpoints @@ -138,21 +139,23 @@ class EsiAccess: m.update(code_verifier) d = m.digest() code_challenge = base64.urlsafe_b64encode(d).decode().replace("=", "") - + state_arg = { + 'mode': self.settings.get('loginMode'), + 'redirect': redirect, + 'state': self.state + } args = { # 'pyfa_version': config.version, # 'login_method': self.settings.get('loginMode'), # todo: encode this into the state # 'client_hash': config.getClientSecret(), 'response_type': 'code', - 'redirect_uri': 'http://localhost:6465', + 'redirect_uri': 'http://127.0.0.1:5500/callback.html', 'client_id': '095d8cd841ac40b581330919b49fe746', # pyfa PKCE app # TODO: move this to some central config location, not hardcoded 'scope': ' '.join(scopes), 'code_challenge': code_challenge, 'code_challenge_method': 'S256', - 'state': self.state, + 'state': base64.b64encode(bytes(json.dumps(state_arg), 'utf-8')) } - if redirect is not None: - args['redirect'] = redirect return '%s?%s' % ( self.oauth_authorize, diff --git a/service/server.py b/service/server.py index 8ec1ca607..55c65c903 100644 --- a/service/server.py +++ b/service/server.py @@ -4,7 +4,7 @@ import socket import threading from logbook import Logger import socketserver - +import json pyfalog = Logger(__name__) # noinspection PyPep8 @@ -69,38 +69,35 @@ if (window.location.href.indexOf('step=2') == -1) {{ # 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 - + 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) except (KeyboardInterrupt, SystemExit): raise except Exception as ex: pyfalog.error("Error logging into EVE") pyfalog.error(ex) - msg = "

Error

\n

{}

".format(ex.message) + self.send_response(500) + # send error finally: - self.send_response(200) + self.send_header('Access-Control-Allow-Origin', '*') self.end_headers() - self.wfile.write(str.encode(HTML.format(msg))) - if step2: - # Only stop once if we've received something in the querystring + if is_success: self.server.stop() def log_message(self, format, *args): From 33aa208513cb49d7294f0fc4e43149837a1ea14e Mon Sep 17 00:00:00 2001 From: blitzmann Date: Sun, 17 Oct 2021 21:01:30 -0400 Subject: [PATCH 03/15] Token validation and various cleanup --- requirements.txt | 1 + service/esi.py | 23 ++++--- service/esiAccess.py | 139 ++++++++++++++++++++++++++++++------------- service/server.py | 76 +++++------------------ 4 files changed, 125 insertions(+), 114 deletions(-) diff --git a/requirements.txt b/requirements.txt index ff8797eb6..02a4c51b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ packaging >= 16.8 roman >= 2.0.0 beautifulsoup4 >= 4.6.0 pyyaml >= 5.1 +python-jose==3.0.1 \ No newline at end of file diff --git a/service/esi.py b/service/esi.py index 4251c89ce..9a3cf45dd 100644 --- a/service/esi.py +++ b/service/esi.py @@ -14,7 +14,7 @@ from eos.saveddata.ssocharacter import SsoCharacter from service.esiAccess import APIException import gui.globalEvents as GE from gui.ssoLogin import SsoLogin, SsoLoginServer -from service.server import StoppableHTTPServer, AuthHandler +from service.server import StoppableHTTPServer, AuthHandler, SSOError from service.settings import EsiSettings from service.esiAccess import EsiAccess import gui.mainFrame @@ -134,14 +134,7 @@ class Esi(EsiAccess): return 'http://localhost:{}'.format(port) def handleLogin(self, message): - - # 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]) + auth_response = self.auth(message['code'][0]) res = self._session.get( self.oauth_verify, @@ -169,11 +162,17 @@ class Esi(EsiAccess): def handleServerLogin(self, message): if not message: - raise Exception("Could not parse out querystring parameters.") + raise SSOError("Could not parse out querystring parameters.") - if message['state'][0] != self.state: + try: + state_enc = message['state'][0] + state = json.loads(base64.b64decode(state_enc))['state'] + except Exception: + raise SSOError("There was a problem decoding state parameter.") + + if state != self.state: pyfalog.warn("OAUTH state mismatch") - raise Exception("OAUTH State Mismatch.") + raise SSOError("OAUTH State Mismatch.") pyfalog.debug("Handling SSO login with: {0}", message) diff --git a/service/esiAccess.py b/service/esiAccess.py index b24ca898c..a5de73430 100644 --- a/service/esiAccess.py +++ b/service/esiAccess.py @@ -18,9 +18,12 @@ import base64 import secrets import hashlib import json +from jose import jwt +from jose.exceptions import ExpiredSignatureError, JWTError, JWTClaimsError import datetime from service.const import EsiSsoMode, EsiEndpoints +from service.server import SSOError from service.settings import EsiSettings, NetworkSettings from requests import Session @@ -131,45 +134,37 @@ class EsiAccess: def getLoginURI(self, redirect=None): self.state = str(uuid.uuid4()) - if self.settings.get("ssoMode") == EsiSsoMode.AUTO: + # 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("=", "") - # Generate the PKCE code challenge - code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)) - m = hashlib.sha256() - m.update(code_verifier) - d = m.digest() - code_challenge = base64.urlsafe_b64encode(d).decode().replace("=", "") - state_arg = { - 'mode': self.settings.get('loginMode'), - 'redirect': redirect, - 'state': self.state - } - args = { - # 'pyfa_version': config.version, - # 'login_method': self.settings.get('loginMode'), # todo: encode this into the state - # 'client_hash': config.getClientSecret(), - 'response_type': 'code', - 'redirect_uri': 'http://127.0.0.1:5500/callback.html', - 'client_id': '095d8cd841ac40b581330919b49fe746', # pyfa PKCE app # TODO: move this to some central config location, not hardcoded - 'scope': ' '.join(scopes), - 'code_challenge': code_challenge, - 'code_challenge_method': 'S256', - 'state': base64.b64encode(bytes(json.dumps(state_arg), 'utf-8')) - } + state_arg = { + 'mode': self.settings.get('loginMode'), + 'redirect': redirect, + 'state': self.state + } + + args = { + # 'pyfa_version': config.version, + # 'login_method': self.settings.get('loginMode'), # todo: encode this into the state + # 'client_hash': config.getClientSecret(), + 'response_type': 'code', + 'redirect_uri': 'http://127.0.0.1:5500/callback.html', + 'client_id': self.settings.get('clientID') or '095d8cd841ac40b581330919b49fe746', # pyfa PKCE app # TODO: move this to some central config location, not hardcoded + '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) + ) - 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 @@ -229,21 +224,83 @@ class EsiAccess: { 'grant_type': 'authorization_code', 'code': code, + 'client_id': self.settings.get('clientID') or '095d8cd841ac40b581330919b49fe746', + "code_verifier": self.code_verifier } ) def auth(self, code): - request_data = self.get_access_token_request_params(code) - res = self._session.post(**request_data) + # todo: handle invalid auth code, or one that has been used already + values = { + 'grant_type': 'authorization_code', + 'code': code, + 'client_id': self.settings.get('clientID') or '095d8cd841ac40b581330919b49fe746', + "code_verifier": self.code_verifier + } + + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Host": "login.eveonline.com", + } + + res = self._session.post( + "https://login.eveonline.com/v2/oauth/token", + data=values, + headers=headers, + ) + if res.status_code != 200: - raise Exception( - request_data['url'], + raise SSOError( + "https://login.eveonline.com/v2/oauth/token", res.status_code, res.json() ) json_res = res.json() + + self.validate_eve_jwt(json_res['access_token']) return json_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 + """ + + jwk_set_url = "https://login.eveonline.com/oauth/jwks" + + res = self._session.get(jwk_set_url) + res.raise_for_status() + + data = res.json() + + try: + jwk_sets = data["keys"] + except KeyError as e: + raise SSOError("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, data)) + + 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=["login.eveonline.com", "https://login.eveonline.com"] + ) + except ExpiredSignatureError as e: + raise SSOError("The JWT token has expired: {}").format(str(e)) + except JWTError as e: + raise SSOError("The JWT signature was invalid: {}").format(str(e)) + except JWTClaimsError as e: + raise SSOError("The issuer claim was not from login.eveonline.com or " + "https://login.eveonline.com: {}".format(str(e))) + + def refresh(self, ssoChar): request_data = self.get_refresh_token_params(config.cipher.decrypt(ssoChar.refreshToken).decode()) res = self._session.post(**request_data) diff --git a/service/server.py b/service/server.py index 55c65c903..0e5ebe2fe 100644 --- a/service/server.py +++ b/service/server.py @@ -5,67 +5,11 @@ import threading from logbook import Logger import socketserver import json + pyfalog = Logger(__name__) -# noinspection PyPep8 -HTML = ''' - - - - - - pyfa Local Server - - - - - - -
-

pyfa

- {0} -
- - - - -''' - +class SSOError(Exception): + pass # https://github.com/fuzzysteve/CREST-Market-Downloader/ class AuthHandler(http.server.BaseHTTPRequestHandler): @@ -86,16 +30,26 @@ class AuthHandler(http.server.BaseHTTPRequestHandler): 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 SSOError as ex: + pyfalog.error("Error logging into EVE") + pyfalog.error(ex) + self.send_response(500) + 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) self.send_response(500) - # send error - finally: self.send_header('Access-Control-Allow-Origin', '*') self.end_headers() + # send error + + if is_success: self.server.stop() From abd138a01528c40523bd39c866ee48114c53c1d2 Mon Sep 17 00:00:00 2001 From: blitzmann Date: Mon, 18 Oct 2021 13:09:16 -0400 Subject: [PATCH 04/15] Pull endpoints from `.well-known` route Add request caching (only for meta calls for now) Add server list (eventually want to support various servers, for now only TQ) --- config.py | 1 + gui/ssoLogin.py | 4 +- requirements.txt | 3 +- service/esi.py | 26 ++--- service/esiAccess.py | 222 +++++++++++++++++++------------------------ service/server.py | 7 +- service/settings.py | 1 + 7 files changed, 115 insertions(+), 149 deletions(-) diff --git a/config.py b/config.py index a59f99ea2..c4fdc4674 100644 --- a/config.py +++ b/config.py @@ -43,6 +43,7 @@ experimentalFeatures = None version = None language = None +API_CLIENT_ID = '095d8cd841ac40b581330919b49fe746' ESI_CACHE = 'esi_cache' LOGLEVEL_MAP = { 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 02a4c51b3..ea8831fcf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,5 @@ packaging >= 16.8 roman >= 2.0.0 beautifulsoup4 >= 4.6.0 pyyaml >= 5.1 -python-jose==3.0.1 \ No newline at end of file +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 9a3cf45dd..37a0e5764 100644 --- a/service/esi.py +++ b/service/esi.py @@ -11,10 +11,10 @@ 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, SSOError import gui.globalEvents as GE from gui.ssoLogin import SsoLogin, SsoLoginServer -from service.server import StoppableHTTPServer, AuthHandler, SSOError +from service.server import StoppableHTTPServer, AuthHandler from service.settings import EsiSettings from service.esiAccess import EsiAccess import gui.mainFrame @@ -134,24 +134,16 @@ class Esi(EsiAccess): return 'http://localhost:{}'.format(port) def handleLogin(self, message): - auth_response = self.auth(message['code'][0]) + auth_response, data = 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 SSOError("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) diff --git a/service/esiAccess.py b/service/esiAccess.py index a5de73430..d90afd900 100644 --- a/service/esiAccess.py +++ b/service/esiAccess.py @@ -10,6 +10,8 @@ Eventually I'll rewrite this to be a bit cleaner and a bit more generic, but for """ # noinspection PyPackageRequirements +from collections import namedtuple + from logbook import Logger import uuid import time @@ -20,12 +22,14 @@ 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.server import SSOError 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 @@ -40,6 +44,8 @@ pyfalog = Logger(__name__) # os.mkdir(cache_path) # +class SSOError(Exception): + pass scopes = [ 'esi-skills.read_skills.v1', @@ -47,6 +53,13 @@ scopes = [ '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 APIException(Exception): """ Exception for SSO related errors """ @@ -82,46 +95,47 @@ 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() + + self.server_base: ApiBase = supported_servers[self.settings.get("server")] + @property def sso_url(self): - # if self.settings.get("ssoMode") == EsiSsoMode.CUSTOM: - return "https://login.eveonline.com/v2" - # 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): @@ -131,7 +145,7 @@ 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()) # Generate the PKCE code challenge @@ -148,12 +162,9 @@ class EsiAccess: } args = { - # 'pyfa_version': config.version, - # 'login_method': self.settings.get('loginMode'), # todo: encode this into the state - # 'client_hash': config.getClientSecret(), 'response_type': 'code', 'redirect_uri': 'http://127.0.0.1:5500/callback.html', - 'client_id': self.settings.get('clientID') or '095d8cd841ac40b581330919b49fe746', # pyfa PKCE app # TODO: move this to some central config location, not hardcoded + 'client_id': self.client_id, 'scope': ' '.join(scopes), 'code_challenge': code_challenge, 'code_challenge_method': 'S256', @@ -165,7 +176,6 @@ class EsiAccess: urlencode(args) ) - def get_oauth_header(self, token): """ Return the Bearer Authorization header required in oauth calls @@ -173,92 +183,54 @@ 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, - 'client_id': self.settings.get('clientID') or '095d8cd841ac40b581330919b49fe746', - "code_verifier": self.code_verifier - } - ) - def auth(self, code): - # todo: handle invalid auth code, or one that has been used already + # todo: properly handle invalid auth code, or one that has been used already values = { 'grant_type': 'authorization_code', 'code': code, - 'client_id': self.settings.get('clientID') or '095d8cd841ac40b581330919b49fe746', + 'client_id': self.client_id, "code_verifier": self.code_verifier } + res = self.token_call(values) + json_res = res.json(); + + decoded_jwt = self.validate_eve_jwt(json_res['access_token']) + return json_res, decoded_jwt + + def refresh(self, ssoChar): + # 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": "login.eveonline.com", + "Host": self.server_base.sso, } res = self._session.post( - "https://login.eveonline.com/v2/oauth/token", + self.server_meta["token_endpoint"], data=values, headers=headers, ) if res.status_code != 200: - raise SSOError( - "https://login.eveonline.com/v2/oauth/token", + raise APIException( + self.server_meta["token_endpoint"], res.status_code, res.json() ) - json_res = res.json() - self.validate_eve_jwt(json_res['access_token']) - return json_res + return res def validate_eve_jwt(self, jwt_token): """Validate a JWT token retrieved from the EVE SSO. @@ -269,19 +241,12 @@ class EsiAccess: validation errors """ - jwk_set_url = "https://login.eveonline.com/oauth/jwks" - - res = self._session.get(jwk_set_url) - res.raise_for_status() - - data = res.json() - try: - jwk_sets = data["keys"] + jwk_sets = self.jwks["keys"] except KeyError as e: raise SSOError("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, data)) + "from the SSO looks like: {}".format(e, self.jwks)) jwk_set = next((item for item in jwk_sets if item["alg"] == "RS256")) @@ -290,7 +255,7 @@ class EsiAccess: jwt_token, jwk_set, algorithms=jwk_set["alg"], - issuer=["login.eveonline.com", "https://login.eveonline.com"] + issuer=[self.server_base.sso, "https://%s" % self.server_base.sso] ) except ExpiredSignatureError as e: raise SSOError("The JWT token has expired: {}").format(str(e)) @@ -300,20 +265,6 @@ class EsiAccess: raise SSOError("The issuer claim was not from login.eveonline.com or " "https://login.eveonline.com: {}".format(str(e))) - - 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 _before_request(self, ssoChar): self._session.headers.clear() self._session.headers.update(self._basicHeaders) @@ -354,3 +305,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 0e5ebe2fe..4278f2e36 100644 --- a/service/server.py +++ b/service/server.py @@ -6,10 +6,9 @@ from logbook import Logger import socketserver import json -pyfalog = Logger(__name__) +from service.esiAccess import APIException, SSOError -class SSOError(Exception): - pass +pyfalog = Logger(__name__) # https://github.com/fuzzysteve/CREST-Market-Downloader/ class AuthHandler(http.server.BaseHTTPRequestHandler): @@ -34,7 +33,7 @@ class AuthHandler(http.server.BaseHTTPRequestHandler): self.end_headers() except (KeyboardInterrupt, SystemExit): raise - except SSOError as ex: + except (SSOError, APIException) as ex: pyfalog.error("Error logging into EVE") pyfalog.error(ex) self.send_response(500) 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( From 17391f119cac105f4d3352232f3f6e66961f25ca Mon Sep 17 00:00:00 2001 From: blitzmann Date: Mon, 18 Oct 2021 13:12:53 -0400 Subject: [PATCH 05/15] No longer looks like esipy, removing comment chunk --- service/esiAccess.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/service/esiAccess.py b/service/esiAccess.py index d90afd900..9641a4151 100644 --- a/service/esiAccess.py +++ b/service/esiAccess.py @@ -1,14 +1,3 @@ -""" - -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 @@ -35,15 +24,6 @@ from urllib.parse import urlencode, quote 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) -# - class SSOError(Exception): pass From f3f7d688ab4125f71e2cd004ce8f010c5e1fdeb2 Mon Sep 17 00:00:00 2001 From: blitzmann Date: Mon, 18 Oct 2021 14:06:36 -0400 Subject: [PATCH 06/15] Properly handle the "manual" mode of copy and paste auth information --- service/esi.py | 7 ++++--- service/esiAccess.py | 7 +++---- service/server.py | 4 +--- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/service/esi.py b/service/esi.py index 37a0e5764..3f95999a6 100644 --- a/service/esi.py +++ b/service/esi.py @@ -108,7 +108,8 @@ class Esi(EsiAccess): 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,7 +135,7 @@ class Esi(EsiAccess): return 'http://localhost:{}'.format(port) def handleLogin(self, message): - auth_response, data = self.auth(message['code'][0]) + auth_response, data = self.auth(message['code']) currentCharacter = self.getSsoCharacter(data['name']) @@ -157,7 +158,7 @@ class Esi(EsiAccess): raise SSOError("Could not parse out querystring parameters.") try: - state_enc = message['state'][0] + state_enc = message['state'] state = json.loads(base64.b64decode(state_enc))['state'] except Exception: raise SSOError("There was a problem decoding state parameter.") diff --git a/service/esiAccess.py b/service/esiAccess.py index 9641a4151..ee3832b18 100644 --- a/service/esiAccess.py +++ b/service/esiAccess.py @@ -63,6 +63,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() @@ -95,8 +96,6 @@ class EsiAccess: jwks_call.raise_for_status() self.jwks = jwks_call.json() - self.server_base: ApiBase = supported_servers[self.settings.get("server")] - @property def sso_url(self): return 'https://%s/v2' % self.server_base.sso @@ -173,7 +172,7 @@ class EsiAccess: } res = self.token_call(values) - json_res = res.json(); + json_res = res.json() decoded_jwt = self.validate_eve_jwt(json_res['access_token']) return json_res, decoded_jwt @@ -187,7 +186,7 @@ class EsiAccess: } res = self.token_call(values) - json_res = res.json(); + json_res = res.json() self.update_token(ssoChar, json_res) return json_res diff --git a/service/server.py b/service/server.py index 4278f2e36..e1ac1ee4e 100644 --- a/service/server.py +++ b/service/server.py @@ -22,7 +22,7 @@ class AuthHandler(http.server.BaseHTTPRequestHandler): return parsed_path = urllib.parse.urlparse(self.path) - parts = urllib.parse.parse_qs(parsed_path.query) + parts = {k: ";".join(v) for k, v in urllib.parse.parse_qs(parsed_path.query).items()} is_success = False try: self.server.callback(parts) @@ -48,8 +48,6 @@ class AuthHandler(http.server.BaseHTTPRequestHandler): self.end_headers() # send error - - if is_success: self.server.stop() From 7b9e196ca875e3ad9ddfaca8e20338da49c56229 Mon Sep 17 00:00:00 2001 From: blitzmann Date: Tue, 19 Oct 2021 00:02:53 -0400 Subject: [PATCH 07/15] additional error handling --- service/esi.py | 10 +++++----- service/esiAccess.py | 25 +++++++++++++------------ service/server.py | 9 ++++++--- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/service/esi.py b/service/esi.py index 3f95999a6..8d449e690 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, SSOError +from service.esiAccess import APIException, GenericSsoError import gui.globalEvents as GE from gui.ssoLogin import SsoLogin, SsoLoginServer from service.server import StoppableHTTPServer, AuthHandler @@ -141,7 +141,7 @@ class Esi(EsiAccess): sub_split = data["sub"].split(":") if (len(sub_split) != 3): - raise SSOError("JWT sub does not contain the expected data. Contents: %s" % data["sub"]) + raise GenericSsoError("JWT sub does not contain the expected data. Contents: %s" % data["sub"]) cid = sub_split[-1] if currentCharacter is None: currentCharacter = SsoCharacter(cid, data['name'], config.getClientSecret()) @@ -155,17 +155,17 @@ class Esi(EsiAccess): def handleServerLogin(self, message): if not message: - raise SSOError("Could not parse out querystring parameters.") + raise GenericSsoError("Could not parse out querystring parameters.") try: state_enc = message['state'] state = json.loads(base64.b64decode(state_enc))['state'] except Exception: - raise SSOError("There was a problem decoding state parameter.") + raise GenericSsoError("There was a problem decoding state parameter.") if state != self.state: pyfalog.warn("OAUTH state mismatch") - raise SSOError("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 ee3832b18..5ebdced7b 100644 --- a/service/esiAccess.py +++ b/service/esiAccess.py @@ -20,13 +20,10 @@ 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__) -class SSOError(Exception): - pass - scopes = [ 'esi-skills.read_skills.v1', 'esi-fittings.read_fittings.v1', @@ -40,9 +37,13 @@ supported_servers = { "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 @@ -50,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']) @@ -163,7 +165,6 @@ class EsiAccess: return {'Authorization': 'Bearer %s' % token} def auth(self, code): - # todo: properly handle invalid auth code, or one that has been used already values = { 'grant_type': 'authorization_code', 'code': code, @@ -223,7 +224,7 @@ class EsiAccess: try: jwk_sets = self.jwks["keys"] except KeyError as e: - raise SSOError("Something went wrong when retrieving the JWK set. The returned " + 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)) @@ -237,11 +238,11 @@ class EsiAccess: issuer=[self.server_base.sso, "https://%s" % self.server_base.sso] ) except ExpiredSignatureError as e: - raise SSOError("The JWT token has expired: {}").format(str(e)) + raise GenericSsoError("The JWT token has expired: {}".format(str(e))) except JWTError as e: - raise SSOError("The JWT signature was invalid: {}").format(str(e)) + raise GenericSsoError("The JWT signature was invalid: {}".format(str(e))) except JWTClaimsError as e: - raise SSOError("The issuer claim was not from login.eveonline.com or " + raise GenericSsoError("The issuer claim was not from login.eveonline.com or " "https://login.eveonline.com: {}".format(str(e))) def _before_request(self, ssoChar): diff --git a/service/server.py b/service/server.py index e1ac1ee4e..fcf09b2ca 100644 --- a/service/server.py +++ b/service/server.py @@ -5,8 +5,9 @@ import threading from logbook import Logger import socketserver import json +import traceback -from service.esiAccess import APIException, SSOError +from service.esiAccess import APIException, GenericSsoError pyfalog = Logger(__name__) @@ -33,10 +34,10 @@ class AuthHandler(http.server.BaseHTTPRequestHandler): self.end_headers() except (KeyboardInterrupt, SystemExit): raise - except (SSOError, APIException) as ex: + except (GenericSsoError, APIException) as ex: pyfalog.error("Error logging into EVE") pyfalog.error(ex) - self.send_response(500) + self.send_response(400) self.send_header('Access-Control-Allow-Origin', '*') self.end_headers() self.wfile.write(str.encode(str(ex))) @@ -46,6 +47,8 @@ class AuthHandler(http.server.BaseHTTPRequestHandler): self.send_response(500) self.send_header('Access-Control-Allow-Origin', '*') self.end_headers() + self.wfile.write(str.encode(str(''.join(traceback.format_tb(ex.__traceback__))))) + # send error if is_success: From 2a94dcebf8159cf00072e2b8e8b9597d6a7af454 Mon Sep 17 00:00:00 2001 From: blitzmann Date: Tue, 19 Oct 2021 00:15:10 -0400 Subject: [PATCH 08/15] Remove various preferences related to SSO, move callback to config --- config.py | 1 + .../pyfaEsiPreferences.py | 131 +----------------- service/esi.py | 4 +- service/esiAccess.py | 2 +- 4 files changed, 7 insertions(+), 131 deletions(-) diff --git a/config.py b/config.py index c4fdc4674..0e2690be9 100644 --- a/config.py +++ b/config.py @@ -45,6 +45,7 @@ language = None API_CLIENT_ID = '095d8cd841ac40b581330919b49fe746' ESI_CACHE = 'esi_cache' +SSO_CALLBACK = 'http://127.0.0.1:5500/callback.html' LOGLEVEL_MAP = { "critical": CRITICAL, diff --git a/gui/builtinPreferenceViews/pyfaEsiPreferences.py b/gui/builtinPreferenceViews/pyfaEsiPreferences.py index 9f81d65f9..7e6ea0551 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 options 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/service/esi.py b/service/esi.py index 8d449e690..3fb38ac03 100644 --- a/service/esi.py +++ b/service/esi.py @@ -102,8 +102,8 @@ 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: diff --git a/service/esiAccess.py b/service/esiAccess.py index 5ebdced7b..0a53c6de5 100644 --- a/service/esiAccess.py +++ b/service/esiAccess.py @@ -144,7 +144,7 @@ class EsiAccess: args = { 'response_type': 'code', - 'redirect_uri': 'http://127.0.0.1:5500/callback.html', + 'redirect_uri': config.SSO_CALLBACK, 'client_id': self.client_id, 'scope': ' '.join(scopes), 'code_challenge': code_challenge, From 1b5acc36d391894ecbbed0dc1e1eacc749e8e8a6 Mon Sep 17 00:00:00 2001 From: blitzmann Date: Tue, 19 Oct 2021 00:15:22 -0400 Subject: [PATCH 09/15] update callback --- config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.py b/config.py index 0e2690be9..08d641195 100644 --- a/config.py +++ b/config.py @@ -45,7 +45,7 @@ language = None API_CLIENT_ID = '095d8cd841ac40b581330919b49fe746' ESI_CACHE = 'esi_cache' -SSO_CALLBACK = 'http://127.0.0.1:5500/callback.html' +SSO_CALLBACK = 'https://pyfa-org.github.io/Pyfa/callback' LOGLEVEL_MAP = { "critical": CRITICAL, From 03c6f7c8942b529d6cbfb9725fa58b26673f1432 Mon Sep 17 00:00:00 2001 From: blitzmann Date: Sun, 24 Oct 2021 13:07:32 -0400 Subject: [PATCH 10/15] Update token exception message to include button to manage ESI characters --- gui/esiFittings.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/gui/esiFittings.py b/gui/esiFittings.py index 70bfe92e4..3136076a3 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 @@ -223,16 +224,23 @@ 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 + 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, _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.ShowModal() + 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 else: # We don't know how to handle the error, raise it for the global error handler to pick it up raise ex From adba55eb7dc924a9ef9701f3dc888606b7ab10fc Mon Sep 17 00:00:00 2001 From: blitzmann Date: Sun, 24 Oct 2021 14:07:04 -0400 Subject: [PATCH 11/15] Ensure that the token error dialog spawns n the three most used API calls that might trigger token refresh: fetch skills, fetch fits, and export fit --- gui/characterEditor.py | 31 +++++++++++++++++++++++-------- gui/characterSelection.py | 10 ++-------- gui/esiFittings.py | 29 +++++++++++++++-------------- 3 files changed, 40 insertions(+), 30 deletions(-) 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 3136076a3..f5cbd1591 100644 --- a/gui/esiFittings.py +++ b/gui/esiFittings.py @@ -133,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: @@ -223,13 +223,14 @@ class ESIServerExceptionHandler: class ESIExceptionHandler: # todo: make this a generate excetpion handler for all calls - def __init__(self, parentWindow, ex): + 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"), @@ -343,9 +344,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) @@ -354,19 +355,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): From c18cbe5e59493fbf910f69c842ce5171845499fb Mon Sep 17 00:00:00 2001 From: blitzmann Date: Sun, 24 Oct 2021 14:30:52 -0400 Subject: [PATCH 12/15] Handle server timeouts gracefully --- gui/esiFittings.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/gui/esiFittings.py b/gui/esiFittings.py index f5cbd1591..08c7936fc 100644 --- a/gui/esiFittings.py +++ b/gui/esiFittings.py @@ -242,6 +242,16 @@ class ESIExceptionHandler: 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: # We don't know how to handle the error, raise it for the global error handler to pick it up raise ex From 533f6f3b243ea8bf8fb44e116b47046f21a76084 Mon Sep 17 00:00:00 2001 From: blitzmann Date: Sun, 24 Oct 2021 14:45:07 -0400 Subject: [PATCH 13/15] Handle API exceptions during fit deletion --- gui/esiFittings.py | 49 +++++++++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/gui/esiFittings.py b/gui/esiFittings.py index 08c7936fc..0f9950880 100644 --- a/gui/esiFittings.py +++ b/gui/esiFittings.py @@ -150,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: @@ -165,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: @@ -187,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([]) From 4b83169070ab2cb15d1b24b011a0ee3b2f6e35db Mon Sep 17 00:00:00 2001 From: blitzmann Date: Tue, 26 Oct 2021 12:20:11 -0400 Subject: [PATCH 14/15] typo fix --- gui/builtinPreferenceViews/pyfaEsiPreferences.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/builtinPreferenceViews/pyfaEsiPreferences.py b/gui/builtinPreferenceViews/pyfaEsiPreferences.py index 7e6ea0551..1356fec16 100644 --- a/gui/builtinPreferenceViews/pyfaEsiPreferences.py +++ b/gui/builtinPreferenceViews/pyfaEsiPreferences.py @@ -37,7 +37,7 @@ 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 EVE SSO Server 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 to allow for" " character login. Use this if having issues with the local server.")) From 8ebf478cf74d378983dee5212c4e277ce4fb1b2c Mon Sep 17 00:00:00 2001 From: blitzmann Date: Tue, 26 Oct 2021 16:50:04 -0400 Subject: [PATCH 15/15] fix typo in requirements --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ea8831fcf..4a95eb125 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,4 @@ 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 +requests-cache==0.8.1 \ No newline at end of file