diff --git a/src/vk_parser_app/admin.py b/src/vk_parser_app/admin.py index 666e2bd..d1bc33a 100644 --- a/src/vk_parser_app/admin.py +++ b/src/vk_parser_app/admin.py @@ -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")