Пароль верный, а сервер отвечает password authentication failed; или роль входит без пароля, хотя в конфигурации написано scram-sha-256. Обе истории объясняет один механизм: сервер читает pg_hba.conf сверху вниз, берёт первую подошедшую строку и целиком доверяет её методу. Второй механизм — сам метод и то, что лежит в pg_authid.rolpassword: от формата секрета зависит, какой обмен вообще возможен.
Файл pg_hba.conf лежит в каталоге данных, как в карте PGDATA; здесь он настраивается и проверяется.
Какая строка pg_hba.conf сработала и что лежит в секрете роли
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 страхуют от ошибки в файле на сервере. Требования закона и стандартов к паролям и журналу входов здесь не разбираются, их трактовка за юристом.
Чек-лист
- Перед
pg_reload_conf()выполниselect line_number, error from pg_hba_file_rules where error is not null; в логе ищиwas not reloaded. - Узкие запреты ставь выше общих разрешений;
log_connections = 'authentication'показывает файл и номер строки каждого входа. - Найди роли и строки с
md5:rolpassword like 'md5%'иauth_method = 'md5'; смени пароли приpassword_encryption = scram-sha-256, потом замени метод в файле. trustоставляй только там, где доступ закрыт другим способом; в строках для подсети его не должно быть.- В клиентах приложений ставь
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.