B-64: 安全修复——随机初始密码与跨公司重复上传隔离
- 公司账号创建改为随机一次性初始密码,只在创建响应中显示一次, 密码保证不等于用户名;删除「用户名即初始密码」兼容分支,继续 强制首次登录改密。 - 跨公司相同字节文件上传只返回通用重复状态:不再返回其他公司的 原批次 ID、银行、模板、期间、交易数或诊断;同公司重复上传的 幂等摘要保持可用。 - 补充服务端回归测试,覆盖同公司与跨公司两个分支及随机密码; 完整测试 85 项全绿,node --check 通过。
This commit is contained in:
@@ -27,6 +27,8 @@ python server.py
|
||||
|
||||
数据库与原始文件默认保存在 `data/`(已加入 `.gitignore`)。上传文件按
|
||||
SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并复用已有批次。
|
||||
同一公司重复上传返回原批次摘要;不同公司相同内容只返回通用重复状态,
|
||||
不暴露其他公司的批次标识或摘要。
|
||||
技术决策见 `docs/decisions/002-persistence.md`。
|
||||
|
||||
## 登录与账号
|
||||
@@ -40,8 +42,8 @@ SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并
|
||||
- 引导管理员及管理员创建的公司账号首次登录都必须修改密码。
|
||||
- 公司、公司账号由管理员在总账端「公司与账号 → 新增公司」或
|
||||
`/api/admin/companies`、`/api/admin/users` 动态创建;
|
||||
公司账号的初始密码与登录账号相同,首次登录强制改密
|
||||
(「重置密码」仍生成随机一次性密码)。
|
||||
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
|
||||
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
|
||||
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
|
||||
|
||||
访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
|
||||
|
||||
@@ -33,14 +33,11 @@
|
||||
公司账号必须绑定公司、管理员不得绑定公司。
|
||||
- 登录时前端选择工作端口(portal),服务端校验 portal 与角色一致,
|
||||
不匹配返回 403「账号与该工作端口不匹配」。
|
||||
- 管理员创建公司账号时,初始密码等于登录账号本身并置
|
||||
`must_change_password=1`(2026-08-08 产品决定,替代随机初始密码);
|
||||
首次登录必须改密,改密前所有业务 API 返回 403。初始密码未改前
|
||||
等同账号名,因此创建用户的审计 detail 只记录公司,不重复账号名。
|
||||
- 「重置密码」仍生成随机一次性密码:重置发生在用户已改密之后,
|
||||
可预测的口令会让知道账号名的人直接接管账户。
|
||||
- 已知风险:初始密码可预测,账号创建后应尽快完成首登改密;
|
||||
创建到改密之间,知道账号名的人即可登录该账号。
|
||||
- 管理员创建公司账号时,初始密码为随机一次性密码并置
|
||||
`must_change_password=1`;首次登录必须改密,改密前所有业务 API 返回 403。
|
||||
初始密码只在创建成功响应中显示一次,永不入库明文、永不写日志;
|
||||
创建用户的审计 detail 只记录公司,不重复账号名。
|
||||
- 「重置密码」同样生成随机一次性密码并吊销既有会话。
|
||||
|
||||
## 租户隔离在服务端强制,404 优于 403
|
||||
|
||||
|
||||
@@ -633,11 +633,13 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if username:
|
||||
# Optionally create the company login in the same request, so
|
||||
# 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.
|
||||
initial_password = auth.generate_initial_password(exclude=username)
|
||||
try:
|
||||
user_id = auth.create_user(
|
||||
connection, username, username, "company",
|
||||
connection, username, initial_password, "company",
|
||||
company_id=company_id, must_change_password=True,
|
||||
)
|
||||
except ValueError as exc:
|
||||
@@ -654,7 +656,7 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
)
|
||||
payload.update(
|
||||
{"user_id": user_id, "username": username,
|
||||
"initial_password": username}
|
||||
"initial_password": initial_password}
|
||||
)
|
||||
self._send_json(200, payload)
|
||||
finally:
|
||||
@@ -965,10 +967,11 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
except (TypeError, ValueError):
|
||||
self._send_json(400, {"status": "error", "message": "必须指定有效的 company_id。"})
|
||||
return
|
||||
# Product decision (B-40, 2026-08-08): the initial password equals
|
||||
# the username, and must_change_password forces a change at first
|
||||
# login. Password reset keeps a random one-time password instead.
|
||||
initial_password = username
|
||||
# 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. Password
|
||||
# reset keeps a random one-time password instead.
|
||||
initial_password = auth.generate_initial_password(exclude=username)
|
||||
try:
|
||||
user_id = auth.create_user(
|
||||
connection,
|
||||
@@ -981,8 +984,8 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
# While the initial password is unchanged it equals the username,
|
||||
# so the username itself must stay out of audit details.
|
||||
# The random one-time password is never written to audit detail;
|
||||
# only the company binding is recorded.
|
||||
auth.audit(
|
||||
connection,
|
||||
"user_create",
|
||||
@@ -1142,28 +1145,34 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
}
|
||||
)
|
||||
elif result.status == "duplicate":
|
||||
sheet = connection.execute(
|
||||
"""
|
||||
SELECT bank_name, template_id, header_row, period_start, period_end,
|
||||
transaction_count, warnings
|
||||
FROM sheet_batches
|
||||
WHERE import_batch_id = ?
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(result.batch_id,),
|
||||
).fetchone()
|
||||
if sheet is not None:
|
||||
payload.update(
|
||||
{
|
||||
"bank": sheet["bank_name"],
|
||||
"template": sheet["template_id"],
|
||||
"header_row": sheet["header_row"],
|
||||
"period_start": sheet["period_start"],
|
||||
"period_end": sheet["period_end"],
|
||||
"transactions": sheet["transaction_count"],
|
||||
"warnings": json.loads(sheet["warnings"]),
|
||||
}
|
||||
)
|
||||
# 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(
|
||||
"""
|
||||
SELECT bank_name, template_id, header_row, period_start,
|
||||
period_end, transaction_count, warnings
|
||||
FROM sheet_batches
|
||||
WHERE import_batch_id = ?
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(result.batch_id,),
|
||||
).fetchone()
|
||||
if sheet is not None:
|
||||
payload.update(
|
||||
{
|
||||
"bank": sheet["bank_name"],
|
||||
"template": sheet["template_id"],
|
||||
"header_row": sheet["header_row"],
|
||||
"period_start": sheet["period_start"],
|
||||
"period_end": sheet["period_end"],
|
||||
"transactions": sheet["transaction_count"],
|
||||
"warnings": json.loads(sheet["warnings"]),
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
def _read_upload(self) -> tuple[str, bytes, dict[str, str]]:
|
||||
|
||||
@@ -53,8 +53,13 @@ def verify_password(password: str, stored: str) -> bool:
|
||||
return hmac.compare_digest(digest, expected)
|
||||
|
||||
|
||||
def generate_initial_password() -> str:
|
||||
"""Generate a 12-char initial password with upper, lower and digit chars."""
|
||||
def generate_initial_password(exclude: str | None = None) -> str:
|
||||
"""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
|
||||
while True:
|
||||
password = "".join(
|
||||
@@ -64,6 +69,7 @@ def generate_initial_password() -> str:
|
||||
any(char.isupper() for char in password)
|
||||
and any(char.islower() for char in password)
|
||||
and any(char.isdigit() for char in password)
|
||||
and (exclude is None or password.lower() != exclude.lower())
|
||||
):
|
||||
return password
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class ImportResult:
|
||||
source_file_id: int
|
||||
batches: tuple[StatementBatch, ...] = ()
|
||||
message: str | None = None
|
||||
duplicate_same_company: bool = False # only meaningful when status == 'duplicate'
|
||||
|
||||
|
||||
def import_statement(
|
||||
@@ -96,7 +97,7 @@ def _record_duplicate(
|
||||
) -> ImportResult:
|
||||
original = connection.execute(
|
||||
"""
|
||||
SELECT id FROM import_batches
|
||||
SELECT id, company_id FROM import_batches
|
||||
WHERE source_file_id = ? AND status = 'parsed'
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
@@ -105,7 +106,7 @@ def _record_duplicate(
|
||||
if original is None:
|
||||
original = connection.execute(
|
||||
"""
|
||||
SELECT id FROM import_batches
|
||||
SELECT id, company_id FROM import_batches
|
||||
WHERE source_file_id = ? AND status != 'duplicate'
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
@@ -113,7 +114,7 @@ def _record_duplicate(
|
||||
).fetchone()
|
||||
now = utc_now()
|
||||
with connection:
|
||||
connection.execute(
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches
|
||||
(source_file_id, status, duplicate_of_id, company_id, diagnostics, created_at, updated_at)
|
||||
@@ -128,12 +129,22 @@ def _record_duplicate(
|
||||
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(
|
||||
original["id"],
|
||||
batch_id,
|
||||
"duplicate",
|
||||
sha256,
|
||||
source_file_id,
|
||||
message="相同内容的文件已导入,本次按重复上传处理。",
|
||||
duplicate_same_company=same_company,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -71,6 +71,11 @@ class PasswordPolicyTests(unittest.TestCase):
|
||||
self.assertTrue(any(char.isdigit() for char in password))
|
||||
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):
|
||||
def test_company_role_requires_company(self) -> None:
|
||||
|
||||
@@ -263,7 +263,9 @@ class MasterDataApiTests(unittest.TestCase):
|
||||
assert status == 200, data
|
||||
payload = as_json(data)
|
||||
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": "乙公司"})
|
||||
assert status == 200, data
|
||||
@@ -272,9 +274,11 @@ class MasterDataApiTests(unittest.TestCase):
|
||||
"/api/admin/users", {"username": "cashier-b", "company_id": cls.company_b}
|
||||
)
|
||||
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_b = cls._login_company_user("cashier-b", "cashier-b")
|
||||
cls.cashier_a = cls._login_company_user("cashier-a", cls.initial_a)
|
||||
cls.cashier_b = cls._login_company_user("cashier-b", cls.initial_b)
|
||||
|
||||
@classmethod
|
||||
def _login_company_user(cls, username: str, initial: str) -> Client:
|
||||
|
||||
@@ -9,7 +9,7 @@ import unittest
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ class IdempotencyTests(PersistenceTestCase):
|
||||
self.assertEqual("parsed", first.status)
|
||||
self.assertEqual("duplicate", second.status)
|
||||
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()
|
||||
self.assertEqual(1, files["n"])
|
||||
@@ -188,6 +189,43 @@ class IdempotencyTests(PersistenceTestCase):
|
||||
).fetchone()
|
||||
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:
|
||||
first = self.import_sample()
|
||||
second = import_statement(
|
||||
|
||||
@@ -169,14 +169,15 @@ class ServerAuthMatrixTests(unittest.TestCase):
|
||||
assert status == 200, data
|
||||
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(
|
||||
"/api/admin/users", {"username": "cashier-a", "company_id": cls.company_a}
|
||||
)
|
||||
assert status == 200, data
|
||||
payload = as_json(data)
|
||||
# Product decision: registration initial password equals the username.
|
||||
assert payload["initial_password"] == "cashier-a"
|
||||
# Security: the initial password is random and never equals the username.
|
||||
assert payload["initial_password"] != "cashier-a"
|
||||
assert len(payload["initial_password"]) >= 12
|
||||
cls.initial_password_a = payload["initial_password"]
|
||||
cls.known_passwords.add(cls.initial_password_a)
|
||||
|
||||
@@ -431,7 +432,13 @@ class ServerAuthMatrixTests(unittest.TestCase):
|
||||
CCB_SAMPLE.read_bytes(),
|
||||
)
|
||||
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)
|
||||
try:
|
||||
duplicate = connection.execute(
|
||||
@@ -442,6 +449,21 @@ class ServerAuthMatrixTests(unittest.TestCase):
|
||||
self.assertIsNotNone(duplicate)
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
+1
-1
@@ -205,7 +205,7 @@
|
||||
<dialog id="companyDialog" class="dialog">
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
+5
-2
@@ -1006,7 +1006,7 @@ function initAdmin() {
|
||||
await loadAdminCompanies();
|
||||
showToast(
|
||||
accountCreated ? "公司与账号已创建" : "公司已创建",
|
||||
accountCreated ? `账号 ${result.username} 的初始密码与登录账号相同,首次登录必须修改` : "可稍后在账号管理中创建公司账号",
|
||||
accountCreated ? `账号 ${result.username} 的初始密码已生成(仅此一次显示):${result.initial_password},首次登录必须修改` : "可稍后在账号管理中创建公司账号",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1204,12 +1204,15 @@ async function parseFile() {
|
||||
}
|
||||
state.parseResult = result;
|
||||
const duplicated = result.status === "duplicate";
|
||||
const opaqueDuplicate = duplicated && !result.bank;
|
||||
const panel = $("#parseResult");
|
||||
panel.classList.toggle("is-exception", !parsed);
|
||||
$("use", panel).setAttribute("href", parsed ? "icons.svg#circle-check" : "icons.svg#circle-alert");
|
||||
$("strong", panel).textContent = duplicated ? "文件已导入过" : 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} 系统不会猜测模板或自动入账。`;
|
||||
panel.hidden = false;
|
||||
$("#parseButton span").textContent = parsed ? "确认导入" : "提交异常";
|
||||
|
||||
Reference in New Issue
Block a user