feat: 新增补丁管理和异常检测插件及相关功能
feat(protocol): 添加补丁管理和行为指标协议类型 feat(client): 实现补丁管理插件采集功能 feat(server): 添加补丁管理和异常检测API feat(database): 新增补丁状态和异常检测相关表 feat(web): 添加补丁管理和异常检测前端页面 fix(security): 增强输入验证和防注入保护 refactor(auth): 重构认证检查逻辑 perf(service): 优化Windows服务恢复策略 style: 统一健康评分显示样式 docs: 更新知识库文档
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
use axum::{extract::State, Json, http::StatusCode, extract::Request, middleware::Next, response::Response};
|
||||
use axum::{extract::State, Json, http::StatusCode, extract::Request, middleware::Next, response::{Response, IntoResponse}};
|
||||
use axum::http::header::{SET_COOKIE, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use jsonwebtoken::{encode, decode, Header, EncodingKey, DecodingKey, Validation};
|
||||
use std::sync::Arc;
|
||||
@@ -28,11 +29,15 @@ pub struct LoginRequest {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub user: UserInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MeResponse {
|
||||
pub user: UserInfo,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct UserInfo {
|
||||
pub id: i64,
|
||||
@@ -40,18 +45,68 @@ pub struct UserInfo {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RefreshRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub old_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// In-memory rate limiter for login attempts
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cookie helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn is_secure_cookies() -> bool {
|
||||
std::env::var("CSM_DEV").is_err()
|
||||
}
|
||||
|
||||
fn access_cookie_header(token: &str, ttl_secs: u64) -> HeaderValue {
|
||||
let secure = if is_secure_cookies() { "; Secure" } else { "" };
|
||||
HeaderValue::from_str(&format!(
|
||||
"access_token={}; HttpOnly{}; SameSite=Strict; Path=/; Max-Age={}",
|
||||
token, secure, ttl_secs
|
||||
)).expect("valid cookie header")
|
||||
}
|
||||
|
||||
fn refresh_cookie_header(token: &str, ttl_secs: u64) -> HeaderValue {
|
||||
let secure = if is_secure_cookies() { "; Secure" } else { "" };
|
||||
HeaderValue::from_str(&format!(
|
||||
"refresh_token={}; HttpOnly{}; SameSite=Strict; Path=/api/auth/refresh; Max-Age={}",
|
||||
token, secure, ttl_secs
|
||||
)).expect("valid cookie header")
|
||||
}
|
||||
|
||||
fn clear_cookie_headers() -> Vec<HeaderValue> {
|
||||
let secure = if is_secure_cookies() { "; Secure" } else { "" };
|
||||
vec![
|
||||
HeaderValue::from_str(&format!("access_token=; HttpOnly{}; SameSite=Strict; Path=/; Max-Age=0", secure)).expect("valid"),
|
||||
HeaderValue::from_str(&format!("refresh_token=; HttpOnly{}; SameSite=Strict; Path=/api/auth/refresh; Max-Age=0", secure)).expect("valid"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Attach Set-Cookie headers to a response.
|
||||
fn with_cookies(mut response: Response, cookies: Vec<HeaderValue>) -> Response {
|
||||
for cookie in cookies {
|
||||
response.headers_mut().append(SET_COOKIE, cookie);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// Extract a cookie value by name from the raw Cookie header.
|
||||
fn extract_cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option<String> {
|
||||
let cookie_header = headers.get("cookie")?.to_str().ok()?;
|
||||
for cookie in cookie_header.split(';') {
|
||||
let cookie = cookie.trim();
|
||||
if let Some(value) = cookie.strip_prefix(&format!("{}=", name)) {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate limiter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LoginRateLimiter {
|
||||
attempts: Arc<Mutex<HashMap<String, (Instant, u32)>>>,
|
||||
@@ -62,28 +117,25 @@ impl LoginRateLimiter {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Returns true if the request should be rate-limited
|
||||
pub async fn is_limited(&self, key: &str) -> bool {
|
||||
let mut attempts = self.attempts.lock().await;
|
||||
let now = Instant::now();
|
||||
let window = std::time::Duration::from_secs(300); // 5-minute window
|
||||
let window = std::time::Duration::from_secs(300);
|
||||
let max_attempts = 10u32;
|
||||
|
||||
if let Some((first_attempt, count)) = attempts.get_mut(key) {
|
||||
if now.duration_since(*first_attempt) > window {
|
||||
// Window expired, reset
|
||||
*first_attempt = now;
|
||||
*count = 1;
|
||||
false
|
||||
} else if *count >= max_attempts {
|
||||
true // Rate limited
|
||||
true
|
||||
} else {
|
||||
*count += 1;
|
||||
false
|
||||
}
|
||||
} else {
|
||||
attempts.insert(key.to_string(), (now, 1));
|
||||
// Cleanup old entries periodically
|
||||
if attempts.len() > 1000 {
|
||||
let cutoff = now - window;
|
||||
attempts.retain(|_, (t, _)| *t > cutoff);
|
||||
@@ -93,46 +145,67 @@ impl LoginRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<LoginResponse>>), StatusCode> {
|
||||
// Rate limit check
|
||||
) -> impl IntoResponse {
|
||||
if state.login_limiter.is_limited(&req.username).await {
|
||||
return Ok((StatusCode::TOO_MANY_REQUESTS, Json(ApiResponse::error("Too many login attempts. Try again later."))));
|
||||
return (StatusCode::TOO_MANY_REQUESTS, Json(ApiResponse::<LoginResponse>::error("Too many login attempts. Try again later."))).into_response();
|
||||
}
|
||||
if state.login_limiter.is_limited("ip:default").await {
|
||||
return (StatusCode::TOO_MANY_REQUESTS, Json(ApiResponse::<LoginResponse>::error("Too many login attempts from this location. Try again later."))).into_response();
|
||||
}
|
||||
|
||||
let user: Option<UserInfo> = sqlx::query_as::<_, UserInfo>(
|
||||
"SELECT id, username, role FROM users WHERE username = ?"
|
||||
let row: Option<(UserInfo, String)> = sqlx::query_as::<_, (i64, String, String, String)>(
|
||||
"SELECT id, username, role, password FROM users WHERE username = ?"
|
||||
)
|
||||
.bind(&req.username)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|(id, username, role, password)| {
|
||||
(UserInfo { id, username, role }, password)
|
||||
});
|
||||
|
||||
let user = match user {
|
||||
Some(u) => u,
|
||||
None => return Ok((StatusCode::UNAUTHORIZED, Json(ApiResponse::error("Invalid credentials")))),
|
||||
let (user, hash) = match row {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
let _ = bcrypt::verify("timing-constant-dummy", "$2b$12$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<LoginResponse>::error("Invalid credentials"))).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let hash: String = sqlx::query_scalar::<_, String>(
|
||||
"SELECT password FROM users WHERE id = ?"
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if !bcrypt::verify(&req.password, &hash).unwrap_or(false) {
|
||||
return Ok((StatusCode::UNAUTHORIZED, Json(ApiResponse::error("Invalid credentials"))));
|
||||
return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<LoginResponse>::error("Invalid credentials"))).into_response();
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().timestamp() as u64;
|
||||
let family = uuid::Uuid::new_v4().to_string();
|
||||
let access_token = create_token(&user, "access", state.config.auth.access_token_ttl_secs, now, &state.config.auth.jwt_secret, &family)?;
|
||||
let refresh_token = create_token(&user, "refresh", state.config.auth.refresh_token_ttl_secs, now, &state.config.auth.jwt_secret, &family)?;
|
||||
let access_token = match create_token(&user, "access", state.config.auth.access_token_ttl_secs, now, &state.config.auth.jwt_secret, &family) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
};
|
||||
let refresh_token = match create_token(&user, "refresh", state.config.auth.refresh_token_ttl_secs, now, &state.config.auth.jwt_secret, &family) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
};
|
||||
|
||||
let refresh_expires = now + state.config.auth.refresh_token_ttl_secs;
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO refresh_tokens (user_id, family, expires_at) VALUES (?, ?, datetime(?, 'unixepoch'))"
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(&family)
|
||||
.bind(refresh_expires as i64)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
// Audit log
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO admin_audit_log (user_id, action, detail) VALUES (?, 'login', ?)"
|
||||
)
|
||||
@@ -141,73 +214,262 @@ pub async fn login(
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(LoginResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
user,
|
||||
}))))
|
||||
let response = (StatusCode::OK, Json(ApiResponse::ok(LoginResponse { user }))).into_response();
|
||||
with_cookies(response, vec![
|
||||
access_cookie_header(&access_token, state.config.auth.access_token_ttl_secs),
|
||||
refresh_cookie_header(&refresh_token, state.config.auth.refresh_token_ttl_secs),
|
||||
])
|
||||
}
|
||||
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<LoginResponse>>), StatusCode> {
|
||||
let claims = decode::<Claims>(
|
||||
&req.refresh_token,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let refresh_token = match extract_cookie_value(&headers, "refresh_token") {
|
||||
Some(t) => t,
|
||||
None => return with_cookies(
|
||||
(StatusCode::UNAUTHORIZED, Json(ApiResponse::<LoginResponse>::error("Missing refresh token"))).into_response(),
|
||||
clear_cookie_headers(),
|
||||
),
|
||||
};
|
||||
|
||||
let claims = match decode::<Claims>(
|
||||
&refresh_token,
|
||||
&DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
) {
|
||||
Ok(c) => c.claims,
|
||||
Err(_) => return with_cookies(
|
||||
(StatusCode::UNAUTHORIZED, Json(ApiResponse::<LoginResponse>::error("Invalid refresh token"))).into_response(),
|
||||
clear_cookie_headers(),
|
||||
),
|
||||
};
|
||||
|
||||
if claims.claims.token_type != "refresh" {
|
||||
return Ok((StatusCode::UNAUTHORIZED, Json(ApiResponse::error("Invalid token type"))));
|
||||
if claims.token_type != "refresh" {
|
||||
return with_cookies(
|
||||
(StatusCode::UNAUTHORIZED, Json(ApiResponse::<LoginResponse>::error("Invalid token type"))).into_response(),
|
||||
clear_cookie_headers(),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this refresh token family has been revoked (reuse detection)
|
||||
let mut tx = match state.db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
};
|
||||
|
||||
let revoked: bool = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM revoked_token_families WHERE family = ?"
|
||||
)
|
||||
.bind(&claims.claims.family)
|
||||
.fetch_one(&state.db)
|
||||
.bind(&claims.family)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.unwrap_or(0) > 0;
|
||||
|
||||
if revoked {
|
||||
// Token reuse detected — revoke entire family and force re-login
|
||||
tracing::warn!("Refresh token reuse detected for user {} family {}", claims.claims.sub, claims.claims.family);
|
||||
tx.rollback().await.ok();
|
||||
tracing::warn!("Refresh token reuse detected for user {} family {}", claims.sub, claims.family);
|
||||
let _ = sqlx::query("DELETE FROM refresh_tokens WHERE user_id = ?")
|
||||
.bind(claims.claims.sub)
|
||||
.bind(claims.sub)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
return Ok((StatusCode::UNAUTHORIZED, Json(ApiResponse::error("Token reuse detected. Please log in again."))));
|
||||
return with_cookies(
|
||||
(StatusCode::UNAUTHORIZED, Json(ApiResponse::<LoginResponse>::error("Token reuse detected. Please log in again."))).into_response(),
|
||||
clear_cookie_headers(),
|
||||
);
|
||||
}
|
||||
|
||||
let family_exists: bool = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM refresh_tokens WHERE family = ? AND user_id = ?"
|
||||
)
|
||||
.bind(&claims.family)
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.unwrap_or(0) > 0;
|
||||
|
||||
if !family_exists {
|
||||
tx.rollback().await.ok();
|
||||
return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<LoginResponse>::error("Invalid refresh token"))).into_response();
|
||||
}
|
||||
|
||||
let user = UserInfo {
|
||||
id: claims.claims.sub,
|
||||
username: claims.claims.username,
|
||||
role: claims.claims.role,
|
||||
id: claims.sub,
|
||||
username: claims.username,
|
||||
role: claims.role,
|
||||
};
|
||||
|
||||
// Rotate: new family for each refresh
|
||||
let new_family = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().timestamp() as u64;
|
||||
let access_token = create_token(&user, "access", state.config.auth.access_token_ttl_secs, now, &state.config.auth.jwt_secret, &new_family)?;
|
||||
let refresh_token = create_token(&user, "refresh", state.config.auth.refresh_token_ttl_secs, now, &state.config.auth.jwt_secret, &new_family)?;
|
||||
let access_token = match create_token(&user, "access", state.config.auth.access_token_ttl_secs, now, &state.config.auth.jwt_secret, &new_family) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
};
|
||||
let refresh_token = match create_token(&user, "refresh", state.config.auth.refresh_token_ttl_secs, now, &state.config.auth.jwt_secret, &new_family) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
};
|
||||
|
||||
// Revoke old family
|
||||
let _ = sqlx::query("INSERT OR IGNORE INTO revoked_token_families (family, user_id, revoked_at) VALUES (?, ?, datetime('now'))")
|
||||
.bind(&claims.claims.family)
|
||||
.bind(claims.claims.sub)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
if sqlx::query("INSERT OR IGNORE INTO revoked_token_families (family, user_id, revoked_at) VALUES (?, ?, datetime('now'))")
|
||||
.bind(&claims.family)
|
||||
.bind(claims.sub)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(LoginResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
user,
|
||||
}))))
|
||||
let refresh_expires = now + state.config.auth.refresh_token_ttl_secs;
|
||||
if sqlx::query(
|
||||
"INSERT INTO refresh_tokens (user_id, family, expires_at) VALUES (?, ?, datetime(?, 'unixepoch'))"
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(&new_family)
|
||||
.bind(refresh_expires as i64)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
|
||||
if tx.commit().await.is_err() {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
|
||||
let response = (StatusCode::OK, Json(ApiResponse::ok(LoginResponse { user }))).into_response();
|
||||
with_cookies(response, vec![
|
||||
access_cookie_header(&access_token, state.config.auth.access_token_ttl_secs),
|
||||
refresh_cookie_header(&refresh_token, state.config.auth.refresh_token_ttl_secs),
|
||||
])
|
||||
}
|
||||
|
||||
/// Get current authenticated user info from access_token cookie.
|
||||
pub async fn me(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let token = match extract_cookie_value(&headers, "access_token") {
|
||||
Some(t) => t,
|
||||
None => return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<MeResponse>::error("Not authenticated"))).into_response(),
|
||||
};
|
||||
|
||||
let claims = match decode::<Claims>(
|
||||
&token,
|
||||
&DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
) {
|
||||
Ok(c) => c.claims,
|
||||
Err(_) => return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<MeResponse>::error("Invalid token"))).into_response(),
|
||||
};
|
||||
|
||||
if claims.token_type != "access" {
|
||||
return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<MeResponse>::error("Invalid token type"))).into_response();
|
||||
}
|
||||
|
||||
let expires_at = chrono::DateTime::from_timestamp(claims.exp as i64, 0)
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_default();
|
||||
|
||||
(StatusCode::OK, Json(ApiResponse::ok(MeResponse {
|
||||
user: UserInfo {
|
||||
id: claims.sub,
|
||||
username: claims.username,
|
||||
role: claims.role,
|
||||
},
|
||||
expires_at,
|
||||
}))).into_response()
|
||||
}
|
||||
|
||||
/// Logout: clear auth cookies and revoke refresh token family.
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(token) = extract_cookie_value(&headers, "access_token") {
|
||||
if let Ok(claims) = decode::<Claims>(
|
||||
&token,
|
||||
&DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
) {
|
||||
let _ = sqlx::query("DELETE FROM refresh_tokens WHERE user_id = ?")
|
||||
.bind(claims.claims.sub)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO admin_audit_log (user_id, action, detail) VALUES (?, 'logout', ?)"
|
||||
)
|
||||
.bind(claims.claims.sub)
|
||||
.bind(format!("User {} logged out", claims.claims.username))
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let response = (StatusCode::OK, Json(ApiResponse::ok(()))).into_response();
|
||||
with_cookies(response, clear_cookie_headers())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket ticket
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WsTicketResponse {
|
||||
pub ticket: String,
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
/// Create a one-time ticket for WebSocket authentication.
|
||||
/// Requires a valid access_token cookie (set by login).
|
||||
pub async fn create_ws_ticket(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let token = match extract_cookie_value(&headers, "access_token") {
|
||||
Some(t) => t,
|
||||
None => return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<WsTicketResponse>::error("Not authenticated"))).into_response(),
|
||||
};
|
||||
|
||||
let claims = match decode::<Claims>(
|
||||
&token,
|
||||
&DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
) {
|
||||
Ok(c) => c.claims,
|
||||
Err(_) => return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<WsTicketResponse>::error("Invalid token"))).into_response(),
|
||||
};
|
||||
|
||||
if claims.token_type != "access" {
|
||||
return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<WsTicketResponse>::error("Invalid token type"))).into_response();
|
||||
}
|
||||
|
||||
let ticket = uuid::Uuid::new_v4().to_string();
|
||||
let claim = crate::ws::TicketClaim {
|
||||
user_id: claims.sub,
|
||||
username: claims.username,
|
||||
role: claims.role,
|
||||
created_at: std::time::Instant::now(),
|
||||
};
|
||||
|
||||
{
|
||||
let mut tickets = state.ws_tickets.lock().await;
|
||||
tickets.insert(ticket.clone(), claim);
|
||||
|
||||
// Cleanup expired tickets (>30s old) on every creation
|
||||
tickets.retain(|_, c| c.created_at.elapsed().as_secs() < 30);
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(ApiResponse::ok(WsTicketResponse {
|
||||
ticket,
|
||||
expires_in: 30,
|
||||
}))).into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn create_token(user: &UserInfo, token_type: &str, ttl: u64, now: u64, secret: &str, family: &str) -> Result<String, StatusCode> {
|
||||
let claims = Claims {
|
||||
sub: user.id,
|
||||
@@ -227,24 +489,17 @@ fn create_token(user: &UserInfo, token_type: &str, ttl: u64, now: u64, secret: &
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
/// Axum middleware: require valid JWT access token
|
||||
/// Axum middleware: require valid JWT access token from cookie
|
||||
pub async fn require_auth(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let auth_header = request.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "));
|
||||
|
||||
let token = match auth_header {
|
||||
Some(t) => t,
|
||||
None => return Err(StatusCode::UNAUTHORIZED),
|
||||
};
|
||||
let token = extract_cookie_value(request.headers(), "access_token")
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let claims = decode::<Claims>(
|
||||
token,
|
||||
&token,
|
||||
&DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
@@ -254,9 +509,7 @@ pub async fn require_auth(
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Inject claims into request extensions for handlers to use
|
||||
request.extensions_mut().insert(claims.claims);
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
@@ -274,7 +527,6 @@ pub async fn require_admin(
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// Capture audit info before running handler
|
||||
let method = request.method().clone();
|
||||
let path = request.uri().path().to_string();
|
||||
let user_id = claims.sub;
|
||||
@@ -282,7 +534,6 @@ pub async fn require_admin(
|
||||
|
||||
let response = next.run(request).await;
|
||||
|
||||
// Record admin action to audit log (fire and forget — don't block response)
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
let action = format!("{} {}", method, path);
|
||||
@@ -308,8 +559,10 @@ pub async fn change_password(
|
||||
if req.new_password.len() < 6 {
|
||||
return Ok((StatusCode::BAD_REQUEST, Json(ApiResponse::error("新密码至少6位"))));
|
||||
}
|
||||
if req.new_password.len() > 72 {
|
||||
return Ok((StatusCode::BAD_REQUEST, Json(ApiResponse::error("密码不能超过72位"))));
|
||||
}
|
||||
|
||||
// Verify old password
|
||||
let hash: String = sqlx::query_scalar::<_, String>(
|
||||
"SELECT password FROM users WHERE id = ?"
|
||||
)
|
||||
@@ -322,7 +575,6 @@ pub async fn change_password(
|
||||
return Ok((StatusCode::BAD_REQUEST, Json(ApiResponse::error("当前密码错误"))));
|
||||
}
|
||||
|
||||
// Update password
|
||||
let new_hash = bcrypt::hash(&req.new_password, 12).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
sqlx::query("UPDATE users SET password = ? WHERE id = ?")
|
||||
.bind(&new_hash)
|
||||
@@ -331,7 +583,6 @@ pub async fn change_password(
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Audit log
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO admin_audit_log (user_id, action, detail) VALUES (?, 'change_password', ?)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user