Регистрация с авторизацией и сбросом пароля

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

Новые методы модели пользователя

Раньше у нас было только 2 контроллера безопасности: Login и Logout, сейчас добавится еще 3: Register, Reset и Activate.
Для удобства их работы, нужно обновить модель User и добавить некоторые полезные методы.

    // Этот метод уже есть - просто напоминаю исходный код
    public function resetPassword($length = 20): string
    {
        $tmp = bin2hex(random_bytes($length));
        $this->setAttribute('tmp_password', $tmp);
        $this->save();

        return $tmp;
    }

    // Ссылка на отправку почты юзеру
    public function sendEmail(string $subject, string $tpl, ?array $data = []): ?string
    {
        return (new Mail())->send($this->email, $subject, $tpl, $data);
    }

    // Метод заполнения свойств пользователя с проверкой данных
    public function fillData($data): User
    {
        // Пробегаем по присланным данным
        array_walk($data, static function (&$val) {
            if (is_string($val)) {
                // Обрезаем пробелы у строк
                $val = trim($val);
                if (empty($val)) {
                    // Пустые строки приводим к null
                    $val = null;
                }
            }
        });

        // Дальше проверка обязательных свойств
        if (empty($data['username'])) {
            throw new RuntimeException('errors.user.no_username');
        }
        if (empty($data['fullname'])) {
            throw new RuntimeException('errors.user.no_fullname');
        }
        if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
            throw new RuntimeException('errors.user.no_email');
        }

        // А теперь проверка уникальности свойств
        $c = self::query();
        // Данные могут заполнятся и для существующих юзеров
        if ($this->id) {
            // Так что проверяем уникальность с учётом id
            $c->where('id', '!=', $this->id);
        }

        if ((clone $c)->where('username', $data['username'])->count()) {
            throw new RuntimeException('errors.user.username_exists');
        }

        if ((clone $c)->where('email', $data['email'])->count()) {
            throw new RuntimeException('errors.user.email_exists');
        }

        $this->fill($data);

        // Обратно возвращаем самого юзера
        return $this;
    }

    // Этот метод создаёт новый токен авторизации
    public function createToken(?string $ip = null): UserToken
    {
        //  Создаём токен через связь с моделью
        $token = $this->tokens()->create([
            'token' => Jwt::makeToken($this->id),
            'valid_till' => date('Y-m-d H:i:s', time() + getenv('JWT_EXPIRE')),
            'ip' => $ip,
        ]);

        // Ограничение на активные "сессии" пользователя
        if ($max = getenv('JWT_MAX')) {
            $c = $this->tokens()->where('active', true);
            if ($c->count() > $max && $res = $c->orderBy('updated_at')->orderBy('created_at')->first()) {
                $res->update(['active' => false]);
            }
        }

        return $token;
    }
Итак, теперь проверка свойств пользователя и создание токена авторизации лежит на модели User.

Контроллер входа на сайт

Обновляем контроллер Security/Login:
<?php

namespace App\Controllers\Security;

// ...

class Login extends Controller
{
    public function post(): ResponseInterface
    {
        // Получение логина и пароля
        $username = trim($this->getProperty('username', ''));
        $password = trim($this->getProperty('password', ''));

        // Получение юзера
        $user = User::query()->where('username', $username)->first();
        // Проверка пароля
        if (!$user || !$user->verifyPassword($password)) {
            return $this->failure('errors.login.wrong');
        }
        // Проверка статуса
        if (!$user->active) {
            return $this->failure('errors.user.inactive');
        }
        if ($user->blocked) {
            return $this->failure('errors.user.blocked');
        }

        // Генерация пароля
        $token = $user->createToken($this->request->getAttribute('ip_address'));

        // Возврат токена
        return $this->success(['token' => $token->token]);
    }

}
Мы больше не расширяем базовый контроллер из Vesp, а делаем всё самостоятельно.

Контроллер выхода с сайта

Тут еще проще - надо просто деактивировать текущий токен юзера. Этот контроллер тоже есть, просто немного оптимизировал:
<?php

namespace App\Controllers\Security;

// ...

class Logout extends Controller
{
    // Контроллер требует авторизацию
    protected $scope = 'profile';

    public function post(): ResponseInterface
    {
        // Ищем запись в БД
        if ($userToken = $this->user->tokens()->find($this->request->getAttribute('token'))) {
            // И дективируем
            $userToken->active = false;
            $userToken->save();
        }

        // В любом случае успешный ответ
        return $this->success();
    }
}

Контроллер регистрации

Благодаря тому, что мы задали общий метод User::fillData(), его можно использовать в любом месте приложения.
Что мы и делаем в контроллере авторизации:
<?php

namespace App\Controllers\Security;

// ...

class Register extends Controller
{
    public function post(): ResponseInterface
    {
        try {
            $user = new User();
            $properties = $this->getProperties();
            // Юзеру будет нужно подтвердить свой email
            $properties['active'] = false;
            // Форсируем группу User
            $properties['role_id'] = getenv('REGISTER_ROLE_ID') ?: 2;
            // Если не указан пароль - генерируем случайный
            if (empty($properties['password'])) {
                $properties['password'] = bin2hex(random_bytes(20));
            }
            // Заполняем модель данными с проверкой
            $user->fillData($properties);
            $user->save();
        } catch (\Exception $e) {
            // Если что-то не так, здесь будет сообщение об ошибке
            return $this->failure($e->getMessage());
        }

        // Про отправку почты см. ниже

        $lang = $this->request->getHeaderLine('Content-Language') ?: 'ru';
        $subject = getenv('EMAIL_SUBJECT_REGISTER_' . strtoupper($lang));
        $data = ['user' => $user->toArray(), 'code' => $user->resetPassword(10), 'lang' => $lang];
        if ($error = $user->sendEmail($subject, 'email-register', $data)) {
            return $this->failure($error);
        }

        return $this->success();
    }
}
Контроллер попробует создать юзера, если что-то из присланных данных не пройдёт проверку - получим ошибку.

Контроллер "сброса пароля"

Как такового именно сброса пароля у нас нет. У нас есть создание временного пароля методом User::resetPassword.
Этот пароль мы отправим на почту, а дальше юзер должен прислать нам его обратно. Единственный способ получить временный пароль, очевидно, владеть указанной почтой. Сам временный пароль в базе хранится в виде хэша.
После успешного входа юзер сможет поменять свой пароль в профиле.
<?php

namespace App\Controllers\Security;

// ...

class Reset extends Controller
{
    public function post(): ResponseInterface
    {
        $username = trim($this->getProperty('username', ''));

        // Получем юзера по логину или почте
        $user = User::query()->where('username', $username)->first();
        if (!$user && strpos($username, '@')) {
            $user = User::query()->where('email', $username)->first();
        }

        if ($user) {
            // Заблокированым пользователям сбрасывать нечего
            if ($user->blocked) {
                return $this->failure('errors.user.blocked');
            }
            // Без почты мы ничего не отправим
            if (!$user->email) {
                return $this->failure('errors.user.no_email');
            }

            // Отправляем новый код в письме

            $lang = $this->request->getHeaderLine('Content-Language') ?: 'ru';
            $subject = getenv('EMAIL_SUBJECT_RESET_' . strtoupper($lang));
            $data = ['user' => $user->toArray(), 'code' => $user->resetPassword(10), 'lang' => $lang];
            if ($error = $user->sendEmail($subject, 'email-reset', $data)) {
                return $this->failure($error);
            }
        }

        return $this->success();
    }
}
У временного пароля нет срока действия, но он будет удалён при активации.

Контроллер активации

Этот контроллер, по сути, даёт вход на сайт по одноразовой ссылке из письма. Мы его используем и для подверждения зарегистрированной учётки, и для сброса пароля.
В обоих случаях при указании верного username и code юзер получит токен авторизации через User::createToken().
<?php

namespace App\Controllers\Security;

// ...

class Activate extends Controller
{
    public function post(): ResponseInterface
    {
        $username = trim($this->getProperty('username', ''));
        $code = trim($this->getProperty('code', ''));

        // Поиск юзера
        if (!$user = User::query()->where('username', $username)->first()) {
            return $this->failure('', 404);
        }

        // Активация нового пароля
        if (!$user->activatePassword($code)) {
            return $this->failure('', 403);
        }

        // Если всё хорошо - возвращаем токен
        $token = $user->createToken($this->request->getAttribute('ip_address'));

        return $this->success(['token' => $token->token]);
    }
}

Маршруты

Обновляем core/routes.php:
$group->group(
    '/security',
    static function (RouteCollectorProxy $group) {
        $group->map(['OPTIONS', 'POST'], '/login', App\Controllers\Security\Login::class);
        $group->map(['OPTIONS', 'POST'], '/logout', App\Controllers\Security\Logout::class);
        $group->map(['OPTIONS', 'POST'], '/register', App\Controllers\Security\Register::class);
        $group->map(['OPTIONS', 'POST'], '/reset', App\Controllers\Security\Reset::class);
        $group->map(['OPTIONS', 'POST'], '/activate', App\Controllers\Security\Activate::class);
    }
);
Все запросы в группу security через POST.

Отправка писем

Для отправки почты я использую новый метод User::sendEmail(), который просто вызывает наш почтовый сервис.
Язык для оформления писем получается из заголовка запроса на сервер, а тема письма из .env:
EMAIL_SUBJECT_REGISTER_RU="Подтвердите регистрацию"
EMAIL_SUBJECT_REGISTER_EN="Confirm registration"
EMAIL_SUBJECT_RESET_RU="Ссылка для входа"
EMAIL_SUBJECT_RESET_EN="Login link"
Так-то у нас все лексиконы в файлах javascript, поэтому для почты приходится использовать переменные окружения.
На другом проекте я сделал отдельную подсистему работы с почтой, в которой и шаблоны, и темы писем редактируются сразу из админки.
Но здесь нам такого пока не надо.
Оформление писем у нас через Fenom, поэтому можно использовать всякие условия и переменные. Например, вот так выглядит вход по одноразовой ссылке.
{extends 'email.tpl'}

{block 'content'}
    {var $link = $.env.SITE_URL ~ 'service/confirm/' ~ $user.username ~ '/' ~ $code}
    {if $lang === 'en'}
        <h2>Login link</h2>
        <p>
            <a href="{$link}">{$link}</a>
        </p>
    {else}
        <h2>Ссылка для входа</h2>
        <p>
            <a href="{$link}">{$link}</a>
        </p>
    {/if}
{/block}
Юзер получает письмо и кликает по ссылке.
Для того, чтобы обработать ссылку нашего формата service/confirm/:username/:code нужно создать соответствующую страничку:
Нижние подчёркивания у директории и страницы говорят Nuxt о том, что это динамические переменные маршрута.
Содержимое страницы:
<script>
export default {
  async asyncData({params, redirect, app}) {
    // Проверка авторизации и переменных
    if (!app.$auth.loggedIn && params.username && params.code) {
      try {
        // Запрос на сервер
        const {data} = await app.$axios.post('security/activate', params)
        if (data.token) {
          // Сохранение токена, если есть
          await app.$auth.setUserToken(data.token)
        }
      } catch (e) {}
    }
    redirect({name: 'index'})
  },
  render() {
    return null
  },
}
</script>
У этой страницы вообще нет тега template, и чтобы Nuxt не ругался, я добавляю пустую функцию render().
Нам не нужно никакого оформления, задача страницы - отправить запрос в API и сделать редирект на главную. Если получится авторизоваться - хорошо, если нет, то API вернёт ошибку. В любом случае, страница никогда не отрисовывается, а только делает редирект.
Таким образом можно делать и другие страницы для переходов по прямым ссылкам, которые должны отправить что-то в API.

Компонент авторизации

Переходим на фронтенд.
Тут у нас должно быть сразу 3 формы: вход, выход и сброс пароля. Не буду привозить их код, там ничего интересного, но покажу общий компонент <app-login />, где они все используются:
<template>
  <vesp-modal :url="url" :value="model" hide-header action-create="post" v-on="listeners">
    <template #form-fields>
      <b-tabs v-model="tab" pills justified content-class="pt-4">
        <b-tab :title="$t('security.login')" lazy>
          <form-login v-model="login" />
        </b-tab>
        <b-tab :title="$t('security.register')" lazy>
          <form-register v-model="register" />
        </b-tab>
        <b-tab :title="$t('security.reset')" lazy>
          <form-reset v-model="reset" />
        </b-tab>
      </b-tabs>
    </template>
  </vesp-modal>
</template>
Внутри одной общей формы находятся сразу 3, но все они внутри "ленивых" вкладок, которые отрисовываются только при активации.
Таким образом, одномоментно внутри формы поля только активной вкладки, и никаких других.
Переменные url и model формируются динамически, иходя из активной вкладки. Обычно vesp-modal отправляет форму без id через метод PUT, поэтому мы указываем делать это через POST.
<script>
// Импорт форм
import FormLogin from '~/components/forms/login'
import FormRegister from '~/components/forms/register'
import FormReset from '~/components/forms/reset'

export default {
  name: 'AppLogin',
  components: {FormReset, FormRegister, FormLogin},
  data() {
    return {
      // Активная вкладка
      tab: 0,
      // Переменные для разных форм
      login: {},
      register: {},
      reset: {},
    }
  },
  computed: {
    // Определяем динамический url и данные формы для отправки
    url() {
      return '/security/' + (!this.tab ? 'login' : this.tab === 1 ? 'register' : 'reset')
    },
    model() {
      return !this.tab ? this.login : this.tab === 1 ? this.register : this.reset
    },
    // Добавляем реакцию на успешную отправку формы
    listeners() {
      return {...this.$listeners, 'after-submit': this.afterSubmit}
    },
  },
  methods: {
    // Сохраняем токен авторизации при успешном логине
    afterSubmit(data) {
      if (!this.tab && data.token) {
        this.$auth.setUserToken(data.token)
      }
    },
  },
}
</script>
Благодаря реактивности Vue и функционалу vesp-modal я смог создать очень компактный компонент для входа на сайт.

Добаботка панели навигации

На видео выше заметно, что кнопка авторизации находится в <app-navbar />, давайте посмотрим, как она работает:
<b-navbar variant="light" toggleable="md">
  <b-container class="flex-nowrap">
   <!-- Здесь логотип --> 

  <!-- Проверка авторизации -->
    <div class="ml-auto">
        <b-button v-if="!$auth.loggedIn" @click="showLogin = true">
        <fa icon="right-to-bracket" />
      </b-button>

      <!-- Если юзер авторизован, выводим droopdown -->
      <b-dropdown v-else>
        <template #button-content>
          <fa icon="user" />
        </template>

        <!-- Имя, редактрования профиля и выход -->
        <b-dropdown-text>{{ $auth.user.fullname }}</b-dropdown-text>
        <b-dropdown-divider></b-dropdown-divider>
        <b-dropdown-item @click="showProfile = true">{{ $t('security.profile') }}</b-dropdown-item>
        <b-dropdown-item link-class="d-flex justify-content-between align-items-center" @click="onLogout">
          {{ $t('security.logout') }}
          <fa icon="right-from-bracket" />
        </b-dropdown-item>
      </b-dropdown>
    </div>

  <!-- Здесь корзина с кнопкой -->

  <!-- В зависимости от авторизации рендерится или вход, или профиль -->
  <app-login v-if="!$auth.loggedIn && showLogin" @hidden="showLogin = false" />
  <user-profile v-else-if="$auth.loggedIn && showProfile" @hidden="showProfile = false" />
</b-navbar>
Про редактирование профиля будет ниже, а показ компонента и выход с сайта работают вот так:
  data() {
    return {
      showCart: false,
      showLogin: false,
      showProfile: false,
    }
  },
  methods: {
    // В настройках Vesp по умолчанию прописано делать запрос на адрес /api/security/logout
    async onLogout() {
      await this.$auth.logout()
    },
  },
Навигационная панель позволяет нам сделать логин и разлогин на любой странице сайта.

Редактирование профиля

Мы уже приготовили очень здоровскую форму редактирования пользователя в админке, не вижу причин не использовать её и здесь. Нужно только задать возможность для отключения вывода некоторых полей.
Редактируем admin/components/forms/user.vue:
export default {
  name: 'FormUser',
  components: {FileUpload},
  props: {
    value: {
      type: Object,
      required: true,
    },
    showGroup: {
      type: Boolean,
      default: true,
    },
    showComment: {
      type: Boolean,
      default: true,
    },
    showStatus: {
      type: Boolean,
      default: true,
    },
  },
// ...
Дописываем этим полям условия v-if и теперь компонент можно импортировать на страницу профиля юзера админки admin/pages/user/profile.vue:
<template>
  <div>
    <b-form @submit.prevent="onSubmit">
      <!-- форма с отключением ненужных полей -->
      <form-user v-model="record" :show-group="false" :show-comment="false" :show-status="false" />

      <b-button variant="primary" type="submit">{{ $t('actions.submit') }}</b-button>
    </b-form>
  </div>
</template>

<script>
import FormUser from '../../components/forms/user'

export const url = 'user/profile'
export default {
  name: 'ProfilePage',
  components: {FormUser},
// ...
Чтобы работала загрузка файла, обновляем контроллер User/Profile:
<?php

namespace App\Controllers\User;

// ...

class Profile extends Controller
{
    // Подключаем файловый трейт
    use FileModelController;

    protected $scope = 'profile';

    // Указываем переменную для загрузки
    public $attachments = ['file'];

    // Получение профиля пользователя
    public function get(): ResponseInterface
    {
        $data = $this->user->toArray();
        $data['scope'] = $this->user->role->scope;
        $data['cart'] = $this->user->cart->id ?? null;
        // Подключаем вывод аватарки 
        $data['file'] = $this->user->file ? $this->user->file->only('id', 'updated_at') : null;

        return $this->success(['user' => $data]);
    }

    public function patch(): ResponseInterface
    {
        // Проверка присланных данных
        try {
            $this->user->fillData($this->getProperties());
        } catch (\Exception $e) {
            return $this->failure($e->getMessage());
        }

        // Поддержка загрузки аватарки
        if ($error = $this->processFiles($this->user)) {
            return $error;
        }

        // Сохраняем
        $this->user->save();
        // И перезагружаем юзера из БД
        $this->user->refresh();

        // Чтобы вернуть уже свежие данные
        return $this->get();
    }
}
Здесь мы подключаем наш трейт FileModelController и добавляем проверку присылаемых данных через общий метод юзера.
Остаётся только использовать обновлённую форму юзера и на публичном сайте.
Пишем компонент <user-profile />. В отличие от страницы админки, на сайте он работает в модальном окошке:
<template>
  <vesp-modal v-model="record" :url="url" :title="$t('security.profile')" size="lg" v-on="listeners">
    <template #form-fields>
      <!-- форма с отключением ненужных полей -->
      <form-user v-model="record" :show-group="false" :show-comment="false" :show-status="false" />
    </template>
  </vesp-modal>
</template>

<script>
// Импорт компонента из админки
import FormUser from '../../admin/components/forms/user'

export default {
  name: 'UserProfile',
  components: {FormUser},
  data() {
    return {
      url: 'user/profile',
      record: {},
    }
  },
  computed: {
    listeners() {
      return {...this.$listeners, 'after-submit': this.afterSubmit}
    },
  },
  mounted() {
    // Заполняем форму при рендере компонента данными юзера из хранилища
    this.record = {...this.$auth.user}
  },
  methods: {
    // При успешной отправке формы сохраняем ответ в хранилище
    afterSubmit({user}) {
      this.record = user
      this.$auth.setUser(user)
    },
  },
}
</script>
Вот так мы используем одну форму аж в трёх местах: редактрование юзера, профиль юзера адмики, профиль юзера на сайте.

Заключение

Результат всей работы выглядит вот так:
Конечно, я прописал новые строки в лексиконы, а также импортировал нужные компоненты BootstarpVue вместе с иконками FontAwesome в nuxt.config.js. Дальше это всё можно украшать, но общая логика останется той же.
Все изменения смотрите в итоговом коммите.

5 комментариев

Мы больше не расширяем ,fpjdsq контроллер из Vesp
Тут похоже на опечатку.
Василий Наумкин
Спасибо, поправил!
Сергей Лелеко
Для чего применяется clone $c при проверки полей пользователя, то есть зачем в данном случае применяется клонирование? Раннее вроде достаточно было работы с $c.
Василий Наумкин
Если не клонировать переменную, то во втором запросе сохранится where из первого, и проверка будет работать неправильно.
Сергей Лелеко
Понял! да , сталкивался с этой проблемкой
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Василий Наумкин
03.12.2024, 13:13:34
Генерация - это создание статичный файлов, для их работы потом pm2 не нужен, только правильная настр...
Василий Наумкин
22.11.2024, 03:33:54
Спасибо!
inna
06.11.2024, 15:47:13
Да. Все работает. Спасибо.
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.