Пишем систему авторизации на PHP

Сегодня мы напишем авторизацию пользователя на сайте. Вся система будет работать следующим образом: пользователь вводит логин и пароль на форме входа, если они правильные – в Cookie браузера будет установлена специальная запись – auth token (авторизационный токен). При дальнейших запросах на сервер этот токен будет проверяться и если он будет правильным, то пользователь считается авторизованным.

Первым делом решаем, что страница с формой логина и пароля будет находиться по адресу http://myproject.loc/users/login. Создаём соответствующий роут.

src/routes.php

...
'~^users/login$~' => [\MyProject\Controllers\UsersController::class, 'login'],
...

После этого добавляем новый экшен в контроллере.

src/MyProject/Controllers/UsersController.php

public function login()
{
    $this->view->renderHtml('users/login.php');
}

И, наконец, создаём шаблон с формой для этого экшена.

templates/users/login.php

<?php include __DIR__ . '/../header.php'; ?>
    <div style="text-align: center;">
        <h1>Вход</h1>
        <?php if (!empty($error)): ?>
            <div style="background-color: red;padding: 5px;margin: 15px"><?= $error ?></div>
        <?php endif; ?>
        <form action="/users/login" method="post">
            <label>Email <input type="text" name="email" value="<?= $_POST['email'] ?? '' ?>"></label>
            <br><br>
            <label>Пароль <input type="password" name="password" value="<?= $_POST['password'] ?? '' ?>"></label>
            <br><br>
            <input type="submit" value="Войти">
        </form>
    </div>
<?php include __DIR__ . '/../footer.php'; ?>

Теперь можно зайти в браузер и убедиться, что форма открывается.
Форма авторизации

Теперь нам нужно добавить обработку отправленной формы и добавить в модели пользователя метод для логина.

src/MyProject/Models/Users/User.php

public static function login(array $loginData): User
{
    if (empty($loginData['email'])) {
        throw new InvalidArgumentException('Не передан email');
    }

    if (empty($loginData['password'])) {
        throw new InvalidArgumentException('Не передан password');
    }

    $user = User::findOneByColumn('email', $loginData['email']);
    if ($user === null) {
        throw new InvalidArgumentException('Нет пользователя с таким email');
    }

    if (!password_verify($loginData['password'], $user->getPasswordHash())) {
        throw new InvalidArgumentException('Неправильный пароль');
    }

    if (!$user->isConfirmed) {
        throw new InvalidArgumentException('Пользователь не подтверждён');
    }

    $user->refreshAuthToken();
    $user->save();

    return $user;
}

public function getPasswordHash(): string
{
    return $this->passwordHash;
}

private function refreshAuthToken()
{
    $this->authToken = sha1(random_bytes(100)) . sha1(random_bytes(100));
}

Обратите внимание – при успешном входе auth token пользователя в базе обновляется – все его предыдущие сессии станут недействительными.

src/MyProject/Controllers/UsersController.php

public function login()
{
    if (!empty($_POST)) {
        try {
            $user = User::login($_POST);
        } catch (InvalidArgumentException $e) {
            $this->view->renderHtml('users/login.php', ['error' => $e->getMessage()]);
            return;
        }
    }
    $this->view->renderHtml('users/login.php');
}

Проверяем, что ошибки корректно обрабатываются. Для этого пробуем вводить некорректные логин и пароль, а также отправлять форму с пустыми полями.

Нет пользователя с таким email

Неправильный пароль

Отлично, теперь нужно добавить обработку ситуации, когда логин и пароль верны и метод login в модели User вернул нам пользователя.

Создадим специальный сервис, который будет работать с пользовательскими сессиями через Cookie. Назовём его UsersAuthService.

src/MyProject/Models/Users/UsersAuthService.php

<?php

namespace MyProject\Models\Users;

class UsersAuthService
{
    public static function createToken(User $user): void
    {
        $token = $user->getId() . ':' . $user->getAuthToken();
        setcookie('token', $token, 0, '/', '', false, true);
    }

    public static function getUserByToken(): ?User
    {
        $token = $_COOKIE['token'] ?? '';

        if (empty($token)) {
            return null;
        }

        [$userId, $authToken] = explode(':', $token, 2);

        $user = User::getById((int) $userId);

        if ($user === null) {
            return null;
        }

        if ($user->getAuthToken() !== $authToken) {
            return null;
        }

        return $user;
    }
}

А теперь мы можем использовать его для удобного создания нужной Cookie в контроллере.

src/MyProject/Controllers/UsersController.php

public function login()
{
    if (!empty($_POST)) {
        try {
            $user = User::login($_POST);
            UsersAuthService::createToken($user);
            header('Location: /');
            exit();
        } catch (InvalidArgumentException $e) {
            $this->view->renderHtml('users/login.php', ['error' => $e->getMessage()]);
            return;
        }
    }

    $this->view->renderHtml('users/login.php');
}

Теперь откроем консоль разработчика в Google Chrome и введем правильные логин и пароль. Видим, что нас перекинуло на главную страницу нашего блога, и что была установлена Cookie с именем token.

Авторизационный токен в Cookie

Теперь нам нужно научиться передавать пользователя во View. Согласитесь, пользователь нам понадобится почти на каждой странице сайта, в каждом экшене. И будет неудобно каждый раз при рендеринге шаблона прокидывать пользователя:

src/MyProject/Controllers/MainController.php

public function main()
{
    $articles = Article::findAll();
    $this->view->renderHtml('main/main.php', [
        'articles' => $articles,
        'user' => UsersAuthService::getUserByToken()
    ]);
}

Поэтому мы сделаем во View возможность добавлять переменные еще перед рендерингом, вот так:

src/MyProject/View/View.php

<?php

namespace MyProject\View;

class View
{
    private $templatesPath;

    private $extraVars = [];

    public function __construct(string $templatesPath)
    {
        $this->templatesPath = $templatesPath;
    }

    public function setVar(string $name, $value): void
    {
        $this->extraVars[$name] = $value;
    }

    public function renderHtml(string $templateName, array $vars = [], int $code = 200)
    {
        http_response_code($code);

        extract($this->extraVars);
        extract($vars);

        ob_start();
        include $this->templatesPath . '/' . $templateName;
        $buffer = ob_get_contents();
        ob_end_clean();

        echo $buffer;
    }
}

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

src/MyProject/Controllers/MainController.php

/** @var User|null */
private $user;

public function __construct()
{
    $this->user = UsersAuthService::getUserByToken();
    $this->view = new View(__DIR__ . '/../../../templates');
    $this->view->setVar('user', $this->user);
}

И добавить в шапке сайта (в шаблонах) вывод пользователя, если он был передан во View:

templates/header.php

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>Мой блог</title>
    <link rel="stylesheet" href="/styles.css">
</head>
<body>

<table class="layout">
    <tr>
        <td colspan="2" class="header">
            Мой блог
        </td>
    </tr>
    <tr>
        <td colspan="2" style="text-align: right">
            <?= !empty($user) ? 'Привет, ' . $user->getNickname() : 'Войдите на сайт' ?>
        </td>
    </tr>
    <tr>
        <td>

Обновим теперь страничку и увидим приветствие.
Приветствие пользователя

Если мы сейчас перейдём на страницу со статьёй, то увидим, что система просит нас залогиниться.
Пользователь не авторизован

Это потому что мы в контроллере статей не прокинули пользователя во View. Давайте добавим тот же код, что и в конструкторе MainController.

src/MyProject/Controllers/ArticlesController.php

/** @var User|null */
private $user;

public function __construct()
{
    $this->user = UsersAuthService::getUserByToken();
    $this->view = new View(__DIR__ . '/../../../templates');
    $this->view->setVar('user', $this->user);
}

Обновим страничку, и увидим, что теперь пользователь был передан в шаблон.
Приветствие авторизованного пользователя

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

src/MyProject/Controllers/AbstractController.php

<?php

namespace MyProject\Controllers;

use MyProject\Models\Users\User;
use MyProject\Models\Users\UsersAuthService;
use MyProject\View\View;

abstract class AbstractController
{
    /** @var View */
    protected $view;

    /** @var User|null */
    protected $user;

    public function __construct()
    {
        $this->user = UsersAuthService::getUserByToken();
        $this->view = new View(__DIR__ . '/../../../templates');
        $this->view->setVar('user', $this->user);
    }
}

Обратите внимание, свойства user и view теперь с типом protected – они будут доступны в наследниках. Ну а теперь нам достаточно просто отнаследоваться в наших контроллерах от этого класса и можно удалить в них конструкторы и свойства view и user – они будут унаследованы от AbstractController. Это существенно упростит их код.

src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

use MyProject\Models\Articles\Article;

class MainController extends AbstractController
{
    public function main()
    {
        $articles = Article::findAll();
        $this->view->renderHtml('main/main.php', ['articles' => $articles]);
    }
}

src/MyProject/Controllers/ArticlesController.php

<?php

namespace MyProject\Controllers;

use MyProject\Exceptions\NotFoundException;
use MyProject\Models\Articles\Article;
use MyProject\Models\Users\User;

class ArticlesController extends AbstractController
{
    public function view(int $articleId)
    {
    ...

src/MyProject/Controllers/UsersController.php

<?php

namespace MyProject\Controllers;

use MyProject\Exceptions\InvalidArgumentException;
use MyProject\Models\Users\User;
use MyProject\Models\Users\UserActivationService;
use MyProject\Models\Users\UsersAuthService;
use MyProject\Services\EmailSender;

class UsersController extends AbstractController
{
    public function signUp()
    {
    ...

Теперь можно пройтись по всем страничкам сайта и убедиться, что всё по-прежнему работает. Если теперь нам нужно будет добавить какой-то функционал для всех контроллеров, то мы просто сделаем это в AbstractController.

Ну вот и всё – наша система авторизации готова! Разумеется, есть еще несколько вещей, которые нужно сделать. Их вы реализуете самостоятельно в домашнем задании.

Текущее состояние проекта на гитхабе.

loader
Домашнее задание
  • Если пользователь залогинен, то сделайте рядом с приветствием ссылку для выхода из этой учетной записи. Реализуйте функционал для разлогинивания (роутинг + экшен, удаляющий cookie).
    Разлогин
  • Если пользователь не залогинен, показывайте ссылки для регистрации и входа на сайт.
    Регистрация и вход
Комментарии
Этот урок набрал набрал достаточно большое количество комментариев и дальнейшее его комментирование отключено. Если вы хотели убедиться в правильности выполнения ДЗ или у вас возник вопрос по уроку, посмотрите ранее добавленные комментарии, кликнув по кнопке ниже. Скорее всего вы найдете там то, что искали. Если это не помогло - задайте вопрос в чате в телеграме - https://t.me/php_zone
Логические задачи с собеседований