Просьба «а можно сохранять пароли, чтобы мы могли их посмотреть» приходит от заказчиков регулярно, и почти всегда за ней стоит реальная задача: интеграция с системой, которая умеет только логин и пароль, или поддержка, которая хочет помочь клиенту войти. Просто ответить «нельзя» недостаточно — если не предложить рабочую альтернативу, это сделают за вас и хуже.
Разберём, почему хранение паролей в открытом виде — не «плохой стиль», а инцидент безопасности, и чем закрывается каждая из задач, ради которых к нему прибегают.
Предупреждение
Это антипаттерн! Хранение паролей в открытом виде — критическая уязвимость. Эта статья объясняет, почему так делать нельзя, и предлагает безопасные альтернативы.
Как это обычно делают (неправильно)
Иногда разработчики добавляют сохранение пароля в пользовательское поле:
<?php
// ❌ НЕ ДЕЛАЙТЕ ТАК!
AddEventHandler('main', 'OnAfterUserLogin', function (&$fields) {
if ($fields['USER_ID'] > 0) {
$user = new CUser();
$user->Update($fields['USER_ID'], [
'UF_USER_PASS' => $fields['PASSWORD'], // Пароль в открытом виде!
]);
}
});Почему это критически опасно
1. Нарушение принципов безопасности
Пароли должны храниться только в хэшированном виде. Битрикс использует безопасное хэширование с солью — пароль невозможно восстановить из хэша. Сохранение в открытом виде уничтожает всю эту защиту.
2. Утечка при взломе
При SQL-инъекции или доступе к бэкапу базы данных злоумышленник получит все пароли в открытом виде.
3. Требования регуляторов
Законодательство о защите персональных данных (152-ФЗ в России, GDPR в ЕС) требует принимать адекватные меры защиты, а хранение паролей в открытом виде адекватной мерой не является ни по одному из отраслевых стандартов. Практический аспект здесь важнее формулировок: при разборе инцидента с утечкой этот факт превращает «нас взломали» в «мы не обеспечили защиту», и разница в последствиях существенная.
4. Риск для других сервисов
Пользователи повторно используют пароли — это не гипотеза, а измеренная реальность. Утечка вашей базы означает компрометацию почты, госуслуг и банковских кабинетов ваших клиентов. Масштаб ущерба тут несопоставим с масштабом вашего сайта, и именно поэтому к этой ошибке относятся строже, чем к другим.
5. Это ломается даже без утечки
Пароль сохраняется в момент входа, а меняется он в другом месте. Через месяц в поле лежит устаревшее значение, а система, которая на него полагается, начинает вести себя непредсказуемо. Половину таких историй обнаруживают не как уязвимость, а как «интеграция перестала работать у части пользователей».
Какие задачи пытаются решить
Обычно хранение паролей нужно для:
- Интеграция с внешними системами — авторизация на стороннем сервисе
- Синхронизация аккаунтов — создание учётки в другой системе
- Восстановление доступа — помощь пользователю без сброса пароля
Правильные решения
Для интеграции: OAuth/JWT токены
Вместо передачи пароля используйте токены:
<?php
// Генерация токена для внешней системы
function generateIntegrationToken(int $userId): string
{
$token = bin2hex(random_bytes(32));
$expiry = time() + 3600; // 1 час
// Сохраняем хэш токена, не сам токен.
// Для случайных токенов достаточно быстрого SHA-256: bcrypt намеренно
// медленный, и на API, где токен проверяется на каждом запросе,
// это превращается в узкое место без выигрыша в стойкости
$user = new CUser();
$user->Update($userId, [
'UF_API_TOKEN_HASH' => hash('sha256', $token),
'UF_API_TOKEN_EXPIRY' => $expiry,
]);
return $token; // Токен отдаём пользователю один раз
}
// Проверка токена
function validateToken(int $userId, string $token): bool
{
$user = CUser::GetByID($userId)->Fetch();
if ($user['UF_API_TOKEN_EXPIRY'] < time()) {
return false; // Токен истёк
}
return hash_equals((string) $user['UF_API_TOKEN_HASH'], hash('sha256', $token));
}Для синхронизации: API с токенами
<?php
// Создание пользователя во внешней системе через API
function syncUserToExternalSystem(int $userId): void
{
$user = CUser::GetByID($userId)->Fetch();
// Генерируем временный пароль для внешней системы
$tempPassword = bin2hex(random_bytes(16));
// Отправляем во внешнюю систему
$response = httpClient()->post('https://external.com/api/users', [
'email' => $user['EMAIL'],
'name' => $user['NAME'],
'temp_password' => $tempPassword,
'require_password_change' => true,
]);
// Уведомляем пользователя
sendEmail($user['EMAIL'], 'Ваш аккаунт создан',
"Временный пароль: {$tempPassword}\nСмените его при первом входе.");
}Для SSO: Single Sign-On
Вместо синхронизации паролей внедрите единую точку авторизации:
<?php
// Генерация SSO-токена
function generateSsoToken(int $userId): string
{
$payload = [
'user_id' => $userId,
'exp' => time() + 300, // 5 минут
'nonce' => bin2hex(random_bytes(16)),
];
return base64_encode(json_encode($payload)) . '.' .
hash_hmac('sha256', json_encode($payload), SSO_SECRET);
}
// Редирект на внешнюю систему с токеном
header('Location: https://external.com/sso?token=' . generateSsoToken($userId));Для восстановления: ссылка сброса
<?php
// Генерация ссылки сброса пароля
function generatePasswordResetLink(int $userId): string
{
$token = bin2hex(random_bytes(32));
$expiry = time() + 3600;
$user = new CUser();
$user->Update($userId, [
'UF_RESET_TOKEN' => password_hash($token, PASSWORD_DEFAULT),
'UF_RESET_EXPIRY' => $expiry,
]);
return "https://site.ru/reset?user={$userId}&token={$token}";
}Таблица сравнения подходов
| Задача | Плохое решение | Правильное решение |
|---|---|---|
| Интеграция с API | Хранить пароль | OAuth токены |
| Синхронизация аккаунтов | Копировать пароль | Временный пароль + смена |
| SSO между системами | Общая база паролей | JWT/SAML токены |
| Помощь пользователю | Показать пароль | Ссылка сброса |
Почему для токенов быстрый хэш, а для паролей — медленный. Разница не в «строгости», а в том, что защищает от подбора. Пароль придумал человек: энтропии мало, словарь подходит, поэтому нужен намеренно медленный алгоритм (bcrypt, argon2), делающий перебор невыгодным. Токен из random_bytes(32) перебрать нельзя в принципе — там 256 бит случайности, — поэтому замедлять проверку незачем, и SHA-256 с hash_equals здесь правильный выбор. Использовать bcrypt для токенов, проверяемых на каждом запросе, — это добровольно посаженная себе на API задержка.
Аудит безопасности
Проверьте систему на наличие паролей в открытом виде. Обратите внимание: значения пользовательских полей лежат не в b_user, а в отдельных таблицах — b_uts_user для одиночных значений и b_utm_user для множественных. Запрос вида SELECT UF_* FROM b_user невалиден и ничего не найдёт.
-- 1. Ищем подозрительные пользовательские поля
SELECT ID, ENTITY_ID, FIELD_NAME, USER_TYPE_ID
FROM b_user_field
WHERE ENTITY_ID = 'USER'
AND (FIELD_NAME LIKE '%PASS%' OR FIELD_NAME LIKE '%PWD%' OR FIELD_NAME LIKE '%SECRET%');
-- 2. Смотрим содержимое найденного поля (подставьте своё имя колонки)
SELECT VALUE_ID, UF_USER_PASS
FROM b_uts_user
WHERE UF_USER_PASS IS NOT NULL AND UF_USER_PASS <> ''
LIMIT 20;# 3. И то, что чаще всего находится быстрее любого SQL, —
# сам код, который это записывает
grep -rn "PASSWORD" /home/bitrix/www/local/ | grep -iv "CONFIRM_PASSWORD\|checkPassword"Если нашли — немедленно:
- Удалите данные из поля
- Принудительно сбросьте пароли затронутых пользователей
- Уведомите их о необходимости сменить пароль
- Удалите код, сохраняющий пароли
А если пароль действительно нужен
Отдельно про случай, который обычно и приводит людей к этой статье: на проекте есть legacy-система, у которой нет ни API-токенов, ни OAuth, — она принимает только логин и пароль, и ваш сайт должен ходить в неё от имени пользователя.
Честный порядок действий:
- Ещё раз проверить, что альтернативы нет. У большинства систем, о которых так говорят, обнаруживается сервисная учётная запись или базовая авторизация на уровне интеграции — то есть один набор учётных данных для сервиса, а не пароль каждого пользователя. Это решает задачу без хранения чужих паролей вообще.
- Если пароль всё-таки нужен в восстановимом виде — это уже не хэширование, а шифрование, и разница принципиальна: ключ обязан храниться вне базы данных (переменные окружения, отдельное хранилище секретов), чтобы дамп базы сам по себе ничего не давал. Доступ к расшифровке — из одного места в коде и с логированием каждого обращения.
- Зафиксировать это решение письменно — с указанием, кто его принял и почему. Не ради бюрократии: через год про причину забудут, а конструкция останется, и без пояснения её либо повторят там, где не нужно, либо снесут вместе с работающей интеграцией.
Это плохой вариант. Он остаётся плохим, даже когда сделан аккуратно, — но он на порядок лучше поля UF_USER_PASS с паролями в открытом виде, которое появится, если разговор закончится словом «нельзя».
Итог одной фразой: безопасность — не фича, а требование, и пароли в открытом виде являются инцидентом с момента появления в базе, а не с момента утечки. Но отказ должен приходить вместе с рабочей альтернативой — иначе задачу решат в обход вас.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.