From caa960d70ac984a3b9e6886a84f9c90f8bcad2a9 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 8 Sep 2026 17:19:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=B4=A2=E5=8A=A1=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E9=A1=B5=E6=89=B9=E9=87=8F=E8=AF=BB=E5=8F=96=E5=BA=97?= =?UTF-8?q?=E9=93=BA=E5=AF=86=E7=A0=81=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../finance-store-account-password-reveal.md | 30 ++++++++-- .../[id]/password/reveal/route.test.ts | 26 ++++++++- .../[id]/password/reveal/route.ts | 38 ++++++++---- ...tore-account-password-reveal.repository.ts | 41 ++++++++++++- .../store-account-password-reveal.contract.ts | 14 +++++ ...re-account-password-reveal.service.test.ts | 46 +++++++++++++++ .../store-account-password-reveal.service.ts | 58 ++++++++++++++++++- 7 files changed, 232 insertions(+), 21 deletions(-) diff --git a/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md b/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md index 78f308e2..6c2f3862 100644 --- a/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md +++ b/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md @@ -1,4 +1,4 @@ -# 财务系统店铺密码按需读取契约 +# 财务系统店铺密码直接显示契约 状态:代码和本地测试交付;生产迁移、配置、发布及真实接口验收须单独批准。 @@ -11,7 +11,7 @@ - 2026-09-08 对 `furniture3d_prod` 的只读核验:`accounts_v1` COUNT=958、DISTINCT id=958;957 条非空密文均可在生产主机内存中解密,空值 1、解密失败 0。过程只输出聚合,不导出密码或密文。 - 普通 `store_account_reader` 可读视图、不能读取基表或密码列。此次迁移不增加其基表权限。 - 本接口只信任专用服务身份 `finance-store-accounts`。财务系统必须在自身后端校验用户登录态、查看/复制权限及店铺范围,然后从可信会话中取 `requesterId`。不得把客户端传入的任意人员 ID 当成操作者。服务凭据仅保存在财务后端,禁止交给浏览器。 -- 不提供列表、批量密码导出或自动预取。列表只读取 `passwordConfigured`;每次明确的查看/复制操作单独调用接口。上游审计记录的用户 ID 是专用财务服务的身份断言,未授权请求中的 ID 不可信,`serviceId=null`。 +- 不提供密码列表或批量导出。财务页面可在已通过自身页面权限校验后,自动读取当前可见页最多 50 个账号;CSV 和共享镜像仍只读取 `passwordConfigured`。复制操作继续走单账号接口。上游审计记录的用户 ID 是专用财务服务的身份断言,未授权请求中的 ID 不可信,`serviceId=null`。 ## HTTP 契约 @@ -19,6 +19,8 @@ `POST https://cp.jiansun.vip/internal/store-accounts/{id}/password/reveal` +`POST https://cp.jiansun.vip/internal/store-accounts/visible-page/password/reveal` + `id` 为 `accounts_v1.id`,仅接受单个 1–128 位字母、数字、下划线或连字符。无查询参数;禁止在 URL、请求体、请求头中传待读取密码。 请求头: @@ -36,12 +38,30 @@ Content-Type: application/json `purpose` 仅允许 `view` 或 `copy`。`requesterId` 是财务会话中的稳定人员标识,1–128 位 `[A-Za-z0-9_.:@-]`。不接受批量 ID、查询参数、普通登录 Cookie/JWT、普通数据库凭据或其他内部任务密钥。 +当前页批量请求严格只接受以下字段,正文最大 8192 字节;`accountIds` 为 1 至 50 个互不重复且符合单账号 ID 规则的值: + +```json +{ "requesterId": "finance-user-123", "purpose": "view", "accountIds": ["account-1", "account-2"] } +``` + 成功 `200`: ```json { "requestId": "<服务端生成的 UUID>", "password": "<仅本次响应体中的密码>" } ``` +当前页批量成功的 HTTP 状态为 `200`,每个账号分别返回 `200/404/429/503`。每项都有独立 UUID;只有状态 `200` 的项包含密码,并按请求顺序完整返回: + +```json +{ + "requestId": "<批次 UUID>", + "items": [ + { "accountId": "account-1", "requestId": "<项目 UUID>", "status": 200, "password": "<密码>" }, + { "accountId": "account-2", "requestId": "<项目 UUID>", "status": 404 } + ] +} +``` + 所有状态均返回 `Cache-Control: no-store`、`Pragma: no-cache`、`X-Content-Type-Options: nosniff`。`X-Request-Id` 与响应体、审计的追踪 ID 相同。不会在响应头中放密码。 错误体: @@ -61,7 +81,7 @@ Content-Type: application/json ## 限流和超时 -- 数据库滚动窗口:服务全部账号 30 次/分钟、300 次/24 小时;同一人员 10 次/分钟;同一账号 5 次/分钟。查看和复制共享额度,轮换令牌不重置服务身份或额度。 +- 单账号数据库滚动窗口:服务全部账号 30 次/分钟、300 次/24 小时;同一人员 10 次/分钟;同一账号 5 次/分钟。当前页批次另按批次标记限制为服务 10 次/分钟、500 次/24 小时、同一人员 5 次/分钟;批次中的每个账号仍受同一账号 5 次/分钟限制。查看和复制共享账号额度,轮换令牌不重置服务身份或额度。 - 已认证的成功、404、参数拒绝和解密失败均占额度;429 不延长窗口。额度检查与审计在同一数据库事务中用事务级锁串行化,跨进程/重启共享状态。 - 429 不携带剩余数量或精确窗口,避免推测其他用户行为。客户端不得自动重试密码读取;提示用户稍后再试,持续超限联系管理员。 - 财务客户端设置总超时 10 秒、禁用自动重定向和重试。后端池上限 4、连接获取 1 秒、SQL 执行 1.5 秒/客户端查询 2 秒、锁等待 1 秒,路由运行预算 10 秒。网关必须配置请求体 1 秒和总请求截止,防止慢请求占用。 @@ -77,6 +97,7 @@ Content-Type: application/json | STORE_ACCOUNT_REVEAL_EDGE_TOKEN | 上游与可信 TLS 网关之间的独立随机凭据,base64url 43–128 字符;与财务令牌完全分离,不提供给财务系统;缺失时拒绝全部调用 | | DATABASE_URL | 上游现有数据库连接;仅专用不输出查询/异常的连接池读取密文、写审计 | | STORE_ACCOUNT_REVEAL_URL | 财务后端配置完整 HTTPS 地址模板 | +| STORE_ACCOUNT_REVEAL_VISIBLE_PAGE_URL | 财务后端配置当前页批量 HTTPS 地址;必须使用精确 `visible-page` 路径 | | STORE_ACCOUNT_REVEAL_TOKEN | 财务后端保存与上游专用令牌对应的值 | | STORE_REVEAL_TEST_ADMIN_URL | 仅本地集成测试;必须指向回环地址、有建测试库/角色权限的 PostgreSQL | | STORE_REVEAL_TEST_POSTGRES_CONTAINER | 仅本地日志验收,指定本地 PostgreSQL 容器以只读扫描测试期间日志 | @@ -88,7 +109,7 @@ Content-Type: application/json 3. 对精确路径 `/internal/store-accounts/*/password/reveal` 禁止网关缓存、响应落盘缓冲、CDN缓存、请求/响应体及 Authorization 采集;包括 APM、WAF、异常上报、链路追踪和调试抓包。网关日志只允许时间、方法、路径(不带参数)、状态、耗时、追踪 ID。不要采集响应对象。 4. 网关自己的 403/404/429/503 也使用脱敏 JSON、`Cache-Control: no-store` 和追踪 ID;不得返回上游诊断页。禁止将该路由接入通用 `withApi`/request logger;目前路由直接返回白名单响应,不记录异常对象。 5. 数据库不执行明文解密,SQL 参数不包含明文密码。现有密码写入或密钥轮换时,关闭 PostgreSQL/pgAudit 的绑定参数及错误参数记录,防止记录密文。不要启用该连接池的 SQL 结果或参数追踪。 -6. 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器仅在用户点击后获取,避免 localStorage/sessionStorage/Service Worker 持久化;显示或复制完清除页面内存状态,不把密码放入通知、埋点或消息队列。 +6. 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器在进入已授权页面后只读取当前可见页,避免 localStorage/sessionStorage/Service Worker 持久化;页面隐藏或离开时清除页面密码,不把密码放入通知、埋点或消息队列。 ## 视图与审计迁移 @@ -121,6 +142,7 @@ Content-Type: application/json ## 本次验证记录 +- 当前页批量扩展的服务、HTTP 路由和代理边界共 3 个测试套件、41 项测试通过;`npm run type-check` 通过。测试覆盖 1–50 个账号、重复账号拒绝、部分 404/429/503、批次与每账号审计,以及失败时不返回明文。 - 单元/HTTP/代理路由测试:34 项通过,含伪造 HTTPS URL/转发头和错误网关凭据拒绝。 - 追加现有代理路由的 7 项回归,与 10 项集成测试合计 6 套件、51 项全部通过。 - 隔离本地 PostgreSQL 集成测试:10 项通过,覆盖完整加解密与 HTTP handler、已提交审计、403/404/429/no-store、并发/跨实例限流、分钟/24小时配额、审计失败拒绝泄露、原字段及筛选保留、958/958 与 boolean 957/1/0、普通只读角色拒绝基表/密码/审计读取。 diff --git a/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.test.ts b/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.test.ts index 597a81b8..20dbfcef 100644 --- a/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.test.ts +++ b/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.test.ts @@ -1,11 +1,15 @@ /** @jest-environment node */ -import { randomBytes } from 'node:crypto' +import { randomBytes, randomUUID } from 'node:crypto' import { POST, GET } from './route' jest.mock('server-only', () => ({})) const mockReveal = jest.fn() +const mockRevealVisiblePage = jest.fn() jest.mock('@/server/services/store-account-password-reveal.service', () => ({ - StoreAccountPasswordRevealService: jest.fn().mockImplementation(() => ({ reveal: (...args: unknown[]) => mockReveal(...args) })), + StoreAccountPasswordRevealService: jest.fn().mockImplementation(() => ({ + reveal: (...args: unknown[]) => mockReveal(...args), + revealVisiblePage: (...args: unknown[]) => mockRevealVisiblePage(...args), + })), })) const path = 'https://example.test/internal/store-accounts/test-id/password/reveal' @@ -15,7 +19,7 @@ function request(url = path, body = JSON.stringify({ requesterId: 'u', purpose: } describe('secret HTTP response boundary', () => { - beforeEach(() => { mockReveal.mockReset(); delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN }) + beforeEach(() => { mockReveal.mockReset(); mockRevealVisiblePage.mockReset(); delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN }) afterAll(() => { delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN }) it.each([200, 403, 404, 429, 503])('sets no-store for status %i and places no credential in headers', async status => { @@ -85,4 +89,20 @@ describe('secret HTTP response boundary', () => { await POST(request(path, body), context) expect(mockReveal.mock.calls[0][0].body).toBeNull() }) + + it('routes the reserved visible-page id to the bounded batch service without exposing passwords in headers', async () => { + const password = randomBytes(24).toString('base64url') + mockRevealVisiblePage.mockResolvedValue({ status: 200, items: [ + { accountId: 'account-1', requestId: randomUUID(), status: 200, password }, + ] }) + const visible = request(path.replace('test-id', 'visible-page'), JSON.stringify({ + requesterId: 'finance-user-1', purpose: 'view', accountIds: ['account-1'], + })) + const response = await POST(visible, { params: Promise.resolve({ id: 'visible-page' }) }) + expect(response.status).toBe(200) + expect(mockReveal).not.toHaveBeenCalled() + expect(mockRevealVisiblePage).toHaveBeenCalledWith(expect.objectContaining({ post: true })) + expect(JSON.stringify([...response.headers]).includes(password)).toBe(false) + expect((await response.json()).items[0].password === password).toBe(true) + }) }) diff --git a/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.ts b/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.ts index 4835b803..201d03ac 100644 --- a/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.ts +++ b/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto' import { StoreAccountPasswordRevealService } from '@/server/services/store-account-password-reveal.service' import { hasTrustedRevealTransport } from '@/server/services/store-account-password-reveal-transport' +import { VISIBLE_PAGE_ACCOUNT_ID } from '@/server/services/store-account-password-reveal.contract' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -13,7 +14,7 @@ const errors = { 503: ['UNAVAILABLE', '服务暂不可用'], } as const -async function readBody(request: Request): Promise { +async function readBody(request: Request, maxBytes = 1024): Promise { if (request.method !== 'POST' || !/^application\/json(?:\s*;|$)/i.test(request.headers.get('content-type') || '')) return null const reader = request.body?.getReader() if (!reader) return null @@ -29,7 +30,7 @@ async function readBody(request: Request): Promise { const { done, value } = await reader.read() if (done) break size += value.byteLength - if (size > 1024) { await reader.cancel(); return null } + if (size > maxBytes) { await reader.cancel(); return null } chunks.push(value) } return timedOut ? null : JSON.parse(Buffer.concat(chunks).toString('utf8')) @@ -40,16 +41,25 @@ export async function POST(request: Request, context: { params: Promise<{ id: st const requestId = randomUUID() const url = new URL(request.url) const secure = hasTrustedRevealTransport(request.headers) - const result = await service.reveal({ - accountId: (await context.params).id, body: await readBody(request), - authorization: request.headers.get('authorization'), + const accountId = (await context.params).id + const body = await readBody(request, accountId === VISIBLE_PAGE_ACCOUNT_ID ? 8192 : 1024) + const common = { + body, authorization: request.headers.get('authorization'), secure: secure && !url.search, post: request.method === 'POST', requestId, - }) - const headers = { - 'Cache-Control': 'no-store', 'Pragma': 'no-cache', - 'X-Content-Type-Options': 'nosniff', 'X-Request-Id': requestId, - 'Strict-Transport-Security': 'max-age=31536000', } + if (accountId === VISIBLE_PAGE_ACCOUNT_ID) { + const result = await service.revealVisiblePage(common) + const headers = responseHeaders(requestId) + if (result.status === 200) { + return Response.json({ requestId, items: result.items }, { status: 200, headers }) + } + const [code, message] = errors[result.status as keyof typeof errors] + return Response.json({ requestId, error: { code, message } }, { status: result.status, headers }) + } + const result = await service.reveal({ + accountId, ...common, + }) + const headers = responseHeaders(requestId) if (result.status === 200) { return Response.json({ requestId, password: result.password }, { status: 200, headers }) } @@ -57,6 +67,14 @@ export async function POST(request: Request, context: { params: Promise<{ id: st return Response.json({ requestId, error: { code, message } }, { status: result.status, headers }) } +function responseHeaders(requestId: string) { + return { + 'Cache-Control': 'no-store', 'Pragma': 'no-cache', + 'X-Content-Type-Options': 'nosniff', 'X-Request-Id': requestId, + 'Strict-Transport-Security': 'max-age=31536000', + } +} + export const GET = POST export const PUT = POST export const PATCH = POST diff --git a/furniture-3d-system/src/server/repositories/store-account-password-reveal.repository.ts b/furniture-3d-system/src/server/repositories/store-account-password-reveal.repository.ts index 8f54d054..cea7bf45 100644 --- a/furniture-3d-system/src/server/repositories/store-account-password-reveal.repository.ts +++ b/furniture-3d-system/src/server/repositories/store-account-password-reveal.repository.ts @@ -1,7 +1,8 @@ import 'server-only' import { Pool, type PoolClient } from 'pg' import { - REVEAL_LIMITS, + FINANCE_SERVICE_ID, REVEAL_LIMITS, + VISIBLE_PAGE_LIMITS, type RevealAudit, type RevealRepository, type RevealTransaction, } from '@/server/services/store-account-password-reveal.contract' @@ -41,6 +42,33 @@ class Transaction implements RevealTransaction { .some(key => counts[key] >= REVEAL_LIMITS[key]) } + async limitedVisiblePage(event: RevealAudit) { + await this.client.query('SELECT pg_advisory_xact_lock(1937010546, 1)') + const { rows: [counts] } = await this.client.query<{ + serviceMinute: number; serviceDay: number; requesterMinute: number + }>(`SELECT + count(*) FILTER (WHERE "createdAt" > clock_timestamp() - interval '1 minute')::int AS "serviceMinute", + count(*)::int AS "serviceDay", + count(*) FILTER (WHERE "createdAt" > clock_timestamp() - interval '1 minute' AND "requesterId" = $2)::int AS "requesterMinute" + FROM public."StoreAccountPasswordAccessAudit" + WHERE "serviceId" = $1 AND "accountId" IS NULL AND status <> 429 + AND "createdAt" > clock_timestamp() - interval '24 hours'`, + [event.serviceId, event.requesterId]) + return (Object.keys(VISIBLE_PAGE_LIMITS) as Array) + .some(key => counts[key] >= VISIBLE_PAGE_LIMITS[key]) + } + + async limitedAccounts(accountIds: string[]) { + const { rows } = await this.client.query<{ accountId: string }>(`SELECT "accountId" + FROM public."StoreAccountPasswordAccessAudit" + WHERE "serviceId" = $1 AND status <> 429 + AND "createdAt" > clock_timestamp() - interval '1 minute' + AND "accountId" = ANY($2::text[]) + GROUP BY "accountId" HAVING count(*) >= $3`, + [FINANCE_SERVICE_ID, accountIds, REVEAL_LIMITS.accountMinute]) + return new Set(rows.map(row => row.accountId)) + } + async findPassword(accountId: string) { const { rows } = await this.client.query<{ passwordCiphertext: string }>( 'SELECT "passwordCiphertext" FROM public."StoreAccount" WHERE id = $1', [accountId], @@ -48,6 +76,13 @@ class Transaction implements RevealTransaction { return rows[0]?.passwordCiphertext || null } + async findPasswords(accountIds: string[]) { + const { rows } = await this.client.query<{ id: string; passwordCiphertext: string }>( + 'SELECT id, "passwordCiphertext" FROM public."StoreAccount" WHERE id = ANY($1::text[])', [accountIds], + ) + return new Map(rows.filter(row => row.passwordCiphertext).map(row => [row.id, row.passwordCiphertext])) + } + async audit(event: RevealAudit) { // Explicit allowlist: never spread request, response, account record or exception. await this.client.query(`INSERT INTO public."StoreAccountPasswordAccessAudit" @@ -55,6 +90,10 @@ class Transaction implements RevealTransaction { VALUES ($1, $2, $3, $4, $5, $6)`, [event.requestId, event.requesterId, event.serviceId, event.accountId, event.purpose, event.status]) } + + async auditMany(events: RevealAudit[]) { + for (const event of events) await this.audit(event) + } } export class StoreAccountPasswordRevealRepository implements RevealRepository { diff --git a/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts b/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts index 28ee8baf..d8619e90 100644 --- a/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts +++ b/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts @@ -5,9 +5,17 @@ export const revealInputSchema = z.object({ purpose: z.enum(['view', 'copy']), }).strict() export const accountIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,128}$/) +export const revealVisiblePageInputSchema = z.object({ + requesterId: z.string().regex(/^[A-Za-z0-9_.:@-]{1,128}$/), + purpose: z.literal('view'), + accountIds: z.array(accountIdSchema).min(1).max(50) + .refine(ids => new Set(ids).size === ids.length), +}).strict() export const FINANCE_SERVICE_ID = 'finance-store-accounts' +export const VISIBLE_PAGE_ACCOUNT_ID = 'visible-page' export const REVEAL_LIMITS = { serviceMinute: 30, serviceDay: 300, requesterMinute: 10, accountMinute: 5 } as const +export const VISIBLE_PAGE_LIMITS = { serviceMinute: 10, serviceDay: 500, requesterMinute: 5 } as const export type RevealInput = z.infer export type RevealStatus = 200 | 403 | 404 | 429 | 503 export type RevealAudit = { @@ -19,10 +27,16 @@ export type RevealAudit = { status: RevealStatus } export type RevealResult = { status: RevealStatus; password?: string } +export type RevealVisiblePageItem = { accountId: string; requestId: string; status: RevealStatus; password?: string } +export type RevealVisiblePageResult = { status: RevealStatus; items?: RevealVisiblePageItem[] } export interface RevealTransaction { limited(audit: RevealAudit): Promise + limitedVisiblePage(audit: RevealAudit): Promise + limitedAccounts(accountIds: string[]): Promise> findPassword(accountId: string): Promise + findPasswords(accountIds: string[]): Promise> audit(event: RevealAudit): Promise + auditMany(events: RevealAudit[]): Promise } export interface RevealRepository { transaction(run: (tx: RevealTransaction) => Promise): Promise diff --git a/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts b/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts index 353aa1c3..1aacd475 100644 --- a/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts +++ b/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts @@ -24,8 +24,14 @@ describe('finance password reveal security boundary', () => { events = [] tx = { limited: jest.fn().mockResolvedValue(false), + limitedVisiblePage: jest.fn().mockResolvedValue(false), + limitedAccounts: jest.fn().mockResolvedValue(new Set()), findPassword: jest.fn().mockResolvedValue(encryptStoreAccountPassword(syntheticPassword)), + findPasswords: jest.fn().mockResolvedValue(new Map([ + ['synthetic-account', encryptStoreAccountPassword(syntheticPassword)], + ])), audit: jest.fn(async event => { events.push(event) }), + auditMany: jest.fn(async batch => { events.push(...batch) }), } service = new StoreAccountPasswordRevealService({ transaction: run => run(tx) }) }) @@ -76,6 +82,46 @@ describe('finance password reveal security boundary', () => { expect((await service.reveal(request())).status).toBe(403) }) + it('returns up to one visible page with a batch marker and one password-free audit per account', async () => { + const result = await service.revealVisiblePage({ + body: { requesterId: 'finance-user-1', purpose: 'view', accountIds: ['synthetic-account'] }, + authorization: `Bearer ${token}`, secure: true, post: true, requestId: randomUUID(), + }) + expect(result.status).toBe(200) + expect(result.items).toHaveLength(1) + expect(result.items?.[0].password === syntheticPassword).toBe(true) + expect(tx.findPasswords).toHaveBeenCalledWith(['synthetic-account']) + expect(events).toHaveLength(2) + expect(events[0].accountId).toBeNull() + expect(events[1]).toMatchObject({ accountId: 'synthetic-account', status: 200 }) + expect(JSON.stringify(events).includes(syntheticPassword)).toBe(false) + }) + + it('returns per-account not-found and rate-limit states without exposing another account password', async () => { + tx.limitedAccounts.mockResolvedValue(new Set(['limited-account'])) + tx.findPasswords.mockResolvedValue(new Map()) + const result = await service.revealVisiblePage({ + body: { requesterId: 'finance-user-1', purpose: 'view', accountIds: ['limited-account', 'missing-account'] }, + authorization: `Bearer ${token}`, secure: true, post: true, requestId: randomUUID(), + }) + expect(result).toMatchObject({ status: 200, items: [ + { accountId: 'limited-account', status: 429 }, { accountId: 'missing-account', status: 404 }, + ] }) + expect(tx.findPasswords).toHaveBeenCalledWith(['missing-account']) + }) + + it.each([ + { accountIds: [] }, { accountIds: ['a', 'a'] }, { accountIds: ['../a'] }, + { accountIds: Array.from({ length: 51 }, (_, index) => `a-${index}`) }, + ])('rejects invalid visible-page batches without selecting credentials (%#)', async fields => { + const result = await service.revealVisiblePage({ + body: { requesterId: 'finance-user-1', purpose: 'view', ...fields }, + authorization: `Bearer ${token}`, secure: true, post: true, requestId: randomUUID(), + }) + expect(result.status).toBe(403) + expect(tx.findPasswords).not.toHaveBeenCalled() + }) + it.each([null, ''])('returns the same 404 for missing or empty passwords', async value => { tx.findPassword.mockResolvedValue(value) expect((await service.reveal(request())).status).toBe(404) diff --git a/furniture-3d-system/src/server/services/store-account-password-reveal.service.ts b/furniture-3d-system/src/server/services/store-account-password-reveal.service.ts index 7a455e0b..a4816372 100644 --- a/furniture-3d-system/src/server/services/store-account-password-reveal.service.ts +++ b/furniture-3d-system/src/server/services/store-account-password-reveal.service.ts @@ -1,10 +1,10 @@ import 'server-only' -import { createHash, timingSafeEqual } from 'node:crypto' +import { createHash, randomUUID, timingSafeEqual } from 'node:crypto' import { decryptStoreAccountPassword } from '@/lib/store-account-crypto' import { StoreAccountPasswordRevealRepository } from '@/server/repositories/store-account-password-reveal.repository' import { - accountIdSchema, FINANCE_SERVICE_ID, revealInputSchema, - type RevealAudit, type RevealRepository, type RevealResult, + accountIdSchema, FINANCE_SERVICE_ID, revealInputSchema, revealVisiblePageInputSchema, + type RevealAudit, type RevealRepository, type RevealResult, type RevealVisiblePageResult, } from './store-account-password-reveal.contract' function authenticate(authorization: string | null) { @@ -68,4 +68,56 @@ export class StoreAccountPasswordRevealService { return { status: 503 } } } + + async revealVisiblePage(input: { + body: unknown; authorization: string | null + secure: boolean; post: boolean; requestId: string + }): Promise { + const body = revealVisiblePageInputSchema.safeParse(input.body) + const authenticated = authenticate(input.authorization) + const batchEvent: RevealAudit = { + requestId: input.requestId, + requesterId: body.success ? body.data.requesterId : null, + serviceId: authenticated ? FINANCE_SERVICE_ID : null, + accountId: null, + purpose: body.success ? 'view' : 'unknown', status: 403, + } + try { + return await this.repository.transaction(async tx => { + let result: RevealVisiblePageResult = { status: 403 } + if (authenticated && await tx.limitedVisiblePage(batchEvent)) { + result = { status: 429 } + } else if (authenticated && input.secure && input.post && body.success) { + const limited = await tx.limitedAccounts(body.data.accountIds) + const allowed = body.data.accountIds.filter(id => !limited.has(id)) + const encrypted = await tx.findPasswords(allowed) + const items = body.data.accountIds.map(accountId => { + const requestId = randomUUID() + if (limited.has(accountId)) return { accountId, requestId, status: 429 as const } + const ciphertext = encrypted.get(accountId) + if (!ciphertext) return { accountId, requestId, status: 404 as const } + try { + const password = this.decrypt(ciphertext) + return password + ? { accountId, requestId, status: 200 as const, password } + : { accountId, requestId, status: 404 as const } + } catch { return { accountId, requestId, status: 503 as const } } + }) + await tx.auditMany([ + { ...batchEvent, status: 200 }, + ...items.map(item => ({ + requestId: item.requestId, requesterId: body.data.requesterId, + serviceId: FINANCE_SERVICE_ID, accountId: item.accountId, + purpose: 'view' as const, status: item.status, + })), + ]) + return { status: 200, items } + } + await tx.audit({ ...batchEvent, status: result.status }) + return result + }) + } catch { + return { status: 503 } + } + } }