Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save CodeAdminDe/ae81bf9765e25ccfcdae51369088bfd0 to your computer and use it in GitHub Desktop.
Save CodeAdminDe/ae81bf9765e25ccfcdae51369088bfd0 to your computer and use it in GitHub Desktop.
HowTo - Laravel 8 with landlord session table and tenant_id scopeing

202112_HowTo_Laravel8_Landlord_Sessions_with_Tenat_ID

This is a short "how-to implement" of a tenant scopeing for sessions when using

with a multi-database setup and sessions within the landlord database.

Disclamer: No warranty or guarantee of completeness and correctness. This is a record for personal use and should be considered as a "personal note".

Fictional inital situation

A multi-tenant app with multi-database setup is planned. Each tenant has its own database. (tenant databases). Just as described in the basic setup of spatie/laravel-multitenancy: https://spatie.be/docs/laravel-multitenancy/v2/installation/base-installation.

By default all tenant sessions are stored in the main database (landlord).

Goal

To avoid conflicts / security issues between different tenant sessions, Laravel's DatabaseSessionHandler will be extended to scope the sessions by a tenant_id.

Because this sample uses Laravel Jetstream, we'll override the LogoutOtherBrowserSessionsForm Livewire component to handle the new situation.

HowTo - Custom session handler integration

Content

    1. Migration
    1. CustomSessionHandler
    1. SessionServiceProvider
    1. SESSION_DRIVER environment variable
    1. Jetstream - LogoutOtherBrowserSessionsForm

1. Migration

We need to add a new tenant_id column to laravels default sessions table. ($table->unsignedBigInteger('tenant_id')->nullable()->index();) and migrate the new sessions table.

2. CustomSessionHandler

We need to create a custom SessionHandler. Because we want to use allmost all features of Laravel's default DatabaseSessionHandler, we'll extend this and overwrite the necessary methods. Save the file at any location you prefer, (there is no specific folder provided to save your personal SessionHandlers) within this example, we'll save it at app\SessionHandlers\CustomSessionHandler.php (Content: You find the file within this gist.)

The lines changed in comparison with the original methods are provided with comments.

3. SessionServiceProvider

To load the CustomSessionHandler, we'll need to register it, we'll do this within a dedicated ServiceProvider (it's fine to do this within an already existing provider, but i like to separate it ;-)) called SessionServiceProvider and save it as app\Providers\SessionServiceProvider.php (Content: You find the file within this gist.)

The handler will be loaded with the same properties like the original DatabaseSessionHandler would be.

To register the newly crated SessionServiceProvider, append it to the config array providers within the file config/app.php.

4. SESSION_DRIVER environment variable

To use the new driver, just add / update the following line within your .env file: SESSION_DRIVER=custom-database.

5. Jetstream - LogoutOtherBrowserSessionsForm

Jetstream provides a great feature which allows the user to logout from other sessions. We need to tweak this feature a litte bit in order to respect our previous changes.

  • Within the resources/views/profile/show.blade.php:

    • Change the line @livewire('profile.logout-other-browser-sessions-form') to @livewire('logout-other-browser-sessions-form')
  • Create a new livewire component: php artisan make:livewire LogoutOtherBrowserSessionsForm

  • Do not edit the blade view, just leave it blank, the component will use the view of the extended component.

  • Edit the new app\Http\Livewire\LogoutOtherBrowserSessionsForm.php and let it extend \Laravel\Jetstream\Http\Livewire\LogoutOtherBrowserSessionsForm, then override the the methods deleteOtherSessionRecords and getSessionsProperty. (Content: You find the file within this gist.) The lines changed in comparison with the original methods are provided with comments.

LICENSE

Note: Software = Content of this gist.

Copyright 2021 Frederic Habich frederic.habich@codeadmin.de

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

<?php
namespace App\Http\Livewire;
use Spatie\Multitenancy\Models\Tenant;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Laravel\Jetstream\Http\Livewire\LogoutOtherBrowserSessionsForm as JetstreamLogoutOtherBrowserSessionsForm;
class LogoutOtherBrowserSessionsForm extends JetstreamLogoutOtherBrowserSessionsForm
{
/**
* {@inheritDoc}
*
* @return void
*/
protected function deleteOtherSessionRecords()
{
if (config('session.driver') !== 'custom-database') { //OVERWRITTEN: changed database to custom-database
return;
}
DB::connection(config('session.connection'))->table(config('session.table', 'sessions'))
->where('user_id', Auth::user()->getAuthIdentifier())
->where('tenant_id', Tenant::current()->id) // OVERWRITTEN: added this line to scope the builder to current tenant sessions.
->where('id', '!=', request()->session()->getId())
->delete();
}
/**
* {@inheritDoc}
*
* @return \Illuminate\Support\Collection
*/
public function getSessionsProperty()
{
if (config('session.driver') !== 'custom-database') { //OVERWRITTEN: changed database to custom-database
return collect();
}
return collect(
DB::connection(config('session.connection'))->table(config('session.table', 'sessions'))
->where('user_id', Auth::user()->getAuthIdentifier())
->where('tenant_id', Tenant::current()->id) // OVERWRITTEN: added this line to scope the builder to current tenant sessions.
->orderBy('last_activity', 'desc')
->get()
)->map(function ($session) {
return (object) [
'agent' => $this->createAgent($session),
'ip_address' => $session->ip_address,
'is_current_device' => $session->id === request()->session()->getId(),
'last_active' => Carbon::createFromTimestamp($session->last_activity)->diffForHumans(),
];
});
}
}
<?php
namespace App\Providers;
use App\SessionHandlers\CustomSessionHandler
use Illuminate\Support\Facades\Session;
use Illuminate\Support\ServiceProvider;
class SessionServiceProvider extends ServiceProvider
{
public function register()
{
//
}
public function boot()
{
Session::extend('custom-database', function ($app) {
return new CustomSessionHandler(
$app->db->connection(config('session.connection')),
config('session.table'),
config('session.lifetime'),
$app
);
});
}
}
<?php
namespace App\SessionHandlers;
use Spatie\Multitenancy\Models\Tenant;
use Illuminate\Session\DatabaseSessionHandler;
class CustomSessionHandler extends DatabaseSessionHandler
{
/**
* {@inheritdoc}
*
* @return string|false
*/
#[\ReturnTypeWillChange]
public function read($sessionId)
{
//OVERWRITTEN: scope to tenant_id if possible
//$session = (object) $this->getQuery()->find($sessionId);
$tenantId = Tenant::current()?->id;
$session = (object) $this->getQuery()->where('tenant_id', $tenantId)->find($sessionId);
if ($this->expired($session)) {
$this->exists = true;
return '';
}
if (isset($session->payload)) {
$this->exists = true;
return base64_decode($session->payload);
}
return '';
}
/**
* {@inheritdoc}
*
* @param string $data
* @return array
*/
protected function getDefaultPayload($data)
{
$payload = [
'payload' => base64_encode($data),
'last_activity' => $this->currentTime(),
'tenant_id' => Tenant::current()?->id, //OVERWRITTEN: Added tenant_id
];
if (! $this->container) {
return $payload;
}
return tap($payload, function (&$payload) {
$this->addUserInformation($payload)
->addRequestInformation($payload);
});
}
}
@masterix21
Copy link

I have a suggestion like so:

class CustomSessionHandler extends DatabaseSessionHandler
{
    protected function getQuery()
    {
        if (! Tenant::checkCurrent()) {
            return parent::getQuery()->whereNull('tenant_id');
        }

        return parent::getQuery()->where('tenant_id', Tenant::current()->id);
    }

    protected function addRequestInformation(&$payload)
    {
        parent::addRequestInformation($payload);

        $payload['tenant_id'] = Tenant::current()?->id;

        return $this;
    }
}

Seems more readable, but I haven't tested it.

Thanks for sharing your code with us.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment