From 1f8869d7e7a94e3fcef76f62f1b957fea75ea917 Mon Sep 17 00:00:00 2001
From: WuYongJie <549727955@qq.com>
Date: Tue, 8 Sep 2026 06:52:45 +0800
Subject: [PATCH 1/2] feat: add administrator store password reveal and copy
---
.../Business/StoreAccountPasswords.php | 81 +++++++++
app/Http/Middleware/AddXHeader.php | 3 +-
app/Services/StoreAccountPasswordClient.php | 64 +++++++
config/business_navigation.php | 2 +-
config/store_account_password.php | 6 +
...0_create_store_account_password_audits.php | 28 +++
.../StoreAccountMirrorRepository.php | 13 +-
.../pages/partials/directory-table.blade.php | 20 ++-
.../partials/store-account-password.blade.php | 95 ++++++++++
routes/admin.php | 3 +
.../Business/StoreAccountPasswordsTest.php | 164 ++++++++++++++++++
11 files changed, 473 insertions(+), 6 deletions(-)
create mode 100644 app/Http/Controllers/Business/StoreAccountPasswords.php
create mode 100644 app/Services/StoreAccountPasswordClient.php
create mode 100644 config/store_account_password.php
create mode 100644 database/migrations/2026_09_08_080000_create_store_account_password_audits.php
create mode 100644 resources/views/business/pages/partials/store-account-password.blade.php
create mode 100644 tests/Feature/Business/StoreAccountPasswordsTest.php
diff --git a/app/Http/Controllers/Business/StoreAccountPasswords.php b/app/Http/Controllers/Business/StoreAccountPasswords.php
new file mode 100644
index 00000000..2726ffeb
--- /dev/null
+++ b/app/Http/Controllers/Business/StoreAccountPasswords.php
@@ -0,0 +1,81 @@
+input('purpose');
+ if (! preg_match('/^[A-Za-z0-9_-]{1,128}$/D', $account)
+ || ! in_array($purpose, ['view', 'copy'], true)
+ || array_diff(array_keys($request->all()), ['purpose', '_token']) !== [] || $request->query() !== []) {
+ return $this->respond(422, $requestId);
+ }
+ try {
+ DB::table('store_account_password_audits')->insert([
+ 'request_id' => $requestId, 'actor_user_id' => $request->user()->id,
+ 'company_id' => company_id(), 'store_account_id' => $account,
+ 'purpose' => $purpose, 'created_at' => now(),
+ ]);
+ } catch (Throwable) {
+ return $this->respond(503, $requestId);
+ }
+
+ $result = ['status' => 503];
+ try {
+ $pages = collect(SidebarPermissions::navigationFor($request->user()))->firstWhere('slug', 'store-management');
+ // Re-read role membership for every secret request; a cached admin
+ // role must not survive a permission revocation.
+ if (! $request->user()->roles()->whereIn('roles.name', [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN])->exists()
+ || ! collect($pages['pages'] ?? [])->contains('slug', 'store-accounts')) {
+ $result = ['status' => 403];
+ } elseif (RateLimiter::tooManyAttempts('store-password:'.$request->user()->id, 10)) {
+ $result = ['status' => 429];
+ } else {
+ RateLimiter::hit('store-password:'.$request->user()->id, 60);
+ $result = $accounts->containsAccount($account)
+ ? $client->reveal($account, $request->user()->id, $purpose)
+ : ['status' => 404];
+ }
+ } catch (Throwable) {
+ $result = ['status' => 503];
+ }
+ try {
+ $saved = DB::table('store_account_password_audits')->where('request_id', $requestId)->update([
+ 'status' => $result['status'], 'upstream_request_id' => $result['upstream_request_id'] ?? null,
+ 'completed_at' => now(),
+ ]);
+ if ($saved !== 1) {
+ return $this->respond(503, $requestId);
+ }
+ } catch (Throwable) {
+ return $this->respond(503, $requestId);
+ }
+ return $this->respond($result['status'], $requestId, $result['password'] ?? null);
+ }
+
+ private function respond(int $status, string $requestId, ?string $password = null)
+ {
+ $messages = [403 => '当前账号没有查看店铺密码的权限。', 404 => '该账号暂无可读取的密码。',
+ 422 => '请求无效,请刷新页面后重试。', 429 => '操作过于频繁,请稍后再试。',
+ 503 => '密码服务暂不可用,请稍后再试或联系管理员。'];
+ return response()->json($status === 200
+ ? ['password' => $password, 'requestId' => $requestId]
+ : ['message' => $messages[$status] ?? $messages[503], 'requestId' => $requestId], $status)
+ ->withHeaders(['Cache-Control' => 'no-store, private', 'Pragma' => 'no-cache',
+ 'X-Content-Type-Options' => 'nosniff', 'Referrer-Policy' => 'no-referrer']);
+ }
+}
diff --git a/app/Http/Middleware/AddXHeader.php b/app/Http/Middleware/AddXHeader.php
index 461e1884..aac4d5f0 100644
--- a/app/Http/Middleware/AddXHeader.php
+++ b/app/Http/Middleware/AddXHeader.php
@@ -27,8 +27,9 @@ class AddXHeader
}
$response->header('X-XSS-Protection', '0');
$response->header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
- if ($request->routeIs('login.dingtalk', 'login.dingtalk.callback')) {
+ if ($request->routeIs('login.dingtalk', 'login.dingtalk.callback', 'business.store-account-passwords.reveal')) {
$response->header('Cache-Control', 'no-store');
+ $response->header('Pragma', 'no-cache');
$response->header('Referrer-Policy', 'no-referrer');
}
}
diff --git a/app/Services/StoreAccountPasswordClient.php b/app/Services/StoreAccountPasswordClient.php
new file mode 100644
index 00000000..3f40932e
--- /dev/null
+++ b/app/Services/StoreAccountPasswordClient.php
@@ -0,0 +1,64 @@
+http = $http ?? new Client();
+ }
+
+ public function reveal(string $accountId, int $actorId, string $purpose): array
+ {
+ $template = (string) config('store_account_password.url');
+ $token = (string) config('store_account_password.token');
+ if (substr_count($template, '{id}') !== 1 || parse_url($template, PHP_URL_SCHEME) !== 'https'
+ || ! preg_match('/^[A-Za-z0-9_-]{43,128}$/D', $token)) {
+ return ['status' => 503];
+ }
+ try {
+ $response = $this->http->request('POST', str_replace('{id}', rawurlencode($accountId), $template), [
+ 'headers' => ['Authorization' => 'Bearer '.$token, 'Accept' => 'application/json'],
+ 'json' => ['requesterId' => 'finance-user-'.$actorId, 'purpose' => $purpose],
+ 'verify' => true, 'allow_redirects' => false, 'http_errors' => false,
+ 'connect_timeout' => 3, 'timeout' => 10,
+ 'sink' => fopen('php://memory', 'w+'),
+ ]);
+ $status = $response->getStatusCode();
+ if ($response->getBody()->isSeekable()) {
+ $response->getBody()->rewind();
+ }
+ $body = $response->getBody()->read(65537);
+ $response->getBody()->close();
+ if (strlen($body) > 65536) {
+ return ['status' => 503];
+ }
+ $data = json_decode($body, true);
+ unset($body);
+ $requestId = $data['requestId'] ?? null;
+ if (! is_string($requestId) || ! preg_match('/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/iD', $requestId)) {
+ $requestId = null;
+ }
+ if ($status !== 200) {
+ return ['status' => in_array($status, [404, 429], true) ? $status : 503, 'upstream_request_id' => $requestId];
+ }
+ if (! $requestId || ! preg_match('/(?:^|,)\s*no-store\s*(?:,|$)/i', $response->getHeaderLine('Cache-Control'))
+ || ! is_string($data['password'] ?? null) || $data['password'] === '') {
+ return ['status' => 503, 'upstream_request_id' => $requestId];
+ }
+ return ['status' => 200, 'password' => $data['password'], 'upstream_request_id' => $requestId];
+ } catch (Throwable) {
+ // Never log exception objects: transport errors may contain response bodies.
+ return ['status' => 503];
+ }
+ }
+}
diff --git a/config/business_navigation.php b/config/business_navigation.php
index 2fd1b400..5663fc9a 100644
--- a/config/business_navigation.php
+++ b/config/business_navigation.php
@@ -243,7 +243,7 @@ return [
'directory' => [
'icon' => 'key',
'status' => '共享镜像',
- 'description' => '读取店铺账号系统的共享业务镜像;密码不返回明文,仅展示已允许读取的账号资料。',
+ 'description' => '账号资料来自共享镜像。管理员可按需查看或复制密码,密码不会随表格导出。',
'empty' => '共享镜像中暂未读取到店铺账号数据,请检查账号数据同步状态。',
'fields' => [
['name' => 'store_name', 'label' => '店铺名称'],
diff --git a/config/store_account_password.php b/config/store_account_password.php
new file mode 100644
index 00000000..22510491
--- /dev/null
+++ b/config/store_account_password.php
@@ -0,0 +1,6 @@
+ env('STORE_ACCOUNT_REVEAL_URL', 'https://cp.jiansun.vip/internal/store-accounts/{id}/password/reveal'),
+ 'token' => env('STORE_ACCOUNT_REVEAL_TOKEN'),
+];
diff --git a/database/migrations/2026_09_08_080000_create_store_account_password_audits.php b/database/migrations/2026_09_08_080000_create_store_account_password_audits.php
new file mode 100644
index 00000000..686b0bac
--- /dev/null
+++ b/database/migrations/2026_09_08_080000_create_store_account_password_audits.php
@@ -0,0 +1,28 @@
+uuid('request_id')->primary();
+ $table->unsignedInteger('actor_user_id')->nullable()->index();
+ $table->unsignedInteger('company_id');
+ $table->string('store_account_id', 128);
+ $table->string('purpose', 8);
+ $table->unsignedSmallInteger('status')->nullable();
+ $table->uuid('upstream_request_id')->nullable();
+ $table->timestamp('created_at')->useCurrent()->index();
+ $table->timestamp('completed_at')->nullable();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('store_account_password_audits');
+ }
+};
diff --git a/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php
index 1c4ba15c..7ec925fc 100644
--- a/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php
+++ b/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php
@@ -39,8 +39,8 @@ class StoreAccountMirrorRepository
'profit_center' => $row->profitCenter,
'account_type' => $this->accountType($row->accountType),
'account' => $row->account,
- // 上游只读视图明确不返回密码密文,列表只能展示未知配置状态。
- 'password_configured' => null,
+ // Only the safe boolean may enter the mirror, never a password.
+ 'password_configured' => isset($row->passwordConfigured) ? (bool) $row->passwordConfigured : null,
'bound_phone' => $row->boundPhone,
'verification_contact' => $row->verificationContact,
'account_group_name' => $row->accountGroupName,
@@ -52,6 +52,15 @@ class StoreAccountMirrorRepository
return ['data' => $rows, 'total' => $total];
}
+ public function containsAccount(string $id): bool
+ {
+ $dataset = $this->datasets->get(self::DATASET);
+ if (! $this->mirror->ready($dataset)) {
+ throw new RuntimeException('store_account_mirror_not_ready');
+ }
+ return $this->mirror->connection()->table($dataset->targetTable())->where('id', $id)->exists();
+ }
+
/**
* 店铺维度报表的归属字段覆盖表,以店铺名称(storeName)为键。
*
diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php
index 2b2c70ea..73c7719a 100644
--- a/resources/views/business/pages/partials/directory-table.blade.php
+++ b/resources/views/business/pages/partials/directory-table.blade.php
@@ -492,7 +492,19 @@
$display = in_array($value, [true, 1, '1', '是', '✔'], true) ? '是' : '否';
}
@endphp
-
{{ filled($display) ? $display : '—' }} |
+
+ @if (($page['slug'] ?? '') === 'store-accounts' && $field['name'] === 'password_configured')
+ {{ filled($display) ? $display : '—' }}
+ @if (\App\Utilities\SystemAccess::canOpenSettings() && $value !== false)
+
+
+
+
+ @endif
+ @else
+ {{ filled($display) ? $display : '—' }}
+ @endif
+ |
@endforeach
@empty
@@ -542,6 +554,10 @@
@endif
+@if (($page['slug'] ?? '') === 'store-accounts' && \App\Utilities\SystemAccess::canOpenSettings())
+ @include('business.pages.partials.store-account-password')
+@endif
+
@once
+@endonce
diff --git a/routes/admin.php b/routes/admin.php
index 41583ed3..822423c1 100644
--- a/routes/admin.php
+++ b/routes/admin.php
@@ -11,6 +11,9 @@ use Modules\EcommerceFinance\Http\Controllers\AlipayBills;
use Modules\EcommerceFinance\Http\Controllers\Api\GoodsReconciliations;
use Modules\EcommerceFinance\Http\Controllers\Api\SupplierPayables;
+Route::post('store-account-passwords/{account}/reveal', 'Business\StoreAccountPasswords@reveal')
+ ->name('business.store-account-passwords.reveal');
+
Route::get('ecommerce-finance/alipay-bills', [AlipayBills::class, 'index'])
->name('ecommerce-finance.alipay-bills.index');
Route::get('ecommerce-finance/alipay-bills/{bill}', [AlipayBills::class, 'show'])
diff --git a/tests/Feature/Business/StoreAccountPasswordsTest.php b/tests/Feature/Business/StoreAccountPasswordsTest.php
new file mode 100644
index 00000000..d91948ab
--- /dev/null
+++ b/tests/Feature/Business/StoreAccountPasswordsTest.php
@@ -0,0 +1,164 @@
+ str_repeat('t', 43), 'store_account_password.url' => 'https://upstream.example.test/internal/store-accounts/{id}/password/reveal']);
+ $this->mock(StoreAccountMirrorRepository::class)->shouldReceive('containsAccount')->with('fixture-account')->andReturnTrue()->byDefault();
+ }
+
+ private function transport(int $status = 200, ?array $body = null, array $headers = ['Cache-Control' => 'no-store']): void
+ {
+ $handler = HandlerStack::create(new MockHandler([new Response($status, $headers, json_encode($body ?? [
+ 'requestId' => '12345678-1234-4123-8123-123456789012', 'password' => $this->fixture,
+ ]))]));
+ $handler->push(Middleware::history($this->history));
+ $this->app->instance(StoreAccountPasswordClient::class, new StoreAccountPasswordClient(new Client(['handler' => $handler])));
+ }
+
+ private function endpoint(): string
+ {
+ return route('business.store-account-passwords.reveal', ['account' => 'fixture-account']);
+ }
+
+ public function test_admin_view_uses_trusted_actor_and_persists_password_free_audit(): void
+ {
+ $this->transport();
+ $response = $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertOk()
+ ->assertJsonPath('password', $this->fixture);
+ $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
+ $row = (array) DB::table('store_account_password_audits')->first();
+ $this->assertSame(200, $row['status']);
+ $this->assertSame($this->user->id, $row['actor_user_id']);
+ $this->assertStringNotContainsString($this->fixture, json_encode($row));
+ $sent = json_decode((string) $this->history[0]['request']->getBody(), true);
+ $this->assertSame(['requesterId' => 'finance-user-'.$this->user->id, 'purpose' => 'view'], $sent);
+ $this->assertFalse($this->history[0]['options']['allow_redirects']);
+ $this->assertTrue($this->history[0]['options']['verify']);
+ $this->assertSame(10, $this->history[0]['options']['timeout']);
+ }
+
+ public function test_normal_admin_can_copy_without_dingtalk_binding(): void
+ {
+ $this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]);
+ $this->transport();
+ $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'copy'])->assertOk();
+ $this->assertDatabaseHas('store_account_password_audits', ['purpose' => 'copy', 'status' => 200]);
+ }
+
+ public function test_regular_user_cannot_call_upstream(): void
+ {
+ $this->user->roles()->sync([Role::where('name', 'user')->firstOrFail()->id]);
+ $this->user->permissions()->attach(\App\Models\Auth\Permission::where('name', 'read-admin-panel')->firstOrFail()->id);
+ $this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal');
+ $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertForbidden();
+ }
+
+ public function test_restricted_admin_cannot_bypass_store_page_scope(): void
+ {
+ setting()->set('system.sidebar_permissions', ['configured' => ['user_'.$this->user->id], 'subjects' => ['user_'.$this->user->id => []]]);
+ $this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal');
+ $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertForbidden();
+ }
+
+ public function test_actor_spoofing_is_rejected(): void
+ {
+ $this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal');
+ $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view', 'requesterId' => 'someone-else'])->assertStatus(422);
+ }
+
+ public function test_account_must_exist_in_authorized_mirror(): void
+ {
+ $this->mock(StoreAccountMirrorRepository::class)->shouldReceive('containsAccount')->andReturnFalse();
+ $this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal');
+ $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertNotFound();
+ }
+
+ #[DataProvider('upstreamErrors')]
+ public function test_errors_never_expose_upstream_bodies(int $upstream, int $expected): void
+ {
+ $this->transport($upstream);
+ $response = $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'copy'])->assertStatus($expected);
+ $this->assertStringNotContainsString($this->fixture, $response->getContent());
+ $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
+ }
+
+ public static function upstreamErrors(): array
+ {
+ return [[403, 503], [404, 404], [429, 429], [503, 503], [302, 503]];
+ }
+
+ public function test_missing_audit_storage_prevents_upstream_read(): void
+ {
+ Schema::drop('store_account_password_audits');
+ $this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal');
+ $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertStatus(503);
+ }
+
+ public function test_failed_success_audit_suppresses_password(): void
+ {
+ $this->mock(StoreAccountPasswordClient::class)->shouldReceive('reveal')->andReturnUsing(function () {
+ Schema::drop('store_account_password_audits');
+ return ['status' => 200, 'password' => $this->fixture];
+ });
+ $response = $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertStatus(503);
+ $this->assertStringNotContainsString($this->fixture, $response->getContent());
+ }
+
+ public function test_missing_no_store_header_rejects_upstream_success(): void
+ {
+ $this->transport(headers: []);
+ $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertStatus(503)->assertJsonMissingPath('password');
+ }
+
+ public function test_unconfigured_token_does_not_send_a_request(): void
+ {
+ $this->transport();
+ config(['store_account_password.token' => null]);
+ $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertStatus(503);
+ $this->assertSame([], $this->history);
+ }
+
+ public function test_local_rate_limit_prevents_upstream_request(): void
+ {
+ for ($i = 0; $i < 10; $i++) {
+ \Illuminate\Support\Facades\RateLimiter::hit('store-password:'.$this->user->id, 60);
+ }
+ $this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal');
+ $this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertStatus(429);
+ }
+
+ public function test_admin_page_has_explicit_actions_and_no_plaintext_in_table(): void
+ {
+ $this->mock(\App\Business\UpstreamBusinessInformation::class)->shouldReceive('paginate')->andReturn(
+ new \Illuminate\Pagination\LengthAwarePaginator([(object) [
+ 'id' => 'fixture-account', 'store_name' => '测试店铺', 'account' => 'fixture-login', 'password_configured' => true,
+ ]], 1, 50, 1)
+ );
+ $response = $this->loginAs()->get(route('business.pages.show', ['section' => 'store-management', 'page' => 'store-accounts']))->assertOk();
+ $response->assertSee('data-store-password="view"', false)->assertSee('data-store-password="copy"', false)
+ ->assertSee('data-password-dialog', false)->assertDontSee($this->fixture);
+ $html = $response->getContent();
+ $this->assertGreaterThan(strpos($html, ''), strpos($html, '