#!/usr/bin/env python3 """ 清除登录防暴破锁定(删除 login_attempts 中近期失败记录)。 锁定规则见 server/application/services/auth_service.py: MAX_LOGIN_FAILURES=5,LOCKOUT_MINUTES=15(按 username + ip_address 统计) 用法: python scripts/unlock_login_lockout.py python scripts/unlock_login_lockout.py --username admin python scripts/unlock_login_lockout.py --all-failures # 删除该用户全部失败记录 需在仓库根目录有 .env(含 NEXUS_DATABASE_URL 或 DATABASE_URL),或在生产机部署目录执行。 """ from __future__ import annotations import argparse import asyncio import os import re import sys from pathlib import Path from urllib.parse import unquote, urlparse ROOT = Path(__file__).resolve().parents[1] def _load_dotenv() -> None: env_path = ROOT / ".env" if not env_path.is_file(): return for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") key, value = key.strip(), value.strip().strip("\"'") if key and key not in os.environ: os.environ[key] = value def _parse_database_url(url: str) -> dict: """Parse mysql+aiomysql://user:pass@host:port/db""" u = urlparse(url.replace("mysql+aiomysql://", "mysql://", 1)) return { "host": u.hostname or "127.0.0.1", "port": u.port or 3306, "user": unquote(u.username or ""), "password": unquote(u.password or ""), "db": (u.path or "/").lstrip("/") or "nexus", } async def _unlock(username: str, all_failures: bool) -> int: import aiomysql raw = ( os.environ.get("NEXUS_DATABASE_URL") or os.environ.get("DATABASE_URL") or "" ) if not raw: print("ERROR: 未找到 NEXUS_DATABASE_URL / DATABASE_URL,请在 .env 中配置或在生产目录执行", file=sys.stderr) return 1 cfg = _parse_database_url(raw) conn = await aiomysql.connect( host=cfg["host"], port=int(cfg["port"]), user=cfg["user"], password=cfg["password"], db=cfg["db"], connect_timeout=20, ) try: async with conn.cursor() as cur: if all_failures: await cur.execute( "DELETE FROM login_attempts WHERE username = %s AND success = 0", (username,), ) else: await cur.execute( """ DELETE FROM login_attempts WHERE username = %s AND success = 0 AND attempted_at >= UTC_TIMESTAMP() - INTERVAL 20 MINUTE """, (username,), ) deleted = cur.rowcount await conn.commit() print(f"OK: 已删除用户「{username}」失败登录记录 {deleted} 条,锁定已解除。") return 0 finally: conn.close() def main() -> int: parser = argparse.ArgumentParser(description="解除 Nexus 登录防暴破锁定") parser.add_argument("--username", default="admin", help="管理员用户名(默认 admin)") parser.add_argument( "--all-failures", action="store_true", help="删除该用户全部历史失败记录(默认仅近 20 分钟)", ) args = parser.parse_args() _load_dotenv() return asyncio.run(_unlock(args.username, args.all_failures)) if __name__ == "__main__": sys.exit(main())