ajkavanagh / pyramid_jwtauth

JSON Web Token (JWT) Auth plugin for Pyramid
12 stars 9 forks source link

Cookie generation #9

Closed Matheo13 closed 9 years ago

Matheo13 commented 9 years ago

Could you explain with an exemple how to create a JWT cookie (headers) and how to use it with pyramid for authentication? The remember function seems to do nothing. Thanks a lot.

ajkavanagh commented 9 years ago

Yep, the remember function doesn't nothing deliberately, as JWTs are really used to protect API calls (think REST) rather than pages in a website. Thus, how the consuming application stores the token is completely application dependent. This is why the remember() function is a noop (https://github.com/ajkavanagh/pyramid_jwtauth/blob/master/pyramid_jwtauth/__init__.py#L253)

Thus in one application I'm using this library in: it's an Angular Single Page application which performs authentication using a login RESTful API, get's the response and then stores the cookie in the SPA:

$document.cookie = 'urn:websandhq.co.uk/auth:login:1={"token":"' + data.auth_login_token + '"}';

The urn:.... just namespaces the cookie for me.

In Pyramid, your would (in your login success function), set the cookie like this:

from pyramid.response import Response

def some_login_function(request):
    ...
    claims = make_some_claims(...)
    jwt = make_jwt(request, claims)
    response = Response(body='hello world!', content_type='text/plain')
    response.set_cookie(some_key, jwt)

The claims are the items you want encoded into the JWT (e.g. a user_id, exp, iss, nbf, sub, etc.) See the JWT specification for me details.

To make the JWT you'd need some code along the lines of:

from pyramid.interfaces import IAuthenticationPolicy

def make_jwt(request, claims):
    policy = request.registry.queryUtility(IAuthenticationPolicy)
    return policy.encode_jwt(claims)

Obviously, this assumes that you've set the library up with a signing_key, etc.

Bottom line, JWTs need a bit more support that say, just session based cookie authentication. They are really meant for RESTful style apps that want to consume a service via an API, and Pyramid is being used to provide that API, rather than as part of a multipage website where you want to protect part of the site by using user authentication that the framework then sets as part of the process. You can use it for that, but then you have to store the token in a cookie (from here)

GET /stars/pollux
Host: galaxies.com

Cookie: access_token=eyJhbGciOiJIUzI1NiIsI.eyJpc3MiOiJodHRwczotcGxlL.mFrs3Zo8eaSNcxiNfvRh9dqKP4F1cB;

However, whilst this would enable the Pyramid app to detect whether the user is authenticated (AND for the browser to automatically send the token), you'd have to hook the authentication to redirect to a login rather than allowing pyramid_jwtauth to send a 401 to the browser, as the browser wouldn't know what to do with it -- to be honest, this is normal practice anyway.

Hope that helps. If so, please let me know, and I'll include this write-up in a file somewhere in the repo.

Matheo13 commented 9 years ago

Thanks a lot for your answer, I take look on it!

Matheo13 commented 9 years ago

Token and cookie generation works fine! But I'm a bit lost regarding authentication_policy/authorization_policy and pyramid permissions. I gonna lurn more about it, but if you have the time a exemple of your hook and premission system would be appreciated. Here is my security view :

from pyramid.httpexceptions import HTTPUnauthorized
from pyramid.security import remember, forget, NO_PERMISSION_REQUIRED, Authenticated
from pyramid.view import view_config
from ecoreleve_server.Models import DBSession, User, authn_policy
import transaction

from pyramid.interfaces import IAuthenticationPolicy
from pyramid.response import Response

route_prefix = 'security/'

@view_config(
    route_name=route_prefix+'login',
    permission=NO_PERMISSION_REQUIRED,
    request_method='POST')
def login(request):
    user_id = request.POST.get('user_id', '')
    pwd = request.POST.get('password', '')

    user = DBSession.query(User).filter(User.id==user_id).one()

    if user is not None and user.check_password(pwd):
        claims = {
            "iss": user_id,
            "exp": 86400,
        }
        jwt = make_jwt(request, claims)
        response = Response(body='login success', content_type='text/plain')
        response.set_cookie('ecoReleve-Core', jwt)
        remember(request, user_id)

        transaction.commit()
        return response
    else:
        transaction.commit()
        return HTTPUnauthorized()

@view_config(
    route_name=route_prefix+'logout',
    permission=NO_PERMISSION_REQUIRED,)
def logout(request):
    headers = forget(request)
    request.response.headerlist.extend(headers)
    return request.response

@view_config(route_name=route_prefix+'has_access', 
    #permission=NO_PERMISSION_REQUIRED
)
def has_access(request):
    print('has_access_________________________')
    jwt = request.cookies['ecoReleve-Core']

    transaction.commit()
    return request.response

def make_jwt(request, claims):
    #policy = request.registry.queryUtility(IAuthenticationPolicy)
    policy = authn_policy
    print(request.registry)
    return policy.encode_jwt(request, claims)

def old_login_function():
    user_id = request.POST.get('user_id', '')
    pwd = request.POST.get('password', '')
    user = DBSession.query(User).filter(User.id==user_id).one()
    if user is not None and user.check_password(pwd):
        headers = remember(request, user_id)
        response = request.response
        response.headerlist.extend(headers)
        transaction.commit()
        return response
    else:
        transaction.commit()
        return HTTPUnauthorized()

authn_policy is a JWTAuthenticationPolicy object but I'm not shure it's the right way to do it. I don't pass into the has_access function due to wrong permission

(I'm also working on REST a single page application, with Backbone on the front side)

ajkavanagh commented 9 years ago

Okay, a few things to consider:

  1. The exp claim is a DATE in unix epoc format (i.e. a number). See http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html#rfc.section.4.1.4 -- it's also OPTIONAL so you can leave it out whilst trying to get other things working (although you should definitely include the exp claim to timeout your tokens).
  2. I'd stick with getting the policy with policy = request.registry.queryUtility(IAuthenticationPolicy). It makes your code more modular and decreases coupling between your modules - they makes it easier for you to reuse your code later EVEN if you change your authentication system.
  3. You're using transaction.commit() all over the place when a) you're not changing the DB, and b) it's done for you by the framework. You can probably leave them out, unless you've done some configuration elsewhere which means you need manual transactions everywhere.
  4. It's not clear from your question, whether the framework is rejecting the call to has_access() or not. Could you post the error message? I'm guessing you've set a default permission using: pyramid.config.Configurator.set_default_permission() or similar?
  5. You can/should switch on the authentication/authorization debugging ENV variable: PYRAMID_DEBUG_AUTHORIZATION=1 when running your app. See: http://docs.pylonsproject.org/projects/pyramid//en/latest/narr/security.html
  6. If you still have no joy, could you post how you are configuring pyramid_jwtauth? i.e. what's in your .ini file.
Matheo13 commented 9 years ago

Effectively I have a default permission to 'read'. If I remove it the app passes into the has_access function, cookie setted or not. If I let it : GET http://127.0.0.1/eco/security/has_access 403 (Forbidden)

    config = Configurator(settings=settings)
    authz_policy = ACLAuthorizationPolicy()

    config.set_authentication_policy(authn_policy)
    config.set_authorization_policy(authz_policy)
    config.set_root_factory(SecurityRoot)

    # Set the default permission level to 'read'
    config.set_default_permission('read')

    #config.include('pyramid_tm')
    config.include('pyramid_jwtauth')
    add_routes(config)
    config.scan()
    return config.make_wsgi_app()

SecurityRoot class if that helps

# Root class security #
class SecurityRoot(object):
    __acl__ = [
        (Allow, Authenticated, 'read'),
        (Allow, 'user', 'edit'),
        (Allow, 'admin', ALL_PERMISSIONS),
        DENY_ALL
    ]

    def __init__(self, request):
        self.request = request

# Useful fucntions #
def role_loader(user_id, request):
    result = DBSession.query(User.Role).filter(User.id==user_id).one()
    transaction.commit()
    return result

(authn_policy is the same JWTAuthenticationPolicy object, gonna change it)

I don't know about transaction.commit(), I will ask, it's not my own project and I don't know verry well pyramid for the moment. I try in debug mod and remove the exp claim for the moment, thanks!

ajkavanagh commented 9 years ago

Hmm. Okay, you're default security is Authenticated read, from your SecurityRoot() class. This means that the the framework is enforcing security for has_access. It does this by calling into pyramid_jwtauth policy using authenticated_userid() and effective_prinicipals(). This looks for an HTTP Authentication header, which in your case, doesn't exist.

You can't use pyramid_jwtauth (or JWT Authentication Bearer tokens) to protect a multi-page web app/site where the browser automatically sends the authentication with each request (e.g. using cookies). It just doesn't work that way, and more importantly, isn't designed for that. It's designed for APIs where the client can set the HTTP Authentication header to go with the request. e.g. from our Angular coffeescript app:

    authServerHttp = (request) ->
      return getAuthSessionToken()
      .then (auth_session_token) ->
        request.url = url_merge(get_auth_server_url(), request.url)
        request.headers = angular.extend(request.headers or {}, {
          'Authorization': "JWT token=\"#{auth_session_token}\""
          'Accept': 'application/json, text/plain'
        })
        return $http(request)

You don't really want to store the token in a cookie as you might as well just use a traditional cookie based session system. If you're not writing an SPA (Single Page App) or other client accessing a backend API service, I'm not clear on why you would want to use JWTs?

Matheo13 commented 9 years ago

Ok, actually I'm making a portal for further applications. REST SPA (with Pyramid as Web Service) and non-REST app (.Net). I want to share a token between apps through a cookie (and under a same domain) to avoid multi-authentication for the user. Store the tooken in a shared database and also in the session to avoid access to the database when it's not necessary. I was interested by http://jwt.io/ mostly because the lib exists in many language, so I can encode/decode the same token in Python and .Net. I'm thinking to keep JWT to generate a token but avoid your plugin. I don't understand well inoff the pyramid security/session system and I'm not sure that's the right way to do wath I want? Thank you anyway, the first example can be usefull I think.

ajkavanagh commented 9 years ago

Okay, an interesting scenario! You could fork pyramid_jwtauth to do validation against a named cookie, or do it manually for each API endpoint. You'd need to change parse_authz_header(request, *default) in utils.py to look for a cookie rather than the HTTP header, and then everything would work as you'd expect. Probably override/change the forget() and remember() functions in __init__.py as well.

The nice thing about the library is that it makes setting up the encryption keys nice and easy, as well as dealing with some of the default claims in the JWT specification.

If you do decide to go manually, PyJWT is the library in Python for handling JWTs (it's what pyrmaid_jwtauth is built against).

I'm guessing you're planning on Cornice or Rest Toolkit to handle the REST side of Pyramid - they both make it easier.

The only part other part about using a JWT in a cookie, is to make sure that you encrypt any sensitive information in the claims.

Matheo13 commented 9 years ago

Excellent, my plan isn't totaly a mess ;) I come back later, thank you.

Matheo13 commented 9 years ago

Sorry for the delay, I actually found my way after some investigations in the pyramid security system. It wasn't that difficult actually, less than I expected, I re-used your plugin to encode/decode the cookie, rewrote the forget/remember functions and it works fine! What else to say? Thank you :)

ajkavanagh commented 9 years ago

Excellent! Sounds like it has gone well. If there is anything that you think should come back to this module for more wide use, then please do put in a PR and I'll see if I can get it merged. :)