fix(release): harden dogfood identities and provisioning lease
This commit is contained in:
parent
410d35dc64
commit
65aecc56aa
362
deploy/manage-dogfood-reviewers.py
Executable file
362
deploy/manage-dogfood-reviewers.py
Executable file
@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env python3
|
||||
"""通过 staging 管理 API 创建或轮换内部狗粮审核账号。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import socket
|
||||
import stat
|
||||
import string
|
||||
import sys
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
BASE_URL = "http://127.0.0.1:48080/admin-api"
|
||||
EXPECTED_HOSTNAME = "mini-desktop"
|
||||
EXPECTED_ACK = "ROTATE_DOGFOOD_REVIEWERS"
|
||||
DEFAULT_ADMIN_USERNAME = "admin"
|
||||
DEFAULT_PUBLIC_ADMIN_PASSWORD = "admin123"
|
||||
ADMIN_LOGIN_REJECTION_CODES = frozenset({1_002_000_000, 1_002_000_001})
|
||||
REVIEWER_PERMISSIONS = frozenset({
|
||||
"project:review:query",
|
||||
"project:review:approve",
|
||||
"telemetry:game-stat:query",
|
||||
})
|
||||
READONLY_PERMISSIONS = frozenset({
|
||||
"project:review:query",
|
||||
"telemetry:game-stat:query",
|
||||
})
|
||||
ACCOUNT_SPECS = (
|
||||
("dogfood_reviewer", "狗粮审核员", "dogfood_reviewer"),
|
||||
("dogfood_readonly", "狗粮只读员", "dogfood_readonly"),
|
||||
)
|
||||
|
||||
|
||||
class ReviewerProvisionError(RuntimeError):
|
||||
"""受控审核账号操作不满足安全前提。"""
|
||||
|
||||
|
||||
def generate_password() -> str:
|
||||
"""生成符合管理端 4-16 位约束的高熵随机密码。"""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
while True:
|
||||
password = "".join(secrets.choice(alphabet) for _ in range(16))
|
||||
if (any(char.islower() for char in password)
|
||||
and any(char.isupper() for char in password)
|
||||
and any(char.isdigit() for char in password)):
|
||||
return password
|
||||
|
||||
|
||||
class ApiClient:
|
||||
"""固定访问本机 staging 管理 API,Bearer token 只保存在进程内存。"""
|
||||
|
||||
def __init__(self, admin_token: str, timeout: float = 10.0):
|
||||
if not admin_token:
|
||||
raise ReviewerProvisionError("管理员 token 不能为空")
|
||||
self.admin_token = admin_token
|
||||
self.timeout = timeout
|
||||
|
||||
def _request(self, method: str, path: str, *, query: dict[str, Any] | None = None,
|
||||
payload: dict[str, Any] | None = None) -> Any:
|
||||
url = BASE_URL + path
|
||||
if query:
|
||||
url += "?" + urlencode(query)
|
||||
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
request = Request(url, data=body, method=method, headers={
|
||||
"Authorization": f"Bearer {self.admin_token}",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
try:
|
||||
with urlopen(request, timeout=self.timeout) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
# 不回显响应体,避免认证中间件或代理错误页带出敏感请求信息。
|
||||
raise ReviewerProvisionError(f"管理 API 调用失败 method={method} path={path}") from exc
|
||||
if result.get("code") != 0:
|
||||
raise ReviewerProvisionError(
|
||||
f"管理 API 拒绝操作 method={method} path={path} code={result.get('code')}")
|
||||
return result.get("data")
|
||||
|
||||
def verify_rbac(self) -> dict[str, dict[str, Any]]:
|
||||
"""校验两个专用角色存在且权限集合精确,作为角色权限验收入口。"""
|
||||
roles = self._request("GET", "/system/role/simple-list")
|
||||
# simple-list 不返回 permission,必须走受 system:menu:query 保护的完整菜单接口。
|
||||
menus = self._request("GET", "/system/menu/list")
|
||||
menu_permissions = {item["id"]: item.get("permission") for item in menus}
|
||||
expected = {
|
||||
"dogfood_reviewer": REVIEWER_PERMISSIONS,
|
||||
"dogfood_readonly": READONLY_PERMISSIONS,
|
||||
}
|
||||
verified: dict[str, dict[str, Any]] = {}
|
||||
for role_code, expected_permissions in expected.items():
|
||||
matches = [role for role in roles if role.get("code") == role_code]
|
||||
if len(matches) != 1:
|
||||
raise ReviewerProvisionError(f"角色必须唯一存在 role={role_code} actual={len(matches)}")
|
||||
role_id = matches[0]["id"]
|
||||
menu_ids = self._request("GET", "/system/permission/list-role-menus",
|
||||
query={"roleId": role_id})
|
||||
actual_permissions = {menu_permissions.get(menu_id) for menu_id in menu_ids}
|
||||
if (len(menu_ids) != len(expected_permissions)
|
||||
or None in actual_permissions
|
||||
or actual_permissions != set(expected_permissions)):
|
||||
raise ReviewerProvisionError(
|
||||
f"角色权限不精确 role={role_code} actual={sorted(map(str, actual_permissions))}")
|
||||
verified[role_code] = {"id": role_id, "permissions": actual_permissions}
|
||||
return verified
|
||||
|
||||
def find_user(self, username: str) -> dict[str, Any] | None:
|
||||
page = self._request("GET", "/system/user/page", query={
|
||||
"pageNo": 1,
|
||||
"pageSize": 10,
|
||||
"username": username,
|
||||
})
|
||||
matches = [user for user in page["list"] if user.get("username") == username]
|
||||
if len(matches) > 1:
|
||||
raise ReviewerProvisionError(f"审核账号不唯一 username={username}")
|
||||
return matches[0] if matches else None
|
||||
|
||||
def create_user(self, username: str, nickname: str, password: str) -> int:
|
||||
return int(self._request("POST", "/system/user/create", payload={
|
||||
"username": username,
|
||||
"nickname": nickname,
|
||||
"password": password,
|
||||
"remark": "staging 内部狗粮专用;账号密码由受控工具轮换",
|
||||
"postIds": [],
|
||||
"email": "",
|
||||
"mobile": "",
|
||||
"sex": 0,
|
||||
"status": 0,
|
||||
}))
|
||||
|
||||
def update_password(self, user_id: int, password: str) -> None:
|
||||
self._request("PUT", "/system/user/update-password",
|
||||
payload={"id": user_id, "password": password})
|
||||
|
||||
def assign_user_roles(self, user_id: int, role_ids: list[int]) -> None:
|
||||
self._request("POST", "/system/permission/assign-user-role",
|
||||
payload={"userId": user_id, "roleIds": role_ids})
|
||||
|
||||
def update_user_status(self, user_id: int, status: int) -> None:
|
||||
self._request("PUT", "/system/user/update-status",
|
||||
payload={"id": user_id, "status": status})
|
||||
|
||||
def get_user_role_ids(self, user_id: int) -> set[int]:
|
||||
role_ids = self._request("GET", "/system/permission/list-user-roles",
|
||||
query={"userId": user_id})
|
||||
return {int(role_id) for role_id in role_ids}
|
||||
|
||||
def verify_password_login_rejected(self, username: str, password: str) -> int:
|
||||
"""匿名验证公开默认密码已失效,只接受密码错误或账号禁用两个确定结果。"""
|
||||
body = json.dumps({"username": username, "password": password}).encode("utf-8")
|
||||
request = Request(BASE_URL + "/system/auth/login", data=body, method="POST", headers={
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
try:
|
||||
with urlopen(request, timeout=self.timeout) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
try:
|
||||
result = json.loads(exc.read().decode("utf-8"))
|
||||
except json.JSONDecodeError as decode_error:
|
||||
raise ReviewerProvisionError("默认超管负例登录返回非 JSON") from decode_error
|
||||
except (URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
raise ReviewerProvisionError("默认超管负例登录调用失败") from exc
|
||||
code = result.get("code")
|
||||
if code not in ADMIN_LOGIN_REJECTION_CODES:
|
||||
raise ReviewerProvisionError(f"默认公开超管密码未被确定拒绝 code={code}")
|
||||
return int(code)
|
||||
|
||||
|
||||
def rotate_reviewers(api: ApiClient, password_factory: Callable[[], str],
|
||||
on_password: Callable[[str, str], None] | None = None) -> dict[str, Any]:
|
||||
"""轮换审核账号并封禁默认公开超管;任一步失败均不签发通过证据。"""
|
||||
roles = api.verify_rbac()
|
||||
accounts: dict[str, str] = {}
|
||||
evidence_accounts: list[dict[str, Any]] = []
|
||||
for username, nickname, role_code in ACCOUNT_SPECS:
|
||||
password = password_factory()
|
||||
accounts[username] = password
|
||||
if on_password is not None:
|
||||
# 先持久化待应用密码,防止进程中断后已轮换账号的凭据永久丢失。
|
||||
on_password(username, password)
|
||||
user = api.find_user(username)
|
||||
user_id = api.create_user(username, nickname, password) if user is None else int(user["id"])
|
||||
|
||||
# 禁用动作由后端同步删除该管理员的 MySQL 与 Redis OAuth2 token;失败时保持禁用。
|
||||
api.update_user_status(user_id, 1)
|
||||
if user is not None:
|
||||
api.update_password(user_id, password)
|
||||
role_id = int(roles[role_code]["id"])
|
||||
api.assign_user_roles(user_id, [role_id])
|
||||
if api.get_user_role_ids(user_id) != {role_id}:
|
||||
raise ReviewerProvisionError(f"账号角色未收敛 username={username}")
|
||||
api.update_user_status(user_id, 0)
|
||||
evidence_accounts.append({
|
||||
"username": username,
|
||||
"userId": user_id,
|
||||
"roleCode": role_code,
|
||||
"roleId": role_id,
|
||||
"permissions": sorted(roles[role_code]["permissions"]),
|
||||
"oldTokensInvalidatedByStatusCycle": True,
|
||||
"status": "ENABLED",
|
||||
})
|
||||
|
||||
# 默认公开超管必须最后处理:禁用会立即使当前及历史 token 失效,之后不能再依赖该 token 调特权接口。
|
||||
admin_password = password_factory()
|
||||
accounts[DEFAULT_ADMIN_USERNAME] = admin_password
|
||||
if on_password is not None:
|
||||
on_password(DEFAULT_ADMIN_USERNAME, admin_password)
|
||||
admin_user = api.find_user(DEFAULT_ADMIN_USERNAME)
|
||||
if admin_user is None:
|
||||
raise ReviewerProvisionError("staging 默认 admin 账号不存在,无法完成公开凭据收口")
|
||||
admin_user_id = int(admin_user["id"])
|
||||
api.update_password(admin_user_id, admin_password)
|
||||
api.update_user_status(admin_user_id, 1)
|
||||
rejection_code = api.verify_password_login_rejected(
|
||||
DEFAULT_ADMIN_USERNAME, DEFAULT_PUBLIC_ADMIN_PASSWORD)
|
||||
evidence_accounts.append({
|
||||
"username": DEFAULT_ADMIN_USERNAME,
|
||||
"userId": admin_user_id,
|
||||
"accountType": "DEFAULT_BOOTSTRAP_ADMIN",
|
||||
"passwordRotated": True,
|
||||
"oldTokensInvalidatedByDisable": True,
|
||||
"defaultPasswordLoginCode": rejection_code,
|
||||
"status": "DISABLED",
|
||||
})
|
||||
return {"accounts": accounts, "evidenceAccounts": evidence_accounts}
|
||||
|
||||
|
||||
def build_rbac_evidence(roles: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""把权限验收结果转换为不含 token、密码的稳定证据结构。"""
|
||||
return [{
|
||||
"roleCode": role_code,
|
||||
"roleId": int(role["id"]),
|
||||
"permissions": sorted(role["permissions"]),
|
||||
} for role_code, role in sorted(roles.items())]
|
||||
|
||||
|
||||
def require_private_file(path: Path) -> str:
|
||||
"""读取 0400/0600 普通文件,拒绝符号链接。"""
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(path, flags)
|
||||
except OSError as exc:
|
||||
raise ReviewerProvisionError(f"敏感输入无法安全打开 path={path}") from exc
|
||||
with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
|
||||
file_stat = os.fstat(handle.fileno())
|
||||
if not stat.S_ISREG(file_stat.st_mode):
|
||||
raise ReviewerProvisionError(f"敏感输入必须是普通文件 path={path}")
|
||||
if stat.S_IMODE(file_stat.st_mode) not in (0o400, 0o600):
|
||||
raise ReviewerProvisionError(f"敏感输入权限必须是 0400 或 0600 path={path}")
|
||||
value = handle.read().strip()
|
||||
if not value:
|
||||
raise ReviewerProvisionError(f"敏感输入不能为空 path={path}")
|
||||
return value
|
||||
|
||||
|
||||
def reserve_private_file(path: Path) -> None:
|
||||
"""以 O_EXCL 预留 0600 输出,拒绝覆盖旧证据或跟随符号链接。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def write_private_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
"""只重写本次预留的 0600 普通文件。"""
|
||||
flags = os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(path, flags)
|
||||
except OSError as exc:
|
||||
raise ReviewerProvisionError(f"输出无法安全打开 path={path}") from exc
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
file_stat = os.fstat(handle.fileno())
|
||||
if not stat.S_ISREG(file_stat.st_mode):
|
||||
raise ReviewerProvisionError(f"输出不再是普通文件 path={path}")
|
||||
if stat.S_IMODE(file_stat.st_mode) != 0o600:
|
||||
raise ReviewerProvisionError(f"输出权限必须保持 0600 path={path}")
|
||||
# 通过文件描述符复核类型和权限后再截断,避免检查前破坏被替换的目标。
|
||||
os.ftruncate(handle.fileno(), 0)
|
||||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="受控创建或轮换 staging 狗粮审核账号")
|
||||
parser.add_argument("--ack", required=True, help=f"必须精确填写 {EXPECTED_ACK}")
|
||||
parser.add_argument("--admin-token-file", type=Path, required=True)
|
||||
parser.add_argument("--credentials-out", type=Path)
|
||||
parser.add_argument("--evidence-out", type=Path, required=True)
|
||||
parser.add_argument("--verify-only", action="store_true", help="只校验角色权限并输出脱敏证据")
|
||||
args = parser.parse_args()
|
||||
if args.ack != EXPECTED_ACK:
|
||||
raise ReviewerProvisionError(f"ack 必须精确设为 {EXPECTED_ACK}")
|
||||
hostname = socket.gethostname().split(".", 1)[0]
|
||||
if hostname != EXPECTED_HOSTNAME:
|
||||
raise ReviewerProvisionError(f"只允许在 {EXPECTED_HOSTNAME} 执行 actual={hostname}")
|
||||
if not args.verify_only and args.credentials_out is None:
|
||||
raise ReviewerProvisionError("轮换模式必须提供 --credentials-out")
|
||||
if args.credentials_out is not None and args.credentials_out == args.evidence_out:
|
||||
raise ReviewerProvisionError("凭据与证据必须写入不同文件")
|
||||
|
||||
admin_token = require_private_file(args.admin_token_file)
|
||||
api = ApiClient(admin_token)
|
||||
if args.verify_only:
|
||||
reserve_private_file(args.evidence_out)
|
||||
roles = api.verify_rbac()
|
||||
write_private_json(args.evidence_out, {
|
||||
"status": "PASS",
|
||||
"baseUrl": BASE_URL,
|
||||
"roles": build_rbac_evidence(roles),
|
||||
})
|
||||
print(f"DOGFOOD_REVIEWER_RBAC_PASS roles={len(roles)} evidence={args.evidence_out}")
|
||||
return 0
|
||||
|
||||
credentials_out = args.credentials_out
|
||||
if credentials_out is None:
|
||||
raise ReviewerProvisionError("轮换模式缺少凭据输出路径")
|
||||
reserve_private_file(credentials_out)
|
||||
try:
|
||||
reserve_private_file(args.evidence_out)
|
||||
except Exception:
|
||||
credentials_out.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
generated_accounts: dict[str, str] = {}
|
||||
|
||||
def persist_pending(username: str, password: str) -> None:
|
||||
generated_accounts[username] = password
|
||||
write_private_json(credentials_out, {
|
||||
"status": "PENDING",
|
||||
"baseUrl": BASE_URL,
|
||||
"accounts": generated_accounts,
|
||||
})
|
||||
|
||||
result = rotate_reviewers(api, generate_password, persist_pending)
|
||||
write_private_json(credentials_out, {
|
||||
"status": "COMPLETE",
|
||||
"baseUrl": BASE_URL,
|
||||
"accounts": result["accounts"],
|
||||
})
|
||||
write_private_json(args.evidence_out, {
|
||||
"status": "PASS",
|
||||
"baseUrl": BASE_URL,
|
||||
"accounts": result["evidenceAccounts"],
|
||||
})
|
||||
print(f"DOGFOOD_REVIEWER_PASS accounts={len(result['evidenceAccounts'])} evidence={args.evidence_out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (ReviewerProvisionError, FileExistsError, FileNotFoundError, PermissionError) as exc:
|
||||
print(f"DOGFOOD_REVIEWER_FAIL {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
241
deploy/tests/test-dogfood-reviewer-security.py
Executable file
241
deploy/tests/test-dogfood-reviewer-security.py
Executable file
@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""内部狗粮审核账号与 RBAC 安全边界测试。"""
|
||||
|
||||
from pathlib import Path
|
||||
import importlib.util
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TOOL = ROOT / "deploy" / "manage-dogfood-reviewers.py"
|
||||
STAGING_DB = ROOT / "game-cloud" / "huijing-server" / "src" / "main" / "resources" / "db" / "staging"
|
||||
STAGING_CONFIG = ROOT / "game-cloud" / "huijing-server" / "src" / "main" / "resources" / "application-staging.yaml"
|
||||
|
||||
|
||||
class ReviewerSecurityTest(unittest.TestCase):
|
||||
|
||||
@staticmethod
|
||||
def load_module():
|
||||
spec = importlib.util.spec_from_file_location("dogfood_reviewers", TOOL)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def test_staging_explicitly_disables_mock_authentication(self):
|
||||
config = STAGING_CONFIG.read_text(encoding="utf-8")
|
||||
self.assertIn("mock-enable: false", config)
|
||||
self.assertNotIn("mock-enable: true", config)
|
||||
|
||||
def test_rbac_seed_is_repeatable_and_contains_no_login_credentials(self):
|
||||
self.assertFalse((STAGING_DB / "V34.0.0__seed_dogfood_review_rbac.sql").exists())
|
||||
migration = STAGING_DB / "R__seed_dogfood_review_rbac.sql"
|
||||
sql = migration.read_text(encoding="utf-8")
|
||||
self.assertNotIn("INSERT INTO system_users", sql)
|
||||
self.assertNotIn("$2a$", sql)
|
||||
self.assertIn("dogfood_reviewer", sql)
|
||||
self.assertIn("dogfood_readonly", sql)
|
||||
|
||||
def test_rotation_uses_exact_roles_and_invalidates_old_tokens(self):
|
||||
module = self.load_module()
|
||||
api = FakeApi(module)
|
||||
|
||||
result = module.rotate_reviewers(api, module.generate_password)
|
||||
|
||||
self.assertEqual({"dogfood_reviewer", "dogfood_readonly", "admin"}, set(result["accounts"]))
|
||||
self.assertEqual([201], api.assigned_roles[101])
|
||||
self.assertEqual([202], api.assigned_roles[102])
|
||||
self.assertEqual([1, 0], api.status_updates[101])
|
||||
self.assertEqual([1, 0], api.status_updates[102])
|
||||
self.assertEqual([1], api.status_updates[1])
|
||||
self.assertEqual(("status", 1, 1), api.events[-2])
|
||||
self.assertEqual(("login-rejected", "admin", "admin123"), api.events[-1])
|
||||
self.assertNotEqual(result["accounts"]["dogfood_reviewer"],
|
||||
result["accounts"]["dogfood_readonly"])
|
||||
for password in result["accounts"].values():
|
||||
self.assertEqual(16, len(password))
|
||||
self.assertNotEqual("admin123", password)
|
||||
self.assertTrue(any(char.islower() for char in password))
|
||||
self.assertTrue(any(char.isupper() for char in password))
|
||||
self.assertTrue(any(char.isdigit() for char in password))
|
||||
|
||||
def test_rotation_creates_missing_accounts_before_fail_closed_status_cycle(self):
|
||||
module = self.load_module()
|
||||
api = FakeApi(module, existing=False)
|
||||
|
||||
result = module.rotate_reviewers(api, module.generate_password)
|
||||
|
||||
self.assertEqual(["dogfood_reviewer", "dogfood_readonly"], api.created_users)
|
||||
self.assertEqual([1], api.updated_password_users)
|
||||
self.assertEqual([1, 0], api.status_updates[101])
|
||||
self.assertEqual([1, 0], api.status_updates[102])
|
||||
self.assertEqual([1], api.status_updates[1])
|
||||
self.assertEqual(3, len(result["evidenceAccounts"]))
|
||||
|
||||
def test_api_rbac_verification_reads_full_menu_permissions(self):
|
||||
module = self.load_module()
|
||||
responses = FakeRbacResponses()
|
||||
api = module.ApiClient("test-admin-token")
|
||||
api._request = responses.request
|
||||
|
||||
roles = api.verify_rbac()
|
||||
|
||||
self.assertEqual(set(module.REVIEWER_PERMISSIONS), roles["dogfood_reviewer"]["permissions"])
|
||||
self.assertIn(("GET", "/system/menu/list"), responses.calls)
|
||||
self.assertNotIn(("GET", "/system/menu/simple-list"), responses.calls)
|
||||
evidence = module.build_rbac_evidence(roles)
|
||||
self.assertEqual(["dogfood_readonly", "dogfood_reviewer"],
|
||||
[item["roleCode"] for item in evidence])
|
||||
|
||||
def test_sensitive_files_require_private_permissions_and_no_overwrite(self):
|
||||
module = self.load_module()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
token_file = root / "admin.token"
|
||||
token_file.write_text("secret-token\n", encoding="utf-8")
|
||||
os.chmod(token_file, 0o600)
|
||||
self.assertEqual("secret-token", module.require_private_file(token_file))
|
||||
|
||||
output = root / "credentials.json"
|
||||
module.reserve_private_file(output)
|
||||
self.assertEqual(0o600, stat.S_IMODE(output.stat().st_mode))
|
||||
with self.assertRaises(FileExistsError):
|
||||
module.reserve_private_file(output)
|
||||
|
||||
os.chmod(token_file, 0o644)
|
||||
with self.assertRaises(module.ReviewerProvisionError):
|
||||
module.require_private_file(token_file)
|
||||
|
||||
symlink = root / "admin-token-link"
|
||||
symlink.symlink_to(token_file)
|
||||
with self.assertRaises(module.ReviewerProvisionError):
|
||||
module.require_private_file(symlink)
|
||||
|
||||
def test_default_admin_negative_login_is_anonymous_and_fail_closed(self):
|
||||
module = self.load_module()
|
||||
captured_requests = []
|
||||
original_urlopen = module.urlopen
|
||||
|
||||
def rejected_urlopen(request, timeout):
|
||||
captured_requests.append(request)
|
||||
return FakeHttpResponse({"code": 1_002_000_001, "msg": "账号被禁用", "data": None})
|
||||
|
||||
module.urlopen = rejected_urlopen
|
||||
try:
|
||||
api = module.ApiClient("bootstrap-token")
|
||||
self.assertEqual(1_002_000_001,
|
||||
api.verify_password_login_rejected("admin", "admin123"))
|
||||
self.assertIsNone(captured_requests[0].get_header("Authorization"))
|
||||
|
||||
module.urlopen = lambda request, timeout: FakeHttpResponse({
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {"accessToken": "unexpected"},
|
||||
})
|
||||
with self.assertRaises(module.ReviewerProvisionError):
|
||||
api.verify_password_login_rejected("admin", "admin123")
|
||||
finally:
|
||||
module.urlopen = original_urlopen
|
||||
|
||||
|
||||
class FakeApi:
|
||||
"""仅模拟账号轮换所需接口,断言调用结果而非 HTTP 实现细节。"""
|
||||
|
||||
def __init__(self, module, existing=True):
|
||||
self.module = module
|
||||
self.existing = existing
|
||||
self.assigned_roles = {}
|
||||
self.status_updates = {1: [], 101: [], 102: []}
|
||||
self.created_users = []
|
||||
self.updated_password_users = []
|
||||
self.events = []
|
||||
|
||||
def verify_rbac(self):
|
||||
return {
|
||||
"dogfood_reviewer": {"id": 201, "permissions": set(self.module.REVIEWER_PERMISSIONS)},
|
||||
"dogfood_readonly": {"id": 202, "permissions": set(self.module.READONLY_PERMISSIONS)},
|
||||
}
|
||||
|
||||
def find_user(self, username):
|
||||
if username == "admin":
|
||||
return {"id": 1, "username": username}
|
||||
if not self.existing:
|
||||
return None
|
||||
return {"id": 101 if username == "dogfood_reviewer" else 102, "username": username}
|
||||
|
||||
def create_user(self, username, nickname, password):
|
||||
if self.existing:
|
||||
raise AssertionError("测试账号已存在,不应走创建分支")
|
||||
self.assert_password(password)
|
||||
self.created_users.append(username)
|
||||
return 101 if username == "dogfood_reviewer" else 102
|
||||
|
||||
def update_password(self, user_id, password):
|
||||
self.assert_password(password)
|
||||
self.updated_password_users.append(user_id)
|
||||
self.events.append(("password", user_id))
|
||||
|
||||
def assign_user_roles(self, user_id, role_ids):
|
||||
self.assigned_roles[user_id] = role_ids
|
||||
|
||||
def update_user_status(self, user_id, status):
|
||||
self.status_updates[user_id].append(status)
|
||||
self.events.append(("status", user_id, status))
|
||||
|
||||
def get_user_role_ids(self, user_id):
|
||||
return set(self.assigned_roles[user_id])
|
||||
|
||||
def verify_password_login_rejected(self, username, password):
|
||||
self.events.append(("login-rejected", username, password))
|
||||
return 1_002_000_000
|
||||
|
||||
@staticmethod
|
||||
def assert_password(password):
|
||||
if len(password) != 16:
|
||||
raise AssertionError("随机密码长度不符合约束")
|
||||
|
||||
|
||||
class FakeRbacResponses:
|
||||
"""模拟管理 API 响应,校验工具使用真实包含 permission 的菜单入口。"""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def request(self, method, path, **kwargs):
|
||||
self.calls.append((method, path))
|
||||
if path == "/system/role/simple-list":
|
||||
return [
|
||||
{"id": 201, "code": "dogfood_reviewer"},
|
||||
{"id": 202, "code": "dogfood_readonly"},
|
||||
]
|
||||
if path == "/system/menu/list":
|
||||
return [
|
||||
{"id": 301, "permission": "project:review:query"},
|
||||
{"id": 302, "permission": "project:review:approve"},
|
||||
{"id": 303, "permission": "telemetry:game-stat:query"},
|
||||
]
|
||||
role_id = int(kwargs["query"]["roleId"])
|
||||
return [301, 302, 303] if role_id == 201 else [301, 303]
|
||||
|
||||
|
||||
class FakeHttpResponse:
|
||||
"""提供 urllib 响应所需的最小上下文管理协议。"""
|
||||
|
||||
def __init__(self, payload):
|
||||
import json
|
||||
self.body = json.dumps(payload).encode("utf-8")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return self.body
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -3,6 +3,10 @@ package com.wanxiang.huijing.framework.env.config;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
/**
|
||||
* 内部狗粮环境的后端安全开关。
|
||||
*
|
||||
@ -23,4 +27,54 @@ public class DogfoodProperties {
|
||||
*/
|
||||
private boolean feedEnabled = true;
|
||||
|
||||
/** 是否开启仅供账号预置使用的密码注册窄租约。 */
|
||||
private boolean provisioningEnabled = false;
|
||||
|
||||
/**
|
||||
* 账号预置租约的 UTC 到期时间,格式为 ISO-8601 Instant,例如 {@code 2026-07-22T12:05:00Z}。
|
||||
*/
|
||||
private String provisioningExpiresAt;
|
||||
|
||||
/**
|
||||
* 使用调用方提供的时钟判定预置租约,确保启动门、运行时边界和测试共享同一时间语义。
|
||||
*
|
||||
* @param clock UTC 判定时钟
|
||||
* @return 精确租约状态;只有 {@link ProvisioningLeaseStatus#ACTIVE} 可以放行狗粮态密码注册
|
||||
*/
|
||||
public ProvisioningLeaseStatus getProvisioningLeaseStatus(Clock clock) {
|
||||
if (!enabled) {
|
||||
return ProvisioningLeaseStatus.DOGFOOD_DISABLED;
|
||||
}
|
||||
if (!provisioningEnabled) {
|
||||
return ProvisioningLeaseStatus.PROVISIONING_DISABLED;
|
||||
}
|
||||
if (provisioningExpiresAt == null || provisioningExpiresAt.trim().isEmpty()) {
|
||||
return ProvisioningLeaseStatus.EXPIRES_AT_MISSING;
|
||||
}
|
||||
final Instant expiresAt;
|
||||
try {
|
||||
expiresAt = Instant.parse(provisioningExpiresAt.trim());
|
||||
} catch (DateTimeParseException ex) {
|
||||
return ProvisioningLeaseStatus.EXPIRES_AT_INVALID;
|
||||
}
|
||||
return clock.instant().isBefore(expiresAt)
|
||||
? ProvisioningLeaseStatus.ACTIVE
|
||||
: ProvisioningLeaseStatus.EXPIRED;
|
||||
}
|
||||
|
||||
/** 使用明确时钟判断预置租约是否有效。 */
|
||||
public boolean isProvisioningLeaseActive(Clock clock) {
|
||||
return getProvisioningLeaseStatus(clock) == ProvisioningLeaseStatus.ACTIVE;
|
||||
}
|
||||
|
||||
/** 预置租约状态;日志只记录状态,不记录租约原值。 */
|
||||
public enum ProvisioningLeaseStatus {
|
||||
ACTIVE,
|
||||
DOGFOOD_DISABLED,
|
||||
PROVISIONING_DISABLED,
|
||||
EXPIRES_AT_MISSING,
|
||||
EXPIRES_AT_INVALID,
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -9,6 +9,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import java.time.Clock;
|
||||
|
||||
/**
|
||||
* 多环境的 Web 组件的自动配置
|
||||
*
|
||||
@ -25,6 +27,14 @@ public class HuijingEnvWebAutoConfiguration {
|
||||
return new DogfoodAccessGuard(properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* 狗粮预置租约统一使用 UTC 系统时钟;测试可按 Bean 名替换为固定时钟。
|
||||
*/
|
||||
@Bean(name = "dogfoodProvisioningClock")
|
||||
public Clock dogfoodProvisioningClock() {
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 {@link EnvWebFilter} Bean
|
||||
*/
|
||||
|
||||
@ -67,6 +67,16 @@
|
||||
<artifactId>bizlog-sdk</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
package com.wanxiang.huijing.framework.security.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* 内部狗粮认证启动不变量校验。
|
||||
*
|
||||
* <p>mock token 会绕过真实 OAuth2 token 校验,因此只要狗粮总开关开启,
|
||||
* mock 认证也必须关闭。该校验在应用接流量前 fail-closed,防止 Nacos 或命令行
|
||||
* 覆盖 staging 文件后重新打开 {@code test<userId>} 身份通道。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class DogfoodSecurityStartupValidator implements ApplicationRunner {
|
||||
|
||||
private final SecurityProperties securityProperties;
|
||||
private final Environment environment;
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
boolean dogfoodEnabled = environment.getProperty("huijing.dogfood.enabled", Boolean.class, false);
|
||||
if (!dogfoodEnabled) {
|
||||
return;
|
||||
}
|
||||
if (Boolean.TRUE.equals(securityProperties.getMockEnable())) {
|
||||
// 只记录配置键和安全期望,不记录 mockSecret 等敏感配置值。
|
||||
log.error("[dogfoodSecurityStartup] 狗粮态认证配置拒绝启动 key=huijing.security.mock-enable expected=false");
|
||||
throw new IllegalStateException("狗粮态要求 huijing.security.mock-enable=false");
|
||||
}
|
||||
log.info("[dogfoodSecurityStartup] 狗粮态 mock 认证已关闭");
|
||||
}
|
||||
|
||||
}
|
||||
@ -15,6 +15,7 @@ import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigureOrder;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
@ -73,6 +74,14 @@ public class HuijingSecurityAutoConfiguration {
|
||||
return new TokenAuthenticationFilter(securityProperties, globalExceptionHandler, oauth2TokenApi);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册狗粮态认证启动校验,确保任何外部配置覆盖都不能重新打开 mock token。
|
||||
*/
|
||||
@Bean
|
||||
public DogfoodSecurityStartupValidator dogfoodSecurityStartupValidator(Environment environment) {
|
||||
return new DogfoodSecurityStartupValidator(securityProperties, environment);
|
||||
}
|
||||
|
||||
@Bean("ss") // 使用 Spring Security 的缩写,方便使用
|
||||
public SecurityFrameworkService securityFrameworkService(PermissionCommonApi permissionApi) {
|
||||
return new SecurityFrameworkServiceImpl(permissionApi);
|
||||
|
||||
@ -28,7 +28,8 @@ public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
|
||||
@Override
|
||||
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
|
||||
log.debug("[commence][访问 URL({}) 时,没有登录]", request.getRequestURI(), e);
|
||||
// 返回 401
|
||||
// HTTP 状态与业务错误码都返回 401,避免网关或客户端把未认证误判成成功响应。
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
ServletUtils.writeJSON(response, CommonResult.error(UNAUTHORIZED));
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
package com.wanxiang.huijing.framework.security.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** 内部狗粮认证启动不变量测试。 */
|
||||
class DogfoodSecurityStartupValidatorTest {
|
||||
|
||||
@Test
|
||||
void shouldRejectMockAuthenticationWhenDogfoodIsEnabled() {
|
||||
SecurityProperties properties = new SecurityProperties();
|
||||
properties.setMockEnable(true);
|
||||
MockEnvironment environment = new MockEnvironment()
|
||||
.withProperty("huijing.dogfood.enabled", "true");
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> new DogfoodSecurityStartupValidator(properties, environment).run(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowMockAuthenticationOutsideDogfood() {
|
||||
SecurityProperties properties = new SecurityProperties();
|
||||
properties.setMockEnable(true);
|
||||
MockEnvironment environment = new MockEnvironment()
|
||||
.withProperty("huijing.dogfood.enabled", "false");
|
||||
|
||||
assertDoesNotThrow(() -> new DogfoodSecurityStartupValidator(properties, environment).run(null));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
package com.wanxiang.huijing.framework.security.core.filter;
|
||||
|
||||
import com.wanxiang.huijing.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import com.wanxiang.huijing.framework.security.config.SecurityProperties;
|
||||
import com.wanxiang.huijing.framework.security.core.handler.AuthenticationEntryPointImpl;
|
||||
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.wanxiang.huijing.framework.web.config.WebProperties;
|
||||
import com.wanxiang.huijing.framework.web.core.handler.GlobalExceptionHandler;
|
||||
import com.wanxiang.huijing.framework.web.core.util.WebFrameworkUtils;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.authentication.InsufficientAuthenticationException;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/** Token 认证过滤器的狗粮安全回归测试。 */
|
||||
class TokenAuthenticationFilterTest {
|
||||
|
||||
@BeforeEach
|
||||
void initializeWebApiPrefixes() {
|
||||
// 生产环境由 Web 自动配置完成该初始化;单元测试显式复现同一前置条件。
|
||||
new WebFrameworkUtils(new WebProperties());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTokenShouldRemainUnauthenticatedAndReturn401WhenMockIsDisabled() throws Exception {
|
||||
SecurityProperties properties = new SecurityProperties();
|
||||
properties.setMockEnable(false);
|
||||
OAuth2TokenCommonApi tokenApi = mock(OAuth2TokenCommonApi.class);
|
||||
when(tokenApi.checkAccessToken("test1")).thenReturn(CommonResult.error(401, "访问令牌不存在"));
|
||||
TokenAuthenticationFilter filter = new TokenAuthenticationFilter(
|
||||
properties, mock(GlobalExceptionHandler.class), tokenApi);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/app-api/protected");
|
||||
request.addHeader("Authorization", "Bearer test1");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
AuthenticationEntryPointImpl authenticationEntryPoint = new AuthenticationEntryPointImpl();
|
||||
|
||||
filter.doFilter(request, response, (currentRequest, currentResponse) -> {
|
||||
// 复用生产认证入口:过滤器未建立身份时必须同时返回 HTTP 401 与业务码 401。
|
||||
assertNull(SecurityFrameworkUtils.getLoginUser());
|
||||
authenticationEntryPoint.commence(request, response,
|
||||
new InsufficientAuthenticationException("未认证"));
|
||||
});
|
||||
|
||||
assertEquals(401, response.getStatus());
|
||||
assertTrue(response.getContentAsString().contains("\"code\":401"));
|
||||
assertNull(SecurityFrameworkUtils.getLoginUser());
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package com.wanxiang.huijing.framework.security.core.handler;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.authentication.InsufficientAuthenticationException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** 未认证请求 HTTP 语义测试。 */
|
||||
class AuthenticationEntryPointImplTest {
|
||||
|
||||
@Test
|
||||
void shouldReturnHttp401AndBusinessCode401() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/app-api/protected");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
new AuthenticationEntryPointImpl().commence(
|
||||
request, response, new InsufficientAuthenticationException("未认证"));
|
||||
|
||||
assertEquals(401, response.getStatus());
|
||||
assertTrue(response.getContentAsString().contains("\"code\":401"));
|
||||
}
|
||||
|
||||
}
|
||||
@ -5,6 +5,7 @@ import cn.hutool.core.util.StrUtil;
|
||||
import com.wanxiang.huijing.framework.common.enums.CommonStatusEnum;
|
||||
import com.wanxiang.huijing.framework.common.enums.UserTypeEnum;
|
||||
import com.wanxiang.huijing.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import com.wanxiang.huijing.framework.env.config.DogfoodProperties;
|
||||
import com.wanxiang.huijing.framework.env.core.DogfoodAccessGuard;
|
||||
import com.wanxiang.huijing.framework.security.config.SecurityProperties;
|
||||
import com.wanxiang.huijing.module.system.api.passport.dto.PlayerRespDTO;
|
||||
@ -35,6 +36,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
@ -90,6 +92,10 @@ public class PassportServiceImpl implements PassportService {
|
||||
private PassportRegisterOutboxMapper registerOutboxMapper; // 注册成功事件 outbox 生产者(§3.6)
|
||||
@Resource
|
||||
private DogfoodAccessGuard dogfoodAccessGuard; // 狗粮态短信信任边界,服务层兜住直调与未来控制器变更
|
||||
@Resource
|
||||
private DogfoodProperties dogfoodProperties; // 狗粮预置租约配置,运行时每次注册都重新判定到期时间
|
||||
@Resource(name = "dogfoodProvisioningClock")
|
||||
private Clock dogfoodProvisioningClock; // 与启动门共享 UTC 时钟语义,测试可替换为固定时钟
|
||||
|
||||
/**
|
||||
* 惰性缓存的固定假 BCrypt hash(防枚举时序对齐用;首次登录查无用户名时以本类 encoder 编出,cost 与真实 hash 一致)。
|
||||
@ -265,6 +271,15 @@ public class PassportServiceImpl implements PassportService {
|
||||
log.warn("[passwordRegister] 用户名密码注册通道已关闭,拒绝新建账号 clientIp={}", clientIp);
|
||||
throw exception(PASSWORD_REGISTER_DISABLED);
|
||||
}
|
||||
// dogfood 只允许受控预置租约临时开放密码注册;非 dogfood 保留原有注册语义。
|
||||
if (dogfoodProperties.isEnabled()
|
||||
&& !dogfoodProperties.isProvisioningLeaseActive(dogfoodProvisioningClock)) {
|
||||
DogfoodProperties.ProvisioningLeaseStatus leaseStatus =
|
||||
dogfoodProperties.getProvisioningLeaseStatus(dogfoodProvisioningClock);
|
||||
log.warn("[passwordRegister] 狗粮态预置租约无效,拒绝新建账号 leaseStatus={} clientIp={}",
|
||||
leaseStatus, clientIp);
|
||||
throw exception(PASSWORD_REGISTER_DISABLED);
|
||||
}
|
||||
// 2) 应用层纯 IP 计数防刷(scene=register,先增后判,越限 429;IP 取连接层 remoteAddr 由 controller 传入,不采信 XFF)
|
||||
checkIpLimit(PassportSmsIpCounterRedisDAO.SCENE_REGISTER, clientIp, registerIpLimitPerHour, registerIpLimitPerDay);
|
||||
// 3) 密码 BCrypt 编码(复用 admin 范式 passwordEncoder.encode,§3.2);明文用后即弃,绝不落库/落日志
|
||||
|
||||
@ -2,6 +2,7 @@ package com.wanxiang.huijing.module.system.service.passport;
|
||||
|
||||
import com.wanxiang.huijing.framework.common.enums.UserTypeEnum;
|
||||
import com.wanxiang.huijing.framework.common.exception.ServiceException;
|
||||
import com.wanxiang.huijing.framework.env.config.DogfoodProperties;
|
||||
import com.wanxiang.huijing.framework.security.config.SecurityProperties;
|
||||
import com.wanxiang.huijing.framework.env.core.DogfoodAccessGuard;
|
||||
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
@ -29,6 +30,7 @@ import org.mockito.Mock;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static com.wanxiang.huijing.framework.common.exception.enums.GlobalErrorCodeConstants.TOO_MANY_REQUESTS;
|
||||
@ -74,6 +76,10 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
|
||||
private PassportRegisterOutboxMapper registerOutboxMapper;
|
||||
@Mock
|
||||
private DogfoodAccessGuard dogfoodAccessGuard;
|
||||
@Mock
|
||||
private DogfoodProperties dogfoodProperties;
|
||||
@Mock
|
||||
private Clock dogfoodProvisioningClock;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
@ -524,6 +530,44 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
|
||||
verify(smsIpCounterRedisDAO, never()).incrHourAndGet(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("狗粮用户名注册:租约无效时在 IP 计数、加密和建号前拒绝")
|
||||
void testPasswordRegister_dogfoodProvisioningLeaseRequired() {
|
||||
when(dogfoodProperties.isEnabled()).thenReturn(true);
|
||||
when(dogfoodProperties.isProvisioningLeaseActive(dogfoodProvisioningClock)).thenReturn(false);
|
||||
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
|
||||
reqVO.setUsername("seedalice");
|
||||
reqVO.setPassword("secret6");
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9"));
|
||||
|
||||
assertEquals(PASSWORD_REGISTER_DISABLED.getCode(), ex.getCode());
|
||||
verifyNoInteractions(smsIpCounterRedisDAO, passwordEncoder, playerMapper, registerOutboxMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("狗粮用户名注册:有效租约允许进入既有注册流程")
|
||||
void testPasswordRegister_dogfoodActiveProvisioningLeaseAllowed() {
|
||||
when(dogfoodProperties.isEnabled()).thenReturn(true);
|
||||
when(dogfoodProperties.isProvisioningLeaseActive(dogfoodProvisioningClock)).thenReturn(true);
|
||||
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
|
||||
reqVO.setUsername("seedalice");
|
||||
reqVO.setPassword("secret6");
|
||||
when(passwordEncoder.encode("secret6")).thenReturn("$2a$10$encodedhash");
|
||||
doAnswer(invocation -> {
|
||||
PlayerDO player = invocation.getArgument(0);
|
||||
player.setId(2048L);
|
||||
return 1;
|
||||
}).when(playerMapper).insert(any(PlayerDO.class));
|
||||
stubToken(2048L);
|
||||
|
||||
LoginRespVO response = passportService.passwordRegister(reqVO, "9.9.9.9");
|
||||
|
||||
assertEquals(2048L, response.getUserId());
|
||||
verify(playerMapper).insert(any(PlayerDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("用户名注册:同 IP 越注册档小时阈值 → 抛 429 且不建号")
|
||||
void testPasswordRegister_ipLimitExceeded() {
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
package com.wanxiang.huijing.server.config;
|
||||
|
||||
import com.wanxiang.huijing.framework.env.config.DogfoodProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@ -21,11 +22,19 @@ import java.util.List;
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DogfoodStartupValidator implements ApplicationRunner {
|
||||
|
||||
private final DogfoodProperties dogfoodProperties;
|
||||
private final Environment environment;
|
||||
private final Clock dogfoodProvisioningClock;
|
||||
|
||||
public DogfoodStartupValidator(DogfoodProperties dogfoodProperties,
|
||||
Environment environment,
|
||||
@Qualifier("dogfoodProvisioningClock") Clock dogfoodProvisioningClock) {
|
||||
this.dogfoodProperties = dogfoodProperties;
|
||||
this.environment = environment;
|
||||
this.dogfoodProvisioningClock = dogfoodProvisioningClock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
@ -38,7 +47,7 @@ public class DogfoodStartupValidator implements ApplicationRunner {
|
||||
log.error("[dogfoodStartup] 狗粮态安全配置校验失败 violations={}", violations);
|
||||
throw new IllegalStateException("狗粮态安全配置不满足启动条件: " + String.join(", ", violations));
|
||||
}
|
||||
log.info("[dogfoodStartup] 狗粮态安全配置校验通过,注册/短信/广告/收付款边界已锁定");
|
||||
log.info("[dogfoodStartup] 狗粮态安全配置校验通过,注册租约/短信/广告/收付款边界已锁定");
|
||||
}
|
||||
|
||||
/**
|
||||
@ -48,7 +57,7 @@ public class DogfoodStartupValidator implements ApplicationRunner {
|
||||
List<String> violations = new ArrayList<>();
|
||||
requireFalse("wanxiang.passport.sms-mock-enabled", violations);
|
||||
requireFalse("wanxiang.passport.invite-register-enabled", violations);
|
||||
requireFalse("wanxiang.passport.password-register-enabled", violations);
|
||||
validatePasswordRegistrationLease(violations);
|
||||
requireTrue("wanxiang.passport.password-login-enabled", violations);
|
||||
requireTrue("ad.force-mock", violations);
|
||||
requireValue("trade.charge-channel", "mock", violations);
|
||||
@ -56,6 +65,18 @@ public class DogfoodStartupValidator implements ApplicationRunner {
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** dogfood 下密码注册只能由有效的短时预置租约放行,租约原值不进入日志。 */
|
||||
private void validatePasswordRegistrationLease(List<String> violations) {
|
||||
if (!environment.getProperty("wanxiang.passport.password-register-enabled", Boolean.class, false)) {
|
||||
return;
|
||||
}
|
||||
DogfoodProperties.ProvisioningLeaseStatus status =
|
||||
dogfoodProperties.getProvisioningLeaseStatus(dogfoodProvisioningClock);
|
||||
if (status != DogfoodProperties.ProvisioningLeaseStatus.ACTIVE) {
|
||||
violations.add("wanxiang.passport.password-register-enabled 仅允许有效预置租约,leaseStatus=" + status);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireFalse(String key, List<String> violations) {
|
||||
if (environment.getProperty(key, Boolean.class, false)) {
|
||||
violations.add(key + " 必须为 false");
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# 绘境AI game-staging 单体启动 profile(仅 mini-desktop 隔离环境)
|
||||
# 基于 application-local.yaml;差异:DB/Redis 指向隔离容器(13306/16379)+强密码(经 .env 注入占位符);nacos 指 mini-infra、xxl 关;mock-enable=true。
|
||||
# 基于 application-local.yaml;差异:DB/Redis 指向隔离容器(13306/16379)+强密码(经 .env 注入占位符);nacos 指 mini-infra、xxl 关;认证 mock 强制关闭。
|
||||
server:
|
||||
port: 48080
|
||||
|
||||
@ -59,7 +59,7 @@ spring:
|
||||
password: ${MYSQL_ROOT_PASSWORD}
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration,classpath:db/staging # staging 额外加载内部狗粮账号与最小 RBAC 种子
|
||||
locations: classpath:db/migration,classpath:db/staging # staging 额外加载内部狗粮最小 RBAC 种子;账号由受控工具轮换
|
||||
url: jdbc:mysql://127.0.0.1:13306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
|
||||
user: root
|
||||
password: ${MYSQL_ROOT_PASSWORD}
|
||||
@ -161,12 +161,14 @@ huijing:
|
||||
dogfood:
|
||||
enabled: true # 内部狗粮后端可信边界总开关
|
||||
feed-enabled: true # 初始开闸;事故止损时由 Nacos 改 false,读路径与发布入池同时拒绝
|
||||
provisioning-enabled: false # 仅 Nacos 受控事务在短时账号预置窗口内开启
|
||||
provisioning-expires-at: "" # Nacos 开窗时写 ISO-8601 UTC 到期时间;缺失/非法/过期均拒绝注册
|
||||
tenant:
|
||||
enable: false # staging 单租户隔离环境:关闭多租户校验拦截器(避免 framework tenant valid 走 Feign)
|
||||
captcha:
|
||||
enable: false
|
||||
security:
|
||||
mock-enable: true
|
||||
mock-enable: false # 狗粮态禁止 test<userId> 模拟身份,启动校验会对覆盖配置 fail-closed
|
||||
permit-all-urls: # staging 放行 actuator 健康检查(无需鉴权,便于探活)
|
||||
- /actuator/**
|
||||
access-log:
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
-- 内部狗粮 staging 专用最小 RBAC repeatable 种子。
|
||||
-- 这里只定义权限与角色,绝不创建账号或写入密码;账号由 deploy/manage-dogfood-reviewers.py 受控创建和轮换。
|
||||
-- 使用 repeatable migration,避免 staging 私有种子占用全局 Flyway 版本号。
|
||||
|
||||
-- 权限串是稳定业务键,不依赖各环境的菜单自增 ID。
|
||||
INSERT INTO system_menu
|
||||
(name, permission, type, sort, parent_id, path, icon, component, component_name,
|
||||
status, visible, keep_alive, always_show, creator, updater, deleted)
|
||||
SELECT '项目审核查询', 'project:review:query', 3, 1, 0, '', '#', '', NULL,
|
||||
0, b'0', b'0', b'0', 'dogfood-seed', 'dogfood-seed', b'0'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_menu WHERE permission = 'project:review:query' AND deleted = b'0'
|
||||
);
|
||||
|
||||
INSERT INTO system_menu
|
||||
(name, permission, type, sort, parent_id, path, icon, component, component_name,
|
||||
status, visible, keep_alive, always_show, creator, updater, deleted)
|
||||
SELECT '项目审核决策', 'project:review:approve', 3, 2, 0, '', '#', '', NULL,
|
||||
0, b'0', b'0', b'0', 'dogfood-seed', 'dogfood-seed', b'0'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_menu WHERE permission = 'project:review:approve' AND deleted = b'0'
|
||||
);
|
||||
|
||||
INSERT INTO system_menu
|
||||
(name, permission, type, sort, parent_id, path, icon, component, component_name,
|
||||
status, visible, keep_alive, always_show, creator, updater, deleted)
|
||||
SELECT '游戏遥测查询', 'telemetry:game-stat:query', 3, 3, 0, '', '#', '', NULL,
|
||||
0, b'0', b'0', b'0', 'dogfood-seed', 'dogfood-seed', b'0'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_menu WHERE permission = 'telemetry:game-stat:query' AND deleted = b'0'
|
||||
);
|
||||
|
||||
-- 两个角色均为 tenant_id=1 的普通自定义角色,不具备 super_admin 绕过能力。
|
||||
INSERT INTO system_role
|
||||
(name, code, sort, data_scope, data_scope_dept_ids, status, type, remark,
|
||||
creator, updater, deleted, tenant_id)
|
||||
SELECT '狗粮审核员', 'dogfood_reviewer', 90, 1, '', 0, 2, '项目审核查询与决策、遥测只读,无配置权限',
|
||||
'dogfood-seed', 'dogfood-seed', b'0', 1
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_role WHERE code = 'dogfood_reviewer' AND tenant_id = 1 AND deleted = b'0'
|
||||
);
|
||||
|
||||
INSERT INTO system_role
|
||||
(name, code, sort, data_scope, data_scope_dept_ids, status, type, remark,
|
||||
creator, updater, deleted, tenant_id)
|
||||
SELECT '狗粮只读员', 'dogfood_readonly', 91, 1, '', 0, 2, '项目审核与遥测只读,无任何写权限',
|
||||
'dogfood-seed', 'dogfood-seed', b'0', 1
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_role WHERE code = 'dogfood_readonly' AND tenant_id = 1 AND deleted = b'0'
|
||||
);
|
||||
|
||||
-- 历史环境若已存在同名角色,repeatable 每次都恢复其安全属性,避免停用或角色类型漂移。
|
||||
UPDATE system_role
|
||||
SET name = '狗粮审核员', sort = 90, data_scope = 1, data_scope_dept_ids = '', status = 0, type = 2,
|
||||
remark = '项目审核查询与决策、遥测只读,无配置权限', updater = 'dogfood-seed'
|
||||
WHERE code = 'dogfood_reviewer' AND tenant_id = 1 AND deleted = b'0';
|
||||
|
||||
UPDATE system_role
|
||||
SET name = '狗粮只读员', sort = 91, data_scope = 1, data_scope_dept_ids = '', status = 0, type = 2,
|
||||
remark = '项目审核与遥测只读,无任何写权限', updater = 'dogfood-seed'
|
||||
WHERE code = 'dogfood_readonly' AND tenant_id = 1 AND deleted = b'0';
|
||||
|
||||
-- repeatable 重跑时将专用角色收敛到精确权限集,防止历史误授权残留。
|
||||
DELETE rm
|
||||
FROM system_role_menu rm
|
||||
JOIN system_role r ON r.id = rm.role_id
|
||||
WHERE r.code IN ('dogfood_reviewer', 'dogfood_readonly')
|
||||
AND r.tenant_id = 1 AND r.deleted = b'0';
|
||||
|
||||
INSERT INTO system_role_menu (role_id, menu_id, creator, updater, deleted, tenant_id)
|
||||
SELECT r.id, m.id, 'dogfood-seed', 'dogfood-seed', b'0', 1
|
||||
FROM system_role r
|
||||
JOIN system_menu m
|
||||
ON m.permission IN ('project:review:query', 'project:review:approve', 'telemetry:game-stat:query')
|
||||
AND m.deleted = b'0'
|
||||
WHERE r.code = 'dogfood_reviewer' AND r.tenant_id = 1 AND r.deleted = b'0';
|
||||
|
||||
INSERT INTO system_role_menu (role_id, menu_id, creator, updater, deleted, tenant_id)
|
||||
SELECT r.id, m.id, 'dogfood-seed', 'dogfood-seed', b'0', 1
|
||||
FROM system_role r
|
||||
JOIN system_menu m
|
||||
ON m.permission IN ('project:review:query', 'telemetry:game-stat:query')
|
||||
AND m.deleted = b'0'
|
||||
WHERE r.code = 'dogfood_readonly' AND r.tenant_id = 1 AND r.deleted = b'0';
|
||||
@ -1,113 +0,0 @@
|
||||
-- 内部狗粮 staging 专用最小 RBAC 种子(V34,避开正式迁移 V33)。
|
||||
-- 目标:reviewer 可查询/审核但不可改配置;readonly 只可查询,任何写接口由 @PreAuthorize 返回 403。
|
||||
-- 本脚本只由 application-staging.yaml 的 db/staging location 加载,不进入其它环境。
|
||||
|
||||
-- 使用权限串作为稳定业务键,避免依赖不同数据库中的自增菜单 ID。
|
||||
INSERT INTO system_menu
|
||||
(name, permission, type, sort, parent_id, path, icon, component, component_name,
|
||||
status, visible, keep_alive, always_show, creator, updater, deleted)
|
||||
SELECT '项目审核查询', 'project:review:query', 3, 1, 0, '', '#', '', NULL,
|
||||
0, b'0', b'0', b'0', 'dogfood-seed', 'dogfood-seed', b'0'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_menu WHERE permission = 'project:review:query' AND deleted = b'0'
|
||||
);
|
||||
|
||||
INSERT INTO system_menu
|
||||
(name, permission, type, sort, parent_id, path, icon, component, component_name,
|
||||
status, visible, keep_alive, always_show, creator, updater, deleted)
|
||||
SELECT '项目审核决策', 'project:review:approve', 3, 2, 0, '', '#', '', NULL,
|
||||
0, b'0', b'0', b'0', 'dogfood-seed', 'dogfood-seed', b'0'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_menu WHERE permission = 'project:review:approve' AND deleted = b'0'
|
||||
);
|
||||
|
||||
INSERT INTO system_menu
|
||||
(name, permission, type, sort, parent_id, path, icon, component, component_name,
|
||||
status, visible, keep_alive, always_show, creator, updater, deleted)
|
||||
SELECT '游戏遥测查询', 'telemetry:game-stat:query', 3, 3, 0, '', '#', '', NULL,
|
||||
0, b'0', b'0', b'0', 'dogfood-seed', 'dogfood-seed', b'0'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_menu WHERE permission = 'telemetry:game-stat:query' AND deleted = b'0'
|
||||
);
|
||||
|
||||
-- 角色按 tenant_id=1 隔离;均为普通自定义角色,不具备 super_admin 绕过能力。
|
||||
INSERT INTO system_role
|
||||
(name, code, sort, data_scope, data_scope_dept_ids, status, type, remark,
|
||||
creator, updater, deleted, tenant_id)
|
||||
SELECT '狗粮审核员', 'dogfood_reviewer', 90, 1, '', 0, 2, '项目审核查询与决策、遥测只读,无配置权限',
|
||||
'dogfood-seed', 'dogfood-seed', b'0', 1
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_role WHERE code = 'dogfood_reviewer' AND tenant_id = 1 AND deleted = b'0'
|
||||
);
|
||||
|
||||
INSERT INTO system_role
|
||||
(name, code, sort, data_scope, data_scope_dept_ids, status, type, remark,
|
||||
creator, updater, deleted, tenant_id)
|
||||
SELECT '狗粮只读员', 'dogfood_readonly', 91, 1, '', 0, 2, '项目审核与遥测只读,无任何写权限',
|
||||
'dogfood-seed', 'dogfood-seed', b'0', 1
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_role WHERE code = 'dogfood_readonly' AND tenant_id = 1 AND deleted = b'0'
|
||||
);
|
||||
|
||||
-- staging 预置管理账号。密码沿用本项目本地初始化账号的 BCrypt 测试密码,首次真机验收后由 ops 轮换。
|
||||
INSERT INTO system_users
|
||||
(username, password, nickname, remark, dept_id, post_ids, email, mobile, sex, avatar,
|
||||
status, login_ip, creator, updater, deleted, tenant_id)
|
||||
SELECT 'dogfood_reviewer', '$2a$04$.vd8nPeLwxt6hnSzmAoAyul8BOLX7Cib6QhcxRe30rfvrIPQHH1OG',
|
||||
'狗粮审核员', 'staging 内部狗粮专用,禁止赋 super_admin', NULL, '[]', '', '', 0, '',
|
||||
0, '', 'dogfood-seed', 'dogfood-seed', b'0', 1
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_users WHERE username = 'dogfood_reviewer' AND tenant_id = 1 AND deleted = b'0'
|
||||
);
|
||||
|
||||
INSERT INTO system_users
|
||||
(username, password, nickname, remark, dept_id, post_ids, email, mobile, sex, avatar,
|
||||
status, login_ip, creator, updater, deleted, tenant_id)
|
||||
SELECT 'dogfood_readonly', '$2a$04$.vd8nPeLwxt6hnSzmAoAyul8BOLX7Cib6QhcxRe30rfvrIPQHH1OG',
|
||||
'狗粮只读员', 'staging 内部狗粮专用,禁止赋 super_admin', NULL, '[]', '', '', 0, '',
|
||||
0, '', 'dogfood-seed', 'dogfood-seed', b'0', 1
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_users WHERE username = 'dogfood_readonly' AND tenant_id = 1 AND deleted = b'0'
|
||||
);
|
||||
|
||||
-- reviewer = 审核 query/approve + 遥测 query;readonly = 审核 query + 遥测 query。
|
||||
-- NOT EXISTS 保证脚本可重复执行且不产生重复授权。
|
||||
INSERT INTO system_role_menu (role_id, menu_id, creator, updater, deleted, tenant_id)
|
||||
SELECT r.id, m.id, 'dogfood-seed', 'dogfood-seed', b'0', 1
|
||||
FROM system_role r
|
||||
JOIN system_menu m ON m.permission IN ('project:review:query', 'project:review:approve', 'telemetry:game-stat:query') AND m.deleted = b'0'
|
||||
WHERE r.code = 'dogfood_reviewer' AND r.tenant_id = 1 AND r.deleted = b'0'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM system_role_menu rm
|
||||
WHERE rm.role_id = r.id AND rm.menu_id = m.id AND rm.deleted = b'0'
|
||||
);
|
||||
|
||||
INSERT INTO system_role_menu (role_id, menu_id, creator, updater, deleted, tenant_id)
|
||||
SELECT r.id, m.id, 'dogfood-seed', 'dogfood-seed', b'0', 1
|
||||
FROM system_role r
|
||||
JOIN system_menu m ON m.permission IN ('project:review:query', 'telemetry:game-stat:query') AND m.deleted = b'0'
|
||||
WHERE r.code = 'dogfood_readonly' AND r.tenant_id = 1 AND r.deleted = b'0'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM system_role_menu rm
|
||||
WHERE rm.role_id = r.id AND rm.menu_id = m.id AND rm.deleted = b'0'
|
||||
);
|
||||
|
||||
INSERT INTO system_user_role (user_id, role_id, creator, updater, deleted, tenant_id)
|
||||
SELECT u.id, r.id, 'dogfood-seed', 'dogfood-seed', b'0', 1
|
||||
FROM system_users u
|
||||
JOIN system_role r ON r.code = 'dogfood_reviewer' AND r.tenant_id = 1 AND r.deleted = b'0'
|
||||
WHERE u.username = 'dogfood_reviewer' AND u.tenant_id = 1 AND u.deleted = b'0'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM system_user_role ur
|
||||
WHERE ur.user_id = u.id AND ur.role_id = r.id AND ur.deleted = b'0'
|
||||
);
|
||||
|
||||
INSERT INTO system_user_role (user_id, role_id, creator, updater, deleted, tenant_id)
|
||||
SELECT u.id, r.id, 'dogfood-seed', 'dogfood-seed', b'0', 1
|
||||
FROM system_users u
|
||||
JOIN system_role r ON r.code = 'dogfood_readonly' AND r.tenant_id = 1 AND r.deleted = b'0'
|
||||
WHERE u.username = 'dogfood_readonly' AND u.tenant_id = 1 AND u.deleted = b'0'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM system_user_role ur
|
||||
WHERE ur.user_id = u.id AND ur.role_id = r.id AND ur.deleted = b'0'
|
||||
);
|
||||
@ -17,7 +17,7 @@ class DogfoodRbacSeedTest {
|
||||
|
||||
@Test
|
||||
void reviewerAndReadonlyShouldHaveRequiredQueriesWithoutConfigWrites() throws Exception {
|
||||
String sql = new ClassPathResource("db/staging/V34.0.0__seed_dogfood_review_rbac.sql")
|
||||
String sql = new ClassPathResource("db/staging/R__seed_dogfood_review_rbac.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue(sql.contains("'telemetry:game-stat:query'"), "必须创建遥测查询权限");
|
||||
@ -26,9 +26,10 @@ class DogfoodRbacSeedTest {
|
||||
assertTrue(sql.contains("IN ('project:review:query', 'telemetry:game-stat:query')"),
|
||||
"readonly 必须具备 smoke 所需的两个查询权限");
|
||||
assertFalse(sql.contains("aigc:config:"), "狗粮角色不得获得配置控制面权限");
|
||||
assertFalse(sql.contains("INSERT INTO system_users"), "RBAC migration 不得创建可登录账号");
|
||||
assertFalse(sql.contains("$2a$"), "RBAC migration 不得写入公开 BCrypt 密码");
|
||||
|
||||
String readonlyGrant = sql.substring(sql.indexOf("r.code = 'dogfood_readonly'"),
|
||||
sql.indexOf("INSERT INTO system_user_role"));
|
||||
String readonlyGrant = sql.substring(sql.lastIndexOf("WHERE r.code = 'dogfood_readonly'"));
|
||||
assertFalse(readonlyGrant.contains("project:review:approve"), "readonly 不得获得审核写权限");
|
||||
|
||||
assertEquals("@ss.hasPermission('project:review:approve')", AdminProjectController.class
|
||||
|
||||
@ -7,6 +7,10 @@ import com.wanxiang.huijing.framework.env.core.DogfoodAccessGuard;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@ -15,6 +19,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
class DogfoodStartupValidatorTest {
|
||||
|
||||
private static final ErrorCode TEST_ERROR = new ErrorCode(403, "测试拒绝");
|
||||
private static final Instant NOW = Instant.parse("2026-07-22T12:00:00Z");
|
||||
private static final Clock FIXED_CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||
|
||||
@Test
|
||||
void guardShouldRejectDogfoodRestrictionAndClosedFeed() {
|
||||
@ -36,7 +42,7 @@ class DogfoodStartupValidatorTest {
|
||||
DogfoodProperties properties = enabledDogfood();
|
||||
MockEnvironment environment = safeEnvironment();
|
||||
|
||||
assertDoesNotThrow(() -> new DogfoodStartupValidator(properties, environment).run(null));
|
||||
assertDoesNotThrow(() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -47,7 +53,7 @@ class DogfoodStartupValidatorTest {
|
||||
.withProperty("wanxiang.passport.password-register-enabled", "true");
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> new DogfoodStartupValidator(properties, environment).run(null));
|
||||
() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -59,7 +65,52 @@ class DogfoodStartupValidatorTest {
|
||||
.withProperty("trade.payout-channel", "alipay");
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> new DogfoodStartupValidator(properties, environment).run(null));
|
||||
() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowPasswordRegistrationWithActiveProvisioningLease() {
|
||||
DogfoodProperties properties = enabledDogfood();
|
||||
properties.setProvisioningEnabled(true);
|
||||
properties.setProvisioningExpiresAt("2026-07-22T12:05:00Z");
|
||||
MockEnvironment environment = safeEnvironment()
|
||||
.withProperty("wanxiang.passport.password-register-enabled", "true");
|
||||
|
||||
assertDoesNotThrow(() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectExpiredProvisioningLease() {
|
||||
DogfoodProperties properties = enabledDogfood();
|
||||
properties.setProvisioningEnabled(true);
|
||||
properties.setProvisioningExpiresAt("2026-07-22T12:00:00Z");
|
||||
MockEnvironment environment = safeEnvironment()
|
||||
.withProperty("wanxiang.passport.password-register-enabled", "true");
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidProvisioningLease() {
|
||||
DogfoodProperties properties = enabledDogfood();
|
||||
properties.setProvisioningEnabled(true);
|
||||
properties.setProvisioningExpiresAt("not-an-instant");
|
||||
MockEnvironment environment = safeEnvironment()
|
||||
.withProperty("wanxiang.passport.password-register-enabled", "true");
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void provisioningLeaseShouldBeInactiveWhenDogfoodIsDisabled() {
|
||||
DogfoodProperties properties = new DogfoodProperties();
|
||||
properties.setProvisioningEnabled(true);
|
||||
properties.setProvisioningExpiresAt("2026-07-22T12:05:00Z");
|
||||
|
||||
assertEquals(DogfoodProperties.ProvisioningLeaseStatus.DOGFOOD_DISABLED,
|
||||
properties.getProvisioningLeaseStatus(FIXED_CLOCK));
|
||||
}
|
||||
|
||||
private static DogfoodProperties enabledDogfood() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user