Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JWT support #89

Open
timkelty opened this issue May 4, 2018 · 40 comments
Open

Add JWT support #89

timkelty opened this issue May 4, 2018 · 40 comments

Comments

@timkelty
Copy link
Contributor

timkelty commented May 4, 2018

I have a project where I need to implement a GraphQL api, using JWT for access and authentication.

Still working out the best way to get this should work, but starting this ticket just to get some things down.

For my use-case, I'm looking at two types of access:

  • "granted" access, e.g. a shared JWT token, generated from CraftQL (similar to how permission/access works now)
  • "authenticated" access (e.g. for member access), where a request with user/pass would be posted to a Craft endpoint, returning a JWT to the client.
    • Subsequent requests to the server would include the JWT in the Authentication: bearer header
    • The token would be parsed on the server, determining what the access level was

In both cases, the body of the JWT would be the user info/access/permission levels.

For authenticated access, it seems the tricky part is syncing/refreshing the expiration of the token. It seems like you'd have to refresh the expiration of the token with every subsequent request somehow.

There are some low-level JWT methods here:

These articles have made the most sense to me:

Would love any input from @nateiler as well!

@nateiler
Copy link

nateiler commented May 4, 2018

First, my disclosure: I'm coming from the 'old school' RESTful background. I haven't looked at CraftQL in depth so this may be a little short sighted.

Issue
A JWT is issued against an authentic user. Pretty straight forward; we know who you and issue a token where it can be consumed and the identity determined at future point in time. It would have a relatively short expiry. Probably something similar to the length of a Craft session. If you're going to include permission levels in the body, be cautious as the JWT body is publicly viewable AND you should verify the permissions on the server side (to prevent tampering) ... so you may just want to handle them on the server side (Craft permissions / RBAC). The craft-jwt plugin should help you here w/ issuing a token.

Note: Once a JWT token is created, its in the wild for use until expiration or another mechanism for invalidation.

My guess is you'll want to send the current token and re-issue a new token on each SPA action ... similar to a standard page load w/ CSRF. Your front end will be responsible for storing the latest token from the last response.

Consume
The easiest way to consume the token is via the Authorization filter. I don't if CraftQL allows manipulation at the controller behavior level but Yii filters are the way to go. The craft-jwtpackage includes a filter and you can read more about them here: https://www.yiiframework.com/doc/guide/2.0/en/rest-authentication

I won't get into everything that happens upon consumption, but when it's all said and done, you should have a 'logged in' user (accessing the identity via Craft::$app->getUsers->getIdentity() works).

Authorization
This is where I digress (when it comes to GraphQL). I would typically recommend addressing access (to third parties) at the action and resource level (GET Users, GET User:1, POST User, etc). This translates pretty easily w/ REST but I'm not versed enough w/ GraphQL to give much insight. I skimmed one of the articles above and saw mentions of managing access at the field level (however user address is different than organization address, so perhaps resource + field ???). I would also suggest implementing this at the controller filter level if possible.

You could register and assign users to native Craft user groups/permissions. Depending on the complexity, you could roll your own RBAC style (I believe this is on the Craft roadmap too). I can provide more resources on RBAC too.

Please don't get lazy w/ security. I've seen the use a global authorization / access token (IE: you're using sending the same token, code, obscure string, as I). Don't do it. It's garbage. No exceptions; we have modern, secure frameworks at our disposal for this.

@timkelty
Copy link
Contributor Author

timkelty commented May 4, 2018

Thanks for jumping in @nateiler!

Some questions…

be cautious as the JWT body is publicly viewable AND you should verify the permissions on the server side (to prevent tampering)

Wouldn't your JWT be encrypted with secret key, so you'd be able to trust it?

// header
{
  "alg": "HS256",
  "typ": "JWT"
}

// payload
{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

// signature
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  <secret_key>
)

How would you envision non-logged (guest) in access to work? E.g. I need the SPA client to be able to have access, to the API, but doesn't login credentials like a user. Would the SPA client still have to request a token up front, same as a user?

@nateiler
Copy link

nateiler commented May 4, 2018

I was incorrect. The body could be tampered with, but on the token verification side this SHOULD be caught via the signature verification process. There a couple forms of JWT:

A JWE type token would be encrypted and the the contents are unknown. Contents are unknown to the end user

A JWS type token is not encrypted and the contents could be viewed. It's up to consumer to verify the signature which usually contains the body.

https://jwt.io/

@markhuot
Copy link
Owner

markhuot commented May 4, 2018

Thanks for all this info! I'm still a little hazy on the benefit of JWT over just a "token" in the traditional sense?

Right now: you generate a token in CraftQL. That token can have permissions applied so it can only query and not mutate, for example. Or, can only query one entry type not all entry types. You can have any many of these tokens as you'd like and they're all random so they shouldn't be "guessable."

JWT: would allow me to encode some information within the token, but I'm not sure what information I would even need, other than the account information. But isn't that account information the token, as it's currently written?

So, what benefit would JWT provide? I could encrypt the query, itself, as it's sent over?

I see the advantages of JWT but I'm just not seeing how they could be practically included in to CraftQL, yet. I'd love to discuss this more though because it does seem like something I could add in without too much trouble (once I get my head around the benefits).

@nateiler
Copy link

nateiler commented May 4, 2018

Mark, the point you made about issuing a token for non-mutation actions is pretty harmless. While the tokens are random, and not easy to guess the outcome is security thru obscurity. All you would need is some user to send a tweet with a token and then the entire world could use it and access the same data. Rare, but it could happen.

A JWT token contains an identity in the body. It's issued from your web app and (in this case) validated/verified by your web app. If a security concern raises, you can address it on a per-use bases instead of potentially re-issuing a new token to all consumers.

Mutations should really be cautious of this as one tech savvy user could exploit this (and you wouldn't be able to pinpoint who did it).

In the case of CraftQL, the request would include a JWT authorization header along with the a standard GraphQL request/data. On the server side, you would grab that Authorization header, validate the token and establish an identity. From this point forward, all actions (service requests) are taken on behalf of the identity provided ... so Craft::$app->getUsers()->getIdentity()->can('foo-bar') is available, etc.

@markhuot
Copy link
Owner

markhuot commented May 5, 2018

I think I’ve got my head around this now. Originally I was assuming that the client would create the token and send it over to the server, which would then be validated, and accepted or rejected by the server. I guess that flow could work, but for CraftQL it would be more realistic to have CraftQL generate the token and have the client store it and send it back with each request.

This article helped me, too,

Under the current flow a user creates a token in the Craft UI and copies/pastes it. Is this okay for your specific use case or do you need a sign in route that a user can enter Craft credentials and be granted a JWT?

@timkelty
Copy link
Contributor Author

Under the current flow a user creates a token in the Craft UI and copies/pastes it. Is this okay for your specific use case or do you need a sign in route that a user can enter Craft credentials and be granted a JWT?

@markhuot right, in my case, I need to have the token returned from a sign-in.

@timkelty
Copy link
Contributor Author

timkelty commented May 10, 2018

@nateiler @markhuot

Here's the part I'm not quite clear on:

So if the token is encrypted with a secret, that means only the server (Craft in this case) can know the secret (obv. if the client had the secret, it would be exposed).

What that also seems to imply is that the client can never use the body of the JWT directly? In which case, it seems like you'd have to, with every request:

  • send along your current JWT token in the Authentication: bearer
  • server verifies the token, returning the decrypted body along with a NEW (refreshed) JWT token to the client
    • Is this response just json? {newToken: xxx, body: {memberGroups: [3,4,7]}} or is the new token supposed to be in a response header?
  • Client can then do it's access control logic based on the response (e.g. {memberGroups: [3,4,7]})

UPDATE

Ok I think I finally understand (mostly). What my above confusion was missing was the fact the JWT body itself is not encrypted, so the client can receive the token and freely read the body data w/o the secret key. Only the server needs the secret to verify and issue new tokens.

SO - I think the only remaining gray area is how the server returns the new/refreshed tokens it creates. I was thinking this would be standardized in a header it is for requests (Authentication: bearer), but I can't find any info on this. Seems like people do it however they want…custom header, in the body, etc - which seems weird.

@markhuot
Copy link
Owner

I wasn’t thinking of including anything important in the body of the token (since it's passed in the clear).

So, for the following example: “user logs in and is presented with a dashboard of their content” the flow would be,

  1. user sees a login form
  2. user enters username/pass
  3. credentials are posted to CraftQL at /api via the GraphQL request below
  4. CraftQL validates the user and generates a token for them, the token doesn’t contain any personally identifying information. It does not include their user id or their groups. The response data, however, does return that information you're looking for
  5. front-end gets back the token and stores it in a cookie or local storage
  6. front-end can use the rest of the response from authorize however it sees fit

The authorize request would look like this,

{
  authorize(username: String!, password: String!) { #returns a User object
    token
    id
    groups {
      id
      name
    }
  }
}

@nateiler
Copy link

@timkelty Correct. A token that is signed only, doesn't have an encrypted body. The signature is technically hashed, not encrypted, using the algo identified in the header of the token. Since you're issuing it to yourself, this doesn't really matter much.

The signature will probably be handled via the JWT library. You'll need to provide a secret. Craft/Yii have a Security component which may help with this.

@markhuot I would say that CraftQL doesn't need to support JWT authorization natively. There are numerous ways that one may want to authorize with your API. My assumption is, all incoming API requests run through a single controller; if that's the case you could implement a behavior event (similar to https://github.com/craftcms/cms/blob/c0532b799d48e45b614f85466cc85db11bd54891/src/base/Component.php#L33) which would give devs the ability to get creative.

@markhuot
Copy link
Owner

That makes sense @nateiler, I may take that approach in the short term.

I would like to allow CraftQL to handle authorization at some point so you could (in theory) spin up a SPA with Craft and CraftQL without writing any PHP code.

@nateiler
Copy link

That might be more of a long term approach. You (or another developer) could write a 'JWT Authorization for CraftQL' plugin which registers itself with that behavior to provide the authorization.

Still click administration, for some end users; but super flexible for edge cases. Similar idea to registering an OAuth provider, payment gateway, etc.

One could also handle access control via controller behaviors as well.

@davorpeic
Copy link

I'm open to beta test this.. :)

@markhuot
Copy link
Owner

markhuot commented Jun 1, 2018

It's happening (but not even close to done yet…).

#103

giphy

@markhuot
Copy link
Owner

I have a very early pass of this over on the dev-user-tokens branch. If you have the time to pull it I'd be open to any critiques. The basic gist is,

First, ask for a token,

{
  authorize(username:"foobar" password:"foobar") {
    token
    user {
      ...userFields
    }
  }
}

The token field will return the JWT token. Use that in your Authorization: Bearer {token} header and you'll be authenticated as the user who asked for the token.

In order to implement this I had to add CraftQL permissions to the user permission system. That means you'll need to check your user and for anyone other than an admin add the correct CraftQL permissions. That should look like this,

screen shot 2018-06-12 at 1 41 30 pm

Right now the user tokens do not expire. I'd like to offer that as a setting, but haven't gotten to that yet.

Please let me know if any of this seems usable for your use cases or if there's something missing.

@markhuot
Copy link
Owner

This is ready to merge. I'm going to do a bit more testing on it, but it'll probably be in master next week some time.

@timkelty
Copy link
Contributor Author

I'll try and take a look this/next week too! Excited!

@markhuot
Copy link
Owner

Has anyone had a chance to test this out yet?

@timkelty
Copy link
Contributor Author

@markhuot tragically, no. I SWEAR I'm getting to it early this week though 😀

@markhuot
Copy link
Owner

No worries! I just wanted to make sure I wasn't holding anyone up. I'm using this on dev-user-tokens for some upcoming work so it'll merge at some point either way.

@jan-thoma
Copy link

Regarding the expiry of tokens. I was working on an own solution but maybe switch to this project if JWT Tokens are fully implemented. Form my experience it could work something like this (i'currently using firebase/php-jwt):

  • Add an expiry field to the JWT token.
  • On every request decode the token it will fail if it's expired, abort with a 403
  • If the token is valid issue a new one and send it via the auth header

@markhuot
Copy link
Owner

@jan-thoma, this is implemented almost as you describe. Each JWT token has an exp field that contains the expiration timestamp. The duration is set via a config/craftql.php via userTokenDuration.

A request does not automatically increase that expiration… although I like that idea. Currently you would need to do the following:

query (username: String!, password: String!) {
  authorize(username:$username, password:$password) {
    token
  }
}

That would get you the initial token. Then to refresh it you'd need to,

query(token: String!) {
  token: refresh(token:$token)
}

That would get you a new token with an updated exp.

What I like about this is you don't have to keep swapping out tokens on every request. However, I get the feeling this isn't the way to do JWT. Do you have any examples of where JWT is implemented and uses a constantly updating exp?

@jan-thoma
Copy link

This here was my approach, the code is heavy alpha stage but it might helps:

<?php
/**
 * Craft3 jwt plugin for Craft CMS 3.x
 *
 * Generates and validates JWT Tokens
 *
 * @link      https://t-k-f.ch
 * @copyright Copyright (c) 2018 [email protected]
 */

namespace tkf\craft3jwt\services;

use tkf\craft3jwt\Craft3Jwt;
use craft\helpers\DateTimeHelper;

use Craft;
use craft\base\Component;
use yii\web\HttpException;
use \Firebase\JWT\JWT as PhpJwt;

/**
 * @author    [email protected]
 * @package   Craft3Jwt
 * @since     1.0.0
 */
class Jwt extends Component
{
    // Private Properties
    // =========================================================================

    /**
     * @var boolean
     */
    private $authorized = false;

    /**
     * @var string
     */
    private $message = '';

    /**
     * @var number
     */
    private $status = 200;

    /**
     * @var object
     */
    private $token = null;

    /**
     * @var string
     */
    private $loginName = false;

    /**
     * @var string
     */
    private $password = false;

    /**
     * @var object
     */
    private $user = null;

    // Public Methods
    // =========================================================================

    /*
     * @return mixed
     */
    public function requestJwtToken()
    {
        $this->getOptionsRequest();

        try {
            $this->loginName = Craft::$app->request->getRequiredBodyParam('loginName');
            $this->password = Craft::$app->request->getRequiredBodyParam('password');
        }
        catch (\Exception $error)
        {
            return $this->errorToJson($error);
        }

        $this->validateUser();

        if (!$this->authorized)
        {
            return $this->errorToJson(new HttpException($this->status, $this->message));
        }

        return [
            'data' =>
            [
                'success' => true
            ]
        ];
    }

    /*
     * @return mixed
     */
    public function validateJwtToken()
    {
        $this->getOptionsRequest();

        try {
            $authorization = \Craft::$app->request->headers->get('authorization');
            $jwt = explode(' ', $authorization)[1];
        }
        catch (\Exception $e)
        {
            $this->abortRequest(400, 'authorization header missing or malformed');
        }

        try {
            $this->token = phpJwt::decode($jwt, \Craft::$app->config->general->securityKey, array('HS256'));
        }
        catch (\Exception $e)
        {
            $this->abortRequest(403, 'token invalid');
        }

        $this->validateUser();

        if (!$this->authorized)
        {
            $this->abortRequest($this->status, $this->message);
        }

        return $this->user;
    }

    /*
     * @return mixed
     */

    // Private Methods
    // =========================================================================

    /*
     * @return mixed
     */
    private function abortRequest ($status, $message)
    {
        throw new HttpException($status, $message);
    }

    /*
     * @return mixed
     */
    private function createJwtToken ()
    {
        $timestamp = DateTimeHelper::currentTimeStamp();
        $key = \Craft::$app->config->general->securityKey;
        $token = array(
            'iss' => "https://example.com",
            'aud' => "https://sub.example.com",
            'iat' => $timestamp,
            'exp' => $timestamp + $this->getSettings()['jwtExpiration'],
            'usr' => $this->user->uid
        );

        $jwt = phpJwt::encode($token, $key);

        \Craft::$app->response->headers->set('Authorization: Bearer', $jwt);
        \Craft::$app->response->headers->set('Access-Control-Expose-Headers', 'Authorization');
    }

    /*
     * @return mixed
     */
    private function errorToJson ($error)
    {
        \Craft::$app->response->setStatusCode($error->statusCode);

        return [
            'error' =>
            [
                'code' =>  $error instanceof HttpException ? $error->statusCode : $error->getCode(),
                'message' => $error->getMessage()
            ]
        ];
    }

    /*
     * @return mixed
     */
    private function getOptionsRequest ()
    {
        if(\Craft::$app->request->getMethod() === 'OPTIONS')
        {
            exit($this->setOptionsHeaders());
        }
    }

    /*
     * @return mixed
     */
    private function getSettings()
    {
        return Craft3jwt::$plugin->getSettings();
    }

    /*
     * @return mixed
     */
    private function setMessage ($authorized, $message)
    {
        $this->authorized = $authorized;
        $this->status = ($authorized) ? 200 : 403;
        $this->message = $message;
    }

    /*
     * @return mixed
     */
    private function setOptionsHeaders ()
    {
        header('Access-Control-Allow-Origin: *');
        header('Access-Control-Allow-Headers: Authorization');
        header('Access-Control-Allow-Methods: GET, POST');
    }

    /*
     * @return mixed
     */
    private function validateUser ()
    {
        if ($this->loginName)
        {
            $this->user =  Craft::$app->users->getUserByUsernameOrEmail($this->loginName);
        }

        if ($this->token)
        {
            $this->user =  \Craft::$app->users->getUserByUid($this->token->usr);
        }

        if (!$this->user)
        {
            $this->setMessage(false, 'invalid username or password');

            return;
        }

        if ($this->user->locked)
        {
            $this->setMessage(false, 'user locked for ' . DateTimeHelper::humanDurationFromInterval($this->user->remainingCooldownTime));

            return;
        }

        if ($this->user->suspended)
        {
            $this->setMessage(false, 'user suspended');

            return;
        }

        if ($this->password)
        {
            $authorized = $this->user->authenticate($this->password);

            $this->setMessage($authorized, ($authorized) ? 'success' : 'wrong username or password');

            if ($this->authorized)
            {
                $this->createJwtToken();
            }

            return;
        }

        if ($this->token && $this->user)
        {
            $this->authorized = true;
            $this->createJwtToken();

            return;
        }
    }
}

@markhuot
Copy link
Owner

Yup, seems similar to https://github.com/markhuot/craftql/blob/user-tokens/src/Types/Query.php#L362-L373.

Also with Firebase\JWT\JWT it has built in checking for expiration, which is great.

I'll look in to the rolling expiration times since it shouldn't affect backwards compatibility and removes the need to keep making a refresh query.

@jan-thoma
Copy link

It would be great then to have the option to create an application wide key. With the same scope options as an user has which not expires, to access everything that is considered public.

@markhuot
Copy link
Owner

This branch doesn't forego the existing Token system that is non-user-based. So, you should still be able to create a token and manage the permissions down to what you need. That token (as is currently the case) will never expire.

In short there will be two token types moving forward:

  • user tokens: tied to user permissions and expire
  • app tokens: not tied to a user and never expire

@markhuot
Copy link
Owner

I've added in support for rolling JWT tokens. Basically the initial request would still be the same,

query (username: String!, password: String!) {
  authorize(username:$username, password:$password) {
    token
  }
}

That'll get you back a token string that you can store in your app. Send that back via the Authorization header, like so:

Authorization: Bearer {$token}

The change, is that when CraftQL responds it'll send an Authorization header back in the response with an updated token. The exact response will look like this,

HTTP/1.1 200 OK
Date: Wed, 18 Jul 2018 21:57:24 GMT
Server: Apache
X-Powered-By: Craft Commerce,Craft CMS
Authorization: TOKEN eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjQwIiwiZXhwIjoxNTMxOTY1NDQ0fQ.hvZE7Qz5jej6HTuhVrIFvj2v3W7epRW2nCVanvPw9OY
Allow: POST, GET
Connection: close
Transfer-Encoding: chunked
Content-Type: application/json; charset=UTF-8

{"data":{"entries":[{"id":41}]}}

I did some digging and don't really see a standard header name for CraftQL to respond with so Authorization seemed like the best fit. Has anyone used anything better? X-Auth-Token came up quite a few times too…

@jan-thoma
Copy link

Following to RFC 6750 which describes 0Auth

When sending the access token in the "Authorization" request header
field defined by HTTP/1.1 [RFC2617], the client uses the "Bearer"
authentication scheme to transmit the access token.

For example:

     GET /resource HTTP/1.1
     Host: server.example.com
     Authorization: Bearer mF_9.B5f-4.1JqM

So using the "Authorization: Bearer" in the request and the response should follow the specs in the closest manner.

markhuot added a commit that referenced this issue Jul 19, 2018
@markhuot
Copy link
Owner

Thanks @jan-thoma. I updated this to use the Authorization: Bearer {token} format to better align with the spec.

However, I'm not seeing anything in RFC 6750 that indicates this should be the case for responses. The text you copied seems to be discussing the request instead. Nonetheless, it seems okay to sync the two up.

@timkelty
Copy link
Contributor Author

timkelty commented Aug 13, 2018

@markhuot finally digging into this, and it seems just what I was hoping for! 👏

To clarify:

If you're sending all your requests with your updated token, you shouldn't need to manually call refresh(token:$token), correct?


For my uses, for this to be useful, I need a bit more info in the JWT body, namely which member groups the user is in.

Obviously different applications will need different jwt body specs…is it possible to expose this via event hook, so it can be customized?

@jan-thoma
Copy link

once you have the token you just can call your userdata via the api. i think you shouldn't expose as less as possible userdata in the token because anybody can decode the payload.

@markhuot
Copy link
Owner

@timkelty, I'm worried about using the JWT body for this since GraphQL actually supports this sort of multidimensional retrieval on any request. For example, if I added a me field that would return the user accessing data via token, you could do this,

{
  entries: entries(limit: 5) {
    id
    title
    url
  }
  user: me {
    id
    groups {
      id
      name
    }
  }
}

Like you say, the benefit here is that you have more control over the data you're requesting and it's not as public.

What do you think?

@timkelty
Copy link
Contributor Author

@markhuot yep, that seems like a good solution to me.

@timkelty
Copy link
Contributor Author

timkelty commented Aug 16, 2018

@markhuot been working on integrating this, and came across my first hurdle:

Apparently, to be able to inspect the Authorization header in the response, Access-Control-Expose-Headers needs to be set (this was news to me: https://stackoverflow.com/questions/28107459/in-the-http-cors-spec-whats-the-difference-between-allow-headers-and-expose-he)

Adding $response->headers->add('Access-Control-Expose-Headers', 'Authorization'); seemed to work: user-tokens...timkelty:user-tokens

Another option might be to not bother with responding the token in the Authorization header response, and instead require the request to query for it to get a new one? Then you don't have to worry about standardizing on a response header Authorization: bearer/X-Auth-Token – you just require the user to query for a new token.

@timkelty
Copy link
Contributor Author

Any update on this one?

I think the only remaining issues I'm having are:

@gertst
Copy link

gertst commented Oct 11, 2018

Can we have an example on how to implement this?

@chrisrowe
Copy link

+1 for this. I'm holding off on until #103 closes out

@zstrangeway
Copy link

@markhuot Hey Mark, do you have any updates on this? JWT authentication is exactly what I need and I would much rather use your product than have to roll my own API.

@u12206050
Copy link

@markhuot I have locally tested the user-tokens branch, seems to work, but lacks proper permissions.
I personally need to be able to set permissions as follows:

On Entries:

  1. Notes: Query&Create (not Update) only my own
  2. Posts: Query all (Currently this is impossible if I have the first rule applied)

Currently it only supports viewing all OR only viewing my own.

@u12206050
Copy link

I have created a PR into user-tokens to support individual entry type permissions.
#200

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

9 participants