Skip to content

Commit 86c350c

Browse files
committed
Add rudimentery experiment JSON Web Token (jwt) support
issue2551061: Add rudimentary experimental support for JSON Web Tokens to allow delegation of limited access rights to third parties. See doc/rest.txt for details and intent.
1 parent 4344a82 commit 86c350c

File tree

8 files changed

+833
-11
lines changed

8 files changed

+833
-11
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ before_install:
8585
install:
8686
- pip install mysqlclient==1.3.13
8787
- pip install psycopg2
88-
- pip install gpg pytz whoosh
88+
- pip install gpg pytz whoosh pyjwt
8989
- pip install pytest-cov codecov
9090

9191
before_script:

CHANGES.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ Features:
8888
- The database filter method now can also do an exact string search.
8989
- The database filter method now has limit and offset parameters that
9090
map to the corresponging parameters of SQL.
91+
- issue2551061: Add rudimentary experimental support for JSON Web
92+
Tokens to allow delegation of limited access rights to third
93+
parties. See doc/rest.txt for details and intent. (John Rouillard)
94+
9195

9296
Fixed:
9397

doc/installation.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ jinja2
112112
its TEMPLATE-INFO.txt file) you need
113113
to have the jinja2_ template engine installed.
114114

115+
pyjwt
116+
To use jwt tokens for login (experimental), install pyjwt. If you
117+
don't have it installed, jwt tokens are not supported.
118+
115119
Windows Service
116120
You can run Roundup as a Windows service if pywin32_ is installed.
117121
Otherwise it must be started manually.

doc/rest.txt

Lines changed: 245 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,7 +1237,8 @@ allows filtering to happen with admin privs escaping the standard
12371237
permissions scheme. For example access to a user's roles should be
12381238
limited to the user (read only) and an admin. If you have customized
12391239
your schema to implement `Restricting the list of
1240-
users that are assignable to a task <customizing.html#restricting-the-list-of-users-that-are-assignable-to-a-task>`__ so that only users with a
1240+
users that are assignable to a task <customizing.html#restricting-the-list-of-users-that-are-assignable-to-a-task>`__
1241+
so that only users with a
12411242
Developer role are allowed to be assigned to an issue, a rest end
12421243
point must be added to provide a view that exposes users with this
12431244
permission.
@@ -1333,6 +1334,248 @@ assuming user 4 is the only user with the Developer role. Note that
13331334
the url passes the `roles=User` filter option which is silently
13341335
ignored.
13351336

1337+
Changing Access Roles with JSON Web Tokens
1338+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1339+
1340+
As discussed above Roundup's schema is the access control mechanism.
1341+
However you may want to integrate a third party system with roundup.
1342+
E.G. suppose you use a time tracking service that takes an issue id
1343+
and keeps a running count of how much time was spent on it. Then with
1344+
a single button push it can add the recorded time to the roundup
1345+
issue.
1346+
1347+
You probably don't want to give this third party service your roundup
1348+
username and credentials. Especially if your roundup instance is under
1349+
your company's single sign on infrastructure.
1350+
1351+
So what we need is a way for this third part service to impersonate
1352+
you and have access to create a roundup timelog entry (see
1353+
`<customizing.html#adding-a-time-log-to-your-issues>`__. Then add it
1354+
to the associated issue. This should happen without sharing passwords
1355+
and without the third party service to see the issue (except the
1356+
``times`` property), user, or other information in the tracker.
1357+
1358+
Enter the use of a JSON web token. Roundup has rudimentary ability to
1359+
manage JWTs and use them for authentication and authorization.
1360+
1361+
There are 5 steps to set this up:
1362+
1363+
1. install pyjwt library using pip or pip3. If roundup can't find the
1364+
jwt module you will see the error ``Support for jwt disabled.``
1365+
2. create a new role that allows Create access to timelog and edit
1366+
access to an issues' ``times`` property.
1367+
3. add support for issuing (and validating) jwts to the rest interface.
1368+
This uses the `Adding new rest endpoints`_ mechanism.
1369+
4. configure roundup's config.ini [web] jwt_secret with at least 32
1370+
random characters of data. (You will get a message
1371+
``Support for jwt disabled by admin.`` if it's not long enough.)
1372+
5. add an auditor to make sure that users with this role are appending
1373+
timelog links to the `times` property of the issue.
1374+
1375+
Create role
1376+
"""""""""""
1377+
1378+
Adding this snippet of code to the tracker's ``schema.py`` should create a role with the
1379+
proper authorization::
1380+
1381+
db.security.addRole(name="User:timelog", description="allow a user to create and append timelogs")
1382+
perm = db.security.addPermission(name='Create', klass='timelog',
1383+
description="Allow timelog creation", props_only=False)
1384+
db.security.addPermissionToRole("User:timelog", perm)
1385+
perm = db.security.addPermission(name='Edit', klass='issue',
1386+
properties=('id', 'times'),
1387+
description="Allow editing timelog for issue", props_only=False)
1388+
db.security.addPermissionToRole("User:timelog", perm)
1389+
1390+
Then role is named to work with the jwt issue rest call. Starting the role
1391+
name with ``User:`` allows the jwt issue code to create a token with
1392+
this role if the user requesting the role has the User role.
1393+
1394+
Create rest endpoints
1395+
"""""""""""""""""""""
1396+
1397+
Here is code to add to your tracker's ``interfaces.py`` (note code is
1398+
python3)::
1399+
1400+
from roundup.rest import Routing, RestfulInstance, _data_decorator
1401+
1402+
class RestfulInstance(object):
1403+
@Routing.route("/jwt/issue", 'POST')
1404+
@_data_decorator
1405+
def generate_jwt(self, input):
1406+
import jwt
1407+
import datetime
1408+
from roundup.anypy.strings import b2s
1409+
1410+
# require basic auth to generate a token
1411+
# At some point we can support a refresh token.
1412+
# maybe a jwt with the "refresh": True claim generated
1413+
# using: "refresh": True in the json request payload.
1414+
1415+
denialmsg='Token creation requires login with basic auth.'
1416+
if 'HTTP_AUTHORIZATION' in self.client.env:
1417+
try:
1418+
auth = self.client.env['HTTP_AUTHORIZATION']
1419+
scheme, challenge = auth.split(' ', 1)
1420+
except (ValueError, AttributeError):
1421+
# bad format for header
1422+
raise Unauthorised(denialmsg)
1423+
if scheme.lower() != 'basic':
1424+
raise Unauthorised(denialmsg)
1425+
else:
1426+
raise Unauthorised(denialmsg)
1427+
1428+
# If we reach this point we have validated that the user has
1429+
# logged in with a password using basic auth.
1430+
all_roles = list(self.db.security.role.items())
1431+
rolenames = []
1432+
for role in all_roles:
1433+
rolenames.append(role[0])
1434+
1435+
user_roles = list(self.db.user.get_roles(self.db.getuid()))
1436+
1437+
claim= { 'sub': self.db.getuid(),
1438+
'iss': self.db.config.TRACKER_WEB,
1439+
'aud': self.db.config.TRACKER_WEB,
1440+
'iat': datetime.datetime.utcnow(),
1441+
}
1442+
1443+
lifetime = 0
1444+
if 'lifetime' in input:
1445+
if input['lifetime'].value != 'unlimited':
1446+
try:
1447+
lifetime = datetime.timedelta(seconds=int(input['lifetime'].value))
1448+
except ValueError:
1449+
raise UsageError("Value 'lifetime' must be 'unlimited' or an integer to specify" +
1450+
" lifetime in seconds. Got %s."%input['lifetime'].value)
1451+
else:
1452+
lifetime = datetime.timedelta(seconds=86400) # 1 day by default
1453+
1454+
if lifetime: # if lifetime = 0 make unlimited by omitting exp claim
1455+
claim['exp'] = datetime.datetime.utcnow() + lifetime
1456+
1457+
newroles = []
1458+
if 'roles' in input:
1459+
for role in input['roles'].value:
1460+
if role not in rolenames:
1461+
raise UsageError("Role %s is not valid."%role)
1462+
if role in user_roles:
1463+
newroles.append(role)
1464+
continue
1465+
parentrole = role.split(':', 1)[0]
1466+
if parentrole in user_roles:
1467+
newroles.append(role)
1468+
continue
1469+
1470+
raise UsageError("Role %s is not permitted."%role)
1471+
1472+
claim['roles'] = newroles
1473+
else:
1474+
claim['roles'] = user_roles
1475+
secret = self.db.config.WEB_JWT_SECRET
1476+
myjwt = jwt.encode(claim, secret, algorithm='HS256')
1477+
1478+
result = {"jwt": b2s(myjwt),
1479+
}
1480+
1481+
return 200, result
1482+
1483+
@Routing.route("/jwt/validate", 'GET')
1484+
@_data_decorator
1485+
def validate_jwt(self,input):
1486+
import jwt
1487+
if not 'jwt' in input:
1488+
raise UsageError("jwt key must be specified")
1489+
1490+
myjwt = input['jwt'].value
1491+
1492+
secret = self.db.config.WEB_JWT_SECRET
1493+
try:
1494+
result = jwt.decode(myjwt, secret,
1495+
algorithms=['HS256'],
1496+
audience=self.db.config.TRACKER_WEB,
1497+
issuer=self.db.config.TRACKER_WEB,
1498+
)
1499+
except jwt.exceptions.InvalidTokenError as err:
1500+
return 401, str(err)
1501+
1502+
return 200, result
1503+
1504+
**Note this is sample code. Use at your own risk.** It breaks a few
1505+
rules about jwts (e.g. it allows you to make unlimited lifetime
1506+
jwts). If you subscribe to the concept of jwt refresh tokens, this code
1507+
will have to be changed as it will only generate jwts with
1508+
username/password authentication.
1509+
1510+
Currently use of jwts an experiment. If this appeals to you consider
1511+
providing patches to existing code to:
1512+
1513+
1. record all jwts created by a user
1514+
2. using the record to allow jwts to be revoked and ignored by the
1515+
roundup core
1516+
3. provide a UI page for managing/revoking jwts
1517+
4. provide a rest api for revoking jwts
1518+
1519+
These end points can be used like::
1520+
1521+
curl -u demo -s -X POST -H "Referer: https://.../demo/" \
1522+
-H "X-requested-with: rest" \
1523+
-H "Content-Type: application/json" \
1524+
--data '{"lifetime": "3600", "roles": [ "user:timelog" ] }' \
1525+
https://.../demo/rest/jwt/issue
1526+
1527+
(note roles is a json array/list of strings not a string) to get::
1528+
1529+
{
1530+
"data": {
1531+
"jwt": "eyJ0eXAiOiJK......XxMDb-Q3oCnMpyhxPXMAk"
1532+
}
1533+
}
1534+
1535+
The jwt is shortened in the example since it's large. You can validate
1536+
a jwt to see if it's still valid using::
1537+
1538+
1539+
curl -s -H "Referer: https://.../demo/" \
1540+
-H "X-requested-with: rest" \
1541+
https://rouilj.dynamic-dns.net/demo/rest/jwt/validate?jwt=eyJ0eXAiOiJK......XxMDb-Q3oCnMpyhxPXMAk
1542+
1543+
(note no login is required) which returns::
1544+
1545+
{
1546+
"data": {
1547+
"user": "3",
1548+
"roles": [
1549+
"user:timelog"
1550+
],
1551+
"iss": "https://.../demo/",
1552+
"aud": "https://.../demo/",
1553+
"iat": 1569542404,
1554+
"exp": 1569546004
1555+
}
1556+
}
1557+
1558+
Final steps
1559+
^^^^^^^^^^^
1560+
1561+
See the `upgrading documentation`__ on how to regenerate an updated copy of
1562+
config.ini using roundup-admin. Then set the ``jwt_secret`` to at
1563+
least 32 characters (more is better up to 512 bits).
1564+
1565+
Writing an auditor that uses "db.user.get_roles" to see if the user
1566+
making the change has the ``user:timelog`` role, and then comparing
1567+
the original ``times`` list to the new list to verify that it is being
1568+
added to and not changed otherwise is left as an exercise for the
1569+
reader. (If you develop one, please contribute via the tracker:
1570+
https://issues.roundup-tracker.org/.)
1571+
1572+
Lastly you can create a JWT using the end point above and make a rest
1573+
call to create a new timelog entry and another call to update the
1574+
issues times property. If you have other ideas on how jwts can be
1575+
used, please share on the roundup mailing lists. See:
1576+
https://sourceforge.net/p/roundup/mailman/ for directions on
1577+
subscribing and for archives of the lists.
1578+
13361579

13371580
Creating Custom Rate Limits
13381581
===========================
@@ -1378,7 +1621,7 @@ and period that are specific to a user. If either is set to 0,
13781621
the defaults from ``config.ini`` file are used.
13791622

13801623
Test Examples
1381-
=============
1624+
^^^^^^^^^^^^^
13821625

13831626
Rate limit tests:
13841627

roundup/cgi/client.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,13 @@ def handle_xmlrpc(self):
493493
self.determine_charset()
494494
self.determine_language()
495495
# Open the database as the correct user.
496-
self.determine_user()
496+
try:
497+
self.determine_user()
498+
except LoginError as msg:
499+
output = xmlrpc_.client.dumps(
500+
xmlrpc_.client.Fault(1, "%s:%s" % (exc_type, exc_value)),
501+
allow_none=True)
502+
497503
self.check_anonymous_access()
498504

499505
try:
@@ -532,7 +538,7 @@ def handle_rest(self):
532538
self.determine_user()
533539
except LoginError as err:
534540
self.response_code = http_.client.UNAUTHORIZED
535-
output = b"Invalid Login\n"
541+
output = s2b("Invalid Login - %s"%str(err))
536542
self.setHeader("Content-Length", str(len(output)))
537543
self.setHeader("Content-Type", "text/plain")
538544
self.write(output)
@@ -914,6 +920,11 @@ def determine_user(self):
914920
"""Determine who the user is"""
915921
self.opendb('admin')
916922

923+
# if we get a jwt, it includes the roles to be used for this session
924+
# so we define a new function to encpsulate and return the jwt roles
925+
# and not take the roles from the database.
926+
override_get_roles = None
927+
917928
# get session data from db
918929
# XXX: rename
919930
self.session_api = Session(self)
@@ -963,6 +974,49 @@ def determine_user(self):
963974
# just the time. If random is SystemRandom,
964975
# this is a no-op.
965976
random_.seed("%s%s"%(password,time.time()))
977+
elif scheme.lower() == 'bearer':
978+
try: # will jwt import?
979+
import jwt
980+
from roundup.hyperdb import iter_roles
981+
try: # handle jwt exceptions
982+
secret = self.db.config.WEB_JWT_SECRET
983+
if len(secret) < 32:
984+
# no support for jwt, this is fine.
985+
self.setHeader("WWW-Authenticate", "Basic")
986+
raise LoginError('Support for jwt disabled by admin.')
987+
token = jwt.decode(challenge, secret,
988+
algorithms=['HS256'],
989+
audience=self.db.config.TRACKER_WEB,
990+
issuer=self.db.config.TRACKER_WEB)
991+
except jwt.exceptions.InvalidTokenError as err:
992+
self.setHeader("WWW-Authenticate", "Basic, Bearer")
993+
self.make_user_anonymous()
994+
raise LoginError(str(err))
995+
996+
# if we got here token is valid, use the role
997+
# and sub claims.
998+
try:
999+
# make sure to str(token['sub']) the subject. As decoded
1000+
# by json, it is unicode which thows an error when used
1001+
# with 'nodeid in db' down the call chain.
1002+
user = self.db.user.get(str(token['sub']), 'username')
1003+
except IndexError:
1004+
raise LoginError("Token subject is invalid.")
1005+
1006+
# validate roles
1007+
all_rolenames = [ role[0] for role in self.db.security.role.items() ]
1008+
for r in token['roles']:
1009+
if r.lower() not in all_rolenames:
1010+
raise LoginError("Token roles are invalid.")
1011+
1012+
# will be used later to override the get_roles method
1013+
override_get_roles = \
1014+
lambda self: iter_roles(','.join(token['roles']))
1015+
1016+
except ImportError:
1017+
# no support for jwt, this is fine.
1018+
self.setHeader("WWW-Authenticate", "Basic")
1019+
raise LoginError('Support for jwt disabled.')
9661020

9671021
# if user was not set by http authorization, try session lookup
9681022
if not user:
@@ -991,6 +1045,12 @@ def determine_user(self):
9911045

9921046
# reopen the database as the correct user
9931047
self.opendb(self.user)
1048+
if override_get_roles:
1049+
# opendb destroys and re-opens the db if instance.optimize
1050+
# is not true. This deletes an override of get_roles. So
1051+
# assign get_roles override from the jwt if needed at this
1052+
# point.
1053+
self.db.user.get_roles = override_get_roles
9941054

9951055
def check_anonymous_access(self):
9961056
"""Check that the Anonymous user is actually allowed to use the web

0 commit comments

Comments
 (0)