Mercurial > p > roundup > code
comparison doc/rest.txt @ 5878:1b57d8f3eb97
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.
| author | John Rouillard <rouilj@ieee.org> |
|---|---|
| date | Fri, 27 Sep 2019 20:38:31 -0400 |
| parents | 04deafac71ab |
| children | 94a7669677ae |
comparison
equal
deleted
inserted
replaced
| 5877:08b241c9fea4 | 5878:1b57d8f3eb97 |
|---|---|
| 1235 However the templating system can access the hyperdb directly which | 1235 However the templating system can access the hyperdb directly which |
| 1236 allows filtering to happen with admin privs escaping the standard | 1236 allows filtering to happen with admin privs escaping the standard |
| 1237 permissions scheme. For example access to a user's roles should be | 1237 permissions scheme. For example access to a user's roles should be |
| 1238 limited to the user (read only) and an admin. If you have customized | 1238 limited to the user (read only) and an admin. If you have customized |
| 1239 your schema to implement `Restricting the list of | 1239 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 | |
| 1241 Developer role are allowed to be assigned to an issue, a rest end | 1242 Developer role are allowed to be assigned to an issue, a rest end |
| 1242 point must be added to provide a view that exposes users with this | 1243 point must be added to provide a view that exposes users with this |
| 1243 permission. | 1244 permission. |
| 1244 | 1245 |
| 1245 Using the normal ``/data/user?roles=Developer`` will return all the | 1246 Using the normal ``/data/user?roles=Developer`` will return all the |
| 1331 | 1332 |
| 1332 assuming user 4 is the only user with the Developer role. Note that | 1333 assuming user 4 is the only user with the Developer role. Note that |
| 1333 the url passes the `roles=User` filter option which is silently | 1334 the url passes the `roles=User` filter option which is silently |
| 1334 ignored. | 1335 ignored. |
| 1335 | 1336 |
| 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 | |
| 1336 | 1579 |
| 1337 Creating Custom Rate Limits | 1580 Creating Custom Rate Limits |
| 1338 =========================== | 1581 =========================== |
| 1339 | 1582 |
| 1340 You can replace the default rate limiter that is configured using | 1583 You can replace the default rate limiter that is configured using |
| 1376 function. This new function uses values for the number of calls | 1619 function. This new function uses values for the number of calls |
| 1377 and period that are specific to a user. If either is set to 0, | 1620 and period that are specific to a user. If either is set to 0, |
| 1378 the defaults from ``config.ini`` file are used. | 1621 the defaults from ``config.ini`` file are used. |
| 1379 | 1622 |
| 1380 Test Examples | 1623 Test Examples |
| 1381 ============= | 1624 ^^^^^^^^^^^^^ |
| 1382 | 1625 |
| 1383 Rate limit tests: | 1626 Rate limit tests: |
| 1384 | 1627 |
| 1385 seq 1 300 | xargs -P 20 -n 1 curl --head -si \ | 1628 seq 1 300 | xargs -P 20 -n 1 curl --head -si \ |
| 1386 https://.../rest/data/status/new \# | grep Remaining | 1629 https://.../rest/data/status/new \# | grep Remaining |
