Files
GopherGate/src/dashboard/auth.rs
T
hobokenchicken e07377adc0
CI / Check (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release Build (push) Has been cancelled
feat: add multi-user RBAC with admin/viewer roles and user management
Add complete multi-user support with role-based access control:

Backend:
- Add users CRUD endpoints (GET/POST/PUT/DELETE /api/users) with admin-only guards
- Add display_name column to users table with ALTER TABLE migration
- Fix auth to use session-based user identity (not hardcoded 'admin')
- Add POST /api/auth/logout to revoke server-side sessions
- Add require_admin() and extract_session() helpers for clean RBAC
- Guard all mutating endpoints (clients, providers, models, settings, backup)

Frontend:
- Add Users management page with create/edit/reset-password/delete modals
- Add role gating: hide edit/delete buttons for viewers on clients, providers, models
- Settings page hides auth tokens and admin actions for viewers
- Logout now revokes server session before clearing localStorage
- Sidebar shows real display_name and formatted role (Administrator/Viewer)
- Fix sidebar header: single logo with onerror fallback, renamed to 'LLM Proxy'
- Add badge and btn-action CSS classes for role pills and action buttons
- Bump cache-bust to v=7
2026-03-02 15:58:33 -05:00

213 lines
7.2 KiB
Rust

use axum::{extract::State, response::Json};
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,
) -> 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
&& let Some(session) = state.session_manager.validate_session(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());
return Json(ApiResponse::success(serde_json::json!({
"authenticated": true,
"user": {
"username": session.username,
"name": display_name,
"role": session.role
}
})));
}
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>,
) -> Json<ApiResponse<serde_json::Value>> {
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 = match token {
Some(t) => state.session_manager.validate_session(t).await,
None => None,
};
let username = match session {
Some(s) => s.username,
None => return 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 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(_) => Json(ApiResponse::success(
serde_json::json!({ "message": "Password updated successfully" }),
)),
Err(e) => Json(ApiResponse::error(format!("Failed to update database: {}", e))),
}
} else {
Json(ApiResponse::error("Current password incorrect".to_string()))
}
}
Err(e) => 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 if valid, or an error response.
pub(super) async fn extract_session(
state: &DashboardState,
headers: &axum::http::HeaderMap,
) -> Result<super::sessions::Session, 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(t).await {
Some(session) => Ok(session),
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.
pub(super) async fn require_admin(
state: &DashboardState,
headers: &axum::http::HeaderMap,
) -> Result<super::sessions::Session, Json<ApiResponse<serde_json::Value>>> {
let session = extract_session(state, headers).await?;
if session.role != "admin" {
return Err(Json(ApiResponse::error("Admin access required".to_string())));
}
Ok(session)
}