支持财务当前页批量读取店铺密码 (#18)
This commit is contained in:
parent
811926e67b
commit
caa960d70a
@ -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、普通只读角色拒绝基表/密码/审计读取。
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<unknown> {
|
||||
async function readBody(request: Request, maxBytes = 1024): Promise<unknown> {
|
||||
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<unknown> {
|
||||
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
|
||||
|
||||
@ -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<keyof typeof VISIBLE_PAGE_LIMITS>)
|
||||
.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 {
|
||||
|
||||
@ -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<typeof revealInputSchema>
|
||||
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<boolean>
|
||||
limitedVisiblePage(audit: RevealAudit): Promise<boolean>
|
||||
limitedAccounts(accountIds: string[]): Promise<Set<string>>
|
||||
findPassword(accountId: string): Promise<string | null>
|
||||
findPasswords(accountIds: string[]): Promise<Map<string, string>>
|
||||
audit(event: RevealAudit): Promise<void>
|
||||
auditMany(events: RevealAudit[]): Promise<void>
|
||||
}
|
||||
export interface RevealRepository {
|
||||
transaction<T>(run: (tx: RevealTransaction) => Promise<T>): Promise<T>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<RevealVisiblePageResult> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user