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)