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
+40 -31
View File
@@ -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]]: