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

@@ -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 ---
/**