spatie / laravel-multitenancy

Make your Laravel app usable by multiple tenants
https://spatie.be/docs/laravel-multitenancy
MIT License
1.13k stars 153 forks source link

APP_KEY per tenant not persistant, consequent. #251

Closed BartMommens closed 3 years ago

BartMommens commented 3 years ago

Hey all,

I'm currently trying to change the config('app.key') dynamic per tenant.

I have an extra table that contains the app keys for a specific tenant.

I'm running custom task that should switch certain config variables on runtime.

    namespace App\Tasks\System\Tenant;

    use App\Models\System\Tenant\TenantSettings;
    use Illuminate\Encryption\Encrypter;
    use Illuminate\Encryption\EncryptionServiceProvider;
    use Illuminate\Support\Facades\App;
    use Illuminate\Support\Facades\Crypt;
    use Spatie\Multitenancy\Tasks\SwitchTenantTask;
    use Spatie\Multitenancy\Models\Tenant;

    class SwitchTenantConfigTask implements SwitchTenantTask
    {

        public function makeCurrent(Tenant $tenant): void
        {
            $tenantSettings = TenantSettings::where('tenant_id', $tenant->id)->first();
            config([
                'app.key' => $tenantSettings->app_key,
                'session.domain' => $tenant->domain,
                'sanctum.stateful' => explode(',', 'spa.' . $tenant->domain)
            ]);
            //Force RE-Register EncryptionServiceProvider
            App::register(EncryptionServiceProvider::class,true);

        }

        public function forgetCurrent(): void
        {
            config([
                'session.domain' => null,
                'sanctum.stateful' => null
            ]);
        }
    }

So upon switching tenant a want to switch the app.key in the config with the one in the database. when i dd(config('app.key') after setting it it displays the correct key. Unfortunatly some of my models have accessors to encrypt / decrypt the data. example:

    namespace App\Traits;

    use Illuminate\Contracts\Encryption\DecryptException;
    use Illuminate\Support\Facades\Crypt;

    trait EncryptedLastNameTrait
    {
        /* ENCRYPTION PART */

        /**
         * Set encrypted Admin's Last Name.
         *
         * @param string $value
         * @return string
         */
        protected function setLastNameAttribute($value)
        {
            if (!empty($value)) {
                $value = Crypt::encryptString($value);
            }
            $this->attributes['last_name'] = $value;
        }

        /* DECRYPTION PART */

        /**
         * Get decrypted Admin's Last Name.
         *
         * @param string $value
         * @return string
         */
        protected function getLastNameAttribute($value)
        {
            if (empty($value)) {
                return $value;
            }
            try {
                return Crypt::decryptString($value);
            } catch (DecryptException $e) {
                return '';
            }
        }

    }

Here the aAPP_KEY from the environement is still being used after setting it on the tenant switch task. So all the data is still encrypted with the main key instead of the tenant's key. Also when i run Tinker for a tenant and dumpt the app.key from config the tenant key is displayed but Tinker also keeps using the Main APP_KEY.

For that reason i try to force register the EncryptionServiceProvider::class right after setting the new app.key on runtime... but that doesn't change anything.

Anyone have any idea how to make this work?

masterix21 commented 3 years ago

I didn't have ever changed the key at runtime: I think you should do the change before calling the EncryptionServiceProvider.

BartMommens commented 3 years ago

I found a solution but still working out the kinks. Basically EncryptionServiceProvider creates a Singleton at boot. So overriding the config('app.key') will not help and the same with force registering the EncryptionServiceProvider.

So the thing is at first you'll need main app key to decode the tenant data in the Landlord Database so at this point you're stuck with the .env APP_KEY. So what i'm doing now is getting the app key from the tenant database decoding it and storing it in a separate config var config(['tenant.app.key' => $tenantSettings->app_key]); Now i'm going to create a new trait that loads a custom instance of Crypt and encrypt all models for tenant with this crypter to use that custom key to encrypt everything :)

Sharing code below in a sec

BartMommens commented 3 years ago

So here is what i did:

So the EncryptionServiceProvider is using the main APP_KEY (which encrypts all data in the landlord database). In my SwithTenantConfigTask i get the tenants app_key ( currently in a separate model due to interface thingie #250 )

SwithTenantConfigTask.php

namespace App\Tasks\System\Tenant;

use App\Models\System\Tenant\TenantSettings;
use Illuminate\Support\Facades\App;
use Spatie\Multitenancy\Tasks\SwitchTenantTask;
use Spatie\Multitenancy\Models\Tenant;

class SwitchTenantConfigTask implements SwitchTenantTask
{

    public function makeCurrent(Tenant $tenant): void
    {
        $tenantSettings = TenantSettings::where('tenant_id', $tenant->id)->first();

        config([
            'tenant.app.key' => $tenantSettings->app_key,
        ]);

        #Check key is set and was able to be decoded
        if(empty(config('tenant.app.key'))){
            abort('500', 'Tenant app key is missing!');
        }

    }

    public function forgetCurrent(): void
    {
        config([
            'tenant.app.key' => null
        ]);
    }
}

So at this point i have a config('tenant.app.key').

Now here is an example of a model for a tenat being User.

    namespace App\Models;

    use App\Traits\EncryptTenantStrings;
    use Illuminate\Contracts\Auth\MustVerifyEmail;
    use Illuminate\Foundation\Auth\User as Authenticatable;
    use Illuminate\Notifications\Notifiable;
    use Spatie\Multitenancy\Models\Concerns\UsesTenantConnection;

    class User extends Authenticatable implements MustVerifyEmail
    {
        use HasFactory,
            Notifiable,
            UsesTenantConnection,
            EncryptTenantStrings;

        /**
         * The attributes that are mass assignable.
         *
         * @var array
         */
        protected $fillable = [
            'first_name',
            'last_name',
            'email',
            'password',
        ];

        /**
         * The attributes that should be hidden for arrays.
         *
         * @var array
         */
        protected $hidden = [
            'last_name',
            'email',
            'password',
            'remember_token',
        ];

        /**
         * The attributes that should be cast to native types.
         *
         * @var array
         */
        protected $casts = [
            'email_verified_at' => 'datetime',
        ];

        /**
         * Set encrypted User's First Name.
         *
         * @param string $value
         * @return string
         */
        protected function setFirstNameAttribute($value)
        {
            $this->attributes['first_name'] = $this->encryptString($value);
        }

        /* DECRYPTION PART */

        /**
         * Get decrypted User's First Name.
         *
         * @param string $value
         * @return string
         */
        protected function getFirstNameAttribute($value)
        {
            return $this->decryptString($value);
        }

        protected function setLastNameAttribute($value)
        {
            $this->attributes['last_name'] = $this->encryptString($value);
        }

        /* DECRYPTION PART */

        /**
         * Get decrypted Admin's Last Name.
         *
         * @param string $value
         * @return string
         */
        protected function getLastNameAttribute($value)
        {
            return $this->decryptString($value);
        }
    }

Here i use Accessors and mutators that use a custom encrypter instance via a trait:

    namespace App\Traits;

    use Illuminate\Contracts\Encryption\DecryptException;
    use Illuminate\Encryption\Encrypter;

    trait EncryptTenantStrings
    {
        protected $tenantEncrypter;

        public function encryptString(string $plaintext): string
        {
            if (!empty($plaintext)) {
                return $this->getTenantEncrypter()->encryptString($plaintext);
            }
            return $plaintext;
        }

        public function decryptString(string $ciphertext): string
        {
            if (empty($ciphertext)) {
                return $ciphertext;
            }
            try {
                return $this->getTenantEncrypter()->decryptString($ciphertext);
            } catch (DecryptException $e) {
                return '';
            }
        }

        protected function getTenantEncrypter(): Encrypter
        {
            if (!$this->tenantEncrypter) {
                $encryptionKey = base64_decode(substr(config('tenant.app.key'), 7));  //get the appkey and decode it.
                $this->tenantEncrypter = new Encrypter($encryptionKey, config('app.cipher'));
            }
            return $this->tenantEncrypter;
        }
    }

This works, i'm not sure it's an optiomal way of doing but at this point it's working. Hope i can help someone with this code. Suggestions of optimizing or making it cleaner / leaner are always welcome. Also If this approach is crappy let me know :) i'll try to rethink my strat again 🙃

masterix21 commented 3 years ago

@BartMommens, format your code using the PHP format style syntax, please. Thanks for your share.

BartMommens commented 3 years ago

@masterix21 i'm trying but can't get it tho show properly i'm a github sharer noob :)

BartMommens commented 3 years ago

@masterix21 sorry for the wait, i have properly formatted the code :)

masterix21 commented 3 years ago

Thanks