Zapomenuté heslo

Ruku na srdeční sval - kdo kdy nezapomněl nějaké heslo. Nejsem výjimka. Dokonce jsem si kvůli tomu napsal aplikaci, do které si hesla skládám. Musím si totiž evidovat hesla na FTP svých klientů, do administrací jejich redakčních systémů apod. Jestli ale zapomene heslo náš uživatel, nemůžeme mu ho vlastně říct, protože v naší databázi uživatelů neukládáme přímo čitelné heslo, ale jen jeho hash. Třeba:

\n$2y$10$pRWlZ22kM3AQgoYAKHSxtOh2pkxL9xsd0R2Z/167conLdUfA0N7jO

To je hash hesla, které v čitelné podobě zní: "user123". Je ovšem zajímavé, že když stejné heslo zadám jinému uživateli, ten má hash jiný. Třeba:

\n$2y$10$9wMxZoHAaDGWCk3U6.SCYeFNHCay4.GshYeqEfRThl1N9mNwFfo0i

A aby toho nebylo málo, tak když stejnému uživateli v administraci zadám znovu stejné heslo a uložím záznam, hash se zase změní. Nette to dělá takto šikovně, aby se nestalo, že si někdo stáhne databázi s účty uživatelů a zkouší si měnit své heslo. Kdyby se pak jeho hash shodoval s nějakým jiným uživatelem, znal by jeho heslo. Takhle mají oba uživatelé sice stejné heslo, ale hash jiný :-)

Jenže co dělat, když uživatel vážně heslo nezná? Určitě si zakážeme jakákoli hesla posílat mailem, volat přes telefon, nebo psát na papírek. My to uděláme fikaně. A v několika krocích:

  1. nejdříve zapomětlivému uživateli zobrazíme formulář, ve kterém ho požádáme o jeho přihlašovací jméno, nebo email. To by snad vědět měl. Pokud neví ani to, tak to má zatím fakt smůlu
  2. po odeslání formuláře zkusíme uživatele podle jeho username či mailu najít. Pokud se to povede, pak mu vygenerujeme náhodný hash, třeba z kombinace aktuálního času a jeho mailu a takový hash uložíme k jeho záznamu v tabulce user
  3. také si uděláme časovou značku, která bude říkat, jak dlouho je žádost o změnu hesla platná. Třeba hodinu, dvě, nebo jeden den
    1. existuje uživatel s daným usernamepo odeslání nového hesla se nejprve zkontroluje, zda:
    2. zda má správný dočasný hashve formuláři uvidí uživatel pouze jedno políčko pro nové heslo.
    3. zda to stihnul před vypršením limituuživateli pošleme zprávu na jeho mail s dalšími pokyny. Budeme vlastně chtít, aby zašel na konkrétní stránku, kde se mu ukáže formulář s možností zadat nové heslo. V tom formuláři si přichystáme skrytá pole s jeho uživatelským jménem, emailem a tím dočasným hashem
  4. pokud jsou podmínky splněny, heslo mu dovolíme uložit

Tím je bezpečnost zaručena - nejen že se v žádném okamžiku neposílá čitelné heslo, ale dokonce ani hash opravdového hesla. V žádném okamžiku neví nikdo jaké heslo používá, kromě samotného uživatele. Ani aplikace, ani administrátor.

Bude to vyžadovat celkem dost práce, ale s chutí do toho :-).

Ještě než začneme, tak je dobré vědět, že budeme zasílat e-maily. Pokud máš systém nakonfigurovaný tal, že ti i z localhost odejde email, super - nic nemusíš řešit. Já to ale takhle nemám a email mi prostě neodejde. Nainstaloval jsem si tedy Nextras, který umí e-mail "odeslat" do souboru a v Tracy panelu je pak vidět. Pokud chceš, přidej si do composer.json řádek:

"nextras/mail-panel": "^2.3"

Pak aktualizujeme závislosi příkazem:

composer update

Posledním krokem je úprava konfigurace. V souboru app/config/local.neon přidej následující řádky:

tracy:
    bar:
        - Nextras\MailPanel\MailPanel(%tempDir%/mail-panel-latte)

services:
    nette.mailer:
        class: Nette\Mail\IMailer
        factory: Nextras\MailPanel\FileMailer(%tempDir%/mail-panel-mails)

parameters:
    mailer: NextrasMailer

V první řadě si uděláme zase komponentu. Bude zobrazovat formulář se žádostí o nové heslo. Takže ve složce src/BaseModule/AdminModule/Components vyrob složku RememberPass. Začneme rozhraním ISignRememberPassFactory.php:

<?php

namespace BaseModule\AdminModule\Components\Sign\RememberPass;

use Nette\Mail\IMailer;

interface ISignRememberPassFactory
{

    /**
     * @param $mailer
     * @return SignRememberPass
     */
    public function create(IMailer $mailer);
}

Budeme posílat mail uživateli na jeho adresu, takže proto si předáváme závisost na IMailer. V samotné třídě toho budeme spoustu potřebovat, takže konstruktor má celkem dost závislostí:

<?php

namespace BaseModule\AdminModule\Components\Sign\RememberPass;

use Nette;
use BaseModule\Components\FormFactory;
use Latte\Engine;
use Nette\Application\LinkGenerator;
use Nette\Application\UI\Form;
use Nette\Bridges\ApplicationLatte\UIMacros;
use Nette\Mail\IMailer;
use Nette\Mail\Message;
use Nette\Security\Passwords;
use Nette\Utils\DateTime;
use UserModule\Model\Facade\UserFacade;
use Nette\Security\User;

class SignRememberPass extends Nette\Application\UI\Control
{
    /** @var FormFactory */
    private $factory;

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

    /** @var string */
    private $templateFile = __DIR__ . '/Sign.rememberPass.latte';

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

    /** @var LinkGenerator */
    private $generator;

    /** @var IMailer $mailer */
    private $mailer;

    /**
     * @param IMailer       $mailer
     * @param LinkGenerator $generator
     * @param FormFactory   $factory
     * @param User          $user
     * @param UserFacade    $facade
     */
    public function __construct(
        IMailer $mailer,
        LinkGenerator $generator,
        FormFactory $factory,
        User $user,
        UserFacade $facade
    ) {
        parent::__construct();
        $this->factory = $factory;
        $this->user = $user;
        $this->facade = $facade;
        $this->generator = $generator;
        $this->mailer = $mailer;
    }

    public function render()
    {
        $this->template->setFile($this->templateFile);
        $this->template->render();
    }

No a pak už stačí jen vygenerovat a ošetřit formulář, který bude od uživatele vyžadoat jen jeho email:

    /**
     * @return Form
     */
    public function createComponentSignRememberPassForm()
    {
        $form = $this->factory->create();
        $form->addText('email', 'Váš email')
            ->addCondition(Form::FILLED)
            ->addRule(Form::EMAIL, 'Vložte validní email');

        $form->addSubmit('send', 'Odeslat');
        $form->onSuccess[] = function (Form $form) {
            $values = $form->getValues();
            $user = null;
            if ($values->email > '') {
                $user = $this->facade->findOneBy(['email' => $values->email]);
            }

            if (is_null($user)) {
                $this->flashMessage('Neznámý uživatel', 'error');
                $this->redirect('this');
            }

            $now = new DateTime();
            $validity = new DateTime('+1 day');
            $user->setPasswordHash(Passwords::hash($now . $user->getEmail()));
            $user->setPasswordHashValidity($validity);
            $this->facade->save($user);

            /** @var Message $message */
            $message = new Message();

            $params = [
                'email' => $user->getEmail(),
                'token' => $user->getPasswordHash(),
            ];

            $latte = new Engine();
            $latte->addProvider("uiControl", $this);
            $latte->addProvider("uiPresenter", $this);
            $latte->addProvider('uiControl', $this->generator);
            UIMacros::install($latte->getCompiler());
            $message->setFrom('noreply@rsrs.loc')
                ->addTo($user->getEmail())
                ->setSubject('Zapomenuté heslo')
                ->setHtmlBody($latte->renderToString(__DIR__ . '/rememberMail.latte', $params));

            $this->mailer->send($message);
        };

        return $form;
    }

Samotný formulář je primitivní. Za pozornost stojí to, co s děje po jeho odeslání. Nejdřív se pokusíme najít uživatele podle jeho emailu. Pokud neexistuje, celá akce končí.  Jestli ho ale najdeme, tak:

  1. zjistíme aktuální čas
  2. určíme, do kdy musí uživatel stihnou zadání nového hesla
  3. entitě uživatele nastavíme passwordHash na náhodný hash, který vygenerujeme z aktuálního času a jeho emailu
  4. pak entitě nastavíme časové omezení - passwordHashValidity
  5. vygenerujeme email, kterému předáme vygenerovaný hash jako token a taky email uživatele a email odešleme uživateli

Potřebujeme ještě 2 šablony pro komponentu. První zobrazí formulář s výzvou. Stačil by klidně jeden řádek s příkazem pro vložení komponenty. Ale pokud si s tím chceš vyhrát, může šablona Sign.rememberPass.latte vypadat třeba takto:

{block content}
	<div class="form-2 w600" n:if="$flashes">
        {snippet flash}
			<div n:foreach="$flashes as $flash" class="alert alert-{$flash->type} fade in">
				<p>{$flash->message}</p>
			</div>
        {/snippet}
	</div>
	<section class="main">
		<form n:name=signRememberPassForm class="form-2 w600">
			<h1><span class="sign-up">Zapomenuté heslo</span></h1>
			<div><p>Zapoměli jste heslo? To se stane. Nemůžeme vám jej zaslat, protože dbáme na bezpečnost a heslo neznáme.
					Vyplňte níže emailovou adresu, kterou jste zadali při registraci, nebo která vám byla přidělena a
					my na ni zašleme instrukce jak dál pokračovat.</p></div>
			<p>
				<label n:name=email><i class="fa fa-envelope"></i>Váš Email</label>
				<input type="text"  n:name=email placeholder="Email">
			</p>
			<p class="clearfix">
				<input type="submit" n:name=send value="Resetovat heslo">
			</p>
		</form>​​
	</section>
{/block}

Netradičně bude komponenta vyžadovat ještě jednu šablonu - rememberMail.latte. V ní bude vzor emailu, který posíláme uživateli:

<html>
    <head>
        <title>Zapomenuté heslo</title>
    </head>

    <body>
      <p>Někdo (doufáme, že vy) požádal o změnu přihlašovacího hesla na webu sandbox.loc. Pokud si opravdu přejete změnit heslo, klikněte na odkaz níže a následujte instrukce.</p>
      <p><a n:href="Sign:Admin:Sign:newPass, token => $token">Vygenerovat nové heslo</a></p>
    </body>
</html>

Takže uživatel dostal email, ve kterém ho vyzýváme, aby kliknul na nějaký odkaz. Když to udělá, dostane se na stránku, která má v URL jeho email a ten token s hashem. Zobrazit se mu má formulář, který vyžaduje jeho nové heslo. Celé to zajistí další komponenta. Takže ve složce NewPass vyrobíme zase komponentu. Interface je v té nejjednodušší podobě:

<?php

namespace BaseModule\AdminModule\Components\Sign\NewPass;

interface ISignNewPassFactory
{

    /**
     * @return SignNewPass
     */
    public function create();
}

Třída začíná klasicky. Nemusíme si tahat mailer, ale potřebujeme věci okolo uživatele:

<?php

namespace BaseModule\AdminModule\Components\Sign\NewPass;

use Nette;
use Nette\Security\User;
use BaseModule\Components\FormFactory;
use UserModule\Model\Facade\UserFacade;

class SignNewPass extends Nette\Application\UI\Control
{
    /** @var FormFactory */
    private $factory;

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

    /** @var string */
    private $templateFile = __DIR__ . '/Sign.newPass.latte';

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

    /**
     * @param FormFactory $factory
     * @param User        $template
     * @param UserFacade  $facade
     */
    public function __construct(FormFactory $factory, User $template, UserFacade $facade)
    {
        parent::__construct();
        $this->factory = $factory;
        $this->user = $template;
        $this->facade = $facade;
    }

    public function render()
    {
        $this->template->setFile($this->templateFile);
        $this->template->render();
    }

Metoda pro vygenerování formuláře zase zobrazí jedno políčko s výzvou pro nové heslo. Ve skrytém poli posílám ten passwordHash, podle kterého budu po odeslání formuláře vyhledávat uživatele, abych mu změnil heslo:

    /**
     * @return \Nette\Application\UI\Form
     */
    public function createComponentNewPassForm()
    {
        $form = $this->factory->create();
        $form->addHidden('password_hash');

        $form->addPassword('password', 'Nové heslo')
            ->setRequired('Vyplňte své nové heslo');

        $form->addSubmit('send', 'Odeslat');

        $presenter = $this->getPresenter();
        $form->onSuccess[] = function (Nette\Application\UI\Form $form) use ($presenter) {
            $values = $form->getValues();
            $user = $this->facade->findOneBy(['passwordHash' => $values->password_hash]);

            $user->setPasswordHash(null);
            $user->setPasswordHashValidity(null);
            $user->setPassword(Nette\Security\Passwords::hash($values->password));
            $this->facade->save($user);

            $presenter->flashMessage('Heslo bylo změněno', 'success');
            $presenter->redirect('in');
        };

        return $form;
    }

Čili po odeslání hesla se pokusím najít uživatele podletoho hashe, co byl v hidden formulářovém prvku. Když ho najdu, změním heslo, vynuluju passwordHash a passwordHashValitity a přesměruji uživatele na stránku s přihlášením.

Pro úplnost ještě šablona Sign.newPass.latte:

{block content}
	<div class="form-2" n:if="$flashes">
        {snippet flash}
			<div n:foreach="$flashes as $flash" class="alert alert-{$flash->type} fade in">
				<p>{$flash->message}</p>
			</div>
        {/snippet}
	</div>
	<section class="main">
		<form n:name=newPassForm class="form-2">
			<h1><span class="log-in">Nové heslo</span></h1>
			<ul class="errors" n:if="$form->hasErrors()">
				<li n:foreach="$form->errors as $error">{$error}</li>
			</ul>
			<input type="hidden" n:name=password_hash>
			<p>
				<label n:name=password><i class="fa fa-lock"></i>Nové heslo</label>
				<input type="password" tabindex="2" n:name=password placeholder="Heslo" class="showpassword">
				<label for="showPassword"><input id="showPassword" tabindex="4" class="showpasswordcheckbox"
												 type="checkbox">Zobrazit heslo</label>
			</p>
			<p class="clearfix">
				<input type="submit" n:name=send value="Změnit heslo">
			</p>
		</form>
	</section>
{/block}

Samozřejmě nesmíme zapomenout na úpravu services.neon a doplnit služby:

    - BaseModule\AdminModule\Components\Sign\NewPass\ISignNewPassFactory
    - BaseModule\AdminModule\Components\Sign\RememberPass\ISignRememberPassFactory

Poslední věcí je úprava presenteru SignPresenter.php. Doplníme metody, které vyrobí obě komponenty a ošetří akce pro žádost o reset hesla:

    /**
     * @param string $token
     * @throws \Nette\Application\AbortException
     * @throws \Exception
     */
    public function actionNewPass($token)
    {
        $user = $this->facade->findOneBy(['passwordHash' => $token]);

        if (is_null($user)) {
            $this->flashMessage('Nepodařilo se najít uživatelský účet pro obnovu hesla', 'error');
            $this->redirect('in');
        }

        $now = new Nette\DateTime();
        if ($user->getPasswordHashValidity() < $now) {
            $user->setPasswordHash('');
            $user->setPasswordHashValidity(null);
            $this->facade->save($user);
            $this->presenter->flashMessage(
                'Vypršela platnost tokenu. Požádejte prosím o nové heslo znovu. Platnost žádosti je 1 den.',
                'error'
            );
            $this->redirect('remember');
        }

        /** @var Nette\Application\UI\Form $form */
        $form = $this['newPass']->getComponent('newPassForm');
        if (!$form->isSubmitted()) {
            $row = [
                'password_hash' => $user->getPasswordHash(),
            ];
            $form->setDefaults($row);
        }
    }

Metoda myslí na to, že když uživatel navštíví stránku s výzvou pro nové heslo, nejprve si najde jeho účet podle tokenu. Pak ještě zkontroluje, jestli byl dodržen časový limit. Pokud to uživatel nestihl včas, smažou se všechny stopy po pokusu o změnu hesla.

Jěšte 2 metody pro vyrobení těch konponent do presenteru doplníme:

    /**
     * @return SignRememberPass
     */
    protected function createComponentRememberPass()
    {
        return $this->signRememberPassFactory->create($this->mailer);
    }

    /**
     * @return SignNewPass
     */
    protected function createComponentNewPass()
    {
        return $this->signNewPassFactory->create();
    }

Je jasné, že si injectneš obě nové služby a také mailer do presenteru:

    /** @var ISignNewPassFactory @inject */
    public $signNewPassFactory;

    /** @var ISignRememberPassFactory @inject */
    public $signRememberPassFactory;

    /** @var IMailer @inject */
    public $mailer;

Zbývá už jen pro každou akci presenteru vyrobit šablonu. Šablona remember.latte:

{block content}
    {control rememberPass}
{/block}

{block title}
  Zapomenuté heslo
{/block}


A podobně newPass.latte:

{block content}
    {control newPass}
{/block}

{block title}
    Nové heslo
{/block}

Pokud jsi došel až sem, můžeš si zkusit zadat adresu http://rsrs.loc/admin/sign/remember a zadat žádost o nové heslo. Odkaz je dobré přidat do přihlašovacího formuláře:

			<p>
				<a href="{plink Sign:remember}" class="btn btn-link btn-sm" role="button"><i class="fa
				fa-mail-reply-all"></i>
					Zapomenuté heslo</a>
			</p>

Jakmile odešleš formulář se svým emailem, Nextras mailer zajistí, že se vyrobí soubor ve složce temp/mail-panel-mails. Pěkné je, že se v Tracy panelu zobrazí odkaz na vygenerované e-maily, takže si je můžeš normálně přečíst:

Dnes jsme toho zvládli hodně. Takže přidávám pravidelný odkaz ke stažení. Dnes to bude opět po delší době celá aplikace.

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