Správa uživatelů

Administrace nám funguje už celkem dobře. Umíme sice zatím jen pracovat se stránkami, ale už snad tušíš co všechno by se dalo dělat. Je ale samozřejmě nesmysl, aby kdokoli, kdo zadá /admin, viděl administraci a mohl dělat co ho napadne. Takže dnes si vyrobíme model pro práci s uživateli. Jakmile bude někdo chtít do administrace, donutíme ho přihlásit se.

Zachováme celý princip modularity, takže ve složce src vyrobíme složku UserModule a v ní postupně model, Admin a Front, DI. Bude to stejná struktura, jako máme u PageModule. Postupně budu zkracovat postupy a počítám s tím, že už spoustu věcí zvládneš sám, jen s menší pomocí. V každém případě ti na konci vždy nabídnu všechno ke stažení, takže i kdyby se ti to nepovedlo, prostě si pak zdroje stáhni a prostuduj si je.

Tak začneme entitou User.php. Budeme tam mít informace o uživateli, plus 2 pole, která použijeme v případě zapomenutého hesla - tomu se bude věnovat příští kapitola. Takže obsah:

<?php

namespace UserModule\Model\Entity;

use BaseModule\Model\Entity\IdentifiedEntity;
use Doctrine\ORM\Mapping as ORM;

/**
 * User
 *
 * @ORM\Table(name="user",
 *     indexes={@ORM\Index(name="username", columns={"username"}),
 *     @ORM\Index(name="email", columns={"email"})})
 * @ORM\Entity
 */
class User extends IdentifiedEntity
{
    use \Kdyby\Doctrine\Entities\Attributes\Identifier;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=125, nullable=false)
     */
    private $username;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=125, nullable=false)
     */
    private $password;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=125, nullable=true)
     */
    private $firstName;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=125, nullable=true)
     */
    private $lastName;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=125, nullable=true)
     */
    private $email;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=25, nullable=true)
     */
    private $role;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=125, nullable=true)
     */
    private $passwordHash;

    /**
     * @var \DateTime
     *
     * @ORM\Column(type="datetime", nullable=true)
     */
    private $passwordHashValidity;

Gettery a settery si vygeneruj, nebo napiš samostatně. Žádný zádrhel v tom není a je to vlastně podobné jako v případě stránek. Další je na řadě UserFacade.php. Tam budou běžné metody pro výběr všech záznamů, nalezení konkrétních, vložení či smazání. To můžeš s klidem zkopírovat z PageFacade. Co ale bude nová metoda, to je login() pro přihlášení a logout pro odhlášení.

Malý rozdíl ale je - konstruktor vyžaduje objekt Security\User:

<?php

namespace UserModule\Model\Facade;

use Nette\Security;
use Nette\Security\Passwords;
use Nette\Security\User as SecUser;
use Kdyby\Doctrine\EntityManager;
use Kdyby\Doctrine\InvalidStateException;
use UserModule\Model\Entity\User;

final class UserFacade
{

    /** @var EntityManager */
    private $em;

    /** @var \Kdyby\Doctrine\EntityRepository */
    private $repository;

    /** @var SecUser */
    private $user;

    /**
     * UserFacade constructor.
     *
     * @param EntityManager $em
     * @param SecUser       $user
     */
    public function __construct(EntityManager $em, SecUser $user)
    {
        $this->em = $em;
        $this->repository = $em->getRepository(User::class);
        $this->user = $user;
    }

Metoda pro přihlášení uživatele je celkem komplexní. Řeší nesprávné uživatelské jméno, nesprávné heslo a potřebu změnit hash hesla. Pokud ale vše sedí, uživatele přihlásí:

    /**
     * Method login
     *
     * @param $username
     * @param $password
     *
     * @return \Nette\Security\User
     * @throws \Nette\Security\AuthenticationException
     * @throws \Exception
     */
    public function login($username, $password)
    {
        $user = $this->repository->findOneBy(['username' => $username]);
        if (!$user) {
            throw new Security\AuthenticationException(
                'The username is incorrect.',
                Security\IAuthenticator::IDENTITY_NOT_FOUND
            );
        } elseif (!Passwords::verify($password, $user->getPassword())) {
            throw new Security\AuthenticationException(
                'The password is incorrect.',
                Security\IAuthenticator::INVALID_CREDENTIAL
            );
        } elseif (Passwords::needsRehash($user->getPassword())) {
            $user->setPassword(Passwords::hash($password));
            $this->em->persist($user);
            try {
                $this->em->flush();
            } catch (\Exception $e) {
                throw $e;
            }
        }
        $arr = $user->extract();
        $this->user->getStorage()->setNamespace('Admin');
        $this->user->login(new Security\Identity($arr['id'], $arr['username'], $arr));

        return $this->user;
    }

Tohle si možná menší vysvětlení zaslouží. Tak metoda přijímá jako argumenty username a password.

  • Nejprve se pokusí najít uživatele podle username. Pokud uživatel není nalezen, končí výjimkou
  • Pokud uživatelenajde, ověří správnost hesla. Hesla se samozřejmě neukládají, pouze jejich hashe, takže porovnáváme uložený hash s hashem odeslaného hesla. Pokud se neshodují, končíme zase výjimkou
  • Jesli existuje uživatel a trefili jsme heslo, pak se ještě zkontroluje, jestli se nemá vygenerovat jiný hash. Pokud ano, vygeneruje se a uloží se. To je mimochodem ochrana proti rozšifrování hesel. Více v příštím článku
  • Pak už jen nastavíme jmenný prostor pro přihlášení, což je 'Admin' a uživatele přihlásíme

Metoda pro odhlášení je jednoduchá - nic nekontroluje a jen odhlásí uživatele:

    /**
     * Method logout
     *
     * @return \Nette\Security\User
     */
    public function logout()
    {
        $this->user->getStorage()->setNamespace('Admin');
        $this->user->logout();

        return $this->user;
    }

Další na řadě je UserExtension.php ve složce DI. Opět vlastně stejné jako v případě PageExtension.php:

<?php

namespace UserModule\DI;

use Nette;
use BaseModule\DI\BaseExtension;
use Kdyby\Doctrine\DI\IEntityProvider;

final class UserExtension extends BaseExtension implements IEntityProvider
{
    /**
     * Method getEntityMappings
     *
     * @return array
     */
    public function getEntityMappings()
    {
        return [
            'User' => __DIR__ . '/../Model/Entity/',
        ];
    }

    /**
     * Method loadConfiguration
     */
    public function loadConfiguration()
    {
        $builder = $this->getContainerBuilder();

        $this->compiler->loadDefinitions(
            $builder,
            $this->loadFromFile(__DIR__ . '/services.neon')['services'],
            $this->name
        );
        $this->appendRoute('User:Admin', 'admin/user/<action>[/<id>]', 'User:default');
    }

    /**
     * Method beforeCompile
     *
     * @throws \Nette\DI\ServiceCreationException
     */
    public function beforeCompile()
    {
        $builder = $this->getContainerBuilder();
        $builder->getDefinition($builder->getByType(Nette\Application\IPresenterFactory::class))->addSetup(
            'setMapping',
            [['User' => 'UserModule\*Module\Presenters\*Presenter']]
        );
    }
}

V services.neon zatím bude jen jedna služba:

services:
    - UserModule\Model\Facade\UserFacade

Aby systém věděl, že nějaká služba existuje, doplníme do souboru app/config/common.php řádek:

user: UserModule\DI\UserExtension

Dej ho nad řádek page. Je to kvůli routingu. Systém totiž prochází všechny routy zeshora dolů a když nenajde nic odpovídajícího, předpokládá, že se jená o stránku. Proto by stránky měly vždy zůstat jako poslední. Je na čase vygenerovat MySQL tabulku. Aby to fungovalo, smaž složku cache, která leží ve složce temp. Pak ve složce www spusť command line a zadej příkaz:

php index.php orm:schema-tool:update -f

Měla by se vygenerovat tabulka user.

No a pak už je to klasika - potřebujeme 3 komponenty - Grid pro výpis, Add pro přidání a Detail pro editaci záznamu. Každá komponenta má opět 3 části: interface, třídu a šablonu. Dá se to zkoro kopírovat modul od modulu. Čili tady jen na ukázku předvedu třídu Add.php:

<?php

namespace UserModule\AdminModule\Components\User\Add;

use BaseModule\Components\FormFactory;
use UserModule\Model\Entity\User;
use UserModule\Model\Facade\UserFacade;
use Nette\Application\UI\Control;
use Nette\Application\UI\Form;
use Nette\Security\Passwords;

class UserAdd extends Control
{

    /**
     * @var IUserAddFactory @inject
     */
    public $addFactory;

    /**
     * @var FormFactory
     */
    private $formFactory;

    /**
     * @var string
     */
    private $latteFile = __DIR__ . '/User.add.latte';

    /**
     * @var UserFacade
     */
    private $facade;

    /**
     * UserAdd constructor.
     *
     * @param UserFacade  $facade
     * @param FormFactory $formFactory
     */
    public function __construct(UserFacade $facade, FormFactory $formFactory)
    {
        parent::__construct();
        $this->formFactory = $formFactory;
        $this->facade = $facade;
    }

    /**
     * Method render
     */
    public function render()
    {
        $this->getTemplate()->setFile($this->latteFile);
        $this->getTemplate()->render();
    }

    /**
     * Method createComponentAddForm
     *
     * @return \Nette\Application\UI\Form
     */
    public function createComponentAddForm()
    {
        $roles = [
            'registered' => 'Registrovaný uživatel',
            'admin' => 'Administrátor',
            'host' => 'Host',
        ];

        $form = $this->formFactory->create();
        $form->addText('username', 'Login')
            ->setRequired(true)
            ->setAttribute('class', 'col-sm-9 form-control form-control-sm');
        $form->addPassword('password', '')
            ->setAttribute('class', 'col-sm-9 form-control form-control-sm');
        $form->addText('first_name', 'Jméno')
            ->setRequired(true)
            ->setAttribute('class', 'col-sm-9 form-control form-control-sm');
        $form->addText('last_name', 'Příjmení')
            ->setRequired(true)
            ->setAttribute('class', 'col-sm-9 form-control form-control-sm');
        $form->addText('email', 'Email')
            ->setRequired(true)
            ->setAttribute('class', 'col-sm-9 form-control form-control-sm');
        $form->addSelect('role', 'Role: ', $roles)
            ->setRequired(true)
            ->setAttribute('class', 'col-sm-9 form-control form-control-sm');
        $form->addSubmit('send', 'odeslat')
            ->setAttribute('class', 'btn btn-primary pull-left');

        $form->onSuccess[] = function (Form $form) {
            $values = $form->getValues();
            $values->password = Passwords::hash($values->password);

            $entity = new User();
            $entity->hydrate($values);

            $this->facade->insert($entity);
        };

        return $form;
    }
}

Je tu asi jediná zajímavá věc, a ta se děje po odeslání formuláře. Nejprve heslo, které bylo odesláno z přidávacího formuláře, zaměníme za jeho hashovanou podobu. Poté teprve proběhne hydratace objektu User. Následně se uloží a to je vše.

Nezapomeň na přidání všech 3 služeb do services.neon v DI:

    - UserModule\AdminModule\Components\User\Grid\IUserGridFactory
    - UserModule\AdminModule\Components\User\Detail\IUserDetailFactory
    - UserModule\AdminModule\Components\User\Add\IUserAddFactory

Samozřejmě musíš upravit i presenter a vyrobit v něm komponenty, plus vytvořit odpovídající šablony a vložit komponenty do nich. To už ale nechám na tobě.

Vyzkoušej si vložit uživatele na adrese http://rsrs.loc/admin/user/add.

Kdyby sis nevěděl rady, stáhni si dnešní práci. Příště si ukážeme jak se přihlásit, zaslat si zapomenuté heslo a jak se odhlásit.

Celý seriál o vývoji redakčního systému

O mně

Jmenuji se Rudolf Svátek - lektor výpočetní techniky, trochu PHP programátor a SEO konzultant na volné noze.

Adresa

Příčná 326/3
736 01 Havířov

Kontakty

Email: office@rudolfsvatek.cz
Telefon: +420 777 828 353
Skype: svatekr