Phase 1.1: API Token 认证中间件 - auth_middleware 新增 zclaw_ 前缀 token 分支 (SHA-256 验证) - 合并 token 自身权限与角色权限,异步更新 last_used_at - 添加 GET /api/v1/auth/me 端点返回当前用户信息 - get_role_permissions 改为 pub(crate) 供中间件调用 Phase 1.2: 真实 SSE 流式中转 - RelayResponse::Sse 改为 axum::body::Body (bytes_stream) - 流式请求超时提升至 300s,转发 SSE headers (Cache-Control, Connection) - 添加 futures 依赖用于 StreamExt Phase 1.3: 滑动窗口速率限制中间件 - 按 account_id 做 per-minute 限流 (默认 60 rpm + 10 burst) - 超限返回 429 + Retry-After header - RateLimitConfig 支持配置化,DashMap 存储时间戳 21 tests passed, zero warnings.
214 lines
6.8 KiB
Rust
214 lines
6.8 KiB
Rust
//! 认证 HTTP 处理器
|
|
|
|
use axum::{extract::State, http::StatusCode, Json};
|
|
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},
|
|
};
|
|
|
|
/// POST /api/v1/auth/register
|
|
pub async fn register(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<RegisterRequest>,
|
|
) -> SaasResult<(StatusCode, Json<AccountPublic>)> {
|
|
if req.username.len() < 3 {
|
|
return Err(SaasError::InvalidInput("用户名至少 3 个字符".into()));
|
|
}
|
|
if req.password.len() < 8 {
|
|
return Err(SaasError::InvalidInput("密码至少 8 个字符".into()));
|
|
}
|
|
|
|
let existing: Vec<(String,)> = sqlx::query_as(
|
|
"SELECT id FROM accounts WHERE username = ?1 OR email = ?2"
|
|
)
|
|
.bind(&req.username)
|
|
.bind(&req.email)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
|
|
if !existing.is_empty() {
|
|
return Err(SaasError::AlreadyExists("用户名或邮箱已存在".into()));
|
|
}
|
|
|
|
let password_hash = hash_password(&req.password)?;
|
|
let account_id = uuid::Uuid::new_v4().to_string();
|
|
let role = "user".to_string(); // 注册固定为普通用户,角色由管理员分配
|
|
let display_name = req.display_name.unwrap_or_default();
|
|
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, ?6, 'active', ?7, ?7)"
|
|
)
|
|
.bind(&account_id)
|
|
.bind(&req.username)
|
|
.bind(&req.email)
|
|
.bind(&password_hash)
|
|
.bind(&display_name)
|
|
.bind(&role)
|
|
.bind(&now)
|
|
.execute(&state.db)
|
|
.await?;
|
|
|
|
log_operation(&state.db, &account_id, "account.create", "account", &account_id, None, None).await?;
|
|
|
|
Ok((StatusCode::CREATED, Json(AccountPublic {
|
|
id: account_id,
|
|
username: req.username,
|
|
email: req.email,
|
|
display_name,
|
|
role,
|
|
status: "active".into(),
|
|
totp_enabled: false,
|
|
created_at: now,
|
|
})))
|
|
}
|
|
|
|
/// POST /api/v1/auth/login
|
|
pub async fn login(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<LoginRequest>,
|
|
) -> SaasResult<Json<LoginResponse>> {
|
|
let row: Option<(String, String, String, String, String, String, bool, String)> =
|
|
sqlx::query_as(
|
|
"SELECT id, username, email, display_name, role, status, totp_enabled, created_at
|
|
FROM accounts WHERE username = ?1 OR email = ?1"
|
|
)
|
|
.bind(&req.username)
|
|
.fetch_optional(&state.db)
|
|
.await?;
|
|
|
|
let (id, username, email, display_name, role, status, totp_enabled, created_at) =
|
|
row.ok_or_else(|| SaasError::AuthError("用户名或密码错误".into()))?;
|
|
|
|
if status != "active" {
|
|
return Err(SaasError::Forbidden(format!("账号已{},请联系管理员", status)));
|
|
}
|
|
|
|
let (password_hash,): (String,) = sqlx::query_as(
|
|
"SELECT password_hash FROM accounts WHERE id = ?1"
|
|
)
|
|
.bind(&id)
|
|
.fetch_one(&state.db)
|
|
.await?;
|
|
|
|
if !verify_password(&req.password, &password_hash)? {
|
|
return Err(SaasError::AuthError("用户名或密码错误".into()));
|
|
}
|
|
|
|
let permissions = get_role_permissions(&state.db, &role).await?;
|
|
let config = state.config.read().await;
|
|
let token = create_token(
|
|
&id, &role, permissions.clone(),
|
|
state.jwt_secret.expose_secret(),
|
|
config.auth.jwt_expiration_hours,
|
|
)?;
|
|
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
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?;
|
|
|
|
Ok(Json(LoginResponse {
|
|
token,
|
|
account: AccountPublic {
|
|
id, username, email, display_name, role, status, totp_enabled, created_at,
|
|
},
|
|
}))
|
|
}
|
|
|
|
/// POST /api/v1/auth/refresh
|
|
pub async fn refresh(
|
|
State(state): State<AppState>,
|
|
axum::extract::Extension(ctx): axum::extract::Extension<AuthContext>,
|
|
) -> SaasResult<Json<serde_json::Value>> {
|
|
let config = state.config.read().await;
|
|
let token = create_token(
|
|
&ctx.account_id, &ctx.role, ctx.permissions.clone(),
|
|
state.jwt_secret.expose_secret(),
|
|
config.auth.jwt_expiration_hours,
|
|
)?;
|
|
Ok(Json(serde_json::json!({ "token": token })))
|
|
}
|
|
|
|
/// GET /api/v1/auth/me — 返回当前认证用户的公开信息
|
|
pub async fn me(
|
|
State(state): State<AppState>,
|
|
axum::extract::Extension(ctx): axum::extract::Extension<AuthContext>,
|
|
) -> SaasResult<Json<AccountPublic>> {
|
|
let row: Option<(String, String, String, String, String, String, bool, String)> =
|
|
sqlx::query_as(
|
|
"SELECT id, username, email, display_name, role, status, totp_enabled, created_at
|
|
FROM accounts WHERE id = ?1"
|
|
)
|
|
.bind(&ctx.account_id)
|
|
.fetch_optional(&state.db)
|
|
.await?;
|
|
|
|
let (id, username, email, display_name, role, status, totp_enabled, created_at) =
|
|
row.ok_or_else(|| SaasError::NotFound("账号不存在".into()))?;
|
|
|
|
Ok(Json(AccountPublic {
|
|
id, username, email, display_name, role, status, totp_enabled, created_at,
|
|
}))
|
|
}
|
|
|
|
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"
|
|
)
|
|
.bind(role)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let permissions_str = row
|
|
.ok_or_else(|| SaasError::Internal(format!("角色 {} 不存在", role)))?
|
|
.0;
|
|
|
|
let permissions: Vec<String> = serde_json::from_str(&permissions_str)?;
|
|
Ok(permissions)
|
|
}
|
|
|
|
/// 检查权限 (admin:full 自动通过所有检查)
|
|
pub fn check_permission(ctx: &AuthContext, permission: &str) -> SaasResult<()> {
|
|
if ctx.permissions.contains(&"admin:full".to_string()) {
|
|
return Ok(());
|
|
}
|
|
if !ctx.permissions.contains(&permission.to_string()) {
|
|
return Err(SaasError::Forbidden(format!("需要 {} 权限", permission)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 记录操作日志
|
|
pub async fn log_operation(
|
|
db: &sqlx::SqlitePool,
|
|
account_id: &str,
|
|
action: &str,
|
|
target_type: &str,
|
|
target_id: &str,
|
|
details: Option<serde_json::Value>,
|
|
ip_address: Option<&str>,
|
|
) -> SaasResult<()> {
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
sqlx::query(
|
|
"INSERT INTO operation_logs (account_id, action, target_type, target_id, details, ip_address, created_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"
|
|
)
|
|
.bind(account_id)
|
|
.bind(action)
|
|
.bind(target_type)
|
|
.bind(target_id)
|
|
.bind(details.map(|d| d.to_string()))
|
|
.bind(ip_address)
|
|
.bind(&now)
|
|
.execute(db)
|
|
.await?;
|
|
Ok(())
|
|
}
|