This commit introduces: - AES-256-GCM encryption for LLM provider API keys in the database. - HMAC-SHA256 signed session tokens with activity-based refresh logic. - Standardized frontend XSS protection using a global escapeHtml utility. - Hardened security headers and request body size limits. - Improved database integrity with foreign key enforcement and atomic transactions. - Integration tests for the full encrypted key storage and proxy usage lifecycle.
230 lines
8.2 KiB
Rust
230 lines
8.2 KiB
Rust
use axum::{extract::State, http::{HeaderMap, HeaderValue}, response::{Json, IntoResponse}};
|
|
use bcrypt;
|
|
use serde::Deserialize;
|
|
use sqlx::Row;
|
|
use tracing::warn;
|
|
|
|
use super::{ApiResponse, DashboardState};
|
|
|
|
// Authentication handlers
|
|
#[derive(Deserialize)]
|
|
pub(super) struct LoginRequest {
|
|
pub(super) username: String,
|
|
pub(super) password: String,
|
|
}
|
|
|
|
pub(super) async fn handle_login(
|
|
State(state): State<DashboardState>,
|
|
Json(payload): Json<LoginRequest>,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
let pool = &state.app_state.db_pool;
|
|
|
|
let user_result = sqlx::query(
|
|
"SELECT username, password_hash, display_name, role, must_change_password FROM users WHERE username = ?",
|
|
)
|
|
.bind(&payload.username)
|
|
.fetch_optional(pool)
|
|
.await;
|
|
|
|
match user_result {
|
|
Ok(Some(row)) => {
|
|
let hash = row.get::<String, _>("password_hash");
|
|
if bcrypt::verify(&payload.password, &hash).unwrap_or(false) {
|
|
let username = row.get::<String, _>("username");
|
|
let role = row.get::<String, _>("role");
|
|
let display_name = row
|
|
.get::<Option<String>, _>("display_name")
|
|
.unwrap_or_else(|| username.clone());
|
|
let must_change_password = row.get::<bool, _>("must_change_password");
|
|
let token = state
|
|
.session_manager
|
|
.create_session(username.clone(), role.clone())
|
|
.await;
|
|
Json(ApiResponse::success(serde_json::json!({
|
|
"token": token,
|
|
"must_change_password": must_change_password,
|
|
"user": {
|
|
"username": username,
|
|
"name": display_name,
|
|
"role": role
|
|
}
|
|
})))
|
|
} else {
|
|
Json(ApiResponse::error("Invalid username or password".to_string()))
|
|
}
|
|
}
|
|
Ok(None) => Json(ApiResponse::error("Invalid username or password".to_string())),
|
|
Err(e) => {
|
|
warn!("Database error during login: {}", e);
|
|
Json(ApiResponse::error("Login failed due to system error".to_string()))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn handle_auth_status(
|
|
State(state): State<DashboardState>,
|
|
headers: axum::http::HeaderMap,
|
|
) -> impl IntoResponse {
|
|
let token = headers
|
|
.get("Authorization")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|v| v.strip_prefix("Bearer "));
|
|
|
|
if let Some(token) = token
|
|
&& let Some((session, new_token)) = state.session_manager.validate_session_with_refresh(token).await
|
|
{
|
|
// Look up display_name from DB
|
|
let display_name = sqlx::query_scalar::<_, Option<String>>(
|
|
"SELECT display_name FROM users WHERE username = ?",
|
|
)
|
|
.bind(&session.username)
|
|
.fetch_optional(&state.app_state.db_pool)
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.flatten()
|
|
.unwrap_or_else(|| session.username.clone());
|
|
|
|
let mut headers = HeaderMap::new();
|
|
if let Some(refreshed_token) = new_token {
|
|
if let Ok(header_value) = HeaderValue::from_str(&refreshed_token) {
|
|
headers.insert("X-Refreshed-Token", header_value);
|
|
}
|
|
}
|
|
return (headers, Json(ApiResponse::success(serde_json::json!({
|
|
"authenticated": true,
|
|
"user": {
|
|
"username": session.username,
|
|
"name": display_name,
|
|
"role": session.role
|
|
}
|
|
}))));
|
|
}
|
|
|
|
(HeaderMap::new(), Json(ApiResponse::error("Not authenticated".to_string())))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(super) struct ChangePasswordRequest {
|
|
pub(super) current_password: String,
|
|
pub(super) new_password: String,
|
|
}
|
|
|
|
pub(super) async fn handle_change_password(
|
|
State(state): State<DashboardState>,
|
|
headers: axum::http::HeaderMap,
|
|
Json(payload): Json<ChangePasswordRequest>,
|
|
) -> impl IntoResponse {
|
|
let pool = &state.app_state.db_pool;
|
|
|
|
// Extract the authenticated user from the session token
|
|
let token = headers
|
|
.get("Authorization")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|v| v.strip_prefix("Bearer "));
|
|
|
|
let (session, new_token) = match token {
|
|
Some(t) => match state.session_manager.validate_session_with_refresh(t).await {
|
|
Some((session, new_token)) => (Some(session), new_token),
|
|
None => (None, None),
|
|
},
|
|
None => (None, None),
|
|
};
|
|
|
|
let mut response_headers = HeaderMap::new();
|
|
if let Some(refreshed_token) = new_token {
|
|
if let Ok(header_value) = HeaderValue::from_str(&refreshed_token) {
|
|
response_headers.insert("X-Refreshed-Token", header_value);
|
|
}
|
|
}
|
|
|
|
let username = match session {
|
|
Some(s) => s.username,
|
|
None => return (response_headers, Json(ApiResponse::error("Not authenticated".to_string()))),
|
|
};
|
|
|
|
let user_result = sqlx::query("SELECT password_hash FROM users WHERE username = ?")
|
|
.bind(&username)
|
|
.fetch_one(pool)
|
|
.await;
|
|
|
|
match user_result {
|
|
Ok(row) => {
|
|
let hash = row.get::<String, _>("password_hash");
|
|
if bcrypt::verify(&payload.current_password, &hash).unwrap_or(false) {
|
|
let new_hash = match bcrypt::hash(&payload.new_password, 12) {
|
|
Ok(h) => h,
|
|
Err(_) => return (response_headers, Json(ApiResponse::error("Failed to hash new password".to_string()))),
|
|
};
|
|
|
|
let update_result = sqlx::query(
|
|
"UPDATE users SET password_hash = ?, must_change_password = FALSE WHERE username = ?",
|
|
)
|
|
.bind(new_hash)
|
|
.bind(&username)
|
|
.execute(pool)
|
|
.await;
|
|
|
|
match update_result {
|
|
Ok(_) => (response_headers, Json(ApiResponse::success(
|
|
serde_json::json!({ "message": "Password updated successfully" }),
|
|
))),
|
|
Err(e) => (response_headers, Json(ApiResponse::error(format!("Failed to update database: {}", e)))),
|
|
}
|
|
} else {
|
|
(response_headers, Json(ApiResponse::error("Current password incorrect".to_string())))
|
|
}
|
|
}
|
|
Err(e) => (response_headers, Json(ApiResponse::error(format!("User not found: {}", e)))),
|
|
}
|
|
}
|
|
|
|
pub(super) async fn handle_logout(
|
|
State(state): State<DashboardState>,
|
|
headers: axum::http::HeaderMap,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
let token = headers
|
|
.get("Authorization")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|v| v.strip_prefix("Bearer "));
|
|
|
|
if let Some(token) = token {
|
|
state.session_manager.revoke_session(token).await;
|
|
}
|
|
|
|
Json(ApiResponse::success(serde_json::json!({ "message": "Logged out" })))
|
|
}
|
|
|
|
/// Helper: Extract and validate a session from the Authorization header.
|
|
/// Returns the Session and optional new token if refreshed, or an error response.
|
|
pub(super) async fn extract_session(
|
|
state: &DashboardState,
|
|
headers: &axum::http::HeaderMap,
|
|
) -> Result<(super::sessions::Session, Option<String>), Json<ApiResponse<serde_json::Value>>> {
|
|
let token = headers
|
|
.get("Authorization")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|v| v.strip_prefix("Bearer "));
|
|
|
|
match token {
|
|
Some(t) => match state.session_manager.validate_session_with_refresh(t).await {
|
|
Some((session, new_token)) => Ok((session, new_token)),
|
|
None => Err(Json(ApiResponse::error("Session expired or invalid".to_string()))),
|
|
},
|
|
None => Err(Json(ApiResponse::error("Not authenticated".to_string()))),
|
|
}
|
|
}
|
|
|
|
/// Helper: Extract session and require admin role.
|
|
/// Returns session and optional new token if refreshed.
|
|
pub(super) async fn require_admin(
|
|
state: &DashboardState,
|
|
headers: &axum::http::HeaderMap,
|
|
) -> Result<(super::sessions::Session, Option<String>), Json<ApiResponse<serde_json::Value>>> {
|
|
let (session, new_token) = extract_session(state, headers).await?;
|
|
if session.role != "admin" {
|
|
return Err(Json(ApiResponse::error("Admin access required".to_string())));
|
|
}
|
|
Ok((session, new_token))
|
|
}
|