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

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