From fd492cdf94f8fd5acc0ea2644414f7676daf1df8 Mon Sep 17 00:00:00 2001 From: Jonathan Harris Date: Mon, 14 Jan 2019 21:33:41 +0000 Subject: [PATCH] Support authentication when running from source Fixes #395 --- EDMarketConnector.py | 8 ++--- README.md | 8 ++--- companion.py | 14 ++++---- protocol.py | 81 +++++++++++++++++++++++++++++++++----------- 4 files changed, 78 insertions(+), 33 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index dffea9e3..041af14a 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -53,7 +53,7 @@ import prefs import plug from hotkey import hotkeymgr from monitor import monitor -from protocol import ProtocolHandler +from protocol import protocolhandler from dashboard import dashboard from theme import theme @@ -71,7 +71,7 @@ class AppWindow: def __init__(self, master): # Start a protocol handler to handle cAPI registration - self.protocolhandler = ProtocolHandler(master) + protocolhandler.setmaster(master) self.holdofftime = config.getint('querytime') + companion.holdoff @@ -588,7 +588,7 @@ class AppWindow: # cAPI auth def auth(self, event=None): try: - companion.session.auth_callback(self.protocolhandler.lastpayload) + companion.session.auth_callback() self.status['text'] = _('Authentication successful') # Successfully authenticated with the Frontier website except companion.ServerError as e: self.status['text'] = unicode(e) @@ -680,7 +680,7 @@ class AppWindow: if platform!='darwin' or self.w.winfo_rooty()>0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 config.set('geometry', '+{1}+{2}'.format(*self.w.geometry().split('+'))) self.w.withdraw() # Following items can take a few seconds, so hide the main window while they happen - self.protocolhandler.close() + protocolhandler.close() hotkeymgr.unregister() dashboard.close() monitor.close() diff --git a/README.md b/README.md index d0e5ddd3..82c2b9c0 100644 --- a/README.md +++ b/README.md @@ -219,22 +219,22 @@ Windows: Running from source -------- -Download and extract the source code of the [latest release](https://github.com/Marginal/EDMarketConnector/releases/latest). +Download and extract the [latest source code](https://github.com/Marginal/EDMarketConnector/archive/master.zip) (or fork and clone if you're comfortable with using `git`). To use the cAPI you will need to visit the [Frontier authentication website](https://auth.frontierstore.net/), login with your Frontier credentials, register a "Developer App" and obtain the "Client Id". Mac: * Requires the Python “keyring”, “requests” and “watchdog” modules, plus an up-to-date “py2app” module if you also want to package the app - install these with `easy_install -U keyring requests watchdog py2app` . -* Run with `python ./EDMarketConnector.py` . +* Run with `CLIENT_ID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX python ./EDMarketConnector.py` . Windows: * Requires Python2.7 and the Python “keyring”, “requests” and “watchdog” modules, plus “py2exe” 0.6 if you also want to package the app. -* Run with `EDMarketConnector.py` . +* Run with `set CLIENT_ID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX && EDMarketConnector.py` . Linux: * Requires the Python “imaging-tk”, “iniparse”, “keyring” and “requests” modules. On Debian-based systems install these with `sudo apt-get install python-imaging-tk python-iniparse python-keyring python-requests` . -* Run with `./EDMarketConnector.py` . +* Run with `CLIENT_ID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX ./EDMarketConnector.py` . Command-line -------- diff --git a/companion.py b/companion.py index 12a60c85..b1dda167 100644 --- a/companion.py +++ b/companion.py @@ -16,11 +16,13 @@ import webbrowser import zlib from config import appname, appversion, config +from protocol import protocolhandler + holdoff = 60 # be nice timeout = 10 # requests timeout -CLIENT_ID = None # Replace with FDev Client Id +CLIENT_ID = os.getenv('CLIENT_ID') # Obtain from https://auth.frontierstore.net/client/signup SERVER_AUTH = 'https://auth.frontierstore.net' URL_AUTH = '/auth' URL_TOKEN = '/token' @@ -189,8 +191,8 @@ class Auth: print 'Auth\tNew authorization request' self.verifier = self.base64URLEncode(os.urandom(32)) self.state = self.base64URLEncode(os.urandom(8)) - # Won't work under IE <= 10 : https://blogs.msdn.microsoft.com/ieinternals/2011/07/13/understanding-protocols/ - webbrowser.open('%s%s?response_type=code&audience=frontier&scope=capi&client_id=%s&code_challenge=%s&code_challenge_method=S256&state=%s&redirect_uri=edmc://auth' % (SERVER_AUTH, URL_AUTH, CLIENT_ID, self.base64URLEncode(hashlib.sha256(self.verifier).digest()), self.state)) + # Won't work under IE: https://blogs.msdn.microsoft.com/ieinternals/2011/07/13/understanding-protocols/ + webbrowser.open('%s%s?response_type=code&audience=frontier&scope=capi&client_id=%s&code_challenge=%s&code_challenge_method=S256&state=%s&redirect_uri=%s' % (SERVER_AUTH, URL_AUTH, CLIENT_ID, self.base64URLEncode(hashlib.sha256(self.verifier).digest()), self.state, protocolhandler.redirect)) def authorize(self, payload): # Handle OAuth authorization code callback. Returns access token if successful, otherwise raises CredentialsError @@ -218,7 +220,7 @@ class Auth: 'client_id': CLIENT_ID, 'code_verifier': self.verifier, 'code': data['code'][0], - 'redirect_uri': 'edmc://auth', + 'redirect_uri': protocolhandler.redirect, } r = self.session.post(SERVER_AUTH + URL_TOKEN, data=data, timeout=timeout) if r.status_code == requests.codes.ok: @@ -311,11 +313,11 @@ class Session: # Wait for callback # Callback from protocol handler - def auth_callback(self, payload): + def auth_callback(self): if self.state != Session.STATE_AUTH: raise CredentialsError() # Shouldn't be getting a callback try: - self.start(self.auth.authorize(payload)) + self.start(self.auth.authorize(protocolhandler.lastpayload)) self.auth = None except: self.state = Session.STATE_INIT # Will try to authorize again on next login or query diff --git a/protocol.py b/protocol.py index ef85371f..9c507845 100644 --- a/protocol.py +++ b/protocol.py @@ -3,27 +3,30 @@ import threading import urllib2 -from sys import platform +import sys from config import appname class GenericProtocolHandler: - def __init__(self, master): - self.master = master + def __init__(self): + self.redirect = 'edmc://auth' # Base redirection URL + self.master = None self.lastpayload = None + def setmaster(self, master): + self.master = master + def close(self): pass def event(self, url): - if url.startswith('edmc://'): - self.lastpayload = url[7:] - self.master.event_generate('<>', when="tail") + self.lastpayload = url + self.master.event_generate('<>', when="tail") -if platform == 'darwin': +if sys.platform == 'darwin' and getattr(sys, 'frozen', False): import struct import objc @@ -36,15 +39,14 @@ if platform == 'darwin': POLL = 100 # ms - def __init__(self, master): - GenericProtocolHandler.__init__(self, master) + def __init__(self): + GenericProtocolHandler.__init__(self) self.eventhandler = EventHandler.alloc().init() - self.eventhandler.handler = self self.lasturl = None def poll(self): # No way of signalling to Tkinter from within the callback handler block that doesn't cause Python to crash, so poll. - if self.lasturl: + if self.lasturl and self.lasturl.startswith(self.redirect): self.event(self.lasturl) self.lasturl = None @@ -56,11 +58,11 @@ if platform == 'darwin': return self def handleEvent_withReplyEvent_(self, event, replyEvent): - self.handler.lasturl = urllib2.unquote(event.paramDescriptorForKeyword_(keyDirectObject).stringValue()).strip() - self.handler.master.after(ProtocolHandler.POLL, self.handler.poll) + protocolhandler.lasturl = urllib2.unquote(event.paramDescriptorForKeyword_(keyDirectObject).stringValue()).strip() + protocolhandler.master.after(ProtocolHandler.POLL, protocolhandler.poll) -elif platform == 'win32': +elif sys.platform == 'win32' and getattr(sys, 'frozen', False): from ctypes import * from ctypes.wintypes import * @@ -135,8 +137,8 @@ elif platform == 'win32': class ProtocolHandler(GenericProtocolHandler): - def __init__(self, master): - GenericProtocolHandler.__init__(self, master) + def __init__(self): + GenericProtocolHandler.__init__(self) self.thread = threading.Thread(target=self.worker, name='DDE worker') self.thread.daemon = True self.thread.start() @@ -177,7 +179,9 @@ elif platform == 'win32': args = wstring_at(GlobalLock(msg.lParam)).strip() GlobalUnlock(msg.lParam) if args.lower().startswith('open("') and args.endswith('")'): - self.event(urllib2.unquote(args[6:-2]).strip()) + url = urllib2.unquote(args[6:-2]).strip() + if url.startswith(self.redirect): + self.event(url) SetForegroundWindow(GetParent(self.master.winfo_id())) # raise app window PostMessage(msg.wParam, WM_DDE_ACK, hwnd, PackDDElParam(WM_DDE_ACK, 0x80, msg.lParam)) else: @@ -190,8 +194,47 @@ elif platform == 'win32': else: print 'Failed to register DDE for cAPI' -else: # Linux +else: # Linux / Run from source + + from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler class ProtocolHandler(GenericProtocolHandler): - pass + def __init__(self): + GenericProtocolHandler.__init__(self) + self.httpd = HTTPServer(('localhost', 0), HTTPRequestHandler) + self.redirect = 'http://localhost:%d/auth' % self.httpd.server_port + self.thread = threading.Thread(target=self.worker, name='DDE worker') + self.thread.daemon = True + self.thread.start() + + def close(self): + thread = self.thread + if thread: + self.thread = None + self.httpd.shutdown() + thread.join() # Wait for it to quit + + def worker(self): + self.httpd.serve_forever() + + class HTTPRequestHandler(BaseHTTPRequestHandler): + + def do_HEAD(self): + self.do_GET() + + def do_GET(self): + url = urllib2.unquote(self.path) + if url.startswith('/auth'): + protocolhandler.event(url) + self.send_response(200) + else: + self.send_response(404) # Not found + self.end_headers() + + def log_request(self, code, size=None): + pass + + +# singleton +protocolhandler = ProtocolHandler()