Справочник DBA: базы данных изнутри читается по порядку

Статья 11.1 · раздел 11 «Безопасность и соответствие» · 80 из 109

Аутентификация PostgreSQL: правила pg_hba.conf, SCRAM-SHA-256 и проверка, кто как вошёл

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в двух контейнерах postgres:18 (сервер с --memory 512m и клиент) в одной сети docker; TLS с одноразовым CA на два дня; опыт выполнен 28 октября 2026; данных нет, пять ролей
содержание решать задачи SQL на собеседовании

Пароль верный, а сервер отвечает password authentication failed; или роль входит без пароля, хотя в конфигурации написано scram-sha-256. Обе истории объясняет один механизм: сервер читает pg_hba.conf сверху вниз, берёт первую подошедшую строку и целиком доверяет её методу. Второй механизм — сам метод и то, что лежит в pg_authid.rolpassword: от формата секрета зависит, какой обмен вообще возможен.

Файл pg_hba.conf лежит в каталоге данных, как в карте PGDATA; здесь он настраивается и проверяется.

DBA / 01

Какая строка pg_hba.conf сработала и что лежит в секрете роли

Пять правил pg_hba.conf и формат SCRAM-секретаПять правил pg_hba.conf сверху вниз. Строка 1: local, роль postgres, peer, вход postgres через сокет. Строка 2: роль legacy из подсети 192.168.48.0/20, md5, затем scram-sha-256. Строка 3: база billing, reject, отказ роли app. Строка 4: все роли из подсети, scram-sha-256, вход app. Строка 5: 127.0.0.1, trust, вход app с самого сервера без пароля. Ниже секрет роли app в pg_authid: схема SCRAM-SHA-256 с 4096 итераций, соль 16 байт, StoredKey 32 байта, ServerKey 32 байта.pg_hba.conf читается сверху вниз; первое подошедшее правило решает1 local all postgres peerpostgres через сокет2 host all legacy 192.168.48.0/20 md5legacy с клиента: md5, потом SCRAM3 host billing all 192.168.48.0/20 rejectapp в billing: отказ4 host all all 192.168.48.0/20 scram-sha-256app в postgres: SCRAM5 host all all 127.0.0.1/32 trustapp с самого сервера: без пароляpg_authid.rolpassword роли app: в секрете нет пароля, только производные от негоSCRAM-SHA-256$4096соль 16 байтStoredKey 32 БServerKey 32 Б
Роль app с одного и того же адреса получает три разных исхода по строкам 3, 4 и 5, а метод определяет не роль, а первая подошедшая строка. В секрете SCRAM хранятся схема, число итераций, соль и два ключа по 32 байта.

Стенд

Правила pg_hba.conf срабатывают по адресу клиента, поэтому нужны сервер и клиент в одной сети. Подсеть возьми свою (docker network inspect dba-auth-net -f '{{(index .IPAM.Config 0).Subnet}}'): в опыте 192.168.48.0/20, сервер .2, клиент .3.

docker network create dba-auth-net
docker run -d --name dba-auth-server --network dba-auth-net --memory 512m -e POSTGRES_PASSWORD=pg postgres:18
docker run -d --name dba-auth-client --network dba-auth-net --entrypoint sleep postgres:18 3600
CREATE DATABASE billing;
CREATE ROLE app LOGIN PASSWORD 'app-pass';
SET password_encryption = 'md5';
CREATE ROLE legacy LOGIN PASSWORD 'legacy-pass';
ALTER SYSTEM SET log_connections = 'authentication';
SELECT pg_reload_conf();

Роль legacy с md5-секретом изображает старую роль после обновления; сервер предупреждает WARNING: setting an MD5-encrypted password, поддержка md5 будет удалена. Значение authentication в log_connections (в 18 это список этапов) пишет в лог, по какой строке файла вошла каждая сессия.

Пять правил и ошибка в шестом

Файл заменён целиком (docker cp в /var/lib/postgresql/18/docker/pg_hba.conf и chown postgres:postgres), в последней строке опечатка:

local   all      postgres                       peer
host    all      legacy    192.168.48.0/20      md5
host    billing  all       192.168.48.0/20      reject
host    all      all       192.168.48.0/20      scram-sha-256
host    all      all       127.0.0.1/32         trust
host    all      bad       192.168.48.0/20      scram-sha256

После pg_reload_conf() смотрим, как файл разобрал сервер:

select rule_number rn, line_number ln, type, address, auth_method, database, user_name, error from pg_hba_file_rules;
вывод
 rn | ln | type  |   address    |  auth_method  | database  | user_name  |                    error                     
----+----+-------+--------------+---------------+-----------+------------+----------------------------------------------
  1 |  1 | local |              | peer          | {all}     | {postgres} | 
  2 |  2 | host  | 192.168.48.0 | md5           | {all}     | {legacy}   | 
  3 |  3 | host  | 192.168.48.0 | reject        | {billing} | {all}      | 
  4 |  4 | host  | 192.168.48.0 | scram-sha-256 | {all}     | {all}      | 
  5 |  5 | host  | 127.0.0.1    | trust         | {all}     | {all}      | 
    |  6 |       |              |               |           |            | invalid authentication method "scram-sha256"

pg_hba_file_rules читает файл, а не действующие правила: у строки с ошибкой нет номера правила, зато есть line_number и текст. Что сервер сделал с файлом, видно в логе:

вывод
LOG:  invalid authentication method "scram-sha256"
CONTEXT:  line 6 of configuration file "/var/lib/postgresql/18/docker/pg_hba.conf"
LOG:  /var/lib/postgresql/18/docker/pg_hba.conf was not reloaded
LOG:  connection authenticated: user="postgres" method=trust (/var/lib/postgresql/18/docker/pg_hba.conf:117)

Одна опечатка отменяет перезагрузку всего файла, а работают прежние правила: следующее подключение прошло по строке 117 старого файла из образа, с method=trust. Поэтому сначала запрос к pg_hba_file_rules (where error is not null), потом перезагрузка. Убираем шестую строку, снова pg_reload_conf(), и запрос ошибок возвращает 0.

Какая строка сработала

Пять подключений: сокет внутри сервера, app и legacy с клиента, app в базу billing и app с самого сервера по 127.0.0.1.

S=dba-auth-server; C=dba-auth-client
docker exec -u postgres $S psql -Atc "select current_user"
docker exec -e PGPASSWORD=app-pass $C psql -h $S -U app -d postgres -Atc "select current_user, inet_client_addr()"
docker exec -e PGPASSWORD=legacy-pass $C psql -h $S -U legacy -d postgres -Atc "select current_user"
docker exec -e PGPASSWORD=app-pass $C psql -h $S -U app -d billing -Atc "select 1"
docker exec $S psql -h 127.0.0.1 -U app -d postgres -Atc "select current_user"
вывод (лог сервера без времени и pid; ответ клиента, где он есть)
LOG:  connection authenticated: identity="postgres" method=peer (/var/lib/postgresql/18/docker/pg_hba.conf:1)
LOG:  connection authenticated: identity="app" method=scram-sha-256 (/var/lib/postgresql/18/docker/pg_hba.conf:4)     app|192.168.48.3
LOG:  connection authenticated: identity="legacy" method=md5 (/var/lib/postgresql/18/docker/pg_hba.conf:2)
FATAL:  pg_hba.conf rejects connection for host "192.168.48.3", user "app", database "billing", no encryption
LOG:  connection authenticated: user="app" method=trust (/var/lib/postgresql/18/docker/pg_hba.conf:5)

Последний вход показывает цену trust: app вошла без пароля, потому что адрес 127.0.0.1 подошёл раньше любого метода с паролем. Неверный пароль с клиента даёт password authentication failed for user "app", а DETAIL в логе называет строку: Connection matched file ... line 4. К следующим строкам сервер не переходит (документация).

Порядок строк — часть политики. Меняем местами строки 3 и 4 (общее разрешение выше reject для billing) и повторяем app в billing:

Ответ клиента: billing. Запрет ниже общего разрешения не срабатывает: app вошла в базу. Узкие запреты ставят выше широких разрешений. Так работает перебор в check_hba: список разобранных строк, первая подошедшая по адресу, базе и роли возвращается.

		if (!check_role(port->user_name, roleid, hba->roles, false))
			continue;

		/* Found a record that matched! */
		port->hba = hba;
		return;

Это hba.c, check_hba, строки 2619-2624, тег REL_18_6: continue пропускает строку, return фиксирует первую подошедшую. Если не подошла ни одна, ниже создаётся строка uaImplicitReject.

Что хранит rolpassword

При password_encryption = scram-sha-256 (по умолчанию в 18.6) роль app хранит в pg_authid не пароль, а секрет:

select split_part(rolpassword,'$',1) as scheme,
 split_part(split_part(rolpassword,'$',2),':',1) as iterations,
 length(decode(split_part(split_part(rolpassword,'$',2),':',2),'base64')) as salt_bytes,
 length(decode(split_part(split_part(rolpassword,'$',3),':',1),'base64')) as storedkey_bytes,
 length(decode(split_part(split_part(rolpassword,'$',3),':',2),'base64')) as serverkey_bytes
from pg_authid where rolname='app';
вывод (начало строки rolpassword — остальное обрезано при публикации — и разбор)
SCRAM-SHA-256$4096:oMicZo…
    scheme     | iterations | salt_bytes | storedkey_bytes | serverkey_bytes 
---------------+------------+------------+-----------------+-----------------
 SCRAM-SHA-256 | 4096       |         16 |              32 |              32

Формат записан в коде прямо в комментарии:

	/*
	 * The secret is of form:
	 *
	 * SCRAM-SHA-256$<iterations>:<salt>$<storedkey>:<serverkey>
	 */

Это auth-scram.c, parse_scram_secret, строки 616-620. Итераций 4096 (параметр scram_iterations), соль 16 байт, ключи по 32 байта; пароля в строке нет, и сервер не получает его по сети (документация). С md5 иначе: та же страница документации говорит, что md5 не защищает, если хеш пароля украден с сервера.

Перевод ролей с md5 на SCRAM

Сначала найди роли и правила, которые остались на md5:

select rolname, left(rolpassword, 5) as scheme from pg_authid where rolpassword like 'md5%';
select line_number, auth_method, user_name from pg_hba_file_rules where auth_method in ('md5','password','trust');
вывод
 rolname | scheme 
---------+--------
 legacy  | md507

 line_number | auth_method | user_name 
-------------+-------------+-----------
           2 | md5         | {legacy}
           5 | trust       | {all}
(2 rows)

Из md5-хеша SCRAM-секрет не построить, поэтому пароль задаётся заново (пользователь меняет его сам или ты выдаёшь новый); password_encryption при этом scram-sha-256, как по умолчанию:

alter role legacy password 'legacy-pass';
select rolname, left(rolpassword, 13) as scheme from pg_authid where rolname='legacy';
вывод
ALTER ROLE
 rolname |    scheme     
---------+---------------
 legacy  | SCRAM-SHA-256

Строка md5 в pg_hba.conf при этом не мешает входу: сервер выбирает обмен по виду секрета. Вход legacy с клиента по строке 2 всё ещё помечен в логе как method=md5, а ответ клиента приходит. Что прошёл SCRAM, видно в CheckPWChallengeAuth:

	if (port->hba->auth_method == uaMD5 && pwtype == PASSWORD_TYPE_MD5)
		auth_result = CheckMD5Auth(port, shadow_pass, logdetail);
	else
		auth_result = CheckSASLAuth(&pg_be_scram_mech, port, shadow_pass,
									logdetail);

Это auth.c, строки 859-863: по комментарию над этим кодом (строки 849-858), md5-обмен идёт только для md5-секрета, а если у роли SCRAM-секрет, выбирается SCRAM. Метка в логе берётся из строки файла, не из обмена, так что по одному log_connections перевод не проверить. Клиентский require_auth=scram-sha-256 (ниже) к legacy на строке md5 проходит: обмен был SCRAM.

Теперь в строке 2 меняем md5 на scram-sha-256, pg_reload_conf() и повторяем вход (select 'in' под legacy с клиента):

вывод (pg_hba_file_rules, ответ клиента, строка лога)
 line_number | auth_method | user_name 
-------------+-------------+-----------
           5 | trust       | {all}

in
LOG:  connection authenticated: identity="legacy" method=scram-sha-256 (/var/lib/postgresql/18/docker/pg_hba.conf:2)

Старых записей не осталось, когда оба запроса из начала раздела пусты для md5. В конце опыта, когда пароль сменила и роль old из следующего раздела, select count(*) as md5_roles from pg_authid where rolpassword like 'md5%' вернул 0. Роли без пароля (rolpassword is null, как guest и alice ниже) этот запрос не видит: для них строка trust или cert должна быть сделана осознанно.

Что проверяет клиент: require_auth и channel_binding

pg_hba.conf решает, что требует сервер; клиент может ответить своим условием. require_auth задаёт метод, который сервер обязан довести до конца, иначе libpq разорвёт соединение (параметры подключения). В опыте: роль guest без пароля в строке с trust, роль old с md5-секретом в строке md5.

c() { docker exec -e PGPASSWORD=app-pass $C psql "$1" -Atc "select current_user"; }
c "host=$S user=app dbname=postgres require_auth=scram-sha-256"
c "host=$S user=guest dbname=postgres require_auth=scram-sha-256"
c "host=$S user=app dbname=postgres require_auth=md5"
c "host=$S user=old dbname=postgres require_auth=scram-sha-256"
вывод
app
psql: error: connection to server at "dba-auth-server" (192.168.48.2), port 5432 failed: authentication method requirement "scram-sha-256" failed: server did not complete authentication
psql: error: connection to server at "dba-auth-server" (192.168.48.2), port 5432 failed: authentication method requirement "md5" failed: server requested SASL authentication
psql: error: connection to server at "dba-auth-server" (192.168.48.2), port 5432 failed: authentication method requirement "scram-sha-256" failed: server requested a hashed password

Второй отказ важен: если в pg_hba.conf по ошибке стоит trust, клиент с require_auth=scram-sha-256 не подключится к такому серверу. Третий: сервер предложил SASL, а не md5. Четвёртый: md5-роль не проходит требование SCRAM. Для legacy на строке md5 с SCRAM-секретом то же требование выполнено и вход прошёл. require_auth=!none (любой метод, кроме входа без проверки) пропускает app, а none пропускает guest.

channel_binding привязывает обмен SCRAM к TLS-соединению. Включаем TLS с одноразовым CA (сертификаты сервера и клиентов — openssl req и openssl x509 -CA; ssl, ssl_cert_file, ssl_key_file, ssl_ca_file через ALTER SYSTEM и pg_reload_conf()):

вывод (channel_binding=require)
app, sslmode=disable:  channel binding required, but SSL not in use
app, sslmode=require:  app
old (md5-секрет), sslmode=require:  channel binding required but not supported by server's authentication request
app, sslmode=verify-full без CA: SSL error: certificate verify failed
app, sslmode=verify-full, sslrootcert=ca.crt:  app

В pg_stat_ssl для последней сессии: t|TLSv1.3|TLS_AES_256_GCM_SHA384. Клиент с channel_binding=require отказывается от соединения без TLS и от md5-роли; проверку сертификата сервера делает sslmode=verify-full с sslrootcert.

pg_ident.conf и clientcert=verify-full

Клиентский сертификат проверяют метод cert или параметр clientcert=verify-full у другого метода в строке hostssl; CN сертификата должен совпасть с именем роли или подойти по карте из pg_ident.conf (документация). Сертификат с CN=alice@corp должен открывать роль alice.

# pg_ident.conf
extmap   alice@corp   alice
# pg_hba.conf
hostssl  all  app     192.168.48.0/20   scram-sha-256  clientcert=verify-full
hostssl  all  alice   192.168.48.0/20   cert           map=extmap
host     all  all     192.168.48.0/20   scram-sha-256
select map_number, line_number, map_name, sys_name, pg_username, error from pg_ident_file_mappings;
вывод
 map_number | line_number | map_name |  sys_name  | pg_username | error 
------------+-------------+----------+------------+-------------+-------
          1 |          73 | extmap   | alice@corp | alice       | 

Пять подключений с клиента (sslmode=verify-full, sslrootcert, sslcert и sslkey по ситуации):

вывод (ответ клиента; для отказов в скобках запись сервера)
1. app, сертификат CN=app, пароль:          app
2. app, сертификат CN=alice@corp, пароль:   FATAL:  password authentication failed for user "app"
   (provided user name (app) and authenticated user name (alice@corp) do not match)
3. app, без клиентского сертификата:        FATAL:  connection requires a valid client certificate
4. alice, сертификат CN=alice@corp, без пароля:  alice
5. alice, сертификат CN=app:                FATAL:  certificate authentication failed for user "alice"
   (no match in usermap "extmap" for user "alice" authenticated as "app")

Верный пароль при чужом сертификате не проходит (случай 2); сертификат без пароля открывает только роль, для которой есть строка в карте (случаи 4 и 5). Файл pg_ident.conf проверяет view pg_ident_file_mappings, как pg_hba_file_rules проверяет pg_hba.conf.

OAuth в PostgreSQL 18

PostgreSQL 18 добавил метод oauth (заметки о выпуске): метод в pg_hba.conf, параметры libpq, oauth_validator_libraries для библиотек проверки токена и флаг сборки --with-libcurl. По документации токены выдаёт провайдер, а не PostgreSQL; в строке обязательны issuer (HTTPS-адрес сервера авторизации) и scope, validator нужен при нескольких библиотеках, без map имя из токена должно совпасть с именем роли. delegate_ident_mapping отдаёт сопоставление валидатору, документация предупреждает о нём отдельно. Опыта нет: нужны провайдер и валидатор.

Что из этого следует

Метод входа определяет первая подошедшая строка, поэтому порядок строк и trust — главные места ошибок: проверяй файл до перезагрузки, а после прогоняй по подключению в каждую строку. Перевод ролей с md5 — новый пароль и замена метода в файле, проверка — два запроса. Клиентские require_auth и channel_binding страхуют от ошибки в файле на сервере. Требования закона и стандартов к паролям и журналу входов здесь не разбираются, их трактовка за юристом.

Чек-лист

  1. Перед pg_reload_conf() выполни select line_number, error from pg_hba_file_rules where error is not null; в логе ищи was not reloaded.
  2. Узкие запреты ставь выше общих разрешений; log_connections = 'authentication' показывает файл и номер строки каждого входа.
  3. Найди роли и строки с md5: rolpassword like 'md5%' и auth_method = 'md5'; смени пароли при password_encryption = scram-sha-256, потом замени метод в файле.
  4. trust оставляй только там, где доступ закрыт другим способом; в строках для подсети его не должно быть.
  5. В клиентах приложений ставь require_auth=scram-sha-256, channel_binding=require, sslmode=verify-full; для входа по сертификату карту проверяй через pg_ident_file_mappings.

Удалить стенд: docker rm -f -v dba-auth-server dba-auth-client && docker network rm dba-auth-net.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru