| New file |
| | |
| | | #!/usr/bin/env python3 |
| | | # 重置后台平台管理员 admin 的口令。 |
| | | # |
| | | # 算法与应用完全一致(见 MerchantPasswordHasher): |
| | | # pbkdf2$210000$<base64 salt(16B)>$<base64 PBKDF2WithHmacSHA256(password, salt, 210000, 256bit)> |
| | | # |
| | | # 明文只写到 /root/admin-password.txt(600),**不打印到 stdout**。 |
| | | # 输出只有:长度/字符类别的摘要 + 自检结果 + 端到端验证结果。 |
| | | # |
| | | # 顺带做两件卫生工作: |
| | | # * login_failed_count 清零、locked_until 置 NULL(等价于应用里的 resetStaffPassword) |
| | | # * 吊销所有未过期的 admin_session —— 服务商那次告警的报文里带出过 accessToken, |
| | | # 换口令时把在用的会话一并作废,避免旧 token 继续可用;端到端验证自己产生的那个也会被吊销。 |
| | | # |
| | | # 用法:python3 /root/reset-admin-password.py |
| | | |
| | | import base64 |
| | | import hashlib |
| | | import json |
| | | import os |
| | | import secrets |
| | | import subprocess |
| | | import sys |
| | | import urllib.request |
| | | |
| | | LENGTH = 20 |
| | | # 不含单引号/双引号/反斜杠/空格/`$`:既能满足服务商要求的四类组合, |
| | | # 又不会破坏 SQL 字符串字面量与 shell 引用。 |
| | | CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789@%^*+_.-" |
| | | SPECIALS = "@%^*+_.-" |
| | | OUT_PATH = "/root/admin-password.txt" |
| | | LOGIN_URL = "http://127.0.0.1:8080/api/admin/auth/login" |
| | | |
| | | |
| | | def generate(): |
| | | while True: |
| | | value = "".join(secrets.choice(CHARS) for _ in range(LENGTH)) |
| | | if (any(c.isupper() for c in value) |
| | | and any(c.islower() for c in value) |
| | | and any(c.isdigit() for c in value) |
| | | and any(c in SPECIALS for c in value)): |
| | | return value |
| | | |
| | | |
| | | def derive(password, salt, iterations): |
| | | return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations, dklen=32) |
| | | |
| | | |
| | | def matches(password, encoded): |
| | | parts = encoded.split("$") |
| | | if len(parts) != 4 or parts[0] != "pbkdf2": |
| | | return False |
| | | iterations = int(parts[1]) |
| | | salt = base64.b64decode(parts[2]) |
| | | expected = base64.b64decode(parts[3]) |
| | | return derive(password, salt, iterations) == expected |
| | | |
| | | |
| | | def mysql(sql): |
| | | result = subprocess.run(["mysql", "-N", "-B", "-D", "talent_card", "-e", sql], |
| | | capture_output=True, text=True) |
| | | if result.returncode != 0: |
| | | print("mysql 执行失败: " + result.stderr.strip()) |
| | | sys.exit(1) |
| | | return result.stdout.strip() |
| | | |
| | | |
| | | def revoke_sessions(): |
| | | mysql("update admin_session set revoked_at = now() where revoked_at is null;") |
| | | |
| | | |
| | | def login_works(password): |
| | | """拿新口令真调一次登录接口。只回布尔值,绝不打印 token。""" |
| | | request = urllib.request.Request( |
| | | LOGIN_URL, |
| | | data=json.dumps({"account": "admin", "password": password}).encode(), |
| | | headers={"Content-Type": "application/json"}, |
| | | method="POST") |
| | | try: |
| | | with urllib.request.urlopen(request, timeout=10) as response: |
| | | payload = json.loads(response.read().decode()) |
| | | token = (payload.get("data") or {}).get("accessToken") |
| | | return response.status == 200 and bool(token) |
| | | except Exception as exception: |
| | | print(" 登录接口返回异常: " + type(exception).__name__ + " " + str(exception)) |
| | | return False |
| | | |
| | | |
| | | def main(): |
| | | password = generate() |
| | | |
| | | salt = os.urandom(16) |
| | | encoded = ("pbkdf2$210000$" |
| | | + base64.b64encode(salt).decode() |
| | | + "$" |
| | | + base64.b64encode(derive(password, salt, 210000)).decode()) |
| | | |
| | | # 口令取自上面的字符集(无引号),所以内联进 SQL 字面量是安全的 |
| | | mysql("update admin_operator set password_hash = '" + encoded |
| | | + "', login_failed_count = 0, locked_until = null, updated_at = now()" |
| | | + " where account = 'admin' and deleted = 0;") |
| | | revoke_sessions() |
| | | |
| | | stored = mysql("select password_hash from admin_operator where account = 'admin' and deleted = 0") |
| | | if not stored: |
| | | print("!!! 没读到 admin 的哈希,中止") |
| | | sys.exit(1) |
| | | |
| | | fd = os.open(OUT_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) |
| | | with os.fdopen(fd, "w") as handle: |
| | | handle.write("account: admin\npassword: " + password + "\n") |
| | | os.chmod(OUT_PATH, 0o600) |
| | | |
| | | print("已重置 admin 的口令。") |
| | | print(" 明文写入 " + OUT_PATH + "(权限 600),未打印到 stdout。") |
| | | print(" 长度 " + str(len(password)) + ";含大写/小写/数字/符号四类。") |
| | | print(" 失败计数与锁定已清零;未过期的 admin_session 已全部吊销。") |
| | | print(" 自检(用库里存的哈希重新算一遍该口令): " |
| | | + ("匹配 ✓" if matches(password, stored) else "不匹配 ✗")) |
| | | |
| | | passed = login_works(password) |
| | | print(" 端到端(真调一次登录接口,只看成功与否): " + ("成功 ✓" if passed else "失败 ✗")) |
| | | revoke_sessions() # 把这次验证产生的会话也作废 |
| | | |
| | | remaining = mysql("select count(*) from admin_session where revoked_at is null") |
| | | print(" 吊销后仍有效的 admin_session 数: " + remaining) |
| | | return 0 if passed else 1 |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | sys.exit(main()) |