Статьи

Как обезопасить приложения Laravel с 2FA с помощью SMS

Эта статья была рецензирована Кристофером Томасом . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!


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

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

Twilio и Laravel

Что мы строим

Есть большой шанс, что вы уже знакомы с потоком 2FA:

  • Пользователь заходит на страницу входа.

  • Он вводит электронную почту и пароль.

    Форма входа

  • Мы отправляем проверочный код по номеру телефона.

    2FA проверочный код SMS

  • Пользователь должен ввести полученный код.

    Введите проверочный код

  • Если код верен, мы регистрируем их. В противном случае мы даем им еще один шанс попробовать войти.

    Приборная доска

Окончательное демонстрационное приложение на GitHub .

Установка

Я предполагаю, что ваша среда разработки уже настроена. Если нет, мы рекомендуем Homestead Improved для быстрого старта.

Создайте новый проект Laravel, используя установщик Laravel или Composer.

laravel new demo 

Или

 composer create-project --prefer-dist laravel/laravel demo 

Отредактируйте файл .env и добавьте свои учетные данные базы данных.

 DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=homestead DB_USERNAME=root DB_PASSWORD=root 

Аутентификация лесов

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

  • Вход, регистрация и сброс пароля просмотра и контроллеров.
  • Необходимые маршруты.

Запустите php artisan make:auth из командной строки.

Создание миграций

Обновите класс миграции users и добавьте поля country_code и phone .

 // database/migrations/2014_10_12_000000_create_users_table.php class CreateUsersTable extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::create('users', function (Blueprint $table) { $table->increments('id'); $table->string('name'); $table->string('email')->unique(); $table->string('password'); $table->string('country_code', 4)->nullable(); $table->string('phone')->nullable(); $table->rememberToken(); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('users'); } } 

У каждого пользователя есть список сгенерированных им токенов (проверочных кодов). Запустите команду php artisan make:model Token -m чтобы создать модель и файл миграции. Схема таблицы будет выглядеть так:

 // database/migrations/2016_12_14_105000_create_tokens_table.php class CreateTokensTable extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::create('tokens', function (Blueprint $table) { $table->increments('id'); $table->string('code', 4); $table->integer('user_id')->unsigned(); $table->boolean('used')->default(false); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('tokens'); } } по // database/migrations/2016_12_14_105000_create_tokens_table.php class CreateTokensTable extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::create('tokens', function (Blueprint $table) { $table->increments('id'); $table->string('code', 4); $table->integer('user_id')->unsigned(); $table->boolean('used')->default(false); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('tokens'); } } 

Я ограничил проверочный код четырьмя цифрами, но вы можете усложнить его, увеличив его. Мы вернемся к этому вопросу позже. Давайте запустим php artisan migrate для создания нашей базы данных.

Обновление моделей

Модели уже есть и должны обновляться только соответственно:

 // app/User.php class User extends Authenticatable { use Notifiable; /** * The attributes that are mass assignable. * * @var array */ protected $fillable = [ 'name', 'email', 'password', 'country_code', 'phone' ]; /** * The attributes that should be hidden for arrays. * * @var array */ protected $hidden = [ 'password', 'remember_token', ]; /** * User tokens relation * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function tokens() { return $this->hasMany(Token::class); } /** * Return the country code and phone number concatenated * * @return string */ public function getPhoneNumber() { return $this->country_code.$this->phone; } } 

Здесь нет ничего особенного, мы просто добавили отношение users -> tokens и добавили вспомогательный метод getPhoneNumber чтобы получить полный номер телефона пользователя.

 // app/Token.php class Token extends Model { const EXPIRATION_TIME = 15; // minutes protected $fillable = [ 'code', 'user_id', 'used' ]; public function __construct(array $attributes = []) { if (! isset($attributes['code'])) { $attributes['code'] = $this->generateCode(); } parent::__construct($attributes); } /** * Generate a six digits code * * @param int $codeLength * @return string */ public function generateCode($codeLength = 4) { $min = pow(10, $codeLength); $max = $min * 10 - 1; $code = mt_rand($min, $max); return $code; } /** * User tokens relation * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function user() { return $this->belongsTo(User::class); } /** * True if the token is not used nor expired * * @return bool */ public function isValid() { return ! $this->isUsed() && ! $this->isExpired(); } /** * Is the current token used * * @return bool */ public function isUsed() { return $this->used; } /** * Is the current token expired * * @return bool */ public function isExpired() { return $this->created_at->diffInMinutes(Carbon::now()) > static::EXPIRATION_TIME; } } 

Помимо настройки методов отношений и обновления атрибутов fillable, мы добавили:

  • конструктор для установки свойства кода при создании.
  • метод generateCode для генерации случайных цифр в зависимости от параметра длины кода.
  • метод isExpired чтобы увидеть, истек ли срок действия ссылки с помощью константы EXPIRATION_TIME .
  • метод isValid чтобы увидеть, не истекла ли ссылка и не используется ли она.

Создание видов

Файл представления register должен быть обновлен, чтобы включить код страны и поле телефона.

 // resources/views/auth/register.blade.php @extends('layouts.app') @section('content') <div class="container"> <div class="row"> <div class="col-md-8 col-md-offset-2"> <div class="panel panel-default"> <div class="panel-heading">Register</div> <div class="panel-body"> @include("partials.errors") <form class="form-horizontal" role="form" method="POST" action="{{ url('/register') }}"> // ... <div class="form-group"> <label for="phone" class="col-md-4 control-label">Phone</label> <div class="col-md-6"> <div class="input-group"> <div class="input-group-addon"> <select name="country_code" style="width: 150px;"> <option value="+1">(+1) US</option> <option value="+212">(+212) Morocco</option> </select> </div> <input id="phone" type="text" class="form-control" name="phone" required> @if ($errors->has('country_code')) <span class="help-block"> <strong>{{ $errors->first('country_code') }}</strong> </span> @endif @if ($errors->has('phone')) <span class="help-block"> <strong>{{ $errors->first('phone') }}</strong> </span> @endif </div> </div> </div> // ... </form> </div> </div> </div> </div> </div> @endsection 

Затем мы создаем новое представление для пользователя, чтобы ввести код подтверждения.

 // resources/views/auth/code.blade.php @extends('layouts.app') @section('content') <div class="container"> <div class="row"> <div class="col-md-8 col-md-offset-2"> <div class="panel panel-default"> <div class="panel-heading">Login</div> <div class="panel-body"> @include("partials.errors") <form class="form-horizontal" role="form" method="POST" action="{{ url('/code') }}"> {{ csrf_field() }} <div class="form-group"> <label for="code" class="col-md-4 control-label">Four digits code</label> <div class="col-md-6"> <input id="code" type="text" class="form-control" name="code" value="{{ old('code') }}" required autofocus> @if ($errors->has('code')) <span class="help-block"> <strong>{{ $errors->first('code') }}</strong> </span> @endif </div> </div> <div class="form-group"> <div class="col-md-8 col-md-offset-4"> <button type="submit" class="btn btn-primary"> Login </button> </div> </div> </form> </div> </div> </div> </div> </div> @endsection 

errors.blade.php напечатает список ошибок проверки.

 // resources/views/errors.blade.php @if (count($errors) > 0) <div class="alert alert-danger "> <button type="button" class="close" data-dismiss="alert" aria-label="Close"> <span aria-hidden="true">&times;</span></button> <ul> @foreach ($errors->all() as $error) <li>{{ $error }}</li> @endforeach </ul> </div> @endif 

Создание контроллеров

Вместо создания новых контроллеров, почему бы не использовать контроллеры аутентификации? В конце концов, добавить особо нечего!

Метод RegisterController@register вызывается, когда пользователь публикует форму, и если вы откроете файл, вы обнаружите, что он вызывает registered метод после создания пользователя.

 // app/Http/Controllers/RegisterController.php class RegisterController extends Controller { // ... protected function registered(Request $request, $user) { $user->country_code = $request->country_code; $user->phone = $request->phone; $user->save(); } } 

Нам также необходимо обновить проверку запроса и сделать обязательными поля кода страны и телефона.

 // app/Http/Controllers/RegisterController.php class RegisterController extends Controller { // ... protected function validator(array $data) { return Validator::make($data, [ 'name' => 'required|max:255', 'email' => 'required|email|max:255|unique:users', 'password' => 'required|min:6|confirmed', 'country_code' => 'required', 'phone' => 'required' ]); } } 

Теперь нам нужно обновить LoginController и переопределить метод login .

 // app/Http/Controllers/LoginController.php class LoginController extends Controller { // ... public function login(Request $request) { $this->validateLogin($request); //retrieveByCredentials if ($user = app('auth')->getProvider()->retrieveByCredentials($request->only('email', 'password'))) { $token = Token::create([ 'user_id' => $user->id ]); if ($token->sendCode()) { session()->set("token_id", $token->id); session()->set("user_id", $user->id); session()->set("remember", $request->get('remember')); return redirect("code"); } $token->delete();// delete token because it can't be sent return redirect('/login')->withErrors([ "Unable to send verification code" ]); } return redirect()->back() ->withInputs() ->withErrors([ $this->username() => Lang::get('auth.failed') ]); } } 

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

Ой, подожди! Мы не определили метод sendCode внутри модели Token ?

Добавление Twilio

Перед отправкой кода пользователю через SMS нам необходимо настроить Twilio для работы. Нам нужно создать новый пробный аккаунт .

После этого перейдите на страницу консоли Twilio и скопируйте идентификатор своей учетной записи и токен авторизации . Последняя часть — создать новый номер телефона для отправки SMS. Перейдите на страницу телефонных номеров консоли и создайте новую.

 TWILIO_SID=XXXXXXXXXXX TWILIO_TOKEN=XXXXXXXXXXXX TWILIO_NUMBER=+XXXXXXXXXX 

Twilio имеет официальный пакет PHP, который мы можем использовать.

 composer require twilio/sdk 

Чтобы использовать пакет Twilio, мы собираемся создать нового провайдера и привязать его к контейнеру:

 pgp artisan make:provider TwilioProvider 
 // app/Providers/TwilioProvider.php use Illuminate\Support\ServiceProvider; use Twilio\Rest\Client; class TwilioProvider extends ServiceProvider { /** * Bootstrap the application services. * * @return void */ public function boot() {} /** * Register the application services. * * @return void */ public function register() { $this->app->bind('twilio', function() { return new Client(env('TWILIO_SID'), env('TWILIO_TOKEN')); }); } } 

Теперь мы можем наконец вернуться к нашему методу sendCode :

 // app/Token.php class Token extends Model { //... public function sendCode() { if (! $this->user) { throw new \Exception("No user attached to this token."); } if (! $this->code) { $this->code = $this->generateCode(); } try { app('twilio')->messages->create($this->user->getPhoneNumber(), ['from' => env('TWILIO_NUMBER'), 'body' => "Your verification code is {$this->code}"]); } catch (\Exception $ex) { return false; //enable to send SMS } return true; } } 

Если текущий токен не привязан к пользователю, функция сгенерирует исключение. В противном случае он попытается отправить им SMS.

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

Тестирование приложения

Вывод

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

Вы когда-нибудь использовали 2FA для приложения? С какими проблемами вы столкнулись? Это был хороший опыт для ваших пользователей?

Если у вас есть какие-либо вопросы или комментарии по поводу 2FA или Laravel, вы можете опубликовать их ниже!