Yii2 Multi-Tenant Auth: How to Support Concurrent Multi-User Sessions in the Same Browser

0 0
1
Viewed: 56 times
Version: 2.0
Category: Tutorials
Written by: braaalsalahi braaalsalahi
Created: Aug 18, 2026

Yii2 Multi-Tenant Auth: How to Support Concurrent Multi-User Sessions in the Same Browser

  1. 1. Introduction & The Engineering Challenge
  2. 2. Architecture Overview: Real-Time Dynamic Context Switching
  3. 3. Step 1: The Custom User Component (common\components\User.php)
  4. 4. Step 2: The Custom Session Component (common\components\Session.php)
  5. 5. Step 3: Application Configuration (config/main.php)
  6. 6. Key Benefits & Takeaways

A Comprehensive Guide to Multi-Context Authentication, Real-Time Context Switching, and Session Isolation in Yii2 Framework.

1. Introduction & The Engineering Challenge

In modern web applications built on Yii2 Advanced Framework—such as E-Commerce platforms with a Main Admin Panel and a Merchant/Seller Portal—developers often face a critical requirement:

How can a user log into the Main Admin panel AND a Merchant portal concurrently using two different accounts in the exact same browser without session collisions?

The Core Problem:

Web browsers attach the same session cookie (PHP Session ID) to every HTTP request sent to the same domain. Out of the box, Yii2 relies on a single session key ($_SESSION['__id']) to track the authenticated identity.

When a user logs into the Merchant portal in Tab 2, PHP overwrites $_SESSION['__id'] with the Merchant User ID. The moment the user refreshes Tab 1 (Admin Panel), Yii2 loads the Merchant User identity instead of the Admin, leading to broken authorization, UI glitches, or unexpected logouts.

2. Architecture Overview: Real-Time Dynamic Context Switching

Rather than attempting to hack runtime session names or creating fragile beforeRequest event hooks, the optimal solution introduces a Zero Code Mutation Architecture.

By subclassing yii\web\User and yii\web\Session, we intercept authentication and session access points to perform Real-Time Dynamic Context Switching based on the request URI:

                  ┌──────────────────────────────────────────────┐
                  │                HTTP Request                  │
                  └──────────────────────┬───────────────────────┘
                                         │
                         ┌───────────────┴───────────────┐
                         │   URL Routing Context Check   │
                         └───────────────┬───────────────┘
                                         │
              ┌──────────────────────────┴──────────────────────────┐
              ▼                                                     ▼
   [ Main Admin Route ]                                   [ Merchant Module Route ]
   Context = 'admin'                                      Context = 'merchant'
   ------------------                                     ---------------------
   idParam = '__id'                                       idParam = '__id_merchant'
   Cookie  = '_identity_admin'                            Cookie  = '_identity_merchant'
   Session = $_SESSION['key']                             Session = $_SESSION['merchant_key']
   Flashes = $_SESSION['__flash']                         Flashes = $_SESSION['merchant___flash']

3. Step 1: The Custom User Component (common\components\User.php)

We extend yii\web\User to dynamically evaluate the active URL route. Depending on the route context, the component updates $idParam and $identityCookie on the fly and invalidates cached identity objects when switching between tabs.

<?php

namespace common\components;

use Yii;
use yii\web\User as BaseUser;

class User extends BaseUser
{
    private $_lastContext = null;

    public function init()
    {
        $this->applyContext();
        parent::init();
    }

    /**
     * Determines the active application context based on URL path.
     */
    protected function getCurrentContext()
    {
        try {
            if (Yii::$app->has('request')) {
                $path = Yii::$app->request->getPathInfo();
                $merchantPrefix = 'merchant'; // Prefix for merchant routes

                if ($path === $merchantPrefix || strpos($path, $merchantPrefix . '/') === 0) {
                    return 'merchant';
                }
            }
        } catch (\Exception $e) {
        }
        return 'admin';
    }

    /**
     * Applies context-specific idParam and identityCookie configurations.
     */
    public function applyContext()
    {
        $context = $this->getCurrentContext();
        
        if ($this->_lastContext !== $context) {
            $env = defined('YII_ENV') ? '_' . YII_ENV : '';

            if ($context === 'merchant') {
                $this->idParam = '__id_merchant';
                $this->identityCookie = [
                    'name' => '_identity_merchant' . $env,
                    'httpOnly' => true,
                ];
            } else {
                $this->idParam = '__id';
                $this->identityCookie = [
                    'name' => '_identity_admin' . $env,
                    'httpOnly' => true,
                ];
            }

            // Invalidate identity cache when switching context
            if ($this->_lastContext !== null) {
                $this->setIdentity(null);
            }

            $this->_lastContext = $context;
        }
    }

    // Intercept entry points to ensure context is applied before execution
    public function getIdentity($autoRenew = true)
    {
        $this->applyContext();
        return parent::getIdentity($autoRenew);
    }

    public function login(\yii\web\IdentityInterface $identity, $duration = 0)
    {
        $this->applyContext();
        return parent::login($identity, $duration);
    }

    public function logout($destroySession = true)
    {
        $this->applyContext();
        return parent::logout($destroySession);
    }

    protected function renewAuthStatus()
    {
        $this->applyContext();
        parent::renewAuthStatus();
    }
}

4. Step 2: The Custom Session Component (common\components\Session.php)

To prevent generic session data collisions (Yii::$app->session->set('key', $val)) and flash alert mismatches (setFlash), we subclass yii\web\Session.

This component automatically prefixes session keys and isolates flash arrays based on the active context:

<?php

namespace common\components;

use Yii;
use yii\web\Session as BaseSession;

class Session extends BaseSession
{
    /**
     * Returns key prefix based on current route context.
     */
    protected function getContextPrefix()
    {
        try {
            if (Yii::$app->has('request')) {
                $path = Yii::$app->request->getPathInfo();
                $merchantPrefix = 'merchant';

                if ($path === $merchantPrefix || strpos($path, $merchantPrefix . '/') === 0) {
                    return 'merchant_';
                }
            }
        } catch (\Exception $e) {
        }
        return '';
    }

    /**
     * Ensures session status is active before reading/writing.
     */
    protected function ensureSessionActive()
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            @session_start();
        }
    }

    // --- Session Key Getters & Setters ---

    public function get($key, $defaultValue = null)
    {
        $this->ensureSessionActive();
        return parent::get($this->getContextPrefix() . $key, $defaultValue);
    }

    public function set($key, $value)
    {
        $this->ensureSessionActive();
        parent::set($this->getContextPrefix() . $key, $value);
    }

    public function remove($key)
    {
        $this->ensureSessionActive();
        return parent::remove($this->getContextPrefix() . $key);
    }

    public function has($key)
    {
        $this->ensureSessionActive();
        return parent::has($this->getContextPrefix() . $key);
    }

    // --- Flash Messages Isolation ---

    public function setFlash($key, $value = true, $removeAfterAccess = true)
    {
        $this->ensureSessionActive();
        $param = $this->getContextPrefix() . $this->flashParam;

        $counters = isset($_SESSION[$param]) && is_array($_SESSION[$param]) ? $_SESSION[$param] : [];
        $counters[$key] = $removeAfterAccess ? -1 : 0;
        $_SESSION[$param] = $counters;
        $_SESSION[$param][$key] = $value;
    }

    public function getFlash($key, $defaultValue = null, $delete = false)
    {
        $this->ensureSessionActive();
        $param = $this->getContextPrefix() . $this->flashParam;

        $counters = isset($_SESSION[$param]) && is_array($_SESSION[$param]) ? $_SESSION[$param] : [];
        if (!isset($counters[$key])) {
            return $defaultValue;
        }

        $value = isset($_SESSION[$param][$key]) ? $_SESSION[$param][$key] : $defaultValue;
        if ($delete) {
            $this->removeFlash($key);
        }

        return $value;
    }

    public function getAllFlashes($delete = false)
    {
        $this->ensureSessionActive();
        $param = $this->getContextPrefix() . $this->flashParam;

        $counters = isset($_SESSION[$param]) && is_array($_SESSION[$param]) ? $_SESSION[$param] : [];
        $flashes = [];
        foreach ($counters as $key => $count) {
            if (isset($_SESSION[$param][$key])) {
                $flashes[$key] = $_SESSION[$param][$key];
                if ($delete) {
                    unset($counters[$key], $_SESSION[$param][$key]);
                }
            }
        }
        if ($delete) {
            $_SESSION[$param] = $counters;
        }

        return $flashes;
    }

    public function removeFlash($key)
    {
        $this->ensureSessionActive();
        $param = $this->getContextPrefix() . $this->flashParam;

        $counters = isset($_SESSION[$param]) && is_array($_SESSION[$param]) ? $_SESSION[$param] : [];
        $value = isset($_SESSION[$param][$key]) ? $_SESSION[$param][$key] : null;
        unset($counters[$key], $_SESSION[$param][$key]);
        $_SESSION[$param] = $counters;

        return $value;
    }

    public function hasFlash($key)
    {
        return $this->getFlash($key) !== null;
    }
}

5. Step 3: Application Configuration (config/main.php)

Configure your custom components in backend/config/main.php (or your relevant configuration file):

return [
    'components' => [
        // 1. Custom Session Component
        'session' => [
            'class' => 'common\components\Session',
            'timeout' => 86400 * 30,
            'name' => 'advanced_backend_session',
            'cookieParams' => [
                'httpOnly' => true,
                'secure' => false,
                'lifetime' => 3600 * 24 * 30,
            ],
        ],

        // 2. Custom User Component
        'user' => [
            'class' => 'common\components\User',
            'identityClass' => 'common\models\User',
            'enableAutoLogin' => true,
            'identityCookie' => [
                'name' => '_identity_admin',
                'httpOnly' => true,
            ],
        ],
        
        // ... rest of your application components
    ],
];

6. Key Benefits & Takeaways

  1. Zero Code Mutation Across Existing Project Code:
    • Developers continue calling Yii::$app->user->identity, Yii::$app->user->id, Yii::$app->session->set(), and Yii::$app->session->setFlash() standardly without modifying views, controllers, or widgets.
  2. True Concurrent Multi-User Authentication:
    • Users can be signed into an Admin account in Tab 1 and a Merchant account in Tab 2 simultaneously within the same browser.
  3. 100% RBAC & Cache Compatibility:
    • Standard RBAC managers (yii\rbac\DbManager or custom cached managers) key permission queries by $userId (userAccessCheck:userId:permission), preserving full isolation.
Conclusion

By leveraging object-oriented extension points in Yii2's core architecture (yii\web\User and yii\web\Session), you achieve robust multi-tenant authentication and session isolation elegantly. Happy coding!