Совместное использование аутентификации yii1/yii2

  • Tutorial
image

Это статья не имеет смысла без первой части, в которой есть ответ «зачем это делать».

Она про методику плавной миграцию проекта с yii1 на yii2. Ее суть в том, что ветки проекта на yii1 и его новой версии на yii2 работают совместно на одном домене в одном виртуальном хосте, а миграция производится постепенно, мелкими шагами (по страницам, контролерам, модулям и т.п.).

Первая часть была про то, как голый проект на yii2 запустить в существующем виртуальном хосте, т.е. заставить обе ветки работать совместно не мешая друг другу.

После чего начинается самый психологически сложный этап: нужно создать минимальную инфраструктуру для старта. Я бы выделил 2 задачи: дублированный дизайн и сквозную аутентификацию пользователей.

Дублированию дизайна отводится первое место по скучности. Если не повезло, то можно просто скопировать / переверстать старый «1 в 1». Лично я всегда совмещал с редизайном. Т.е. интерфейс и дизайн значительно обновлялся и в этом плане, работа не тупая. Но тут каждому свое – я большое внимание уделяю интерфейсу и дизайну, кто-то наоборот, любит больше backend и консоль. Тем не менее, не зависимо от предпочтений, мимо этой задачи не пройти — сделать интерфейс придется, и объем работы будет достаточно большой.

Сквозная аутентификация немного интереснее, а работы будет поменьше. Как и в первой статье, тут не будет никаких откровений. Характер статьи: tutorial для тех, кто решает такую задачу в первый раз.

Если это ваш случай, то подробнее под катом

В первую очередь надо разделить функционал между ветками. Т.к. подразумевается, что этап миграции только на старте, то, скорее всего, вся работа с пользователями (регистрация, аутентификация, восстановление пароля и т.п.) остаются на старом сайте. А новая ветка на yii2 должна просто видеть уже аутентифицированных пользователей.

Механизмы аутентификации yii1/yii2 немного различаются и надо подстроить код yii2, чтобы он мог видеть уже аутентифицированного пользователя. Т.к. данных об аутентификации хранятся в сессии, то надо всего лишь надо согласовать параметры чтения сессионных данных.

В сессии от yii1 это хранится как-то так:

print_r($_SESSION);

Array
(
    [34e60d27092d90364d1807021107e5a3__id] => 123456
    [34e60d27092d90364d1807021107e5a3__name] => tester
    [34e60d27092d90364d1807021107e5a3__states] => Array
        (
        )
)

Как хранится у вас – проверьте, например, prefixKey генерируется по разному в разных версиях yii1.

Вот такие вам данные понадобятся из yii1

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

Проще всего, сделать тестовую страницу и показать на ней все нужные данные — они понадобятся в дальнейшем.

Аутентификация в yii2


В Yii2 весь нужный нам функционал находится в компоненте user (\yii\web\User), который управляет состоянием аутентификации.

  • В его методе getIdentity() вызывается renewAuthStatus(), в котором сессия аутентификации ищется по ключу из переменной $idParam (по умолчанию там хранится '__id');
  • В сессионной переменной по ключу $idParam хранится id пользователя (например, от app/model/User).

Алгоритм аутентификации подробно описан в официальном руководстве.

Разумеется, в yii1 сессии сохраняются по другому ключу. Поэтому нужно сделать так, чтобы yii2 искал ID пользователя по тем же самым ключам, с которым он сохраняется в yii1.

Для этого:

1. Меняем класс компонента user, отвечающий за управление состоянием аутентификации, на свой собственный, унаследованный от yii/web/User в config/web.php

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

2. Корректируем в app\models\WebUser значение $idParam.

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

Под спойлером будет немножко методов, которые как-бы эмулируют подобное поведение из yii1.

Вообще можно было бы просто скопировать оригинальный _keyPrefix (или даже сразу idParam) из yii1 и не эмулировать его генерацию, но тогда это было бы похоже на инструкцию «скопируйте непонятную фигню».

Скопировать, действительно, можно потому что _keyPrefix в yii1 почти статический. Он зависит от имени класса компонента user и от ID приложения, который в свою очередь получается из места места размещения приложения и его названия.

// Так в yii1 генерирует _keyPrefix
$this->_keyPrefix = md5('Yii.'.get_class($this).'.'.Yii::app()->getId());

// А так - ID приложения
$this->_id=sprintf('%x',crc32($this->getBasePath().$this->name));

Если ограничиться только задачей аутентификации, то копирование значения _keyPrefix значительно сокращает объем работы. Но у меня будут примеры для более широкого использования.

Компонент user (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();
    }

}

И дополнительные методы к нему же (WebUser). Отделил для удобства просмотра.

/**
 * Ключ для сессии из 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;
}

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

После чего в новой ветке на yii2 начинает работать узнавание пользователей предварительно авторизованных в yii1. В идеале, на этом надо бы и остановится, потому что дальше начинается скользкий путь.

Login пользователя в yii2


После того, как согласован формат хранения в сессии ID пользователя, возможно, даже «автоматически» заработает Login пользователя через yii2.

Использовать форму login-а на yii2 в боевом режиме без отключения соответствующей формы на yii1 считаю неправильным, тем не менее, базовый функционал будет рабочим после небольших согласований.

Т.к. считаем, что пока регистрация пользователей, а соответственно и хеширование паролей у нас осталось в уii1, то для рабочего login через yii2 нам надо сделать так, чтобы метод валидации сохраненного пароля в yii2 мог понять что было захешировано и сохранено в Yii1.

Проверьте, что делают эти методы.

Например, если в Yii2 условно стандартная модель пользователя User валидирует пароль так:

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

То, смотреть метод validatePassword($password, $hash) из 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);
    }

А если на Yii1 хеширование пароля в модели User сделано так:

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

То сравнивать с verifyPassword($password, $hash) из 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;
}


Если методы хеширования и валидации различаются, то нужно поменять валидацию в validatePassword() из app\model\User.

Из коробок последних версий фрейморка хеши паролей Yii1/Yii2 совместимы. Но это совсем не означает, что они будут совместимы у вас или что будет совпадать в дальнейшем. С большой степенью вероятности, методы хеширования проекта в Yii1 и валидации в новом проекте на Yii2 будут различаться.

Автологин в Yii2 по кукам из yii1


Раз ветка на Yii2 уже умеет прозрачно пользоваться аутентификационными данными пользователей от Yii1, то почему бы не настроить автологин по кукам?
Если вас постигнет такая мысль, то я советую от нее отказаться. Не вижу ни одной веской причины включать автологин на Yii2 без переноса на эту ветку работы с пользователями (аутентификации, в первую очередь). Т.е. имею ввиду следующий случай:

аутентификация пользователя осуществляется на Yii1, но ветка Yii2 должна уметь делать автологин по кукам, сохраненным в Yii1.

Если честно, это откровенное извращение. Легко и изящно сделать это не получится и как раз здесь проходит грань между осознанной и оправданной задачей по миграции и изобретением ненужных велосипедов.

Сложность в том, что в обоих ветках Yii защищается от подделки кук, поэтому согласовать методы сложновато.

  • В Yii2 задействованы компоненты: user (\yii\web\User), Request, Security + CookieCollection
  • В Yii1: CWebUser, CHttpRequest, CSecurityManager, CStatePersister, CCookieCollection

Тем не менее, случаи бывают разные. Ниже будет пример как сделать автологин с велосипедами.

В yii2 нас интересует метод getIdentityAndDurationFromCookie() из \yii\web\User. В первой же строке этого метода должна быть получена нужная кука:

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

Но ее не будет, т.к. коллекция Yii::$app->getRequest()->getCookies() будет пустой, потому что в Request cookies загружаются с валидацией в loadCookies() и, конечно, ее не проходят.

Проще всего сделать форк стандартного поведения, переписав getIdentityAndDurationFromCookie(). Например, так:

  1. Загрузить нужную cookie напрямую из суперглобального $_COOKIE, чтобы не ломать адаптировать стандартный механизм загрузки cookies.

    Имя идентификационной куки — это как раз _keyPrefix, который уже умеем получать (или скопировали). Поэтому меняем стандартный $identityCookie в init().
  2. Расшифровать полученную куку «примерно, как в yii1». Как угодно. Например, я скопировал нужные методы из CSecurityManager.

Ниже, собственно, код.

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

public function init() {
    $this->idParam = $this->getIdParamYii1();

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


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

    
/**
 * Переписываем оригинальный метод      
 */
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;
}


В коде используется некий класс UtilYii1Security – это модифицированный копипаст нужных методов из CSecurityManager, чтобы с одной стороны было похоже на оригинал, но с упрощениями. Например, в CSecurityManager несколько вариантов генерации HMAC (hash-based message authentication code), которые зависят от версии php и наличия mbstring. Но т.к. известно, что yii1 работает в том же окружении, что и yii2, то задача упрощается и, соответственно, код тоже.

Т.к. совершенно ясно, что здесь пишется явный костыль, то и не надо пытаться делать его универсальным и придавать благообразную форму, достаточно «заточить» его под свои условия.

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');
    }

}



Последовательность действий


При миграции с yii1 на yii2 в части аутентификации я придерживался следующей последовательности:

  1. Сделать прозрачную аутентификацию пользователей между ветками.
    Т.е. чтобы ветка на yii2 принимала пользователей, аутентифицированных в yii1. Это быстро, не сложно.
  2. Перенести аутентификацию (login пользователя) из yii1 в yii2.
    Одновременно отключив ее в старой ветке. Заметьте, что после этого перестанет работать автологин по кукам, т.к. куки от yii1 уже не подходят, а новых страниц на yii2 еще мало.
  3. Портировать на yii2 хотя бы главную страницу сайта
    Чтобы можно было задействовать автологин по новым куках, сохраняемым в yii2.
    Наличие автологина хотя бы на главной поможет замаскировать пропавший автологин на прежней ветке.
  4. Проверить чтобы yii1 понимал аутентифицированных в yii2.
    Через согласование ключей сессии.
  5. Перенести в yii2 регистрацию пользователей.
    Перенос надо делать со согласованием ранее сохраненных хешей паролей. Может быть, сохранить старый формат хеша или ввести новый, но так чтобы login понимал оба типа.
  6. Подумать не добавить ли пользователям сайта сервис который дает yii2 «из коробки».
    Имею ввиду реализация интерфейса IdentityInterface у User, который дает возможности аутентификация по токену, восстановление пароля и т.п. Возможно, у вас уже есть соответствующая обвязка, но вдруг нет? Тогда это отличный вариант улучшить сервис с минимальными усилиями.

    Если «да», то из этого последует реализация (миграция) личного кабинет в yii2 (хотя бы частично).
    Если «нет», то все равно подумать про миграцию личного кабинета (даже без новинок).

PS:


Тут описаны не всегда однозначные решения в специфической задаче и не все их нужно применять.

Они описаны не с целью сказать — «делай как я». Например, делать в yii2 автологин по кукам из yii1 — это возможно, но, мягко говоря, не хорошо (и такой костыль должен быть чем-то оправдан).

Но я уже потратил на это время при пошаговой миграции проектов и будут рад, если кто-то, глядя на мой опыт, его сэкономит.
Поделиться публикацией

Похожие публикации

Комментарии 0

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое