Laravel: Аутентификация

Стоит признать, аутентификация в Laravel сделана очень просто. Это большое преимущество фреймворка по сравнению с той же Symfony, которая до недавнего времени имела достаточно сложный и тяжело настраиваемый компонент Security, который, к слову, недавно переписали.

Гварды, провайдеры, Или как аутентифицировать пользователя вручную

Если у вас есть под рукой установленный Laravel, можете найти в нем файл config/auth.php. Как вы уже догадались, это файл конфигурации, используемый механизмом аутентификации фреймворка. В нем представлены так называемые гварды и провайдеры. Гвард - это инструмент, отвечающий за способ аутентификации: сессия, токены и так далее. Провайдеры описывают, каким способом будет получен юзер: например, из базы данных с помощью ORM (Eloquent) или с помощью обычного соединения к БД.

По умолчанию Laravel предлагает два гварда, это сессия и токен, и два провайдера — eloquent и database. Если вы откроете файл config/auth.php, то должны увидеть следующее:

'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'token' => [
            'driver' => 'token',
            'provider' => 'users',
            'hash' => false,
        ],
    ],

'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
    ],

У нас есть два гварда — это сессия и токен, которые используют один провайдер — eloquent. Например, гвард web (сессионный гвард) использует драйвер session и провайдер users, который в свою очередь использует драйвер eloquent и для маппинга результата использует модель User. Если бы вы не хотели использовать ORM, вы могли бы изменить конфиг следующим образом:

'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'non-orm-users',
        ],
        'token' => [
            'driver' => 'token',
            'provider' => 'users',
            'hash' => false,
        ],
    ],

'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
        'non-orm-users' => [
            'driver' => 'database',
            'table'  => 'users',
        ],
    ],

Теперь фреймворк будет использовать DatabaseServiceProvider и таблицу users.

Представим, вы зарегистрировали пользователя и хотите его аутентифицировать. Вы должны сделать следующее:

use Illuminate\Contracts\Auth\Factory as AuthManager;

final class UserAuthenticator
{
    private AuthManager $authManager;
    public function __construct(AuthManager $authManager)
    {
        $this->authManager = $authManager;
    }

    public function authenticate(UserAuthenticationCredentials $credentials)
    {
        // регистрируем тут или раньше, неважно

        $this->authManager->guard('web')->loginUsingId($user->id, true);
    }
}

Если честно, это все. Если вы используете дефолтную модель User и дефолтные настройки, то это правда все.

Погружаемся глубже

Когда вы в конструкторе запросили Illuminate\Contracts\Auth\Factory, фреймворк дал вам Illuminate\Auth\AuthManager. Метод guard пробует создать для вас гвард или берет дефолтный, если вы не указали ни один, а заодно он сохраняет полученный гвард в локальном кэше на случай, если вы будете несколько раз подряд создавать один и тот же гвард:

public function guard($name = null)
{
    $name = $name ?: $this->getDefaultDriver();

    return $this->guards[$name] ?? $this->guards[$name] = $this->resolve($name);
}

Дефолтный драйвер указывается в конфиге.

В методе resolve мы находим настройки под конкретный гвард.

protected function resolve($name)
    {
        $config = $this->getConfig($name);

        if (is_null($config)) {
            throw new InvalidArgumentException("Auth guard [{$name}] is not defined.");
        }

        if (isset($this->customCreators[$config['driver']])) {
            return $this->callCustomCreator($name, $config);
        }

        $driverMethod = 'create'.ucfirst($config['driver']).'Driver';

        if (method_exists($this, $driverMethod)) {
            return $this->{$driverMethod}($name, $config);
        }

        throw new InvalidArgumentException(
            "Auth driver [{$config['driver']}] for guard [{$name}] is not defined."
        );
    }

Так, например, если вы запросили гвард web, метод this→getConfig($name) вернет следующее:

[
   'driver' => 'session',
   'provider' => 'users',
],

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

if (isset($this->customCreators[$config['driver']])) {
      return $this->callCustomCreator($name, $config);
}

Подробнее о том, как добавить кастомный гвард, читайте в документации.

Если же такого нет, создаем один из существующих гвардов:

$driverMethod = 'create'.ucfirst($config['driver']).'Driver';

if (method_exists($this, $driverMethod)) {
    return $this->{$driverMethod}($name, $config);
}

Например, для гварда web фреймворк вызовет метод createSessionDriver, который находится тут же в этом классе:

public function createSessionDriver($name, $config)
{
    $provider = $this->createUserProvider($config['provider'] ?? null);

    $guard = new SessionGuard($name, $provider, $this->app['session.store'])
     if (method_exists($guard, 'setCookieJar')) {
          $guard->setCookieJar($this->app['cookie']);
     }

     if (method_exists($guard, 'setDispatcher')) {
         $guard->setDispatcher($this->app['events']);
     }

     if (method_exists($guard, 'setRequest')) {
         $guard->setRequest($this->app->refresh('request', $guard, 'setRequest'));
     }

     return $guard;
}

Для каждого гварда нужен провайдер, поэтому фреймворк смотрит на указанный в конфиге для текущего гварда провайдер и пробует его создать:

public function createUserProvider($provider = null)
    {
        if (is_null($config = $this->getProviderConfiguration($provider))) {
            return;
        }

        if (isset($this->customProviderCreators[$driver = ($config['driver'] ?? null)])) {
            return call_user_func(
                $this->customProviderCreators[$driver], $this->app, $config
            );
        }

        switch ($driver) {
            case 'database':
                return $this->createDatabaseProvider($config);
            case 'eloquent':
                return $this->createEloquentProvider($config);
            default:
                throw new InvalidArgumentException(
                    "Authentication user provider [{$driver}] is not defined."
                );
        }
    } 

Так же, как и с гвардом, Laravel для начала смотрит, есть ли под такой провайдер кастомный создатель провайдера. Подробнее про кастомные провайдеры можно почитать в документации. Если кастомного нет, создаем провайдера под нужный драйвер. Возьмем, к примеру, eloquent:

protected function createEloquentProvider($config)
{
    return new EloquentUserProvider($this->app['hash'], $config['model']);
}

Каждый провайдер должен имплементить интерфейс UserProvider. Эти методы помогают гварду доставать юзера, обновлять его токены и валидировать входящие от юзера данные (например, пароль). В то же время каждый гвард обязан имплементить интерфейс Guard.

Теперь давайте соберем все наши знания и посмотрим, как происходит аутентификация. Мы вызываем либо метод loginUsingId, либо сразу метод login, если у нас есть инстанс юзера или любого другого объекта, который имплементит интерфейс Authenticatable. Именно поэтому ваша дефолтная модель User по умолчанию наследует класс Illuminate\Foundation\Auth\User, который реализует этот интерфейс и его методы, чтобы вы не делали это самостоятельно, так как в большинстве случаев реализация будет одна и та же.

public function loginUsingId($id, $remember = false)
    {
        if (! is_null($user = $this->provider->retrieveById($id))) {
            $this->login($user, $remember);

            return $user;
        }

        return false;
    }

Гвард обращается к провайдеру и его методу retrieveById, представленному в интерфейсе UserProvider.

Провайдер выполняет простой запрос в базу, используя метод интерфейса Authenticatable getAuthIdentifierName, который должен возвращать название поля в базе данных, представляющего наш айдишник:

public function retrieveById($identifier)
    {
        $model = $this->createModel();

        return $this->newModelQuery($model)
                    ->where($model->getAuthIdentifierName(), $identifier)
                    ->first();
    }

Достав юзера, вызываем метод login:

public function login(AuthenticatableContract $user, $remember = false)
    {
        $this->updateSession($user->getAuthIdentifier());

        if ($remember) {
            $this->ensureRememberTokenIsSet($user);

            $this->queueRecallerCookie($user);
        }

        $this->fireLoginEvent($user, $remember);

        $this->setUser($user);
    }

В updateSession мы сохраняем в сессию айдишник юзера и генерируем новый sessionId в методе migrate:

protected function updateSession($id)
{
    $this->session->put($this->getName(), $id);

    $this->session->migrate(true);
}

На работе сессии стоит остановиться подробнее. Если вы еще недавно писали на php только процедуры и скрипты, то наверняка для работы с сессией в начале каждого файла вызывали session_start. Однако Laravel не использует нативные сессии php, айди сессии сохраняется (и по желанию шифруется) в куках, откуда Laravel каждый запрос достает эту куку и заполняет сессию. Дополнительно фреймворк использует пользовательские обработчики сессии. Подробнее о том, как создавать собственные обработчики сессии можно почитать в документации PHP и в этом гисте, где описан цикл жизни таких обработчиков.

Нам осталось только сохранить remember токен в базу, если вы передали в метод loginUsingId вторым параметром true:

if ($remember) {
     $this->ensureRememberTokenIsSet($user);

     $this->queueRecallerCookie($user);
}

$this->fireLoginEvent($user, $remember);

$this->setUser($user);

А также кинуть событие об успешной аутентификации пользователя.

Теперь, кажется, пользователь прошел аутентификацию? Не совсем. Мы сохранили айдишник в сессию, но теперь нужно его добавить в куки, чтобы при следующем запросе пользователю снова не приходилось входить. В одной из прошлых частей обзора мы обсуждали миддлвары и то, что они могут выполняться как до основного обработчика, так и после, а еще и до, и после разом. В качестве примера была приведена миддлвара StartSession. Она оборачивает все приложение, так как запускается до и после, и до выполнения основного запроса стартует сессию, а после — сохраняет данные в хранилище, за которое отвечает текущий обработчик сессии.

Фреймворк позволяет гибко настраивать любые компоненты приложения, и обработчики сессий — не исключение. Для обработки вы можете выбрать базу данных, редис, файловую систему, memcached и многие другие драйверы, указанные в этом списке. Также вы можете использовать драйвер array, это полезно для тестирования, где вам нет нужды использовать полноценные драйверы для работы с сессией.

Давайте разберем код миддлвары StartSession:

public function handle($request, Closure $next)
    {
        if (! $this->sessionConfigured()) {
            return $next($request);
        }

        // If a session driver has been configured, we will need to start the session here
        // so that the data is ready for an application. Note that the Laravel sessions
        // do not make use of PHP "native" sessions in any way since they are crappy.
        $request->setLaravelSession(
            $session = $this->startSession($request)
        );

        $this->collectGarbage($session);

        $response = $next($request);

        $this->storeCurrentUrl($request, $session);

        $this->addCookieToResponse($response, $session);

        $this->saveSession($request);

        return $response;
    }

До выполнения запроса, то есть до вызова $response = $next($request) мы начинаем сессию:

/**
     * Start the session for the given request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Contracts\Session\Session
     */
    protected function startSession(Request $request)
    {
        return tap($this->getSession($request), function ($session) use ($request) {
            $session->setRequestOnHandler($request);

            $session->start();
        });
    }

    /**
     * Get the session implementation from the manager.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Contracts\Session\Session
     */
    public function getSession(Request $request)
    {
        return tap($this->manager->driver(), function ($session) use ($request) {
            $session->setId($request->cookies->get($session->getName()));
        });
    }

Мы должны определить текущий драйвер сессии, этим занимается SessionManager. Драйвером, как мы уже обсудили, может быть база данных, редис, файл, но каким бы ни был обработчик, он передается в класс Illuminate\Session\Store, который реализует интерфейс Illuminate\Contracts\Session\Session:

protected function buildSession($handler)
{
     return $this->config->get('session.encrypt')
             ? $this->buildEncryptedSession($handler)
             : new Store($this->config->get('session.cookie'), $handler);
}

Имя куки, в которой хранится айдишник сессии, задается в этом месте конфига. Например, если в енве будет указано APP_NAME=super-app, ларавел сохранит куки с айдишником под именем super_app_session. Достаем по этому имени куку и вызываем метод setId у объекта Store:

tap($this->manager->driver(), function ($session) use ($request) {
    $session->setId($request->cookies->get($session->getName()));
})

Стартуем сессию:

$session->start();

При старте мы загружаем ранее сохраненные данные из сессии из выбранного нами обработчика по айдишнику сессии:

public function start()
{
    $this->loadSession();

    if (! $this->has('_token')) {
        $this->regenerateToken();
    }

    return $this->started = true;
}

...

protected function loadSession()
{
     $this->attributes = array_merge($this->attributes, $this->readFromHandler());
}

/**
  * Read the session data from the handler.
  *
  * @return array
  */
protected function readFromHandler()
{
    if ($data = $this->handler->read($this->getId())) {
        $data = @unserialize($this->prepareForUnserialize($data));

        if ($data !== false && ! is_null($data) && is_array($data)) {
            return $data;
        }
     }

     return [];
}

А еще мы генерируем новый токен, который используется в качестве Csrf токена для предотвращения межсайтовых запросов. Подробнее про данный вид атак можете почитать, например, тут. Данный токен используется в миддлваре VerifyCsrfToken.

После старта сессии мы в любом месте нашего приложения можем использовать сессию, чтобы доставать оттуда раннее сохраненные данные или сохранять новые. Все они попадают в защищенное свойство $attributes. Теперь чтобы получить пользователя в любом месте вашего приложения, вам необходимо заинжектить AuthManager и, выбрав нужный гвард (например, web), получить объект пользователя, вызвав метод user():

public function user()
    {
        if ($this->loggedOut) {
            return;
        }

        if (! is_null($this->user)) {
            return $this->user;
        }

        $id = $this->session->get($this->getName());

        if (! is_null($id) && $this->user = $this->provider->retrieveById($id)) {
            $this->fireAuthenticatedEvent($this->user);
        }

        if (is_null($this->user) && ! is_null($recaller = $this->recaller())) {
            $this->user = $this->userFromRecaller($recaller);

            if ($this->user) {
                $this->updateSession($this->user->getAuthIdentifier());

                $this->fireLoginEvent($this->user, true);
            }
        }

        return $this->user;
    }

Достаем айдишник пользователя следующим образом:

$id = $this->session->get($this->getName());

Если помните, ранее при логине мы сделали все ровно наоборот:

$this->session->put($this->getName(), $id);

Если пользователь был сохранен с кукой remember, необходимо достать его с помощью так называемого Recaller, в этом случае мы будем доставать пользователя по айдишнику и remember_token'у. Итак, юзера достали, эвенты бросили, сессию обновили. Но кажется, что мы что-то забыли? Да, верно, мы не договорили о том, что происходит после вызова метода login. Как вы помните, мы добавили в сессию айдишник юзера и мигрировали сессию, то есть сгенерировали новый sessionId. Теперь нам нужно сбросить данные, накопленные в свойстве $attributes в хранилище обработчика, которым пользуемся.

Делается это в той же миддлваре StartSession,  только уже после запроса, то есть после $next($request):

$this->collectGarbage($session);

$response = $next($request);

$this->storeCurrentUrl($request, $session);

$this->addCookieToResponse($response, $session);

$this->saveSession($request);

return $response;

Для начала собираем "мусор" — данные, которые уже отжили свое. Получаем ответ от обработчика и сохраняем текущий урл в сессию. Текущий урл может использоваться (если не удалось получить referer) при редиректе назад, когда вы вызываете в своих контроллерах нечто подобное:

return back();

...

return $this->redirector->back();

Добавляем в ответ куку под названием, которое указано в конфиге (ранее мы привели пример с super_app_session), и со значением айдишника сессии, сгенерированного при вызове метода login:

protected function addCookieToResponse(Response $response, Session $session)
{
    if ($this->sessionIsPersistent($config = $this->manager->getSessionConfig())) {
          $response->headers->setCookie(new Cookie(
          $session->getName(), $session->getId(), $this->getCookieExpirationDate(),
          $config['path'], $config['domain'], $config['secure'] ?? false,
          $config['http_only'] ?? true, false, $config['same_site'] ?? null
      ));
    }
}

И под конец сохраняем сессию с помощью нашего Store:

public function save()
    {
        $this->ageFlashData();

        $this->handler->write($this->getId(), $this->prepareForStorage(
            serialize($this->attributes)
        ));

        $this->started = false;
    }

Тут мы пишем флаш данные, которые запишутся в сессию на один запрос (с помощью них обычно показываются ошибки или успешные сообщения, полученные после выполнения каких-то действий на сервере) и, используя один из хэндлеров (база данных, файлы, редис, ну и так далее), сохраняем данные в хранилище, предварительно их засериализовав и зашифровав, если используется шифрующий драйвер.

Чтобы защитить свои роуты от анонимных пользователей, вы можете повесить на них миддлвару auth. За нее отвечает класс Authenticate. Если у вас несколько способов аутентификации, вы можете попросить миддлвару попробовать аутентифицировать по одному из гвардов следующим образом:

$router->middleware(['auth:web,token'])->group(...)

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

$authManager->guard()->user();

Например, если юзер вошел с помощью TokenGuard, а вы просите достать юзера следующим образом:

$authManager->guard('web')->user();

вы ничего не получите. Поэтому запомните: если у вас один обработчик и несколько способов аутентификаций, защищайте контроллер миддлварой auth, передавая в нее все возможные гварды, через которые может войти юзер, а дальше вызывайте $authManager→guard()→user(), не передавая туда имя гварда.

Повторная аутентификация

Как только сессия закончится, пользователю придется повторно ввести свои данные. Чтобы провести аутентификацию в этот раз, необходимо вызвать метод гварда attempt:

use Illuminate\Contracts\Auth\Factory as AuthManager;

final class UserAuthenticator
{
    private AuthManager $authManager;
    public function __construct(AuthManager $authManager)
    {
        $this->authManager = $authManager;
    }

    public function authenticateAgain(UserAttemptCredentials $credentials)
    {
        $this->authManager->guard('web')->attempt($credentials);
    }
}

Метод attempt попробует достать по переданным данным пользователя, намеренно пропуская поле password, так как в базе оно хранится в зашифрованном виде, в то время как пользователь вводит plain пароль.

public function attempt(array $credentials = [], $remember = false)
    {
        $this->fireAttemptEvent($credentials, $remember);

        $this->lastAttempted = $user = $this->provider->retrieveByCredentials($credentials);

        if ($this->hasValidCredentials($user, $credentials)) {
            $this->login($user, $remember);

            return true;
        }

        $this->fireFailedEvent($user, $credentials);

        return false;
    }

Достав пользователя с помощью метода retrieveByCredentials нашего провайдера (Eloquent или Database), фреймворк вызывает метод hasValidCredentials, где Eloquent/DatabaseProvider просит Hasher проверить plain пароль на соответствие с хэшем, хранящемся в базе. Если все совпадает, вызывается уж знакомый нам метод login и все выше описанное повторяется.

Конечно, вы можете не использовать дефолтный способ аутентификации, тогда вы должны будете самостоятельно достать юзера и вызвать метод login.

Логаут

Чтобы разлогинить пользователя, необходимо вызвать метод logout вашего гварда:

$this->authManager->guard('web')->logout();

В этом случае Laravel удалит сессию и почистит куки, если пользователь бы аутентифицирован с remember_token'ом:

$this->session->remove($this->getName());

if (! is_null($this->recaller())) {
     $this->getCookieJar()->queue($this->getCookieJar()
          ->forget($this->getRecallerName()));
}

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

loader
Комментарии
Новый комментарий

Логические задачи с собеседований