📜 ⬆️ ⬇️

Sharing Authentication yii1 / yii2

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 branches on yii1 and its new version on yii2 work together on the same domain in the same virtual host, and the migration is performed 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, you can simply copy / rearrange 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.


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.

For this:

1. Change the class of the user component, which is responsible for managing the state of authentication, 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 .

 public function init() { //  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)
 namespace app\models; use yii\web\User; class WebUser extends User { /** *    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', ]; public function init() { //  idParam (    //    ) $this->idParam = $this->getIdParamYii1(); } } 

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

 /** *     Yii1,    ID  */ public function getIdParamYii1() { return $this->getStateKeyPrefix() . '__id'; } /** *    Yii 1 * @return string */ public function getStateKeyPrefix() { 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 */ public function getAppIdYii1() { if ($this->paramsYii1['appId']) return $this->paramsYii1['appId']; return $this->paramsYii1['appId'] = sprintf('%x', crc32($this->getBasePathYii1() . $this->paramsYii1['appName'])); } /** * @return string    Yii1 */ private function getBasePathYii1() { $basePath = realpath(\Yii::getAlias('@app') . DIRECTORY_SEPARATOR . $this->paramsYii1['relPath']); if (!$basePath) throw new InvalidConfigException('basePath  yii1  .'); return $basePath; } 

Only for the “ reconcile session key format ” task, the decomposition of methods is a bit complicated, but they will be useful for the examples below.

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:

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

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

validatePassword ()
 public function validatePassword($password, $hash) { if (!is_string($password) || $password === '') { throw new 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 ) { throw new InvalidArgumentException('Hash is invalid.'); } if (function_exists('password_verify')) { return password_verify($password, $hash); } $test = crypt($password, $hash); $n = strlen($test); if ($n !== 60) { return false; } return $this->compareString($test, $hash); } 

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

 public function hashPassword($password) { return CPasswordHelper::hashPassword($password); } 

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

hashPassword ()
 public static function hashPassword($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) throw new 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.


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. Set identityCookie name in accordance with yii1

 public function init() { $this->idParam = $this->getIdParamYii1(); //     $this->identityCookie = ['name' => $this->getStateKeyPrefix()]; } 


2. Add two more methods.

 /** *    */ protected function getIdentityAndDurationFromCookie() { $id = $this->getIdIdentityFromCookiesYii1(); if (!$id) { return null; } $class = $this->identityClass; $identity = $class::findOne($id); if ($identity !== null) { return ['identity' => $identity, 'duration' => 0]; } return null; } /** *  ID identity  cookies,   yii1 * * @return null|integer  ID     null */ protected function getIdIdentityFromCookiesYii1() { if (!isset($_COOKIE[$this->identityCookie['name']])) return null; $cookieValue = $_COOKIE[$this->identityCookie['name']]; // Cookies  yii1 ,  $utilSecurity = new UtilYii1Security($this->getBasePathYii1()); $data = $utilSecurity->validateData($cookieValue); if ($data === false) { return null; } $data = @unserialize($data); if (is_array($data) && isset($data[0], $data[1], $data[2], $data[3])) { list($id, $name, $duration, $states) = $data; return $id; } return null; } 


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
 <?php namespace app\components; /* *   CSecurityManager  Yii1, *     * */ use yii\base\Exception; use yii\base\Model; use yii\base\InvalidConfigException; class UtilYii1Security { /** * ,   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 */ public function __construct($basePath) { $this->basePath = $basePath; $this->stateFile = $this->basePath . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'state.bin'; if (!realpath($this->stateFile)) throw new InvalidConfigException('    '); } public function validateData($data, $key = null) { if (!is_string($data)) return false; $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; } else return false; } public function computeHMAC($data, $key = null) { if ($key === null) $key = $this->getValidationKey(); return hash_hmac($this->hashAlgorithm, $data, $key); } public function getValidationKey() { if ($this->_validationKey !== null) return $this->_validationKey; if (($key = $this->loadStateValidationKey(self::STATE_VALIDATION_KEY)) !== null) { $this->_validationKey = $key; } return $this->_validationKey; } //  validationKey    Yii1 private function loadStateValidationKey($key) { $content = $this->loadState(); if ($content) { $content = unserialize($content); if (isset($content[$key])) return $content[$key]; } return false; } //      Yii1 protected function loadState() { $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; } return false; } public function compareString($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; } private function strlen($string) { return mb_strlen($string, '8bit'); } private function substr($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 glad if someone, looking at my experience, will save it.

Source: https://habr.com/ru/post/419999/


All Articles