2

This isn't a question so much in need of an answer, but further suggestions and answers and recommendations are welcome. I want to share with the world how I resolved this issue and hope it helps others.

Laravel comes with several pre-designed authentication solutions that you can spin up with a few artisan commands. These include:

  • standard users table authentication
  • OAuth2 (via the Laravel Passport package)
  • Social media based authentication (via the Laravel Socialite package)

As useful as all of these are, in this age of micro-services, Laravel doesn't provide much in the way of an out-of-the-box bootstrap for API-only authentication using custom APIs.

I was faced with this problem several months ago and I searched Google and Stackoverflow for an answer. I found helpful articles which helped to point the way, and these are cited. It took some effort to understand how to glue them together and step-debugging to iron out the kinks.

The answer is provided in the hope that it helps others - and myself, where I have to do the same thing again in the future.

Assumptions and Scope:

  • you've created your own API like https://example.com/login and https://example.com/logout
  • you're running a website that requires authentication, but not via models and tables or social media
  • your API manages interactions with tables, including user-login/logout
  • you use the Laravel Passport add-on for OAuth2 authentication (acknowledgements to @ShuvoJoseph for bringing this to my attention)
quinny
  • 656
  • 1
  • 7
  • 24

2 Answers2

4

The solution involves seven PHP files

  • app/Http/Controllers/HomeController.php - homepage controller; the destination for an authenticated user
  • app/Providers/ApiUserProvider.php - a custom provider to bootstrap and register the logged-in user, and implements the interface Illuminate\Contracts\Auth\UserProvider
  • app/CoreExtensions/SessionGuardExtended.php - custom guard-controller to log-in the user and receives the authentication values and stores them in session array; extends class Illuminate\Auth\SessionGuard
  • app/ApiUser - if you're using OAuth2 (Laravel's Passport); custom user class that exposes the OAuth access_token; extends Illuminate\Auth\GenericUser and implements the interface Illuminate\Contracts\Auth\Authenticatable
  • config/auth.php - the auth config which instructs the Auth() facade to return the custom session guard
  • app/Providers/AuthServiceProvider.php - the auth bootstrap
  • app/Providers/AppServiceProvider.php - the main application bootstrap

Source research/investigation material are cited for you to investigate for yourself and comprehend the background context to their existence. I make no claims to be a genius who created the solution from scratch through my own mojo, but rather that - like all innovators - I build on the efforts of others. The unique selling point of my article is that I provide a complete packaged solution, whereas the cited sources provide solutions to niche parts of the overall answer. Together, after much trial and error, they helped me to form a complete solution.

A really useful article to understands how config/auth.php affects execution in AuthManager.php is https://www.2hatslogic.com/blog/laravel-custom-authentication/

No code modifications are made to the following, but they're included to acknowledge the role they play and their importance in the process:

  • vendor/laravel/framework/src/Illuminate/Auth/AuthManager.php - main authorization factory manager
  • Auth() facade - returns the shrink-wrapped Illuminate\Auth\SessionGuard class instance by default, unless it's instructed to do otherwise through the config/auth.php file - Auth() is used ubiquitously throughout Laravel code to retrieve the session guard

The Code

app/Http/Controllers/HomeController.php

<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;

/**
 * Handles and manages the home-page
 * 
 * @category controllers
 */
class HomeController extends Controller
{
    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('auth');
    }

    public function index()
    {
        blah
    }

    ... other methods ... 

}

app/Providers/ApiUserProvider.php

Sources:

<?php
namespace App\Providers;

use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;
use App\ApiUser;

/**
 * Delegates API user login and authentication
 * 
 * @category providers
 */
class ApiUserProvider implements UserProvider
{
    
    /**
     * Custom API Handler 
     * Used to request API and capture responses
     * 
     * @var \Path\To\Your\Internal\Api\Handler
     */
    private $_oApi = null;
    
    /**
     * POST request to API
     * 
     * @param string  $p_url      Endpoint URL
     * @param array   $p_arrParam Parameters
     * @param boolean $p_isOAuth2 Is OAuth2 authenticated request? [Optional, Default=True]
     * 
     * @return array
     */
    private function _post(string $p_url, array $p_arrParam, bool $p_isOAuth2=true)
    {
        if (!$this->_oApi) {
            $this->_oApi = new \Path\To\Your\Internal\Api\Handler();
        }
        $arrResponse = $this->_oApi->post($p_url, $p_arrParam, $p_isOAuth2);
        return $arrResponse;
    }
    
    /**
     * GET request to API
     * 
     * @param string $p_url     Endpoint URL
     * @param array $p_arrParam Parameters [Optional, Default = array()]
     * 
     * @return array
     */
    private function _get(string $p_url, array $p_arrParam=[], bool $p_isOAuth2=true)
    {   
        if (!$this->_oApi) {
            $this->_oApi = new \Path\To\Your\Internal\Api\Handler();
        }
        $arrResponse = $this->_oApi->get($p_url, $p_arrParam);
        return $arrResponse;
    }
    
    /**
     * Retrieve a user by the given credentials.
     *
     * @param array $p_arrCredentials
     * 
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
     */
    public function retrieveByCredentials(array $p_arrCredentials)
    {
        $arrResponse = $this->_post('/login', $p_arrCredentials, false);
        if ( $arrResponse['result'] ) {
            $arrPayload = array_merge(
                $arrResponse['data'],
                $p_arrCredentials
            );
            return $this->getApiUser($arrPayload);
        }
    }

    /**
     * Retrieve a user by their unique identifier.
     *
     * @param mixed $p_id
     * 
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
     */
    public function retrieveById($p_id)
    {
        $arrResponse = $this->_get("user/id/{$p_id}");        
        if ( $arrResponse['result'] ) {
            return $this->getApiUser($arrResponse['data']);
        }
    }

    /**
     * Validate a user against the given credentials.
     *
     * @param \Illuminate\Contracts\Auth\Authenticatable $p_oUser
     * @param array                                      $p_arrCredentials
     * 
     * @return bool
     */
    public function validateCredentials(UserContract $p_oUser, array $p_arrCredentials)
    {
        return $p_oUser->getAuthPassword() == $p_arrCredentials['password'];
    }

    /**
     * Get the api user.
     *
     * @param mixed $p_user
     * 
     * @return \App\Auth\ApiUser|null
     */
    protected function getApiUser($p_user)
    {
        if ($p_user !== null) {
            return new ApiUser($p_user);
        }
        return null;
    }

    protected function getUserById($id)
    {
        $user = [];

        foreach ($this->getUsers() as $item) {
            if ($item['account_id'] == $id) {
                $user = $item;

                break;
            }
        }

        return $user ?: null;
    }

    protected function getUserByUsername($username)
    {
        $user = [];

        foreach ($this->getUsers() as $item) {
            if ($item['email_address'] == $username) {
                $user = $item;

                break;
            }
        }

        return $user ?: null;
    }
    

    /**
     * The methods below need to be defined because of the Authenticatable contract
     * but need no implementation for 'Auth::attempt' to work and can be implemented
     * if you need their functionality
     */
    public function retrieveByToken($identifier, $token) { }
    public function updateRememberToken(UserContract $user, $token) { }
    
}

app/CoreExtensions/SessionGuardExtended.php

Sources:

<?php
namespace App\CoreExtensions;

use Illuminate\Auth\SessionGuard;
use Illuminate\Contracts\Auth\Authenticatable;

/**
 * Extended SessionGuard() functionality 
 * Provides added functionality to store the OAuth tokens in the session for later use
 * 
 * @category guards
 * 
 * @see https://stackoverflow.com/questions/36087061/extending-laravel-5-2-sessionguard
 */
class SessionGuardExtended extends SessionGuard
{
    
    /**
     * Log a user into the application.
     *
     * @param  \Illuminate\Contracts\Auth\Authenticatable  $p_oUser
     * @param  bool  $p_remember
     * @return void
     */
    public function login(Authenticatable $p_oUser, $p_remember = false)
    {
        
        parent::login($p_oUser, $p_remember);
        
        /**
         * Writing the OAuth tokens to the session
         */
        $key = 'authtokens';
        $this->session->put(
            $key, 
            [
                'access_token' => $p_oUser->getAccessToken(),
                'refresh_token' => $p_oUser->getRefreshToken(),
            ]
        );
    }
    
    /**
     * Log the user out of the application.
     *
     * @return void
     */
    public function logout()
    {
        parent::logout();
        
        /**
         * Deleting the OAuth tokens from the session
         */
        $this->session->forget('authtokens');        
    }
    
}

app/ApiUser

Sources:

<?php
namespace App;

use Illuminate\Auth\GenericUser;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;

class ApiUser extends GenericUser implements UserContract
{
    
    /**
     * Returns the OAuth access_token
     * 
     * @return mixed
     */
    public function getAccessToken()
    {
        return $this->attributes['access_token'];
    }
    
    
    public function getRefreshToken()
    {
        return $this->attributes['refresh_token'];
    }
    
}

app/Providers/AuthServiceProvider.php

<?php
namespace App\Providers;

use Illuminate\Support\Facades\Auth;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;

class AuthServiceProvider extends ServiceProvider
{
    
    /**
     * Register any authentication / authorization services.
     *
     * @return void
     */
    public function boot()
    {
        $this->registerPolicies();
        
        Auth::provider('frank_sinatra', function ($app, array $config) {
            // Return an instance of Illuminate\Contracts\Auth\UserProvider...

            return new ApiUserProvider();
        });
        
    }
}

app/Providers/AppServiceProvider.php

Sources:

Note:

There is a couple of nuanced issues regarding the change to coding in this PHP file. If you want to understand more, look at vendor/laravel/framework/src/Illuminate/Auth/AuthManager.php, AuthManager::resolve() in particular.

  1. References to config/auth.php 'session' and 'token' are served by hard-coded methods AuthManager::createSessionDriver() and AuthManager::createTokenDriver() (Tell me please if you know of a way to extend AuthManager.php in the app)
  2. AppServiceProvider.php to the rescue! Custom guards can be registered in AppServiceProvider::boot() and intercepted before the default code can be executed.
  3. I'm OK with point 2 above, but couldn't we do something clever like return the custom session-guard name or instance from AppServiceProvider, have setCookieJar(), setDispatcher(), setRequest() in a specialized public method in AuthManager.php, which can be hooked into AppServiceProvider.php or driven by config/auth.php to execute after creating the custom session-guard in AuthManager.php?
  4. Without the cookies or sessions, the user's identity isn't preserved through the redirect. The only way to resolve this is to include the setCookieJar(), setDispatcher() and setRequest() in AppServiceProvider within our current solution.
<?php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Auth;
use App\CoreExtensions\SessionGuardExtended;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }

    /**
     * Bootstrap any application services.
     * 
     * @see https://stackoverflow.com/questions/36087061/extending-laravel-5-2-sessionguard
     *
     * @return void
     */
    public function boot()
    {
        
        /**
         * Extending Illuminate\Auth\SessionGuard()
         * This is so we can store the OAuth tokens in the session
         */
        Auth::extend(
            'sessionExtended',
            function ($app) {
            
                $guard = new SessionGuardExtended(
                    'sessionExtended', 
                    new ApiUserProvider(), 
                    app()->make('session.store'),
                    request()
                );
            
                // When using the remember me functionality of the authentication services we
                // will need to be set the encryption instance of the guard, which allows
                // secure, encrypted cookie values to get generated for those cookies.
                if (method_exists($guard, 'setCookieJar')) {
                    $guard->setCookieJar($this->app['cookie']);
                }

                if (method_exists($guard, 'setDispatcher')) {
                    $guard->setDispatcher($this->app['events']);
                }

                if (method_exists($guard, 'setRequest')) {
                    $guard->setRequest($this->app->refresh('request', $guard, 'setRequest'));
                }

                return $guard;
            }
        );
    }
}

config/auth.php

Sources:

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Authentication Defaults
    |--------------------------------------------------------------------------
    |
    | This option controls the default authentication "guard" and password
    | reset options for your application. You may change these defaults
    | as required, but they're a perfect start for most applications.
    |
    */

    'defaults' => [
        //'guard' => 'web', /** This refers to the settings under ['guards']['web'] */
        'guard' => 'webextended', /** This refers to the settings under ['guards']['webextended'] */
        'passwords' => 'users', /** This refers to the settings under ['passwords']['users'] */
    ],

    /*
    |--------------------------------------------------------------------------
    | Authentication Guards
    |--------------------------------------------------------------------------
    |
    | Next, you may define every authentication guard for your application.
    | Of course, a great default configuration has been defined for you
    | here which uses session storage and the Eloquent user provider.
    |
    | All authentication drivers have a user provider. This defines how the
    | users are actually retrieved out of your database or other storage
    | mechanisms used by this application to persist your user's data.
    |
    | Supported: "session", "token"
    |
    */

    'guards' => [
        'web' => [
            'driver' => 'session', /** This refers to Illuminate/Auth/SessionGuard */
            'provider' => 'users', /** This refers to the settings under ['providers']['users'] */
        ],
        
        'webextended' => [
            'driver' => 'sessionExtended', /** @see app/Providers/AppServiceProvider::boot() */
            'provider' => 'users', /** This refers to the settings under ['providers']['users'] */
        ],

        'api' => [
            'driver' => 'token', /** This refers to Illuminate/Auth/TokenGuard */
            'provider' => 'users',
            'hash' => false,
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | User Providers
    |--------------------------------------------------------------------------
    |
    | All authentication drivers have a user provider. This defines how the
    | users are actually retrieved out of your database or other storage
    | mechanisms used by this application to persist your user's data.
    |
    | If you have multiple user tables or models you may configure multiple
    | sources which represent each model / table. These sources may then
    | be assigned to any extra authentication guards you have defined.
    |
    | Supported: "database", "eloquent"
    |
    */

    'providers' => [
        'users' => [
            'driver' => 'frank_sinatra',  /** @see app/Providers/AuthServiceProvider::boot() */
            //'model' => App\User::class,
        ],

        // 'users' => [
        //     'driver' => 'database',
        //     'table' => 'users',
        // ],
    ],

    [
        blah
    ],

    [
        other settings
    ],

];

How To Use This Solution

Very simple. There's no change in the overall approach. In other words, we use the Auth() facade.

When logging in with your custom API /login?username=<username>&password=<password>

request()->flash();
$arrData = request()->all();

if ( Auth::attempt($arrData, true) ) {
    return redirect('home');
} else  {
    return back()->withErrors(
        [
            'username' => "Those credentials can't be found",
            'password' => "Those credentials can't be found",
        ]
    );
}

When logging out with your custom API /logout

Auth::logout();
return redirect('home');
Community
  • 1
  • 1
quinny
  • 656
  • 1
  • 7
  • 24
  • I invite anyone to submit any improvements or point out solutions I might've missed. Let's get this nailed and out there for everyone to use. – quinny May 24 '20 at 01:33
  • 1
    One further point that this whole exercise drew to my attention. While Laravel is configured in such a way to enable developers to easily spin up sights and services, it's easy to conclude that it is therefore simple to include improved functionality through the Laravel framework's sub-system, but this is a mistaken notion. Laravel under the hood is complex and requires an effort to understand. Laravel is very powerful in its facilities, but it's far from straightforward to add new facilities or to extend existing ones. – quinny May 24 '20 at 01:37
  • Everything is working fine, no error. But "Auth::attempt($arrData, true)" this line always returns false! I even logged inside retrieveByCredentials(array $credentials), for successful login, it is returning ApiUser. But Auth::attempt is always false. Any suggestions? – Shuvo Joseph Mar 09 '21 at 12:49
  • 1
    @ShuvoJoseph Hi Shuvo, did you set the default guard to 'webextended' in config/auth.php? – quinny Mar 09 '21 at 18:02
  • @ShuvoJoseph Failing that, it might be worth installing XDebug and putting a breakpoint at "Auth::attempt($arrData, true)" and stepping into the code to see where it leads you. The authenticator facade decides which authentication class to run and that might not be set up correctly. Please let me know if it's my code which is at fault, then I can correct it - I want it to be as spot-on as I can get it so that it helps others and even myself in the future. I can't look right now, but I'll check out the code again when I have chance. – quinny Mar 09 '21 at 18:08
  • @ShuvoJoseph What was your bug in the end? – quinny Mar 10 '21 at 13:25
  • I had a problem in "retrieveById($identifier)" function of ApiUserProvider.php. – Shuvo Joseph Mar 10 '21 at 13:32
  • @ShuvoJoseph Thanks for letting me know. Do I need to correct anything in the solution I provided? Did you need to make some tweaks and should they go into the accepted answer? – quinny Mar 10 '21 at 17:06
  • I had another question. From the login function, I have the Auth::user(), but when I redirect route to some controller, again it comes to LoginController __construct(), because the route is inside Auth middleware group and inside __constuct of LoginController, Auth::user() is again null. The api I am calling does not have any access token or refresh token cause it is session based. I do have the sessionKey in response. Any suggestions? – Shuvo Joseph Mar 11 '21 at 10:26
  • @ShuvoJoseph This solution assumes you're using OAuth2 - this is something I should make clear. Perhaps the dependency is too tightly coupled. I can't guarantee the specific underlying principles are the same for other auth methodologies, but my instinct is that it shouldn't matter. Can I ask you to debug through and see where my solution causes the failure in your implementation. If you can find it, this would be really helpful, because I can add it to the answer and alert future readers. Anything you find will be important. – quinny Mar 18 '21 at 23:20
  • 1
    @ShuvoJoseph What your problem does immediately suggest to me is that the authenticated user isn't being stored in the session. Could that be the problem? – quinny Mar 18 '21 at 23:23
  • Hi @ShuvoJoseph. Is this https://stackoverflow.com/questions/34512098/laravel-5-2-authlogin-not-preserve-when-using-new-app-user?rq=1 of any use? – quinny Mar 19 '21 at 12:10
  • Thanks for the response, I solved it using Session. Saved the ApiUser in Session and in the Auth middleware, checking is there is user data in Session. https://stackoverflow.com/questions/42360183/how-to-set-session-variable-when-user-login-in-laravel – Shuvo Joseph Mar 21 '21 at 13:55
  • Thanks for your feedback @ShuvoJoseph That's good insight and will help others who follow the same approach. – quinny Mar 21 '21 at 22:50
0

The problem with this approach is that it doesn't handle Password Reset, which requires tokens to be stored in a local database and is a pain to override.

dietcheese
  • 78
  • 6
  • Agreed. Even so, I felt this approach needed to be recorded somewhere, because I conducted a lot of research to arrive at a holistic single solution. I wanted to know that there was a StackOverflow article that I could refer to at some point if it was necessary. My investigation revealed that there are more instances than you'd imagine where a system relies upon API-response logins rather than db-based user-logins. – quinny Dec 06 '21 at 21:46
  • I'm glad you did, what you did helped me out. I'm surprised nobody has put together complete Laravel scaffolding for API authentication - seems like an obvious use-case to me. – dietcheese Dec 07 '21 at 22:26
  • Thanks @dietcheese. Much appreciated. Remember, while this solution primarily implements OAuth, which may be a pain for the reasons you mentioned, it can be adapted to other authentication approaches. Without looking at the code directly, but making a best-guess suggestion, I would say that the changes would need to be made to the config/auth.php and anywhere in my original solution that references 'access_token' and 'refresh_token'. The overall solution architecture would remain the same, while the implementation specifics within some of the methods may need to be altered slightly. – quinny Dec 09 '21 at 10:55
  • If you have implemented a solution based on, yet modifying my original post for another authentication approach, then - please! - I invite you to post it as an answer. If it helps and if the prospect of writing a big article fills you with less than joy, reference the components in my answer and the modifications you needed to make. I know that this will prove to be of great value to others searching for a solution to the problem. – quinny Dec 09 '21 at 11:05