diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 26abce2a8..ad437970e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -19,9 +19,9 @@ ## 2026-07-14 后台账号采用 owner 引导账号与一级 Tab 实时授权 - 背景:后台此前只支持一组环境变量管理员,所有 `/admin/api/*` 共用统一 admin 门禁,无法给运营、审核等人员分配独立账号和页面范围。 -- 决策:现有 `GENARRATIVE_ADMIN_USERNAME/PASSWORD` 账号固定作为不可编辑 owner;新增 member 独立保存到私有 `admin_account` 表,密码使用 Argon2id 摘要。member 权限粒度固定为后台 18 个一级 Tab,“账号管理”只允许 owner 且不可授予 member。member 每次请求重新读取当前账号并校验启停、`token_version` 和 Tab 权限;权限、密码或启停变化递增版本并立即淘汰旧 JWT。前端导航过滤和页面挂载门禁只负责体验,正式授权由 api-server 的 API-to-Tab 矩阵执行,未登记的新后台路由对 member 默认拒绝。后台面向运营展示管理员身份时统一使用 `displayName`;持久审计仍保存稳定 subject,由 api-server 解析显示名称,前端不得暴露账号 ID 或用登录用户名代替。 +- 决策:现有 `GENARRATIVE_ADMIN_USERNAME/PASSWORD` 账号固定作为不可编辑 owner;新增 member 独立保存到私有 `admin_account` 表,密码使用 Argon2id 摘要。登录凭据快照与普通账号快照在类型层分离,普通列表、按 ID 查询和写入响应不包含 `password_hash`。Argon2id 在 blocking 任务中运行并由 api-server 有界限流;未知、停用和 owner 错密账号使用 dummy hash 抹平耗时。member 权限粒度固定为后台 18 个一级 Tab,“账号管理”只允许 owner 且不可授予 member。member 每次请求重新读取当前账号并校验启停、`token_version` 和 Tab 权限;权限、密码或启停变化递增版本并立即淘汰旧 JWT。账号不存在返回 `401`,SpacetimeDB 故障保留 `502/503` 而不清理有效 token。前端导航过滤和页面挂载门禁只负责体验,正式授权由 api-server 的 API-to-Tab 矩阵执行,未登记的新后台路由对 member 默认拒绝。后台面向运营展示管理员身份时统一使用 `displayName`;持久审计仍保存稳定 subject,由 api-server 解析显示名称,前端不得暴露账号 ID 或用登录用户名代替。写接口必须在主事务前加载显示名目录,或在主事务后降级解析,不能把已提交写入伪装为失败。 - 影响范围:`admin_account`、SpacetimeDB typed procedures / client facade、后台 JWT 与 session DTO、`/admin/api/accounts*`、后台路由权限中间件、admin-web 导航和账号管理页。 -- 验证方式:SpacetimeDB schema / client / API 定向测试、完整路由矩阵测试、admin-web 权限路由与账号 API 测试、owner/member 浏览器 smoke、`npm run check:spacetime-schema`、编码与 diff 门禁。 +- 验证方式:SpacetimeDB schema / client / API 定向测试、`npm run check:admin-account-procedures` 隔离 procedure smoke、完整路由矩阵测试、admin-web 权限路由与账号 API 测试、owner/member 浏览器 smoke、`npm run check:spacetime-schema`、编码与 diff 门禁。 - 关联文档:`docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md`。 ## 2026-07-13 图片画布生成资源支持提交前统一命名 diff --git a/docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md b/docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md index 987a61d7d..d6d31d693 100644 --- a/docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md +++ b/docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md @@ -95,7 +95,7 @@ - `username` 建议限制为 3 至 64 个字符,只允许 ASCII 字母、数字、`.`、`_`、`-`;规范化后做唯一性校验。 - owner 用户名属于保留名称。创建 member 时必须同时与当前规范化后的 owner 用户名比较并拒绝冲突,不能只依赖 `admin_account.username` 唯一索引。 -- 密码明文只存在于登录、创建和改密请求生命周期内;限制为 6 至 128 个字符,并复用 `platform-auth` 的 Argon2id 哈希与校验能力。 +- 密码明文只存在于登录、创建和改密请求生命周期内;限制为 6 至 128 个字符,并复用 `platform-auth` 的 Argon2id 哈希与校验能力。Argon2id 必须在 blocking 任务中执行,api-server 通过有界信号量限制同时 hash / verify 数量,不得占用 Tokio worker 或无界堆积高成本任务。 - 不提供物理删除 API。离职或停用通过 `enabled = false` 完成,以保留 `created_by`、`updated_by` 和账号标识。 - `display_name` 单独变化只更新 `updated_by`、`updated_at`,不要求递增 `token_version`;权限、密码、`enabled` 任一有效变化必须在同一事务中递增版本。 - `u64` 版本到达上限时更新失败关闭,不能回绕。 @@ -131,7 +131,7 @@ procedure result 使用 typed snapshot,不使用不透明 `row_json`。账号 5. member 不存在、`enabled = false` 或 Argon2id 校验失败时返回同一登录错误,不泄露账号是否存在或被停用。 6. member 登录成功后,用当前 `account_id`、`token_version` 签发后台 JWT。 -owner 优先既保持原账号行为,也防止数据库同名记录遮蔽或降级 owner。密码比较不得写日志;member 必须复用 Argon2id 校验,不能存明文或可逆密文。 +owner 优先既保持原账号行为,也防止数据库同名记录遮蔽或降级 owner。密码比较不得写日志;member 必须复用 Argon2id 校验,不能存明文或可逆密文。未知 member、已停用 member 和 owner 错误密码路径仍执行同成本 dummy Argon2id 校验,避免从响应耗时枚举启用账号。 ### 7.2 JWT claims 与逐请求校验 @@ -148,7 +148,7 @@ owner 优先既保持原账号行为,也防止数据库同名记录遮蔽或 1. 验签并校验后台 issuer、过期时间和 `admin` role。 2. owner JWT:与当前环境变量构造的 owner subject 匹配,得到始终启用、全权限的虚拟当前账号;owner 不查 `admin_account`。 -3. member JWT:按 claim 中 `account_id` 调用 `get_admin_account_by_id_and_return`,账号不存在或 `enabled = false` 时拒绝。 +3. member JWT:按 claim 中 `account_id` 调用 `get_admin_account_by_id_and_return`,账号不存在或 `enabled = false` 时拒绝。只有明确的账号不存在才视为凭据失效;SpacetimeDB 超时、断连或 procedure 故障必须失败关闭但保留 `502/503` 依赖错误语义,不能伪装成 `401` 导致前端清除 token。 4. member claim 的 `token_version` 必须与表中当前值完全一致,否则返回 `401 Unauthorized` 并要求重新登录。 5. 将服务端实时构造的 `AuthenticatedAdmin` 放入 request extensions,后续权限 middleware 只读取该对象,不再相信原始 claims。 @@ -165,7 +165,7 @@ tabPermissions: string[] owner 返回全部 18 个 permission id;member 返回数据库中的实时规范化数组。`GET /admin/api/me` 同样执行逐请求校验并返回实时权限,供刷新页面后恢复导航。 -后台所有面向运营展示的管理员身份统一使用 `displayName`。审计表继续保存稳定 subject,例如 owner subject 或 `admin-account-`;api-server 在返回兑换码、邀请码等操作记录时,按 owner 运行态和 `admin_account` 批量解析显示名称,同时兼容历史用户名记录。已无法解析的历史主体统一展示“已停用管理员”,前端不得直接渲染 `operatorUserId`、账号 ID 或登录用户名代替显示名称。 +后台所有面向运营展示的管理员身份统一使用 `displayName`。审计表继续保存稳定 subject,例如 owner subject 或 `admin-account-`;api-server 在返回兑换码、邀请码等操作记录时,按 owner 运行态和 `admin_account` 批量解析显示名称,同时兼容历史用户名记录。已无法解析的历史主体统一展示“已停用管理员”,前端不得直接渲染 `operatorUserId`、账号 ID 或登录用户名代替显示名称。对写接口,显示名目录必须在主事务前加载,或在主事务成功后降级为占位文案;不得因二次读取失败把已提交写入伪装成失败。 ## 8. 权限中间件与错误语义 @@ -354,6 +354,7 @@ accounts: Array<{ ```bash npm run spacetime:generate +npm run check:admin-account-procedures npm run check:spacetime-runtime-access npm run check:spacetime-schema npm run check:server-rs-ddd @@ -409,6 +410,7 @@ spacetime publish \ cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml admin_account cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml admin_account cargo test -p api-server --manifest-path server-rs/Cargo.toml admin +npm run check:admin-account-procedures npx vitest run apps/admin-web/src/app/adminRoutes.test.ts \ apps/admin-web/src/app/AdminApp.test.tsx \ apps/admin-web/src/pages/AdminAccountManagementPage.test.tsx diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ecd2b2143..15e1423a8 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -202,6 +202,12 @@ SpacetimeDB bindings: npm run spacetime:generate ``` +后台账号 procedure 的 identity、唯一索引和版本事务使用隔离 smoke 验证;脚本会在随机本机端口启动临时 SpacetimeDB 2.6、发布当前 module,结束后自动关闭并清理临时数据: + +```bash +npm run check:admin-account-procedures +``` + ## CodeGraph 本地代码索引 项目已安装 `@colbymchenry/codegraph` 作为开发期依赖,用于在本地生成语义代码索引,辅助 AI / IDE 做符号搜索、调用关系和影响范围分析。索引目录为 `.codegraph/`,其中 `config.json` 可提交,数据库、缓存和日志由 `.codegraph/.gitignore` 保持本机私有。 diff --git a/package.json b/package.json index f50def4fa..d096f6700 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "spacetime:wechat-virtual-payment:reconcile": "node scripts/reconcile-wechat-virtual-payment-order.mjs", "check:wechat-virtual-payment-reconcile": "node scripts/check-wechat-virtual-payment-reconcile.mjs", "check:api-server-env": "node scripts/check-api-server-env.mjs", + "check:admin-account-procedures": "node scripts/check-admin-account-procedures.mjs", "check:spacetime-runtime-access": "node scripts/check-spacetime-runtime-access.mjs", "deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", "build:production-release": "node scripts/run-bash-script.mjs scripts/build-production-release.sh", diff --git a/scripts/check-admin-account-procedures.mjs b/scripts/check-admin-account-procedures.mjs new file mode 100644 index 000000000..9c8440615 --- /dev/null +++ b/scripts/check-admin-account-procedures.mjs @@ -0,0 +1,581 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; + +import { + createSpacetimeWebIdentity, + encodeSpacetimeCliOption, +} from './spacetime-migration-common.mjs'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const database = 'admin-account-smoke'; +const expectedSpacetimeVersion = '2.6.0'; +const commandTimeoutMs = 5 * 60 * 1000; + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function appendOutput(current, chunk) { + const next = `${current}${chunk}`; + return next.length <= 24_000 ? next : next.slice(-24_000); +} + +function runCommand(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd ?? repoRoot, + env: options.env ?? process.env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`${command} timed out after ${commandTimeoutMs}ms`)); + }, options.timeoutMs ?? commandTimeoutMs); + + child.stdout.on('data', (chunk) => { + output = appendOutput(output, chunk.toString()); + }); + child.stderr.on('data', (chunk) => { + output = appendOutput(output, chunk.toString()); + }); + child.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on('exit', (code, signal) => { + clearTimeout(timeout); + if (signal) { + reject(new Error(`${command} exited via ${signal}: ${output.trim()}`)); + } else if (code !== 0) { + reject(new Error(`${command} exited with ${code}: ${output.trim()}`)); + } else { + resolve(output); + } + }); + }); +} + +async function reservePort() { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + assert( + address && typeof address === 'object', + 'Failed to reserve a local port.', + ); + const port = address.port; + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return port; +} + +function startStandalone(dataDir, port) { + const child = spawn( + 'spacetime', + [ + 'start', + '--data-dir', + dataDir, + '--listen-addr', + `127.0.0.1:${port}`, + '--non-interactive', + ], + { + cwd: repoRoot, + env: process.env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + let output = ''; + child.stdout.on('data', (chunk) => { + output = appendOutput(output, chunk.toString()); + }); + child.stderr.on('data', (chunk) => { + output = appendOutput(output, chunk.toString()); + }); + return { child, output: () => output }; +} + +async function waitForStandalone(serverUrl, processState) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (processState.child.exitCode !== null) { + throw new Error( + `SpacetimeDB exited during startup: ${processState.output().trim()}`, + ); + } + try { + const response = await fetch(`${serverUrl}/v1/ping`); + if (response.ok) { + return; + } + } catch { + // Startup is still in progress. + } + await delay(200); + } + throw new Error( + `Timed out waiting for SpacetimeDB: ${processState.output().trim()}`, + ); +} + +async function stopStandalone(child) { + if (child.exitCode !== null) { + return; + } + child.kill('SIGTERM'); + const exited = await Promise.race([ + once(child, 'exit').then(() => true), + delay(5_000).then(() => false), + ]); + if (!exited && child.exitCode === null) { + child.kill('SIGKILL'); + await once(child, 'exit'); + } +} + +async function callProcedure(serverUrl, token, procedureName, input) { + const response = await fetch( + `${serverUrl}/v1/database/${database}/call/${procedureName}`, + { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input === undefined ? [] : [input]), + }, + ); + const text = await response.text(); + if (!response.ok) { + throw new Error( + `${procedureName} returned HTTP ${response.status}: ${text}`, + ); + } + return JSON.parse(text); +} + +function decodeOption(value) { + if (value === null || value === undefined) { + return null; + } + if (Array.isArray(value) && value[0] === 0) { + return value[1]; + } + if (Array.isArray(value) && value[0] === 1) { + return null; + } + throw new Error(`Unexpected SATS option: ${JSON.stringify(value)}`); +} + +function mapAccountSnapshot(value) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value; + } + assert( + Array.isArray(value) && value.length === 10, + 'Invalid admin account snapshot.', + ); + return { + account_id: value[0], + username: value[1], + display_name: value[2], + tab_permissions_json: value[3], + enabled: value[4], + token_version: value[5], + created_by: value[6], + updated_by: value[7], + created_at_micros: value[8], + updated_at_micros: value[9], + }; +} + +function parseAccountResult(value) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value; + } + assert( + Array.isArray(value) && value.length === 4, + 'Invalid admin account result.', + ); + const account = decodeOption(value[1]); + return { + ok: value[0], + account: account === null ? null : mapAccountSnapshot(account), + accounts: value[2].map(mapAccountSnapshot), + error_message: decodeOption(value[3]), + }; +} + +function parseCredentialResult(value) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value; + } + assert( + Array.isArray(value) && value.length === 3, + 'Invalid admin credential result.', + ); + const credential = decodeOption(value[1]); + return { + ok: value[0], + account: + credential === null + ? null + : { + account: mapAccountSnapshot(credential[0]), + password_hash: credential[1], + }, + error_message: decodeOption(value[2]), + }; +} + +function parsePricingResult(value) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value; + } + assert(Array.isArray(value) && value.length === 3, 'Invalid pricing result.'); + return { + ok: value[0], + record: decodeOption(value[1]), + error_message: decodeOption(value[2]), + }; +} + +function assertDenied(result, procedureName) { + assert( + !result.ok, + `${procedureName} unexpectedly allowed an ordinary identity.`, + ); + assert( + result.error_message === '当前 identity 无权调用模型生成运行时服务', + `${procedureName} returned an unexpected denial: ${result.error_message}`, + ); +} + +async function loadPricingModels() { + const filePath = path.join( + repoRoot, + 'server-rs/crates/api-server/config/editor-generation-pricing.default.json', + ); + const config = JSON.parse(await readFile(filePath, 'utf8')); + return Object.entries(config.models).map(([model, pricing]) => ({ + model, + unit: pricing.unit, + price: encodeSpacetimeCliOption(pricing.price), + prices: Object.entries(pricing.prices ?? {}).map(([key, price]) => ({ + key, + price, + })), + })); +} + +async function runSmoke(serverUrl, bootstrapSecret) { + const identityOptions = { database, serverUrl }; + const runtime = await createSpacetimeWebIdentity(identityOptions); + const ordinary = await createSpacetimeWebIdentity(identityOptions); + const models = await loadPricingModels(); + const pricing = parsePricingResult( + await callProcedure( + serverUrl, + runtime.token, + 'initialize_editor_generation_pricing_config_if_missing_and_return', + { + admin_user_id: 'admin-account-smoke', + models, + updated_at_micros: 1, + bootstrap_secret: bootstrapSecret, + }, + ), + ); + assert( + pricing.ok, + `Failed to initialize runtime identity: ${pricing.error_message}`, + ); + + const createInput = { + account_id: 'member-1', + username: 'operator', + display_name: 'Operator', + password_hash: '$argon2id$admin-account-smoke', + tab_permissions_json: '["dashboard"]', + enabled: true, + created_by: 'admin-account-smoke', + }; + const updateInput = { + account_id: 'member-1', + display_name: 'Operator Updated', + password_hash: null, + tab_permissions_json: '["dashboard","tracking"]', + enabled: false, + updated_by: 'admin-account-smoke', + }; + + assertDenied( + parseCredentialResult( + await callProcedure( + serverUrl, + ordinary.token, + 'get_admin_account_by_username_and_return', + { + username: 'operator', + }, + ), + ), + 'get_admin_account_by_username_and_return', + ); + assertDenied( + parseAccountResult( + await callProcedure( + serverUrl, + ordinary.token, + 'get_admin_account_by_id_and_return', + { + account_id: 'member-1', + }, + ), + ), + 'get_admin_account_by_id_and_return', + ); + assertDenied( + parseAccountResult( + await callProcedure( + serverUrl, + ordinary.token, + 'list_admin_accounts_and_return', + ), + ), + 'list_admin_accounts_and_return', + ); + assertDenied( + parseAccountResult( + await callProcedure( + serverUrl, + ordinary.token, + 'create_admin_account_and_return', + createInput, + ), + ), + 'create_admin_account_and_return', + ); + assertDenied( + parseAccountResult( + await callProcedure( + serverUrl, + ordinary.token, + 'update_admin_account_and_return', + updateInput, + ), + ), + 'update_admin_account_and_return', + ); + + const created = parseAccountResult( + await callProcedure( + serverUrl, + runtime.token, + 'create_admin_account_and_return', + createInput, + ), + ); + assert( + created.ok && created.account?.token_version === 1, + 'Runtime create failed.', + ); + + const duplicate = parseAccountResult( + await callProcedure( + serverUrl, + runtime.token, + 'create_admin_account_and_return', + { + ...createInput, + account_id: 'member-duplicate', + username: ' Operator ', + }, + ), + ); + assert( + !duplicate.ok, + 'Duplicate normalized username unexpectedly succeeded.', + ); + assert( + duplicate.error_message === '后台账号用户名已存在', + `Duplicate username error changed: ${duplicate.error_message}`, + ); + + const credential = parseCredentialResult( + await callProcedure( + serverUrl, + runtime.token, + 'get_admin_account_by_username_and_return', + { + username: ' OPERATOR ', + }, + ), + ); + assert( + credential.ok, + `Runtime credential lookup failed: ${credential.error_message}`, + ); + assert( + credential.account?.password_hash === createInput.password_hash, + 'Credential lookup lost the password hash.', + ); + + const byId = parseAccountResult( + await callProcedure( + serverUrl, + runtime.token, + 'get_admin_account_by_id_and_return', + { + account_id: 'member-1', + }, + ), + ); + assert( + byId.ok && byId.account?.username === 'operator', + 'Runtime ID lookup failed.', + ); + assert( + !('password_hash' in byId.account), + 'Public account snapshot exposed password_hash.', + ); + + const updated = parseAccountResult( + await callProcedure( + serverUrl, + runtime.token, + 'update_admin_account_and_return', + updateInput, + ), + ); + assert( + updated.ok && updated.account?.token_version === 2, + 'State update did not increment once.', + ); + + const displayOnly = parseAccountResult( + await callProcedure( + serverUrl, + runtime.token, + 'update_admin_account_and_return', + { + ...updateInput, + display_name: 'Operator Display Only', + }, + ), + ); + assert( + displayOnly.ok && displayOnly.account?.token_version === 2, + 'Display-only update changed token_version.', + ); + + const listed = parseAccountResult( + await callProcedure( + serverUrl, + runtime.token, + 'list_admin_accounts_and_return', + ), + ); + assert( + listed.ok && listed.accounts.length === 1, + 'Failed create mutated the account table.', + ); + assert( + listed.accounts[0].account_id === 'member-1', + 'Unexpected account survived the smoke.', + ); +} + +let tempDir; +let standalone; +try { + const versionOutput = await runCommand('spacetime', ['--version'], { + timeoutMs: 30_000, + }); + assert( + versionOutput.includes( + `spacetimedb tool version ${expectedSpacetimeVersion}`, + ), + `Expected SpacetimeDB ${expectedSpacetimeVersion}, got: ${versionOutput.trim()}`, + ); + + tempDir = await mkdtemp( + path.join(os.tmpdir(), 'genarrative-admin-account-smoke-'), + ); + const port = await reservePort(); + const serverUrl = `http://127.0.0.1:${port}`; + standalone = startStandalone(path.join(tempDir, 'data'), port); + await waitForStandalone(serverUrl, standalone); + + const bootstrapSecret = createHash('sha256') + .update('genarrative-admin-account-procedure-smoke') + .digest('hex'); + const bootstrapSecretHash = createHash('sha256') + .update(bootstrapSecret) + .digest('hex'); + await runCommand( + 'spacetime', + [ + 'publish', + database, + '--server', + serverUrl, + '--module-path', + 'server-rs/crates/spacetime-module', + '--anonymous', + '--yes=all', + '--no-config', + ], + { + env: { + ...process.env, + GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256: + bootstrapSecretHash, + }, + }, + ); + + await runSmoke(serverUrl, bootstrapSecret); + console.log( + '[admin-account-procedure-smoke] Passed identity guards, credential isolation, uniqueness, and token-version transactions.', + ); +} catch (error) { + const standaloneOutput = standalone?.output().trim(); + console.error( + `[admin-account-procedure-smoke] Failed: ${error instanceof Error ? error.message : String(error)}`, + ); + if (standaloneOutput) { + console.error(standaloneOutput); + } + process.exitCode = 1; +} finally { + if (standalone) { + await stopStandalone(standalone.child); + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } +} diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index d0b2ec7d0..4e331594e 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -16,7 +16,7 @@ use axum::{ middleware::Next, response::Response, }; -use platform_auth::verify_password; +use platform_auth::{hash_password, verify_password}; use reqwest::Client; use serde::Deserialize; use serde_json::{Map, Value}; @@ -57,10 +57,13 @@ use spacetime_client::{ EditorShowcaseAssetDisplayUpdateRecordInput, EditorShowcaseAssetRecord, EditorShowcaseAssetRefundMarkRecordInput, EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, EditorShowcaseCampaignConfigUpsertRecordInput, + SpacetimeClientError, }; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use tokio::sync::{OnceCell, Semaphore}; use crate::{ + admin_accounts::normalize_admin_account_username, api_response::json_success_body, assets::{ AssetReadAuthorization, create_direct_upload_ticket_for_owner, @@ -77,6 +80,10 @@ use crate::{ // 首版调试台只允许有限大小的请求体,避免把后台当作通用代理大包转发器。 const MAX_DEBUG_BODY_BYTES: usize = 128 * 1024; +const ADMIN_PASSWORD_MAX_CONCURRENT_OPERATIONS: usize = 4; +static ADMIN_PASSWORD_OPERATION_LIMITER: Semaphore = + Semaphore::const_new(ADMIN_PASSWORD_MAX_CONCURRENT_OPERATIONS); +static ADMIN_DUMMY_PASSWORD_HASH: OnceCell = OnceCell::const_new(); const BLOCKED_DEBUG_HEADERS: &[&str] = &[ "host", "content-length", @@ -202,6 +209,9 @@ pub async fn admin_login( let (claims, tab_permissions) = if submitted_username == expected_username.to_ascii_lowercase() { if submitted_password.trim() != expected_password { + verify_admin_password(admin_dummy_password_hash().await?, submitted_password) + .await + .ok(); return Err(admin_login_rejected()); } ( @@ -212,25 +222,39 @@ pub async fn admin_login( .collect(), ) } else { - let account = state + let submitted_username = match normalize_admin_account_username(&submitted_username) { + Ok(username) => username, + Err(_) => { + verify_admin_password(admin_dummy_password_hash().await?, submitted_password) + .await + .ok(); + return Err(admin_login_rejected()); + } + }; + let account = match state .spacetime_client() .get_admin_account_by_username(submitted_username) .await - .map_err(|error| { - if error.to_string().contains("不存在") { - admin_login_rejected() - } else { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) - .with_message("后台账号服务暂不可用") - } - })?; - if !account.enabled - || !verify_password(&account.password_hash, submitted_password) - .await - .map_err(|_| admin_login_rejected())? { + Ok(account) => Some(account), + Err(error) if is_admin_account_not_found(&error) => None, + Err(_) => { + return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("后台账号服务暂不可用")); + } + }; + let password_hash = match account.as_ref().filter(|account| account.account.enabled) { + Some(account) => account.password_hash.as_str(), + None => admin_dummy_password_hash().await?, + }; + let password_matches = verify_admin_password(password_hash, submitted_password) + .await + .unwrap_or(false); + let Some(account) = account.filter(|account| account.account.enabled && password_matches) + else { return Err(admin_login_rejected()); - } + }; + let account = account.account; let tab_permissions = account.tab_permissions.clone(); ( runtime @@ -1304,8 +1328,14 @@ pub async fn require_admin_auth( .spacetime_client() .get_admin_account_by_id(claims.user_id().to_string()) .await - .map_err(|_| { - AppError::from_status(StatusCode::UNAUTHORIZED).with_message("后台登录状态已失效") + .map_err(|error| { + if is_admin_account_not_found(&error) { + AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("后台登录状态已失效") + } else { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("后台账号服务暂不可用") + } })?; if !account.enabled { return Err( @@ -1345,6 +1375,48 @@ fn internal_admin_error(error: String) -> AppError { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error) } +pub(crate) async fn hash_admin_password(password: &str) -> Result { + let _permit = ADMIN_PASSWORD_OPERATION_LIMITER + .acquire() + .await + .map_err(|_| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("后台密码服务暂不可用") + })?; + hash_password(password).await.map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) + }) +} + +async fn verify_admin_password(password_hash: &str, password: &str) -> Result { + let _permit = ADMIN_PASSWORD_OPERATION_LIMITER + .acquire() + .await + .map_err(|_| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("后台密码服务暂不可用") + })?; + verify_password(password_hash, password) + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) + }) +} + +async fn admin_dummy_password_hash() -> Result<&'static str, AppError> { + ADMIN_DUMMY_PASSWORD_HASH + .get_or_try_init(|| async { hash_admin_password("genarrative-admin-dummy-password").await }) + .await + .map(String::as_str) +} + +fn is_admin_account_not_found(error: &SpacetimeClientError) -> bool { + matches!( + error, + SpacetimeClientError::Procedure(message) if message == "后台账号不存在" + ) +} + enum AdminPermissionRequirement { Authenticated, AnyOf(&'static [&'static str]), @@ -3790,12 +3862,14 @@ mod tests { build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit, clamp_admin_tracking_event_limit, enforce_admin_request_permission, - finalize_admin_database_table_rows_response, is_safe_spacetime_table_name, + finalize_admin_database_table_rows_response, hash_admin_password, + is_admin_account_not_found, is_safe_spacetime_table_name, matches_admin_editor_asset_keyword, normalize_debug_path, normalize_table_count_error, parse_admin_database_table_rows_sql_response, parse_admin_tracking_event_keys_sql_response, parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, resolve_admin_dashboard_range, resolve_admin_database_table_sql_limit, - timestamp_value_to_micros, trim_preview, wallet_ledger_source_type_to_string, + timestamp_value_to_micros, trim_preview, verify_admin_password, + wallet_ledger_source_type_to_string, }; use axum::{ http::{Method, StatusCode}, @@ -3807,6 +3881,7 @@ mod tests { AdminTrackingEventListQuery, }; use shared_contracts::assets::GetReadUrlQuery; + use spacetime_client::{SpacetimeClientError, SpacetimeClientStage}; use crate::{config::AppConfig, state::AppState}; @@ -4758,6 +4833,40 @@ mod tests { assert_eq!(directory.resolve("admin-account-missing"), "已停用管理员"); } + #[tokio::test] + async fn admin_password_helpers_hash_and_verify_off_executor() { + let hash = hash_admin_password("member-password") + .await + .expect("password should hash"); + + assert!( + verify_admin_password(&hash, "member-password") + .await + .expect("password should verify") + ); + assert!( + !verify_admin_password(&hash, "wrong-password") + .await + .expect("wrong password should compare") + ); + } + + #[test] + fn only_exact_admin_account_absence_invalidates_member_session() { + assert!(is_admin_account_not_found( + &SpacetimeClientError::Procedure("后台账号不存在".to_string()) + )); + assert!(!is_admin_account_not_found(&SpacetimeClientError::Timeout( + SpacetimeClientStage::ProcedureResult + ))); + assert!(!is_admin_account_not_found( + &SpacetimeClientError::ConnectDropped + )); + assert!(!is_admin_account_not_found( + &SpacetimeClientError::Procedure("后台账号不存在:依赖异常".to_string()) + )); + } + #[test] fn admin_tab_permissions_cover_shared_and_sensitive_routes() { assert!( diff --git a/server-rs/crates/api-server/src/admin_accounts.rs b/server-rs/crates/api-server/src/admin_accounts.rs index f8e148925..95bbe864e 100644 --- a/server-rs/crates/api-server/src/admin_accounts.rs +++ b/server-rs/crates/api-server/src/admin_accounts.rs @@ -3,7 +3,6 @@ use axum::{ extract::{Extension, Path, State}, http::StatusCode, }; -use platform_auth::hash_password; use serde_json::Value; use shared_contracts::admin::{ ADMIN_TAB_PERMISSIONS, AdminAccountListResponse, AdminAccountPayload, AdminAccountRole, @@ -17,13 +16,16 @@ use spacetime_client::{ use uuid::Uuid; use crate::{ - admin::AuthenticatedAdmin, api_response::json_success_body, http_error::AppError, - request_context::RequestContext, state::AppState, + admin::{AuthenticatedAdmin, hash_admin_password}, + api_response::json_success_body, + http_error::AppError, + request_context::RequestContext, + state::AppState, }; const ADMIN_USERNAME_MIN_CHARS: usize = 3; const ADMIN_USERNAME_MAX_CHARS: usize = 64; -const ADMIN_DISPLAY_NAME_MAX_CHARS: usize = 80; +const ADMIN_DISPLAY_NAME_MAX_CHARS: usize = 64; const ADMIN_PASSWORD_MIN_CHARS: usize = 6; const ADMIN_PASSWORD_MAX_CHARS: usize = 128; @@ -72,7 +74,7 @@ pub async fn admin_create_account( Json(payload): Json, ) -> Result, AppError> { require_owner(&admin)?; - let username = normalize_username(&payload.username)?; + let username = normalize_admin_account_username(&payload.username)?; let owner_username = state .admin_runtime() .map(|runtime| runtime.username().trim().to_ascii_lowercase()) @@ -83,9 +85,7 @@ pub async fn admin_create_account( let display_name = normalize_display_name(&payload.display_name)?; validate_password(&payload.password)?; let tab_permissions = normalize_tab_permissions(payload.tab_permissions)?; - let password_hash = hash_password(&payload.password).await.map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) - })?; + let password_hash = hash_admin_password(&payload.password).await?; let account = state .spacetime_client() @@ -136,10 +136,7 @@ pub async fn admin_update_account( let password_hash = match payload.password { Some(password) => { validate_password(&password)?; - Some(hash_password(&password).await.map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(error.to_string()) - })?) + Some(hash_admin_password(&password).await?) } None => None, }; @@ -172,7 +169,7 @@ fn require_owner(admin: &AuthenticatedAdmin) -> Result<(), AppError> { Err(AppError::from_status(StatusCode::FORBIDDEN).with_message("仅 owner 可管理后台账号")) } -fn normalize_username(raw: &str) -> Result { +pub(crate) fn normalize_admin_account_username(raw: &str) -> Result { let username = raw.trim().to_ascii_lowercase(); let char_count = username.chars().count(); if !(ADMIN_USERNAME_MIN_CHARS..=ADMIN_USERNAME_MAX_CHARS).contains(&char_count) @@ -276,7 +273,11 @@ mod tests { #[test] fn usernames_are_ascii_and_lowercase() { - assert_eq!(normalize_username(" Operator_1 ").unwrap(), "operator_1"); - assert!(normalize_username("管理员").is_err()); + assert_eq!( + normalize_admin_account_username(" Operator_1 ").unwrap(), + "operator_1" + ); + assert!(normalize_admin_account_username("管理员").is_err()); + assert!(normalize_admin_account_username(&"a".repeat(65)).is_err()); } } diff --git a/server-rs/crates/api-server/src/admin_recharge.rs b/server-rs/crates/api-server/src/admin_recharge.rs index 971db6ed1..99a386900 100644 --- a/server-rs/crates/api-server/src/admin_recharge.rs +++ b/server-rs/crates/api-server/src/admin_recharge.rs @@ -529,15 +529,14 @@ pub async fn admin_update_wallet_restriction( AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), ) })?; + let admin_display_names = load_admin_display_name_directory(&state) + .await + .map_err(|error| spacetime_error_response(&request_context, error))?; let wallet = state .spacetime_client() .admin_upsert_profile_wallet_manual_restriction(input) .await .map_err(|error| spacetime_error_response(&request_context, error))?; - let admin_display_names = load_admin_display_name_directory(&state) - .await - .map_err(|error| spacetime_error_response(&request_context, error))?; - Ok(json_success_body( Some(&request_context), AdminWalletRestrictionResponse { diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 834a6fd4c..ce8d714e1 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -902,6 +902,14 @@ pub async fn admin_upsert_profile_task_config( .with_message("个人任务 scopeKind 首版仅支持 user"), )); } + let admin_display_names = load_admin_display_name_directory(&state) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let record = state .spacetime_client() @@ -926,15 +934,6 @@ pub async fn admin_upsert_profile_task_config( map_runtime_profile_client_error(error), ) })?; - let admin_display_names = load_admin_display_name_directory(&state) - .await - .map_err(|error| { - runtime_profile_error_response( - &request_context, - map_runtime_profile_client_error(error), - ) - })?; - Ok(json_success_body( Some(&request_context), build_profile_task_config_admin_response(record, &admin_display_names), @@ -947,6 +946,14 @@ pub async fn admin_disable_profile_task_config( Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { + let admin_display_names = load_admin_display_name_directory(&state) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let record = state .spacetime_client() @@ -962,15 +969,6 @@ pub async fn admin_disable_profile_task_config( map_runtime_profile_client_error(error), ) })?; - let admin_display_names = load_admin_display_name_directory(&state) - .await - .map_err(|error| { - runtime_profile_error_response( - &request_context, - map_runtime_profile_client_error(error), - ) - })?; - Ok(json_success_body( Some(&request_context), build_profile_task_config_admin_response(record, &admin_display_names), @@ -1013,6 +1011,14 @@ pub async fn admin_upsert_profile_wallet_config( Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { + let admin_display_names = load_admin_display_name_directory(&state) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let record = state .spacetime_client() @@ -1028,15 +1034,6 @@ pub async fn admin_upsert_profile_wallet_config( map_runtime_profile_client_error(error), ) })?; - let admin_display_names = load_admin_display_name_directory(&state) - .await - .map_err(|error| { - runtime_profile_error_response( - &request_context, - map_runtime_profile_client_error(error), - ) - })?; - Ok(json_success_body( Some(&request_context), build_profile_wallet_config_admin_response(record, &admin_display_names), diff --git a/server-rs/crates/platform-auth/Cargo.toml b/server-rs/crates/platform-auth/Cargo.toml index 67cda1529..0133f2c14 100644 --- a/server-rs/crates/platform-auth/Cargo.toml +++ b/server-rs/crates/platform-auth/Cargo.toml @@ -16,6 +16,7 @@ rand_core = { workspace = true, features = ["getrandom"] } serde = { workspace = true } shared-kernel = { workspace = true } time = { workspace = true, features = ["std"] } +tokio = { workspace = true, features = ["rt"] } tracing = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index 65dbe3aeb..cd6dba354 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -1755,23 +1755,35 @@ pub fn read_refresh_session_token( } pub async fn hash_password(password: &str) -> Result { - let salt = SaltString::generate(&mut OsRng); - Argon2::default() - .hash_password(password.as_bytes(), &salt) - .map(|hash| hash.to_string()) - .map_err(|error| PasswordHashError::HashFailed(format!("密码哈希失败:{error}"))) + let password = password.to_string(); + tokio::task::spawn_blocking(move || { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(|error| PasswordHashError::HashFailed(format!("密码哈希失败:{error}"))) + }) + .await + .map_err(|error| PasswordHashError::HashFailed(format!("密码哈希任务失败:{error}")))? } pub async fn verify_password( password_hash: &str, password: &str, ) -> Result { - let parsed_hash = PasswordHash::new(password_hash) - .map_err(|error| PasswordHashError::VerifyFailed(format!("密码哈希格式非法:{error}")))?; + let password_hash = password_hash.to_string(); + let password = password.to_string(); + tokio::task::spawn_blocking(move || { + let parsed_hash = PasswordHash::new(&password_hash).map_err(|error| { + PasswordHashError::VerifyFailed(format!("密码哈希格式非法:{error}")) + })?; - Ok(Argon2::default() - .verify_password(password.as_bytes(), &parsed_hash) - .is_ok()) + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok()) + }) + .await + .map_err(|error| PasswordHashError::VerifyFailed(format!("密码校验任务失败:{error}")))? } pub fn create_refresh_session_token() -> String { diff --git a/server-rs/crates/spacetime-client/src/admin_account.rs b/server-rs/crates/spacetime-client/src/admin_account.rs index f51d5e6bf..ffc33e004 100644 --- a/server-rs/crates/spacetime-client/src/admin_account.rs +++ b/server-rs/crates/spacetime-client/src/admin_account.rs @@ -4,7 +4,7 @@ impl SpacetimeClient { pub async fn get_admin_account_by_username( &self, username: String, - ) -> Result { + ) -> Result { let input = AdminAccountGetByUsernameInput { username }; self.call_after_connect( "get_admin_account_by_username_and_return", @@ -14,7 +14,7 @@ impl SpacetimeClient { .get_admin_account_by_username_and_return_then(input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_admin_account_single_procedure_result); + .and_then(map_admin_account_credential_procedure_result); send_once(&sender, mapped); }); }, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 09f236c65..ea01c864f 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -6,10 +6,10 @@ mod mapper; mod telemetry; use mapper::*; pub use mapper::{ - AdminAccountCreateRecordInput, AdminAccountRecord, AdminAccountUpdateRecordInput, - AdminEditorAssetListRecordInput, AdminEditorAssetRecord, AdminWorkVisibilityRecord, - AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, - AiTextChunkRecord, BarkBattleDraftConfigRecord, BarkBattleRunRecord, + AdminAccountCreateRecordInput, AdminAccountCredentialRecord, AdminAccountRecord, + AdminAccountUpdateRecordInput, AdminEditorAssetListRecordInput, AdminEditorAssetRecord, + AdminWorkVisibilityRecord, AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, + AiTaskStageRecord, AiTextChunkRecord, BarkBattleDraftConfigRecord, BarkBattleRunRecord, BarkBattleRuntimeConfigRecord, BattleStateRecord, BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 0e71be3cc..4b1427806 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -33,7 +33,8 @@ pub use self::ai::{ AiTextChunkRecord, }; pub use self::admin_account::{ - AdminAccountCreateRecordInput, AdminAccountRecord, AdminAccountUpdateRecordInput, + AdminAccountCreateRecordInput, AdminAccountCredentialRecord, AdminAccountRecord, + AdminAccountUpdateRecordInput, }; pub use self::assets::{ BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, @@ -217,7 +218,8 @@ pub(crate) use self::combat::{ map_combat_outcome, map_resolve_combat_action_procedure_result, }; pub(crate) use self::admin_account::{ - map_admin_account_list_procedure_result, map_admin_account_single_procedure_result, + map_admin_account_credential_procedure_result, map_admin_account_list_procedure_result, + map_admin_account_single_procedure_result, }; pub(crate) use self::common::{empty_string_to_none, i64_to_u64_ms, parse_optional_json_value}; pub(crate) use self::custom_world::{ diff --git a/server-rs/crates/spacetime-client/src/mapper/admin_account.rs b/server-rs/crates/spacetime-client/src/mapper/admin_account.rs index e81a3093e..b8a6d4fda 100644 --- a/server-rs/crates/spacetime-client/src/mapper/admin_account.rs +++ b/server-rs/crates/spacetime-client/src/mapper/admin_account.rs @@ -5,7 +5,6 @@ pub struct AdminAccountRecord { pub account_id: String, pub username: String, pub display_name: String, - pub password_hash: String, pub tab_permissions: Vec, pub enabled: bool, pub token_version: u64, @@ -15,6 +14,12 @@ pub struct AdminAccountRecord { pub updated_at: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AdminAccountCredentialRecord { + pub account: AdminAccountRecord, + pub password_hash: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct AdminAccountCreateRecordInput { pub account_id: String, @@ -79,6 +84,20 @@ pub(crate) fn map_admin_account_single_procedure_result( .ok_or_else(|| SpacetimeClientError::missing_snapshot("后台账号快照")) } +pub(crate) fn map_admin_account_credential_procedure_result( + result: AdminAccountCredentialProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + result + .account + .map(map_admin_account_credential_snapshot) + .transpose()? + .ok_or_else(|| SpacetimeClientError::missing_snapshot("后台账号凭据快照")) +} + pub(crate) fn map_admin_account_list_procedure_result( result: AdminAccountProcedureResult, ) -> Result, SpacetimeClientError> { @@ -100,7 +119,6 @@ fn map_admin_account_snapshot( account_id: snapshot.account_id, username: snapshot.username, display_name: snapshot.display_name, - password_hash: snapshot.password_hash, tab_permissions: serde_json::from_str::>( snapshot.tab_permissions_json.as_str(), ) @@ -114,17 +132,25 @@ fn map_admin_account_snapshot( }) } +fn map_admin_account_credential_snapshot( + snapshot: AdminAccountCredentialSnapshot, +) -> Result { + Ok(AdminAccountCredentialRecord { + account: map_admin_account_snapshot(snapshot.account)?, + password_hash: snapshot.password_hash, + }) +} + #[cfg(test)] mod tests { use super::*; #[test] - fn maps_disabled_account_and_internal_password_hash() { + fn maps_public_account_without_password_hash() { let record = map_admin_account_snapshot(AdminAccountSnapshot { account_id: "member-1".to_string(), username: "operator".to_string(), display_name: "运营".to_string(), - password_hash: "$argon2id$hash".to_string(), tab_permissions_json: r#"["dashboard","tracking"]"#.to_string(), enabled: false, token_version: 3, @@ -135,9 +161,31 @@ mod tests { }) .expect("snapshot should map"); - assert_eq!(record.password_hash, "$argon2id$hash"); assert_eq!(record.tab_permissions, ["dashboard", "tracking"]); assert!(!record.enabled); assert_eq!(record.token_version, 3); } + + #[test] + fn maps_login_credential_separately_from_public_account() { + let record = map_admin_account_credential_snapshot(AdminAccountCredentialSnapshot { + account: AdminAccountSnapshot { + account_id: "member-1".to_string(), + username: "operator".to_string(), + display_name: "运营".to_string(), + tab_permissions_json: r#"["dashboard"]"#.to_string(), + enabled: true, + token_version: 3, + created_by: "owner".to_string(), + updated_by: "owner".to_string(), + created_at_micros: 0, + updated_at_micros: 0, + }, + password_hash: "$argon2id$hash".to_string(), + }) + .expect("credential snapshot should map"); + + assert_eq!(record.account.account_id, "member-1"); + assert_eq!(record.password_hash, "$argon2id$hash"); + } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 2b52b9596..b9b975017 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -11,6 +11,8 @@ pub mod acknowledge_external_generation_job_summaries_and_return_procedure; pub mod acknowledge_external_generation_jobs_and_return_procedure; pub mod acknowledge_quest_completion_reducer; pub mod admin_account_create_input_type; +pub mod admin_account_credential_procedure_result_type; +pub mod admin_account_credential_snapshot_type; pub mod admin_account_get_by_id_input_type; pub mod admin_account_get_by_username_input_type; pub mod admin_account_procedure_result_type; @@ -1425,6 +1427,8 @@ pub use acknowledge_external_generation_job_summaries_and_return_procedure::ackn pub use acknowledge_external_generation_jobs_and_return_procedure::acknowledge_external_generation_jobs_and_return; pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; pub use admin_account_create_input_type::AdminAccountCreateInput; +pub use admin_account_credential_procedure_result_type::AdminAccountCredentialProcedureResult; +pub use admin_account_credential_snapshot_type::AdminAccountCredentialSnapshot; pub use admin_account_get_by_id_input_type::AdminAccountGetByIdInput; pub use admin_account_get_by_username_input_type::AdminAccountGetByUsernameInput; pub use admin_account_procedure_result_type::AdminAccountProcedureResult; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_account_credential_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_account_credential_procedure_result_type.rs new file mode 100644 index 000000000..afa2b2de2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_account_credential_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::admin_account_credential_snapshot_type::AdminAccountCredentialSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AdminAccountCredentialProcedureResult { + pub ok: bool, + pub account: Option, + pub error_message: Option, +} + +impl __sdk::InModule for AdminAccountCredentialProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_account_credential_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_account_credential_snapshot_type.rs new file mode 100644 index 000000000..731b23d86 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_account_credential_snapshot_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::admin_account_snapshot_type::AdminAccountSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AdminAccountCredentialSnapshot { + pub account: AdminAccountSnapshot, + pub password_hash: String, +} + +impl __sdk::InModule for AdminAccountCredentialSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_account_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_account_snapshot_type.rs index cfe212af8..901e28029 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_account_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_account_snapshot_type.rs @@ -10,7 +10,6 @@ pub struct AdminAccountSnapshot { pub account_id: String, pub username: String, pub display_name: String, - pub password_hash: String, pub tab_permissions_json: String, pub enabled: bool, pub token_version: u64, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_admin_account_by_username_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_admin_account_by_username_and_return_procedure.rs index f6520ebe5..a441962e4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_admin_account_by_username_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_admin_account_by_username_and_return_procedure.rs @@ -4,8 +4,8 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::admin_account_credential_procedure_result_type::AdminAccountCredentialProcedureResult; use super::admin_account_get_by_username_input_type::AdminAccountGetByUsernameInput; -use super::admin_account_procedure_result_type::AdminAccountProcedureResult; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] @@ -32,7 +32,7 @@ pub trait get_admin_account_by_username_and_return { __callback: impl FnOnce( &super::ProcedureEventContext, - Result, + Result, ) + Send + 'static, ); @@ -45,12 +45,12 @@ impl get_admin_account_by_username_and_return for super::RemoteProcedures { __callback: impl FnOnce( &super::ProcedureEventContext, - Result, + Result, ) + Send + 'static, ) { self.imp - .invoke_procedure_with_callback::<_, AdminAccountProcedureResult>( + .invoke_procedure_with_callback::<_, AdminAccountCredentialProcedureResult>( "get_admin_account_by_username_and_return", GetAdminAccountByUsernameAndReturnArgs { input }, __callback, diff --git a/server-rs/crates/spacetime-module/src/admin_account_storage.rs b/server-rs/crates/spacetime-module/src/admin_account_storage.rs index 90ec3846b..243a9d135 100644 --- a/server-rs/crates/spacetime-module/src/admin_account_storage.rs +++ b/server-rs/crates/spacetime-module/src/admin_account_storage.rs @@ -4,12 +4,13 @@ use crate::*; use shared_contracts::admin::ADMIN_TAB_PERMISSIONS; const ADMIN_ACCOUNT_MAX_ID_CHARS: usize = 128; -const ADMIN_ACCOUNT_MAX_USERNAME_CHARS: usize = 80; -const ADMIN_ACCOUNT_MAX_DISPLAY_NAME_CHARS: usize = 80; +const ADMIN_ACCOUNT_MAX_USERNAME_CHARS: usize = 64; +const ADMIN_ACCOUNT_MAX_DISPLAY_NAME_CHARS: usize = 64; const ADMIN_ACCOUNT_MAX_ACTOR_CHARS: usize = 128; const ADMIN_ACCOUNT_MAX_PASSWORD_HASH_CHARS: usize = 512; #[spacetimedb::table(accessor = admin_account)] +#[derive(Clone)] pub struct AdminAccount { #[primary_key] pub account_id: String, @@ -62,7 +63,6 @@ pub struct AdminAccountSnapshot { pub account_id: String, pub username: String, pub display_name: String, - pub password_hash: String, pub tab_permissions_json: String, pub enabled: bool, pub token_version: u64, @@ -72,6 +72,12 @@ pub struct AdminAccountSnapshot { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AdminAccountCredentialSnapshot { + pub account: AdminAccountSnapshot, + pub password_hash: String, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct AdminAccountProcedureResult { pub ok: bool, @@ -80,18 +86,25 @@ pub struct AdminAccountProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AdminAccountCredentialProcedureResult { + pub ok: bool, + pub account: Option, + pub error_message: Option, +} + #[spacetimedb::procedure] pub fn get_admin_account_by_username_and_return( ctx: &mut ProcedureContext, input: AdminAccountGetByUsernameInput, -) -> AdminAccountProcedureResult { +) -> AdminAccountCredentialProcedureResult { let caller = ctx.sender(); match ctx.try_with_tx(|tx| { require_admin_account_service_identity(tx, caller)?; get_admin_account_by_username(tx, input.clone()) }) { - Ok(account) => admin_account_single_ok(account), - Err(message) => admin_account_error(message), + Ok(account) => admin_account_credential_ok(account), + Err(message) => admin_account_credential_error(message), } } @@ -162,13 +175,13 @@ fn require_admin_account_service_identity( fn get_admin_account_by_username( ctx: &ReducerContext, input: AdminAccountGetByUsernameInput, -) -> Result { +) -> Result { let username = normalize_username(&input.username)?; ctx.db .admin_account() .username() .find(&username) - .map(admin_account_snapshot_from_row) + .map(admin_account_credential_snapshot_from_row) .ok_or_else(|| "后台账号不存在".to_string()) } @@ -185,7 +198,7 @@ fn get_admin_account_by_id( .admin_account() .account_id() .find(&account_id) - .map(admin_account_public_snapshot_from_row) + .map(admin_account_snapshot_from_row) .ok_or_else(|| "后台账号不存在".to_string()) } @@ -194,7 +207,7 @@ fn list_admin_accounts(ctx: &ReducerContext) -> Vec { .db .admin_account() .iter() - .map(admin_account_public_snapshot_from_row) + .map(admin_account_snapshot_from_row) .collect::>(); accounts.sort_by(|left, right| { left.username @@ -253,7 +266,7 @@ fn create_admin_account( .admin_account() .account_id() .find(&account_id) - .map(admin_account_public_snapshot_from_row) + .map(admin_account_snapshot_from_row) .ok_or_else(|| "后台账号创建失败".to_string()) } @@ -303,7 +316,7 @@ fn update_admin_account( .admin_account() .account_id() .find(&account_id) - .map(admin_account_public_snapshot_from_row) + .map(admin_account_snapshot_from_row) .ok_or_else(|| "后台账号更新失败".to_string()) } @@ -343,7 +356,6 @@ fn admin_account_snapshot_from_row(row: AdminAccount) -> AdminAccountSnapshot { account_id: row.account_id, username: row.username, display_name: row.display_name, - password_hash: row.password_hash, tab_permissions_json: row.tab_permissions_json, enabled: row.enabled, token_version: row.token_version, @@ -354,10 +366,10 @@ fn admin_account_snapshot_from_row(row: AdminAccount) -> AdminAccountSnapshot { } } -fn admin_account_public_snapshot_from_row(row: AdminAccount) -> AdminAccountSnapshot { - AdminAccountSnapshot { - password_hash: String::new(), - ..admin_account_snapshot_from_row(row) +fn admin_account_credential_snapshot_from_row(row: AdminAccount) -> AdminAccountCredentialSnapshot { + AdminAccountCredentialSnapshot { + password_hash: row.password_hash.clone(), + account: admin_account_snapshot_from_row(row), } } @@ -432,6 +444,16 @@ fn admin_account_single_ok(account: AdminAccountSnapshot) -> AdminAccountProcedu } } +fn admin_account_credential_ok( + account: AdminAccountCredentialSnapshot, +) -> AdminAccountCredentialProcedureResult { + AdminAccountCredentialProcedureResult { + ok: true, + account: Some(account), + error_message: None, + } +} + fn admin_account_list_ok(accounts: Vec) -> AdminAccountProcedureResult { AdminAccountProcedureResult { ok: true, @@ -450,6 +472,14 @@ fn admin_account_error(message: String) -> AdminAccountProcedureResult { } } +fn admin_account_credential_error(message: String) -> AdminAccountCredentialProcedureResult { + AdminAccountCredentialProcedureResult { + ok: false, + account: None, + error_message: Some(message), + } +} + #[cfg(test)] mod tests { use super::*; @@ -542,8 +572,8 @@ mod tests { } #[test] - fn public_account_snapshot_does_not_expose_password_hash() { - let snapshot = admin_account_public_snapshot_from_row(AdminAccount { + fn public_and_credential_account_snapshots_are_type_separated() { + let row = AdminAccount { account_id: "member-1".to_string(), username: "operator".to_string(), display_name: "运营".to_string(), @@ -555,8 +585,12 @@ mod tests { updated_by: "owner".to_string(), created_at: Timestamp::from_micros_since_unix_epoch(10), updated_at: Timestamp::from_micros_since_unix_epoch(10), - }); + }; + let public_snapshot = admin_account_snapshot_from_row(row.clone()); + let credential_snapshot = admin_account_credential_snapshot_from_row(row); - assert!(snapshot.password_hash.is_empty()); + assert_eq!(public_snapshot.account_id, "member-1"); + assert_eq!(credential_snapshot.account, public_snapshot); + assert_eq!(credential_snapshot.password_hash, "$argon2id$secret"); } }