修复后台多账号安全与事务边界

将Argon2密码操作移出异步执行器并限制并发
统一未知停用和非法账号的登录校验与错误语义
隔离登录凭据快照和普通账号公开契约
调整管理员显示名查询顺序避免写入伪失败
新增后台账号procedure身份与事务集成烟测
同步后台账号技术方案与开发验证文档
This commit is contained in:
2026-07-14 20:30:44 +08:00
parent bddc25b4e0
commit f3936f60a3
21 changed files with 953 additions and 120 deletions
@@ -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 图片画布生成资源支持提交前统一命名
@@ -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-<uuid>`;api-server 在返回兑换码、邀请码等操作记录时,按 owner 运行态和 `admin_account` 批量解析显示名称,同时兼容历史用户名记录。已无法解析的历史主体统一展示“已停用管理员”,前端不得直接渲染 `operatorUserId`、账号 ID 或登录用户名代替显示名称。
后台所有面向运营展示的管理员身份统一使用 `displayName`。审计表继续保存稳定 subject,例如 owner subject 或 `admin-account-<uuid>`;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 <database> \
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
@@ -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` 保持本机私有。
+1
View File
@@ -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",
File diff suppressed because it is too large Load Diff
+128 -19
View File
@@ -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<String> = 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<String, AppError> {
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<bool, AppError> {
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!(
@@ -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<AdminCreateAccountRequest>,
) -> Result<Json<Value>, 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<String, AppError> {
pub(crate) fn normalize_admin_account_username(raw: &str) -> Result<String, AppError> {
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());
}
}
@@ -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 {
@@ -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<AuthenticatedAdmin>,
Json(payload): Json<AdminDisableProfileTaskConfigRequest>,
) -> Result<Json<Value>, 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<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertProfileWalletConfigRequest>,
) -> Result<Json<Value>, 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),
@@ -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 }
+22 -10
View File
@@ -1755,23 +1755,35 @@ pub fn read_refresh_session_token(
}
pub async fn hash_password(password: &str) -> Result<String, PasswordHashError> {
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<bool, PasswordHashError> {
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 {
@@ -4,7 +4,7 @@ impl SpacetimeClient {
pub async fn get_admin_account_by_username(
&self,
username: String,
) -> Result<AdminAccountRecord, SpacetimeClientError> {
) -> Result<AdminAccountCredentialRecord, SpacetimeClientError> {
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);
});
},
+4 -4
View File
@@ -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,
@@ -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::{
@@ -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<String>,
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<AdminAccountCredentialRecord, SpacetimeClientError> {
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<Vec<AdminAccountRecord>, 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::<Vec<String>>(
snapshot.tab_permissions_json.as_str(),
)
@@ -114,17 +132,25 @@ fn map_admin_account_snapshot(
})
}
fn map_admin_account_credential_snapshot(
snapshot: AdminAccountCredentialSnapshot,
) -> Result<AdminAccountCredentialRecord, SpacetimeClientError> {
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");
}
}
@@ -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;
@@ -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<AdminAccountCredentialSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for AdminAccountCredentialProcedureResult {
type Module = super::RemoteModule;
}
@@ -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;
}
@@ -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,
@@ -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<AdminAccountProcedureResult, __sdk::InternalError>,
Result<AdminAccountCredentialProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
@@ -45,12 +45,12 @@ impl get_admin_account_by_username_and_return for super::RemoteProcedures {
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AdminAccountProcedureResult, __sdk::InternalError>,
Result<AdminAccountCredentialProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AdminAccountProcedureResult>(
.invoke_procedure_with_callback::<_, AdminAccountCredentialProcedureResult>(
"get_admin_account_by_username_and_return",
GetAdminAccountByUsernameAndReturnArgs { input },
__callback,
@@ -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<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AdminAccountCredentialProcedureResult {
pub ok: bool,
pub account: Option<AdminAccountCredentialSnapshot>,
pub error_message: Option<String>,
}
#[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<AdminAccountSnapshot, String> {
) -> Result<AdminAccountCredentialSnapshot, String> {
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<AdminAccountSnapshot> {
.db
.admin_account()
.iter()
.map(admin_account_public_snapshot_from_row)
.map(admin_account_snapshot_from_row)
.collect::<Vec<_>>();
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<AdminAccountSnapshot>) -> 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");
}
}