Регистрация с авторизацией и сбросом пароля
Мы вплотную подошли к оформлению заказов, но тут есть загвоздка - нам нужно регистрировать покупателя, потому что адрес доставки привязывается к его модели.
Следовательно, нам необходимо сделать на публичном сайте нормальную регистрацию с авторизацией и сбросом пароля.
Чем сегодня и займёмся.
Новые методы модели пользователя
Раньше у нас было только 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. Дальше это всё можно украшать, но общая логика останется той же.
Все изменения смотрите в итоговом коммите.
0
👍
👎
❤️
🔥
😮
😢
😀
😡
260
07.09.2023, 09:05:05
5 комментариев
NightRider
08.09.2023, 12:13:46
Тут похоже на опечатку.
Василий Наумкин
08.09.2023, 13:54:16
Спасибо, поправил!
Сергей Лелеко
09.09.2023, 22:01:35
Для чего применяется clone $c при проверки полей пользователя, то есть зачем в данном случае применяется клонирование? Раннее вроде достаточно было работы с $c.
Василий Наумкин
10.09.2023, 04:39:49
Если не клонировать переменную, то во втором запросе сохранится where из первого, и проверка будет работать неправильно.
Сергей Лелеко
10.09.2023, 09:23:09
Понял! да , сталкивался с этой проблемкой
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Василий Наумкин
03.12.2024, 13:13:34
Генерация - это создание статичный файлов, для их работы потом pm2 не нужен, только правильная настр...
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен.
Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500
Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи.
...
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо!
Извини, тупанул.