security: protect bootstrap-admin, sleep on bad login, tighten brute-force limits
- bootstrap_admin() now syncs password/active status from env on every startup (rotating the env secret immediately takes effect without DB edit) - user_toggle: block deactivation of the bootstrap admin account - login_post: tighten rate limits (5 per IP / 3 per login instead of 10/5) - login_post: add asyncio.sleep(1.0) delay on failed password attempt
This commit is contained in:
@@ -384,21 +384,30 @@ def pkce_challenge(verifier: str) -> str:
|
||||
|
||||
|
||||
async def bootstrap_admin() -> None:
|
||||
"""Create or sync the bootstrap admin user from env secrets on every startup."""
|
||||
if not settings.admin_bootstrap_login or not settings.admin_bootstrap_password:
|
||||
return
|
||||
pool = await get_pool()
|
||||
exists = await pool.fetchval("SELECT 1 FROM admin_users WHERE login=$1", settings.admin_bootstrap_login)
|
||||
login = settings.admin_bootstrap_login.strip().lower()
|
||||
exists = await pool.fetchval("SELECT 1 FROM admin_users WHERE login=$1", login)
|
||||
if exists:
|
||||
# Always sync password from env so rotating the secret takes effect immediately.
|
||||
await pool.execute(
|
||||
"UPDATE admin_users SET password_hash=$1, is_active=TRUE, updated_at=NOW() WHERE login=$2",
|
||||
hash_password(settings.admin_bootstrap_password),
|
||||
login,
|
||||
)
|
||||
logger.info("Bootstrap admin password synced from env: {}", login)
|
||||
return
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO admin_users(login, password_hash, role, is_active)
|
||||
VALUES($1, $2, 'admin', TRUE)
|
||||
""",
|
||||
settings.admin_bootstrap_login.strip().lower(),
|
||||
login,
|
||||
hash_password(settings.admin_bootstrap_password),
|
||||
)
|
||||
logger.warning("Bootstrap admin user created: {}", settings.admin_bootstrap_login)
|
||||
logger.warning("Bootstrap admin user created: {}", login)
|
||||
|
||||
|
||||
async def get_current_user(request: Request) -> dict | None:
|
||||
@@ -1907,7 +1916,8 @@ async def login_post(request: Request, login: str = Form(...), password: str = F
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if ip_failures >= 10 or login_failures >= 5:
|
||||
# Stricter limits: 5 bad attempts per IP or 3 per login in 15 minutes → block.
|
||||
if ip_failures >= 5 or login_failures >= 3:
|
||||
await pool.execute(
|
||||
"INSERT INTO admin_login_attempts(ip, login, success) VALUES($1, $2, FALSE)",
|
||||
ip,
|
||||
@@ -1930,6 +1940,8 @@ async def login_post(request: Request, login: str = Form(...), password: str = F
|
||||
login_value,
|
||||
)
|
||||
await audit(None, "auth.login_failed", "admin_login", None, {"login": login_value}, request)
|
||||
# Constant-time delay to slow brute-force attempts.
|
||||
await asyncio.sleep(1.0)
|
||||
return templates.TemplateResponse("login.html", base_context(request, None, error="Неверный логин или пароль"))
|
||||
|
||||
await pool.execute("INSERT INTO admin_login_attempts(ip, login, success) VALUES($1, $2, TRUE)", ip, login_value)
|
||||
@@ -3855,6 +3867,12 @@ async def user_toggle(request: Request, user_id: int, csrf_token: str = Form(...
|
||||
if user["role"] != "admin" or int(user["id"]) == user_id:
|
||||
return redirect("/users")
|
||||
pool = await get_pool()
|
||||
# Prevent deactivating the bootstrap admin account (managed via env secrets).
|
||||
bootstrap_login = settings.admin_bootstrap_login.strip().lower()
|
||||
target_login = await pool.fetchval("SELECT login FROM admin_users WHERE id=$1", user_id)
|
||||
if target_login and target_login == bootstrap_login:
|
||||
logger.warning("Attempt to deactivate bootstrap admin '{}' was blocked", bootstrap_login)
|
||||
return redirect("/users")
|
||||
await pool.execute("UPDATE admin_users SET is_active=NOT is_active, updated_at=NOW() WHERE id=$1", user_id)
|
||||
await audit(user["id"], "user.toggle", "user", user_id)
|
||||
return redirect("/users")
|
||||
|
||||
Reference in New Issue
Block a user