Sharing Authentication yii1 / yii2

  • Tutorial
image

This article does not make sense without the first part , in which there is an answer “why to do it”.

It is about the method of smooth project migration from yii1 to yii2. Its essence is that the project's branches on yii1 and its new version on yii2 work together on the same domain in the same virtual host, and the migration is done gradually, in small steps (pages, controllers, modules, etc.).

The first part was about how to run a bare project on yii2 in an existing virtual host, i.e. make both branches work together without interfering with each other.

After that, the most psychologically difficult stage begins: you need to create a minimal infrastructure for starting. I would highlight 2 tasks: duplicate design and pass-through user authentication.

Duplication of design is given first place for dullness. If you are unlucky, then you can simply copy / redirect the old “1 to 1”. Personally, I always combined with the redesign. Those. The interface and design has been significantly updated and in this regard, the work is not stupid. But here to each his own - I pay a lot of attention to the interface and design, on the contrary, someone loves more backend and console. Nevertheless, regardless of preferences, not to pass this task - you have to make the interface, and the amount of work will be quite large.

Pass-through authentication is a bit more interesting, and there will be less work. As in the first article, there will be no revelations here. The nature of the article: tutorial for those who solve this problem for the first time.

If this is your case, then more under the cat

First of all, it is necessary to divide the functionality between the branches. Since it is assumed that the migration stage only at the start, then, most likely, all work with users (registration, authentication, password recovery, etc.) remain on the old site. A new branch on yii2 should simply see already authenticated users.

The yii1 / yii2 authentication mechanisms are slightly different and you need to tweak the yii2 code so that it can see the already authenticated user. Since Authentication data is stored in the session, you just need to agree on the parameters for reading session data.

In the session from yii1, it is stored as follows:

print_r($_SESSION);
Array
(
    [34e60d27092d90364d1807021107e5a3__id] => 123456
    [34e60d27092d90364d1807021107e5a3__name] => tester
    [34e60d27092d90364d1807021107e5a3__states] => Array
        (
        )
)

How do you keep it - check, for example, prefixKey is generated differently in different versions of yii1.

Here is the data you need from yii1

Yii::app()->user->getStateKeyPrefix()
Yii::app()->name
Yii::app()->getBasePath()
Yii::app()->getId()
get_class(Yii::app()->user)

The easiest way is to make a test page and show all the necessary data on it - they will be needed later.

Authentication in yii2


In Yii2, all the functionality we need is in the user ( \ yii \ web \ User ) component that controls the authentication state.

  • In his method getIdentity () is called renewAuthStatus () , in which the authentication session is searched by a key from the variable $ idParam (default '__id' stored there);
  • The session variable for the key $ idParam stores the user id (for example, from app / model / User ).

The authentication algorithm is described in detail in the official manual .

Of course, in yii1 sessions are saved using a different key. Therefore, you need to make it so that yii2 searches for a user ID using the same keys with which it is stored in yii1.

To do this:

1. Change the class of the user component, which is responsible for managing the authentication state, to its own, inherited from yii / web / User in config / web.php

'components' => [
        'user' => [
            'class' => 'app\models\WebUser',
            'identityClass' => 'app\models\User',
        ],
]

2. Adjust the value of $ idParam in app \ models \ WebUser .

publicfunctioninit(){
        // Меняем idParam (ключ по которому производится // поиск аутентификации в сессии)$this->idParam = $this->getIdParamYii1();
}

Under the spoiler there will be a few methods that somehow emulate this behavior from yii1.

In general, it would be possible to simply copy the original _keyPrefix (or even idParam right away ) from yii1 and not emulate its generation, but then it would be similar to the “copy incomprehensible garbage” instruction.

You can really copy it because _keyPrefix in yii1 is almost static. It depends on the class name of the user component and on the application ID, which in turn is obtained from the location of the application and its name.

// Так в yii1 генерирует _keyPrefix$this->_keyPrefix = md5('Yii.'.get_class($this).'.'.Yii::app()->getId());
// А так - ID приложения$this->_id=sprintf('%x',crc32($this->getBasePath().$this->name));

If we restrict ourselves only to the task of authentication, then copying the _keyPrefix value significantly reduces the amount of work. But I will have examples for wider use.

The user component (app \ models \ WebUser)
namespaceapp\models;
useyii\web\User;
classWebUserextendsUser{
    /**
     * Отключить пересоздание аутентификационных cookies в yii2, 
    *  пока аутентификация производится в yii1 
     */public $autoRenewCookie = false;
    /**
     * _keyPrefix по аналогии с CWebUser из Yii1
     */private $_keyPrefix;
    /**
     * Набор параметров от Yii1, необходимых для аутентификации
    */private $paramsYii1 = [
        // Имя класса компонента user из конфига Yii1'classUserComponent' => 'CWebUser',
        // ID приложения Yii1 // Можно скопировать, выполнив Yii::app()->getId()'appId' => '',
        // Название приложения из конфига Yii1'appName' => 'My Web Application',
        // Относительный путь к приложению yii1 от \Yii::getAlias('@app')'relPath' => '../htdocs/protected',
    ];
    publicfunctioninit(){
        // Меняем idParam (ключ по которому производится // поиск аутентификации в сессии)$this->idParam = $this->getIdParamYii1();
    }
}

And additional methods to it (WebUser). Separated for easy viewing.

/**
 * Ключ для сессии из Yii1, по которому хранится ID пользователя
*/publicfunctiongetIdParamYii1(){
    return$this->getStateKeyPrefix() . '__id';
}
/**
* Модифицированный метод из Yii 1
* @return string 
*/publicfunctiongetStateKeyPrefix(){
    if ($this->_keyPrefix !== null)
        return$this->_keyPrefix;
    $class = $this->paramsYii1['classUserComponent'];
    return$this->_keyPrefix = md5('Yii.' . $class . '.' . $this->getAppIdYii1());
}
/**
* Эмуляция метода getId() из CApplication
* @return string ID приложения Yii1
*/publicfunctiongetAppIdYii1(){
    if ($this->paramsYii1['appId'])
        return$this->paramsYii1['appId'];
    return$this->paramsYii1['appId'] = sprintf('%x', crc32($this->getBasePathYii1() . $this->paramsYii1['appName']));
}
/**
* @return string Путь к приложению Yii1
*/privatefunctiongetBasePathYii1(){
    $basePath = realpath(\Yii::getAlias('@app') . DIRECTORY_SEPARATOR . $this->paramsYii1['relPath']);
    if (!$basePath)
       thrownew InvalidConfigException('basePath для yii1 задан неверно.');
    return $basePath;
}

Только для задачи "согласовать формат сессионного ключа" декомпозиция методов немного усложненная, но они пригодятся для примеров ниже.

After that, in the new branch on yii2, recognition of users previously authorized in yii1 begins to work. Ideally, this should be stopped, because the slippery slope begins further.

User login to yii2


After the storage format in the session of the user ID has been agreed upon, it is possible that even “automatically” the Login user will work through yii2.

I think it’s wrong to use the login form on yii2 in combat mode without disabling the corresponding form on yii1, however, the basic functionality will be working after small approvals.

Since We believe that while user registration and, accordingly, password hashing, is left in usi1, then for a working login through yii2 we need to make sure that the validation method of the saved password in yii2 can understand what was hashed and saved in Yii1.

Check out what these methods do.

For example, if in Yii2 a conditionally standard User user model validates a password like this:

publicfunctionvalidatePassword($password){
    return \Yii::$app->getSecurity()->validatePassword($password, $this->password);
}

That, look validatePassword method ($ password, $ hash) from yii \ base \ Security (Yii2)

validatePassword ()
publicfunctionvalidatePassword($password, $hash){
        if (!is_string($password) || $password === '') {
            thrownew InvalidArgumentException('Password must be a string and cannot be empty.');
        }
        if (!preg_match('/^\$2[axy]\$(\d\d)\$[\.\/0-9A-Za-z]{22}/', $hash, $matches)
            || $matches[1] < 4
            || $matches[1] > 30
        ) {
            thrownew InvalidArgumentException('Hash is invalid.');
        }
        if (function_exists('password_verify')) {
            return password_verify($password, $hash);
        }
        $test = crypt($password, $hash);
        $n = strlen($test);
        if ($n !== 60) {
            returnfalse;
        }
        return$this->compareString($test, $hash);
    }

And if on Yii1, password hashing in the User model is done like this:

publicfunctionhashPassword($password){
        return CPasswordHelper::hashPassword($password);
}

Then compare with verifyPassword ($ password, $ hash) from yii \ framework \ utils \ CPasswordHelper

hashPassword ()
publicstaticfunctionhashPassword($password,$cost=13){
    self::checkBlowfish();
    $salt=self::generateSalt($cost);
    $hash=crypt($password,$salt);
    if(!is_string($hash) || (function_exists('mb_strlen') ? mb_strlen($hash, '8bit') : strlen($hash))<32)
        thrownew CException(Yii::t('yii','Internal error while generating hash.'));
    return $hash;
}


If the hashing and validation methods are different, then you need to change the validation in validatePassword () from app \ model \ User .

From the boxes of the latest versions of the framework, password hashes are Yii1 / Yii2 compatible. But this does not mean that they will be compatible with you or that will coincide in the future. With a high degree of probability, the project hashing methods in Yii1 and validation in the new project on Yii2 will differ.

Autologin in Yii2 for the cookies from yii1


If the branch on Yii2 already knows how to transparently use the authentication data of users from Yii1, then why not set up autologin for cookies?
If you get the idea, then I advise you to give it up. I do not see any compelling reason to include autologin on Yii2 without transferring to work with users on this thread (authentication, first of all). Those. I mean the following case:

user authentication is performed on Yii1, but the Yii2 branch should be able to autologin the cookies stored in Yii1.

To be honest, this is a frank perversion. It will not be easy and elegant to do this, and here lies the line between the conscious and justified task of migration and the invention of unnecessary bicycles.

The difficulty is that in both branches Yii is protected from counterfeit cookies, so it’s difficult to reconcile the methods.

  • Components are involved in Yii2: user ( \ yii \ web \ User ), Request , Security + CookieCollection
  • In Yii1: CWebUser , CHttpRequest , CSecurityManager , CStatePersister , CCookieCollection

However, the cases are different. Below is an example of how to make autologin with bikes.

In yii2, we are interested in the getIdentityAndDurationFromCookie () method from \ yii \ web \ User . In the first line of this method, the correct cookie should be obtained:

$value = Yii::$app->getRequest()->getCookies()->getValue($this->identityCookie['name']);

But it will not, because the Yii :: $ app-> getRequest () -> getCookies () collection will be empty, because Request Cookies are loaded with validation into loadCookies () and, of course, they do not pass.

The easiest way to fork a standard behavior is to rewrite getIdentityAndDurationFromCookie () . For example:

  1. Download the desired cookie directly from the $ _COOKIE superglobal, in order not to break the standard cookie loading mechanism.

    The name of the identification cookie is just _keyPrefix, which we are already able to receive (or copied). Therefore, we change the standard $ identityCookie in init () .
  2. Decrypt the resulting cookie "approximately as in yii1". As you wish. For example, I copied the necessary methods from CSecurityManager .

Below, in fact, the code.

We work in app / models / WebUser
1. Имя identityCookie куки ставим в соответствии с yii1

publicfunctioninit(){
    $this->idParam = $this->getIdParamYii1();
    // Меняем имя идентификационной куки$this->identityCookie = ['name' => $this->getStateKeyPrefix()];
}


2. Добавляем еще два метода

/**
 * Переписываем оригинальный метод      
 */protectedfunctiongetIdentityAndDurationFromCookie(){
    $id = $this->getIdIdentityFromCookiesYii1();
    if (!$id) {
        returnnull;
    }
    $class = $this->identityClass;
    $identity = $class::findOne($id);
    if ($identity !== null) {
        return ['identity' => $identity, 'duration' => 0];
    }
    returnnull;
}
/**
* Вытащить ID identity из cookies, сохраненных в yii1
* 
* @return null|integer Возращает ID пользователя если определено или null
*/protectedfunctiongetIdIdentityFromCookiesYii1(){
    if (!isset($_COOKIE[$this->identityCookie['name']]))
        returnnull;
    $cookieValue = $_COOKIE[$this->identityCookie['name']];
    // Cookies в yii1 шифрованы, расшифровываем
    $utilSecurity = new UtilYii1Security($this->getBasePathYii1());
    $data = $utilSecurity->validateData($cookieValue);
    if ($data === false) {
       returnnull;
    }
    $data = @unserialize($data);
    if (is_array($data) && isset($data[0], $data[1], $data[2], $data[3])) {
        list($id, $name, $duration, $states) = $data;
        return $id;
    }
    returnnull;
}


The code uses a certain UtilYii1Security class - this is a modified copy-paste of the necessary methods from the CSecurityManager , so that on the one hand it looks like the original, but with simplifications. For example, in CSecurityManager there are several options for HMAC generation (hash-based message authentication code), which depend on the version of php and the presence of mbstring. But since it is known that yii1 works in the same environment as yii2, the task is simplified and, accordingly, the code too.

Since it is clear that a clear crutch is being written here, then you should not try to make it universal and give a good form, it is enough to “sharpen” it under your conditions.

UtilYii1Security.php
<?phpnamespaceapp\components;
/*
 * Эмуляция методов CSecurityManager из Yii1, 
 * необходимых для сквозной аутентификации * 
 */useyii\base\Exception;
useyii\base\Model;
useyii\base\InvalidConfigException;
classUtilYii1Security{
    /**
     * Константа, как в yii1
     */const STATE_VALIDATION_KEY = 'Yii.CSecurityManager.validationkey';
    /**
     * Алгориим хеширования, по умолчанию в yii1
     */public $hashAlgorithm = 'sha1';
    /**
     * Ключ валидации cookies
     */private $_validationKey;
    /**
     * Путь к приложению Yii1
     */private $basePath;
    /**
     * Путь к файлу состояний в yii1
     */private $stateFile;
    /**
     * @param string $basePath - путь к приложению Yii1
     * Нужен для определения пути к файлу состояний stateFile
     */publicfunction__construct($basePath){
        $this->basePath = $basePath;
        $this->stateFile = $this->basePath . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'state.bin';
        if (!realpath($this->stateFile))
            thrownew InvalidConfigException('Путь к файлу состояний неверен');
    }
    publicfunctionvalidateData($data, $key = null){
        if (!is_string($data))
            returnfalse;
        $len = $this->strlen($this->computeHMAC('test'));
        if ($this->strlen($data) >= $len) {
            $hmac = $this->substr($data, 0, $len);
            $data2 = $this->substr($data, $len, $this->strlen($data));
            return$this->compareString($hmac, $this->computeHMAC($data2, $key)) ? $data2 : false;
        } elsereturnfalse;
    }
    publicfunctioncomputeHMAC($data, $key = null){
        if ($key === null)
            $key = $this->getValidationKey();
        return hash_hmac($this->hashAlgorithm, $data, $key);
    }
    publicfunctiongetValidationKey(){
        if ($this->_validationKey !== null)
            return$this->_validationKey;
        if (($key = $this->loadStateValidationKey(self::STATE_VALIDATION_KEY)) !== null) {
            $this->_validationKey = $key;
        }
        return$this->_validationKey;
    }
    // Загрузить validationKey из файла состояний Yii1privatefunctionloadStateValidationKey($key){
        $content = $this->loadState();
        if ($content) {
            $content = unserialize($content);
            if (isset($content[$key]))
                return $content[$key];
        }
        returnfalse;
    }
    // Получаем данные их хранилища состояний Yii1protectedfunctionloadState(){
        $filename = $this->stateFile;
        $file = fopen($filename, "r");
        if ($file && flock($file, LOCK_SH)) {
            $contents = @file_get_contents($filename);
            flock($file, LOCK_UN);
            fclose($file);
            return $contents;
        }
        returnfalse;
    }
    publicfunctioncompareString($expected, $actual){
        $expected .= "\0";
        $actual .= "\0";
        $expectedLength = $this->strlen($expected);
        $actualLength = $this->strlen($actual);
        $diff = $expectedLength - $actualLength;
        for ($i = 0; $i < $actualLength; $i++)
            $diff |= (ord($actual[$i]) ^ ord($expected[$i % $expectedLength]));
        return $diff === 0;
    }
    privatefunctionstrlen($string){
        return mb_strlen($string, '8bit');
    }
    privatefunctionsubstr($string, $start, $length){
        return mb_substr($string, $start, $length, '8bit');
    }
}


Sequencing


When migrating from yii1 to yii2 in terms of authentication, I followed the following sequence:

  1. Make transparent user authentication between branches.
    Those. so that the branch on yii2 accepts users authenticated in yii1. It is fast, not difficult.
  2. Transfer authentication (user login) from yii1 to yii2.
    At the same time turning it off in the old branch. Notice that after this autologin will stop working in the cookies, since cookies from yii1 are no longer suitable, and new pages on yii2 are still few.
  3. Port to yii2 at least the main page of the site
    So that you can use autologin for new cookies stored in yii2.
    Having autologin at least on the main one will help to mask the missing autologin on the previous branch.
  4. Check that yii1 understands authenticated in yii2.
    By negotiating session keys.
  5. Transfer user registration to yii2.
    The transfer should be done with the coordination of previously saved password hashes. Maybe save the old hash format or enter a new one, but so that the login understands both types.
  6. Whether to think about whether to add to the users of the site a service that gives yii2 out of the box.
    I mean the implementation of the IdentityInterface interface for the User , which allows authentication by token, password recovery, etc. Perhaps you already have the appropriate harness, but suddenly not? Then this is a great option to improve the service with minimal effort.

    If “yes”, then this will result in the implementation (migration) of a personal account in yii2 (at least partially).
    If “no”, then still think about the migration of your personal account (even without new products).

PS:


There are described not always unambiguous solutions in a specific task and not all of them need to be applied.

They are described not for the purpose of saying “do as I do.” For example, it is possible to do autologin in yii1 in yii2 - this is possible, but, to put it mildly, not good (and such a crutch should be justified by something).

But I have already spent on this time during the step-by-step migration of projects and will be happy if someone, looking at my experience, will save it.

Also popular now: