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()