修复当前页密码网关分片读取 (#130)

This commit is contained in:
WuYongJie 2026-09-08 18:25:37 +08:00
parent d14c8acb82
commit e1d62f0d79
4 changed files with 105 additions and 4 deletions

View File

@ -74,10 +74,45 @@ class StoreAccountPasswordClient
|| collect($accountIds)->contains(fn ($id) => ! is_string($id) || ! preg_match('/^[A-Za-z0-9_-]{1,128}$/D', $id))) {
return ['status' => 503];
}
$requesterId = 'finance-user-'.$actorId;
$chunks = [];
$chunk = [];
foreach ($accountIds as $accountId) {
$candidate = [...$chunk, $accountId];
$payload = ['requesterId' => $requesterId, 'purpose' => 'view', 'accountIds' => $candidate];
if ($chunk !== [] && (count($candidate) > 25 || strlen((string) json_encode($payload)) > 1000)) {
$chunks[] = $chunk;
$chunk = [$accountId];
} else {
$chunk = $candidate;
}
}
$chunks[] = $chunk;
$items = [];
foreach ($chunks as $accountIdChunk) {
$result = $this->revealVisiblePageChunk($url, $token, $requesterId, $accountIdChunk);
if (($result['status'] ?? 503) !== 200) {
foreach ($items as &$item) {
unset($item['password']);
}
unset($item, $items);
return ['status' => $result['status'] ?? 503];
}
$items += $result['items'];
}
return ['status' => 200, 'items' => $items];
}
private function revealVisiblePageChunk(string $url, string $token, string $requesterId, array $accountIds): array
{
try {
$response = $this->http->request('POST', $url, [
'headers' => ['Authorization' => 'Bearer '.$token, 'Accept' => 'application/json'],
'json' => ['requesterId' => 'finance-user-'.$actorId, 'purpose' => 'view', 'accountIds' => $accountIds],
'json' => ['requesterId' => $requesterId, 'purpose' => 'view', 'accountIds' => $accountIds],
'verify' => true, 'allow_redirects' => false, 'http_errors' => false,
'connect_timeout' => 3, 'timeout' => 10,
'sink' => fopen('php://memory', 'w+'),

View File

@ -13,9 +13,9 @@
- 当前页批量路由:`POST /{company_id}/store-account-passwords/visible-page/reveal`,请求体仅允许 `accounts` 和框架 CSRF 字段;账号 ID 必须是 1 至 50 个互不重复的合法字符串。
- 单账号复制路由:`POST /{company_id}/store-account-passwords/{account}/reveal`,请求体仅允许 `purpose=view|copy` 和框架 CSRF 字段。前端复制使用 `purpose=copy`。
- 财务后端从可信会话生成 `requesterId=finance-user-{id}`,不接受浏览器提供操作者身份。每次请求重新计算页面权限并验证镜像账号。
- 上游批量路由:`POST https://cp.jiansun.vip/internal/store-accounts/visible-page/password/reveal`;单账号路由保持原契约。两者使用同一专用 Bearer 凭据。
- 上游批量路由:`POST https://cp.jiansun.vip/internal/store-accounts/visible-page/password/reveal`;单账号路由保持原契约。两者使用同一专用 Bearer 凭据。财务后端把当前页请求拆成最多 25 个账号、JSON 请求体不超过 1000 字节的分片,常规 50 条页面会顺序发出两个上游请求,以适配公网入口的 1 KiB 请求体限制。
- 批量响应按账号返回独立的 `200/404/429/503` 状态和追踪 ID。任何协议错误、缺少 `no-store`、无效追踪 ID、重复或遗漏账号都会使本批次在财务端按失败处理,密码不会返回浏览器。
- 财务端每用户每分钟最多自动批量读取 5 次,单账号复制仍为每分钟 10 次。上游另有持久化的服务、人员、批次和账号限流。
- 财务端每用户每分钟最多自动读取当前页 5 次,单账号复制仍为每分钟 10 次。上游分片批次限制为每服务每分钟 20 次、每天 1000 次,每人员每分钟 10 次;每账号每分钟 5 次的限制保持不变。
- `store_account_password_audits` 为当前页每个账号写入一条 `view` 审计,复制写入 `copy` 审计。开始审计失败时不调用上游;完成审计无法落库时不向浏览器返回密码。审计只保存操作者、公司、账号 ID、用途、状态、追踪 ID和时间。
## 密码处理

View File

@ -69,7 +69,7 @@
retry.hidden = true;
retry.disabled = true;
page.setAttribute('aria-busy', 'true');
const timeout = setTimeout(() => controller.abort(), 12000);
const timeout = setTimeout(() => controller.abort(), 25000);
try {
const response = await fetch(page.dataset.passwordPageUrl, {

View File

@ -148,6 +148,72 @@ class StoreAccountPasswordsTest extends FeatureTestCase
], $sent);
}
public function test_fifty_visible_accounts_are_sent_as_two_gateway_safe_chunks(): void
{
$accountIds = array_map(fn ($index) => sprintf('fixture-account-%02d', $index), range(1, 50));
$responses = [];
foreach (array_chunk($accountIds, 25) as $batchIndex => $batch) {
$items = [];
foreach ($batch as $index => $accountId) {
$items[] = [
'accountId' => $accountId,
'status' => 200,
'requestId' => sprintf('12345678-1234-4123-8123-%012d', ($batchIndex * 25) + $index + 1),
'password' => $this->fixture,
];
}
$responses[] = new Response(200, ['Cache-Control' => 'private, no-store'], json_encode([
'requestId' => sprintf('22345678-1234-4123-8123-%012d', $batchIndex + 1),
'items' => $items,
]));
}
$handler = HandlerStack::create(new MockHandler($responses));
$handler->push(Middleware::history($this->history));
$this->app->instance(StoreAccountPasswordClient::class, new StoreAccountPasswordClient(new Client(['handler' => $handler])));
$response = $this->loginAs()->postJson($this->visiblePageEndpoint(), ['accounts' => $accountIds])
->assertOk()->assertJsonCount(50, 'items');
$this->assertCount(2, $this->history);
foreach ($this->history as $index => $transaction) {
$body = (string) $transaction['request']->getBody();
$sent = json_decode($body, true);
$this->assertSame(array_slice($accountIds, $index * 25, 25), $sent['accountIds']);
$this->assertLessThanOrEqual(1024, strlen($body));
}
$this->assertSame(50, DB::table('store_account_password_audits')->count());
$this->assertSame(50, DB::table('store_account_password_audits')->where('status', 200)->count());
$this->assertStringNotContainsString($this->fixture, json_encode(DB::table('store_account_password_audits')->get()));
$this->assertSame($this->fixture, $response->json('items.49.password'));
}
public function test_later_chunk_failure_suppresses_passwords_from_earlier_chunks(): void
{
$accountIds = array_map(fn ($index) => sprintf('fixture-account-%02d', $index), range(1, 50));
$items = array_map(fn ($accountId, $index) => [
'accountId' => $accountId,
'status' => 200,
'requestId' => sprintf('12345678-1234-4123-8123-%012d', $index + 1),
'password' => $this->fixture,
], array_slice($accountIds, 0, 25), range(0, 24));
$handler = HandlerStack::create(new MockHandler([
new Response(200, ['Cache-Control' => 'private, no-store'], json_encode([
'requestId' => '22345678-1234-4123-8123-123456789001',
'items' => $items,
])),
new Response(403, ['Cache-Control' => 'no-store'], json_encode(['message' => $this->fixture])),
]));
$handler->push(Middleware::history($this->history));
$this->app->instance(StoreAccountPasswordClient::class, new StoreAccountPasswordClient(new Client(['handler' => $handler])));
$response = $this->loginAs()->postJson($this->visiblePageEndpoint(), ['accounts' => $accountIds])
->assertStatus(503)->assertJsonMissingPath('items')->assertJsonMissingPath('password');
$this->assertCount(2, $this->history);
$this->assertStringNotContainsString($this->fixture, $response->getContent());
$this->assertSame(50, DB::table('store_account_password_audits')->where('status', 503)->count());
}
public function test_visible_page_rejects_duplicates_and_more_than_fifty_accounts(): void
{
$this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('revealVisiblePage');