Посты / Очень простое подтверждение email (верификация) в Laravel 5.4

18.02.2017 16:04
Привет, друзья. Сегодня я хочу показать, как можно сделать очень простую систему подтверждения email адреса пользователя, после того, как он зарегистрируется на сайте. Мы применим нестандартный подход, который покажет, что программирование, это нечто большее, чем просто знание инструмента и паттернов.

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

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

Вполне себе нормальное и стандартное решение. А можно ли еще проще? Для того, чтобы ответить на этот вопрос, необходимо уже включать мозг и думать. А как можно по другому решить эту задачу. И для этого мы присмотримся к нашей таблице users.

Создадим нового пользователя, просто для теста:

Про регистрацию через социальные сети можно посмотреть в этой статье: Авторизация в Laravel, через социальные сети (Ulogin). Просто, гибко и эффективно

Теперь посмотрим в нашу таблицу, что там было создано:

Из-за водяного знака плохо видно, но дата создания и дата редактирования совпадают. Отлично, давайте заметим этот момент. Дальше, токен для запоминания пользователя пустой. Тоже это для себя отметим. Но и само собой понятно, что статус пользователя равен нолю.

Что дает нам эта информация? На самом деле очень многое. Применяя системный анализ мы можем легко построить и зависимости, и алгоритм наших действий.

Раньше я хотел применить в алгоритме факт одинаковых дат в таблице, но потом еще проще сделал. И так, будем использовать зависимость пустого токена и нулевого статуса.

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

В методе регистрации нового пользователя я создаю новую запись в базе и передаю ее в новый объект, который я создаю для отправки сообщения:
// Send user message for activation account.
Mail::to($newUser)->send(new ActivateAccount($newUser));
В почтовом классе я формирую ссылку для активации аккаунта:
// Create activation link.
$activationLink = route('activation', ['id' => $this->user->id, 'token' => md5($this->user->email)]);
Что я делаю и зачем. А логика действий такова. Я предполагаю, что если статус нулевой и токен также нулевой, то это новый пользователь, который еще не подтверждал свой email. А если токен не нулевой, а статус нулевой, то это означает, что адрес был ранее подтвержден, но пользователь в последствии был забаннен. А если и статус равен единице и есть токен, то пользователь уже успешно активирован ранее.

Полностью класс для отправки email выглядит так:
<?php

namespace App\Mail;

use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Queue\ShouldQueue;

class ActivateAccount extends Mailable
{
use Queueable, SerializesModels;

// User data.
protected $user;

/**
* Create a new message instance.
*
* @param \App\User $user
*/
public function __construct(User $user)
{
$this->user = $user;
}

/**
* Build the message.
*
* @return $this
*/
public function build()
{
// Create activation link.
$activationLink = route('activation', [
'id' => $this->user->id,
'token'
=> md5($this->user->email)
]);

return $this->subject(trans('interface.ActivationAccount'))
->view('emails.activate')->with([
'link' => $activationLink
]);
}
}
Как видите, ничего сложного. Передаем линк в шаблон и формируем текст для пользователя.

Итак, что значит этот линк. Все просто, мы указываем хост для ссылки, потом указываем действие для роута:
// Activation user.
Route::get('activate/{id}/{token}', 'RegistrationController@activation')->name('activation');
Ид пользователя и токен - это переменные. Я специально передаю еще и идентификатор пользователя, чтобы не прогонять всю базу в поисках совпадения email адреса. Адрес пользователя я хэширую через функцию md5.

Дальше, когда пользователь кликает на ссылку, алгоритм действий простой. Пробуем получить пользователя из базы. Если его нет, то ошибка, если есть, то проверяем пустой ли токен для запоминания пользователя. Если пустой, то хэшируем пользовательский email и сравниваем с токеном из адресной строки. Если они совпадают, то активируем пользователя и тут же залогиниваем используя токен запоминания пользователя. Вот сам метод активации: 
/**
* Make user activation.
*/
public function activation($userId, $token)
{
$user = User::findOrFail($userId);

// Check token in user DB. if null then check data (user make first activation).
if (is_null($user->remember_token)) {
// Check token from url.
if (md5($user->email) == $token) {
// Change status and login user.
$user->status = 1;
$user->save();

\Session::flash('flash_message', trans('interface.ActivatedSuccess'));

// Make login user.
Auth::login($user, true);
} else {
// Wrong token.
\Session::flash('flash_message_error', trans('interface.ActivatedWrong'));
}
} else {
// User was activated early.
\Session::flash('flash_message_error', trans('interface.ActivatedAlready'));
}
return redirect('/');
}
Это все! И не нужно никаких дополнительных таблиц, полей, методов, моделей и связей! Всем желаю удачи, придумывайте свои простые и эффективные способы по построению функционала. До скорой встречи.

П.С. Спасибо за дельные комментарии, которые помогли сделать код качественнее.
2

Комментарии (33):

Спасибо за уточнение. 
Хотя, в принципе можно сделать проще:
в LoginController переопределяем функции sendFailedLoginResponse и credentials, и по сути можно и дальше использовать коробочное решение.

pavel писал(а):
А если пользователь еще не подтвердил свой email, то как лучше ему выводить об этом сообщение, в случае если он все-так попробует войти на сайт с неподтвержденным адресом? Переопределить метод авторизации и добавить туда проверку на предмет того, активирован пользователь или нет?
Да, это можно сделать в методе входа в аккаунт. Проверяете его логин и пароль и находите, что пользователь зарегистрирован, но аккаунт не активирован, и выводите ему об этом запись. Лично я переделал роут на залогинивание пользователя, и делаю обработку через свой контроллер. В контроллере в методе залогинивания можно проводить проверку, что-то типа такого:
// Check user status.
if (!$auth->status) {
\Session::flash('flash_message_error', 'Ваш аккаунт не активен!');

return redirect($this->redirectTo);
}
При этом можно проверить, активирован он (но был забаннен) или нет.
А если пользователь еще не подтвердил свой email, то как лучше ему выводить об этом сообщение, в случае если он все-так попробует войти на сайт с неподтвержденным адресом? Переопределить метод авторизации и добавить туда проверку на предмет того, активирован пользователь или нет?
zigmund wrote:
получается я могу залогиниться один раз, увидеть id юзера, смекнуть, что есть хеш майла в адресе и налогинить кучу юзеров, просто подставля следующий id? Может нужно добавлять соль в метод получения токена?
С солью, конечно, будет железобетонно. Но мыло можно ведь не только через md5 хэшить ;)
получается я могу залогиниться один раз, увидеть id юзера, смекнуть, что есть хеш майла в адресе и налогинить кучу юзеров, просто подставля следующий id? Может нужно добавлять соль в метод получения токена?
Artur wrote:
Добрый день, объясните пожалуйста по подробней куда писать эту строку кода Mail::to($newUser)->send(new ActivateAccount($newUser));
Я этот метод вызываю в классе регистрации нового пользователя. После того, как вы внесли данные о пользователе в базу, вы должны пользователю отправить сообщение с ссылкой для активации.
Добрый день, объясните пожалуйста по подробней куда писать эту строку кода
Mail::to($newUser)->send(new ActivateAccount($newUser));
saxarra wrote:
Мне не совсем поправилась форма регистрации. Ник и емайл должны стоять рядом, иначе приходиться переключать язык(обычно так происходит). А вообще, я считаю, что чем проще тем лучше. Т.е чем меньше строчек тем ловчей в идеале 2-3. Имя емайл и ник или ник и емайл.
А люди часто ник на русском пишут :)
Мне не совсем поправилась форма регистрации. Ник и емайл должны стоять рядом, иначе приходиться переключать язык(обычно так происходит). А вообще, я считаю, что чем проще тем лучше. Т.е чем меньше строчек тем ловчей в идеале 2-3. Имя емайл и ник или ник и емайл.
Paul писал(а):
Очередь создается, но отправка не происходит. Подскажите, если вы реализовали у себя через очередь или знаете что я делаю не так.
А я очередь вообще не делал. У меня свой выделенный почтовый сервер. Пользователь не успел нажать на кнопку, а письмо уже пришло. А в качестве драйвера я использую:
# Production Mail environment
MAIL_DRIVER=mail
Спасибо. В целом все работает, но отправка идет очень долго (у меня до 30 секунд), возможно проблема в smtp настройках на сервере. Поэтому решил сделать через очередь

Mail::queue('emails.activate', array('link' => $activationLink), function($message)
{
$message->to($data['email'])->subject('Подтверждение email');
});

Очередь создается, но отправка не происходит. Подскажите, если вы реализовали у себя через очередь или знаете что я делаю не так.
Paul писал(а):
Для версии 5.3 подойдет решение?
Да, без проблем.
Для версии 5.3 подойдет решение?
helldar писал(а):
Точно! Только еще момент: после строчки "Auth::login($user, true);" редирект один светится)
Сейчас поправлю.
Kirill wrote:
Вынес. Вот это работу проделали, теперь любо посмотреть :)
Точно! Только еще момент: после строчки "Auth::login($user, true);" редирект один светится)
helldar писал(а):
You're welcome! "return redirect('/');" можно в конец метода вынести, т.к. все равно на него ссылаться будет. А так сократим код аж на 4 строки.
Вынес. Вот это работу проделали, теперь любо посмотреть :)
Kirill wrote:
Благодарю за дельные комментарии. Я сейчас поправлю в статье код, чтобы выглядело все по человечески.
You're welcome!
"return redirect('/');" можно в конец метода вынести, т.к. все равно на него ссылаться будет. А так сократим код аж на 4 строки.
Благодарю за дельные комментарии. Я сейчас поправлю в статье код, чтобы выглядело все по человечески.
helldar писал(а):
Код-ревью отличная штука)
Согласен целиком и полностью. Вот только в некоторых конторах по разному устроен этот процесс. В некоторых, код ревью делает тимлид, а в других - проверяют друг у друга перед передачей на тестирование.
helldar писал(а):
Строку "Auth::loginUsingId($user->id, true);" можно заменить на "Auth::login($user, true);"
Разница только в длине самой строки, а суть та же)
Вот код неплохо и отрефакторился :)
Строку "Auth::loginUsingId($user->id, true);" можно заменить на "Auth::login($user, true);"
Разница только в длине самой строки, а суть та же)
helldar писал(а):
Плюс такого кода - внезапно захотим заменить линку вида, например, "/activate/1/weqweqwewe" на "/activate/qwdweferferferf/1". В роутах в одном месте заменили и готово, а так придется еще и в класс мыла лазить, если сразу вспомнить что еще там прописывать надо.
Сделать роутом в классе мыла - это толково! Здесь я согласен на все 100%.
По поводу двоеточия где-то на Ларакасте в видяхах кто-то так юзал. Мне показалось это быстрым.
Как говорится, мы не ищем легких путей - нам лень)
А проставить двойное двоеточие меньше секунды времени требуется)
Плюс такого кода - внезапно захотим заменить линку вида, например, "/activate/1/weqweqwewe" на "/activate/qwdweferferferf/1". В роутах в одном месте заменили и готово, а так придется еще и в класс мыла лазить, если сразу вспомнить что еще там прописывать надо.
Можно и одной. Я почему-то привык с двоеточием. Мало того, с двойным)
У меня роуты именуются, например, "user::info", "admin::dashboard" и так далее.
Привык к своему коду и если глаз видит двойное двоеточие, значит это роут, то бишь мозг значительно быстрее понимает что за информация перед ним.
Проще говоря - мне лень думать что там написано и сделал так, чтобы сократить время восприятия)))
  Может, глазу приятней будет функция оформленная таким образом?
  
Kirill писал(а):
А почему две точки, а не 'email.confirm' ?
Ааа, понял, вы так в роуте имя дали. Я, обычно через точку делаю.
helldar писал(а):
'email:confirm'
А почему две точки, а не 'email.confirm' ?
helldar писал(а):
Участок кода:
  $activationLink = 'https://' . $_SERVER['HTTP_HOST'] . '/activate/' . $this->user->id . '/' . md5($this->user->email);
выглядит оооочень страшно.

Не проще ли сделать в роутах, собственно, роут, например:
Route::get('activate/{user_id}/{md5}', 'EmailController@confirm')->name('email:confirm');

Заголовок функции в контроллере оформить как:
public function confirm($user_id, $md5) { }

А в классе создания мыла по шаблону юзать:
$activationLink = route('email:confirm', ['user_id' => $this->user->id, 'md5' => md5($this->user->email)]);
?

Не пойму, зачем костыль в Ларе в виде " $_SERVER['HTTP_HOST']"?)
Да, вы абсолютно правы. Ваш код гораздо изящнее и правильнее.
Поправка на текущий код: Роут оформить как:
  Route::get('activate/{user_id}/{token}', 'RegistrationController@activation')->name('email:confirm');  
Участок кода:
  $activationLink = 'https://' . $_SERVER['HTTP_HOST'] . '/activate/' . $this->user->id . '/' . md5($this->user->email);
выглядит оооочень страшно.

Не проще ли сделать в роутах, собственно, роут, например:
Route::get('activate/{user_id}/{md5}', 'EmailController@confirm')->name('email:confirm');

Заголовок функции в контроллере оформить как:
public function confirm($user_id, $md5) { }

А в классе создания мыла по шаблону юзать:
$activationLink = route('email:confirm', ['user_id' => $this->user->id, 'md5' => md5($this->user->email)]);
?

Не пойму, зачем костыль в Ларе в виде " $_SERVER['HTTP_HOST']"?)