Files
Genarrative/server-rs/crates/api-server/src/admin_accounts.rs
T
kdletters f3936f60a3 修复后台多账号安全与事务边界
将Argon2密码操作移出异步执行器并限制并发
统一未知停用和非法账号的登录校验与错误语义
隔离登录凭据快照和普通账号公开契约
调整管理员显示名查询顺序避免写入伪失败
新增后台账号procedure身份与事务集成烟测
同步后台账号技术方案与开发验证文档
2026-07-14 20:30:44 +08:00

284 lines
9.5 KiB
Rust

use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
};
use serde_json::Value;
use shared_contracts::admin::{
ADMIN_TAB_PERMISSIONS, AdminAccountListResponse, AdminAccountPayload, AdminAccountRole,
AdminCreateAccountRequest, AdminCreateAccountResponse, AdminUpdateAccountRequest,
AdminUpdateAccountResponse,
};
use spacetime_client::{
AdminAccountCreateRecordInput, AdminAccountRecord, AdminAccountUpdateRecordInput,
SpacetimeClientError,
};
use uuid::Uuid;
use crate::{
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 = 64;
const ADMIN_PASSWORD_MIN_CHARS: usize = 6;
const ADMIN_PASSWORD_MAX_CHARS: usize = 128;
pub async fn admin_list_accounts(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, AppError> {
let runtime = state.admin_runtime().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用")
})?;
let mut accounts = vec![AdminAccountPayload {
account_id: runtime.subject().to_string(),
username: runtime.username().to_string(),
display_name: runtime.display_name().to_string(),
account_role: AdminAccountRole::Owner,
tab_permissions: ADMIN_TAB_PERMISSIONS
.iter()
.map(|permission| (*permission).to_string())
.collect(),
enabled: true,
token_version: 1,
created_by: "environment".to_string(),
updated_by: "environment".to_string(),
created_at: String::new(),
updated_at: String::new(),
}];
accounts.extend(
state
.spacetime_client()
.list_admin_accounts()
.await
.map_err(map_admin_account_error)?
.into_iter()
.map(admin_account_payload),
);
Ok(json_success_body(
Some(&request_context),
AdminAccountListResponse { accounts },
))
}
pub async fn admin_create_account(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminCreateAccountRequest>,
) -> Result<Json<Value>, AppError> {
require_owner(&admin)?;
let username = normalize_admin_account_username(&payload.username)?;
let owner_username = state
.admin_runtime()
.map(|runtime| runtime.username().trim().to_ascii_lowercase())
.unwrap_or_default();
if username == owner_username {
return Err(AppError::from_status(StatusCode::CONFLICT).with_message("后台用户名已存在"));
}
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_admin_password(&payload.password).await?;
let account = state
.spacetime_client()
.create_admin_account(AdminAccountCreateRecordInput {
account_id: format!("admin-account-{}", Uuid::new_v4()),
username,
display_name,
password_hash,
tab_permissions,
enabled: payload.enabled,
created_by: admin.session().subject.clone(),
})
.await
.map_err(map_admin_account_error)?;
Ok(json_success_body(
Some(&request_context),
AdminCreateAccountResponse {
account: admin_account_payload(account),
},
))
}
pub async fn admin_update_account(
State(state): State<AppState>,
Path(account_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpdateAccountRequest>,
) -> Result<Json<Value>, AppError> {
require_owner(&admin)?;
let account_id = account_id.trim().to_string();
if account_id.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("后台账号 ID 不能为空")
);
}
if state
.admin_runtime()
.is_some_and(|runtime| runtime.subject() == account_id.as_str())
{
return Err(
AppError::from_status(StatusCode::FORBIDDEN).with_message("owner 账号不能通过后台修改")
);
}
let display_name = normalize_display_name(&payload.display_name)?;
let tab_permissions = normalize_tab_permissions(payload.tab_permissions)?;
let password_hash = match payload.password {
Some(password) => {
validate_password(&password)?;
Some(hash_admin_password(&password).await?)
}
None => None,
};
let account = state
.spacetime_client()
.update_admin_account(AdminAccountUpdateRecordInput {
account_id,
display_name,
password_hash,
tab_permissions,
enabled: payload.enabled,
updated_by: admin.session().subject.clone(),
})
.await
.map_err(map_admin_account_error)?;
Ok(json_success_body(
Some(&request_context),
AdminUpdateAccountResponse {
account: admin_account_payload(account),
},
))
}
fn require_owner(admin: &AuthenticatedAdmin) -> Result<(), AppError> {
if admin.session().account_role == AdminAccountRole::Owner {
return Ok(());
}
Err(AppError::from_status(StatusCode::FORBIDDEN).with_message("仅 owner 可管理后台账号"))
}
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)
|| !username
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("后台用户名格式无效")
);
}
Ok(username)
}
fn normalize_display_name(raw: &str) -> Result<String, AppError> {
let display_name = raw.trim().to_string();
if display_name.is_empty() || display_name.chars().count() > ADMIN_DISPLAY_NAME_MAX_CHARS {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("后台显示名称格式无效")
);
}
Ok(display_name)
}
fn validate_password(password: &str) -> Result<(), AppError> {
let char_count = password.chars().count();
if !(ADMIN_PASSWORD_MIN_CHARS..=ADMIN_PASSWORD_MAX_CHARS).contains(&char_count) {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("后台密码长度必须为 6 至 128 个字符"));
}
Ok(())
}
fn normalize_tab_permissions(permissions: Vec<String>) -> Result<Vec<String>, AppError> {
let requested = permissions
.into_iter()
.map(|permission| permission.trim().to_string())
.collect::<std::collections::HashSet<_>>();
if requested
.iter()
.any(|permission| !ADMIN_TAB_PERMISSIONS.contains(&permission.as_str()))
{
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("后台 Tab 权限包含未知值")
);
}
Ok(ADMIN_TAB_PERMISSIONS
.iter()
.filter(|permission| requested.contains(**permission))
.map(|permission| (*permission).to_string())
.collect())
}
fn admin_account_payload(account: AdminAccountRecord) -> AdminAccountPayload {
AdminAccountPayload {
account_id: account.account_id,
username: account.username,
display_name: account.display_name,
account_role: AdminAccountRole::Member,
tab_permissions: account.tab_permissions,
enabled: account.enabled,
token_version: account.token_version,
created_by: account.created_by,
updated_by: account.updated_by,
created_at: account.created_at,
updated_at: account.updated_at,
}
}
fn map_admin_account_error(error: SpacetimeClientError) -> AppError {
let message = error.to_string();
let status = if message.contains("已存在") {
StatusCode::CONFLICT
} else if message.contains("不存在") {
StatusCode::NOT_FOUND
} else if message.contains("不能为空") || message.contains("无效") {
StatusCode::BAD_REQUEST
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_message(message)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tab_permissions_are_validated_deduplicated_and_ordered() {
assert_eq!(
normalize_tab_permissions(vec![
"tracking".to_string(),
"dashboard".to_string(),
"tracking".to_string(),
])
.expect("permissions should normalize"),
["dashboard", "tracking"]
);
assert!(normalize_tab_permissions(vec!["accounts".to_string()]).is_err());
}
#[test]
fn usernames_are_ascii_and_lowercase() {
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());
}
}