Token validation and various cleanup

This commit is contained in:
blitzmann
2021-10-17 21:01:30 -04:00
parent 1874cbe0c5
commit 33aa208513
4 changed files with 125 additions and 114 deletions

View File

@@ -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

View File

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

View File

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

View File

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