Валидация паролей в Laravel
Пароль 123456 пройдёт правило required|string, и формально валидация не соврала - строка есть. Но это не тот пароль, который должен попасть в базу. Laravel предоставляет класс Password для контроля сложности: минимальная длина, регистр, цифры, символы, проверка по базе утечек. Здесь разбираем его вместе с правилами confirmed, same и current_password.
Основы валидации - в отдельной статье.
Password rule object
Класс Illuminate\Validation\Rules\Password строит набор требований к паролю через цепочку методов:
use Illuminate\Validation\Rules\Password;
$request->validate([
'password' => ['required', 'confirmed', Password::min(8)],
]);
Минимум 8 символов + подтверждение. Но для продакшена этого мало.
Методы сложности
Password::min(8)
->letters() // хотя бы одна буква
->mixedCase() // заглавная + строчная
->numbers() // хотя бы одна цифра
->symbols() // хотя бы один спецсимвол (!@#$%...)
Каждый метод добавляет отдельное требование, и каждое нарушение генерирует своё сообщение об ошибке. Пользователь видит не абстрактное “пароль слишком простой”, а конкретный список: “нужна заглавная буква”, “нужна цифра”. Цепочку можно собрать под свои нужды:
// Для внутренней админки - строже
Password::min(12)->mixedCase()->numbers()->symbols()
// Для публичной регистрации - мягче
Password::min(8)->letters()->numbers()
uncompromised() - проверка по базе утечек
Password::min(8)->uncompromised()
Laravel отправляет первые 5 символов SHA-1 хеша пароля в API haveibeenpwned.com и проверяет, есть ли полный хеш в базе утечек. Используется модель k-Anonymity: сам пароль не покидает сервер. По умолчанию (порог 1) пароль считается скомпрометированным, если встречается хотя бы раз в любой утечке.
Порог можно поднять, если дефолт слишком строг для вашего приложения:
// Отвергать, если пароль встречается 3+ раз в базе утечек
Password::min(8)->uncompromised(3)
Значение 1 (дефолт) - рекомендация Laravel. Значение 3 мягче: отсечёт самые популярные скомпрометированные пароли, но пропустит те, что встречались 1-2 раза.
Метод делает HTTP-запрос, поэтому в тестах может замедлить прогон. Для тестов можно отключить через Password::defaults() с разными конфигами для prod и test.
Цепочка всех методов
Полный набор для максимальной безопасности:
'password' => [
'required', 'confirmed', 'max:72',
Password::min(8)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised(),
],
max:72 задаётся отдельным строковым правилом - Password объект отвечает за сложность, а max за длину.
На практике symbols() часто убирают для публичных регистраций - он раздражает пользователей и мало добавляет к безопасности при наличии остальных требований. NIST SP 800-63B рекомендует длину как основной фактор сложности. Пароль из 20 букв надёжнее, чем 8 символов с обязательными !@#. Если следовать этой логике, оптимальный набор - Password::min(12)->letters()->numbers()->uncompromised() без symbols() и mixedCase().
Подтверждение пароля: confirmed
Правило confirmed требует наличия поля {field}_confirmation с тем же значением:
$request->validate([
'password' => ['required', 'confirmed', Password::min(8)],
]);
Laravel ожидает поле password_confirmation в запросе. HTML-форма:
<input type="password" name="password">
<input type="password" name="password_confirmation">
Если имя поля подтверждения отличается от стандартного, укажите его параметром:
'password' => ['required', 'confirmed:password_repeat'],
Теперь Laravel будет искать поле password_repeat.
Правило same
same проверяет совпадение двух полей. В отличие от confirmed, привязки к суффиксу _confirmation нет - указываете произвольное поле:
$request->validate([
'password' => ['required', Password::min(8)],
'password_check' => ['required', 'same:password'],
]);
Для паролей confirmed удобнее - одно правило на одно поле, без дублирования.
confirmed vs same - когда что
Разница в привязке ошибки:
// confirmed: ошибка на поле password
'password' => ['required', 'confirmed']
// same: ошибка на поле password_check
'password_check' => ['required', 'same:password']
С confirmed достаточно одного правила - имя поля подтверждения (password_confirmation) определяется автоматически. С same нужны правила для обоих полей, и ошибка привязана ко второму.
Для паролей стандартом стало confirmed. same полезнее для других сценариев совпадения: подтверждение email, повтор PIN-кода.
current_password - проверка текущего пароля
При смене пароля нужно убедиться, что пользователь знает текущий. Правило current_password сверяет значение с хешем авторизованного пользователя:
$request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
]);
Если приложение использует несколько guard-ов, укажите нужный:
'current_password' => ['required', 'current_password:admin'],
Без параметра используется guard по умолчанию.
current_password проверяет пароль через Hash::check() с хешем из базы. Если пользователь не авторизован (guard вернул null), правило провалится. Убедитесь, что маршрут защищён middleware auth.
Типичный случай - страница настроек профиля, где перед сменой email или пароля нужно подтвердить текущий пароль:
$request->validate([
'current_password' => ['required', 'current_password'],
'email' => ['required', 'email', Rule::unique('users')->ignore($request->user())],
]);
Подтверждение текущего пароля здесь не для валидации нового пароля, а для подтверждения личности перед критическим действием.
Password::defaults() - глобальная конфигурация
Дублировать Password::min(8)->mixedCase()->numbers() в каждом Form Request утомительно. Password::defaults() задаёт конфигурацию глобально:
use Illuminate\Validation\Rules\Password;
// В AppServiceProvider::boot()
public function boot(): void
{
Password::defaults(function () {
$rule = Password::min(8);
return $this->app->isProduction()
? $rule->mixedCase()->numbers()->uncompromised()
: $rule;
});
}
В продакшене - строгие правила с проверкой утечек. В dev/test - только минимальная длина, чтобы не мучиться с тестовыми паролями. Замыкание вызывается при каждом обращении к Password::defaults(), поэтому окружение определяется динамически.
После этого в правилах достаточно:
'password' => ['required', 'confirmed', Password::defaults()],
Расширение defaults дополнительными правилами
Можно добавить свои правила поверх дефолтов через метод rules():
use App\Rules\NotPreviousPassword;
Password::defaults(function () {
return Password::min(8)
->mixedCase()
->rules([new NotPreviousPassword]);
});
NotPreviousPassword - кастомное правило, которое проверяет, что новый пароль не совпадает с N предыдущими. Laravel вызовет и стандартные проверки, и ваши. О создании кастомных правил - в статье про пользовательские правила валидации.
Максимальная длина пароля
Bcrypt, который Laravel использует по умолчанию, обрезает входные данные до 72 байт. Пароль длиннее 72 символов (или меньше, если содержит мультибайтовые символы) будет усечён без предупреждения. Два пароля длиной 100 символов, отличающихся только после 72-го символа, дадут одинаковый хеш.
Добавьте max:72 к правилам:
'password' => ['required', 'confirmed', 'max:72', Password::defaults()],
Если используете Argon2 (HASH_DRIVER=argon2id), ограничение другое, но на практике max:255 покроет все разумные случаи.
Пароль при двухфакторной аутентификации
Если приложение использует 2FA (через Laravel Fortify или собственную реализацию), валидация пароля не меняется. 2FA - это дополнительный шаг после проверки пароля, а не замена. Пароль проверяется как обычно, а OTP/TOTP-код валидируется отдельным правилом уже после успешной аутентификации.
При этом требования к паролю можно чуть ослабить: с 2FA даже скомпрометированный пароль не даёт доступа без второго фактора. Но это спорное решение - лучше оставить строгие требования и для пароля, и для второго фактора.
Генерация безопасного пароля
Иногда приложение генерирует пароль за пользователя (приглашение, временный доступ). Str::password() создаёт случайный пароль:
use Illuminate\Support\Str;
$temporaryPassword = Str::password(16);
// Пример: "k3$mP9vL!xQ2nR7w"
По умолчанию длина 32 символа. Можно настроить наличие букв, цифр и символов через параметры:
Str::password(
length: 20,
letters: true,
numbers: true,
symbols: true,
)
Сгенерированный пароль пройдёт любые правила Password, если длина и состав совпадают с требованиями. Типичный сценарий: администратор создаёт пользователя, система генерирует временный пароль, отправляет его по email, а при первом входе требует смену. Для этого в модели User добавьте флаг force_password_change и проверяйте его в middleware.
Запрет на совпадение с email или именем
Пароль [email protected] для пользователя [email protected] - плохая идея. Проверку можно добавить через after() в Form Request:
public function after(): array
{
return [
function ($validator) {
$password = $this->input('password');
$email = $this->input('email') ?? $this->user()?->email;
if ($password && $email && false !== stripos($password, explode('@', $email)[0])) {
$validator->errors()->add('password', 'Пароль не должен содержать имя из email.');
}
},
];
}
Для переиспользования вынесите логику в кастомное правило. Аналогичную проверку можно сделать для имени пользователя, номера телефона или других данных, которые не должны быть частью пароля.
Политики паролей по ролям
Разным пользователям - разные требования. Администратору нужен более сложный пароль, чем обычному пользователю:
class StoreUserRequest extends FormRequest
{
public function rules(): array
{
$passwordRule = 'admin' === $this->input('role')
? Password::min(12)->mixedCase()->numbers()->symbols()->uncompromised()
: Password::defaults();
return [
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', 'unique:users'],
'role' => ['required', 'in:user,editor,admin'],
'password' => ['required', 'confirmed', $passwordRule],
];
}
}
Альтернатива - разные Form Request для разных ролей. Но если отличается только пароль, условие в rules() проще.
Пароль в OAuth/Socialite
Если пользователь регистрируется через OAuth (Google, GitHub), пароль не нужен. В таких случаях поле password в таблице users остаётся null:
$request->validate([
'provider' => ['required', 'in:google,github'],
'token' => ['required', 'string'],
]);
// Пароль не валидируется - авторизация через OAuth
$user = User::firstOrCreate(
['email' => $socialUser->getEmail()],
['name' => $socialUser->getName(), 'password' => null],
);
Если позже пользователь захочет установить пароль (для входа без OAuth), используйте обычную валидацию без current_password - текущего пароля у него нет:
public function rules(): array
{
$rules = [
'password' => ['required', 'confirmed', Password::defaults()],
];
// current_password только если у пользователя уже есть пароль
if (null !== $this->user()->password) {
$rules['current_password'] = ['required', 'current_password'];
}
return $rules;
}
Полный пример: регистрация
class RegisterRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:60'],
'email' => ['required', 'email:rfc,dns', 'unique:users'],
'password' => ['required', 'confirmed', Password::defaults()],
];
}
}
class RegisterController extends Controller
{
public function store(RegisterRequest $request): RedirectResponse
{
// password хешируется через каст 'hashed' в модели User
$user = User::create($request->validated());
Auth::login($user);
return to_route('dashboard');
}
}
Blade-форма (фрагмент):
<form method="POST" action="/register">
@csrf
<label for="password">Пароль</label>
<input type="password" name="password" id="password">
@error('password')
<span>{{ $message }}</span>
@enderror
<label for="password_confirmation">Подтверждение пароля</label>
<input type="password" name="password_confirmation" id="password_confirmation">
<button type="submit">Зарегистрироваться</button>
</form>
Ошибка подтверждения привязана к полю password, а не к password_confirmation. В Blade @error('password') покажет и ошибку несовпадения.
Полный пример: смена пароля
Смена пароля требует три поля: текущий пароль, новый, подтверждение нового.
class ChangePasswordRequest extends FormRequest
{
public function rules(): array
{
return [
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::defaults()],
];
}
public function messages(): array
{
return [
'current_password.current_password' => 'Неверный текущий пароль.',
];
}
}
class PasswordController extends Controller
{
public function update(ChangePasswordRequest $request): RedirectResponse
{
$request->user()->update([
'password' => $request->validated('password'),
]);
return back()->with('status', 'Пароль изменён.');
}
}
Новый пароль не нужно хешировать вручную, если модель User использует каст hashed на атрибуте password. Eloquent хеширует значение автоматически при записи.
Blade-форма для смены пароля:
<form method="POST" action="/password">
@csrf
@method('PUT')
<label for="current_password">Текущий пароль</label>
<input type="password" name="current_password" id="current_password">
@error('current_password')
<span>{{ $message }}</span>
@enderror
<label for="password">Новый пароль</label>
<input type="password" name="password" id="password">
@error('password')
<span>{{ $message }}</span>
@enderror
<label for="password_confirmation">Подтвердите новый пароль</label>
<input type="password" name="password_confirmation" id="password_confirmation">
<button type="submit">Сменить пароль</button>
</form>
Три поля: текущий, новый, подтверждение. Ошибки confirmed и min привязаны к полю password, ошибка current_password - к своему полю.
Полный пример: сброс пароля
При сбросе текущий пароль не запрашивается - пользователь подтвердил личность через email-токен:
class ResetPasswordRequest extends FormRequest
{
public function rules(): array
{
return [
'token' => ['required', 'string'],
'email' => ['required', 'email', 'exists:users'],
'password' => ['required', 'confirmed', Password::defaults()],
];
}
}
Здесь нет current_password - авторизация происходит через токен. Но Password::defaults() и confirmed остаются. Сброс пароля реализован в Illuminate\Auth\Passwords\PasswordBroker, и обычно достаточно стандартных контроллеров Laravel Breeze или Fortify. Form Request выше показывает только валидацию полей.
Пароль в API
Для API валидация пароля работает так же, но ответ приходит в JSON. Эндпоинт регистрации:
class ApiRegisterRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', 'confirmed', Password::defaults()],
];
}
}
При ошибке клиент получит 422:
{
"message": "The password field must contain at least one uppercase and one lowercase letter.",
"errors": {
"password": [
"The password field must contain at least one uppercase and one lowercase letter.",
"The password field must contain at least one number."
]
}
}
Все требования Password возвращаются отдельными сообщениями в массиве errors.password. Фронтенд может показать их списком, чтобы пользователь видел, что именно не хватает.
Индикатор сложности на фронтенде
Валидация на сервере обязательна, но пользователю удобно видеть требования до отправки формы. Два подхода:
Первый - отдать правила через API и проверять на клиенте. Работает с Laravel Precognition:
Route::post('/register', [RegisterController::class, 'store'])
->middleware('precognitive');
Precognition отправит правила на сервер при потере фокуса и вернёт ошибки в реальном времени.
Второй - продублировать проверки в JS. Менее надёжно (правила могут рассинхронизироваться), но работает без серверных запросов. Серверная валидация при этом остаётся - JS-проверка только для UX.
Распространённый вариант: индикатор с четырьмя уровнями (слабый, средний, хороший, сильный). Проверяете длину, наличие цифр, регистр, спецсимволы - то же, что Password rule object делает на сервере. Главное - не полагаться на клиентскую проверку как на единственную.
Кастомные сообщения для пароля
Стандартные сообщения Laravel на английском. Для русскоязычного проекта переопределите их в Form Request:
public function messages(): array
{
return [
'password.required' => 'Введите пароль.',
'password.confirmed' => 'Пароли не совпадают.',
'password.min' => 'Пароль должен быть не короче :min символов.',
];
}
Сообщения от Password rule object (letters, mixedCase, numbers, symbols, uncompromised) можно переопределить глобально через lang-файлы. В lang/ru/validation.php:
'password' => [
'letters' => 'Пароль должен содержать хотя бы одну букву.',
'mixed' => 'Пароль должен содержать заглавные и строчные буквы.',
'numbers' => 'Пароль должен содержать хотя бы одну цифру.',
'symbols' => 'Пароль должен содержать хотя бы один спецсимвол.',
'uncompromised' => 'Этот пароль найден в базе утечек. Выберите другой.',
],
История паролей
Laravel не имеет встроенного механизма для запрета повторного использования старых паролей. Но реализовать его несложно:
// Migration
Schema::create('password_histories', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('password');
$table->timestamp('created_at');
});
Кастомное правило:
class NotPreviousPassword implements ValidationRule
{
public function __construct(private User $user, private int $count = 5) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$previous = $this->user->passwordHistories()
->latest()
->take($this->count)
->pluck('password');
foreach ($previous as $hash) {
if (Hash::check($value, $hash)) {
$fail("Нельзя использовать один из {$this->count} последних паролей.");
return;
}
}
}
}
Подключение в Form Request:
'password' => [
'required',
'confirmed',
Password::defaults(),
new NotPreviousPassword($this->user(), 5),
],
Сохранение в историю - в observer или в контроллере после успешного обновления.
Валидация при импорте пользователей
При массовом импорте пользователей (CSV, API) пароли валидируются через Validator::make():
foreach ($rows as $row) {
$validator = Validator::make($row, [
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', Password::min(8)->letters()->numbers()],
]);
if ($validator->fails()) {
$this->warn("Row skipped: " . $validator->errors()->first());
continue;
}
User::create($validator->validated());
}
Здесь confirmed не нужен - подтверждение пароля имеет смысл только в интерактивных формах. В импорте пароль задаётся однократно.
Если импорт предполагает временный пароль, который пользователь сменит при первом входе, можно ослабить требования:
'password' => ['required', Password::min(8)],
И пометить пользователя флагом must_change_password, чтобы приложение потребовало смену при логине.
Тестирование паролей
public function test_registration_requires_strong_password(): void
{
$response = $this->post('/register', [
'name' => 'Test User',
'email' => '[email protected]',
'password' => 'short',
'password_confirmation' => 'short',
]);
$response->assertSessionHasErrors('password');
}
public function test_password_confirmation_must_match(): void
{
$response = $this->post('/register', [
'name' => 'Test User',
'email' => '[email protected]',
'password' => 'SecurePass123!',
'password_confirmation' => 'DifferentPass456!',
]);
$response->assertSessionHasErrors('password');
}
Для тестирования смены пароля:
public function test_change_password_requires_current(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put('/password', [
'current_password' => 'wrong-password',
'password' => 'NewSecure123!',
'password_confirmation' => 'NewSecure123!',
]);
$response->assertSessionHasErrors('current_password');
}
public function test_change_password_succeeds(): void
{
$user = User::factory()->create(['password' => 'OldPass123!']);
$this->actingAs($user);
$response = $this->put('/password', [
'current_password' => 'OldPass123!',
'password' => 'NewSecure456!',
'password_confirmation' => 'NewSecure456!',
]);
$response->assertSessionHasNoErrors();
$this->assertTrue(Hash::check('NewSecure456!', $user->fresh()->password));
}
Если Password::defaults() включает uncompromised(), тесты будут делать HTTP-запрос к haveibeenpwned.com. Решение - в TestCase::setUp() переопределить defaults:
protected function setUp(): void
{
parent::setUp();
Password::defaults(fn () => Password::min(8));
}
Теперь тесты не зависят от внешнего API. В CI это критично - haveibeenpwned.com может быть недоступен или отвечать медленно.
Для полной проверки uncompromised() отдельно напишите интеграционный тест с пометкой @group external, который запускается вручную:
/** @group external */
public function test_compromised_password_rejected(): void
{
Password::defaults(fn () => Password::min(8)->uncompromised());
$response = $this->post('/register', [
'name' => 'Test',
'email' => '[email protected]',
'password' => 'password',
'password_confirmation' => 'password',
]);
$response->assertSessionHasErrors('password');
}
Частые ошибки
Подтверждение на поле _confirmation
{{-- Неправильно: ошибка confirmed здесь не появится --}}
@error('password_confirmation')
<span>{{ $message }}</span>
@enderror
{{-- Правильно: confirmed привязан к основному полю --}}
@error('password')
<span>{{ $message }}</span>
@enderror
Ошибка confirmed всегда привязана к основному полю (password), а не к полю подтверждения. Многие фронтендеры ставят @error на оба поля - это лишнее, ошибка появится только на основном.
Хеширование перед валидацией
// Ошибка: хешируете пароль ДО валидации
$request->merge(['password' => Hash::make($request->password)]);
$request->validate(['password' => Password::min(8)]);
Password::min(8) проверит длину хеша (~60 символов), а не исходного пароля. Хешируйте после валидации, или используйте каст hashed в модели.
Password::defaults() без вызова boot()
Если Password::defaults() вызван в AppServiceProvider::boot(), но провайдер не зарегистрирован в bootstrap/providers.php, дефолты не применятся. Password::defaults() без аргументов вернёт Password::min(8) без дополнительных требований.
same вместо confirmed
same:password на поле password_confirmation работает, но ошибка будет привязана к password_confirmation, а не к password. Пользователь увидит ошибку под вторым полем. С confirmed ошибка всегда на основном поле - проще обрабатывать в Blade.
uncompromised() в офлайн-среде
Если сервер без доступа в интернет, uncompromised() выбросит исключение при попытке достучаться до API. Для таких сред уберите uncompromised() из defaults или оберните в проверку окружения.
Пароль не хешируется
Если модель User не использует каст hashed на атрибуте password, пароль сохранится в открытом виде. В Laravel 11+ каст включён в стандартный stub модели. Проверьте метод casts():
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
Без этого каста нужен явный вызов Hash::make() перед сохранением. Проверить наличие каста можно через php artisan model:show User - в секции Casts должен быть password: hashed.
max без min
Password::min(8) задаёт минимум, но без max пользователь может отправить пароль на 10 000 символов. Bcrypt усечёт вход до 72 байт перед хешированием, но гигантская строка всё равно потребляет память на приём и обработку запроса. Добавляйте max:72 (или max:255 для Argon2) для защиты от abuse и для корректности - два пароля, отличающихся только после 72-го байта, дадут одинаковый хеш.