markhuot / craftql

A drop-in GraphQL server for Craft CMS
Other
319 stars 53 forks source link

Add JWT support #89

Open timkelty opened 6 years ago

timkelty commented 6 years ago

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:

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

@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:

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 commented 6 years ago

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 commented 6 years ago

@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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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

markhuot commented 6 years ago

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

https://github.com/markhuot/craftql/pull/103

giphy

markhuot commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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

markhuot commented 6 years ago

Has anyone had a chance to test this out yet?

timkelty commented 6 years ago

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

markhuot commented 6 years ago

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 commented 6 years ago

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):

markhuot commented 6 years ago

@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 commented 6 years ago

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 jan.thoma@t-k-f.ch
 */

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    jan.thoma@t-k-f.ch
 * @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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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:

markhuot commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

@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 commented 6 years ago

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 commented 6 years ago

@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 commented 6 years ago

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

timkelty commented 6 years ago

@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: https://github.com/markhuot/craftql/compare/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 commented 5 years ago

Any update on this one?

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

gertst commented 5 years ago

Can we have an example on how to implement this?

chrisrowe commented 5 years ago

+1 for this. I'm holding off on until https://github.com/markhuot/craftql/pull/103 closes out

zstrangeway commented 5 years ago

@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 commented 5 years ago

@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 commented 5 years ago

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

200