Token validation and various cleanup
This commit is contained in:
@@ -11,3 +11,4 @@ packaging >= 16.8
|
||||
roman >= 2.0.0
|
||||
beautifulsoup4 >= 4.6.0
|
||||
pyyaml >= 5.1
|
||||
python-jose==3.0.1
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,67 +5,11 @@ import threading
|
||||
from logbook import Logger
|
||||
import socketserver
|
||||
import json
|
||||
|
||||
pyfalog = Logger(__name__)
|
||||
|
||||
# noinspection PyPep8
|
||||
HTML = '''
|
||||
<!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>
|
||||
'''
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user