diff --git a/crates/zclaw-saas/src/account/handlers.rs b/crates/zclaw-saas/src/account/handlers.rs index b43b78e..36b26f6 100644 --- a/crates/zclaw-saas/src/account/handlers.rs +++ b/crates/zclaw-saas/src/account/handlers.rs @@ -44,12 +44,25 @@ pub async fn update_account( Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { + let is_self_update = id == ctx.account_id; + // 非管理员只能修改自己的资料 - if id != ctx.account_id { + if !is_self_update { 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)) } @@ -63,7 +76,7 @@ pub async fn update_status( require_admin(&ctx)?; service::update_account_status(&state.db, &id, &req.status).await?; 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}))) } @@ -83,7 +96,7 @@ pub async fn create_token( ) -> SaasResult> { 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, - 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)) } @@ -94,7 +107,7 @@ pub async fn revoke_token( Extension(ctx): Extension, ) -> SaasResult> { 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}))) } diff --git a/crates/zclaw-saas/src/auth/handlers.rs b/crates/zclaw-saas/src/auth/handlers.rs index 1212523..4d06141 100644 --- a/crates/zclaw-saas/src/auth/handlers.rs +++ b/crates/zclaw-saas/src/auth/handlers.rs @@ -1,18 +1,20 @@ //! 认证 HTTP 处理器 -use axum::{extract::State, http::StatusCode, Json}; +use axum::{extract::{State, ConnectInfo}, http::StatusCode, Json}; +use std::net::SocketAddr; use secrecy::ExposeSecret; use crate::state::AppState; use crate::error::{SaasError, SaasResult}; use super::{ jwt::create_token, password::{hash_password, verify_password}, - types::{AuthContext, LoginRequest, LoginResponse, RegisterRequest, AccountPublic}, + types::{AuthContext, LoginRequest, LoginResponse, RegisterRequest, ChangePasswordRequest, AccountPublic}, }; /// POST /api/v1/auth/register pub async fn register( State(state): State, + ConnectInfo(addr): ConnectInfo, Json(req): Json, ) -> SaasResult<(StatusCode, Json)> { if req.username.len() < 3 { @@ -54,7 +56,8 @@ pub async fn register( .execute(&state.db) .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 { id: account_id, @@ -71,6 +74,7 @@ pub async fn register( /// POST /api/v1/auth/login pub async fn login( State(state): State, + ConnectInfo(addr): ConnectInfo, Json(req): Json, ) -> SaasResult> { 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") .bind(&now).bind(&id) .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 { token, @@ -158,6 +163,45 @@ pub async fn me( })) } +/// PUT /api/v1/auth/password — 修改密码 +pub async fn change_password( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, + Json(req): Json, +) -> SaasResult> { + 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> { let row: Option<(String,)> = sqlx::query_as( "SELECT permissions FROM roles WHERE id = ?1" diff --git a/crates/zclaw-saas/src/auth/mod.rs b/crates/zclaw-saas/src/auth/mod.rs index 49b9a84..b8e72d0 100644 --- a/crates/zclaw-saas/src/auth/mod.rs +++ b/crates/zclaw-saas/src/auth/mod.rs @@ -10,16 +10,18 @@ use axum::{ http::header, middleware::Next, response::{IntoResponse, Response}, + extract::ConnectInfo, }; use secrecy::ExposeSecret; use crate::error::SaasError; use crate::state::AppState; use types::AuthContext; +use std::net::SocketAddr; /// 通过 API Token 验证身份 /// /// 流程: SHA-256 哈希 → 查 api_tokens 表 → 检查有效期 → 获取关联账号角色权限 → 更新 last_used_at -async fn verify_api_token(state: &AppState, raw_token: &str) -> Result { +async fn verify_api_token(state: &AppState, raw_token: &str, client_ip: Option) -> Result { use sha2::{Sha256, Digest}; 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 Option { + // 优先从 ConnectInfo 获取 + if let Some(ConnectInfo(addr)) = req.extensions().get::>() { + 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 提取身份 pub async fn auth_middleware( State(state): State, mut req: Request, next: Next, ) -> Response { + let client_ip = extract_client_ip(&req); let auth_header = req.headers() .get(header::AUTHORIZATION) .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 token.starts_with("zclaw_") { // API Token 路径 - verify_api_token(&state, token).await + verify_api_token(&state, token, client_ip.clone()).await } else { // JWT 路径 jwt::verify_token(token, state.jwt_secret.expose_secret()) @@ -102,6 +125,7 @@ pub async fn auth_middleware( account_id: claims.sub, role: claims.role, permissions: claims.permissions, + client_ip, }) .map_err(|_| SaasError::Unauthorized) } @@ -132,9 +156,10 @@ pub fn routes() -> axum::Router { /// 需要认证的路由 pub fn protected_routes() -> axum::Router { - use axum::routing::{get, post}; + use axum::routing::{get, post, put}; axum::Router::new() .route("/api/v1/auth/refresh", post(handlers::refresh)) .route("/api/v1/auth/me", get(handlers::me)) + .route("/api/v1/auth/password", put(handlers::change_password)) } diff --git a/crates/zclaw-saas/src/auth/types.rs b/crates/zclaw-saas/src/auth/types.rs index d50cdb6..95674a1 100644 --- a/crates/zclaw-saas/src/auth/types.rs +++ b/crates/zclaw-saas/src/auth/types.rs @@ -26,6 +26,13 @@ pub struct RegisterRequest { pub display_name: Option, } +/// 修改密码请求 +#[derive(Debug, Deserialize)] +pub struct ChangePasswordRequest { + pub old_password: String, + pub new_password: String, +} + /// 公开账号信息 (无敏感数据) #[derive(Debug, Clone, Serialize)] pub struct AccountPublic { @@ -45,4 +52,5 @@ pub struct AuthContext { pub account_id: String, pub role: String, pub permissions: Vec, + pub client_ip: Option, } diff --git a/crates/zclaw-saas/src/db.rs b/crates/zclaw-saas/src/db.rs index d41cd4c..d4876ee 100644 --- a/crates/zclaw-saas/src/db.rs +++ b/crates/zclaw-saas/src/db.rs @@ -228,6 +228,7 @@ pub async fn init_db(database_url: &str) -> SaasResult { .execute(&pool) .await?; sqlx::query(SEED_ROLES).execute(&pool).await?; + seed_admin_account(&pool).await?; tracing::info!("Database initialized (schema v{})", SCHEMA_VERSION); Ok(pool) } @@ -244,6 +245,58 @@ pub async fn init_memory_db() -> SaasResult { 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)] mod tests { use super::*; diff --git a/crates/zclaw-saas/src/main.rs b/crates/zclaw-saas/src/main.rs index 53b22c3..2e954e7 100644 --- a/crates/zclaw-saas/src/main.rs +++ b/crates/zclaw-saas/src/main.rs @@ -25,7 +25,7 @@ async fn main() -> anyhow::Result<()> { .await?; 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::()).await?; Ok(()) } diff --git a/crates/zclaw-saas/src/model_config/handlers.rs b/crates/zclaw-saas/src/model_config/handlers.rs index 532b2c3..7dc655a 100644 --- a/crates/zclaw-saas/src/model_config/handlers.rs +++ b/crates/zclaw-saas/src/model_config/handlers.rs @@ -38,7 +38,7 @@ pub async fn create_provider( check_permission(&ctx, "provider:manage")?; let provider = service::create_provider(&state.db, &req).await?; 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))) } @@ -51,7 +51,7 @@ pub async fn update_provider( ) -> SaasResult> { check_permission(&ctx, "provider:manage")?; 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)) } @@ -63,7 +63,7 @@ pub async fn delete_provider( ) -> SaasResult> { check_permission(&ctx, "provider:manage")?; 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}))) } @@ -97,7 +97,7 @@ pub async fn create_model( check_permission(&ctx, "model:manage")?; let model = service::create_model(&state.db, &req).await?; 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))) } @@ -110,7 +110,7 @@ pub async fn update_model( ) -> SaasResult> { check_permission(&ctx, "model:manage")?; 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)) } @@ -122,7 +122,7 @@ pub async fn delete_model( ) -> SaasResult> { check_permission(&ctx, "model:manage")?; 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}))) } @@ -146,7 +146,7 @@ pub async fn create_api_key( ) -> SaasResult<(StatusCode, Json)> { 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, - 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))) } @@ -158,7 +158,7 @@ pub async fn rotate_api_key( Json(req): Json, ) -> SaasResult> { 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}))) } @@ -169,7 +169,7 @@ pub async fn revoke_api_key( Extension(ctx): Extension, ) -> SaasResult> { 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}))) } diff --git a/crates/zclaw-saas/src/relay/handlers.rs b/crates/zclaw-saas/src/relay/handlers.rs index 35b15e5..d21fa06 100644 --- a/crates/zclaw-saas/src/relay/handlers.rs +++ b/crates/zclaw-saas/src/relay/handlers.rs @@ -60,7 +60,7 @@ pub async fn chat_completions( ).await?; 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( diff --git a/crates/zclaw-saas/tests/integration_test.rs b/crates/zclaw-saas/tests/integration_test.rs index 559758f..e41518b 100644 --- a/crates/zclaw-saas/tests/integration_test.rs +++ b/crates/zclaw-saas/tests/integration_test.rs @@ -11,6 +11,8 @@ const MAX_BODY_SIZE: usize = 1024 * 1024; // 1MB async fn build_test_app() -> axum::Router { use zclaw_saas::{config::SaaSConfig, db::init_memory_db, state::AppState}; + use axum::extract::ConnectInfo; + use std::net::SocketAddr; // 测试环境设置开发模式 (允许 http、默认 JWT secret) std::env::set_var("ZCLAW_SAAS_DEV", "true"); @@ -37,6 +39,14 @@ async fn build_test_app() -> axum::Router { .merge(public_routes) .merge(protected_routes) .with_state(state) + .layer(axum::middleware::from_fn( + |mut req: axum::extract::Request, next: axum::middleware::Next| async move { + req.extensions_mut().insert(ConnectInfo::( + "127.0.0.1:0".parse().unwrap(), + )); + next.run(req).await + }, + )) } /// 注册并登录,返回 JWT token diff --git a/desktop/src/components/SaaS/SaaSSettings.tsx b/desktop/src/components/SaaS/SaaSSettings.tsx index 89890f6..4280b22 100644 --- a/desktop/src/components/SaaS/SaaSSettings.tsx +++ b/desktop/src/components/SaaS/SaaSSettings.tsx @@ -2,7 +2,8 @@ import { useState } from 'react'; import { useSaaSStore } from '../../store/saasStore'; import { SaaSLogin } from './SaaSLogin'; 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() { const isLoggedIn = useSaaSStore((s) => s.isLoggedIn); @@ -125,6 +126,9 @@ export function SaaSSettings() { )} + + {/* Password change section */} + {isLoggedIn && !showLogin && } ); } @@ -156,3 +160,121 @@ function CloudFeatureRow({ ); } + +function ChangePasswordSection() { + const [isOpen, setIsOpen] = useState(false); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(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 ( +
+
setIsOpen(!isOpen)} + > +

+ 账号安全 +

+ {isOpen ? '收起' : '展开'} +
+ + {isOpen && ( +
+
+ + 修改密码 +
+ +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+ + {error && ( +

{error}

+ )} + {success && ( +

密码修改成功

+ )} + + +
+
+ )} +
+ ); +} diff --git a/desktop/src/lib/saas-client.ts b/desktop/src/lib/saas-client.ts index d8e2f48..bfab10d 100644 --- a/desktop/src/lib/saas-client.ts +++ b/desktop/src/lib/saas-client.ts @@ -294,6 +294,16 @@ export class SaaSClient { return data.token; } + /** + * Change the current user's password. + */ + async changePassword(oldPassword: string, newPassword: string): Promise { + await this.request('PUT', '/api/v1/auth/password', { + old_password: oldPassword, + new_password: newPassword, + }); + } + // --- Model Endpoints --- /**