PostgreSQL默认只负责校验密码是否正确,并不关心同一个账户已经连续输错多少次。也就是说,如果数据库暴露在公网并且只开启了密码认证,攻击者可以对目标账户持续发起猜测,而数据库不会在中途锁定该账户。这种设计虽然避免了误锁导致服务不可用,但也给暴力破解留下了空间。本文讨论的扩展正是为了解决这一问题:通过C语言编写的认证钩子,在认证阶段记录失败次数,达到阈值后直接拒绝后续尝试。

锁定机制的整体设计
实现登录失败锁定,需要先明确三件事:失败次数存储在哪里、何时判定锁定、锁定后如何解除。由于认证失败发生在用户进入数据库之前,此时还没有建立正常的会话事务,因此不能依赖简单的表触发器来实时记录。扩展通常采用共享内存保存计数,或者使用一个辅助进程持久化到磁盘。对于生产环境,如果实例因故障重启,内存中的失败计数会丢失,这是可以接受的折中;如果希望重启后仍保留锁定状态,则需要在锁定期内把关键信息写入文件或unlogged表。
另一个设计要点是锁定判定条件。可以在认证钩子中拿到用户名和客户端地址,如果某个用户连续失败次数超过阈值,就直接返回拒绝,而不再调用后续的密码校验逻辑。锁定状态可以用时间窗口来管理,例如连续失败5次后锁定10分钟,10分钟后自动恢复计数。也可以设置永不自动解锁,必须由管理员手动清除。不同场景对误锁的容忍度不同,建议把阈值和锁定时长都做成GUC参数,方便运维调整。
除了用户维度,还需要考虑客户端地址维度。攻击者可能使用同一个IP遍历多个用户名,也可能使用多个IP集中猜解一个账户。更完善的扩展会同时跟踪用户与IP的组合,或者至少提供按IP锁定的选项。考虑到实现复杂度和性能,第一版可以先只针对数据库用户名做锁定,后续再扩展。
用认证钩子记录失败次数
PostgreSQL提供了一个ClientAuthentication_hook类型,允许扩展在认证过程中插入自定义逻辑。该钩子函数在客户端认证成功或失败后都会触发。我们可以在钩子里检查认证结果,如果失败,则根据端口结构体中的用户名增加失败计数。端口结构体Port包含了database_name、user_name、raddr等信息,足够用于识别账户。
下面是一段简化后的C扩展代码,展示了如何注册钩子并在认证失败时累加计数。为了保持示例可读,这里只展示核心逻辑,完整扩展还需要处理共享内存的初始化和并发安全。
#include <postgres.h>
#include <fmgr.h>
#include <miscadmin.h>
#include <storage/lwlock.h>
#include <storage/shmem.h>
#include <utils/guc.h>
#include <libpq/libpq.h>
#include <utils/timestamp.h>
PG_MODULE_MAGIC;
void _PG_init(void);
static void lock_on_auth(Port *port, int status);
static int max_failures = 5;
static int lock_minutes = 10;
typedef struct AccountLockEntry
{
int failures;
TimestampTz last_fail_time;
bool locked;
} AccountLockEntry;
static AccountLockEntry *account_array = NULL;
void
_PG_init(void)
{
DefineCustomIntVariable("auth_lock.max_failures",
"Maximum failures before locking.",
NULL,
&max_failures,
5, 0, 1000,
PGC_SIGHUP, 0,
NULL, NULL, NULL);
DefineCustomIntVariable("auth_lock.lock_minutes",
"Minutes to lock account after too many failures.",
NULL,
&lock_minutes,
10, 0, 1440,
PGC_SIGHUP, 0,
NULL, NULL, NULL);
if (!process_shared_preload_libraries_in_progress)
return;
RequestAddinShmemSpace(sizeof(AccountLockEntry) * 1000);
RequestNamedLWLockTranche("auth_lock_locks", 1);
ClientAuthentication_hook = lock_on_auth;
}
static void
lock_on_auth(Port *port, int status)
{
if (port == NULL || port->user_name == NULL)
return;
if (status == STATUS_OK)
return;
/* 根据用户名简单映射到数组下标,这里省略哈希实现 */
int idx = 0;
for (int i = 0; port->user_name[i] != '\0'; i++)
idx = (idx + port->user_name[i]) % 1000;
if (account_array[idx].locked)
{
TimestampTz now = GetCurrentTimestamp();
long secs = (now - account_array[idx].last_fail_time) / 1000000;
if (secs < lock_minutes * 60)
{
ereport(FATAL,
(errcode(ERRCODE_INVALID_AUTHORIZATION_SPECIFICATION),
errmsg("account %s is locked due to repeated login failures",
port->user_name)));
}
else
{
account_array[idx].locked = false;
account_array[idx].failures = 0;
}
}
account_array[idx].failures++;
account_array[idx].last_fail_time = GetCurrentTimestamp();
if (account_array[idx].failures >= max_failures)
{
account_array[idx].locked = true;
ereport(FATAL,
(errcode(ERRCODE_INVALID_AUTHORIZATION_SPECIFICATION),
errmsg("too many login failures, account %s is locked",
port->user_name)));
}
}
代码中的FATAL错误会直接终止当前客户端连接,其他连接不会受到影响。需要注意的是,ClientAuthentication_hook运行在认证阶段,如果在这里调用需要数据库事务的函数会非常危险。示例中仅使用共享内存数组和轻量级锁,避免访问表。生产实现还要用LWLock保护计数更新,防止多个并发连接同时修改同一项导致计数丢失。
哈希映射部分为了演示而做了简化,实际使用中应当根据用户名建立真正的哈希表,否则不同用户可能因为下标碰撞而共享失败计数。例如用户alice和bob经过简单映射可能落到同一个数组位置,导致bob的失败次数被叠加到alice的项里。虽然这不会直接造成安全问题,但可能引发误锁,因此需要完善的哈希函数和冲突处理。
配置参数与解锁方式
扩展编译安装后,需要在postgresql.conf中设置shared_preload_libraries让扩展在服务器启动时加载。示例中通过GUC暴露了auth_lock.max_failures和auth_lock.lock_minutes两个参数,管理员可以用ALTER SYSTEM命令动态调整阈值和锁定时长,也可以通过reload让新配置生效。
ALTER SYSTEM SET shared_preload_libraries = 'auth_lock'; ALTER SYSTEM SET auth_lock.max_failures = 8; ALTER SYSTEM SET auth_lock.lock_minutes = 15; SELECT pg_reload_conf();
如果对外提供的是多个应用共用的账户,锁定阈值不宜设置过低,否则一次配置错误就可能让所有应用无法连接。常见做法是为普通账户设置5到10次失败锁定,为高权限账户设置更低的阈值,并同时监控锁定事件。扩展内部可以加入简单日志记录,每次锁定发生时写入服务器日志,便于安全审计。
解锁可以设计为自动和手动两种。自动解锁由时间窗口控制,超过锁定时长后计数清零,这与上面代码中的判断逻辑一致。手动解锁则需要扩展提供一个函数或者通过管理表操作。如果扩展不提供SQL函数,管理员可以重启实例清除共享内存状态,但这样会同时清空所有账户的失败计数。更好的方案是注册一个C函数,接收用户名参数,将该用户对应的项清零并解除锁定。
下面展示一个手动解锁函数的SQL声明和一个简单的调用示例。实际函数需要能够在后端进程中访问共享内存,因此必须在C层实现。
CREATE FUNCTION auth_lock_reset(account_name text)
RETURNS boolean
AS 'MODULE_PATHNAME', 'auth_lock_reset'
LANGUAGE C STRICT;
SELECT auth_lock_reset('app_user');
如果暂时不想编写完整的C扩展,也可以使用log_failures或外部工具记录认证失败日志,再由定时任务分析日志并执行锁操作。不过这种方案通常滞后于实际攻击,而且锁定的动作无法在认证阶段即刻生效。对于要求较高的系统,建议还是采用认证钩子方案。
生产环境注意事项
这一节重点说明扩展部署过程中容易踩到的坑。首先是共享内存的分配。RequestAddinShmemSpace在_PG_init中只能调用一次,并且必须在process_shared_preload_libraries_in_progress为真时进行。如果扩展不是通过shared_preload_libraries加载,这段分配逻辑会失败或导致服务器启动异常。因此部署文档必须明确要求将扩展加入共享预加载库。
其次是并发控制。多个客户端同时认证时,失败计数需要原子更新。PostgreSQL的共享内存不会自动提供线程安全,必须配合LWLock或自旋锁。如果为了简单而忽略锁,计数可能在高并发下被覆盖,攻击者可以利用这一点绕过锁定。建议在每次更新前获取一个独立的锁事务保证串行,或者使用原子指令。
还有一个实际问题是代理或中间件场景下,数据库看到的客户端地址都是同一台代理服务器。此时按IP锁定会误伤所有经过该代理的用户,因此默认只按用户名锁定更合适。如果能够从应用层传递真实客户端地址,再结合port中的raddr进行组合判断,可以做到更细粒度,但这需要额外的信任机制。
最后是测试验证。部署完成后,可以使用psql故意输入错误密码,观察失败次数累积和锁定效果。测试时要避免使用生产账户,最好在临时实例上验证。锁定触发后,即使输入正确密码也应该被拒绝,并返回账户已锁定的错误信息。通过调整参数可以缩短锁定时长,便于快速验证自动解锁是否按预期恢复。
综上,PostgreSQL本身没有内置账户锁定,但通过客户端认证钩子和共享内存可以高效地补上这一层防护。扩展的难点在于认证阶段无法安全访问数据库表,因此状态管理要放在共享内存中,并做好并发保护。阈值、锁定时长和解锁策略需要结合业务场景权衡,避免误锁导致业务中断。配合日志监控和外部工具,可以构建一套相对完整的防暴力破解方案。
PostgreSQL登录失败锁定账户安全修改时间:2026-09-22 03:40:14