Двухфакторная аутентификация через почту

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

В основе проекта будет админка на Laravel 6, созданная с помощью QuickAdminPanel, но вы можете начать с «пустого» дефолтого Laravel Auth, шаги будут работать точно так же.

Шаг 1. Два новых поля в таблице Users

Наша новая миграция:

Schema::table('users', function (Blueprint $table) {
    $table->string('two_factor_code')->nullable();
    $table->dateTime('two_factor_expires_at')->nullable();
});

Поле two_factor_code будет содержать случайное 6-значное число, а two_factor_expires_at будет содержать его время жизни — в нашем случае, срок истекает через 10 минут.

Также добавляем эти поля в app/User.php — в массивы $fillable и X:

class User extends Authenticatable
{

    protected $dates = [
        'updated_at',
        'created_at',
        'deleted_at',
        'email_verified_at',
        'two_factor_expires_at',
    ];

    protected $fillable = [
        'name',
        'email',
        'password',
        'created_at',
        'updated_at',
        'deleted_at',
        'remember_token',
        'email_verified_at',
        'two_factor_code',
        'two_factor_expires_at',
    ];

Шаг 2. Генерация от отправка кода при логине

Метод, который нужно добавить в app/Http/Controllers/Auth/LoginController.php:

protected function authenticated(Request $request, $user)
{
    $user->generateTwoFactorCode();
    $user->notify(new TwoFactorCode());
}

Таким образом, мы переопределяем метод authenticated() ядра Laravel и добавляем логику того, что должно происходить после входа пользователя в систему.

Сначала мы генерируем код и добавляем для этого метод в app/User.php:

public function generateTwoFactorCode()
{
    $this->timestamps = false;
    $this->two_factor_code = rand(100000, 999999);
    $this->two_factor_expires_at = now()->addMinutes(10);
    $this->save();
}

Помимо установки двухфакторного кода и срока его действия, мы также указываем, что это обновление не должно касаться столбца updated_at в таблице users, соответственно мы делаем $this->timestamps = false;.

Теперь, обратно в LoginController. Мы вызываем $user->notify() и используем систему уведомлений Laravel. Для этого нам нужно создать класс уведомлений с помощью

php artisan make: messages TwoFactorCode

И пишем в app/Notifications/TwoFactorCode.php:

class TwoFactorCode extends Notification
{

    /**
     * Get the mail representation of the notification.
     *
     * @param  mixed  $notifiable
     * @return \Illuminate\Notifications\Messages\MailMessage
     */
    public function toMail($notifiable)
    {
        return (new MailMessage)
            ->line('Ваш двухфакторый код: '.$notifiable->two_factor_code)
            ->action('Проверить', route('verify.index'))
            ->line('Срок действия кода — 10 минут')
            ->line('Если вы не пытались войти на сайт, то проигнорируйте это сообщение.');
    }
}

Этот код отправляет такое письмо:

Двухфакторая аутентификация в Ларавел

Здесь нужно упомянуть две вещи:

  • Параметр $notifiable метода toMail() автоматически назначается как объект залогиненного пользователя, поэтому мы можем получить доступ к столбцу users.two_factor_code через $notifiable->two_factor_code;
  • Чуть позже мы создадим маршрут route ('verify.index'), который повторно отправляет код.

Шаг 3. Показываем форму подтверждения с Мидлваром

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

Двухфакторая аутентификация в Ларавел

Фактически, они увидят эту форму, если они введут любой URL. Она будет им показываться до тех пор, пока они не введут проверочный код.

Для этого мы сгенерируем мидлвар:

php artisan make: middleware TwoFactor

И заполним его app/Http/Middleware/TwoFactor.php:

class TwoFactor
{

    public function handle($request, Closure $next)
    {
        $user = auth()->user();

        if(auth()->check() && $user->two_factor_code)
        {
            if($user->two_factor_expires_at->lt(now()))
            {
                $user->resetTwoFactorCode();
                auth()->logout();

                return redirect()->route('login')
                    ->withMessage('Срок действия двухфакторного кода истек. Пожалуйста, войдите еще раз.');
            }

            if(!$request->is('verify*'))
            {
                return redirect()->route('verify.index');
            }
        }

        return $next($request);
    }
}

Если вы не знаете, как работает мидлвар (middleware), прочитайте официальную документацию Laravel. В основном это класс, который выполняет некоторые действия, обычно ограничивающие доступ к какой-либо странице или функции.

Итак, в нашем случае мы проверяем, существует ли двухфакторный кодовый набор. Если да, то проверяем, не истек ли он. Если срок его действия истек, то сбрасываем его и перенаправляем обратно на форму входа. Если он все еще активен, то перенаправляем обратно на форму подтверждения.

Другими словами, если users.two_factor_code пустой, то значит он проверен и пользователь может двигаться дальше.

Вот код метода resetTwoFactorCode() в app/User.php:

public function resetTwoFactorCode()
{
    $this->timestamps = false;
    $this->two_factor_code = null;
    $this->two_factor_expires_at = null;
    $this->save();
}

Далее, мы назначаем нашему мидлвару «псевдоним» в app/Http/Kernel.php:

class Kernel extends HttpKernel
{
    // ...

    protected $routeMiddleware = [
        'can'           => \Illuminate\Auth\Middleware\Authorize::class,
        // ... more middlewares

        'twofactor'     => \App\Http\Middleware\TwoFactor::class,
    ];
}

Теперь нам нужно назначить это twofactor мидлвар некоторым маршрутам. В нашем случае это группа маршрутов в routes/web.php, сгенерированная QuickAdminPanel:

Route::group([
    'prefix' => 'admin', 
    'as' => 'admin.', 
    'namespace' => 'Admin', 
    'middleware' => ['auth', 'twofactor']
], function () {
    Route::resource('permissions', 'PermissionsController');
    Route::resource('roles', 'RolesController');
    Route::resource('users', 'UsersController');
});

Шаг 4. Контроллер/Шаблон страницы проверки

На этом этапе любой запрос на любой URL будет перенаправлен на проверку кода. Для этого у нас будет два дополнительных маршрута:

Route::get('verify/resend', 'Auth\TwoFactorController@resend')->name('verify.resend');
Route::resource('verify', 'Auth\TwoFactorController')->only(['index', 'store']);

Основная логика будет в app/Http/Controllers/Auth/TwoFactorController.php:

class TwoFactorController extends Controller
{
    public function index() 
    {
        return view('auth.twoFactor');
    }

    public function store(Request $request)
    {
        $request->validate([
            'two_factor_code' => 'integer|required',
        ]);

        $user = auth()->user();

        if($request->input('two_factor_code') == $user->two_factor_code)
        {
            $user->resetTwoFactorCode();

            return redirect()->route('admin.home');
        }

        return redirect()->back()
            ->withErrors(['two_factor_code' => 
                'The two factor code you have entered does not match']);
    }

    public function resend()
    {
        $user = auth()->user();
        $user->generateTwoFactorCode();
        $user->notify(new TwoFactorCode());

        return redirect()->back()->withMessage('Двухфакторный код отправлен снова');
    }
}

Основная форма находится в методе index(), затем она передает данные в метод store() для проверки кода, а метод resend() предназначен для повторной генерации и отправки кода новым письмом.

Давайте посмотрим на форму подтверждения — в resources/views/auth/twoFactor.blade.php:

@if(session()->has('message'))
    <p class="alert alert-info">
        {{ session()->get('message') }}
    </p>
@endif
<form method="POST" action="{{ route('verify.store') }}">
    {{ csrf_field() }}
    <h1>Two Factor Verification</h1>
    <p class="text-muted">
        You have received an email which contains two factor login code.
        If you haven't received it, press <a href="{{ route('verify.resend') }}">here</a>.
    </p>

    <div class="input-group mb-3">
        <div class="input-group-prepend">
            <span class="input-group-text">
                <i class="fa fa-lock"></i>
            </span>
        </div>
        <input name="two_factor_code" type="text" 
            class="form-control{{ $errors->has('two_factor_code') ? ' is-invalid' : '' }}" 
            required autofocus placeholder="Two Factor Code">
        @if($errors->has('two_factor_code'))
            <div class="invalid-feedback">
                {{ $errors->first('two_factor_code') }}
            </div>
        @endif
    </div>

    <div class="row">
        <div class="col-6">
            <button type="submit" class="btn btn-primary px-4">
                Verify
            </button>
        </div>
    </div>
</form>

Я намеренно пропустил весь «родительский» HTML шаблон, потому что он зависит от вашего дизайна, а это основная форм в Blade. Думаю, что тут всё само собой понятно.

Единственное, что может нуждается в объяснении, это session()->has(‘message’) — откуда это сообщение? Оно из контроллера из метода resend(). Вот его последняя строка:

return redirect()->back()->withMessage('Двухфакторный код отправлен снова');

Вдруг вы не знаете, но метод redirect() в Laravel можно использовать цепочечным методом (chained method) ->withWhwhat('text'). Этот текст сохраняется в сессии как session(‘whatever’) (в нижнем регистре).

Иии, вот и все! У нас есть полная логика для отправки двухфакторного кода по электронной почте.

Единственное, что я не затронул, так как это индивидуально — КОГДА вы захотите отправить этот код. В нашем примере мы отправляем его каждый раз, но это не для реальной жизни. Вам нужно будет отправлять код всякий раз, когда, например, логинятся с нового IP-адреса, или из другой страны, или каждый 5-й раз, или по каким-либо другим условиям. Поэтому, пожалуйста, сделайте его сами — просто отредактируйте Middleware и LoginController, чтобы сказать Laravel когда нужно его отправить.

Ссылка на полный репозиторий: https://github.com/LaravelDaily/Laravel-Two-Factor-Auth-Email

Автор: Povilas Korop
Перевод: Алексей Широков

Наш Телеграм-канал — следите за новостями о Laravel.