tymondesigns / jwt-auth

🔐 JSON Web Token Authentication for Laravel & Lumen
https://jwt-auth.com
MIT License
11.3k stars 1.54k forks source link

About refresh token with more than one async requests from client. #301

Closed knvpk closed 8 years ago

knvpk commented 9 years ago

Problem: using the built in refresh token flow (i.e., one token for one request) , im not able use because my angular js app can do more than one request at a time, so i wan to make refresh middleware with the concept below?

my solution: generally we will add the token to blacklist every time it hit the server, but my solution is to delay the process by one minute (i.e., add the token to blacklist after 1 minute when it arrives to server). in this case there may be chance in a point of time one user can have two or more valid token but with less than one minute as expiry time.

Is this approach is ok or any issues in it, please give me the suggestion.

solution implementation: write a middleware to dispatch a job (which has implementation of adding the token to blacklist) with a delay of 1 minute. and then refresh the token and add new token to headers of the response, my client can capable of handling the token from respone and send it with request.

JustinLien commented 9 years ago

@pavankumarkatakam the latest have the ability to give the blacklist a time buffer: Check out the configs here. By default is is giving a 1 min 0 sec grace period.

knvpk commented 9 years ago

is it in released versions, or only in the develop branch.

tdhsmith commented 9 years ago

The grace period is only available in develop atm.

using the built in refresh token flow (i.e., one token for one request)

FYI: the built-in refresh middleware is actually not sufficient for "single-use" tokens (one token for one request). See comments here. It is designed for multiple-use tokens that are only refreshed as needed.

You are free to choose "single-use" or "multi-use". Though with multi-use tokens, you won't have to worry about this problem.

knvpk commented 9 years ago

When the develop branch is going to releasae, @tymondesigns

JustinLien commented 9 years ago

The answer is probably something like... as soon as he finishes his sandwich.

knvpk commented 9 years ago

ok, i have seen the code, is there any breaking changes ? one i found is ServiceProvider name is changed. like that?

tdhsmith commented 9 years ago

Yes, quite a few.

A lot of the method signatures are subtly different. For example, most of the JWTAuth class' functions no longer take a $token parameter. They instead assume the class-internal copy is updated (e.g. from parseToken() or setToken). A new middleware is provided for running this parsing without validity checks. The payload functions also change format a bit, especially with the claim getters and setters.

The user model integration has changed greatly and assumes less about your subjects (it's easier for them not to be "users"). The user provider has been removed and now you must implement the JWTSubject interface on the appropriate class (this also helps a lot with setups where the subject claim isn't an id/primary key). I think there are a couple other changes there with providers that I don't know about (some things with Auth integration I think?).

Smaller things: the config closures were removed, a number of classes renamed (Factory/Manager/Contracts), Token wrapping and requireToken behavior might be different, the default middleware now throws Symfony\Component\HttpKernel\Exception\BadRequestHttpException on missing tokens (was something package-specific before), blacklisting has some timeline differences, uses different storage keys, and has a new grace period option, jti claims avoid potential collisions much better, simpler Lumen support, null ttl... etc etc

I don't mean to scare anyone -- upgrading should actually be quite easy! (it was pretty painless for my work team) But there is a lot for @tymondesigns to document, which is the biggest challenge at the moment.

tdhsmith commented 9 years ago

Most of these are really just fixes or new features that people won't notice. But on a technical level, the behavior does change, so it needs to be handled carefully. ;)

For most people I think you will only need to worry about SP name change, implementing JWTSubject, and new middleware names and behaviors.

knvpk commented 8 years ago

I have just seen the code, its quite reverse implementation than expected (that i mentioned at the top), the current develop code is, after the first token when we refresh it, the old token is immediately added to blacklist but with the "valid_untill" key with grace period, but hen checking the blacklist it also checks the valid_until.

problem: with the current approach the old tokens are still valid when they are refreshed continously with in the graceperiod. i think this approach is buggy.

tdhsmith commented 8 years ago

problem: with the current approach the old tokens are still valid when they are refreshed continously with in the graceperiod. i think this approach is buggy.

The cache's add method will not update entries that already exist, so you cannot keep an an old token in its grace period by repeatedly invalidating it. It will be blacklisted after one grace_period, regardless of how often we try to invalidate/refresh it.

Or am I not understanding what your concern is?

my solution is to delay the process by one minute (i.e., add the token to blacklist after 1 minute when it arrives to server).

Generally it is a lot more complicated to implement "scheduled" tasks than to simply "emulate" them, as the package does with valid_until.

JustinLien commented 8 years ago

@tdhsmith I had to look into the dev version after your long post, but informative post. =D Are you using this for production by any chance?

The biggest changes is probably the need to update your User Model. You need to implement JWTSubject with getJWTIdentifier and getJWTCustomClaims methods.

You will also need to replace "Tymon\JWTAuth\Providers\JWTAuthServiceProvider::class" with "Tymon\JWTAuth\Providers\LaravelServiceProvider::class" (there's one for Lumen) in your config/app.php.

This should get everyone started with the dev branch. Would be nice to have this in the readme--had to dig into issue for all these information.

I do like how the the cache method is being handled in the dev--it removes the old value and replaces it with the new one whereas the current production piles them up (while invalidate them at the same time).

tdhsmith commented 8 years ago

@JustinLien No, it's not in production yet, but that has to do with our project timeline and not JWTAuth in particular.

Would be nice to have this in the readme

Which I believe is primarily why 0.6 hasn't been released yet. The maintainer is working on documentation. :wink: (I didn't give a complete summary because I didn't think I could do a good job.)

ajoslin103 commented 8 years ago

@pavankumarkatakam I had the same problem (with Laravel 4 and Angular) I implemented a queue of promises for the requests (I'm using angular-jwt and it can handle a promise being returned from the tokenGetter), then not resolving the next one until the previous one had come back and updated the jwt in local storage. That was working fine, but [irregularly] the newly jwt token would be blacklisted already when it was returned for the next request. I have only just gotten this working, with no time to figure out what's going on in jwt-auth. And since I am using L4 I'm not sure there will be any happy for me... I may have to forget about single-use jwt's...

sidjaix commented 6 years ago

I am also facing the same kind of issue with JWT token usage. Below is the scenario of it. Please guide us if you know any approach or solution for this.

On every HTTP call, we are checking whether the token is present in local storage or not.

If it's not there in local storage or expired, we are keeping the current request on hold and will make a separate HTTP get a request to get token and store it in local storage. Once we get the token the previously hold request will be continued.

But if a page is having more than one HTTP request and the token is not present or expired, all those requests are kept on hold and making a call to get token. in this scenario, every request is getting a different token which is incorrect.

We need a way to overcome this issue.

ajoslin103 commented 6 years ago

I managed this problem by maintaining a single queue for all token calls into which I put functions that would check if a token was available in local storage before processing the request and storing the token locally when retrieved.

Then I processed only one of those queued requests at once.

That way if a particular token was queued up to be requested more than once the following calls would find the token already refreshed in local storage.

sidjaix commented 6 years ago

Hi @ajoslin103, Thank you for responding, Could you share some code with me that I can refer to solve the problem.

ajoslin103 commented 6 years ago

All this code is ancient -- from early 2015 and uncommented [sorry about that]

Frontend code is Angular 1.x Reading back in this thread I see where I might have turned off single-use because of odd blacklisting of returned tokens... The backend was written twice, originally in Laravel, then re-written in AdonisJS. Since AdonisJS is Laravel inspired it was extremely easy to port. Basically just converted the Controllers and Configs and it just worked after a little fiddling. It was totally worth the effort in order to able to put a debugger on the backend !!

My overall scheme is/was to declare permissions as an integer (from min:no perms to max:god-level) and always check that the user had enough permissions to do the task. This allowed me (in the backend) to turn off the [bottlenecked] permission check and develop in god-mode, and then turn permissions back on to enforce who could modify what. The signed JWT protected the users' original permission level nicely while it left the server and came back from the unsafe side. (edited: to add the php: ensureMinimumAccess and js:withAccess functions)

Hope this POC (pile of code) helps !

Al;

This was an Angular service: authentication.js

'use strict';
(function () {
  function serviceFn($log, $q, ENV, locker, jwtHelper) {
    function responseInterceptor(action) {
      return function interceptor(response) {
        jwtResponseRecorded(response, 'resourceInterceptor for:' + action);
        return response.resource;
      };
    }
    var interceptors = {
      'get': {
        method: 'GET',
        isArray: false,
        interceptor: {
          response: responseInterceptor('get'),
        }
      },
      'save': {
        method: 'POST',
        isArray: false,
        interceptor: {
          response: responseInterceptor('save'),
        }
      },
      'query': {
        method: 'GET',
        isArray: true,
        interceptor: {
          response: responseInterceptor('query'),
        }
      },
      'remove': {
        method: 'DELETE',
        isArray: false,
        interceptor: {
          response: responseInterceptor('remove'),
        }
      },
      'delete': {
        method: 'DELETE',
        isArray: false,
        interceptor: {
          response: responseInterceptor('delete'),
        }
      },
    };
    var tokenPromises = [];
    function processTokenPromises() {
      if (tokenPromises.length > 0) {
        var pending = tokenPromises.shift();
        var gotToken = locker.get('jwt', 'no-token');
        pending.resolve(gotToken);
        $log.debug('resolved tokenPromise for:' + pending.forWho + ' with: ' + gotToken);
      }
    }
    function processQueueConditionally(_howMany) {
      return function processor() {
        if ((_howMany === 1) || (!ENV.JWT_SINGLE_USE)) {
          processTokenPromises();
        }
      };
    }
    function jwtResponseRecorded(response, callerName) {
      var recorded = false, newToken = '';
      if (response[ENV.jwtTokenNameFromLogin]) { // two places to look
        $log.debug('locker.put from resp for:' + callerName);
        newToken = response[ENV.jwtTokenNameFromLogin];
      }
      if (response.headers && response.headers(ENV.receiveJWTviaName)) { // two places to look
        $log.debug('locker.put from head for:' + callerName);
        newToken = response.headers(ENV.receiveJWTviaName);
      }
      if (newToken) {
        $log.debug('decoded:' + JSON.stringify(jwtHelper.decodeToken(newToken)));
        locker.put('jwt', newToken);
        processTokenPromises();
        recorded = true;
      }
      return recorded;
    }
    function jwtTokenGetter(forWho) {
      var tokenRequest = $q.defer();
      tokenRequest.forWho = forWho;
      $log.debug('promised token for: ' + forWho);
      tokenPromises.push(tokenRequest);
      var numPending = tokenPromises.length;
      setTimeout(processQueueConditionally(numPending), 1);
      return tokenRequest.promise;
    }
    function getMyName() {
      var decodedToken = jwtHelper.decodeToken(locker.get('jwt', 'no-token'));
      // added for new AdonisJS backend which encapsulates customClaims in .payload.data
      return decodedToken.payload ? decodedToken.payload.data.username : decodedToken.username;
    }
    function getMyEmail() {
      var decodedToken = jwtHelper.decodeToken(locker.get('jwt', 'no-token'));
      // added for new AdonisJS backend which encapsulates customClaims in .payload.data
      return decodedToken.payload ? decodedToken.payload.data.email : decodedToken.email;
    }
    function getMyAccess() {
      var decodedToken = jwtHelper.decodeToken(locker.get('jwt', 'no-token'));
      // added for new AdonisJS backend which encapsulates customClaims in .payload.data
      return decodedToken.payload ? decodedToken.payload.data.access : decodedToken.access;
    }
    function doLogout() {
      locker.put('jwt', 'no-token');
    }
    function hasAccess(needed, promise) {
      try {
        if (getMyAccess() >= needed) {
          return true;
        }
      } catch (err) {
        $log.debug('err testing token', err.toString())
      }
      if (promise) {
        promise.reject('You will need to login again with elevated privs to perform this action.');
      } else {
        $log.warn('You will need to login again with elevated privs to perform this action.');
      }
      return false;
    }
    return {
      responseInterceptors: {},
      jwtResponseRecorded: jwtResponseRecorded,
      jwtTokenGetter: jwtTokenGetter,
      getMyAccess: getMyAccess,
      getMyEmail: getMyEmail,
      getMyName: getMyName,
      doLogout: doLogout,
      hasAccess: hasAccess
    };
  }
  angular.module('build1App')
    .service('authentication', serviceFn);
})();

and it was attached to the app like this:

    function angularJwt($httpProvider, jwtInterceptorProvider, $provide, ENV) {
        if (ENV.sendJWTviaUrl) {
            // "if you are behind an uncooperative apache then you can send the jwt as an url param -- angular-jwt"
            jwtInterceptorProvider.urlParam = ENV.sendJWTviaName; // (some configs of apache will strip a jwt header)
        }
        // get the jwt we have stored, we need to give this to only one requestor at a time, as we could be getting a new one with each request
        jwtInterceptorProvider.tokenGetter = ['config', 'authentication', 'ENV', function(config, authentication, ENV) {
            // only if requesting a protected resource from the backend
            if (new RegExp(ENV.backend).test(config.url)) {
                var jwtToken = authentication.jwtTokenGetter(config.url);
                return jwtToken; // then return a token promise
            }
        }];
        $httpProvider.interceptors.push('jwtInterceptor');
        $provide.factory('responseInterceptor', ['authentication', function(authentication) {
            return {
                response: function(resp) {
                    var wasRecorded = authentication.jwtResponseRecorded(resp, 'app responseInterceptor');
                    return resp;
                }
            };
        }]);
        $httpProvider.interceptors.push('responseInterceptor');
    }
    function setupLogout($rootScope, $state, authentication) {
        $rootScope.doLogout = function() {
            authentication.doLogout();
            $state.go('about');
        };
    }

I would get the data from the server like this:

        vm.dataPromise = function() {
            return $resource(ENV.backend+'/section',{}, authentication.responseInterceptors).query(postProcess,errorHandling.handleServerError).$promise;
        };
        function postProcess(response) {
            [... use response data here ...]
        }

this was the backend Authenticator (Laravel)

<?php
use Tymon\JWTAuth\Exceptions\JWTException;
class AuthenticateController extends Controller
{

    public function resetForm($token)
    {
        return View::make('reset_password') #Config::get('confide::reset_password_form'))
                ->with('token', $token);
    }
    public function forgot()
    {
        if (Confide::forgotPassword(Input::get('email'))) {
            $notice_msg = Lang::get('confide::confide.alerts.password_forgot');
            return [ 'success' => true, 'message' => $notice_msg];
        } else {
            $error_msg = Lang::get('confide::confide.alerts.wrong_password_forgot');
            return [ 'success' => false, 'error' => $error_msg];
        }
    }
    public function reset()
    {
        $repo = App::make('UserRepository');
        $input = array(
            'token'                 =>Input::get('token'),
            'password'              =>Input::get('password'),
            'password_confirmation' =>Input::get('password_confirmation'),
        );
        // By passing an array with the token, password and confirmation
        if ($repo->resetPassword($input)) {
            $notice_msg = Lang::get('confide::confide.alerts.password_reset');
            return Redirect::away($_ENV['PUBLIC_URL'].'/reset-success')
                ->with('notice', $notice_msg);
        } else {
            $error_msg = Lang::get('confide::confide.alerts.wrong_password_reset');
            return Redirect::action('AuthenticateController@resetForm', array('token'=>$input['token']))
                ->withInput()
                ->with('error', $error_msg);
        }
    }
    public function confirm($code)
    {
        if (Confide::confirm($code)) {
            $notice_msg = Lang::get('confide::confide.alerts.confirmation');
            return Redirect::away($_ENV['PUBLIC_URL'].'/confirm-success')
                ->with('notice', $notice_msg);
        } else {
            $error_msg = Lang::get('confide::confide.alerts.wrong_confirmation');
            return Redirect::away($_ENV['PUBLIC_URL'].'/confirm-failure')
                ->with('error', $error_msg);
        }
    }
    public function doLogin()
    {
        $repo = App::make('UserRepository');
        $input = Input::all();
        if ($repo->login($input)) {
            // return Redirect::intended('/');
            $credentials = Input::only('username', 'password');
            try {
                $who = Auth::user();
                // add the user's access level, their role, and....
                $customClaims = [ 'username' => $credentials['username'], 'email' => $who->email, 'access' => $who->access ];
                // attempt to verify the credentials and create a token for the user
                if (! $token = JWTAuth::attempt($credentials,$customClaims)) {
                    return [ 'success' => false, 'error' => 'invalid_credentials'];
                }
            } catch (JWTException $e) {
                // something went wrong whilst attempting to encode the token
                return [ 'success' => false, 'error' => 'could_not_create_token'];
            }
            // all good so return the token
            return compact('token');
        } else {
            if ($repo->isThrottled($input)) {
                $err_msg = Lang::get('confide::confide.alerts.too_many_attempts');
            } elseif ($repo->existsButNotConfirmed($input)) {
                $err_msg = Lang::get('confide::confide.alerts.not_confirmed');
            } else {
                $err_msg = Lang::get('confide::confide.alerts.wrong_credentials');
            }
            return [ "success" => false, "message" => $err_msg ];
        }
    }
    public function store()
    {
        $repo = App::make('UserRepository');
        $user = $repo->signup(Input::all());
        if ($user->id) {
            DB::table('tbl_users')
            ->where('id', $user->id)
            ->update(array(
                'userId' => uniqid('',true), 
                'creatorId' => 'New.Registrant',
                'access'=> 1
            ));
            if (Config::get('confide::signup_email')) {
                Mail::queueOn(
                    Config::get('confide::email_queue'),
                    Config::get('confide::email_account_confirmation'),
                    compact('user'),
                    function ($message) use ($user) {
                        $message
                            ->to($user->email, $user->username)
                            ->subject(Lang::get('confide::confide.email.account_confirmation.subject'));
                    }
                );
            }
            return [ "success" => true, "message" => Lang::get('confide::confide.alerts.account_created') ];
        } else {
            $error = $user->errors()->all(':message');
            return [ "success" => false, "message" => $error ];
        }
    }
    public function logout()
    {
        Confide::logout();
        JWTAuth::invalidate(Input::get('token'));
        return [ "success" => true, "message" => "user is logged out." ];
    }

}

this was the converted Authenticator (AdonisJS)

'use strict'
const CatLog = require('cat-log')
const log = new CatLog('AuthenticationController')
const User = use('App/Model/User')
class AuthenticationController {
  * login(request, response) {
    const username = request.input('username')
    const password = request.input('password')
    if (username && password) {
      try {
        const login = yield request.auth.attempt(username, password)
        log.debug(`user: ${username} attempted login result: ${login}`)
        if (login) {
          try {
            const user = yield User.query().where('username', username).first()
            const customClaims = {
              username: user.username,
              email: user.email,
              access: user.access
            }
            const jwt = request.auth.authenticator('jwt')
            const token = yield jwt.generate(user, customClaims)
            response.json({ token: token })
            return
          } catch (dbErr) {
            response.internalServerError('Database Misconfigured: ' + dbErr.message)
            return
          }
        }
      } catch (authErr) {
        if (/ECONNREFUSED/.test(authErr.message)) {
          log.error('Database Not Available: ' + authErr.message)
          response.serviceUnavailable()
          return
        }
        if (/Unable to find user/.test(authErr.message)) {
          log.error('Unknown User: ' + authErr.message)
          response.unauthorized('Invalid credentails')
          return
        }
        response.internalServerError('Unknown error: ' + authErr.message)
        return
      }
      log.warning('Unknown User: ' + authErr.message) // auth.attempt did not throw ?
      response.unauthorized('Invalid credentails')
      return
    }
    response.conflict('Missing parameters')
  }
  * logout(request, response) {
    const token = request.param('token')
    log.debug('logout token:', token)
    response.json({ success: true, message: 'user is logged out.', token: 'no-token' })
  }
}
module.exports = AuthenticationController

this was a storage method in Laravel

    public function store()
    {
        Log::info("public function Booklet@store()");
        $who = Auth::user();
        $now = date('r',time());
        parent::ensureMinimumAccess($who,700);
        $scriptName = Input::get('newName');
        $nameInUse = DB::table('tbl_scripts')
            ->where('scriptName',$scriptName)
            ->count();
        if ($nameInUse) {
            App::abort(409,'The Booklet was not created because there is already a Booklet with that name.');
        }
        $id = DB::table('tbl_scripts')
            ->insertGetId([
                'scriptName' => $scriptName,
                'lastModified' => $now,
                'lastUserId' => $who->userId
            ]);
        return $id;
    }

supported by the ensureMinimumAccess function

    public function ensureMinimumAccess ( $user, $needs, $msg = '' ) {
        if ($user) {
            $scold = 'Newly registered users must be granted permissions by existing users.';
            if (! $user->access) {
                App::abort(401,$scold);
            }
            $scold = 'You need elevated permissions to complete this action.';
            if ($msg) { $scold .= ' '.$msg; }
            if ($needs > $user->access) {
                Log::info("Forbidden: user access:".$user->access." is lower than needed:".$needs);
                App::abort(403,$scold);
            }
        }
    }

and it's converted AdonisJS version:

    *
    store(request, response) {
        let { who, now, why, what } = request.authUser.withAccess(700);
        if (why) { response.status(what).send(why); return; }
        const newName = request.input('newName');
        log.debug('store on newName:', newName)
        let nameInUse = yield Database.table('tbl_scripts')
            .where('scriptName', newName)
            .count();
        if (Helpful.pluckCount(nameInUse)) {
            response.conflict('The Booklet was not created because there is already a Booklet with that name.');
            return
        }
        let id = yield Database.table('tbl_scripts')
            .insert({
                'scriptName': newName,
                'lastModified': now,
                'lastUserId': who
            });
        response.ok()
    }

supported by the withAccess function

    withAccess(needs) {
        const now = new Date().toUTCString();
        const who = this.original.userId;
        let what = 200;
        let why = '';

        if (! this.original.access) {
            log.debug('Newly registered users must be granted permissions by existing users.')
            why = 'Newly registered users must be granted permissions by existing users.'
            what = 409
        }

        if (needs > this.original.access) {
            log.debug(`You need elevated permissions (> ${needs}) to complete this action.`)
            why = 'You need elevated permissions to complete this action.'
            what = 401
        }

        return { now: now, who: who, why: why, what: what }
    }