#!/usr/bin/env python3 # 重置后台平台管理员 admin 的口令。 # # 算法与应用完全一致(见 MerchantPasswordHasher): # pbkdf2$210000$$ # # 明文只写到 /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())