| 3 |
"query": "\n WITH prev AS (\n SELECT failed_login_attempts AS old_attempts, locked_until AS old_lock\n FROM users WHERE id = $1 FOR UPDATE\n )\n UPDATE users u\n SET failed_login_attempts = u.failed_login_attempts + 1,\n last_failed_login_at = NOW(),\n locked_until = CASE\n -- Set/refresh the lock only when reaching the threshold AND not\n -- already inside an active lock window. Without the second clause,\n -- every failed attempt during a lock pushed `locked_until` forward\n -- another window, letting an attacker keep a victim perpetually\n -- locked with one wrong password every <LOCKOUT_MINUTES. Now an\n -- active lock simply runs out; a fresh failure after it expires\n -- re-locks (the counter isn't reset until a successful login).\n WHEN u.failed_login_attempts + 1 >= $2\n AND (u.locked_until IS NULL OR u.locked_until <= NOW())\n THEN NOW() + ($3 || ' minutes')::interval\n ELSE u.locked_until\n END\n FROM prev\n WHERE u.id = $1\n RETURNING\n u.failed_login_attempts,\n (prev.old_attempts + 1 >= $2\n AND (prev.old_lock IS NULL OR prev.old_lock <= NOW())) AS \"just_locked!\"\n ", |