修复后台多账号安全与事务边界
将Argon2密码操作移出异步执行器并限制并发 统一未知停用和非法账号的登录校验与错误语义 隔离登录凭据快照和普通账号公开契约 调整管理员显示名查询顺序避免写入伪失败 新增后台账号procedure身份与事务集成烟测 同步后台账号技术方案与开发验证文档
This commit is contained in:
@@ -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` 保持本机私有。
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
@@ -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;
|
||||
}
|
||||
+18
@@ -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
-4
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user