From 7f1a93f6a644470cb6bc677e1fe216c19f87d35e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=85=BE=E8=AE=AFWorkBuddy?= Date: Sun, 16 Aug 2026 01:50:42 +0800 Subject: [PATCH] =?UTF-8?q?B-64:=20=E5=AE=89=E5=85=A8=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E2=80=94=E2=80=94=E9=9A=8F=E6=9C=BA=E5=88=9D=E5=A7=8B=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E4=B8=8E=E8=B7=A8=E5=85=AC=E5=8F=B8=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 公司账号创建改为随机一次性初始密码,只在创建响应中显示一次, 密码保证不等于用户名;删除「用户名即初始密码」兼容分支,继续 强制首次登录改密。 - 跨公司相同字节文件上传只返回通用重复状态:不再返回其他公司的 原批次 ID、银行、模板、期间、交易数或诊断;同公司重复上传的 幂等摘要保持可用。 - 补充服务端回归测试,覆盖同公司与跨公司两个分支及随机密码; 完整测试 85 项全绿,node --check 通过。 --- README.md | 6 ++- docs/decisions/003-auth.md | 13 +++---- server.py | 71 +++++++++++++++++++--------------- src/bank_importer/auth.py | 10 ++++- src/bank_importer/importing.py | 19 +++++++-- tests/test_auth.py | 5 +++ tests/test_master_data.py | 10 +++-- tests/test_persistence.py | 40 ++++++++++++++++++- tests/test_server_auth.py | 30 ++++++++++++-- web/admin.html | 2 +- web/app.js | 7 +++- 11 files changed, 155 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index c45cf59..ac40353 100644 --- a/README.md +++ b/README.md @@ -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` 覆盖): diff --git a/docs/decisions/003-auth.md b/docs/decisions/003-auth.md index 812e332..f62df1b 100644 --- a/docs/decisions/003-auth.md +++ b/docs/decisions/003-auth.md @@ -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 diff --git a/server.py b/server.py index 883d2a4..fa3b48e 100644 --- a/server.py +++ b/server.py @@ -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]]: diff --git a/src/bank_importer/auth.py b/src/bank_importer/auth.py index da5941d..37445ec 100644 --- a/src/bank_importer/auth.py +++ b/src/bank_importer/auth.py @@ -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 diff --git a/src/bank_importer/importing.py b/src/bank_importer/importing.py index e98b3ca..88a8589 100644 --- a/src/bank_importer/importing.py +++ b/src/bank_importer/importing.py @@ -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, ) diff --git a/tests/test_auth.py b/tests/test_auth.py index c70823d..74188b7 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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: diff --git a/tests/test_master_data.py b/tests/test_master_data.py index 1ea5b55..bf41a0d 100644 --- a/tests/test_master_data.py +++ b/tests/test_master_data.py @@ -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: diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 227c195..7cd60f4 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -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( diff --git a/tests/test_server_auth.py b/tests/test_server_auth.py index d06cca1..a0c9200 100644 --- a/tests/test_server_auth.py +++ b/tests/test_server_auth.py @@ -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 # ------------------------------------------------------------------ diff --git a/web/admin.html b/web/admin.html index 97a423c..8e95f5d 100644 --- a/web/admin.html +++ b/web/admin.html @@ -205,7 +205,7 @@

新增公司

公司主档与公司账号可一次创建

-

初始密码与登录账号相同,首次登录必须修改。

+

创建后生成随机初始密码,仅显示一次,首次登录必须修改。

diff --git a/web/app.js b/web/app.js index 92b9a21..a265b18 100644 --- a/web/app.js +++ b/web/app.js @@ -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 ? "确认导入" : "提交异常";