MidПрактика7 min

Сброс пароля (Password Reset)

Процесс сброса пароля, кастомизация, Password Broker, уведомления, безопасность

Laravel предоставляет полноценный механизм сброса пароля: от генерации токена и отправки email до сброса и обновления пароля. Система построена на концепции Password Broker.

Архитектура сброса пароля

Процесс состоит из четырёх шагов:

  1. Запрос -- пользователь вводит email
  2. Отправка -- Laravel генерирует токен и отправляет email со ссылкой
  3. Переход -- пользователь кликает ссылку и попадает на форму
  4. Сброс -- пользователь вводит новый пароль, токен проверяется и пароль обновляется

Конфигурация

// config/auth.php
'passwords' => [
    'users' => [
        'provider' => 'users',                // User provider name
        'table' => 'password_reset_tokens',     // Table for tokens
        'expire' => 60,                         // Token expires in 60 minutes
        'throttle' => 60,                       // 60 seconds between requests
    ],
],

Миграция для токенов

<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('password_reset_tokens', function (Blueprint $table) {
            $table->string('email')->primary();
            $table->string('token');
            $table->timestamp('created_at')->nullable();
        });
    }
};

Реализация маршрутов

1. Форма запроса сброса (Forgot Password)

<?php

// routes/web.php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Route;

// Show forgot password form
Route::get('/forgot-password', function () {
    return view('auth.forgot-password');
})->middleware('guest')->name('password.request');

// Handle forgot password form submission
Route::post('/forgot-password', function (Request $request) {
    $request->validate([
        'email' => ['required', 'email'],
    ]);

    // Send the password reset link
    $status = Password::sendResetLink(
        $request->only('email')
    );

    return $status === Password::RESET_LINK_SENT
        ? back()->with(['status' => __($status)])
        : back()->withErrors(['email' => __($status)]);
})->middleware('guest')->name('password.email');

2. Форма сброса пароля (Reset Password)

// Show reset password form
Route::get('/reset-password/{token}', function (string $token) {
    return view('auth.reset-password', ['token' => $token]);
})->middleware('guest')->name('password.reset');

// Handle reset password form submission
Route::post('/reset-password', function (Request $request) {
    $request->validate([
        'token' => ['required'],
        'email' => ['required', 'email'],
        'password' => ['required', 'min:8', 'confirmed'],
    ]);

    $status = Password::reset(
        $request->only('email', 'password', 'password_confirmation', 'token'),
        function (\App\Models\User $user, string $password) {
            $user->forceFill([
                'password' => \Illuminate\Support\Facades\Hash::make($password),
            ])->setRememberToken(\Illuminate\Support\Str::random(60));

            $user->save();

            event(new \Illuminate\Auth\Events\PasswordReset($user));
        }
    );

    return $status === Password::PASSWORD_RESET
        ? redirect()->route('login')->with('status', __($status))
        : back()->withErrors(['email' => [__($status)]]);
})->middleware('guest')->name('password.update');

Password Broker

Password Broker -- это центральный компонент, управляющий процессом сброса пароля.

Статусы Password Broker

use Illuminate\Support\Facades\Password;

// Possible statuses:
Password::RESET_LINK_SENT;        // 'passwords.sent'
Password::PASSWORD_RESET;          // 'passwords.reset'
Password::INVALID_USER;            // 'passwords.user'
Password::INVALID_TOKEN;           // 'passwords.token'
Password::RESET_THROTTLED;         // 'passwords.throttled'

Работа с несколькими Brokers

// config/auth.php
'passwords' => [
    'users' => [
        'provider' => 'users',
        'table' => 'password_reset_tokens',
        'expire' => 60,
        'throttle' => 60,
    ],
    'admins' => [
        'provider' => 'admins',
        'table' => 'admin_password_reset_tokens',
        'expire' => 30,
        'throttle' => 120,
    ],
],
// Using a specific broker
$status = Password::broker('admins')->sendResetLink(
    $request->only('email')
);

Реализация для API (SPA)

<?php

declare(strict_types=1);

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;

final class PasswordResetController extends Controller
{
    /**
     * Send a password reset link.
     */
    public function sendResetLink(Request $request): JsonResponse
    {
        $request->validate([
            'email' => ['required', 'email'],
        ]);

        $status = Password::sendResetLink(
            $request->only('email')
        );

        if ($status === Password::RESET_LINK_SENT) {
            return response()->json([
                'message' => 'Ссылка для сброса пароля отправлена на ваш email.',
            ]);
        }

        return response()->json([
            'message' => __($status),
        ], 422);
    }

    /**
     * Reset the user's password.
     */
    public function resetPassword(Request $request): JsonResponse
    {
        $request->validate([
            'token' => ['required'],
            'email' => ['required', 'email'],
            'password' => [
                'required',
                'confirmed',
                PasswordRule::min(8)
                    ->letters()
                    ->mixedCase()
                    ->numbers()
                    ->symbols(),
            ],
        ]);

        $status = Password::reset(
            $request->only('email', 'password', 'password_confirmation', 'token'),
            function ($user, string $password) {
                $user->forceFill([
                    'password' => Hash::make($password),
                    'remember_token' => Str::random(60),
                ])->save();

                event(new \Illuminate\Auth\Events\PasswordReset($user));
            }
        );

        if ($status === Password::PASSWORD_RESET) {
            return response()->json([
                'message' => 'Пароль успешно обновлён.',
            ]);
        }

        return response()->json([
            'message' => __($status),
        ], 422);
    }
}

Кастомизация уведомления

Через callback

<?php

declare(strict_types=1);

namespace App\Providers;

use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Customize the reset password email
        ResetPassword::toMailUsing(function (object $notifiable, string $token) {
            $url = url(route('password.reset', [
                'token' => $token,
                'email' => $notifiable->getEmailForPasswordReset(),
            ], false));

            return (new MailMessage())
                ->subject('Сброс пароля')
                ->greeting("Здравствуйте, {$notifiable->name}!")
                ->line('Вы получили это письмо, потому что запросили сброс пароля.')
                ->action('Сбросить пароль', $url)
                ->line("Ссылка действительна в течение {config('auth.passwords.users.expire')} минут.")
                ->line('Если вы не запрашивали сброс пароля, проигнорируйте это письмо.')
                ->salutation('С уважением, Команда');
        });
    }
}

Через URL callback (для SPA)

use Illuminate\Auth\Notifications\ResetPassword;

ResetPassword::createUrlUsing(function (object $notifiable, string $token) {
    return config('app.frontend_url') . '/reset-password?' . http_build_query([
        'token' => $token,
        'email' => $notifiable->getEmailForPasswordReset(),
    ]);
});

Полностью кастомное уведомление

<?php

declare(strict_types=1);

namespace App\Notifications;

use Illuminate\Auth\Notifications\ResetPassword as BaseResetPassword;
use Illuminate\Notifications\Messages\MailMessage;

final class CustomResetPassword extends BaseResetPassword
{
    protected function buildMailMessage(string $url): MailMessage
    {
        return (new MailMessage())
            ->subject('Восстановление доступа')
            ->markdown('emails.password-reset', [
                'url' => $url,
                'expire' => config('auth.passwords.users.expire'),
            ]);
    }
}
// Override in User model
final class User extends Authenticatable
{
    public function sendPasswordResetNotification(mixed $token): void
    {
        $this->notify(new \App\Notifications\CustomResetPassword($token));
    }
}

Безопасность токенов

Как работает токен

// 1. When sending reset link:
// - A random 64-character token is generated
// - The HASH of the token is stored in password_reset_tokens table
// - The PLAIN token is sent to the user via email

// 2. When resetting password:
// - Laravel retrieves the hash from the table
// - Uses Hash::check() to compare the plain token with the stored hash
// - If valid and not expired, the password is reset

// 3. After reset:
// - The token record is deleted from the table

Throttling

// The 'throttle' config value (in seconds) prevents
// sending reset links too frequently.
// Default: 60 seconds between requests.

// If a user requests a link within the throttle period:
$status = Password::sendResetLink($request->only('email'));
// Returns: Password::RESET_THROTTLED

Очистка устаревших токенов

# Remove expired tokens
php artisan auth:clear-resets

# Schedule periodic cleanup
# In routes/console.php:
Schedule::command('auth:clear-resets')->everyFifteenMinutes();

Password Confirmation

Laravel также предоставляет функцию подтверждения пароля для защиты чувствительных действий.

// routes/web.php
Route::get('/settings', function () {
    return view('settings');
})->middleware(['auth', 'password.confirm']);

// The user must re-enter their password before accessing this route
// After confirming, they have a grace period (default: 3 hours)
// config/auth.php
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), // 3 hours in seconds

Для API

Route::middleware(['auth:sanctum'])->group(function () {
    Route::post('/confirm-password', function (Request $request) {
        if (! Hash::check($request->password, $request->user()->password)) {
            return response()->json([
                'message' => 'Неверный пароль.',
            ], 422);
        }

        $request->session()->passwordConfirmedAt(now());

        return response()->json(['message' => 'Пароль подтверждён.']);
    });

    // Protected by password confirmation
    Route::delete('/account', function (Request $request) {
        // Delete account...
    })->middleware('password.confirm');
});

Тестирование

<?php

declare(strict_types=1);

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use Tests\TestCase;

final class PasswordResetTest extends TestCase
{
    public function test_reset_link_can_be_requested(): void
    {
        Notification::fake();

        $user = User::factory()->create();

        $response = $this->postJson('/forgot-password', [
            'email' => $user->email,
        ]);

        Notification::assertSentTo($user, ResetPassword::class);
    }

    public function test_password_can_be_reset(): void
    {
        Notification::fake();

        $user = User::factory()->create();

        // Request reset link
        $this->postJson('/forgot-password', ['email' => $user->email]);

        Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
            // Use the token from the notification
            $response = $this->postJson('/reset-password', [
                'token' => $notification->token,
                'email' => $user->email,
                'password' => 'new-password-123',
                'password_confirmation' => 'new-password-123',
            ]);

            $response->assertOk();

            // Verify the password was updated
            $this->assertTrue(
                Hash::check('new-password-123', $user->fresh()->password)
            );

            return true;
        });
    }

    public function test_invalid_token_is_rejected(): void
    {
        $user = User::factory()->create();

        $response = $this->postJson('/reset-password', [
            'token' => 'invalid-token',
            'email' => $user->email,
            'password' => 'new-password-123',
            'password_confirmation' => 'new-password-123',
        ]);

        $response->assertStatus(422);
    }

    public function test_reset_link_is_throttled(): void
    {
        $user = User::factory()->create();

        // First request
        $this->postJson('/forgot-password', ['email' => $user->email]);

        // Second request within throttle window
        $response = $this->postJson('/forgot-password', [
            'email' => $user->email,
        ]);

        $response->assertStatus(422);
    }

    public function test_expired_token_is_rejected(): void
    {
        $user = User::factory()->create();

        // Create a token
        $token = Password::createToken($user);

        // Travel forward in time past expiration
        $this->travel(61)->minutes();

        $response = $this->postJson('/reset-password', [
            'token' => $token,
            'email' => $user->email,
            'password' => 'new-password-123',
            'password_confirmation' => 'new-password-123',
        ]);

        $response->assertStatus(422);
    }
}

Проверь себя

Как Laravel хранит токен сброса пароля в таблице password_reset_tokens?

Что возвращает Password::sendResetLink(), если пользователь запрашивает ссылку слишком часто?

Для чего используется middleware 'password.confirm'?

Какой метод модели User нужно переопределить для отправки кастомного уведомления сброса пароля?