-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathigorServlet.py
433 lines (384 loc) · 19.4 KB
/
igorServlet.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
# Enable coverage if installed and enabled through COVERAGE_PROCESS_START environment var
try:
import coverage
coverage.process_startup()
except ImportError:
pass
from flask import Flask, request, abort, make_response
import gevent.pywsgi
import threading
import time
import json
import argparse
import os
import sys
import jwt
import logging
DEBUG=False
def myWebError(msg, code=400):
resp = make_response(msg, code)
abort(resp)
class IgorServlet(threading.Thread):
"""Class implementing a REST server for use with Igor.
Objects of this class implement a simple REST server, which can optionally be run in a separate
thread. After creating the server object (either before the service is running or while it is running)
the calling application can use ``addEndpoint()`` to create endpoints with callbacks
for the various http(s) verbs.
The service understands Igor access control (external capabilities and issuer shared
secret keys) and takes care of implementing the access control policy set by the issuer.
Callbacks are only made when the REST call carries a capability that allows the specific
operation.
The service is implemented using *Flask* and *gevent.pywsgi*.
The service can by used by calling ``run()``, which will run forever in the current thread and not return.
Alternatively you can call ``start()`` which will run the service in a separate thread until
``stop()`` is called.
Arguments:
port (int): the TCP port the service will listen to.
name (str): name of the service.
nossl (bool): set to ``True`` to serve http (default: serve https).
capabilities (bool): set to ``True`` to enable using Igor capability-based access control.
noCapabilities (bool): set to ``True`` to disable using Igor capability-based access control.
The default for those two arguments is currently to *not* use capabilities but this is expected
to change in a future release.
database (str): the directory containing the SSL key *sslname*\ ``.key`` and certificate *sslname*\ ``.crt``.
Default is ``'.'``, but ``argumentParser()`` will return ``database='~/.igor'``. This is because
certificates contain only a host name, not a port or protocol, hence if ``IgorServlet`` is running
on the same machine as Igor they must share a certificate.
sslname (str): The name used in the key and certificate filename. Default is ``igor`` for reasons explained above.
nolog (bool): Set to ``True`` to disable *gevent.pywsgi* apache-style logging of incoming requests to stdout
audience (str): The ``<aud>`` value trusted in the capabilities. Usually either the hostname or the base URL of this service.
issuer (str): The ``<iss>`` value trusted in the capabilities. Usually the URL for the Igor running the issuer with ``/issuer`` as endpoint. Can be set after creation.
issuerSharedKey (str): The secret symmetric key shared between the audience (this program) and the issuer. Can be set after creation.
Note that while it is possible to specify ``capabilities=True`` and ``nossl=True`` at the same time this
is not a good idea: the capabilities are encrypted but have a known structure, and therefore the *issuerSharedKey*
would be open to a brute-force attack.
"""
@staticmethod
def argumentParser(parser=None, description=None, port=None, name=None): # pragma: no cover
"""Static method to create ArgumentParser with common arguments for IgorServlet.
Programs using IgorServlet as their REST interface will likely share a number of command
line arguments (to allow setting of port, protocol, capability support, etc). This
method returns such a parser, which will return (from ``parse_args()``) a namespace
with arguments suitable for IgorServlet.
Arguments:
parser (argparse.ArgumentParser): optional parser (by default one is created)
description (str): description for the parser constructor (if one is created)
port (int): default for the ``--port`` argument (the port the service listens to)
name (str): name of the service (default taken from ``sys.argv[0]``)
Returns:
A pre-populated ``argparse.ArgumentParser``
"""
if parser is None:
if description is None:
description = "Mini-server for use with Igor"
parser = argparse.ArgumentParser(description=description)
DEFAULTDIR=os.path.join(os.path.expanduser('~'), '.igor')
if 'IGORSERVER_DIR' in os.environ:
DEFAULTDIR = os.environ['IGORSERVER_DIR']
if port is None:
port = 8080
if name is None:
name = os.path.basename(os.path.splitext(sys.argv[0])[0])
parser.add_argument("--database", metavar="DIR", help="Config and logfiles in DIR (default: %s, environment IGORSERVER_DIR)" % DEFAULTDIR, default=DEFAULTDIR)
parser.add_argument("--name", metavar="NAME", help="Program name for use in log and config filenames in database dir(default: %s)" % name, default=name)
parser.add_argument("--sslname", metavar="NAME", help="Program name to look up SSL certificate and key in database dir (default: igor)", default="igor")
parser.add_argument("--port", metavar="PORT", type=int, help="Port to serve on (default: %d)" % port, default=port)
parser.add_argument("--nossl", action="store_true", help="Do no use https (ssl) on the service, even if certificates are available")
parser.add_argument("--nolog", action="store_true", help="Disable http server logging to stdout")
parser.add_argument("--noCapabilities", action="store_true", help="Disable access control via capabilities (allowing all access)")
parser.add_argument("--capabilities", action="store_true", help="Enable access control via capabilities")
parser.add_argument("--audience", metavar="URL", help="Audience string for this servlet (for checking capabilities and their signature)")
parser.add_argument("--issuer", metavar="URL", help="Issuer string for this servlet (for checking capabilities and their signature)")
parser.add_argument("--sharedKey", metavar="B64STRING", help="Secret key shared with issuer (for checking capabilities and their signature)")
return parser
def __init__(self, port=8080, name='igorServlet', nossl=False, capabilities=None, noCapabilities=None, database=".", sslname='igor', nolog=False, audience=None, issuer=None, issuerSharedKey=None, **kwargs):
threading.Thread.__init__(self)
self.endpoints = {}
self.useCapabilities = False # Default-default
self.issuer = None
self.issuerSharedKey = None
self.audience = None
self.server = None
self.port = port
self.name = name
# self.middleware = ()
self.nolog = nolog
self.sslname = sslname
if not self.sslname:
self.sslname = self.name
self.datadir = database
self.ssl = not nossl
keyFile = os.path.join(self.datadir, self.sslname + '.key')
if self.ssl and not os.path.exists(keyFile):
print('Warning: Using http in stead of https: no private key file', keyFile, file=sys.stderr)
self.ssl = False
if self.ssl:
self.privateKeyFile = keyFile
self.certificateFile = os.path.join(self.datadir, self.sslname + '.crt')
else:
self.privateKeyFile = None
self.certificateFile = None
if capabilities != None:
self.useCapabilities = capabilities
elif noCapabilities != None:
self.useCapabilities = not noCapabilities
self.audience = audience
self.issuer = issuer
self.issuerSharedKey = issuerSharedKey
if DEBUG: print('igorServlet: IgorServlet.__init__ called for', self)
self.app = Flask(__name__)
def setIssuer(self, issuer, issuerSharedKey):
"""Set URL of issuer trusted by this service and shared symmetric key.
If the issuer and shared key are not known yet during IgorServlet creation they can be
set later using this method, or changed.
Arguments:
issuer (str): The ``<iss>`` value trusted in the capabilities. Usually the URL for the Igor running the issuer with ``/issuer`` as endpoint.
issuerSharedKey (str): The secret symmetric key shared between the audience (this program) and the issuer.
"""
self.issuer = issuer
self.issuerSharedKey = issuerSharedKey
def hasIssuer(self):
"""Return ``True`` if this IgorServlet has an issuer and shared key"""
return not not self.issuer and not not self.issuerSharedKey
def run(self):
"""Run the REST service.
This will start serving and never return, until ``stop()`` is called (in a callback
or from another thread).
"""
if self.ssl:
kwargs = dict(keyfile=self.privateKeyFile, certfile=self.certificateFile)
else:
kwargs = {}
if self.nolog:
kwargs['log'] = None
self.server = gevent.pywsgi.WSGIServer(("0.0.0.0", self.port), self.app, **kwargs)
self.server.serve_forever(stop_timeout=10)
def stop(self):
"""Stop the REST service.
This will stop the service and ``join()`` the thread that was running it.
"""
if self.server:
self.server.stop(timeout=10)
self.server = None
return self.join()
def addEndpoint(self, path, mimetype='application/json', get=None, put=None, post=None, delete=None):
"""Add REST endpoint.
Use this call to add an endpoint to this service and supply the corresponding
callback methods.
When a REST request is made to this endpoint the first things that happens (if
capability support has been enabled) is that a capability is carried in the request
and that it is valid (using *audience*, *issuer* and *issuerSecretKey*). Then
it is checked that the capability gives the right to execute this operation.
Arguments to the REST call (and passed to the callback method) can be supplied
in three different ways:
- if the request carries a URL query the values are supplied to the callback
as named parameters.
- otherwise, if the request contains JSON data this should be an object, and
the items in the object are passed as named parameters.
- otherwise, if the request contains data that is not JSON this data is
passed (as a string) as the ``data`` argument.
Arguments:
path (str): The new endpoint, starting with ``/``.
mimetype (str): How the return value of the callback should be encoded.
Currently ``application/json`` and ``text/plain`` are supported.
get: Callback for GET calls.
put: Callback for PUT calls.
post: Callback for POST calls.
delete: Callback for DELETE calls.
"""
self.endpoints[path] = dict(mimetype=mimetype, get=get, put=put, post=post, delete=delete)
methods = []
if get:
methods.append("GET")
if put:
methods.append("PUT")
if post:
methods.append("POST")
if delete:
methods.append("DELETE")
self.app.add_url_rule(path, path, self._forward, methods=methods)
def _forward(self):
method = request.method.lower()
path = request.path
endpoint = self.endpoints.get(path)
if not path:
abort(404)
entry = endpoint[method]
if not entry:
abort(404)
if self.useCapabilities:
# We are using capability-based access control Check that the caller has the correct
# rights.
if not self._checkRights(method, path):
abort(401)
methodArgs = {}
methodArgs = request.values.to_dict()
if not methodArgs:
if request.is_json:
methodArgs = request.json
elif request.data:
data = request.data
if not isinstance(data, str):
assert isinstance(data, bytes)
data = data.decode('utf8')
methodArgs = dict(data=data)
else:
methodArgs = {}
try:
rv = entry(**methodArgs)
except TypeError as arg:
return myWebError("400 Error in parameters: %s" % arg)
if endpoint['mimetype'] == 'text/plain':
if type(rv) != type(str):
rv = "%s" % (rv,)
elif endpoint['mimetype'] == 'application/json':
if isinstance(rv, bytes):
rv = rv.decode('utf8')
rv = json.dumps(rv)
else:
assert 0, 'Unsupported mimetype %s' % endpoint['mimetype']
# Finally ensure we send utf8 bytes back
rv = rv.encode('utf-8')
return rv
def _checkRights(self, method, path):
if DEBUG: print('IgorServlet: check access for method %s on %s' % (method, path))
if not self.issuer:
if DEBUG: print('IgorServlet: issuer not set, cannot check access')
return False
if not self.issuerSharedKey:
if DEBUG: print('IgorServlet: issuerSharedKey not set, cannot check access')
return False
# Get capability from headers
headers = request.headers
authHeader = headers.get('Authorization')
if not authHeader:
if DEBUG: print('IgorServlet: no Authorization: header')
return False
authFields = authHeader.split()
if authFields[0].lower() != 'bearer':
if DEBUG: print('IgorServlet: no Authorization: bearer header')
return False
encoded = authFields[1] # base64.b64decode(authFields[1])
if DEBUG: print('IgorServlet: got bearer token data %s' % encoded)
decoded = self._decodeBearerToken(encoded)
if not decoded:
# _decodeBearerToken will return None if the key is not from the right issuer or the signature doesn't match.
return False
capPath = decoded.get('obj')
capModifier = decoded.get(method)
if not capPath:
if DEBUG: print('IgorServlet: capability does not contain obj field')
return False
if not capModifier:
if DEBUG: print('IgorServlet: capability does not have %s right' % method)
return False
pathHead = path[:len(capPath)]
pathTail = path[len(capPath):]
if pathHead != capPath or (pathTail and pathTail[0] != '/'):
if DEBUG: print('IgorServlet: capability path %s does not match %s' % (capPath, path))
return False
if capModifier == 'self':
if capPath != path:
if DEBUG: print('IgorServlet: capability path %s does not match self for %s' % (capPath, path))
return False
elif capModifier == 'child':
if pathTail.count('/') != 1:
if DEBUG: print('IgorServlet: capability path %s does not match direct child for %s' % (capPath, path))
return False
elif capModifier == 'descendant':
if not pathTail.count:
if DEBUG: print('IgorServlet: capability path %s does not match descendant for %s' % (capPath, path))
return False
elif capModifier == 'descendant-or-self':
pass
else:
if DEBUG: print('IgorServlet: capability has unkown modifier %s for right %s' % (capModifier, method))
return False
if DEBUG:
print('IgorServlet: Capability matches')
return True
def _decodeBearerToken(self, data):
try:
content = jwt.decode(data, self.issuerSharedKey, issuer=self.issuer, audience=self.audience, algorithms=['RS256', 'HS256'])
except jwt.DecodeError:
if DEBUG:
print('IgorServlet: incorrect signature on bearer token %s' % data)
print('IgorServlet: content: %s' % jwt.decode(data, verify=False))
return myWebError('401 Unauthorized, Incorrect signature on key', 401)
except jwt.InvalidIssuerError:
if DEBUG:
print('IgorServlet: incorrect issuer on bearer token %s' % data)
print('IgorServlet: content: %s' % jwt.decode(data, verify=False))
return myWebError('401 Unauthorized, incorrect issuer on key', 401)
except jwt.InvalidAudienceError:
if DEBUG:
print('IgorServlet: incorrect audience on bearer token %s' % data)
print('IgorServlet: content: %s' % jwt.decode(data, verify=False))
return myWebError('401 Unauthorized, incorrect audience on key', 401)
return content
def argumentParser(*args, **kwargs):
return IgorServlet.argumentParser(*args, **kwargs)
def main(): # pragma: no cover
global DEBUG
DEBUG = True
if DEBUG: print('igorServlet: main called')
class HelloClass:
"""Example class that returns static data (which may be recomputed every call)"""
def __init__(self):
if DEBUG: print('HelloClass.__init__ called for', self)
def get_hello(self):
return 'Hello World from test'
class CounterClass(threading.Thread):
"""Example active class that returns a counter for the number of seconds it has been running"""
def __init__(self):
threading.Thread.__init__(self)
if DEBUG: print('CounterClass.__init__ called for', self)
self.counter = 0
self.stopped = False
def run(self):
while not self.stopped:
time.sleep(1)
self.counter += 1
def stop(self):
self.stopped = 1
self.join()
def get_count(self):
return {'counter':self.counter}
#
# Parse command line arguments and instantiate the web server
#
parser = IgorServlet.argumentParser()
args = parser.parse_args()
s = IgorServlet(**vars(args))
#
# Instantiate and start the worker classes for this example program
#
helloObject = HelloClass()
counterObject = CounterClass()
#
# Add the endpoints to the web server
#
s.addEndpoint('/hello', get=helloObject.get_hello, mimetype='text/plain')
s.addEndpoint('/helloJSON', get=helloObject.get_hello, mimetype='application/json')
s.addEndpoint('/count', get=counterObject.get_count, mimetype='text/plain')
s.addEndpoint('/countJSON', get=counterObject.get_count, mimetype='application/json')
#
# Start the web server and possibly the other servers
#
s.start()
counterObject.start()
try:
while True:
time.sleep(10)
if DEBUG: print('IgorServlet: time passing and still serving...')
except KeyboardInterrupt:
pass
#
# Stop everything when termination has been requested.
#
if DEBUG: print('IgorServlet: stopping server')
counterObject.stop()
s.stop()
s = None
if __name__ == '__main__':
main()