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:
iven
2026-03-27 14:45:47 +08:00
parent 15450ca895
commit 8cce2283f7
11 changed files with 310 additions and 25 deletions

View File

@@ -44,12 +44,25 @@ pub async fn update_account(
Extension(ctx): Extension<AuthContext>,
Json(req): Json<UpdateAccountRequest>,
) -> 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)?;
}
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<Json<TokenInfo>> {
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<AuthContext>,
) -> SaasResult<Json<serde_json::Value>> {
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})))
}

View File

@@ -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<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(req): Json<RegisterRequest>,
) -> SaasResult<(StatusCode, Json<AccountPublic>)> {
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<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(req): Json<LoginRequest>,
) -> SaasResult<Json<LoginResponse>> {
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<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>> {
let row: Option<(String,)> = sqlx::query_as(
"SELECT permissions FROM roles WHERE id = ?1"

View File

@@ -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<AuthContext, SaasError> {
async fn verify_api_token(state: &AppState, raw_token: &str, client_ip: Option<String>) -> Result<AuthContext, SaasError> {
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<AuthConte
account_id,
role,
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 提取身份
pub async fn auth_middleware(
State(state): State<AppState>,
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<AppState> {
/// 需要认证的路由
pub fn protected_routes() -> axum::Router<AppState> {
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))
}

View File

@@ -26,6 +26,13 @@ pub struct RegisterRequest {
pub display_name: Option<String>,
}
/// 修改密码请求
#[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<String>,
pub client_ip: Option<String>,
}

View File

@@ -228,6 +228,7 @@ pub async fn init_db(database_url: &str) -> SaasResult<SqlitePool> {
.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<SqlitePool> {
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::*;

View File

@@ -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::<std::net::SocketAddr>()).await?;
Ok(())
}

View File

@@ -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<Json<ProviderInfo>> {
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<Json<serde_json::Value>> {
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<Json<ModelInfo>> {
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<Json<serde_json::Value>> {
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<AccountApiKeyInfo>)> {
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<RotateApiKeyRequest>,
) -> SaasResult<Json<serde_json::Value>> {
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<AuthContext>,
) -> SaasResult<Json<serde_json::Value>> {
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})))
}

View File

@@ -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(

View File

@@ -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::<SocketAddr>(
"127.0.0.1:0".parse().unwrap(),
));
next.run(req).await
},
))
}
/// 注册并登录,返回 JWT token

View File

@@ -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() {
</div>
</div>
)}
{/* Password change section */}
{isLoggedIn && !showLogin && <ChangePasswordSection />}
</div>
);
}
@@ -156,3 +160,121 @@ function CloudFeatureRow({
</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>
);
}

View File

@@ -294,6 +294,16 @@ export class SaaSClient {
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 ---
/**