using JWT with roundup

"John P. Rouillard" <[email protected]>
Newsgroups gmane.comp.bug-tracking.roundup.devel
Message-ID <[email protected]>
Hi Everybody:

With the addition of the rest interface, there are a few new use cases
that have come up. This includes allowing users to only add timelogs
to issues without being able to add notes or change other settings.

Right now this has to be done on a per user basis as Roles (and thus
permissions) are tied to a specific user.

In my limited understanding, it seems that a javascript web token
(jwt) would be the way to allow this over the rest interface by
redefining the Roles/permissions for an existing user on a per
connection basis.

This could allow a third party service to interact with roundup on
behalf of a registered user and access a limited set of functions.

I have implemented generation and testing of a jwt by adding code to
interfaces.py in the roundup instance (as documented in the
"Programming the REST API " part of doc/rest.txt).

This uses the pyjwt library (pip install pyjwt). Here is my currently
development code:

from roundup.exceptions import Reject, UsageError
from roundup.anypy.strings import b2s
from roundup.rest import Routing, RestfulInstance, _data_decorator
from cgi import MiniFieldStorage
class RestfulInstance(object):
    @Routing.route("/jwt/issue", 'POST')
    @_data_decorator
    def generate_jwt(self, input):
        import jwt
        import datetime

        claim= { 'user': self.db.getuid(),
                 'roles': [ 'Admin' ],
                 'iss': self.db.config.TRACKER_WEB,
                 'aud': self.db.config.TRACKER_WEB,
                 'iat': datetime.datetime.utcnow(),
               }

        lifetime = 0
        if 'lifetime' in input:
            if input['lifetime'].value != 'unlimited':
                try:
                    lifetime = datetime.timedelta(seconds=int(input['lifetime'].value))
                except ValueError:
                    raise UsageError("Value 'lifetime' must be 'unlimited' or an integer to specify" +
                                     " lifetime in seconds. Got %s."%input['lifetime'].value)
        else:
            lifetime = datetime.timedelta(seconds=86400) # 1 day by default

        if lifetime: # if lifetime = 0 make unlimited by omitting exp claim
            claim['exp'] = datetime.datetime.utcnow() + lifetime

        myjwt = jwt.encode(claim, 'secret', algorithm='HS256')

        result = {"jwt": b2s(myjwt),
                 }

        return 200, result

    @Routing.route("/jwt/validate", 'GET')
    @_data_decorator
    def validate_jwt(self,input):
        import jwt
        if not 'jwt' in input:
            raise UsageError("jwt key must be specified")

        myjwt = input['jwt'].value

        result = jwt.decode(myjwt, 'secret',
                            algorithms=['HS256'],
                            audience=self.db.config.TRACKER_WEB,
                            issuer=self.db.config.TRACKER_WEB,
                            )

        return 200, result

Calling:

   curl -vv -u user:pass -s -X POST \
        -H "Referer: https://.../demo/" \
        -H "X-requested-with: rest" \
        -H "Content-Type: application/json"  \
        --data '{}' \
        https://.../demo/rest/jwt/issue

returns http status 200 and:

  {
    "data": {
        "jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiMyIsInJvbGVzIjpbIkFkbWluIl0sImlzcyI6Imh0dHBzOi8vcm91aWxqLmR5bmFtaWMtZG5zLm5ldC9kZW1vLyIsImF1ZCI6Imh0dHBzOi8vcm91aWxqLmR5bmFtaWMtZG5zLm5ldC9kZW1vLyIsImlhdCI6MTU2OTE4NzkyMiwiZXhwIjoxNTY5Mjc0MzIyfQ.YmmW-JZ2fVFtfQFEEC8Y9jd3h31TzBFr9C2ajo-0xcI"
    }
  }

testing/verifying the jwt is done by:

   curl -vv -u user:pass -s -H "Referer: https://.../demo/" \
         -H "X-requested-with: rest"  \
         https://.../demo/rest/jwt/validate?eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiMyIsInJvbGVzIjpbIkFkbWluIl0sImlzcyI6Imh0dHBzOi8vcm91aWxqLmR5bmFtaWMtZG5zLm5ldC9kZW1vLyIsImF1ZCI6Imh0dHBzOi8vcm91aWxqLmR5bmFtaWMtZG5zLm5ldC9kZW1vLyIsImlhdCI6MTU2OTE4NzkyMiwiZXhwIjoxNTY5Mjc0MzIyfQ.YmmW-JZ2fVFtfQFEEC8Y9jd3h31TzBFr9C2ajo-0xcI


   {
     "data": {
        "user": "3",
        "roles": [
            "Admin"
        ],
        "iss": "https://.../demo/",
        "aud": "https://.../demo/",
        "iat": 1569187922,
        "exp": 1569274322
    }
  }

where ... is the real domain for roundup. If the validate fails
because the expiration date (24 hours by default) has passed, or the
audience or issue claims are invalid, you get a generic 400 response
that something went wrong. This is still an area for fixing.

The idea is that the JWT would be recognized by roundup when passed with:

   curl -vv -s -H "Referer: https://.../demo/" \
         -H "X-requested-with: rest"  \
         -H "Authorization: Bearer <jwt>" ....

Rest calls using the jwt would be executed as though the user had the
listed roles. These would replace the user's actual roles for the
duration of the rest call. E.G. using the bearer token above, user 3
(demo) would be able to perform the rest actions as though they were
an Admin.

Still to be done:

 1) UI for generating a jwt with valid roles that are a subset of the
    roles of the user. If we use roles, maybe a role like User:timelog
    could be defined in the schema as a valid role for users with the
    User role? This could be done from the user page as well as
    from rest with username and password. Maybe rather than roles we
    need to assign permissions in the jwt?

 2) Make the rest endpoint use the jwt for
    authentication/authorization. Right now this is just a way to
    define them, they can't actually be used.

Does this sound like a fruitful path to go down? Should I expend
additional effort cleaning up 1 and 2?

Quips, comments, evasions, questions or answers?

--
				-- rouilj
John Rouillard
===========================================================================
My employers don't acknowledge my existence much less my opinions.
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.