Reset hasła (nie pamiętam hasła)

Ostatnią z typowych funkcjonalności związanych z autentykacją użytkowników jest reset hasła.

Standardowa ścieżka postępowania wygląda następująco:

  • użytkownik nie pamięta swojego hasła
  • podaje adres email użyty podczas rejestracji
  • na wskazany adres przychodzi wiadomość z wygenerowanym linkiem do zmiany hasła
  • użytkownik podaje nowe hasło korzystając z linku otrzymanego w wiadomości

Zacznę od napisania testu funkcjonalności otrzymania linku resetowania hasła. Do wysyłki wiadomości użyję laravel’owych notyfikacji. Przy okazji pokażę jak można przetestować maila wysłanego w powiadomieniu.

php artisan make:test Auth/ForgotPasswordTest

<?php

namespace Tests\Feature\Auth;

use App\Notifications\ForgotPassword;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class ForgotPasswordTest extends TestCase
{
  use WithFaker, RefreshDatabase;

  /** @test */
  public function user_can_obtain_reset_password_link()
  {
    Notification::fake();

    $user = factory(User::class)->create([
      'email' => 'email@example.com'
    ]);

    $this->json('POST', route('password.forgot'), [
      'email' => $user->email
    ]);

    Notification::assertSentTo(
      $user,
      ForgotPassword::class,
      function ($notification) use ($user) {
        $mailData = $notification->toMail($user)->toArray();

        $this->assertEquals('Reset Password Notification', $mailData['subject']);

        return true;
      }
    );
  }
}

Test napisany. Pierwszy problem to brak odpowiedniego route:

vendor/bin/phpunit --filter=user_can_obtain_reset_password_link
InvalidArgumentException: Route [password.forgot] not defined.

Dodaję odpowiedni wpis w pliku routes/api.php

Route::post('/password/forgot', 'Api\Auth\ForgotPasswordController@getPasswordResetToken')
  ->name('password.forgot');

Kolej na kontroler.

php artisan make:controller Api/Auth/ForgotPasswordController

I metodę getPasswordResetToken(). Cały kontroler wygląda następująco:

<?php

namespace App\Http\Controllers\Api\Auth;

use App\User;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Notifications\ForgotPassword;

class ForgotPasswordController extends Controller
{
  public function getPasswordResetToken(Request $request)
  {
    $user = User::where('email', $request->email)->first();

    $user->generatePasswordResetToken();

    $user->notify(
      new ForgotPassword($user->passwordReset->token)
    );

    return response()->json([
      'message' => 'We have e-mailed your password reset link!'
    ]);
  }
}

Ok, w metodzie getPasswordResetToken odwołuję się do funkcji generatePasswordResetToken() modelu User. Jej zadaniem jest wygenerowanie tokenu dla konkretnego usera. Metodki tej nie ma jeszcze napisanej o czym informuje mnie odpalony test, wiec teraz czas ją zapisać.

Domyślne migracje Laravela (przynajmniej od wersji 5.3) zawierają opis tabelki password_resets, która przechowuje tokeny i przypisane do nich adresy email.

By mieć łatwy dostęp do tej tabeli utworzę model PasswordReset

php artisan make:model Models/PasswordReset

a w modelu User zadeklaruję odpowiednią relację.

public function passwordReset()
{
  return $this->hasOne(PasswordReset::class, 'email', 'email');
}

i wspomnianą wcześniej metodę tworzącą token dla użytkownika:

public function generatePasswordResetToken()
{
  PasswordReset::updateOrCreate(
    ['email' => $this->email],
    [
      'email' => $this->email,
      'token' => Str::random(60)
    ]
  );
}

Kolejny krok jaki zgłasza test

Symfony\Component\Debug\Exception\FatalThrowableError: Class 'App\Notifications\ForgotPassword' not found

to stworzenie odpowiedniej klasy powiadomienia i skonfigurowanie jej.

php artisan make:notification ForgotPassword

W przypadku api, żądanie może być wysłane z dowolnego klienta (aplikacja mobilna, desktopowa, pwa itp…). Preparuję więc jednego z takich klientów, jego adres zapisuję w pliku config/services.php i w powiadomieniu generuję dla niego link resetujący hasło. Ostatecznie klasa powiadomienia wygląda następująco:

 <?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;

class ForgotPassword extends Notification
{
  use Queueable;

  protected $token;

  /**
   * Create a new notification instance.
   *
   * @return void
   */
  public function __construct($token)
  {
    $this->token = $token;
  }

  /**
   * Get the notification's delivery channels.
   *
   * @param  mixed $notifiable
   * @return array
   */
  public function via($notifiable)
  {
    return ['mail'];
  }

  /**
   * Get the mail representation of the notification.
   *
   * @param  mixed $notifiable
   * @return \Illuminate\Notifications\Messages\MailMessage
   */
  public function toMail($notifiable)
  {
    $url = config('services.client') . '/password/reset/' . $this->token;

    return (new MailMessage)
      ->subject('Reset Password Notification')
      ->greeting('Greetings!')
      ->line('You are receiving this email because we received a password reset request for your account.')
      ->action('Reset password', $url)
      ->line('If you did not request a password reset, no further action is required.');

  }
}

Po wprowadzeniu tych funkcjonalności test przechodzi, a użytkownik otrzymuje na swój adres email wiadomość z linkiem resetującym hasło (głównie chodzi o token). Kolejną rzeczą jaką należy zrobić to możliwość resetowania hasła przy pomocy wygenerowanego tokenu.

Tworzę odpowiedni test:

/** @test */
public function user_can_reset_password()
{
  $this->withoutExceptionHandling();

  $user = factory(User::class)->create([
    'email' => 'rano@lptg.pl'
  ]);

  $user->generatePasswordResetToken();

  $this->json('POST', route('password.reset'), [
    'token' => $user->passwordReset->token,
    'password' => 'newPassword',
    'password_confirmation' => 'newPassword',
  ]);

  $user = $user->fresh();

  $this->assertTrue(Hash::check('newPassword', $user->password));
}

Pierwszy problem to brak route, więc go dodaję:

Route::post('/password/reset', 'Api\Auth\ForgotPasswordController@resetPassword')
  ->name('password.reset');

Kolej na metodę resetPassword()

public function resetPassword(Request $request)
{
  $passwordReset = PasswordReset::whereToken($request->token)->first();

  $passwordReset->user->update([
    'password' => Hash::make($request->password)
  ]);

  return response()->json([
    'message' => 'Password reset successfully.'
  ]);
}

Błąd jaki zgłasza uruchomiony test to:

Symfony\Component\Debug\Exception\FatalThrowableError: Call to a member function update() on null

W modelu PasswordReset tworzę relację z modelem User:

public function user()
{
  return $this->hasOne(User::class, 'email', 'email');
}

Po tych zmianach test przechodzi.

Wypada jeszcze dopisać walidację wprowadzanego tokenu i hasła. Np coś takiego:

/** @test */
public function user_cannot_reset_password_with_incorrect_credentials()
{
  $credentials = [
    'token' => 'token that does not exist',
    'password' => 'password',
    'password_confirmation' => 'wrong_confirmation'
  ];

  $this->json('POST', route('password.reset'), $credentials)
    ->assertJsonFragment([
      'message' => 'The given data was invalid.'
    ])
    ->assertStatus(422);
}
php artisan make:request Password/Reset

Cała klasa walidatora:

<?php

namespace App\Http\Requests\Password;

use Illuminate\Foundation\Http\FormRequest;

class Reset extends FormRequest
{
  /**
   * Determine if the user is authorized to make this request.
   *
   * @return bool
   */
  public function authorize()
  {
    return true;
  }

  /**
   * Get the validation rules that apply to the request.
   *
   * @return array
   */
  public function rules()
  {
    return [
      'token' => 'required|max:255|exists:password_resets,token',
      'password' => 'required|confirmed',
    ];
  }
}

Stworzony walidator używam w metodzie resetPassword()

use App\Http\Requests\Password\Reset as ResetPasswordRequest;

...

public function resetPassword(ResetPasswordRequest $request)
{
  $passwordReset = PasswordReset::whereToken($request->token)->first();

  $passwordReset->user->update([
    'password' => Hash::make($request->password)
  ]);

  return response()->json([
    'message' => 'Password reset successfully.'
  ]);
}

Po tych zmianach test przechodzi. Jak i wszystkie poprzednie:

vendor/bin/phpunit
OK (9 tests, 20 assertions)

You may also like...