fix(saas): P0 安全修复 + P1 功能补全 — 角色提升、Admin 引导、IP 记录、密码修改
P0 安全修复: - 修复 account update 自角色提升漏洞: 非 admin 用户更新自己时剥离 role 字段 - 添加 Admin 引导机制: accounts 表为空时自动从环境变量创建 super_admin P1 功能补全: - 所有 17 个 log_operation 调用点传入真实客户端 IP (ConnectInfo + X-Forwarded-For) - AuthContext 新增 client_ip 字段, middleware 层自动提取 - main.rs 使用 into_make_service_with_connect_info 启用 SocketAddr 注入 - 新增 PUT /api/v1/auth/password 密码修改端点 (验证旧密码 + argon2 哈希) - 桌面端 SaaS 设置页添加密码修改 UI (折叠式表单) - SaaSClient 添加 changePassword() 方法 - 集成测试修复: 注入模拟 ConnectInfo 适配 onshot 测试模式
This commit is contained in:
@@ -44,12 +44,25 @@ pub async fn update_account(
|
|||||||
Extension(ctx): Extension<AuthContext>,
|
Extension(ctx): Extension<AuthContext>,
|
||||||
Json(req): Json<UpdateAccountRequest>,
|
Json(req): Json<UpdateAccountRequest>,
|
||||||
) -> SaasResult<Json<serde_json::Value>> {
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
let is_self_update = id == ctx.account_id;
|
||||||
|
|
||||||
// 非管理员只能修改自己的资料
|
// 非管理员只能修改自己的资料
|
||||||
if id != ctx.account_id {
|
if !is_self_update {
|
||||||
require_admin(&ctx)?;
|
require_admin(&ctx)?;
|
||||||
}
|
}
|
||||||
let result = service::update_account(&state.db, &id, &req).await?;
|
|
||||||
log_operation(&state.db, &ctx.account_id, "account.update", "account", &id, None, None).await?;
|
// 安全限制: 非管理员修改自己时,剥离 role 字段防止自角色提升
|
||||||
|
let safe_req = if is_self_update && !ctx.permissions.contains(&"admin:full".to_string()) {
|
||||||
|
UpdateAccountRequest {
|
||||||
|
role: None,
|
||||||
|
..req
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
req
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = service::update_account(&state.db, &id, &safe_req).await?;
|
||||||
|
log_operation(&state.db, &ctx.account_id, "account.update", "account", &id, None, ctx.client_ip.as_deref()).await?;
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +76,7 @@ pub async fn update_status(
|
|||||||
require_admin(&ctx)?;
|
require_admin(&ctx)?;
|
||||||
service::update_account_status(&state.db, &id, &req.status).await?;
|
service::update_account_status(&state.db, &id, &req.status).await?;
|
||||||
log_operation(&state.db, &ctx.account_id, "account.update_status", "account", &id,
|
log_operation(&state.db, &ctx.account_id, "account.update_status", "account", &id,
|
||||||
Some(serde_json::json!({"status": &req.status})), None).await?;
|
Some(serde_json::json!({"status": &req.status})), ctx.client_ip.as_deref()).await?;
|
||||||
Ok(Json(serde_json::json!({"ok": true})))
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +96,7 @@ pub async fn create_token(
|
|||||||
) -> SaasResult<Json<TokenInfo>> {
|
) -> SaasResult<Json<TokenInfo>> {
|
||||||
let token = service::create_api_token(&state.db, &ctx.account_id, &req).await?;
|
let token = service::create_api_token(&state.db, &ctx.account_id, &req).await?;
|
||||||
log_operation(&state.db, &ctx.account_id, "token.create", "api_token", &token.id,
|
log_operation(&state.db, &ctx.account_id, "token.create", "api_token", &token.id,
|
||||||
Some(serde_json::json!({"name": &req.name})), None).await?;
|
Some(serde_json::json!({"name": &req.name})), ctx.client_ip.as_deref()).await?;
|
||||||
Ok(Json(token))
|
Ok(Json(token))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +107,7 @@ pub async fn revoke_token(
|
|||||||
Extension(ctx): Extension<AuthContext>,
|
Extension(ctx): Extension<AuthContext>,
|
||||||
) -> SaasResult<Json<serde_json::Value>> {
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
service::revoke_api_token(&state.db, &id, &ctx.account_id).await?;
|
service::revoke_api_token(&state.db, &id, &ctx.account_id).await?;
|
||||||
log_operation(&state.db, &ctx.account_id, "token.revoke", "api_token", &id, None, None).await?;
|
log_operation(&state.db, &ctx.account_id, "token.revoke", "api_token", &id, None, ctx.client_ip.as_deref()).await?;
|
||||||
Ok(Json(serde_json::json!({"ok": true})))
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
//! 认证 HTTP 处理器
|
//! 认证 HTTP 处理器
|
||||||
|
|
||||||
use axum::{extract::State, http::StatusCode, Json};
|
use axum::{extract::{State, ConnectInfo}, http::StatusCode, Json};
|
||||||
|
use std::net::SocketAddr;
|
||||||
use secrecy::ExposeSecret;
|
use secrecy::ExposeSecret;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::error::{SaasError, SaasResult};
|
use crate::error::{SaasError, SaasResult};
|
||||||
use super::{
|
use super::{
|
||||||
jwt::create_token,
|
jwt::create_token,
|
||||||
password::{hash_password, verify_password},
|
password::{hash_password, verify_password},
|
||||||
types::{AuthContext, LoginRequest, LoginResponse, RegisterRequest, AccountPublic},
|
types::{AuthContext, LoginRequest, LoginResponse, RegisterRequest, ChangePasswordRequest, AccountPublic},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// POST /api/v1/auth/register
|
/// POST /api/v1/auth/register
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
Json(req): Json<RegisterRequest>,
|
Json(req): Json<RegisterRequest>,
|
||||||
) -> SaasResult<(StatusCode, Json<AccountPublic>)> {
|
) -> SaasResult<(StatusCode, Json<AccountPublic>)> {
|
||||||
if req.username.len() < 3 {
|
if req.username.len() < 3 {
|
||||||
@@ -54,7 +56,8 @@ pub async fn register(
|
|||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
log_operation(&state.db, &account_id, "account.create", "account", &account_id, None, None).await?;
|
let client_ip = addr.ip().to_string();
|
||||||
|
log_operation(&state.db, &account_id, "account.create", "account", &account_id, None, Some(&client_ip)).await?;
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(AccountPublic {
|
Ok((StatusCode::CREATED, Json(AccountPublic {
|
||||||
id: account_id,
|
id: account_id,
|
||||||
@@ -71,6 +74,7 @@ pub async fn register(
|
|||||||
/// POST /api/v1/auth/login
|
/// POST /api/v1/auth/login
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
Json(req): Json<LoginRequest>,
|
Json(req): Json<LoginRequest>,
|
||||||
) -> SaasResult<Json<LoginResponse>> {
|
) -> SaasResult<Json<LoginResponse>> {
|
||||||
let row: Option<(String, String, String, String, String, String, bool, String)> =
|
let row: Option<(String, String, String, String, String, String, bool, String)> =
|
||||||
@@ -112,7 +116,8 @@ pub async fn login(
|
|||||||
sqlx::query("UPDATE accounts SET last_login_at = ?1 WHERE id = ?2")
|
sqlx::query("UPDATE accounts SET last_login_at = ?1 WHERE id = ?2")
|
||||||
.bind(&now).bind(&id)
|
.bind(&now).bind(&id)
|
||||||
.execute(&state.db).await?;
|
.execute(&state.db).await?;
|
||||||
log_operation(&state.db, &id, "account.login", "account", &id, None, None).await?;
|
let client_ip = addr.ip().to_string();
|
||||||
|
log_operation(&state.db, &id, "account.login", "account", &id, None, Some(&client_ip)).await?;
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
token,
|
token,
|
||||||
@@ -158,6 +163,45 @@ pub async fn me(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PUT /api/v1/auth/password — 修改密码
|
||||||
|
pub async fn change_password(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
axum::extract::Extension(ctx): axum::extract::Extension<AuthContext>,
|
||||||
|
Json(req): Json<ChangePasswordRequest>,
|
||||||
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
if req.new_password.len() < 8 {
|
||||||
|
return Err(SaasError::InvalidInput("新密码至少 8 个字符".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前密码哈希
|
||||||
|
let (password_hash,): (String,) = sqlx::query_as(
|
||||||
|
"SELECT password_hash FROM accounts WHERE id = ?1"
|
||||||
|
)
|
||||||
|
.bind(&ctx.account_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 验证旧密码
|
||||||
|
if !verify_password(&req.old_password, &password_hash)? {
|
||||||
|
return Err(SaasError::AuthError("旧密码错误".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新密码
|
||||||
|
let new_hash = hash_password(&req.new_password)?;
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
sqlx::query("UPDATE accounts SET password_hash = ?1, updated_at = ?2 WHERE id = ?3")
|
||||||
|
.bind(&new_hash)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&ctx.account_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
log_operation(&state.db, &ctx.account_id, "account.change_password", "account", &ctx.account_id,
|
||||||
|
None, ctx.client_ip.as_deref()).await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({"ok": true, "message": "密码修改成功"})))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn get_role_permissions(db: &sqlx::SqlitePool, role: &str) -> SaasResult<Vec<String>> {
|
pub(crate) async fn get_role_permissions(db: &sqlx::SqlitePool, role: &str) -> SaasResult<Vec<String>> {
|
||||||
let row: Option<(String,)> = sqlx::query_as(
|
let row: Option<(String,)> = sqlx::query_as(
|
||||||
"SELECT permissions FROM roles WHERE id = ?1"
|
"SELECT permissions FROM roles WHERE id = ?1"
|
||||||
|
|||||||
@@ -10,16 +10,18 @@ use axum::{
|
|||||||
http::header,
|
http::header,
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
|
extract::ConnectInfo,
|
||||||
};
|
};
|
||||||
use secrecy::ExposeSecret;
|
use secrecy::ExposeSecret;
|
||||||
use crate::error::SaasError;
|
use crate::error::SaasError;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use types::AuthContext;
|
use types::AuthContext;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
/// 通过 API Token 验证身份
|
/// 通过 API Token 验证身份
|
||||||
///
|
///
|
||||||
/// 流程: SHA-256 哈希 → 查 api_tokens 表 → 检查有效期 → 获取关联账号角色权限 → 更新 last_used_at
|
/// 流程: SHA-256 哈希 → 查 api_tokens 表 → 检查有效期 → 获取关联账号角色权限 → 更新 last_used_at
|
||||||
async fn verify_api_token(state: &AppState, raw_token: &str) -> Result<AuthContext, SaasError> {
|
async fn verify_api_token(state: &AppState, raw_token: &str, client_ip: Option<String>) -> Result<AuthContext, SaasError> {
|
||||||
use sha2::{Sha256, Digest};
|
use sha2::{Sha256, Digest};
|
||||||
|
|
||||||
let token_hash = hex::encode(Sha256::digest(raw_token.as_bytes()));
|
let token_hash = hex::encode(Sha256::digest(raw_token.as_bytes()));
|
||||||
@@ -77,15 +79,36 @@ async fn verify_api_token(state: &AppState, raw_token: &str) -> Result<AuthConte
|
|||||||
account_id,
|
account_id,
|
||||||
role,
|
role,
|
||||||
permissions,
|
permissions,
|
||||||
|
client_ip,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从请求中提取客户端 IP
|
||||||
|
fn extract_client_ip(req: &Request) -> Option<String> {
|
||||||
|
// 优先从 ConnectInfo 获取
|
||||||
|
if let Some(ConnectInfo(addr)) = req.extensions().get::<ConnectInfo<SocketAddr>>() {
|
||||||
|
return Some(addr.ip().to_string());
|
||||||
|
}
|
||||||
|
// 回退到 X-Forwarded-For / X-Real-IP
|
||||||
|
if let Some(forwarded) = req.headers()
|
||||||
|
.get("x-forwarded-for")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
{
|
||||||
|
return Some(forwarded.split(',').next()?.trim().to_string());
|
||||||
|
}
|
||||||
|
req.headers()
|
||||||
|
.get("x-real-ip")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// 认证中间件: 从 JWT 或 API Token 提取身份
|
/// 认证中间件: 从 JWT 或 API Token 提取身份
|
||||||
pub async fn auth_middleware(
|
pub async fn auth_middleware(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
mut req: Request,
|
mut req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
let client_ip = extract_client_ip(&req);
|
||||||
let auth_header = req.headers()
|
let auth_header = req.headers()
|
||||||
.get(header::AUTHORIZATION)
|
.get(header::AUTHORIZATION)
|
||||||
.and_then(|v| v.to_str().ok());
|
.and_then(|v| v.to_str().ok());
|
||||||
@@ -94,7 +117,7 @@ pub async fn auth_middleware(
|
|||||||
if let Some(token) = auth.strip_prefix("Bearer ") {
|
if let Some(token) = auth.strip_prefix("Bearer ") {
|
||||||
if token.starts_with("zclaw_") {
|
if token.starts_with("zclaw_") {
|
||||||
// API Token 路径
|
// API Token 路径
|
||||||
verify_api_token(&state, token).await
|
verify_api_token(&state, token, client_ip.clone()).await
|
||||||
} else {
|
} else {
|
||||||
// JWT 路径
|
// JWT 路径
|
||||||
jwt::verify_token(token, state.jwt_secret.expose_secret())
|
jwt::verify_token(token, state.jwt_secret.expose_secret())
|
||||||
@@ -102,6 +125,7 @@ pub async fn auth_middleware(
|
|||||||
account_id: claims.sub,
|
account_id: claims.sub,
|
||||||
role: claims.role,
|
role: claims.role,
|
||||||
permissions: claims.permissions,
|
permissions: claims.permissions,
|
||||||
|
client_ip,
|
||||||
})
|
})
|
||||||
.map_err(|_| SaasError::Unauthorized)
|
.map_err(|_| SaasError::Unauthorized)
|
||||||
}
|
}
|
||||||
@@ -132,9 +156,10 @@ pub fn routes() -> axum::Router<AppState> {
|
|||||||
|
|
||||||
/// 需要认证的路由
|
/// 需要认证的路由
|
||||||
pub fn protected_routes() -> axum::Router<AppState> {
|
pub fn protected_routes() -> axum::Router<AppState> {
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post, put};
|
||||||
|
|
||||||
axum::Router::new()
|
axum::Router::new()
|
||||||
.route("/api/v1/auth/refresh", post(handlers::refresh))
|
.route("/api/v1/auth/refresh", post(handlers::refresh))
|
||||||
.route("/api/v1/auth/me", get(handlers::me))
|
.route("/api/v1/auth/me", get(handlers::me))
|
||||||
|
.route("/api/v1/auth/password", put(handlers::change_password))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ pub struct RegisterRequest {
|
|||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 修改密码请求
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ChangePasswordRequest {
|
||||||
|
pub old_password: String,
|
||||||
|
pub new_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// 公开账号信息 (无敏感数据)
|
/// 公开账号信息 (无敏感数据)
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct AccountPublic {
|
pub struct AccountPublic {
|
||||||
@@ -45,4 +52,5 @@ pub struct AuthContext {
|
|||||||
pub account_id: String,
|
pub account_id: String,
|
||||||
pub role: String,
|
pub role: String,
|
||||||
pub permissions: Vec<String>,
|
pub permissions: Vec<String>,
|
||||||
|
pub client_ip: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ pub async fn init_db(database_url: &str) -> SaasResult<SqlitePool> {
|
|||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query(SEED_ROLES).execute(&pool).await?;
|
sqlx::query(SEED_ROLES).execute(&pool).await?;
|
||||||
|
seed_admin_account(&pool).await?;
|
||||||
tracing::info!("Database initialized (schema v{})", SCHEMA_VERSION);
|
tracing::info!("Database initialized (schema v{})", SCHEMA_VERSION);
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
}
|
}
|
||||||
@@ -244,6 +245,58 @@ pub async fn init_memory_db() -> SaasResult<SqlitePool> {
|
|||||||
Ok(pool)
|
Ok(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 如果 accounts 表为空且环境变量已设置,自动创建 super_admin 账号
|
||||||
|
async fn seed_admin_account(pool: &SqlitePool) -> SaasResult<()> {
|
||||||
|
let has_accounts: (bool,) = sqlx::query_as(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM accounts LIMIT 1) as has"
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if has_accounts.0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let admin_username = std::env::var("ZCLAW_ADMIN_USERNAME")
|
||||||
|
.unwrap_or_else(|_| "admin".to_string());
|
||||||
|
let admin_password = match std::env::var("ZCLAW_ADMIN_PASSWORD") {
|
||||||
|
Ok(pwd) => pwd,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"accounts 表为空但未设置 ZCLAW_ADMIN_PASSWORD 环境变量。\
|
||||||
|
请通过 POST /api/v1/auth/register 注册首个用户,然后手动将其 role 改为 super_admin。\
|
||||||
|
或设置 ZCLAW_ADMIN_USERNAME 和 ZCLAW_ADMIN_PASSWORD 环境变量后重启服务。"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::auth::password::hash_password;
|
||||||
|
|
||||||
|
let password_hash = hash_password(&admin_password)?;
|
||||||
|
let account_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let email = format!("{}@zclaw.local", admin_username);
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO accounts (id, username, email, password_hash, display_name, role, status, created_at, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, 'super_admin', 'active', ?6, ?6)"
|
||||||
|
)
|
||||||
|
.bind(&account_id)
|
||||||
|
.bind(&admin_username)
|
||||||
|
.bind(&email)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.bind(&admin_username)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"自动创建 super_admin 账号: username={}, email={}", admin_username, email
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
info!("SaaS server listening on {}:{}", config.server.host, config.server.port);
|
info!("SaaS server listening on {}:{}", config.server.host, config.server.port);
|
||||||
|
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>()).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub async fn create_provider(
|
|||||||
check_permission(&ctx, "provider:manage")?;
|
check_permission(&ctx, "provider:manage")?;
|
||||||
let provider = service::create_provider(&state.db, &req).await?;
|
let provider = service::create_provider(&state.db, &req).await?;
|
||||||
log_operation(&state.db, &ctx.account_id, "provider.create", "provider", &provider.id,
|
log_operation(&state.db, &ctx.account_id, "provider.create", "provider", &provider.id,
|
||||||
Some(serde_json::json!({"name": &req.name})), None).await?;
|
Some(serde_json::json!({"name": &req.name})), ctx.client_ip.as_deref()).await?;
|
||||||
Ok((StatusCode::CREATED, Json(provider)))
|
Ok((StatusCode::CREATED, Json(provider)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ pub async fn update_provider(
|
|||||||
) -> SaasResult<Json<ProviderInfo>> {
|
) -> SaasResult<Json<ProviderInfo>> {
|
||||||
check_permission(&ctx, "provider:manage")?;
|
check_permission(&ctx, "provider:manage")?;
|
||||||
let provider = service::update_provider(&state.db, &id, &req).await?;
|
let provider = service::update_provider(&state.db, &id, &req).await?;
|
||||||
log_operation(&state.db, &ctx.account_id, "provider.update", "provider", &id, None, None).await?;
|
log_operation(&state.db, &ctx.account_id, "provider.update", "provider", &id, None, ctx.client_ip.as_deref()).await?;
|
||||||
Ok(Json(provider))
|
Ok(Json(provider))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ pub async fn delete_provider(
|
|||||||
) -> SaasResult<Json<serde_json::Value>> {
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
check_permission(&ctx, "provider:manage")?;
|
check_permission(&ctx, "provider:manage")?;
|
||||||
service::delete_provider(&state.db, &id).await?;
|
service::delete_provider(&state.db, &id).await?;
|
||||||
log_operation(&state.db, &ctx.account_id, "provider.delete", "provider", &id, None, None).await?;
|
log_operation(&state.db, &ctx.account_id, "provider.delete", "provider", &id, None, ctx.client_ip.as_deref()).await?;
|
||||||
Ok(Json(serde_json::json!({"ok": true})))
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ pub async fn create_model(
|
|||||||
check_permission(&ctx, "model:manage")?;
|
check_permission(&ctx, "model:manage")?;
|
||||||
let model = service::create_model(&state.db, &req).await?;
|
let model = service::create_model(&state.db, &req).await?;
|
||||||
log_operation(&state.db, &ctx.account_id, "model.create", "model", &model.id,
|
log_operation(&state.db, &ctx.account_id, "model.create", "model", &model.id,
|
||||||
Some(serde_json::json!({"model_id": &req.model_id, "provider_id": &req.provider_id})), None).await?;
|
Some(serde_json::json!({"model_id": &req.model_id, "provider_id": &req.provider_id})), ctx.client_ip.as_deref()).await?;
|
||||||
Ok((StatusCode::CREATED, Json(model)))
|
Ok((StatusCode::CREATED, Json(model)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ pub async fn update_model(
|
|||||||
) -> SaasResult<Json<ModelInfo>> {
|
) -> SaasResult<Json<ModelInfo>> {
|
||||||
check_permission(&ctx, "model:manage")?;
|
check_permission(&ctx, "model:manage")?;
|
||||||
let model = service::update_model(&state.db, &id, &req).await?;
|
let model = service::update_model(&state.db, &id, &req).await?;
|
||||||
log_operation(&state.db, &ctx.account_id, "model.update", "model", &id, None, None).await?;
|
log_operation(&state.db, &ctx.account_id, "model.update", "model", &id, None, ctx.client_ip.as_deref()).await?;
|
||||||
Ok(Json(model))
|
Ok(Json(model))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ pub async fn delete_model(
|
|||||||
) -> SaasResult<Json<serde_json::Value>> {
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
check_permission(&ctx, "model:manage")?;
|
check_permission(&ctx, "model:manage")?;
|
||||||
service::delete_model(&state.db, &id).await?;
|
service::delete_model(&state.db, &id).await?;
|
||||||
log_operation(&state.db, &ctx.account_id, "model.delete", "model", &id, None, None).await?;
|
log_operation(&state.db, &ctx.account_id, "model.delete", "model", &id, None, ctx.client_ip.as_deref()).await?;
|
||||||
Ok(Json(serde_json::json!({"ok": true})))
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ pub async fn create_api_key(
|
|||||||
) -> SaasResult<(StatusCode, Json<AccountApiKeyInfo>)> {
|
) -> SaasResult<(StatusCode, Json<AccountApiKeyInfo>)> {
|
||||||
let key = service::create_account_api_key(&state.db, &ctx.account_id, &req).await?;
|
let key = service::create_account_api_key(&state.db, &ctx.account_id, &req).await?;
|
||||||
log_operation(&state.db, &ctx.account_id, "api_key.create", "api_key", &key.id,
|
log_operation(&state.db, &ctx.account_id, "api_key.create", "api_key", &key.id,
|
||||||
Some(serde_json::json!({"provider_id": &req.provider_id})), None).await?;
|
Some(serde_json::json!({"provider_id": &req.provider_id})), ctx.client_ip.as_deref()).await?;
|
||||||
Ok((StatusCode::CREATED, Json(key)))
|
Ok((StatusCode::CREATED, Json(key)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ pub async fn rotate_api_key(
|
|||||||
Json(req): Json<RotateApiKeyRequest>,
|
Json(req): Json<RotateApiKeyRequest>,
|
||||||
) -> SaasResult<Json<serde_json::Value>> {
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
service::rotate_account_api_key(&state.db, &id, &ctx.account_id, &req.new_key_value).await?;
|
service::rotate_account_api_key(&state.db, &id, &ctx.account_id, &req.new_key_value).await?;
|
||||||
log_operation(&state.db, &ctx.account_id, "api_key.rotate", "api_key", &id, None, None).await?;
|
log_operation(&state.db, &ctx.account_id, "api_key.rotate", "api_key", &id, None, ctx.client_ip.as_deref()).await?;
|
||||||
Ok(Json(serde_json::json!({"ok": true})))
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ pub async fn revoke_api_key(
|
|||||||
Extension(ctx): Extension<AuthContext>,
|
Extension(ctx): Extension<AuthContext>,
|
||||||
) -> SaasResult<Json<serde_json::Value>> {
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
service::revoke_account_api_key(&state.db, &id, &ctx.account_id).await?;
|
service::revoke_account_api_key(&state.db, &id, &ctx.account_id).await?;
|
||||||
log_operation(&state.db, &ctx.account_id, "api_key.revoke", "api_key", &id, None, None).await?;
|
log_operation(&state.db, &ctx.account_id, "api_key.revoke", "api_key", &id, None, ctx.client_ip.as_deref()).await?;
|
||||||
Ok(Json(serde_json::json!({"ok": true})))
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ pub async fn chat_completions(
|
|||||||
).await?;
|
).await?;
|
||||||
|
|
||||||
log_operation(&state.db, &ctx.account_id, "relay.request", "relay_task", &task.id,
|
log_operation(&state.db, &ctx.account_id, "relay.request", "relay_task", &task.id,
|
||||||
Some(serde_json::json!({"model": model_name, "stream": stream})), None).await?;
|
Some(serde_json::json!({"model": model_name, "stream": stream})), ctx.client_ip.as_deref()).await?;
|
||||||
|
|
||||||
// 执行中转
|
// 执行中转
|
||||||
let response = service::execute_relay(
|
let response = service::execute_relay(
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const MAX_BODY_SIZE: usize = 1024 * 1024; // 1MB
|
|||||||
|
|
||||||
async fn build_test_app() -> axum::Router {
|
async fn build_test_app() -> axum::Router {
|
||||||
use zclaw_saas::{config::SaaSConfig, db::init_memory_db, state::AppState};
|
use zclaw_saas::{config::SaaSConfig, db::init_memory_db, state::AppState};
|
||||||
|
use axum::extract::ConnectInfo;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
// 测试环境设置开发模式 (允许 http、默认 JWT secret)
|
// 测试环境设置开发模式 (允许 http、默认 JWT secret)
|
||||||
std::env::set_var("ZCLAW_SAAS_DEV", "true");
|
std::env::set_var("ZCLAW_SAAS_DEV", "true");
|
||||||
@@ -37,6 +39,14 @@ async fn build_test_app() -> axum::Router {
|
|||||||
.merge(public_routes)
|
.merge(public_routes)
|
||||||
.merge(protected_routes)
|
.merge(protected_routes)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
|
.layer(axum::middleware::from_fn(
|
||||||
|
|mut req: axum::extract::Request, next: axum::middleware::Next| async move {
|
||||||
|
req.extensions_mut().insert(ConnectInfo::<SocketAddr>(
|
||||||
|
"127.0.0.1:0".parse().unwrap(),
|
||||||
|
));
|
||||||
|
next.run(req).await
|
||||||
|
},
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 注册并登录,返回 JWT token
|
/// 注册并登录,返回 JWT token
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useState } from 'react';
|
|||||||
import { useSaaSStore } from '../../store/saasStore';
|
import { useSaaSStore } from '../../store/saasStore';
|
||||||
import { SaaSLogin } from './SaaSLogin';
|
import { SaaSLogin } from './SaaSLogin';
|
||||||
import { SaaSStatus } from './SaaSStatus';
|
import { SaaSStatus } from './SaaSStatus';
|
||||||
import { Cloud, Info } from 'lucide-react';
|
import { Cloud, Info, KeyRound } from 'lucide-react';
|
||||||
|
import { saasClient } from '../../lib/saas-client';
|
||||||
|
|
||||||
export function SaaSSettings() {
|
export function SaaSSettings() {
|
||||||
const isLoggedIn = useSaaSStore((s) => s.isLoggedIn);
|
const isLoggedIn = useSaaSStore((s) => s.isLoggedIn);
|
||||||
@@ -125,6 +126,9 @@ export function SaaSSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Password change section */}
|
||||||
|
{isLoggedIn && !showLogin && <ChangePasswordSection />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,3 +160,121 @@ function CloudFeatureRow({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChangePasswordSection() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [oldPassword, setOldPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setError('新密码至少 8 个字符');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('两次输入的新密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await saasClient.changePassword(oldPassword, newPassword);
|
||||||
|
setSuccess(true);
|
||||||
|
setOldPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '密码修改失败';
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between cursor-pointer"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
账号安全
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-gray-400">{isOpen ? '收起' : '展开'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm mt-3">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<KeyRound className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">修改密码</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
|
当前密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
|
新密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
|
确认新密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-500">{error}</p>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<p className="text-xs text-emerald-600">密码修改成功</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{isSubmitting ? '修改中...' : '修改密码'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -294,6 +294,16 @@ export class SaaSClient {
|
|||||||
return data.token;
|
return data.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the current user's password.
|
||||||
|
*/
|
||||||
|
async changePassword(oldPassword: string, newPassword: string): Promise<void> {
|
||||||
|
await this.request<unknown>('PUT', '/api/v1/auth/password', {
|
||||||
|
old_password: oldPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Model Endpoints ---
|
// --- Model Endpoints ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user