Laravel Passport Scopes

36,451

Solution 1

Or are scopes not the same as roles?

The biggest difference between the two is the context they apply to. Role-based Access Control (RBAC) governs the access control of a user when using the web application directly, while Oauth-2 scope governs the access to the API resources for an external client on behalf of a user.

How can i assign which user model has which scope(s)?

In general Oauth flow, a user (as a resource owner) is requested to authorize a client on things that it can and cannot do on his/her behalf, these are what you called scope. On successful authorization the scope being requested by the client will be assigned to the generated token not to the user per se.

Depending on which Oauth grant flow that you choose, the client should include the scope on its request. In Authorization code grant flow the scope should be included on HTTP GET query parameter when redirecting the user to authorization page, while on Password grant flow the scope must be included in HTTP POST body parameter to request a token.

How would you implement this?

This is an example with Password grant flow, with assumption that you completed the laravel/passport setup beforehand

Define scopes for both admin and user role. Be specific as you can, for example: admin can manage-order and user only read it.

// in AuthServiceProvider boot
Passport::tokensCan([
    'manage-order' => 'Manage order scope'
    'read-only-order' => 'Read only order scope'
]);

Prepare the REST controller

// in controller
namespace App\Http\Controllers;

class OrderController extends Controller
{   
    public function index(Request $request)
    {
        // allow listing all order only for token with manage order scope
    }

    public function store(Request $request)
    {
        // allow storing a newly created order in storage for token with manage order scope
    }

    public function show($id)
    {
        // allow displaying the order for token with both manage and read only scope
    }
}

Assign the route with api guard and scope

// in api.php
Route::get('/api/orders', 'OrderController@index')
    ->middleware(['auth:api', 'scopes:manage-order']);
Route::post('/api/orders', 'OrderController@store')
    ->middleware(['auth:api', 'scopes:manage-order']);
Route::get('/api/orders/{id}', 'OrderController@show')
    ->middleware(['auth:api', 'scopes:manage-order, read-only-order']);

And when issuing a token check the user role first and grant the scope based on that role. To achieve this, we need an extra controller that use AuthenticatesUsers trait to provide login endpoint.

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

class ApiLoginController extends Controller
{
    use AuthenticatesUsers;

    protected function authenticated(Request $request, $user)
    {               
        // implement your user role retrieval logic, for example retrieve from `roles` database table
        $role = $user->checkRole();

        // grant scopes based on the role that we get previously
        if ($role == 'admin') {
            $request->request->add([
                'scope' => 'manage-order' // grant manage order scope for user with admin role
            ]);
        } else {
            $request->request->add([
                'scope' => 'read-only-order' // read-only order scope for other user role
            ]);
        }

        // forward the request to the oauth token request endpoint
        $tokenRequest = Request::create(
            '/oauth/token',
            'post'
        );
        return Route::dispatch($tokenRequest);
    }
}

Add route for api login endpoint

//in api.php
Route::group('namespace' => 'Auth', function () {
    Route::post('login', 'ApiLoginController@login');
});

Instead of doing POST to /oauth/token route, POST to the api login endpoint that we provided before

// from client application
$http = new GuzzleHttp\Client;

$response = $http->post('http://your-app.com/api/login', [
    'form_params' => [
        'grant_type' => 'password',
        'client_id' => 'client-id',
        'client_secret' => 'client-secret',
        'username' => '[email protected]',
        'password' => 'my-password',
    ],
]);

return json_decode((string) $response->getBody(), true);

Upon successful authorization, an access_token and a refresh_token based on scope that we define before will be issued for the client application. Keep that somewhere and include the token to the HTTP header whenever making a request to the API.

// from client application
$response = $client->request('GET', '/api/my/index', [
    'headers' => [
        'Accept' => 'application/json',
        'Authorization' => 'Bearer '.$accessToken,
    ],
]);

The API now should return

{"error":"unauthenticated"}

whenever a token with under privilege is used to consumed restricted endpoint.

Solution 2

Implement the Raymond Lagonda response and it works very well, just to be careful with the following. You need to override some methods from AuthenticatesUsers traits in ApiLoginController:

    /**
     * Send the response after the user was authenticated.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    protected function sendLoginResponse(Request $request)
    {
        // $request->session()->regenerate(); // coment this becose api routes with passport failed here.

        $this->clearLoginAttempts($request);

        return $this->authenticated($request, $this->guard()->user())
                ?: response()->json(["status"=>"error", "message"=>"Some error for failes authenticated method"]);

    }

    /**
     * Get the failed login response instance.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\RedirectResponse
     */
    protected function sendFailedLoginResponse(Request $request)
    {
        return response()->json([
                                "status"=>"error", 
                                "message"=>"Autentication Error", 
                                "data"=>[
                                    "errors"=>[
                                        $this->username() => Lang::get('auth.failed'),
                                    ]
                                ]
                            ]);
    }

If you changed the login: username field to a custom username field eg: e_mail. You must refine the username method as in your LoginController. Also you have to redefine and edit the methods: validateLogin, attemptLogin, credentials since once the login is validated, the request is forwarded to passport and must be called username.

Solution 3

I know this is a little late, but if you're consuming a backend API in an SPA using the CreateFreshApiToken in web middleware, then you can simply add an 'admin' middleware to your app:

php artisan make:middleware Admin

Then in \App\Http\Middleware\Admin do the following:

public function handle($request, Closure $next)
{
    if (Auth::user()->role() !== 'admin') {
        return response(json_encode(['error' => 'Unauthorised']), 401)
            ->header('Content-Type', 'text/json');
    }

    return $next($request);
}

Make sure you have added the role method to \App\User to retrieve the users role.

Now all you need to do is register your middleware in app\Http\Kernel.php $routeMiddleware, like so:

protected $routeMiddleware = [
    // Other Middleware
    'admin' => \App\Http\Middleware\Admin::class,
];

And add that to your route in routes/api.php

Route::middleware(['auth:api','admin'])->get('/customers','Api\CustomersController@index');

Now if you try to access the api without permission you will receive a "401 Unauthorized" error, which you can check for and handle in your app.

Solution 4

With @RaymondLagonda solution. If you are getting a class scopes not found error, add the following middleware to the $routeMiddleware property of your app/Http/Kernel.php file:

'scopes' => \Laravel\Passport\Http\Middleware\CheckScopes::class, 
'scope' => \Laravel\Passport\Http\Middleware\CheckForAnyScope::class,`

Also, if you are getting the error Type error: Too few arguments to function, you should be able to get the $user from the request like below.

(I am using laratrust for managing roles)

public function login(Request $request)
{

    $email = $request->input('username');
    $user = User::where('email','=',$email)->first();

    if($user && $user->hasRole('admin')){
        $request->request->add([
            'scope' => 'manage-everything'
        ]);
    }else{
        return response()->json(['message' => 'Unauthorized'],403);
    }

    $tokenRequest = Request::create(
      '/oauth/token',
      'post'
    );

    return Route::dispatch($tokenRequest);

}

Solution 5

I've managed to get this into working, with @RaymondLagonda solution, for Laravel 5.5 with Sentinel, but it should, also work, without Sentinel.

The solution needs some class methods overriding (so please keep that in mind, for future updates), and adds some protection to your api routes (not exposing client_secret for example).

First step, is to modify your ApiLoginController in order to add construct function:

public function __construct(Request $request){
        $oauth_client_id = env('PASSPORT_CLIENT_ID');
        $oauth_client = OauthClients::findOrFail($oauth_client_id);

        $request->request->add([
            'email' => $request->username,
            'client_id' => $oauth_client_id,
            'client_secret' => $oauth_client->secret]);
    }

In this example, you need to define var ('PASSPORT_CLIENT_ID') in your .env and create OauthClients Model, but you can safely skip this by putting your proper test values here.

One thing to notice, is that we are setting $request->email value to username, just to stick to Oauth2 convention.

Second step is, to override, sendLoginResponse method which is causing errors like Session storage not set, we don't need sessions here, cause it is api.

protected function sendLoginResponse(Request $request)
    {
//        $request->session()->regenerate();

        $this->clearLoginAttempts($request);

        return $this->authenticated($request, $this->guard()->user())
            ?: redirect()->intended($this->redirectPath());
    }

Third step is, to modify your authenticated methods as suggested by @RaymondLagonda. You need to write your own logic here, and especially configure your scopes.

And final step (in case you are using Sentinel) is to modify AuthServiceProvider. Add

$this->app->rebinding('request', function ($app, $request) {
            $request->setUserResolver(function () use ($app) {
                 return \Auth::user();
//                return $app['sentinel']->getUser();
            });
        });

just after $this->registerPolicies(); in boot method.

After these steps you should be able, to get your api working, by providing username ('this will always be email, in this implementation'), password and grant_type='password'

At this point, you can add to middlewares scopes scopes:... or scope:... to protect your routes.

I hope, it is going to really help...

Share:
36,451
Admin
Author by

Admin

Updated on February 16, 2020

Comments

  • Admin
    Admin about 4 years

    I am a bit confused on the laravel scopes part.

    I have a user model and table.

    How can I assign a user the role of user, customer and/or admin.

    I have a SPA with vue and laravel api backend. I use https://laravel.com/docs/5.3/passport#consuming-your-api-with-javascript

        Passport::tokensCan([
            'user' => 'User',
            'customer' => 'Customer',
            'admin' => 'Admin',
        ]);
    

    How can i assign which user model has which scope(s)?

    Or are scopes not the same as roles?

    How would you implement this?

    Thanks in advance!

  • Tarek Adam
    Tarek Adam over 7 years
    This post is insanely helpful. Thank you so much. I had to do one thing differently, I'm on Laravel 5.3 and there is no "AuthenticatorService.php" anywhere. I used the boot function in App\Providers\AuthServiceProvider as per the Laravel docs.
  • Raymond Lagonda
    Raymond Lagonda over 7 years
    @TarekAdam you're right. I edited the answer, thank you.
  • Jaimil Prajapati
    Jaimil Prajapati over 7 years
    @RaymondLagonda Thanks for the answer. I am facing one issue - on Laravel 5.3, when you use "AuthenticatesUser" trait, the "login" method within the trait fails validation. It doesn't even reach "authenticated" method. Does Laravel Passport use same authentication trait?
  • Tarek Adam
    Tarek Adam over 7 years
    @JaimilPrajapati I think I had a similar issue. Normal login uses email but api login uses username. Sneak this into your ApiLoginController... $request->request->add(['username' => $request->email]); $tokenRequest = Request::create( '/oauth/token', 'post' );
  • Raymond Lagonda
    Raymond Lagonda over 7 years
    @JaimilPrajapati I think that warrant another question. But here are things to check out: make sure no guard is applied to the api login route, if you're using custom column check this out stackoverflow.com/questions/39194917/… as of now, password grant flow only take username column and lastly you can always check which line is failing in the login method in AuthenticatesUsers.php.
  • Jaimil Prajapati
    Jaimil Prajapati over 7 years
    Thanks for your help guys. I ended up re-writing Authentication mechanism to apply required logic, before adding appropriate scope. @RaymondLagonda Your idea of intercepting Login request, and adding scope is amazing!
  • David L
    David L about 6 years
    I think something is missing related to CORS feature, any ideas?
  • Wanny Miarelli
    Wanny Miarelli almost 6 years
    Why the ApiLoginController@login method is missing?
  • Anand Naik B
    Anand Naik B over 4 years
    In the controller where you attach scopes based on the user role. Are these the permissions of the role?