Move over ESI functionality to be completely separate from esipy
This commit is contained in:
@@ -50,6 +50,10 @@ class SsoCharacter(object):
|
||||
).total_seconds()
|
||||
}
|
||||
|
||||
def is_token_expired(self):
|
||||
if self.accessTokenExpires is None:
|
||||
return True
|
||||
return datetime.datetime.now() >= self.accessTokenExpires
|
||||
|
||||
def __repr__(self):
|
||||
return "SsoCharacter(ID={}, name={}, client={}) at {}".format(
|
||||
|
||||
@@ -16,8 +16,7 @@ import gui.globalEvents as GE
|
||||
|
||||
from logbook import Logger
|
||||
import calendar
|
||||
from service.esi import Esi
|
||||
from esipy.exceptions import APIException
|
||||
from service.esi import Esi, APIException
|
||||
from service.port import ESIExportException
|
||||
|
||||
pyfalog = Logger(__name__)
|
||||
|
||||
@@ -4,7 +4,6 @@ matplotlib >= 2.0.0
|
||||
python-dateutil
|
||||
requests >= 2.0.0
|
||||
sqlalchemy >= 1.0.5
|
||||
esipy == 0.3.4
|
||||
cryptography
|
||||
diskcache
|
||||
markdown2
|
||||
|
||||
245
service/esi.py
245
service/esi.py
@@ -19,35 +19,53 @@ import gui.globalEvents as GE
|
||||
from service.server import StoppableHTTPServer, AuthHandler
|
||||
from service.settings import EsiSettings
|
||||
|
||||
from .esi_security_proxy import EsiSecurityProxy
|
||||
from esipy import EsiClient, EsiApp
|
||||
from esipy.cache import FileCache
|
||||
|
||||
import wx
|
||||
from requests import Session
|
||||
|
||||
pyfalog = Logger(__name__)
|
||||
|
||||
cache_path = os.path.join(config.savePath, config.ESI_CACHE)
|
||||
|
||||
from esipy.events import AFTER_TOKEN_REFRESH
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
if not os.path.exists(cache_path):
|
||||
os.mkdir(cache_path)
|
||||
# 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)
|
||||
#
|
||||
|
||||
file_cache = FileCache(cache_path)
|
||||
|
||||
|
||||
sso_url = "https://www.pyfa.io" # "https://login.eveonline.com" for actual login
|
||||
sso_url = "https://www.pyfa.io" # "https://login.eveonline.com" for actual login
|
||||
esi_url = "https://esi.tech.ccp.is"
|
||||
|
||||
oauth_authorize = '%s/oauth/authorize' % sso_url
|
||||
oauth_token = '%s/oauth/token' % sso_url
|
||||
|
||||
class EsiException(Exception):
|
||||
pass
|
||||
|
||||
class APIException(Exception):
|
||||
""" Exception for SSO related errors """
|
||||
|
||||
def __init__(self, url, code, json_response):
|
||||
self.url = url
|
||||
self.status_code = code
|
||||
self.response = json_response
|
||||
super(APIException, self).__init__(str(self))
|
||||
|
||||
def __str__(self):
|
||||
if 'error' in self.response:
|
||||
return 'HTTP Error %s: %s' % (self.status_code,
|
||||
self.response['error'])
|
||||
elif 'message' in self.response:
|
||||
return 'HTTP Error %s: %s' % (self.status_code,
|
||||
self.response['message'])
|
||||
return 'HTTP Error %s' % (self.status_code)
|
||||
|
||||
|
||||
class ESIEndpoints(Enum):
|
||||
CHAR = "/v4/characters/{character_id}/"
|
||||
CHAR_SKILLS = "/v4/characters/{character_id}/skills/" # prepend https://esi.evetech.net/
|
||||
CHAR_FITTINGS = "/v1/characters/{character_id}/fittings/"
|
||||
CHAR_DEL_FIT = "/v1/characters/{character_id}/fittings/{fitting_id}/"
|
||||
|
||||
class Servers(Enum):
|
||||
TQ = 0
|
||||
@@ -60,31 +78,8 @@ class LoginMethod(Enum):
|
||||
|
||||
|
||||
class Esi(object):
|
||||
esiapp = None
|
||||
esi_v1 = None
|
||||
esi_v4 = None
|
||||
|
||||
_initializing = None
|
||||
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def initEsiApp(cls):
|
||||
if cls._initializing is None:
|
||||
cls._initializing = True
|
||||
cls.esiapp = EsiApp(cache=file_cache, cache_time=None, cache_prefix='pyfa{0}-esipy-'.format(config.version))
|
||||
cls.esi_v1 = cls.esiapp.get_v1_swagger
|
||||
cls.esi_v4 = cls.esiapp.get_v4_swagger
|
||||
cls._initializing = False
|
||||
|
||||
@classmethod
|
||||
def genEsiClient(cls, security=None):
|
||||
return EsiClient(
|
||||
security=EsiSecurityProxy(sso_url=config.ESI_AUTH_PROXY) if security is None else security,
|
||||
cache=file_cache,
|
||||
headers={'User-Agent': 'pyfa esipy'}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def getInstance(cls):
|
||||
if cls._instance is None:
|
||||
@@ -93,18 +88,8 @@ class Esi(object):
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
try:
|
||||
Esi.initEsiApp()
|
||||
except Exception as e:
|
||||
# todo: this is a stop-gap for #1546. figure out a better way of handling esi service failing.
|
||||
pyfalog.error(e)
|
||||
wx.MessageBox("The ESI module failed to initialize. This can sometimes happen on first load on a slower connection. Please try again.")
|
||||
return
|
||||
|
||||
self.settings = EsiSettings.getInstance()
|
||||
|
||||
AFTER_TOKEN_REFRESH.add_receiver(self.tokenUpdate)
|
||||
|
||||
# these will be set when needed
|
||||
self.httpd = None
|
||||
self.state = None
|
||||
@@ -112,17 +97,31 @@ class Esi(object):
|
||||
|
||||
self.implicitCharacter = None
|
||||
|
||||
# The database cache does not seem to be working for some reason. Use
|
||||
# this as a temporary measure
|
||||
self.charCache = {}
|
||||
|
||||
# need these here to post events
|
||||
import gui.mainFrame # put this here to avoid loop
|
||||
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
|
||||
|
||||
def tokenUpdate(self, **kwargs):
|
||||
print(kwargs)
|
||||
pass
|
||||
if sso_url is None or sso_url == "":
|
||||
raise AttributeError("sso_url cannot be None or empty "
|
||||
"without app parameter")
|
||||
|
||||
self.oauth_authorize = '%s/oauth/authorize' % sso_url
|
||||
self.oauth_token = '%s/oauth/token' % sso_url
|
||||
|
||||
# use ESI url for verify, since it's better for caching
|
||||
if esi_url is None or esi_url == "":
|
||||
raise AttributeError("esi_url cannot be None or empty")
|
||||
self.oauth_verify = '%s/verify/' % esi_url
|
||||
|
||||
|
||||
# session request stuff
|
||||
self._session = Session()
|
||||
self._session.headers.update({
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': (
|
||||
'pyfa v{}'.format(config.version)
|
||||
)
|
||||
})
|
||||
|
||||
def delSsoCharacter(self, id):
|
||||
char = eos.db.getSsoCharacter(id, config.getClientSecret())
|
||||
@@ -151,46 +150,36 @@ class Esi(object):
|
||||
eos.db.commit()
|
||||
return char
|
||||
|
||||
|
||||
def getSkills(self, id):
|
||||
char = self.getSsoCharacter(id)
|
||||
op = Esi.esi_v4.op['get_characters_character_id_skills'](character_id=char.characterID)
|
||||
resp = self.check_response(char.esi_client.request(op))
|
||||
return resp.data
|
||||
resp = self.get(char, ESIEndpoints.CHAR_SKILLS, character_id=char.characterID)
|
||||
# resp = self.check_response(char.esi_client.request(op))
|
||||
return resp.json()
|
||||
|
||||
def getSecStatus(self, id):
|
||||
char = self.getSsoCharacter(id)
|
||||
op = Esi.esi_v4.op['get_characters_character_id'](character_id=char.characterID)
|
||||
resp = self.check_response(char.esi_client.request(op))
|
||||
return resp.data
|
||||
resp = self.get(char, ESIEndpoints.CHAR, character_id=char.characterID)
|
||||
return resp.json()
|
||||
|
||||
def getFittings(self, id):
|
||||
char = self.getSsoCharacter(id)
|
||||
op = Esi.esi_v1.op['get_characters_character_id_fittings'](character_id=char.characterID)
|
||||
resp = self.check_response(char.esi_client.request(op))
|
||||
return resp.data
|
||||
resp = self.get(char, ESIEndpoints.CHAR_FITTINGS, character_id=char.characterID)
|
||||
return resp.json()
|
||||
|
||||
def postFitting(self, id, json_str):
|
||||
# @todo: new fitting ID can be recovered from resp.data,
|
||||
char = self.getSsoCharacter(id)
|
||||
op = Esi.esi_v1.op['post_characters_character_id_fittings'](
|
||||
character_id=char.characterID,
|
||||
fitting=json.loads(json_str)
|
||||
)
|
||||
resp = self.check_response(char.esi_client.request(op))
|
||||
return resp.data
|
||||
resp = self.post(char, ESIEndpoints.CHAR_FITTINGS, json_str, character_id=char.characterID)
|
||||
return resp.json()
|
||||
|
||||
def delFitting(self, id, fittingID):
|
||||
char = self.getSsoCharacter(id)
|
||||
op = Esi.esi_v1.op['delete_characters_character_id_fittings_fitting_id'](
|
||||
character_id=char.characterID,
|
||||
fitting_id=fittingID
|
||||
)
|
||||
resp = self.check_response(char.esi_client.request(op))
|
||||
return resp.data
|
||||
self.delete(char, ESIEndpoints.CHAR_DEL_FIT, character_id=char.characterID, fitting_id=fittingID)
|
||||
|
||||
def check_response(self, resp):
|
||||
if resp.status >= 400:
|
||||
raise EsiException(resp.status)
|
||||
# if resp.status >= 400:
|
||||
# raise EsiException(resp.status)
|
||||
return resp
|
||||
|
||||
@staticmethod
|
||||
@@ -211,10 +200,6 @@ class Esi(object):
|
||||
if 'refresh_token' in tokenResponse:
|
||||
char.refreshToken = config.cipher.encrypt(tokenResponse['refresh_token'].encode())
|
||||
|
||||
# remove, no longer need?
|
||||
if char.esi_client is not None:
|
||||
char.esi_client.security.update_token(tokenResponse)
|
||||
|
||||
def login(self):
|
||||
serverAddr = None
|
||||
if self.settings.get('loginMode') == LoginMethod.SERVER:
|
||||
@@ -263,31 +248,72 @@ class Esi(object):
|
||||
|
||||
return 'http://localhost:{}'.format(port)
|
||||
|
||||
def get_oauth_header(self, token):
|
||||
""" Return the Bearer Authorization header required in oauth calls
|
||||
|
||||
:return: a dict with the authorization header
|
||||
"""
|
||||
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.')
|
||||
|
||||
return {
|
||||
'data': {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refreshToken,
|
||||
},
|
||||
'url': self.oauth_token,
|
||||
}
|
||||
|
||||
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 handleLogin(self, ssoInfo):
|
||||
auth_response = json.loads(base64.b64decode(ssoInfo))
|
||||
|
||||
# We need to preload the ESI Security object beforehand with the auth response so that we can use verify to
|
||||
# get character information
|
||||
# init the security object
|
||||
esisecurity = EsiSecurityProxy(sso_url=config.ESI_AUTH_PROXY)
|
||||
|
||||
esisecurity.update_token(auth_response)
|
||||
|
||||
# we get the character information
|
||||
cdata = esisecurity.verify()
|
||||
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()
|
||||
print(cdata)
|
||||
|
||||
currentCharacter = self.getSsoCharacter(cdata['CharacterName'])
|
||||
|
||||
if currentCharacter is None:
|
||||
currentCharacter = SsoCharacter(cdata['CharacterID'], cdata['CharacterName'], config.getClientSecret())
|
||||
currentCharacter.esi_client = Esi.genEsiClient(esisecurity)
|
||||
|
||||
Esi.update_token(currentCharacter, auth_response) # this also sets the esi security token
|
||||
Esi.update_token(currentCharacter, auth_response)
|
||||
|
||||
eos.db.save(currentCharacter)
|
||||
wx.PostEvent(self.mainFrame, GE.SsoLogin(character=currentCharacter))
|
||||
|
||||
# get (endpoint, char, data?)
|
||||
|
||||
def handleServerLogin(self, message):
|
||||
if not message:
|
||||
raise Exception("Could not parse out querystring parameters.")
|
||||
@@ -299,3 +325,34 @@ class Esi(object):
|
||||
pyfalog.debug("Handling SSO login with: {0}", message)
|
||||
|
||||
self.handleLogin(message['SSOInfo'][0])
|
||||
|
||||
def __before_request(self, ssoChar):
|
||||
if ssoChar.is_token_expired():
|
||||
json_response = self.refresh(ssoChar)
|
||||
# AFTER_TOKEN_REFRESH.send(**json_response)
|
||||
|
||||
if ssoChar.accessToken is not None:
|
||||
self._session.headers.update(self.get_oauth_header(ssoChar.accessToken))
|
||||
|
||||
def get(self, ssoChar, endpoint, *args, **kwargs):
|
||||
self.__before_request(ssoChar)
|
||||
endpoint = endpoint.format(**kwargs)
|
||||
return self._session.get("{}{}".format(esi_url, endpoint))
|
||||
|
||||
# check for warnings, also status > 400
|
||||
|
||||
|
||||
def post(self, ssoChar, endpoint, json, *args, **kwargs):
|
||||
self.__before_request(ssoChar)
|
||||
endpoint = endpoint.format(**kwargs)
|
||||
return self._session.post("{}{}".format(esi_url, endpoint), data=json)
|
||||
|
||||
# check for warnings, also status > 400
|
||||
|
||||
def delete(self, ssoChar, endpoint, *args, **kwargs):
|
||||
self.__before_request(ssoChar)
|
||||
endpoint = endpoint.format(**kwargs)
|
||||
return self._session.delete("{}{}".format(esi_url, endpoint))
|
||||
|
||||
# check for warnings, also status > 400
|
||||
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
""" EsiPy Security Proxy - An ESI Security class that directs authentication towards a third-party service.
|
||||
Client key/secret not needed.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import time
|
||||
|
||||
from requests import Session
|
||||
from requests.utils import quote
|
||||
from six.moves.urllib.parse import urlparse
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from esipy.events import AFTER_TOKEN_REFRESH
|
||||
from esipy.exceptions import APIException
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EsiSecurityProxy(object):
|
||||
""" Contains all the OAuth2 knowledge for ESI use.
|
||||
Based on pyswagger Security object, to be used with pyswagger BaseClient
|
||||
implementation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
**kwargs):
|
||||
""" Init the ESI Security Object
|
||||
|
||||
:param sso_url: the default sso URL used when no "app" is provided
|
||||
:param esi_url: the default esi URL used for verify endpoint
|
||||
:param app: (optionnal) the pyswagger app object
|
||||
:param security_name: (optionnal) the name of the object holding the
|
||||
informations in the securityDefinitions, used to check authed endpoint
|
||||
"""
|
||||
|
||||
app = kwargs.pop('app', None)
|
||||
sso_url = kwargs.pop('sso_url', "https://login.eveonline.com")
|
||||
esi_url = kwargs.pop('esi_url', "https://esi.tech.ccp.is")
|
||||
|
||||
self.security_name = kwargs.pop('security_name', 'evesso')
|
||||
|
||||
# we provide app object, so we don't use sso_url
|
||||
if app is not None:
|
||||
# check if the security_name exists in the securityDefinition
|
||||
security = app.root.securityDefinitions.get(
|
||||
self.security_name,
|
||||
None
|
||||
)
|
||||
if security is None:
|
||||
raise NameError(
|
||||
"%s is not defined in the securityDefinitions" %
|
||||
self.security_name
|
||||
)
|
||||
|
||||
self.oauth_authorize = security.authorizationUrl
|
||||
|
||||
# some URL we still need to "manually" define... sadly
|
||||
# we parse the authUrl so we don't care if it's TQ or SISI.
|
||||
# https://github.com/ccpgames/esi-issues/issues/92
|
||||
parsed_uri = urlparse(security.authorizationUrl)
|
||||
self.oauth_token = '%s://%s/oauth/token' % (
|
||||
parsed_uri.scheme,
|
||||
parsed_uri.netloc
|
||||
)
|
||||
|
||||
# no app object is provided, so we use direct URLs
|
||||
else:
|
||||
if sso_url is None or sso_url == "":
|
||||
raise AttributeError("sso_url cannot be None or empty "
|
||||
"without app parameter")
|
||||
|
||||
self.oauth_authorize = '%s/oauth/authorize' % sso_url
|
||||
self.oauth_token = '%s/oauth/token' % sso_url
|
||||
|
||||
# use ESI url for verify, since it's better for caching
|
||||
if esi_url is None or esi_url == "":
|
||||
raise AttributeError("esi_url cannot be None or empty")
|
||||
self.oauth_verify = '%s/verify/' % esi_url
|
||||
|
||||
# session request stuff
|
||||
self._session = Session()
|
||||
self._session.headers.update({
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': (
|
||||
'EsiPy/Security/ - '
|
||||
'https://github.com/Kyria/EsiPy'
|
||||
)
|
||||
})
|
||||
|
||||
# token data
|
||||
self.refresh_token = None
|
||||
self.access_token = None
|
||||
self.token_expiry = None
|
||||
|
||||
def __get_oauth_header(self):
|
||||
""" Return the Bearer Authorization header required in oauth calls
|
||||
|
||||
:return: a dict with the authorization header
|
||||
"""
|
||||
return {'Authorization': 'Bearer %s' % self.access_token}
|
||||
|
||||
def __make_token_request_parameters(self, params):
|
||||
""" Return the token uri from the securityDefinition
|
||||
|
||||
:param params: the data given to the request
|
||||
:return: the oauth/token uri
|
||||
"""
|
||||
request_params = {
|
||||
'data': params,
|
||||
'url': self.oauth_token,
|
||||
}
|
||||
|
||||
return request_params
|
||||
|
||||
def get_refresh_token_params(self):
|
||||
""" 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 self.refresh_token is None:
|
||||
raise AttributeError('No refresh token is defined.')
|
||||
|
||||
return self.__make_token_request_parameters(
|
||||
{
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': self.refresh_token,
|
||||
}
|
||||
)
|
||||
|
||||
def update_token(self, response_json):
|
||||
""" Update access_token, refresh_token and token_expiry from the
|
||||
response body.
|
||||
The response must be converted to a json object before being passed as
|
||||
a parameter
|
||||
|
||||
:param response_json: the response body to use.
|
||||
"""
|
||||
self.access_token = response_json['access_token']
|
||||
self.token_expiry = int(time.time()) + response_json['expires_in']
|
||||
|
||||
if 'refresh_token' in response_json:
|
||||
self.refresh_token = response_json['refresh_token']
|
||||
|
||||
def is_token_expired(self, offset=0):
|
||||
""" Return true if the token is expired.
|
||||
|
||||
The offset can be used to change the expiry time:
|
||||
- positive value decrease the time (sooner)
|
||||
- negative value increase the time (later)
|
||||
If the expiry is not set, always return True. This case allow the users
|
||||
to define a security object, only knowing the refresh_token and get
|
||||
a new access_token / expiry_time without errors.
|
||||
|
||||
:param offset: the expiry offset (in seconds) [default: 0]
|
||||
:return: boolean true if expired, else false.
|
||||
"""
|
||||
if self.token_expiry is None:
|
||||
return True
|
||||
return int(time.time()) >= (self.token_expiry - offset)
|
||||
|
||||
def refresh(self):
|
||||
""" Update the auth data (tokens) using the refresh token in auth.
|
||||
"""
|
||||
request_data = self.get_refresh_token_params()
|
||||
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(json_res)
|
||||
return json_res
|
||||
|
||||
def verify(self):
|
||||
""" Make a get call to the oauth/verify endpoint to get the user data
|
||||
|
||||
:return: the json with the data.
|
||||
"""
|
||||
res = self._session.get(
|
||||
self.oauth_verify,
|
||||
headers=self.__get_oauth_header()
|
||||
)
|
||||
if res.status_code != 200:
|
||||
raise APIException(
|
||||
self.oauth_verify,
|
||||
res.status_code,
|
||||
res.json()
|
||||
)
|
||||
return res.json()
|
||||
|
||||
def __call__(self, request):
|
||||
""" Check if the request need security header and apply them.
|
||||
Required for pyswagger.core.BaseClient.request().
|
||||
|
||||
:param request: the pyswagger request object to check
|
||||
:return: the updated request.
|
||||
"""
|
||||
if not request._security:
|
||||
return request
|
||||
|
||||
if self.is_token_expired():
|
||||
json_response = self.refresh()
|
||||
AFTER_TOKEN_REFRESH.send(**json_response)
|
||||
|
||||
for security in request._security:
|
||||
if self.security_name not in security:
|
||||
LOGGER.warning(
|
||||
"Missing Securities: [%s]" % ", ".join(security.keys())
|
||||
)
|
||||
continue
|
||||
if self.access_token is not None:
|
||||
request._p['header'].update(self.__get_oauth_header())
|
||||
|
||||
return request
|
||||
Reference in New Issue
Block a user