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()));
}
На этом обзор аутентификации можно закончить. Надеюсь, вам это не показалось сложным, так как проще и, в то же время, гибче сделать систему аутентификации просто невозможно.
Комментарии