Token validation and various cleanup
This commit is contained in:
@@ -11,3 +11,4 @@ packaging >= 16.8
|
|||||||
roman >= 2.0.0
|
roman >= 2.0.0
|
||||||
beautifulsoup4 >= 4.6.0
|
beautifulsoup4 >= 4.6.0
|
||||||
pyyaml >= 5.1
|
pyyaml >= 5.1
|
||||||
|
python-jose==3.0.1
|
||||||
@@ -14,7 +14,7 @@ from eos.saveddata.ssocharacter import SsoCharacter
|
|||||||
from service.esiAccess import APIException
|
from service.esiAccess import APIException
|
||||||
import gui.globalEvents as GE
|
import gui.globalEvents as GE
|
||||||
from gui.ssoLogin import SsoLogin, SsoLoginServer
|
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.settings import EsiSettings
|
||||||
from service.esiAccess import EsiAccess
|
from service.esiAccess import EsiAccess
|
||||||
import gui.mainFrame
|
import gui.mainFrame
|
||||||
@@ -134,14 +134,7 @@ class Esi(EsiAccess):
|
|||||||
return 'http://localhost:{}'.format(port)
|
return 'http://localhost:{}'.format(port)
|
||||||
|
|
||||||
def handleLogin(self, message):
|
def handleLogin(self, message):
|
||||||
|
auth_response = self.auth(message['code'][0])
|
||||||
# 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(
|
res = self._session.get(
|
||||||
self.oauth_verify,
|
self.oauth_verify,
|
||||||
@@ -169,11 +162,17 @@ class Esi(EsiAccess):
|
|||||||
|
|
||||||
def handleServerLogin(self, message):
|
def handleServerLogin(self, message):
|
||||||
if not 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")
|
pyfalog.warn("OAUTH state mismatch")
|
||||||
raise Exception("OAUTH State Mismatch.")
|
raise SSOError("OAUTH State Mismatch.")
|
||||||
|
|
||||||
pyfalog.debug("Handling SSO login with: {0}", message)
|
pyfalog.debug("Handling SSO login with: {0}", message)
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ import base64
|
|||||||
import secrets
|
import secrets
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
from jose import jwt
|
||||||
|
from jose.exceptions import ExpiredSignatureError, JWTError, JWTClaimsError
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
from service.const import EsiSsoMode, EsiEndpoints
|
from service.const import EsiSsoMode, EsiEndpoints
|
||||||
|
from service.server import SSOError
|
||||||
from service.settings import EsiSettings, NetworkSettings
|
from service.settings import EsiSettings, NetworkSettings
|
||||||
|
|
||||||
from requests import Session
|
from requests import Session
|
||||||
@@ -131,45 +134,37 @@ class EsiAccess:
|
|||||||
def getLoginURI(self, redirect=None):
|
def getLoginURI(self, redirect=None):
|
||||||
self.state = str(uuid.uuid4())
|
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
|
state_arg = {
|
||||||
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32))
|
'mode': self.settings.get('loginMode'),
|
||||||
m = hashlib.sha256()
|
'redirect': redirect,
|
||||||
m.update(code_verifier)
|
'state': self.state
|
||||||
d = m.digest()
|
}
|
||||||
code_challenge = base64.urlsafe_b64encode(d).decode().replace("=", "")
|
|
||||||
state_arg = {
|
args = {
|
||||||
'mode': self.settings.get('loginMode'),
|
# 'pyfa_version': config.version,
|
||||||
'redirect': redirect,
|
# 'login_method': self.settings.get('loginMode'), # todo: encode this into the state
|
||||||
'state': self.state
|
# 'client_hash': config.getClientSecret(),
|
||||||
}
|
'response_type': 'code',
|
||||||
args = {
|
'redirect_uri': 'http://127.0.0.1:5500/callback.html',
|
||||||
# 'pyfa_version': config.version,
|
'client_id': self.settings.get('clientID') or '095d8cd841ac40b581330919b49fe746', # pyfa PKCE app # TODO: move this to some central config location, not hardcoded
|
||||||
# 'login_method': self.settings.get('loginMode'), # todo: encode this into the state
|
'scope': ' '.join(scopes),
|
||||||
# 'client_hash': config.getClientSecret(),
|
'code_challenge': code_challenge,
|
||||||
'response_type': 'code',
|
'code_challenge_method': 'S256',
|
||||||
'redirect_uri': 'http://127.0.0.1:5500/callback.html',
|
'state': base64.b64encode(bytes(json.dumps(state_arg), 'utf-8'))
|
||||||
'client_id': '095d8cd841ac40b581330919b49fe746', # pyfa PKCE app # TODO: move this to some central config location, not hardcoded
|
}
|
||||||
'scope': ' '.join(scopes),
|
|
||||||
'code_challenge': code_challenge,
|
return '%s?%s' % (
|
||||||
'code_challenge_method': 'S256',
|
self.oauth_authorize,
|
||||||
'state': base64.b64encode(bytes(json.dumps(state_arg), 'utf-8'))
|
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):
|
def get_oauth_header(self, token):
|
||||||
""" Return the Bearer Authorization header required in oauth calls
|
""" Return the Bearer Authorization header required in oauth calls
|
||||||
@@ -229,21 +224,83 @@ class EsiAccess:
|
|||||||
{
|
{
|
||||||
'grant_type': 'authorization_code',
|
'grant_type': 'authorization_code',
|
||||||
'code': code,
|
'code': code,
|
||||||
|
'client_id': self.settings.get('clientID') or '095d8cd841ac40b581330919b49fe746',
|
||||||
|
"code_verifier": self.code_verifier
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def auth(self, code):
|
def auth(self, code):
|
||||||
request_data = self.get_access_token_request_params(code)
|
# todo: handle invalid auth code, or one that has been used already
|
||||||
res = self._session.post(**request_data)
|
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:
|
if res.status_code != 200:
|
||||||
raise Exception(
|
raise SSOError(
|
||||||
request_data['url'],
|
"https://login.eveonline.com/v2/oauth/token",
|
||||||
res.status_code,
|
res.status_code,
|
||||||
res.json()
|
res.json()
|
||||||
)
|
)
|
||||||
json_res = res.json()
|
json_res = res.json()
|
||||||
|
|
||||||
|
self.validate_eve_jwt(json_res['access_token'])
|
||||||
return json_res
|
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):
|
def refresh(self, ssoChar):
|
||||||
request_data = self.get_refresh_token_params(config.cipher.decrypt(ssoChar.refreshToken).decode())
|
request_data = self.get_refresh_token_params(config.cipher.decrypt(ssoChar.refreshToken).decode())
|
||||||
res = self._session.post(**request_data)
|
res = self._session.post(**request_data)
|
||||||
|
|||||||
@@ -5,67 +5,11 @@ import threading
|
|||||||
from logbook import Logger
|
from logbook import Logger
|
||||||
import socketserver
|
import socketserver
|
||||||
import json
|
import json
|
||||||
|
|
||||||
pyfalog = Logger(__name__)
|
pyfalog = Logger(__name__)
|
||||||
|
|
||||||
# noinspection PyPep8
|
class SSOError(Exception):
|
||||||
HTML = '''
|
pass
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
|
|
||||||
<title>pyfa Local Server</title>
|
|
||||||
<style type="text/css">
|
|
||||||
body {{ text-align: center; padding: 150px; }}
|
|
||||||
h1 {{ font-size: 40px; }}
|
|
||||||
h2 {{ font-size: 32px; }}
|
|
||||||
body {{ font: 20px Helvetica, sans-serif; color: #333; }}
|
|
||||||
#article {{ display: block; text-align: left; width: 650px; margin: 0 auto; }}
|
|
||||||
a {{ color: #dc8100; text-decoration: none; }}
|
|
||||||
a:hover {{ color: #333; text-decoration: none; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<!-- Layout from Short Circuit's CREST login. Shout out! https://github.com/farshield/shortcircuit -->
|
|
||||||
<div id="article">
|
|
||||||
<h1>pyfa</h1>
|
|
||||||
{0}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
function extractFromHash(name, hash) {{
|
|
||||||
var match = hash.match(new RegExp(name + "=([^&]+)"));
|
|
||||||
return !!match && match[1];
|
|
||||||
}}
|
|
||||||
|
|
||||||
var hash = window.location.hash;
|
|
||||||
var token = extractFromHash("access_token", hash);
|
|
||||||
var step2 = extractFromHash("step2", hash);
|
|
||||||
|
|
||||||
function doRedirect() {{
|
|
||||||
if (token){{
|
|
||||||
// implicit authentication
|
|
||||||
var redirect = window.location.origin.concat('/?', window.location.hash.substr(1), '&step=2');
|
|
||||||
window.location = redirect;
|
|
||||||
}}
|
|
||||||
else {{
|
|
||||||
// user-defined
|
|
||||||
var redirect = window.location.href + '&step=2';
|
|
||||||
window.location = redirect;
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
|
|
||||||
// do redirect if we are not already on step 2
|
|
||||||
if (window.location.href.indexOf('step=2') == -1) {{
|
|
||||||
setTimeout(doRedirect(), 1000);
|
|
||||||
}}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
# https://github.com/fuzzysteve/CREST-Market-Downloader/
|
# https://github.com/fuzzysteve/CREST-Market-Downloader/
|
||||||
class AuthHandler(http.server.BaseHTTPRequestHandler):
|
class AuthHandler(http.server.BaseHTTPRequestHandler):
|
||||||
@@ -86,16 +30,26 @@ class AuthHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
pyfalog.info("Successfully logged into EVE.")
|
pyfalog.info("Successfully logged into EVE.")
|
||||||
is_success = True
|
is_success = True
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.end_headers()
|
||||||
except (KeyboardInterrupt, SystemExit):
|
except (KeyboardInterrupt, SystemExit):
|
||||||
raise
|
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:
|
except Exception as ex:
|
||||||
pyfalog.error("Error logging into EVE")
|
pyfalog.error("Error logging into EVE")
|
||||||
pyfalog.error(ex)
|
pyfalog.error(ex)
|
||||||
self.send_response(500)
|
self.send_response(500)
|
||||||
# send error
|
|
||||||
finally:
|
|
||||||
self.send_header('Access-Control-Allow-Origin', '*')
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
# send error
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if is_success:
|
if is_success:
|
||||||
self.server.stop()
|
self.server.stop()
|
||||||
|
|||||||
Reference in New Issue
Block a user