feat: 初始化项目基础架构和核心功能
- 添加项目基础结构:Cargo.toml、.gitignore、设备UID和密钥文件 - 实现前端Vue3项目结构:路由、登录页面、设备管理页面 - 添加核心协议定义(crates/protocol):设备状态、资产、USB事件等 - 实现客户端监控模块:系统状态收集、资产收集 - 实现服务端基础API和插件系统 - 添加数据库迁移脚本:设备管理、资产跟踪、告警系统等 - 实现前端设备状态展示和基本交互 - 添加使用时长统计和水印功能插件
This commit is contained in:
295
crates/server/src/api/auth.rs
Normal file
295
crates/server/src/api/auth.rs
Normal file
@@ -0,0 +1,295 @@
|
||||
use axum::{extract::State, Json, http::StatusCode, extract::Request, middleware::Next, response::Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use jsonwebtoken::{encode, decode, Header, EncodingKey, DecodingKey, Validation};
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::Mutex;
|
||||
use crate::AppState;
|
||||
use super::ApiResponse;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Claims {
|
||||
pub sub: i64,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub exp: u64,
|
||||
pub iat: u64,
|
||||
pub token_type: String,
|
||||
/// Random family ID for refresh token rotation detection
|
||||
pub family: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub user: UserInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct UserInfo {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RefreshRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// In-memory rate limiter for login attempts
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LoginRateLimiter {
|
||||
attempts: Arc<Mutex<HashMap<String, (Instant, u32)>>>,
|
||||
}
|
||||
|
||||
impl LoginRateLimiter {
|
||||
pub fn new() -> Self {
|
||||
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 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
|
||||
} 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);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<LoginResponse>>), StatusCode> {
|
||||
// Rate limit check
|
||||
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."))));
|
||||
}
|
||||
|
||||
let user: Option<UserInfo> = sqlx::query_as::<_, UserInfo>(
|
||||
"SELECT id, username, role FROM users WHERE username = ?"
|
||||
)
|
||||
.bind(&req.username)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let user = match user {
|
||||
Some(u) => u,
|
||||
None => return Ok((StatusCode::UNAUTHORIZED, Json(ApiResponse::error("Invalid credentials")))),
|
||||
};
|
||||
|
||||
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"))));
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
// Audit log
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO admin_audit_log (user_id, action, detail) VALUES (?, 'login', ?)"
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(format!("User {} logged in", user.username))
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(LoginResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
user,
|
||||
}))))
|
||||
}
|
||||
|
||||
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,
|
||||
&DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
if claims.claims.token_type != "refresh" {
|
||||
return Ok((StatusCode::UNAUTHORIZED, Json(ApiResponse::error("Invalid token type"))));
|
||||
}
|
||||
|
||||
// Check if this refresh token family has been revoked (reuse detection)
|
||||
let revoked: bool = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM revoked_token_families WHERE family = ?"
|
||||
)
|
||||
.bind(&claims.claims.family)
|
||||
.fetch_one(&state.db)
|
||||
.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);
|
||||
let _ = sqlx::query("DELETE FROM refresh_tokens WHERE user_id = ?")
|
||||
.bind(claims.claims.sub)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
return Ok((StatusCode::UNAUTHORIZED, Json(ApiResponse::error("Token reuse detected. Please log in again."))));
|
||||
}
|
||||
|
||||
let user = UserInfo {
|
||||
id: claims.claims.sub,
|
||||
username: claims.claims.username,
|
||||
role: claims.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)?;
|
||||
|
||||
// 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;
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(LoginResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
user,
|
||||
}))))
|
||||
}
|
||||
|
||||
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,
|
||||
username: user.username.clone(),
|
||||
role: user.role.clone(),
|
||||
exp: now + ttl,
|
||||
iat: now,
|
||||
token_type: token_type.to_string(),
|
||||
family: family.to_string(),
|
||||
};
|
||||
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
/// Axum middleware: require valid JWT access token
|
||||
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 claims = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
if claims.claims.token_type != "access" {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Inject claims into request extensions for handlers to use
|
||||
request.extensions_mut().insert(claims.claims);
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
/// Axum middleware: require admin role for write operations + audit log
|
||||
pub async fn require_admin(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let claims = request.extensions()
|
||||
.get::<Claims>()
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
if claims.role != "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;
|
||||
let username = claims.username.clone();
|
||||
|
||||
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);
|
||||
let detail = format!("by {}", username);
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO admin_audit_log (user_id, action, detail) VALUES (?, ?, ?)"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&action)
|
||||
.bind(&detail)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
Reference in New Issue
Block a user