Tangible Bytes

A Web Developer’s Blog

Multiple Authentication in Laravel

You may not need this, if you can manage multiple types of user via roles and permissions it will be much the simpler route to follow.

In my case I wanted user with different: properties, relationships, routes, password rules, timeouts, and more.

It was worth the pain of setting up two authenticatable models.

Laravel is very flexible and well documented, but the further you stray from what most people do - the less obvious it is and a few of these steps took me a while to figure out.

It seems I’m not the only one to find this process tricky, there are quite a few blogs posts about it

Create a new Authenticatable Model

This is similar to the standard User model, I called mine Account and they are being used for a Single Sign On process so related routes etc are named sso.

artisan make:migration create_accounts_table --create=accounts

The schema should look like

public function up()
{
    Schema::create('accounts', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->string('email')->unique();
        $table->timestamp('email_verified_at')->nullable();
        $table->string('password');
        $table->rememberToken();
        $table->timestamps();
    });
}

You can have more fields if your user has more properties.

app/Models/Account.php

namespace App\Models;

use Illuminate\Database\Eloquent\Relations\belongsTo;
use Illuminate\Database\Eloquent\Relations\belongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class Account extends Authenticatable
{
    use Notifiable, SoftDeletes;

    protected $hidden = ['password', 'email', 'token', 'token_expire_at', 'remember_token', 'deleted_at', 'email_verified_at'];


}

You don’t have to hide all these fields - but it’s good to keep anything sensitive excluded from API calls by default.

The important things are it has to extend User as Authenticatable and use Notifiable.

Add Guard

config/auth.php

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        // add this 
        'sso' => [
            'driver' => 'session',
            'provider' => 'accounts',
        ],
    ],
    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
        // add this 
        'accounts' => [
            'driver' => 'eloquent',
            'model' => App\Models\Account::class,
            'table' => 'accounts',
        ],
    ],
    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_reset_tokens',
            'expire' => 60,
            'throttle' => 60,
        ],
        // add this
        'accounts' => [
            'provider' => 'accounts',
            'table' => 'password_reset_tokens',
            'expire' => 60,
            'throttle' => 60,
        ],
    ],

This creates a new sso guard which uses sessions and passwords based on the accounts table instead of the users table.

At one point in debugging before I had this all hooked up properly I could see things almost working but I was seeing queries for my account running against the users table.

This setup is a little harder to get right because some misconfigurations just result in an inability to login with no fatal error.

Add Routes

First create a new middleware group - this allows you to put the controls you need in place for this user.

In my case these users will be accessing the Laravel instance exclusively via an API driven by a JavaScript frontend, so I copied the web middleware but removed the Interia specific parts.


    protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \App\Http\Middleware\VerifyCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
            \App\Http\Middleware\HandleInertiaRequests::class,
            \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
        ],
        // added this sso section
        'sso' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \App\Http\Middleware\VerifyCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],

        'api' => [
            \Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
    ];

Now I can can create my route file referencing the authentication guard I just defined

routes/sso.php

<?php

use App\Http\Controllers\AccountController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sso')->prefix('/{site:code}/auth/')->group(function () {
    Route::post('logout', [AccountController::class, 'logout']);
    Route::get('bookmarks', [AccountController::class, 'bookmarks']);
    Route::post('bookmarks', [AccountController::class, 'saveBookmarks']);
    Route::delete('bookmarks', [AccountController::class, 'deleteBookmarks']);
});
Route::prefix('/{site:code}/auth/')->group(function () {
    Route::post('signup', [AccountController::class, 'register']);
    Route::post('login', [AccountController::class, 'login']);
    Route::post('get_reset_link', [AccountController::class, 'sendResetPasswordLink']);
    Route::post('reset', [AccountController::class, 'resetPassword']);
});

In my case I have a site prefix - this is used for multisite login.

The logout and bookmark routes are protected, while the signup and login routes are available to anonymous users.

Register this route file in

app/Providers/RouteServiceProvider.php


 $this->routes(function () {
            Route::middleware('api')
                ->prefix('api')
                ->group(base_path('routes/api.php'));

            // add this 
            Route::middleware('sso')  
                ->prefix('sso') 
                ->group(base_path('routes/sso.php')); 

            Route::middleware('web')
                ->group(base_path('routes/web.php'));
        });

Now all my new sso routes are protected by the middleware which adds things like session and XSRF protection.

Account Controller


<?php

namespace App\Http\Controllers;

use App\Http\Requests\AccountSignupRequest;
use App\Mail\AccountPasswordReset;
use App\Models\Account;
use App\Models\Site;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;

class AccountController extends Controller
{


    public function register(Site $site, AccountSignupRequest $request)
    {

        $account = new Account;
        $account->email = $request->email;
        $account->firstname = $request->firstname;
        $account->lastname = $request->lastname;
        $account->password = Hash::make($request->password);
        $account->last_login = now();
        $account->save();
        $account->refresh();

        $account->sites()->attach($site);

This bit is important !

To get the right kind of authenticatable from the Auth facade we have to specify the custom guard we made if you don’t specify one here then Laravel uses the default guard which will get the wrong kind of Authenticatable - probably User.

        // be sure to specify the guard
        Auth::guard('sso')->login($account);

        return response()->json([
            'status' => 'success',
            'message' => 'Account created successfully',
            'account' => $account,
        ]);
    }


    public function login(Site $site, Request $request)
    {
        $request->validate([
            'email' => 'required|string|email',
            'password' => 'required|string',
        ]);
        $credentials = $request->only('email', 'password');

        $ok = Auth::guard('sso')->attempt($credentials);
        if (! $ok) {
            return response()->json([
                'status' => 'error',
                'message' => 'Unauthorized',
            ], 401);
        }

        $user = Auth::guard('sso')->user();

        return response()->json([
            'status' => 'success',
            'user' => $user,
        ]);
    }

        public function logout()
    {
        Auth::logout();

        return response()->json([
            'status' => 'success',
            'message' => 'Successfully logged out',
        ]);
    }



     public function bookmarks(Site $site)
    {
        $account = Auth::user();
        $bookmarks = $this->getBookmarks($site, $account);
        return response()->json($bookmarks);
    }

  public function sendResetPasswordLink(Site $site, Request $request)
    {
        $request->validate(['email' => 'required|email']);

        $status = Password::broker('accounts')->sendResetLink(
            $request->only('email'),
            function ($user, $token) use ($site) {
                Mail::to($user->email, "{$user->firstname} {$user->lastname}")->send(
                    new AccountPasswordReset($user, $site, $token));
                return Password::RESET_LINK_SENT;
            },
        );

        return response()->json($status);
    }

    public function resetPassword(Site $site, Request $request)
    {
        $request->validate([
            'token' => 'required',
            'email' => 'required|email',
            'password' => 'required|min:8|confirmed',
        ]);

        $status = Password::broker('accounts')->reset(
            $request->only('email', 'password', 'password_confirmation', 'token'),
            function (Account $user, string $password) {
                $user->forceFill([
                    'password' => Hash::make($password),
                ])->setRememberToken(Str::random(60));

                $user->save();

                event(new PasswordReset($user));
            }
        );

        return response()->json($status);
    }
}

the AccountSignupRequest validation looks like

    public function rules(): array
    {

        return [
            'firstname' => ['required', 'string', 'max:255'],
            'lastname' => 'required|string|max:255',
            'email' => ['required', 'email:dns', Rule::unique('accounts')], 
            'password' => 'min:8',
        ];
    }

It’s really not that different to a regular user registration

But you have to specify the guard.

See also why I have used a closure above to send a password reset email with additional data

Useful resources

I also dug into API docs and even source code a little.

It’s worth checking out the Authenticatable interface and the PasswordBroker class

Caveat

This code isn’t live yet - it seems to pass initial testing but let me know if you spot bugs or if you see a better way of doing this.