Since 5.6, Laravel has shipped with functionality to sign URLs. These URLs append a "signature" to the query string, so that Laravel can verify that the link has not been tampered with since it was created. This also allows you to generate temporary signed routes that expire after a configured period of time.
This is useful for things like verifying account emails, or enabling passwordless logins.
Passwordless logins is something that is quite useful for an application, but what if you wanted to be able to generate a signed URL in one application that would allow you to log in to a second application?
The use case
In my day job, we have two main applications:
- our internal CRM, which contact centre staff use to administer customer accounts, manage contacts and billing, and perform service checks, and
- our customer-facing members area, where customers have the ability to manage a subset of their account details, view and pay invoices, etc.
From time to time, a customer may contact us with an issue with their members area. This may be incorrect information being displayed, or some other bug with the members area. Allowing our staff to log in on the customer's behalf is useful for troubleshooting and getting to the underlying issues, which end up as bug reports to the development team.
In a default Laravel installation, your application will use the value stored in the APP_KEY
- and subsequently the app.key
config variable - with which to sign your URLs. This is typically a reasonable solution, except in this particular use case, that means the APP_KEY
would need to be shared between applications. This can lead to a wider surface area of compromise, should the key leak into the wild.
Luckily, Laravel makes it quite simple to swap out the key.
A custom key resolver
URL signing is managed inside the framework's RoutingServiceProvider
. As it is set in a service provider, that makes it a simple task to override on a per-application basis.
Inside our AppServiceProvider
, we can make a similar call, replacing the closure logic to use the value of our custom signing key - URL_SIGNER_KEY
, stored in the app.urlSigner.key
config variable on both applications.
// App\Providers\AppServiceProvider.php // In Laravel 5.6public function register(){ $this->app->make('url')->setKeyResolver(function () { return $this->app->make('config')->get('app.urlSigner.key'); });} // In Laravel >= 6, PHP >= 7.4use Illuminate\Support\Facades\URL; public function register(){ URL::setKeyResolver(fn () => config('app.urlSigner.key'));}
Having a separate key just for URL signing means that should it leak, we can change the key and know that it is only affecting the signing of URLs, and no other part of our application.
Handling the passthrough authentication
From our CRM, we generate a signed URL with the target customer's username and the staff user that is logging in, signing the URL to prevent tampering - or users stumbling upon the URL and being able to log in to any account.
We create a new route - Route::get('/authenticate/{username}', 'AuthenticateStaffLoginController')->middleware('signed');
- applying the signed
middleware, which will verify our signed URL.
The AuthenticatesStaffLoginController
is a single action controller that checks that the staff user passed in the query string exists and is valid, then uses Laravel's user guard to authenticate the target username
.
// App\Http\Controllers\AuthenticateStaffLoginController.phppublic function __invoke($username){ // Ensure the target user and staff user exist $user = Service::where('MembersUsername', $username)->firstOrFail(); $staffUser = StaffUser::findOrFail(request('staff_user')); // If there is an active session, log the user out if (auth()->check()) { auth()->logout(); } // Log in as the target user auth()->login($user); // Set a flag on the session, which we use to fix a notice to the top of // the page reminding the staff user they're impersonating a customer. session(['logged_in_as_staff' => true]); // Log a note to provide an audit trail for the activity auth()->user()->addNote( "Logged into the members area on the customer's behalf", $staffUser->StaffUserID ); return redirect()->route('home');}