B-64: 安全修复——随机初始密码与跨公司重复上传隔离

- 公司账号创建改为随机一次性初始密码,只在创建响应中显示一次,
  密码保证不等于用户名;删除「用户名即初始密码」兼容分支,继续
  强制首次登录改密。
- 跨公司相同字节文件上传只返回通用重复状态:不再返回其他公司的
  原批次 ID、银行、模板、期间、交易数或诊断;同公司重复上传的
  幂等摘要保持可用。
- 补充服务端回归测试,覆盖同公司与跨公司两个分支及随机密码;
  完整测试 85 项全绿,node --check 通过。
This commit is contained in:
腾讯WorkBuddy
2026-08-16 01:50:42 +08:00
parent 545837446c
commit 7f1a93f6a6
11 changed files with 155 additions and 58 deletions
+4 -2
View File
@@ -27,6 +27,8 @@ python server.py
数据库与原始文件默认保存在 `data/`(已加入 `.gitignore`)。上传文件按 数据库与原始文件默认保存在 `data/`(已加入 `.gitignore`)。上传文件按
SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并复用已有批次。 SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并复用已有批次。
同一公司重复上传返回原批次摘要;不同公司相同内容只返回通用重复状态,
不暴露其他公司的批次标识或摘要。
技术决策见 `docs/decisions/002-persistence.md` 技术决策见 `docs/decisions/002-persistence.md`
## 登录与账号 ## 登录与账号
@@ -40,8 +42,8 @@ SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并
- 引导管理员及管理员创建的公司账号首次登录都必须修改密码。 - 引导管理员及管理员创建的公司账号首次登录都必须修改密码。
- 公司、公司账号由管理员在总账端「公司与账号 → 新增公司」或 - 公司、公司账号由管理员在总账端「公司与账号 → 新增公司」或
`/api/admin/companies``/api/admin/users` 动态创建; `/api/admin/companies``/api/admin/users` 动态创建;
公司账号的初始密码与登录账号相同,首次登录强制改密 公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
(「重置密码」生成随机一次性密码)。 首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。 - 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖): 访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
+5 -8
View File
@@ -33,14 +33,11 @@
公司账号必须绑定公司、管理员不得绑定公司。 公司账号必须绑定公司、管理员不得绑定公司。
- 登录时前端选择工作端口(portal),服务端校验 portal 与角色一致, - 登录时前端选择工作端口(portal),服务端校验 portal 与角色一致,
不匹配返回 403「账号与该工作端口不匹配」。 不匹配返回 403「账号与该工作端口不匹配」。
- 管理员创建公司账号时,初始密码等于登录账号本身并置 - 管理员创建公司账号时,初始密码为随机一次性密码并置
`must_change_password=1`2026-08-08 产品决定,替代随机初始密码); `must_change_password=1`;首次登录必须改密,改密前所有业务 API 返回 403。
首次登录必须改密,改密前所有业务 API 返回 403。初始密码未改前 初始密码只在创建成功响应中显示一次,永不入库明文、永不写日志;
等同账号名,因此创建用户的审计 detail 只记录公司,不重复账号名。 创建用户的审计 detail 只记录公司,不重复账号名。
- 「重置密码」生成随机一次性密码:重置发生在用户已改密之后, - 「重置密码」同样生成随机一次性密码并吊销既有会话。
可预测的口令会让知道账号名的人直接接管账户。
- 已知风险:初始密码可预测,账号创建后应尽快完成首登改密;
创建到改密之间,知道账号名的人即可登录该账号。
## 租户隔离在服务端强制,404 优于 403 ## 租户隔离在服务端强制,404 优于 403
+20 -11
View File
@@ -633,11 +633,13 @@ class AppHandler(SimpleHTTPRequestHandler):
if username: if username:
# Optionally create the company login in the same request, so # Optionally create the company login in the same request, so
# a new company is immediately usable without code changes. # a new company is immediately usable without code changes.
# Initial password equals the username (see B-40 decision) and # The initial password is a random one-time value shown only
# in this creation response, never stored plaintext or logged;
# must_change_password forces a change at first login. # must_change_password forces a change at first login.
initial_password = auth.generate_initial_password(exclude=username)
try: try:
user_id = auth.create_user( user_id = auth.create_user(
connection, username, username, "company", connection, username, initial_password, "company",
company_id=company_id, must_change_password=True, company_id=company_id, must_change_password=True,
) )
except ValueError as exc: except ValueError as exc:
@@ -654,7 +656,7 @@ class AppHandler(SimpleHTTPRequestHandler):
) )
payload.update( payload.update(
{"user_id": user_id, "username": username, {"user_id": user_id, "username": username,
"initial_password": username} "initial_password": initial_password}
) )
self._send_json(200, payload) self._send_json(200, payload)
finally: finally:
@@ -965,10 +967,11 @@ class AppHandler(SimpleHTTPRequestHandler):
except (TypeError, ValueError): except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "必须指定有效的 company_id。"}) self._send_json(400, {"status": "error", "message": "必须指定有效的 company_id。"})
return return
# Product decision (B-40, 2026-08-08): the initial password equals # The initial password is a random one-time value shown only in
# the username, and must_change_password forces a change at first # this creation response, never stored plaintext or logged;
# login. Password reset keeps a random one-time password instead. # must_change_password forces a change at first login. Password
initial_password = username # reset keeps a random one-time password instead.
initial_password = auth.generate_initial_password(exclude=username)
try: try:
user_id = auth.create_user( user_id = auth.create_user(
connection, connection,
@@ -981,8 +984,8 @@ class AppHandler(SimpleHTTPRequestHandler):
except ValueError as exc: except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)}) self._send_json(400, {"status": "error", "message": str(exc)})
return return
# While the initial password is unchanged it equals the username, # The random one-time password is never written to audit detail;
# so the username itself must stay out of audit details. # only the company binding is recorded.
auth.audit( auth.audit(
connection, connection,
"user_create", "user_create",
@@ -1142,10 +1145,16 @@ class AppHandler(SimpleHTTPRequestHandler):
} }
) )
elif result.status == "duplicate": elif result.status == "duplicate":
# Same-company duplicates keep their idempotent summary so the
# cashier sees the reused batch's bank/template/period/count.
# Cross-company duplicates are opaque: only the generic duplicate
# status and the uploader's own new batch id are returned, never
# the other company's batch id, summary or diagnostics.
if result.duplicate_same_company:
sheet = connection.execute( sheet = connection.execute(
""" """
SELECT bank_name, template_id, header_row, period_start, period_end, SELECT bank_name, template_id, header_row, period_start,
transaction_count, warnings period_end, transaction_count, warnings
FROM sheet_batches FROM sheet_batches
WHERE import_batch_id = ? WHERE import_batch_id = ?
ORDER BY id LIMIT 1 ORDER BY id LIMIT 1
+8 -2
View File
@@ -53,8 +53,13 @@ def verify_password(password: str, stored: str) -> bool:
return hmac.compare_digest(digest, expected) return hmac.compare_digest(digest, expected)
def generate_initial_password() -> str: def generate_initial_password(exclude: str | None = None) -> str:
"""Generate a 12-char initial password with upper, lower and digit chars.""" """Generate a 12-char initial password with upper, lower and digit chars.
When ``exclude`` is given, the result is guaranteed to differ from it
(case-insensitive) so a fresh account never starts with a password equal
to its own username.
"""
alphabet = string.ascii_letters + string.digits alphabet = string.ascii_letters + string.digits
while True: while True:
password = "".join( password = "".join(
@@ -64,6 +69,7 @@ def generate_initial_password() -> str:
any(char.isupper() for char in password) any(char.isupper() for char in password)
and any(char.islower() for char in password) and any(char.islower() for char in password)
and any(char.isdigit() for char in password) and any(char.isdigit() for char in password)
and (exclude is None or password.lower() != exclude.lower())
): ):
return password return password
+15 -4
View File
@@ -29,6 +29,7 @@ class ImportResult:
source_file_id: int source_file_id: int
batches: tuple[StatementBatch, ...] = () batches: tuple[StatementBatch, ...] = ()
message: str | None = None message: str | None = None
duplicate_same_company: bool = False # only meaningful when status == 'duplicate'
def import_statement( def import_statement(
@@ -96,7 +97,7 @@ def _record_duplicate(
) -> ImportResult: ) -> ImportResult:
original = connection.execute( original = connection.execute(
""" """
SELECT id FROM import_batches SELECT id, company_id FROM import_batches
WHERE source_file_id = ? AND status = 'parsed' WHERE source_file_id = ? AND status = 'parsed'
ORDER BY id LIMIT 1 ORDER BY id LIMIT 1
""", """,
@@ -105,7 +106,7 @@ def _record_duplicate(
if original is None: if original is None:
original = connection.execute( original = connection.execute(
""" """
SELECT id FROM import_batches SELECT id, company_id FROM import_batches
WHERE source_file_id = ? AND status != 'duplicate' WHERE source_file_id = ? AND status != 'duplicate'
ORDER BY id LIMIT 1 ORDER BY id LIMIT 1
""", """,
@@ -113,7 +114,7 @@ def _record_duplicate(
).fetchone() ).fetchone()
now = utc_now() now = utc_now()
with connection: with connection:
connection.execute( cursor = connection.execute(
""" """
INSERT INTO import_batches INSERT INTO import_batches
(source_file_id, status, duplicate_of_id, company_id, diagnostics, created_at, updated_at) (source_file_id, status, duplicate_of_id, company_id, diagnostics, created_at, updated_at)
@@ -128,12 +129,22 @@ def _record_duplicate(
now, now,
), ),
) )
# A duplicate is only "same company" when the uploader belongs to the
# same tenant that owns the original batch. Cross-company duplicates
# must stay opaque: the caller gets the new duplicate batch id (which
# belongs to its own company) but never the other company's batch id.
same_company = original["company_id"] == company_id
if same_company:
batch_id = original["id"]
else:
batch_id = int(cursor.lastrowid)
return ImportResult( return ImportResult(
original["id"], batch_id,
"duplicate", "duplicate",
sha256, sha256,
source_file_id, source_file_id,
message="相同内容的文件已导入,本次按重复上传处理。", message="相同内容的文件已导入,本次按重复上传处理。",
duplicate_same_company=same_company,
) )
+5
View File
@@ -71,6 +71,11 @@ class PasswordPolicyTests(unittest.TestCase):
self.assertTrue(any(char.isdigit() for char in password)) self.assertTrue(any(char.isdigit() for char in password))
self.assertIsNone(auth.validate_password_policy(auth.generate_initial_password(), "x")) self.assertIsNone(auth.validate_password_policy(auth.generate_initial_password(), "x"))
def test_initial_password_exclude_never_equals_username(self) -> None:
for _ in range(50):
password = auth.generate_initial_password(exclude="Cashier99")
self.assertNotEqual(password.lower(), "cashier99")
class CreateUserTests(AuthTestCase): class CreateUserTests(AuthTestCase):
def test_company_role_requires_company(self) -> None: def test_company_role_requires_company(self) -> None:
+7 -3
View File
@@ -263,7 +263,9 @@ class MasterDataApiTests(unittest.TestCase):
assert status == 200, data assert status == 200, data
payload = as_json(data) payload = as_json(data)
cls.company_a = payload["company_id"] cls.company_a = payload["company_id"]
assert payload["initial_password"] == "cashier-a" cls.initial_a = payload["initial_password"]
assert cls.initial_a != "cashier-a"
assert len(cls.initial_a) >= 12
status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "乙公司"}) status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "乙公司"})
assert status == 200, data assert status == 200, data
@@ -272,9 +274,11 @@ class MasterDataApiTests(unittest.TestCase):
"/api/admin/users", {"username": "cashier-b", "company_id": cls.company_b} "/api/admin/users", {"username": "cashier-b", "company_id": cls.company_b}
) )
assert status == 200, data assert status == 200, data
cls.initial_b = as_json(data)["initial_password"]
assert cls.initial_b != "cashier-b"
cls.cashier_a = cls._login_company_user("cashier-a", "cashier-a") cls.cashier_a = cls._login_company_user("cashier-a", cls.initial_a)
cls.cashier_b = cls._login_company_user("cashier-b", "cashier-b") cls.cashier_b = cls._login_company_user("cashier-b", cls.initial_b)
@classmethod @classmethod
def _login_company_user(cls, username: str, initial: str) -> Client: def _login_company_user(cls, username: str, initial: str) -> Client:
+39 -1
View File
@@ -9,7 +9,7 @@ import unittest
from openpyxl import Workbook from openpyxl import Workbook
from bank_importer.db import applied_versions, connect, migrate, rollback from bank_importer.db import applied_versions, connect, migrate, rollback, utc_now
from bank_importer.importing import import_statement from bank_importer.importing import import_statement
@@ -175,6 +175,7 @@ class IdempotencyTests(PersistenceTestCase):
self.assertEqual("parsed", first.status) self.assertEqual("parsed", first.status)
self.assertEqual("duplicate", second.status) self.assertEqual("duplicate", second.status)
self.assertEqual(first.batch_id, second.batch_id) self.assertEqual(first.batch_id, second.batch_id)
self.assertTrue(second.duplicate_same_company)
files = self.connection.execute("SELECT COUNT(*) AS n FROM source_files").fetchone() files = self.connection.execute("SELECT COUNT(*) AS n FROM source_files").fetchone()
self.assertEqual(1, files["n"]) self.assertEqual(1, files["n"])
@@ -188,6 +189,43 @@ class IdempotencyTests(PersistenceTestCase):
).fetchone() ).fetchone()
self.assertEqual(first.batch_id, duplicate["duplicate_of_id"]) self.assertEqual(first.batch_id, duplicate["duplicate_of_id"])
def test_cross_company_duplicate_never_returns_other_companys_batch(self) -> None:
now = utc_now()
with self.connection:
self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
(now, now),
)
self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
(now, now),
)
company_a, company_b = [
row["id"]
for row in self.connection.execute("SELECT id FROM companies ORDER BY id").fetchall()
]
first = import_statement(
self.connection, self.storage, SAMPLE_FILE.name,
SAMPLE_FILE.read_bytes(), company_id=company_a,
)
self.assertEqual("parsed", first.status)
second = import_statement(
self.connection, self.storage, SAMPLE_FILE.name,
SAMPLE_FILE.read_bytes(), company_id=company_b,
)
self.assertEqual("duplicate", second.status)
self.assertFalse(second.duplicate_same_company)
# The returned batch id is the uploader's own duplicate batch, never
# the other company's original batch.
self.assertNotEqual(first.batch_id, second.batch_id)
self.assertNotEqual(second.batch_id, first.batch_id)
own = self.connection.execute(
"SELECT company_id FROM import_batches WHERE id = ?", (second.batch_id,)
).fetchone()
self.assertEqual(company_b, own["company_id"])
def test_repeated_upload_under_a_different_filename_is_still_duplicate(self) -> None: def test_repeated_upload_under_a_different_filename_is_still_duplicate(self) -> None:
first = self.import_sample() first = self.import_sample()
second = import_statement( second = import_statement(
+26 -4
View File
@@ -169,14 +169,15 @@ class ServerAuthMatrixTests(unittest.TestCase):
assert status == 200, data assert status == 200, data
cls.company_b = as_json(data)["company_id"] cls.company_b = as_json(data)["company_id"]
# --- Company user A: initial password shown once, forced change. --- # --- Company user A: random one-time initial password, forced change. ---
status, _, data = cls.admin.post_json( status, _, data = cls.admin.post_json(
"/api/admin/users", {"username": "cashier-a", "company_id": cls.company_a} "/api/admin/users", {"username": "cashier-a", "company_id": cls.company_a}
) )
assert status == 200, data assert status == 200, data
payload = as_json(data) payload = as_json(data)
# Product decision: registration initial password equals the username. # Security: the initial password is random and never equals the username.
assert payload["initial_password"] == "cashier-a" assert payload["initial_password"] != "cashier-a"
assert len(payload["initial_password"]) >= 12
cls.initial_password_a = payload["initial_password"] cls.initial_password_a = payload["initial_password"]
cls.known_passwords.add(cls.initial_password_a) cls.known_passwords.add(cls.initial_password_a)
@@ -431,7 +432,13 @@ class ServerAuthMatrixTests(unittest.TestCase):
CCB_SAMPLE.read_bytes(), CCB_SAMPLE.read_bytes(),
) )
self.assertEqual(200, status, data) self.assertEqual(200, status, data)
self.assertEqual("duplicate", as_json(data)["status"]) payload = as_json(data)
self.assertEqual("duplicate", payload["status"])
# Cross-company duplicate: no original batch id, bank, template,
# period, transaction count or diagnostics may be exposed.
self.assertNotEqual(self.b_batch_id, payload.get("batch_id"))
for leaked_key in ("bank", "template", "header_row", "period_start", "period_end", "transactions", "warnings"):
self.assertNotIn(leaked_key, payload, leaked_key)
connection = connect(self.db_path) connection = connect(self.db_path)
try: try:
duplicate = connection.execute( duplicate = connection.execute(
@@ -442,6 +449,21 @@ class ServerAuthMatrixTests(unittest.TestCase):
self.assertIsNotNone(duplicate) self.assertIsNotNone(duplicate)
self.assertEqual(self.company_a, duplicate["company_id"]) self.assertEqual(self.company_a, duplicate["company_id"])
def test_same_company_duplicate_keeps_idempotent_summary(self) -> None:
# A re-uploads its own file; the idempotent duplicate response keeps
# the original batch id and the parsed summary.
status, _, data = self.cashier_a.post_multipart(
"/api/parse", {}, CITIC_SAMPLE.name, CITIC_SAMPLE.read_bytes()
)
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertEqual("duplicate", payload["status"])
self.assertEqual(self.a_batch_id, payload["batch_id"])
self.assertEqual("中信银行", payload.get("bank"))
self.assertTrue(payload.get("transactions", 0) > 0)
self.assertIn("period_start", payload)
self.assertIn("warnings", payload)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Disable / reset flows # Disable / reset flows
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+1 -1
View File
@@ -205,7 +205,7 @@
<dialog id="companyDialog" class="dialog"> <dialog id="companyDialog" class="dialog">
<form method="dialog" id="companyForm"> <form method="dialog" id="companyForm">
<header><div><h2>新增公司</h2><p>公司主档与公司账号可一次创建</p></div><button class="icon-button" value="cancel" aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header> <header><div><h2>新增公司</h2><p>公司主档与公司账号可一次创建</p></div><button class="icon-button" value="cancel" aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
<div class="dialog-body"><label class="field"><span>公司全称</span><input name="companyName" required /></label><label class="field"><span>统一社会信用代码</span><input name="creditCode" /></label><label class="check-field"><input type="checkbox" name="createUser" checked />同时创建公司账号</label><div class="form-grid"><label class="field"><span>登录账号</span><input name="loginName" required /></label><label class="field"><span>出纳人员</span><input name="cashier" required /></label></div><p class="form-callout"><svg><use href="icons.svg#key-round"/></svg>初始密码与登录账号相同,首次登录必须修改。</p></div> <div class="dialog-body"><label class="field"><span>公司全称</span><input name="companyName" required /></label><label class="field"><span>统一社会信用代码</span><input name="creditCode" /></label><label class="check-field"><input type="checkbox" name="createUser" checked />同时创建公司账号</label><div class="form-grid"><label class="field"><span>登录账号</span><input name="loginName" required /></label><label class="field"><span>出纳人员</span><input name="cashier" required /></label></div><p class="form-callout"><svg><use href="icons.svg#key-round"/></svg>创建后生成随机初始密码,仅显示一次,首次登录必须修改。</p></div>
<footer><button class="button secondary" value="cancel">取消</button><button class="button primary" value="default">创建公司与账号</button></footer> <footer><button class="button secondary" value="cancel">取消</button><button class="button primary" value="default">创建公司与账号</button></footer>
</form> </form>
</dialog> </dialog>
+5 -2
View File
@@ -1006,7 +1006,7 @@ function initAdmin() {
await loadAdminCompanies(); await loadAdminCompanies();
showToast( showToast(
accountCreated ? "公司与账号已创建" : "公司已创建", accountCreated ? "公司与账号已创建" : "公司已创建",
accountCreated ? `账号 ${result.username} 的初始密码与登录账号相同,首次登录必须修改` : "可稍后在账号管理中创建公司账号", accountCreated ? `账号 ${result.username} 的初始密码已生成(仅此一次显示):${result.initial_password},首次登录必须修改` : "可稍后在账号管理中创建公司账号",
); );
}); });
@@ -1204,12 +1204,15 @@ async function parseFile() {
} }
state.parseResult = result; state.parseResult = result;
const duplicated = result.status === "duplicate"; const duplicated = result.status === "duplicate";
const opaqueDuplicate = duplicated && !result.bank;
const panel = $("#parseResult"); const panel = $("#parseResult");
panel.classList.toggle("is-exception", !parsed); panel.classList.toggle("is-exception", !parsed);
$("use", panel).setAttribute("href", parsed ? "icons.svg#circle-check" : "icons.svg#circle-alert"); $("use", panel).setAttribute("href", parsed ? "icons.svg#circle-check" : "icons.svg#circle-alert");
$("strong", panel).textContent = duplicated ? "文件已导入过" : parsed ? "文件解析完成" : "未识别到银行模板"; $("strong", panel).textContent = duplicated ? "文件已导入过" : parsed ? "文件解析完成" : "未识别到银行模板";
$("#parseSummary").textContent = parsed $("#parseSummary").textContent = parsed
? `${result.bank} · 表头第 ${result.header_row} 行 · ${result.transactions} 条明细 · ${result.warnings.length ? `${result.warnings.length} 项提示` : "校验通过"}${duplicated ? " · 重复上传,复用已有批次" : ""}` ? opaqueDuplicate
? "相同内容的文件已由其他公司导入,仅记录重复状态,不重复入账。"
: `${result.bank} · 表头第 ${result.header_row} 行 · ${result.transactions} 条明细 · ${result.warnings.length ? `${result.warnings.length} 项提示` : "校验通过"}${duplicated ? " · 重复上传,复用已有批次" : ""}`
: `${result.message} 系统不会猜测模板或自动入账。`; : `${result.message} 系统不会猜测模板或自动入账。`;
panel.hidden = false; panel.hidden = false;
$("#parseButton span").textContent = parsed ? "确认导入" : "提交异常"; $("#parseButton span").textContent = parsed ? "确认导入" : "提交异常";