tymondesigns / jwt-auth

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

Using JWTGuard with Laravel 5.3.x "Guide" #860

Open mtpultz opened 7 years ago

mtpultz commented 7 years ago

Okay, I've got a bit of time, and need to set up a new project with Laravel 5.3 and JWTGuard. @tymondesigns I hope you don't mind this being in your issues similar to my other "guide" (#513), and if so I can try and find a different spot, but I don't write blogs or tutorials.

This is just a post of the steps to get the JWTGuard in place for an API using Laravel 5.3.x to possibly help with starting the documentation of this feature. Anything from #513 that is the same is still included so you don't need to browse through it unless I missed something. I've set this up twice now, and I found myself that it is just easier to do a clean install, and port. So this assumes you are doing a clean install, and currently are using version v5.3.11 of Laravel .

Also, jwt-auth 1.0 hasn't been released yet so this also assumes that you are pulling in the development branch "tymon/jwt-auth": "dev-develop", using composer.

Login Using JWTGuard with Laravel 5.3.x

1) config/app.php - add Tymon\JWTAuth\Providers\LaravelServiceProvider::class to providers 2) In your terminal publish the config file: php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider", and add it to your list service providers in app.php 3) In your terminal generate the secret: php artisan jwt:secret provided you have JWT_SECRET in your .env file 4) config/auth.php - set the default guard to api, and change the api driver to jwt

'defaults' => [
    'guard' => 'api',
    'passwords' => 'users',
],
'guards' => [
    ...

    'api' => [
        'driver' => 'jwt',
        'provider' => 'users',
    ],
],

5) /routes/api.php - add a few basic authentication routes

Route::post('login', 'Auth\LoginController@login');

Route::group([
    'prefix' => 'restricted',
    'middleware' => 'auth:api',
], function () {

    // Authentication Routes...
    Route::get('logout', 'Auth\LoginController@logout');

    Route::get('/test', function () {
        return 'authenticated';
    });
});

6) /app/http/Controller/auth/LoginController.php

Authentication appears to almost work out of the box using the JWTGuard. To maintain the existing throttling I copied the AuthenticateUsers::login into LoginController and removed the second parameter of the call to attempt from $request->has('remember'); to let it use the default in JWTGuard::attempt, and stored the $token for use, and passed it along to LoginController::sendLoginResponse

public function login(Request $request)
{
    ...

    if ($token = $this->guard()->attempt($credentials)) {
        return $this->sendLoginResponse($request, $token);
    }

    ...
}

7) AuthenticatedUsers::sendLoginResponse needs to know about the $token so I pulled it up to the LoginController and removed $request->session()->regenerate(); since JWT is stateless.

protected function sendLoginResponse(Request $request, $throttles, string $token)
{
    $this->clearLoginAttempts($request);

    return $this->authenticated($request, $this->guard()->user(), $token);
}

8) AuthenticateUsers::sendLoginResponse checks for a method authenticated, which I added to the LoginController to respond when authentication is successful

protected function authenticated(Request $request, $user, string $token)
{
    return response()->json([
        'token' => $token,
    ]);
}

9) AuthenticatesUsers::sendFailedLoginResponse can be pulled up to LoginController to respond with JSON instead of redirecting failed authentication attempts

protected function sendFailedLoginResponse(Request $request)
{
    return response()->json([
        'message' => Lang::get('auth.failed'),
    ], 401);
}

10) User model needs to implement Tymon\JWTAuth\Contracts\JWTSubject (see #260, and JWTSubject)

...

use Tymon\JWTAuth\Contracts\JWTSubject as AuthenticatableUserContract;

use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;

class User extends Model implements
    AuthenticatableContract,
    AuthorizableContract,
    CanResetPasswordContract,
    AuthenticatableUserContract
{
    use Authenticatable, Authorizable, CanResetPassword, Notifiable;

    ...

    /**
     * @return mixed
     */
    public function getJWTIdentifier()
    {
        return $this->getKey();  // Eloquent model method
    }

    /**
     * @return array
     */
    public function getJWTCustomClaims()
    {
        return [
             'user' => [ 
                'id' => $this->id,
                ... 
             ]
        ];
    }
}

11) Test out whether authentication works by hitting the /login route with Postman using a set of valid credentials, which should return a token.

12) In order to test this setup hit the authenticated route /test without setting the token in the header, which should respond with an error message, and then hit /test again after setting the header to key: Authorization, value: Bearer TOKEN_STRING, which should respond with the string authenticated.

Registration Using JWTGuard with Laravel 5.3.x

  1. The RegistersUsers trait applied to the RegisterController contains RegistersUsers::register, which needs to be pulled up to the RegisterController and updated to return a response instead of a redirect.
public function register(Request $request)
{
    ...

    return response()->json([
        'message' => trans(...),
    ]);
}

Logout Using JWTGuard with Laravel 5.3.x

To logout invoke JWTGuard's logout method, which invalidates the token, blacklists the token (assuming you kept the config defaults), resets the user, and unsets the token.

public function logout()
{
   $this->guard()->logout(); // pass true to blacklist forever

    // ...
}
spawn-guy commented 7 years ago

Thanks for the Tutorial. Right on time for me.

remark: if you don't set the 'auth.defaults.guard' to 'api' from 'web' in the auth.php - you're gonna have some true time :)

mtpultz commented 7 years ago

@spawn-guy did you remove the second parameter from attempt in login?

if($token = $this->guard()->attempt($credentials)) {
...
spawn-guy commented 7 years ago

@mtpultz discard that.. i forgot to set the 'auth.defaults.guard' to 'api' from 'web'. my bad.

even though i don't really know why it is needed.

spawn-guy commented 7 years ago

may i add my 5 cents, @mtpultz ? a quick-fix to make the response of the login endpoint to be "resembling" OAuth2 flows - edit response() lines to

/**
 * The user has been authenticated.
 *
 * @param  \Illuminate\Http\Request $request
 * @param  mixed $user
 * @return mixed
 */
protected function authenticated(Request $request, $user, $token)
{
    return response()->json([
        'access_token' => $token,
        'token_type' => 'bearer',
        'expires_in' => \Config::get('jwt.ttl') * 60 // config value is in minutes
    ]);
}

parameter names in response are specified in the RFC

daeXecutoR commented 7 years ago

@spawn-guy maybe I don't get your point but JWT has nothing to do with oauth2 as explained here:

http://www.seedbox.com/en/blog/2015/06/05/oauth-2-vs-json-web-tokens-comment-securiser-un-api/

spawn-guy commented 7 years ago

@daeXecutoR if you use Bearer keyword in the Authorisation header - then how do you tell an api-client to prepend it to the plain token value that you got from the authentication endpoint? (/api/login in this case). and when you are making this first step towards OAuth2 flows with Bearer why not make a second step? and make your /api/login endpoint output the 2 other fields that are in the OAuth2 RFC.

then why not make a step3 .. where you expose your /api/login endpoint as a /oauth/token endpoint?

and to finish the whole picture - check for "grant_type":"password" on the login endpoint (along with username and password). and that will make you 100% compatible with OAuth2 flows.

benefits are:

spawn-guy commented 7 years ago

OAuth2 is not about JWT vs RandomizedTokens. It's about flows of the tokens themselves(doesn't matter the tech you made them with) and some standardization.

JWT is useful when you work with multitude of different services at the same time. That you(as a UserDB owner) grant permission to be used by someone else. and you don't actually need to have the whole userObject everywhere and/or read the full User from Original User database.

daeXecutoR commented 7 years ago

@spawn-guy In my case I'm using angularjs as Front-End for my API and I've now what to add to the header for the restricted ressources ( so I'm okay with just getting a "token: 234....." string)

If you take it further you would also need to setup refresh tokens because your target is to simulate oauth2 password grant except your not using any oauth2-plugins out there for your front-end.

I mean as long as you build everything by yourself you can go down the road as far as you like.

The new 5.3 feature is called passport and not passenger.

spawn-guy commented 7 years ago

@daeXecutoR refresh_token's are totally optional ;)

daeXecutoR commented 7 years ago

@spawn-guy How do you handle your jwt tokens in terms of lifetime? I'm not yet sure which approach I should go for. I was thinking about using short lifed tokens like 60 minutes and create an wachter service in my front-end which requests an extension like 5 minutes prior to the expiration.

spawn-guy commented 7 years ago

@daeXecutoR most of ppl give access_tokens for an Hour. refresh_tokens for 2 weeks. but if you don't have refresh_tokens, each time access_token expires - you need to ask for user's password(which you must NOT store in sessions).

there is an option to refresh the access_tokens.

oh well.. if we are trying to mimic OAuth2 - you can place the same token in both access_token and refresh_token value. but the same time provide 2 expiration values: lower value for access_token and "original" value for refresh_token. then, in the login function in LoginController, you'll need to distinguish different grant_type's: with "password" - you expect username and password; with "refresh_token" - you exchange the old valid token to a new one. but BEWARE of the JWT.. as the information can easily be decoded and read by anyone.

or.. hehe... you can generate 2 JWT tokens. one for access_token with 1hour expiration. the other one for refresh_token with a longer expiration period. and you don;t need to store any of them in the database :)

/**
 * The user has been authenticated.
 *
 * @param  \Illuminate\Http\Request $request
 * @param  mixed $user
 * @return mixed
 */
protected function authenticated(Request $request, $user, $token)
{
    return response()->json([
        'access_token' => $token,
        'token_type' => 'bearer',
        'expires_in' => (\Config::get('jwt.ttl') * 60) - 120 // client has a window of 2 minutes
        'refresh_token' => $token,
        'refresh_token_expires_in' => (\Config::get('jwt.ttl') * 60) // client has a window of 2 minutes to not to ask for a password
    ]);
}
daeXecutoR commented 7 years ago

@spawn-guy the reason I think about storing the token is that it gives me the possibility to sign them and with that improve security. (storing hash/verify and prevent manipulation)

Yet I'm looking for a complete example of laravel 5.3 + jwt + angularjs that covers all of the challenges we discused here ;-)

But thanks for your valuable inputs. I'm going over a new dev environment and try to achive my goals. :-)

daeXecutoR commented 7 years ago

I'm stuck at step 6. There is no /app/LoginController.php file and I even don't know where you edit or what to do with the other steps. Can someone help me out on that?

mtpultz commented 7 years ago

@daeXecutoR that is a typo the LoginController is in /app/http/controllers/auth

daeXecutoR commented 7 years ago

@mtpultz Thanks so much for your support. I got the file, but it is quit empty. Can you show me how the file should look at the end, and where you copied the code from? (I'm pretty new to laravel so sorry for that)

daeXecutoR commented 7 years ago

@mtpultz I guess I found the source file. May you want to add it for others:

\vendor\laravel\framework\src\Illuminate\Foundation\Auth\AuthenticatesUsers.php

daeXecutoR commented 7 years ago

@mtpultz Where do you put the changes in step 10? I don't think I do change the file here:

vendor\laravel\framework\src\Illuminate\Foundation\Auth\User.php

I guess I have to create one in my app folder and apply the changes there, right? But where exactly?

daeXecutoR commented 7 years ago

@mtpultz In Step 5 you have to remove the API-group-prefix. Because your route is in the route/api.php it's allready added by Laravel. Your route is /api/api right now.

daeXecutoR commented 7 years ago

I can't get this to work. If I send username&password credentials I just got a white page. If I try to hit api/test I get "NotFoundHttpException in RouteCollection.php line 161:"

Think I will go with the oauth2 package instead.

mtpultz commented 7 years ago

@daeXecutoR it seems like you're having more issues with OOP and knowing how Laravel works then how to use this package.

  1. From your questions you should read up on Laravel Guards and Routes (you're right it was /api/api since I copied it form the v5.2 guide, and didn't update it)
  2. For step 10 you should read up on models and where they are stored. The User model should already exist after Laravel has installed look in /app.
  3. With regards to AuthenticatesUsers that is a PHP trait applied to the LoginController, which you can override in the LoginController as traits are a way to perform multiple "inheritance". So you can just copy the method into LoginController and make your changes. I'd suggest reading up on PHP a bit too since a lot of your confusion is also related to understanding PHP OOP. If you see a reference in the guide saying pull up a method then it is a method that is being inherited and needs to be overridden.
daeXecutoR commented 7 years ago

@mtpultz You're right, first time using the underlaying Laravel framework and don't see through it yet. However I got a working solution following this tutorial:

https://scotch.io/tutorials/role-based-authentication-in-laravel-with-jwt

Thank you very much for your support @mtpultz and @spawn-guy

mtpultz commented 7 years ago

@daeXecutoR nice glad you found a solution that works for you. Only thing I'd point out is your using jwt-auth (version prior to Laravel Guards) instead of the jwt-guard that integrates into Laravel's Guard API introduced in Laravel 5.2, which is a much more robust API that doesn't require a bunch of setup since it integrate thoroughly with Laravel and lets it do all the lifting. Maybe not now since you're having to learn multiple things at the moment, but I'd suggest not using jwt-auth and use jwt-guards in the long run you'll be better off.

francisrod01 commented 7 years ago

console error:

 [Symfony\Component\Debug\Exception\FatalThrowableError]
  Class 'Tymon\JWTAuth\Providers\LaravelServiceProvider' not found

my composer.json

    "require": {
        "php": ">=5.6.4",
        "laravel/framework": "5.3.*",
        "tymon/jwt-auth": "dev-develop"
    },
mtpultz commented 7 years ago

@devtosystems add the service provider to app.php. Service providers aren't hooked up automatically so each time you pull them in they need to be added to the list of providers. I've updated the steps above to explicitly say it needs to be added to your list of providers.

francisrod01 commented 7 years ago

@mtpultz the versions run is 0.5.* like instalation wiki informs.

mtpultz commented 7 years ago

@devtosystems if you're using jwt-guard you have to use the development branch (or any of the 1.x-alpha branches that also contain the JWTGuard class).

NOTE: 0.5.x only contains the older version this package (JWTAuth class) that existed prior to Laravel v5.2. The wiki is not up to date, and should not be consulted for any version of jwt-auth outside of version v0.5.x or less. The initial version of this "guide" for v5.2 and now v5.3 was due to a lack of documentation on how to implement JWTGuard for the development/alpha branches, and to help provide documentation when the wiki is updated since you can't post PRs to the wiki, otherwise I'd have provided these updates there so they were more readily found. So for now anytime someone has an issue I incrementally update the steps above.

francisrod01 commented 7 years ago

@mtpultz I don't like the jwt-guard use doc middleware. For me it's confuse. =/ I not use jwt-guard more.

Please, answer me, jwt-auth branch dev-develop not use aliases in config/app.php?

mtpultz commented 7 years ago

Sorry @devtosystems this guide is specific to using JWTGuard since it is so much easier (after you understand how it works), and uses Laravel's built in auth middleware, as well as any you can create. I don't know anything about the JWTAuth class of this package.

francisrod01 commented 7 years ago

@mtpultz I have a problem with post for api route in console. Look:

Whoops, looks like something went wrong.

ReflectionException in /vendor/laravel/framework/src/Illuminate/Container/Container.php line 749" 
Class Tymon\JWTAuth\Providers\JWT\NamshiAdapter does not exist<

My test data (without database yet) is:

curl -X POST -H 'Content-Type: application/json' -d '{"email":"francis@tosystems.net","password":"123456"}' http://projects.dev/mylaravelapi/public/api/login
superstarmcawesome commented 7 years ago

@mtpultz can you provide a github sample repo?

s00d commented 7 years ago

@devtosystems remove jwt.php and run php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"

francisrod01 commented 7 years ago

Now it is right because the message contains database erros.

<h1>Whoops, looks like something went wrong.</h1>
<h2 class="block_exception clear_fix">
     <span class="exception_counter">1/1</span>
     <span class="exception_title"><abbr title="PDOException">PDOException</abbr> in <a title="/home/vagrant/mylaravelapi/vendor/laravel/framework/src/Illuminate/Database/Connectors/Connector.php line 119" ondblclick="var f=this.innerHTML;this.innerHTML=this.title;this.title=f;">Connector.php line 119</a>:</span>
     <span class="exception_message">SQLSTATE[HY000] [1045] Access denied for user &#039;homestead&#039;@&#039;localhost&#039; (using password: YES)</span>
</h2>

Now, how do I transcribe plain text messages to the api?

mtpultz commented 7 years ago

@devtosystem you should be asking questions outside of setting up your application using JWTGuard on a forum like stackoverflow. This is just a guide to get the basics running using the JWTGuard. The hope was that at the end of setting it up people would understand how it works not just copy the code. If these steps don't work to get a basic application running using JWTGuard then by all means drop a question.

BasConijn commented 7 years ago

What is the best way to generate an token after registering?

Now i have added something like this to the 'register' function.

$credentials = [
    'email' => $request['email'],
    'password' => $request['password'],
];

$token = $this->guard()->attempt($credentials);

The LoginController uses the "credentials" function. It might be better to reuse this code though.

francisrod01 commented 7 years ago

@BasConijn I did the same. 👍

mattmcdonald-uk commented 7 years ago

You probably also need to pass true to the guard's logout method.

francisrod01 commented 7 years ago

@mattmcdonald-uk passing "true" the token will be updated but the user's session is lost?

mattmcdonald-uk commented 7 years ago

@devtosystems Sorry - that commit had nothing to do with the behaviour I was seeing! But, unless I pass true to logout (setting forceForever to true when invalidate is called), the logged out user's token could still be used.

This may be a side-effect of my allowing a small grace period on tokens for multiple requests, but it seems more secure to ensure the token is entirely blacklisted when the user logs out.

BasConijn commented 7 years ago

What is the difference between "api.auth" and "auth:api"? The "auth:api" does not work because it generates an 500 error with "Unauthenticated".

mtpultz commented 7 years ago

@BasConijn I've never used api.auth in fact I don't even know where to find it, but auth:api is middleware being passed a parameter, which just means the auth middleware is being passed the api guard.

The idea behind $guards is to provide a means to authenticate against different drivers that you assign associated providers. Following the steps above you're setting the api guard to use the jwt driver, and by adding auth:api on routes you're protecting your routes from access unless authenticated, and if you're not authenticated you'll get a 500 response. So in order to access that route you need to be sending up the authentication token, which will be automatically be decoded, and authentication attempted before any controller action, and if the token is invalid, expired, or missing that action is never executed.

BasConijn commented 7 years ago

But with the "api.auth" I am getting the correct 401 or 403 http errors which makes much more sense when providing an invalid token. (It might also be the reason that I use dingo instead of the default router) Edit: api.auth is something of dingo.

mtpultz commented 7 years ago

@BasConijn I stand corrected it does return a 401 for invalid tokens. Your 500 error is unrelated to the authentication check performed. I took a valid token, and set the expiry to expire immediately, and also did another check where I just sent an invalid token, and both return a 401. I would have been surprised if Laravel would produce anything that didn't follow all the standards.

rockgabi commented 7 years ago

If you are getting the login page as response instead of JSON when the authentication failed, that may be because you are not sending the proper headers so laravel treats the request as AJAX.

That was happening to me when testing using postman, I fixed it by passing Content-Type: application/json, and Accept: application/json

mtpultz commented 7 years ago

@devtosystems you need to ask questions that have nothing to do with setting up a basic implementation of Laravel 5.3 with jwt-auth in a forum like http://stackoverflow.com. The original post clearly states:

This is just a post of the steps to get the JWTGuard in place for an API using Laravel 5.3.x to possibly help with starting the documentation

This isn't a sounding board for your development issues outside the guide. All this does is obscure any questions to help people setup the basics that are having issues specifically with the steps outlined in the original post so it can be refined making it more clear. If you can get up and running using the guide then its purpose has been served, you might understand a bit more about the package and guards based on having to make the changes yourself or you might not, but at least the basics work and you can learn as your application grows based on need and hopefully not be blocked.

fthues commented 7 years ago

Has anyone gotten this to work in Lumen 5.3 without Eloquent?

daviestar commented 7 years ago

Hello all, I've been following these steps and I have Authentication behaving correctly. 👍

However my logout method appears to be losing its CORS headers. My routes look like this:

Route::group(['middleware' => 'cors'], function() {
    Route::group(['namespace' => 'Auth'], function() {
        Route::post('login', 'LoginController@login');
        Route::get('logout', 'LoginController@logout')->middleware('auth:api');
        // ...
    });

    Route::group(['middleware' => 'auth:api'], function() {
        // ...
    });
});

LoginController@logout:

public function logout(Request $request)
{
    $this->guard()->logout();

    return response()->json(['message' => 'Logged out'], 200);
}

Response:

500 - No 'Access-Control-Allow-Origin' header is present...

The correct header Authorization: Bearer ${token} is passed to the logout route.

Using php artisan route:list I can see the cors middleware is indeed applied to the logout route.

The CORS functionality works as expected for all other routes.

Does anyone have any suggestions? Cheers.

faustbrian commented 7 years ago

Instead of overwriting the register method you could leave it untouched and instead override the registered method.

/**
 * The user has been registered.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  mixed  $user
 * @return mixed
 */
protected function registered(Request $request, $user)
{
    return response()->json([
        'message' => trans('auth.register'),
    ]);
}
anjosi commented 7 years ago

I managed to receive the token but I cannot hit the /test route. What would be the correct url to enter? api/test? I just always get the NotFoundHttpException.

mtpultz commented 7 years ago

Hi @anjosi can you show your test route? It isn't in the example you have above. Might be better to show all your routes since using above and the example test route it looks like it should be /test. You might need to check the RouteServiceProvider and indicate what you have in there too.

anjosi commented 7 years ago

Here's my routes/api.php file

` use Illuminate\Http\Request;
/*
--------------------------------------------------------------------------
 API Routes                   
--------------------------------------------------------------------------

 Here is where you can register API routes for your application. These
 routes are loaded by the RouteServiceProvider within a group which
 is assigned the "api" middleware group. Enjoy building your API!

 */ 
Route::post('login', 'Auth\LoginController@login');
Route::group([ 
        'prefix' => 'restricted',       
        'middleware' => 'auth:api',     
], function () {
        // Authentication Routes...     
        Route::get('logout', 'Auth\LoginController@logout'); 
        Route::get('/test', function () {
                return 'authenticated';         
        });
});
/*
Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:api');
 */ 
`

And here's the RouteServiceProvider

`<?php

namespace App\Providers;

use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;

class RouteServiceProvider extends ServiceProvider
{
    /**
     * This namespace is applied to your controller routes.
     *
     * In addition, it is set as the URL generator's root namespace.
     *
     * @var string
     */
    protected $namespace = 'App\Http\Controllers';

    /**
     * Define your route model bindings, pattern filters, etc.
     *
     * @return void
     */
    public function boot()
    {
        //

        parent::boot();
    }

    /**
     * Define the routes for the application.
     *
     * @return void
     */
    public function map()
    {
        $this->mapApiRoutes();

        $this->mapWebRoutes();

        //
    }

    /**
     * Define the "web" routes for the application.
     *
     * These routes all receive session state, CSRF protection, etc.
     *
     * @return void
     */
    protected function mapWebRoutes()
    {
        Route::group([
            'middleware' => 'web',
            'namespace' => $this->namespace,
        ], function ($router) {
            require base_path('routes/web.php');
        });
    }

    /**
     * Define the "api" routes for the application.
     *
     * These routes are typically stateless.
     *
     * @return void
     */
    protected function mapApiRoutes()
    {
        Route::group([
            'middleware' => 'api',
            'namespace' => $this->namespace,
            'prefix' => 'api',
        ], function ($router) {
            require base_path('routes/api.php');
        });
    }
}
`